Skip to content

Commit 421ce73

Browse files
precompilation: sample precompile workers with the runtime profiler
Most of a package's precompilation happens in a worker process that the driver spawns, so profiling the session that called `Pkg.precompile()` shows the driver waiting on its children and nothing about where the time went. Give the workers a way to sample themselves. `precompilepkgs` gains a `profile` keyword, taking `true` for every package or a name or a list of names, and `profile_dir` for where the dumps land. The same is available through `JULIA_PRECOMPILE_PROFILE` for precompilation started some other way, such as by loading a package for the first time. A selected worker starts the runtime's sampling profiler before it loads its dependencies and dumps once its image has been written, so the profile covers dependency loading, lowering, inference, code generation and image generation. It writes the raw sample buffer alongside the stack frames its instruction pointers resolve to, since a pointer can only be resolved by the process that recorded it. `contrib/read_precompile_profile.jl` turns the pair back into the arguments that `Profile.print` and the profile viewer packages take. The worker side uses only `ccall` and `Base.StackTraces` on purpose: loading Profile there would put it in the output image's dependency list. Assisted-by: Claude Code (Fable 5.1) Claude-Session: https://claude.ai/code/session_019BZkT3Ky7B3kq9xPiXLYVU
1 parent a5e6c99 commit 421ce73

8 files changed

Lines changed: 352 additions & 6 deletions

File tree

base/loading.jl

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3419,9 +3419,91 @@ end
34193419
const newly_inferred = []
34203420

34213421
# this is called in the external process that generates precompiled package files
3422+
# Sampling profiler for a precompile worker, enabled by pointing
3423+
# `JULIA_PRECOMPILE_PROFILE` at a directory. Every worker then writes
3424+
# `<pkg>-<pid>.profdata`, the raw sample buffer, and `<pkg>-<pid>.profsyms`, its
3425+
# instruction pointers resolved to stack frames. The dump is deliberately split
3426+
# that way because an instruction pointer can only be resolved by the process
3427+
# that recorded it, while everything else is better done afterwards in a session
3428+
# that can load Profile. See the "Profiling package precompilation" devdocs.
3429+
#
3430+
# This runs inside the worker, so it only uses `ccall` and `Base.StackTraces`:
3431+
# loading a package here would place it in the output image's dependency list.
3432+
const _precompile_profile_nmeta = 4 # threadid, taskid, cpu_cycle_clock, sleepstate
3433+
3434+
function _precompile_profile_start(pkg::PkgId)
3435+
dir = get(ENV, "JULIA_PRECOMPILE_PROFILE", "")
3436+
isempty(dir) && return nothing
3437+
delay = something(tryparse(Float64, get(ENV, "JULIA_PRECOMPILE_PROFILE_DELAY", "")), 0.001)
3438+
nsamples = something(tryparse(Int, get(ENV, "JULIA_PRECOMPILE_PROFILE_NSAMPLES", "")), 10_000_000)
3439+
status = ccall(:jl_profile_init, Cint, (Csize_t, UInt64), nsamples, round(UInt64, 1e9 * delay))
3440+
if status != 0
3441+
@warn "Could not allocate the precompile profile buffer" pkg status
3442+
return nothing
3443+
end
3444+
status = ccall(:jl_profile_start_timer, Cint, (Bool,), false)
3445+
if status != 0
3446+
@warn "Could not start the precompile profiler" pkg status
3447+
return nothing
3448+
end
3449+
prefix = joinpath(abspath(dir), string(pkg.name, "-", getpid()))
3450+
# `_postoutput` runs after the package image has been written, so dumping
3451+
# there covers image generation as well as `include`. It only runs when
3452+
# native code is emitted; without it, fall back to `atexit`, which runs
3453+
# before the `.ji` is written and so covers `include` alone.
3454+
if JLOptions().outputo != C_NULL
3455+
postoutput(() -> _precompile_profile_dump(prefix))
3456+
else
3457+
atexit(() -> _precompile_profile_dump(prefix))
3458+
end
3459+
return nothing
3460+
end
3461+
3462+
function _precompile_profile_dump(prefix::String)
3463+
ccall(:jl_profile_stop_timer, Cvoid, ())
3464+
len = Int(ccall(:jl_profile_len_data, Csize_t, ()))
3465+
ptr = convert(Ptr{UInt}, ccall(:jl_profile_get_data, Ptr{UInt8}, ()))
3466+
data = Vector{UInt}(undef, len)
3467+
unsafe_copyto!(pointer(data), ptr, len)
3468+
try
3469+
mkpath(dirname(prefix))
3470+
open(prefix * ".profdata", "w") do io
3471+
write(io, data)
3472+
end
3473+
# Mark the metadata trailer of each block so it is not mistaken for an
3474+
# instruction pointer: a block ends with the four metadata fields
3475+
# followed by two null entries.
3476+
meta = falses(len)
3477+
for i in (_precompile_profile_nmeta + 2):len
3478+
if data[i] == 0 && data[i-1] == 0 && data[i-2] != 0
3479+
meta[(i-_precompile_profile_nmeta-1):i] .= true
3480+
end
3481+
end
3482+
ips = Set{UInt}()
3483+
for i in 1:len
3484+
(meta[i] || data[i] == 0) && continue
3485+
push!(ips, data[i])
3486+
end
3487+
open(prefix * ".profsyms", "w") do io
3488+
println(io, "# ip\tinlined\tline\tfrom_c\tfunc\tfile")
3489+
for ip in ips, sf in StackTraces.lookup(convert(Ptr{Cvoid}, ip))
3490+
println(io, ip, '\t', sf.inlined ? 1 : 0, '\t', sf.line, '\t',
3491+
sf.from_c ? 1 : 0, '\t', sf.func, '\t', sf.file)
3492+
end
3493+
end
3494+
if ccall(:jl_profile_is_buffer_full, Cint, ()) != 0
3495+
@warn "The precompile profile buffer filled up; raise JULIA_PRECOMPILE_PROFILE_NSAMPLES or JULIA_PRECOMPILE_PROFILE_DELAY" prefix
3496+
end
3497+
catch ex
3498+
@warn "Could not write the precompile profile" prefix exception=(ex, catch_backtrace())
3499+
end
3500+
return nothing
3501+
end
3502+
34223503
function include_package_for_output(pkg::PkgId, input::String, syntax_version::VersionNumber, depot_path::Vector{String}, dl_load_path::Vector{String}, load_path::Vector{String},
34233504
concrete_deps::typeof(_concrete_dependencies), source::Union{Nothing,String},
34243505
preresolved::Vector{Pair{PkgId,String}}=Pair{PkgId,String}[])
3506+
_precompile_profile_start(pkg)
34253507

