The "Time-to-First-Plot" (TTFP) problem is the most notorious friction point in the Julia ecosystem. You start a REPL, load your heavy plotting library, execute a function, and wait. You wait for type inference, LLVM IR generation, and native code compilation. In CI/CD pipelines, this latency adds expensive minutes to every commit check. In interactive exploration, it breaks the flow state.
While Julia 1.9 and 1.10 introduced massive improvements to local caching and reduced invalidation, shipping a package that feels "snappy" usually requires manual intervention.
This post details the modern architectural pattern for solving TTFP: decoupling heavy dependencies using Package Extensions (introduced in Julia 1.9) and baking compilation traces into the package image using PrecompileTools.jl.
The Root Cause: JIT vs. AOT
Julia is Just-In-Time (JIT) compiled, but effectively behaves like an Ahead-Of-Time (AOT) compiler that runs lazily.
- Type Inference: When you call
plot(data), Julia looks at the specific types indata(e.g.,Vector{Float64}). - Specialization: It generates a specific method instance for those types.
- Codegen: It lowers code to LLVM IR, optimizes it, and generates native assembly.
Without intervention, this happens every time you restart your session. While Julia caches generic precompilation data (.ji files), it often cannot cache the specific machine code for function calls unless it knows exactly which types will be called ahead of time.
We solve this by forcing the compiler to perform these steps during the package installation/build phase, rather than the runtime phase.
The Solution: Extensions + PrecompileTools
We will build a hypothetical package, SignalProc.jl, that performs lightweight calculations but offers heavy plotting capabilities via CairoMakie.
Strategy
- Weak Dependencies: We do not make
CairoMakiea direct dependency. This keeps the core package load time near-instant. - Package Extensions: We create a module that loads only when both
SignalProcandCairoMakieare present. - PrecompileTools: Inside the extension, we execute a representative workload. The resulting compiled machine code is saved to disk.
Step 1: Project Configuration (Project.toml)
In your SignalProc root, configure the Project.toml to define the extension.
name = "SignalProc"
uuid = "..."
version = "0.1.0"
[deps]
PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
# "Weak" dependencies are compatible but not forced
[weakdeps]
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
# Define the extension module name and what triggers it
[extensions]
SignalProcMakieExt = "CairoMakie"
Step 2: The Core Logic (src/SignalProc.jl)
The core package should remain lightweight. We define a placeholder function plot_signal that will be overwritten or extended when the extension loads.
module SignalProc
using Statistics
using PrecompileTools
# Define a struct that we want to plot later
struct SignalData
timestamps::Vector{Float64}
values::Vector{Float64}
end
function process_signal(data::SignalData)
return mean(data.values)
end
# Placeholder for plotting (optional pattern)
function plot_signal end
export SignalData, process_signal, plot_signal
end
Step 3: The Extension with Precompilation (ext/SignalProcMakieExt.jl)
Create the directory ext/ at the root of your package (peer to src/). This file is only compiled when the user runs using CairoMakie.
This is where the magic happens. We use @setup_workload to create data (which doesn't get saved) and @compile_workload to execute the code paths (which gets compiled and saved).
module SignalProcMakieExt
# Load the packages involved.
# Note: PrecompileTools is usually loaded in the main package,
# but we use it here to direct the compilation of this specific module.
using SignalProc
using CairoMakie
using PrecompileTools
# Implement the plotting logic specifically for Makie
function SignalProc.plot_signal(data::SignalProc.SignalData)
fig = Figure()
ax = Axis(fig[1, 1], title="Signal Analysis")
lines!(ax, data.timestamps, data.values, color=:blue)
scatter!(ax, data.timestamps, data.values, color=:red, markersize=5)
return fig
end
# ---------------------------------------------------------
# THE PRECOMPILATION BLOCK
# ---------------------------------------------------------
@setup_workload begin
# 1. Create representative data.
# This runs once during precompilation but the variables
# are NOT serialized into the package image.
t = collect(0.0:0.1:10.0)
v = sin.(t)
dummy_data = SignalData(t, v)
@compile_workload begin
# 2. Exercise the code paths you want to be fast.
# Julia will compile 'plot_signal' for the specific types
# inside SignalData (Vector{Float64}).
# The return value is discarded, but the machine code
# generated to produce it is cached.
SignalProc.plot_signal(dummy_data)
# If you have other heavy functions, call them here.
end
end
end # module
Why This Works
1. The @setup_workload Macro
Code inside this macro runs during the package build step. It allows you to generate necessary objects (arrays, structs, random data) required to call your functions. Crucially, this data is thrown away after compilation. It does not bloat your package size.
2. The @compile_workload Macro
Code inside this macro is "watched" by the compiler. Julia traces the execution, performs type inference, and generates the native machine code. This code is then serialized into the .ji (Julia Image) and .so/.dll (Shared Object) files responsible for the extension.
3. Separation of Concerns
By placing this in ext/, users who only need process_signal (e.g., in a headless server environment) never load CairoMakie and never pay the cost of loading the plotting infrastructure.
Verification
To verify that your precompilation is working, you can use the @time macro in a fresh REPL session.
Without PrecompileTools:
using SignalProc, CairoMakie
data = SignalData(collect(1.0:10.0), rand(10))
# First run entails compilation latency
@time plot_signal(data)
# Output: 4.239482 seconds (5.1 M allocations: 320.44 MiB, 4.12% gc time, 99% compilation time)
With PrecompileTools:
using SignalProc, CairoMakie
data = SignalData(collect(1.0:10.0), rand(10))
# First run utilizes cached machine code
@time plot_signal(data)
# Output: 0.004120 seconds (15 allocations: 10.2 KiB)
Trade-offs and Best Practices
- Build Time vs. Load Time: Adding heavy workloads to
@compile_workloadincreases the time it takes toPkg.add("SignalProc")orPkg.precompile(). Keep the workload minimal but representative (cover all types, but use small array sizes). - Type Coverage: If your users commonly use
Float32instead ofFloat64, ensure your@compile_workloadincludes a pass withFloat32data. The cached code is type-specific. - CI Implementation: In your CI pipeline, ensure you cache the
~/.juliaartifacts. Otherwise, your CI runners will spend significant time rebuilding these precompilation images on every run.
Conclusion
The "Time-to-First-Plot" issue is no longer an inherent flaw of Julia; it is an architectural choice. By leveraging PrecompileTools within Package Extensions, you shift the compilation cost from the user's interactive session to the installation phase. This results in a professional, responsive experience for end-users while maintaining the high-performance capabilities of the Julia language.