Skip to content

Commit df729ee

Browse files
chore: sync dev guides from central repo
2 parents dc4d295 + 5fc1d59 commit df729ee

6 files changed

Lines changed: 1960 additions & 1 deletion

File tree

docs/dev-guides/.claude/skills/fix-ci/SKILL.md

Lines changed: 355 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""
2+
FlameDiff
3+
4+
Diff two [ProfileCanvas](https://github.com/pfitzseb/ProfileCanvas.jl) flame
5+
graphs saved as HTML files (for example, the artifacts a CI flame-graph job
6+
uploads, or files written locally with `ProfileCanvas.html_file`).
7+
8+
Self-sample counts are directly comparable between two profiles of the same
9+
wall-clock duration; total counts and fractions are not (the total shifts).
10+
Deltas below about 5 samples are noise.
11+
12+
# As a script
13+
14+
julia flame_diff.jl BASELINE.html CANDIDATE.html [TOP_N]
15+
16+
prints the total sample counts for both files followed by the `TOP_N` (default
17+
25) frames with the largest self-sample increases and decreases (candidate
18+
minus baseline), keyed by `function@file:line`.
19+
20+
# From Julia (e.g. alongside `test_compilation.jl`)
21+
22+
include("flame_diff.jl")
23+
using .FlameDiff
24+
rows = flame_diff("baseline.html", "candidate.html") # prints and returns
25+
_, self_counts, total_counts = aggregate_flame(load_flame("candidate.html"))
26+
27+
Produce the input files with ProfileCanvas:
28+
29+
import Profile, ProfileCanvas
30+
Profile.@profile <workload>
31+
ProfileCanvas.html_file("flame.html")
32+
"""
33+
module FlameDiff
34+
35+
import ProfileCanvas: JSON # JSON is a dependency of ProfileCanvas
36+
37+
export load_flame, aggregate_flame, flame_diff
38+
39+
# ─── Loading and aggregating ─────────────────────────────────────────────────
40+
41+
"""
42+
load_flame(path) -> Dict
43+
44+
Extract and parse the profile tree that ProfileCanvas embeds in an HTML file.
45+
The result maps the string `"1"` to the root node; each node is a `Dict` with
46+
keys `"func"`, `"file"`, `"line"`, `"count"`, and `"children"`.
47+
"""
48+
function load_flame(path)
49+
text = read(path, String)
50+
marker = findfirst("new ProfileCanvas.ProfileViewer(", text)
51+
isnothing(marker) && error("$path: not a ProfileCanvas HTML file")
52+
range = findnext(", {", text, last(marker))
53+
isnothing(range) && error("$path: could not find embedded profile data")
54+
# last(range) indexes the '{' of ", {"; slice out the balanced object that
55+
# follows it (braces only ever appear inside strings that JSON escapes, so
56+
# a plain depth count over the bytes is safe) and hand it to JSON.
57+
bytes = codeunits(text)
58+
depth = 0
59+
start = last(range)
60+
for stop in start:lastindex(bytes)
61+
bytes[stop] == UInt8('{') && (depth += 1)
62+
bytes[stop] == UInt8('}') && (depth -= 1) == 0 &&
63+
return JSON.parse(text[start:stop])
64+
end
65+
error("$path: unbalanced braces in embedded profile data")
66+
end
67+
68+
"""
69+
aggregate_flame(tree) -> (root_count, self_counts, total_counts)
70+
71+
Sum self and total sample counts per `function@file:line` frame over the tree
72+
returned by [`load_flame`](@ref). `self_counts[frame]` is a node's own count
73+
minus its children's; `total_counts[frame]` counts each frame once per root-to-
74+
node path that first reaches it, so recursive frames are not double-counted.
75+
"""
76+
function aggregate_flame(tree)
77+
root = tree["1"]
78+
self_counts = Dict{String, Int}()
79+
total_counts = Dict{String, Int}()
80+
function visit(node, seen)
81+
key = string(node["func"], "@", node["file"], ":", node["line"])
82+
children = node["children"]
83+
child_sum = isempty(children) ? 0 : sum(child -> child["count"], children)
84+
self_counts[key] = get(self_counts, key, 0) + node["count"] - child_sum
85+
if !(key in seen)
86+
total_counts[key] = get(total_counts, key, 0) + node["count"]
87+
end
88+
deeper = push!(copy(seen), key)
89+
for child in children
90+
visit(child, deeper)
91+
end
92+
end
93+
visit(root, Set{String}())
94+
return root["count"], self_counts, total_counts
95+
end
96+
97+
# ─── Diffing and reporting ───────────────────────────────────────────────────
98+
99+
const HEADER = string(
100+
rpad("frame", 70),
101+
" ",
102+
lpad("cand", 6),
103+
" ",
104+
lpad("base", 6),
105+
" ",
106+
lpad("ctot", 6),
107+
" ",
108+
lpad("btot", 6),
109+
)
110+
111+
function print_rows(io, rows)
112+
println(io, HEADER)
113+
for row in rows
114+
println(
115+
io,
116+
rpad(first(row.frame, 70), 70),
117+
" ",
118+
lpad(row.cand, 6),
119+
" ",
120+
lpad(row.base, 6),
121+
" ",
122+
lpad(row.cand_total, 6),
123+
" ",
124+
lpad(row.base_total, 6),
125+
)
126+
end
127+
end
128+
129+
"""
130+
flame_diff(baseline_path, candidate_path; top_n = 25, io = stdout)
131+
132+
Print the total sample counts for both flame graphs and the `top_n` frames
133+
with the largest self-sample increases and decreases (candidate minus
134+
baseline). Return the full list of per-frame rows (`NamedTuple`s with fields
135+
`frame`, `cand`, `base`, `cand_total`, `base_total`, `delta`) sorted by `delta`
136+
descending, for programmatic use.
137+
"""
138+
function flame_diff(baseline_path, candidate_path; top_n = 25, io = stdout)
139+
base_root, base_self, base_total = aggregate_flame(load_flame(baseline_path))
140+
cand_root, cand_self, cand_total = aggregate_flame(load_flame(candidate_path))
141+
println(io, "baseline root samples: $base_root ($baseline_path)")
142+
println(io, "candidate root samples: $cand_root ($candidate_path)")
143+
base_root > 0 &&
144+
println(io, "ratio: ", round(cand_root / base_root; digits = 2))
145+
146+
frames = union(keys(base_self), keys(cand_self))
147+
rows = map(collect(frames)) do frame
148+
cand = get(cand_self, frame, 0)
149+
base = get(base_self, frame, 0)
150+
(;
151+
frame,
152+
cand,
153+
base,
154+
cand_total = get(cand_total, frame, 0),
155+
base_total = get(base_total, frame, 0),
156+
delta = cand - base,
157+
)
158+
end
159+
sort!(rows; by = row -> row.delta, rev = true)
160+
161+
println(io, "\n=== top $top_n self-sample increases (candidate - baseline) ===")
162+
print_rows(io, Iterators.filter(row -> row.delta > 0, first(rows, top_n)))
163+
# Decreases: the last top_n rows (most negative) shown most-negative first.
164+
println(io, "\n=== top $top_n self-sample decreases ===")
165+
print_rows(io, Iterators.filter(row -> row.delta < 0, reverse(last(rows, top_n))))
166+
return rows
167+
end
168+
169+
function main(args)
170+
if length(args) < 2
171+
println(stderr, "usage: julia flame_diff.jl BASELINE.html CANDIDATE.html [TOP_N]")
172+
return
173+
end
174+
top_n = length(args) >= 3 ? parse(Int, args[3]) : 25
175+
flame_diff(args[1], args[2]; top_n)
176+
return
177+
end
178+
179+
end # module FlameDiff
180+
181+
if abspath(PROGRAM_FILE) == @__FILE__
182+
FlameDiff.main(ARGS)
183+
end
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Unit tests for flame_diff.jl. Run these to completion before trusting any
2+
# flame_diff output (see SKILL.md). They use synthetic ProfileCanvas-style
3+
# HTML fixtures, so no profiling is required.
4+
5+
using Test
6+
7+
include(joinpath(@__DIR__, "flame_diff.jl"))
8+
using .FlameDiff
9+
10+
# Build a minimal ProfileCanvas-style HTML file around a JSON profile tree.
11+
function fake_flame_file(tree_json)
12+
path = tempname() * ".html"
13+
write(
14+
path,
15+
"""
16+
<html><body><div id="profiler-container-1"></div><script>
17+
const viewer = new ProfileCanvas.ProfileViewer("#profiler-container-1", $tree_json, "Profile");
18+
</script></body></html>
19+
""",
20+
)
21+
return path
22+
end
23+
24+
node(func, file, line, count, children = "[]") = """
25+
{"func":"$func","file":"$file","path":"/x/$file","line":$line,
26+
"count":$count,"countLabel":null,"flags":0,"children":$children}"""
27+
28+
# root(10) -> work!(6) -> helper(2); root(10) -> ∫apply!(3)
29+
const BASELINE_TREE = """{"1": $(node("root", "task.jl", 1, 10, "[" *
30+
node("work!", "a.jl", 5, 6, "[" * node("helper", "b.jl", 9, 2) * "]") *
31+
"," * node("∫apply!", "c.jl", 3, 3) * "]"))}"""
32+
33+
# Same shape, but work! got slower and recursive: work! -> work! -> helper.
34+
const CANDIDATE_TREE = """{"1": $(node("root", "task.jl", 1, 20, "[" *
35+
node("work!", "a.jl", 5, 16, "[" *
36+
node("work!", "a.jl", 5, 8, "[" * node("helper", "b.jl", 9, 2) * "]") *
37+
"]") * "," * node("∫apply!", "c.jl", 3, 3) * "]"))}"""
38+
39+
@testset "FlameDiff" begin
40+
baseline_path = fake_flame_file(BASELINE_TREE)
41+
candidate_path = fake_flame_file(CANDIDATE_TREE)
42+
43+
@testset "load_flame extracts the embedded tree" begin
44+
tree = load_flame(baseline_path)
45+
@test tree["1"]["func"] == "root"
46+
@test tree["1"]["count"] == 10
47+
@test length(tree["1"]["children"]) == 2
48+
@test_throws ErrorException load_flame(@__FILE__) # not ProfileCanvas
49+
end
50+
51+
@testset "aggregate_flame computes self and total counts" begin
52+
root_count, self_counts, total_counts = aggregate_flame(load_flame(baseline_path))
53+
@test root_count == 10
54+
@test self_counts["root@task.jl:1"] == 10 - 6 - 3
55+
@test self_counts["work!@a.jl:5"] == 6 - 2
56+
@test self_counts["helper@b.jl:9"] == 2
57+
@test self_counts["∫apply!@c.jl:3"] == 3 # UTF-8 frame names survive
58+
@test total_counts["work!@a.jl:5"] == 6
59+
end
60+
61+
@testset "recursive frames are not double-counted in totals" begin
62+
_, self_counts, total_counts = aggregate_flame(load_flame(candidate_path))
63+
@test total_counts["work!@a.jl:5"] == 16 # not 16 + 8
64+
@test self_counts["work!@a.jl:5"] == (16 - 8) + (8 - 2)
65+
end
66+
67+
@testset "flame_diff ranks self-sample deltas" begin
68+
io = IOBuffer()
69+
rows = flame_diff(baseline_path, candidate_path; top_n = 3, io)
70+
output = String(take!(io))
71+
@test occursin("baseline root samples: 10", output)
72+
@test occursin("candidate root samples: 20", output)
73+
@test occursin("ratio: 2.0", output)
74+
@test issorted(rows; by = row -> row.delta, rev = true)
75+
biggest = rows[1]
76+
@test biggest.frame == "work!@a.jl:5"
77+
@test biggest.delta == 14 - 4
78+
@test biggest.base_total == 6 && biggest.cand_total == 16
79+
@test rows[end].delta <= 0 # unchanged frames (∫apply!, helper) sort last
80+
end
81+
end

0 commit comments

Comments
 (0)