34263508
@lock require_lock begin
34273509
m = start_loading(pkg, UInt128(0), false)
@@ -3517,7 +3599,7 @@ const PRECOMPILE_VERBOSE_TIMING_MARKER = "__JL_PRECOMP_VERBOSE_TIMING__"
35173599
function create_expr_cache(pkg::PkgId, input::PkgLoadSpec, output::String, output_o::Union{Nothing, String},
35183600
concrete_deps::typeof(_concrete_dependencies), flags::Cmd=``, cacheflags::CacheFlags=CacheFlags(),
35193601
internal_stderr::IO = stderr, internal_stdout::IO = stdout, loadable_exts::Union{Vector{PkgId},Nothing}=nothing;
3520-
report_timing::Bool=false,
3602+
report_timing::Bool=false, profile_dir::Union{Nothing,String}=nothing,
35213603
preresolved::Vector{Pair{PkgId,String}} = @lock(require_lock, collect(preresolved_cachefiles)))
35223604
@nospecialize internal_stderr internal_stdout
35233605
depot_path = String[abspath(x) for x in DEPOT_PATH]
@@ -3574,6 +3656,8 @@ function create_expr_cache(pkg::PkgId, input::PkgLoadSpec, output::String, outpu
35743656
# Only request per-package timing reports when explicitly asked for (e.g. by
35753657
# precompilepkgs), so that the marker lines don't leak into normal load logs.
35763658
report_timing && (cmd = addenv(cmd, "JULIA_PRECOMP_REPORT_TIMING" => 1))
3659+
# Ask this worker to profile itself; see `_precompile_profile_start`.
3660+
profile_dir === nothing || (cmd = addenv(cmd, "JULIA_PRECOMPILE_PROFILE" => profile_dir))
35773661
io = open(pipeline(cmd, stderr = internal_stderr, stdout = internal_stdout),
35783662
"w", stdout)
35793663
# write data over stdin to avoid the (unlikely) case of exceeding max command line size
@@ -3637,11 +3721,11 @@ This can be used to reduce package load times. Cache files are stored in
36373721
`DEPOT_PATH[1]/compiled`. See [Module initialization and precompilation](@ref)
36383722
for important notes.
36393723
"""
3640-
function compilecache(pkg::PkgId, internal_stderr::IO = stderr, internal_stdout::IO = stdout; flags::Cmd=``, cacheflags::CacheFlags=CacheFlags(), loadable_exts::Union{Vector{PkgId},Nothing}=nothing, signal_channel::Union{Channel{Int32},Nothing}=nothing, report_timing::Bool=false)
3724+
function compilecache(pkg::PkgId, internal_stderr::IO = stderr, internal_stdout::IO = stdout; flags::Cmd=``, cacheflags::CacheFlags=CacheFlags(), loadable_exts::Union{Vector{PkgId},Nothing}=nothing, signal_channel::Union{Channel{Int32},Nothing}=nothing, report_timing::Bool=false, profile_dir::Union{Nothing,String}=nothing)
36413725
@nospecialize internal_stderr internal_stdout
36423726
spec = locate_package_load_spec(pkg)
36433727
spec === nothing && throw(ArgumentError("$(repr("text/plain", pkg)) not found during precompilation"))
3644-
return compilecache(pkg, spec, internal_stderr, internal_stdout; flags, cacheflags, loadable_exts, signal_channel, report_timing)
3728+
return compilecache(pkg, spec, internal_stderr, internal_stdout; flags, cacheflags, loadable_exts, signal_channel, report_timing, profile_dir)
36453729
end
36463730

36473731
const MAX_NUM_PRECOMPILE_FILES = Ref(10)
@@ -3650,6 +3734,7 @@ function compilecache(pkg::PkgId, spec::PkgLoadSpec, internal_stderr::IO = stder
36503734
keep_loaded_modules::Bool = true; flags::Cmd=``, cacheflags::CacheFlags=CacheFlags(),
36513735
loadable_exts::Union{Vector{PkgId},Nothing}=nothing, signal_channel::Union{Channel{Int32},Nothing}=nothing,
36523736
pid_channel::Union{Channel{Int32},Nothing}=nothing, report_timing::Bool=false,
3737+
profile_dir::Union{Nothing,String}=nothing,
36533738
preresolved::Vector{Pair{PkgId,String}} = @lock(require_lock, collect(preresolved_cachefiles)))
36543739

36553740
@nospecialize internal_stderr internal_stdout
@@ -3688,7 +3773,7 @@ function compilecache(pkg::PkgId, spec::PkgLoadSpec, internal_stderr::IO = stder
36883773
close(tmpio_o)
36893774
close(tmpio_so)
36903775
end
3691-
p = create_expr_cache(pkg, spec, tmppath, tmppath_o, concrete_deps, flags, cacheflags, internal_stderr, internal_stdout, loadable_exts; report_timing, preresolved)
3776+
p = create_expr_cache(pkg, spec, tmppath, tmppath_o, concrete_deps, flags, cacheflags, internal_stderr, internal_stdout, loadable_exts; report_timing, profile_dir, preresolved)
36923777

36933778
# Report the PID of the compilation subprocess
36943779
if pid_channel !== nothing

base/precompilation.jl

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,12 +279,14 @@ mutable struct BackgroundPrecompileState
279279
confirm_deadline::Float64 # time() deadline for confirmation
280280
info_requested::Bool # whether SIGINFO/SIGUSR1 has been broadcast at least once
281281
key_listening::Bool # whether a key listener task is currently consuming stdin
282+
profile_dir::Union{Nothing, String} # directory for worker profile dumps, or nothing to not profile
283+
profile_pkgs::Union{Nothing, Set{String}} # packages to profile, or nothing for all of them
282284
end
283285
Base.lock(f, bg::BackgroundPrecompileState) = lock(f, bg.lock)
284286
Base.lock(bg::BackgroundPrecompileState) = lock(bg.lock)
285287
Base.unlock(bg::BackgroundPrecompileState) = unlock(bg.lock)
286288

287-
const BG = BackgroundPrecompileState(nothing, false, false, false, nothing, nothing, nothing, nothing, ReentrantLock(), Threads.Condition(), Channel{Int32}[], Dict{PkgId, Int}(), Set{PkgId}(), Threads.Condition(), Channel{PrecompileRequest}(Inf), false, false, :none, 0.0, false, false)
289+
const BG = BackgroundPrecompileState(nothing, false, false, false, nothing, nothing, nothing, nothing, ReentrantLock(), Threads.Condition(), Channel{Int32}[], Dict{PkgId, Int}(), Set{PkgId}(), Threads.Condition(), Channel{PrecompileRequest}(Inf), false, false, :none, 0.0, false, false, nothing, nothing)
288290

289291
# Serializes the inject-vs-launch decision in `_precompilepkgs` with the launch
290292
# itself. Lock ordering: acquired before (outside) BG.lock, never while holding it.
@@ -1105,6 +1107,14 @@ precompiles only the given packages and their dependencies (unless
11051107
samples may be missed. Linux/macOS only, `-` elsewhere.
11061108
Values under 5 ms and zero counts are dimmed for readability.
11071109
1110+
- `profile::Union{Bool,AbstractString,AbstractVector{<:AbstractString}}`: When not `false`,
1111+
each selected package's worker samples itself and writes its profile to a file. Pass `true`
1112+
for every package, or a package name or list of names to profile only those. The dumps are
1113+
read back in a separate session; see the "Profiling package precompilation" devdocs.
1114+
1115+
- `profile_dir::Union{Nothing,AbstractString}`: Where to write those dumps. Defaults to a
1116+
fresh temporary directory, whose path is printed when profiling starts.
1117+
11081118
- `_from_loading::Bool`: Internal flag indicating the call originated from the
11091119
package loading system. When `true` (not default): returns early instead of
11101120
throwing when packages are not found; suppresses progress messages when not
@@ -1175,6 +1185,48 @@ function preresolved_snapshot(s::PrecompileSession)
11751185
@lock s.cache_lock Pair{Base.PkgId,String}[k => first(v) for (k, v) in s.cachepath_cache if !isempty(v)]
11761186
end
11771187

1188+
# Turn the `profile` / `profile_dir` keywords of `precompilepkgs` into the
1189+
# settings that `spawn_precompile_tasks!` hands to each worker. Kept on `BG`
1190+
# rather than on the session so a request merged into a running background run
1191+
# picks it up, the same way `verbose` does.
1192+
function setup_worker_profiling!(profile, profile_dir, io)
1193+
if profile === false
1194+
if profile_dir !== nothing
1195+
throw(ArgumentError("`profile_dir` was given but `profile` is false; pass `profile=true` to enable profiling"))
1196+
end
1197+
# clear any setting left by an earlier profiling run
1198+
@lock BG begin
1199+
BG.profile_dir = nothing
1200+
BG.profile_pkgs = nothing
1201+
end
1202+
return nothing
1203+
end
1204+
pkgs = if profile === true
1205+
nothing
1206+
elseif profile isa AbstractString
1207+
Set{String}((profile,))
1208+
else
1209+
Set{String}(profile)
1210+
end
1211+
dir = profile_dir === nothing ? mktempdir(; prefix="jl_precompile_profile_", cleanup=false) : abspath(String(profile_dir))
1212+
mkpath(dir)
1213+
@lock BG begin
1214+
BG.profile_dir = dir
1215+
BG.profile_pkgs = pkgs
1216+
end
1217+
which = pkgs === nothing ? "all packages" : join(sort!(collect(pkgs)), ", ")
1218+
printpkgstyle(io, :Profiling, "$which into $dir", color = Base.info_color())
1219+
return nothing
1220+
end
1221+
1222+
# The directory this package's worker should dump its profile into, or `nothing`.
1223+
function worker_profile_dir(pkg::PkgId)
1224+
dir, pkgs = @lock BG (BG.profile_dir, BG.profile_pkgs)
1225+
dir === nothing && return nothing
1226+
(pkgs === nothing || pkg.name in pkgs) || return nothing
1227+
return dir
1228+
end
1229+
11781230
function precompilepkgs(pkgs::Union{Vector{String}, Vector{PkgId}}=String[];
11791231
internal_call::Bool=false,
11801232
strict::Bool = false,
@@ -1189,10 +1241,13 @@ function precompilepkgs(pkgs::Union{Vector{String}, Vector{PkgId}}=String[];
11891241
fancyprint::Bool = can_fancyprint(io) && !timing && !verbose,
11901242
manifest::Bool=false,
11911243
ignore_loaded::Bool=true,
1192-
detachable::Bool=false)
1244+
detachable::Bool=false,
1245+
profile::Union{Bool,AbstractString,AbstractVector{<:AbstractString}}=false,
1246+
profile_dir::Union{Nothing,AbstractString}=nothing)
11931247
# verbose timing mode requires timing to be enabled (per-package breakdown
11941248
# is only shown alongside timing lines in non-fancy mode)
11951249
verbose && (timing = true)
1250+
setup_worker_profiling!(profile, profile_dir, io)
11961251
@debug "precompilepkgs called with" pkgs internal_call strict warn_loaded timing verbose _from_loading configs fancyprint manifest ignore_loaded detachable
11971252
# monomorphize this to avoid latency problems
11981253
_precompilepkgs(pkgs, internal_call, strict, warn_loaded, timing, verbose, _from_loading,
@@ -2255,6 +2310,7 @@ function spawn_precompile_tasks!(s::PrecompileSession;
22552310
Base.compilecache(pkg, sourcespec, std_pipe, std_pipe, !s.ignore_loaded;
22562311
flags=flags_, cacheflags, loadable_exts, signal_channel=make_signal_channel(),
22572312
pid_channel=pid_ch, report_timing=true,
2313+
profile_dir=worker_profile_dir(pkg),
22582314
preresolved=preresolved_snapshot(s))
22592315
end
22602316
else
@@ -2281,6 +2337,7 @@ function spawn_precompile_tasks!(s::PrecompileSession;
22812337
Base.compilecache(pkg, sourcespec, std_pipe, std_pipe, !s.ignore_loaded;
22822338
flags=flags_, cacheflags, loadable_exts, signal_channel=make_signal_channel(),
22832339
pid_channel=pid_ch, report_timing=true,
2340+
profile_dir=worker_profile_dir(pkg),
22842341
preresolved=preresolved_snapshot(s))
22852342
end
22862343
end

contrib/read_precompile_profile.jl

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# This file is a part of Julia. License is MIT: https://julialang.org/license
2+
3+
# Read a profile written by a precompilation worker, as enabled by
4+
# `precompilepkgs(profile=...)` or the `JULIA_PRECOMPILE_PROFILE` environment
5+
# variable, and hand it to `Profile` for analysis.
6+
#
7+
# include("contrib/read_precompile_profile.jl")
8+
# data, lidict = read_precompile_profile("/tmp/jl_precompile_profile_XXXX/Makie-12345")
9+
# Profile.print(; C=true) # see the docstring below for the exact call
10+
#
11+
# The worker writes two files because instruction pointers can only be resolved
12+
# in the process that recorded them: `<prefix>.profdata` holds the raw sample
13+
# buffer and `<prefix>.profsyms` the stack frames those pointers resolve to.
14+
15+
using Profile
16+
using Base.StackTraces: StackFrame
17+
18+
"""
19+
read_precompile_profile(prefix) -> (data, lidict)
20+
21+
Load the precompile-worker profile written to `\$prefix.profdata` and
22+
`\$prefix.profsyms`. `prefix` may also be either of those paths.
23+
24+
The result is accepted directly by the `Profile` reporting functions:
25+
26+
```julia
27+
data, lidict = read_precompile_profile(prefix)
28+
Profile.print(stdout, data, lidict; C=true, format=:tree)
29+
```
30+
"""
31+
function read_precompile_profile(prefix::AbstractString)
32+
prefix = replace(String(prefix), r"\.(profdata|profsyms)$" => "")
33+
data = collect(reinterpret(UInt, read(prefix * ".profdata")))
34+
lidict = Dict{UInt64,Vector{StackFrame}}()
35+
for line in eachline(prefix * ".profsyms")
36+
(isempty(line) || startswith(line, '#')) && continue
37+
ip_str, inlined, lineno, from_c, func, file = split(line, '\t'; limit=6)
38+
ip = parse(UInt64, ip_str)
39+
frame = StackFrame(Symbol(func), Symbol(file), parse(Int, lineno), nothing,
40+
from_c == "1", inlined == "1", ip)
41+
push!(get!(Vector{StackFrame}, lidict, ip), frame)
42+
end
43+
return data, lidict
44+
end
45+
46+
"""
47+
print_precompile_profile(prefix; kwargs...)
48+
49+
Read the profile at `prefix` and print it, passing `kwargs` on to `Profile.print`.
50+
"""
51+
function print_precompile_profile(prefix::AbstractString; io::IO=stdout, kwargs...)
52+
data, lidict = read_precompile_profile(prefix)
53+
Profile.print(io, data, lidict; kwargs...)
54+
end

doc/make.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ DevDocs = [
361361
"devdocs/valgrind.md",
362362
"devdocs/gc-debug.md",
363363
"devdocs/external_profilers.md",
364+
"devdocs/precompile_profiling.md",
364365
"devdocs/sanitizers.md",
365366
"devdocs/probes.md",
366367
],

doc/src/devdocs/external_profilers.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ end
8989

9090
Here, we use a custom port for tracy which makes it easier to find the correct client in the Tracy UI to connect to.
9191

92+
For sampled stack traces of the same worker processes, rather than the zones described here, see [Profiling package precompilation](@ref).
93+
9294
### Adding metadata to zones
9395

9496
The various `jl_timing_show_*` and `jl_timing_printf` functions can be used to attach a string (or strings) to a zone. For example, the trace zone for inference shows the method instance that is being inferred.

0 commit comments

Comments
 (0)