Skip to content

flamegraph: intern module paths, and say what width means on a GPU page (#123) - #133

Merged
dpsoft merged 3 commits into
mainfrom
feat/flamegraph-ui-cheap-wins
Sep 5, 2026
Merged

flamegraph: intern module paths, and say what width means on a GPU page (#123)#133
dpsoft merged 3 commits into
mainfrom
feat/flamegraph-ui-cheap-wins

Conversation

@dpsoft

@dpsoft dpsoft commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Two items from the flame-graph UI research, both self-contained.

1. Modules declared once per page

data-module carried the full path on every frame: 566,966 bytes of a 3,165,745-byte page (18%)
for thirteen distinct values
totalling about a kilobyte.

The renderer already applies the opposite rule two lines above the emit site, and says so:

No inline colour: data-domain below selects fill, hatch and outline from paletteCSS, so a
domain's colour is declared once for the whole page rather than once per frame.

Modules are the field that missed it. They are now a JSON table at the top of the chart, with each
frame carrying data-m, an index into it.

page before after
AFTER-cpu-python-walker 3,237,071 2,728,530 (−15%)
GPU-mnist-pytorch 1,129,657 1,002,794 (−11%)

Emitted only when a profile has modules, so a GPU profile — which the builder writes with empty
mappings — pays nothing. Sorted, so the same profile renders byte-identical.

Not HTML-escaped, which is the subtle part: HTML entities are not decoded inside a <script>
element, so escaping would hand JSON.parse a document full of &#34;. json.Marshal already
writes <, >, & as < etc., which makes </script> unrepresentable — asserted with a
module path that tries to break out.

2. A GPU page says what a frame's width measures (#123)

Under [gpu:launch] a CPU frame is as wide as the device time launched from that call path,
not the CPU time spent in the frame. A reader fluent in flame graphs assumes the opposite, because
that is what the shape means everywhere else, and the page carried no signal to correct them. It
was documented only in prose on an index page most viewers never open.

The tooltip now says so, per band — kernel, boundary, unattributed, and everything else — and
only on a GPU profile, decided from the axis label. On a CPU profile the width is the obvious
thing and a caption on every frame would be noise.

Please review this one properly

I introduced three bugs on this branch and caught each only after writing the code:

  1. Interning broke the tooltip's module line. I claimed "nothing reads data-module" — the
    page's own detail() reads it as d.module through a dataset alias my grep missed. There is
    now a test asserting the page ships a reader for the table, not just the table.
  2. A nine-line prose comment went into the shipped script, which
    TestTheShippedAssetsCarryNoProseComments exists to prevent — every comment there is bytes sent
    to every reader. Moved to the Go declaration.
  3. Removing that comment deleted the function widthMeaning(it,d){ line with it, leaving a
    bare function body — and the entire Go suite still passed, because nothing here parses
    JavaScript. A broken script would have shipped and failed only in a browser. There is now a
    structural check on the asset: brace/paren balance, and the presence of every function the
    tooltip path calls.

The third is the one worth a second opinion: the guard I added catches the damage a text edit to a
template literal does, but it is not a parser, and this branch is evidence that the JS in this
repo has no real safety net.

data-module carried the full module path on every frame. Measured on a PyTorch
CPU capture: 566,966 bytes of a 3,165,745-byte page -- 18% -- for THIRTEEN
distinct values totalling about a kilobyte. Nothing read it: not the page's
own script, not a test, not any Go caller.

The renderer already applies the opposite rule two lines above the emit site,
and says so: "No inline colour: data-domain below selects fill, hatch and
outline from paletteCSS, so a domain's colour is declared once for the whole
page rather than once per frame." Modules are the field that missed it.

The paths are now a JSON table at the top of the chart and each frame carries
data-m, an index into it:

  AFTER-cpu-python-walker   3,165,745 -> 2,668,568   -15%
  GPU-mnist-pytorch         1,100,683 ->   970,101   -11%

The table is emitted only when a profile has modules, so a GPU profile -- which
the builder writes with empty mappings -- pays nothing for a table it would
never reference. Sorted, so the same profile renders byte-identical and a
future change to tree-walk order does not show up as a page diff.

NOT HTML-escaped, which is the subtle part and where the first version was
wrong. HTML entities are not decoded inside a script element, so escaping there
would hand JSON.parse a document full of &#34; -- a failure that appears only
in a browser, long after every Go test has passed. json.Marshal already writes
< > & as < > &, which is exactly the escaping this context
needs: it makes "</script>" unrepresentable, so a module path cannot close the
element early. Asserted with a path that tries to.
Under [gpu:launch] a CPU frame is as wide as the DEVICE time launched from
that call path, not the CPU time spent in the frame. Someone fluent in flame
graphs reads it the other way -- that is what the shape means everywhere else
-- and the page carried no signal to correct them. It was documented in prose
on an index page most viewers never open.

The tooltip now says so, per band:

  gpu-kernel             measured time this kernel ran on the device
  boundary               device time launched from this path, sampled one
                         launch in N
  boundary-unattributed  measured device time whose launch was not
                         stack-sampled, so it has no caller
  everything else        GPU time launched from this call path -- not CPU
                         time spent here

Only on a GPU profile, and only because the axis says so. On a CPU profile the
width is the obvious thing and a caption on every frame would be noise; the
line earns its place by appearing exactly where a reader would otherwise be
wrong.

ALSO FIXES A REGRESSION I INTRODUCED one commit ago. Interning the module
paths removed data-module, and detail() reads it as d.module where
d = it.el.dataset -- an alias a grep for "dataset.module" does not find. So
"nothing reads this attribute" was wrong: the page's own tooltip did, and
interning silently emptied its module line. The script now reads the interned
table, and a test asserts the page ships a reader for it rather than only the
table itself.
TestTheShippedAssetsCarryNoProseComments exists because every comment in the
script is bytes sent to every reader, and I had put a nine-line paragraph in
there. The explanation moves to the Go declaration; the page keeps the code.

Removing it took the `function widthMeaning(it,d){` line with it, leaving a
bare function body -- and the whole Go suite still passed, because nothing here
parses JavaScript. A broken script would have shipped and failed only in a
browser.

So there is now a structural check on the asset: brace and paren balance, and
the presence of every function the tooltip path calls. It is not a parser, but
it catches the class of damage a text edit to a template literal actually
does -- which is exactly what happened.
@dpsoft
dpsoft merged commit 61e3ca3 into main Sep 5, 2026
10 checks passed
@dpsoft
dpsoft deleted the feat/flamegraph-ui-cheap-wins branch September 5, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant