|
| 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 |
0 commit comments