If you use Julia for data science or scientific computing, you know the specific pain of the "Time-to-First-Plot" (TTFP). You start a fresh REPL, run using Plots, execute plot(rand(10)), and then stare at your cursor for 10 to 30 seconds.
While Julia 1.9+ introduced native code caching to alleviate this, heavy dependencies like Plots.jl, Makie.jl, or DifferentialEquations.jl still incur significant startup latency due to JIT compilation overhead. For a developer iterating on a script that requires frequent restarts, this latency is not just an annoyance—it is a workflow bottleneck.
The solution is not to wait for the compiler every time. The solution is to compile once, snapshot the memory state, and load that snapshot instantly. We achieve this using PackageCompiler.jl to generate a custom System Image (sysimage).
The Root Cause: JIT vs. AOT
To solve TTFP, you must understand what happens during that 20-second wait. Julia is Just-In-Time (JIT) compiled. When you load a package and call a function for the first time:
- Type Inference: Julia analyzes the types of your arguments to determine which method specializations to use.
- LLVM IR Generation: Julia converts its high-level AST into LLVM Intermediate Representation.
- Native Code Generation: LLVM optimizes the IR and compiles it into machine code (assembly) for your specific CPU architecture.
Standard precompilation saves the type inference steps (serialized into .ji files), and Julia 1.9+ saves some native code (.so/.dll). However, creating a sysimage performs Ahead-Of-Time (AOT) compilation for everything defined in your workload, effectively freezing the state of the compiler after it has done the heavy lifting.
The Fix: Creating a Custom Sysimage
We will build a custom sysimage that bakes Plots and DataFrames (or your specific stack) directly into the Julia binary.
1. Environment Setup
First, ensure you have the necessary packages installed in your environment. We need the target packages (Plots, DataFrames) and the compiler tool (PackageCompiler).
import Pkg
Pkg.add(["Plots", "DataFrames", "PackageCompiler"])
2. Define the Precompile Script
The compiler needs to know what to compile. If you just bake in Plots, Julia still has to compile the plot function when it sees specific arguments (like Vector{Float64}).
Create a file named precompile_app.jl. This script simulates your actual workload to trigger the JIT compiler.
# precompile_app.jl
using Plots
using DataFrames
# 1. Trigger compilation for Plots
# We use the GR backend (default) but avoid opening a GUI window during build
ENV["GKSwstype"] = "100"
p = plot(rand(10), rand(10), title="Precompile Plot")
display(p)
# 2. Trigger compilation for DataFrames
df = DataFrame(A = 1:10, B = rand(10))
describe(df)
# 3. Add any specific function calls you use daily
# e.g., heatmap(rand(10,10))
3. Generate the Sysimage
Create a build script named build_sysimage.jl. This uses PackageCompiler to consume your precompile script and output a shared library (.so on Linux, .dll on Windows, .dylib on macOS).
# build_sysimage.jl
using PackageCompiler
print("Building sysimage... This will take a few minutes.\n")
create_sysimage(
# The packages to include in the image
[:Plots, :DataFrames];
# The script that triggers the JIT compilation paths
precompile_execution_file = "precompile_app.jl",
# Output file name
sysimage_path = "sys_plots.so",
# Optimize for the host CPU
cpu_target = "native"
)
print("Sysimage 'sys_plots.so' created successfully.\n")
Run this script from your shell:
julia --project=. build_sysimage.jl
Note: This process consumes significant RAM and CPU. Expect it to take 2-5 minutes.
4. Running Julia with the Sysimage
Once the build finishes, you have a file named sys_plots.so (or your OS equivalent). You must tell Julia to launch using this image instead of the default one.
Command Line:
julia --project=. --sysimage=sys_plots.so
Verify the Speedup:
Inside the REPL, run the following. You will notice using Plots is instantaneous, and the first plot renders in milliseconds rather than seconds.
@time using Plots
@time plot(rand(10))
Integration with VS Code
Manually typing the --sysimage flag is tedious. If you use VS Code, you can configure the Julia extension to use your custom image automatically for this project.
- Create a
.vscodefolder in your project root. - Create a
settings.jsonfile inside it. - Add the following configuration:
{
"julia.environmentPath": ".",
"julia.additionalArgs": [
"--sysimage=${workspaceFolder}/sys_plots.so"
]
}
Now, every time you start the REPL via Alt+J, Alt+O in VS Code, it will load your pre-compiled environment.
Why This Works
A standard Julia session initializes the runtime and loads the base language. When you load a sysimage, you are essentially loading a memory dump of a Julia process that has already:
- Parsed all the source code for
PlotsandDataFrames. - Inferred types for the functions executed in
precompile_app.jl. - Generated optimized machine code for those specific method signatures.
By mapping this binary directly into memory on startup, you bypass the entire LLVM compilation pipeline for the paths covered in your precompile script.
Conclusion
For interactive exploration and data science, TTFP is a solvable infrastructure problem, not an inherent language flaw. By incorporating PackageCompiler into your project setup, you reduce startup times from tens of seconds to sub-second latency. This turns Julia from a language that feels sluggish during development into a tool that feels as responsive as Python, but with the performance of C++.