Skip to content

Commit e21d665

Browse files
feat: alloc profile analyzer (top_alloc_types/leaves/frames + report)
Adds `src/analyze.jl` — aggregation helpers for `AllocProfileResult` that previously lived as a DTO-local script (`DirectTrajOpt.jl/benchmark/analyze_allocs.jl`) but are general-purpose and belong next to `benchmark_memory!` here in HBJ. Three core query functions return `Vector{AllocFrameSummary}` sorted by allocated bytes, with optional `1/sample_rate` extrapolation: - `top_alloc_types(profile; k=15)` — by allocated type - `top_alloc_leaves(profile; k=25)` — by first non-noise/non-wrapper frame (the user-code call site) - `top_alloc_frames(profile; k=25)` — by every frame in every sample's stacktrace Built-in noise patterns drop Julia runtime / GC / `Profile.Allocs` / loader frames; built-in HBJ-wrapper patterns drop `benchmark_memory!` and package-internal frames so the leaf view shows actual user code. Both sets are extensible via `extra_noise` / `extra_wrappers` kwargs for caller-specific patterns (e.g. an integration that wants to drop its own dispatch shims). `report_alloc_profile` is a pretty-printer that calls all three. Also adds Printf to `[deps]` / `[compat]` (was implicitly available through the runtime but not declared) and a unit test that builds a synthetic `AllocProfileResult`, verifies noise filtering, scaling, and extra-pattern threading.
1 parent 2521f74 commit e21d665

4 files changed

Lines changed: 304 additions & 0 deletions

File tree

Project.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
1111
MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee"
1212
NamedTrajectories = "538bc3a1-5ab9-4fc3-b776-35ca1e893e08"
1313
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
14+
Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
1415
Profile = "9abbd945-dff8-562f-b5e8-e1ebf5ef1b79"
1516
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
1617

@@ -22,6 +23,7 @@ JLD2 = "0.6"
2223
MathOptInterface = "1"
2324
NamedTrajectories = "0.8"
2425
Pkg = "1"
26+
Printf = "1"
2527
Statistics = "1"
2628
julia = "1.10, 1.11, 1.12"
2729

src/HarmoniqsBenchmarks.jl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ include("storage.jl")
55
include("harness.jl")
66
include("extractors.jl")
77
include("report.jl")
8+
include("analyze.jl")
89

910
export EvalBenchmark
1011
export BenchmarkResult
@@ -44,4 +45,10 @@ export ipopt_capture_callback
4445
export ipopt_iterations
4546
export ipopt_primal_infeasibility
4647

48+
export AllocFrameSummary
49+
export top_alloc_types
50+
export top_alloc_frames
51+
export top_alloc_leaves
52+
export report_alloc_profile
53+
4754
end

src/analyze.jl

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
using Printf
2+
3+
"""
4+
AllocFrameSummary(name, count, bytes)
5+
6+
One row of an aggregated `AllocProfileResult` view — a frame, leaf call site,
7+
or allocated type with its (optionally scaled) sample count and byte total.
8+
"""
9+
struct AllocFrameSummary
10+
name::String
11+
count::Int
12+
bytes::Int
13+
end
14+
15+
# Frames that come from the Julia runtime, the allocation profiler itself,
16+
# or the loader. Carry no information about user-code hotpaths.
17+
const _ALLOC_NOISE_FRAME_PATTERNS = [
18+
"Profile.Allocs",
19+
"gc-alloc-profiler",
20+
"gc-stock.c",
21+
"gc.c:",
22+
"jl_apply",
23+
"jl_toplevel_",
24+
"ijl_toplevel_",
25+
"jl_interpret_toplevel_thunk",
26+
"jl_repl_entrypoint",
27+
"interpreter.c",
28+
"_include(",
29+
"include_string(",
30+
"loading.jl",
31+
"client.jl",
32+
"_start() at sys.so",
33+
"ip:0x",
34+
"_start at ",
35+
" at Base.jl:",
36+
"true_main at jlapi.c",
37+
"__libc_start_main",
38+
"loader_exe.c",
39+
"jl_system_image_data",
40+
"macro expansion at Allocs.jl",
41+
"boot.jl:",
42+
"jl_f__call_latest",
43+
]
44+
45+
# Wrapper frames inside HarmoniqsBenchmarks itself — when the caller wants the
46+
# first frame in *their* code, the harness call stack is noise.
47+
const _ALLOC_HBJ_WRAPPER_FRAME_PATTERNS = [
48+
"benchmark_memory!",
49+
"HarmoniqsBenchmarks",
50+
]
51+
52+
const _ALLOC_NOISE_TYPE_PATTERNS = [
53+
"Profile.Allocs",
54+
]
55+
56+
_is_noise_frame(f, extra) = any(p -> occursin(p, f), _ALLOC_NOISE_FRAME_PATTERNS) ||
57+
any(p -> occursin(p, f), extra)
58+
_is_wrapper_frame(f, extra) = any(p -> occursin(p, f), _ALLOC_HBJ_WRAPPER_FRAME_PATTERNS) ||
59+
any(p -> occursin(p, f), extra)
60+
_is_noise_type(t) = any(p -> occursin(p, t), _ALLOC_NOISE_TYPE_PATTERNS)
61+
62+
function _first_user_frame(stack, extra_noise, extra_wrappers)
63+
for f in stack
64+
_is_noise_frame(f, extra_noise) && continue
65+
_is_wrapper_frame(f, extra_wrappers) && continue
66+
return f
67+
end
68+
return isempty(stack) ? "<empty>" : stack[end]
69+
end
70+
71+
_alloc_scale(profile::AllocProfileResult, scale::Bool) =
72+
scale ? max(1.0, 1 / profile.sample_rate) : 1.0
73+
74+
function _rank(by_key::Dict{String,Tuple{Int,Int}}, k::Int, scale::Real)
75+
rows = Vector{AllocFrameSummary}()
76+
sizehint!(rows, length(by_key))
77+
for (name, (cnt, bytes)) in by_key
78+
push!(rows, AllocFrameSummary(name, round(Int, cnt * scale), round(Int, bytes * scale)))
79+
end
80+
sort!(rows; by = r -> -r.bytes)
81+
return rows[1:min(k, length(rows))]
82+
end
83+
84+
"""
85+
top_alloc_types(profile; k=15, scale=true) -> Vector{AllocFrameSummary}
86+
87+
Aggregate samples in `profile` by allocated type, sorted by bytes descending.
88+
With `scale=true` (default), counts and bytes are extrapolated by
89+
`1 / profile.sample_rate` so totals match the actual run rather than the
90+
recorded sample.
91+
"""
92+
function top_alloc_types(profile::AllocProfileResult; k::Int = 15, scale::Bool = true)
93+
by_type = Dict{String,Tuple{Int,Int}}()
94+
for s in profile.samples
95+
_is_noise_type(s.type_name) && continue
96+
cnt, bytes = get(by_type, s.type_name, (0, 0))
97+
by_type[s.type_name] = (cnt + 1, bytes + s.size_bytes)
98+
end
99+
return _rank(by_type, k, _alloc_scale(profile, scale))
100+
end
101+
102+
"""
103+
top_alloc_frames(profile; k=25, scale=true, drop_wrappers=true, extra_noise=String[], extra_wrappers=String[])
104+
-> Vector{AllocFrameSummary}
105+
106+
Aggregate samples by every frame in their stacktraces (a single allocation
107+
contributes once per frame on its stack). Useful for finding hot regions of
108+
code that participate in many allocations even if they are not the leaf call.
109+
110+
Pass `extra_noise` / `extra_wrappers` to drop additional caller-specific
111+
patterns alongside the built-in Julia runtime / HBJ wrapper filters.
112+
"""
113+
function top_alloc_frames(
114+
profile::AllocProfileResult;
115+
k::Int = 25,
116+
scale::Bool = true,
117+
drop_wrappers::Bool = true,
118+
extra_noise::Vector{String} = String[],
119+
extra_wrappers::Vector{String} = String[],
120+
)
121+
by_frame = Dict{String,Tuple{Int,Int}}()
122+
for s in profile.samples
123+
_is_noise_type(s.type_name) && continue
124+
for frame in s.stacktrace
125+
_is_noise_frame(frame, extra_noise) && continue
126+
drop_wrappers && _is_wrapper_frame(frame, extra_wrappers) && continue
127+
cnt, bytes = get(by_frame, frame, (0, 0))
128+
by_frame[frame] = (cnt + 1, bytes + s.size_bytes)
129+
end
130+
end
131+
return _rank(by_frame, k, _alloc_scale(profile, scale))
132+
end
133+
134+
"""
135+
top_alloc_leaves(profile; k=25, scale=true, extra_noise=String[], extra_wrappers=String[])
136+
-> Vector{AllocFrameSummary}
137+
138+
Aggregate samples by their *first non-noise, non-wrapper frame* — i.e. the
139+
call site in user code that directly produced the allocation. Most useful
140+
view for triaging allocation hotspots.
141+
"""
142+
function top_alloc_leaves(
143+
profile::AllocProfileResult;
144+
k::Int = 25,
145+
scale::Bool = true,
146+
extra_noise::Vector{String} = String[],
147+
extra_wrappers::Vector{String} = String[],
148+
)
149+
by_leaf = Dict{String,Tuple{Int,Int}}()
150+
for s in profile.samples
151+
_is_noise_type(s.type_name) && continue
152+
leaf = _first_user_frame(s.stacktrace, extra_noise, extra_wrappers)
153+
cnt, bytes = get(by_leaf, leaf, (0, 0))
154+
by_leaf[leaf] = (cnt + 1, bytes + s.size_bytes)
155+
end
156+
return _rank(by_leaf, k, _alloc_scale(profile, scale))
157+
end
158+
159+
function _fmt_alloc_bytes(b::Real)
160+
b >= 1 << 30 && return @sprintf("%.2f GB", b / (1 << 30))
161+
b >= 1 << 20 && return @sprintf("%.2f MB", b / (1 << 20))
162+
b >= 1 << 10 && return @sprintf("%.2f KB", b / (1 << 10))
163+
return @sprintf("%d B", Int(round(b)))
164+
end
165+
166+
_truncate_name(s, n) = length(s) <= n ? s : string(first(s, n - 1), "")
167+
168+
function _print_summary_table(io::IO, header::AbstractString, rows::Vector{AllocFrameSummary}, name_width::Int)
169+
println(io, "\n", header)
170+
println(io, rpad(" bytes", 14), rpad("samples", 10), "name")
171+
for r in rows
172+
@printf(io, " %-12s %-8d %s\n", _fmt_alloc_bytes(r.bytes), r.count, _truncate_name(r.name, name_width))
173+
end
174+
end
175+
176+
"""
177+
report_alloc_profile(profile; io=stdout, k_types=10, k_leaves=20, k_frames=20,
178+
scale=true, extra_noise=String[], extra_wrappers=String[])
179+
180+
Pretty-print a three-section human-readable report of `profile`: top allocated
181+
types, top leaf call sites, and top all-frames. Sections are sorted by
182+
allocated bytes; counts and totals are scaled by `1 / sample_rate` unless
183+
`scale=false`.
184+
"""
185+
function report_alloc_profile(
186+
profile::AllocProfileResult;
187+
io::IO = stdout,
188+
k_types::Int = 10,
189+
k_leaves::Int = 20,
190+
k_frames::Int = 20,
191+
scale::Bool = true,
192+
extra_noise::Vector{String} = String[],
193+
extra_wrappers::Vector{String} = String[],
194+
)
195+
scale_factor = _alloc_scale(profile, scale)
196+
@printf(
197+
io,
198+
"=== Alloc profile: %s / %s (N=%d, sample_rate=%g, samples=%d, total≈%s) ===\n",
199+
profile.solver, profile.benchmark_name, profile.N, profile.sample_rate,
200+
profile.total_count, _fmt_alloc_bytes(profile.total_bytes * scale_factor),
201+
)
202+
_print_summary_table(
203+
io,
204+
"Top $k_types allocated types (scaled ×$(Int(round(scale_factor)))):",
205+
top_alloc_types(profile; k = k_types, scale = scale),
206+
120,
207+
)
208+
_print_summary_table(
209+
io,
210+
"Top $k_leaves leaf call sites (scaled ×$(Int(round(scale_factor)))):",
211+
top_alloc_leaves(
212+
profile;
213+
k = k_leaves,
214+
scale = scale,
215+
extra_noise = extra_noise,
216+
extra_wrappers = extra_wrappers,
217+
),
218+
140,
219+
)
220+
_print_summary_table(
221+
io,
222+
"Top $k_frames frames (scaled ×$(Int(round(scale_factor)))):",
223+
top_alloc_frames(
224+
profile;
225+
k = k_frames,
226+
scale = scale,
227+
extra_noise = extra_noise,
228+
extra_wrappers = extra_wrappers,
229+
),
230+
140,
231+
)
232+
return nothing
233+
end

test/runtests.jl

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,68 @@ using LinearAlgebra
10081008
@test isempty(compare_convergence([br_timing_only]))
10091009
end
10101010

1011+
@testset "analyze: top_alloc_types / leaves / frames" begin
1012+
samples = [
1013+
AllocSample(1024, "Vector{Float64}",
1014+
["my_solver/hot_loop.jl:42", "Profile.Allocs", "boot.jl:10"]),
1015+
AllocSample(2048, "Vector{Float64}",
1016+
["my_solver/hot_loop.jl:42", "benchmark_memory!", "boot.jl:10"]),
1017+
AllocSample(512, "Dict{Symbol,Any}",
1018+
["my_solver/cold.jl:5", "HarmoniqsBenchmarks/harness.jl:1", "_start at "]),
1019+
AllocSample(256, "Profile.Allocs.RawAlloc",
1020+
["gc-alloc-profiler"]), # noise sample, must be filtered
1021+
]
1022+
profile = AllocProfileResult(
1023+
"test", # package
1024+
"test", # solver
1025+
"analyze_smoke", # benchmark_name
1026+
"abc1234", # commit
1027+
1, 1, 1, # N, state_dim, control_dim
1028+
0.5, # sample_rate
1029+
samples,
1030+
sum(s.size_bytes for s in samples), # total_bytes
1031+
length(samples), # total_count
1032+
string(VERSION), # julia_version
1033+
now(), # timestamp
1034+
"test", # runner
1035+
)
1036+
1037+
types = top_alloc_types(profile)
1038+
# Noise type is filtered.
1039+
@test all(r -> !occursin("Profile.Allocs", r.name), types)
1040+
# Vector{Float64} is the largest bucket (1024+2048 bytes, scaled ×2).
1041+
top = types[1]
1042+
@test top.name == "Vector{Float64}"
1043+
@test top.bytes == round(Int, (1024 + 2048) * 2) # scaled by 1/sample_rate
1044+
@test top.count == 4 # 2 samples scaled ×2
1045+
1046+
# `scale=false` returns raw counts/bytes.
1047+
types_raw = top_alloc_types(profile; scale = false)
1048+
@test types_raw[1].bytes == 1024 + 2048
1049+
1050+
leaves = top_alloc_leaves(profile)
1051+
# Leaf for the Vector{Float64} samples is hot_loop.jl:42 (after
1052+
# filtering Profile.Allocs and benchmark_memory! wrappers).
1053+
@test any(r -> occursin("hot_loop.jl:42", r.name), leaves)
1054+
1055+
frames = top_alloc_frames(profile)
1056+
# HBJ wrapper frames are dropped by default.
1057+
@test all(r -> !occursin("benchmark_memory!", r.name), frames)
1058+
@test all(r -> !occursin("HarmoniqsBenchmarks/harness.jl", r.name), frames)
1059+
1060+
# extra_noise kwarg drops additional patterns.
1061+
frames_no_cold = top_alloc_frames(profile; extra_noise = ["cold.jl"])
1062+
@test all(r -> !occursin("cold.jl", r.name), frames_no_cold)
1063+
1064+
# report_alloc_profile prints without error.
1065+
io = IOBuffer()
1066+
report_alloc_profile(profile; io = io, k_types = 5, k_leaves = 5, k_frames = 5)
1067+
out = String(take!(io))
1068+
@test occursin("Alloc profile", out)
1069+
@test occursin("Vector{Float64}", out)
1070+
@test occursin("hot_loop.jl:42", out)
1071+
end
1072+
10111073
@testset "benchmark_solve! threads convergence kwarg" begin
10121074
crit = InfidelityConvergence(
10131075
target_infidelity = 1e-4, final_infidelity = 5e-5,

0 commit comments

Comments
 (0)