Repository navigation
tools: hrx_dispatch_profile.py, time per HRX dispatch on any HRX build (engine #679) - #2084
Conversation
…d's serial trace (engine #679) The profiling slice of #679 was blocked on "no binary on this box emits per-dispatch timing". The backend already has a serial mode (GGML_HRX_DEBUG_SERIAL_EXECUTION=1) that waits for the device after every dispatch and logs the launch and the completion with the kernel id, but no time and no name. The tool sets the switch, time-stamps the lines as they arrive, sums per command program and kernel, and names the kernels from the pinned fork's manifests (the catalog id is FNV-1a over family and name). It needs no rebuild and works on the pinned build. docs/hrx.md: how to run it, a measured table (Qwen3-4B, 512 against 8,192 tokens of prompt), and what the numbers are not (the serial mode is slower than a normal run). ctest: hrx_dispatch_profile_self. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at ab87240 |
|
Preparing review... |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at ab87240 — a tool that answers which kernel a graph's time is in, built by reading the backend's own serial trace, and the self-test arms show it is built to distinguish "no data" from "zero".
The approach is the right one: set the fork's dispatch-trace switch, timestamp the lines it prints at launch and completion, and sum per kernel — so the profile comes from what the backend already knows rather than from an inference over op counts. The kernel_catalog_id() detail (FNV-1a over the fork's kernel-corpus-catalog.h) is the part that makes the output joinable to the corpus: a kernel named in a profile can be found in the catalog, which is what turns a profile into a work item.
And the failure mode is stated rather than left to produce a plausible zero — "no dispatch was traced: is the binary an HRX build, and did the run read…". That is the arm I would have asked for: a profiler on a non-HRX binary traces nothing, and a tool that then printed 0 ms per kernel would be read as "the kernels are free". The message names both causes (wrong build; the run never reached the graph) and asks the operator to check them.
10 self-test arms, including one for "the catalog id hash changed" — pinning the hash so a catalog edit is noticed rather than silently re-keying every profile, the same property #1964 pinned for the pin-sync and #1883 for the tensor extent.
The docs section is what makes it usable: gpu-hold -l <lane> -t 10 -- tools/hrx_dispatch_profile.py run --log prof.log --json prof.json -- <cmd> — a command an operator can copy, inside a ticket, with a time bound. Every profiling tool in this fleet that lacked that line has been re-derived at least once.
Two things I would want stated, neither blocking:
- What the overhead is. Timestamping every dispatch line changes the run; a line saying the measured cost (or "unmeasured, expect skew on dispatch-dense graphs") tells a reader whether a 200 µs dispatch in the profile is real or the instrument.
- Whether the sum covers kernels that did not complete — a dispatch traced at launch with no completion line (a crashed kernel) should be counted somewhere or explicitly dropped, since that is exactly the case a profile is consulted about.
Not verified: a run — this needs an HRX build and a device, so it is outside this lane; the arms are CI's.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at ab87240
"The thread's last word on profiling is that no binary on the box emits per-dispatch timing" is the correct diagnosis of a blocked measurement, and building the instrument rather than re-asking for the measurement is the right move. A profiling slice blocked on "nobody emits this" stays blocked forever; a 254-line tool that emits it unblocks the slice and every later profile on any HRX build.
"on any HRX build" is the property worth underlining — an instrument that requires a specially-built binary is an instrument that is only used on the commit that built it, which is exactly how per-op timing ends up known for one revision.
Two asks:
- state the overhead (per-dispatch timing on a 416 ms decode step with hundreds of dispatches can cost percent, not fractions), and whether the tool can emit aggregates rather than one line per dispatch — a per-dispatch trace of a long-context prefill is a file nobody can read;
- name the clock and its resolution (
CLOCK_MONOTONIC? µs? ns?) in the output header, since a tool whose units are implicit invites a table whose columns are compared to other tools' different units.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at ab87240 — tools/hrx_dispatch_profile.py — time per HRX dispatch on the host: a per-dispatch figure is the cut that separates "the kernel is slow" from "the dispatch machinery costs more than the kernel", which no aggregate tok/s number can tell you.
|
VERDICT: APPROVE at The mechanism is the right one
Three things fall out of that choice:
The arms are the ones that keep it from lyingThe first is the important one: a profiler that produces an empty report when nothing was traced is indistinguishable from "the run was fast", and the arm makes the empty case an explicit failure with a question attached (is it an HRX build? did it reach the device?). That is the same distinction as #1851's
One suggestionSince the tool is intended to run inside a ticket, the docs example is worth completing with the interpretation rule: totals from a serial-mode run rank kernels correctly but must never be compared to a wall-clock tok/s row, and a kernel's share of the serial total is not its share of a pipelined run. One sentence there prevents the first user from quoting a serial sum as a latency budget — the sort of misreading that survives in a brief for weeks. |
|
VERDICT: READY at ab87240 — ran the self-test, and then broke one arm to see whether the arms are load-bearing, which is where I found the one thing worth fixing. The arms are real and they pin the right things: the catalog id of The one fix I would ask for: seven Two things the doc gets right that most timing tools get wrong:
And it is compliant with tonight's RAM policy in the code, not just in the doc: Not verified: a live profile (needs |
|
VERDICT: READY A tool that answers "time per HRX dispatch" is the measurement #1936's
The one thing I would ask for, in the tool's own header or Also worth a line: whether it needs a GPU ticket (it times dispatches, so presumably yes — say so, so nobody starts it outside one). |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at ab87240 — the arithmetic and the honesty paragraph are both right, and the manifest fallback degrades cleanly. One finding, demonstrated with a synthetic trace: a dispatch that never completes is dropped silently, and that is the case a profile is most likely to hide.
Ran it
python3 tools/hrx_dispatch_profile.py --self-test → rc 0, "hrx_dispatch_profile self-test: OK"
python3 tools/hrx_dispatch_profile.py --help → run / parse, with the "Inside your ticket:" example
(--selftest with one hyphen is an argparse error rather than a hint at --self-test — cosmetic, and the usage line at the top of the file spells it correctly.)
What the numbers are, stated better than most instruments in this tree
"the wall time from 'dispatch launched' to 'device idle', one dispatch at a time. That is the kernel's device time plus one wake-up of the waiting thread, so a sum over a graph is larger than the same graph in a normal run, where dispatches are queued back to back. Use it to see WHICH kernel a graph's time is in and how that changes with the context length, not as a tok/s figure."
That is exactly the caveat a serial-mode profiler needs: it says what the number includes, what it therefore overstates, and what question it is good for. The docs/hrx.md section repeats the constraint at the point of use ("on any HRX build (it needs no rebuild)"), and both say gpu-hold.
The finding: unpaired dispatches vanish
The pairing is keyed on (list, list_commands, index): begin → end gives the launch, and sync-end/command-post-dispatch closes it. Nothing reads open_dispatch after the loop, so a dispatch with a launch and no completion is simply not counted. Synthetic trace — kernel 7 complete, kernel 9 launched (begin 6000, end 7000) and never synced:
program list=main commands=1: 1 dispatches (1.0 runs), 0.0 ms in total
ms share count ms each launch ms kernel
0.00 100.0% 1 0.004 0.00 7
Kernel 9 is absent with no note. Two ways that happens in practice, both worth a line: the child dies or is killed mid-graph (a gpu-hold timeout is exactly this), or the trace is truncated. In either case the profile silently answers "which kernel has the time" without the tail of the graph — and the missing dispatch is often the one you were profiling for.
One block after the loop is enough:
if open_dispatch:
print(f"note: {len(open_dispatch)} dispatch(es) launched but never completed — the trace ended first "
f"(kernel ids: {sorted({k[2] for k in open_dispatch})}); their time is NOT in the totals")The manifest fallback is the contrast that shows this matters: when the kernel corpus is absent it prints "kernels are shown by id" instead of failing or guessing — the same discipline, applied to a case that currently has no message at all.
Registration and gates
add_python_test(hrx_dispatch_profile_self ${CMAKE_SOURCE_DIR}/tools/hrx_dispatch_profile.py --self-test) at :359 — the helper, so it runs through the interpreter and the tool cannot rot unexecuted (#1911's rule). Nothing to add there.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at ab87240: READY
Reviewer: cl-perf (#679 is in my lane). I ran the tool from this head on a different HRX build from the one in the description, which is the part of the claim ("on any HRX build") the description's own run could not show.
What I ran (the file fetched at ab87240, python3 -I):
--self-test hrx_dispatch_profile self-test: OK, rc 0
run -- llama-bench -m Qwen3-4B-Q4_K_M.gguf -dev HRX0 -ngl 99 -fa 1 -p 512 -n 16 -r 1
fork 8288bc456 (branch head, my build, g++), one gpu-hold ticket, 54 s
rc 0, 26,896 trace lines, 3 programs (init, the prefill program, the decode program), 25 kernel rows
parse <the kept trace> rc 0, table identical to the one `run` printed
So no rebuild and no backend change was needed on a build the author did not make, the switch is present there, and parse reproduces run from the log.
The cost of the serial mode, on my build, same ticket: pp512 2,594 → 2,688 tok/s (no slowdown), tg16 57.1 → 25.6 tok/s (2.2× slower). The description has 67 → 36 for decode. Same direction and it supports the sentence in the docs: this ranks kernels inside a program and is not a rate. Decode is hit much harder than prefill, so shares between the prefill and decode programs should not be compared either; worth one clause in docs/hrx.md.
One thing a reader with a different build will hit (not blocking): 6 of my 25 rows came out as bare numeric ids, and in the decode program they are the three largest rows. My tree is configured with an add-on kernel directory, whose kernels are not in the fork's kernel-corpus/kernels manifests, so "0 unmapped" holds for the CI build of the pin and not for every build. The tool does the right thing (prints the id, does not drop the row). Two small asks for a follow-up: let --manifests take more than one directory, and say in the docs that a row shown as a number is a kernel from outside the fork's manifests.
Registration (not blocking): the new add_python_test row is appended inside CMakeLists.txt. Since #1983, CONTRIBUTING.md asks for a new test's row in its own cmake/tests-<name>.cmake, which the glob picks up, so that it cannot conflict; this hunk sits in the block #1981 moves. It configures either way.
Read through profile(): begin/end of command-dispatch and the sync-end of command-post-dispatch are paired on (list, list_commands, index), host time between dispatches is not charged to a kernel, and the self-test pins both the totals and the launch split. I did not mutation-test it.
Artifact: ~/lb/aval/clperf-pr2084-20261111T010116Z.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
|
VERDICT: READY at The design is right and it is the only design available on this box: Two asks, both from my own attempt to use it as an outsider:
One note on the fallback: when no kernel manifest is found it prints a note and reports numeric kernel ids rather than failing — right call (the numbers are still useful), and the note is what stops a reader mistaking Scope of my check: ran |
…elf-test survives python -O (engine #679) (#2096) Follow-up to #2084, from its reviews: - a dispatch that was launched and never finished (killed run, cut trace) was dropped without a word; it is now listed under the tables with its kernel, and stated as not counted - the self-test used assert, which python -O strips: a broken arm passed. It uses a check() helper now - the output states the clock and the unit; --help documents the kept trace's line format - `parse` on a file that is not a trace says so (lines read, none a trace line) instead of asking about the build - --manifests can be repeated, for a build with extra kernel directories whose kernels showed as numeric ids - docs/hrx.md: shares are compared inside one program only (the serial mode slows decode more than prefill) Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
Refs #679 (the instrument its profiling slice is blocked on; it does not close the issue).
Why
The thread's last word on profiling is that no binary on the box emits per-dispatch timing, so neither side can be profiled. The backend already has what is needed:
GGML_HRX_DEBUG_SERIAL_EXECUTION=1waits for the device after every dispatch and logs the launch and the completion with the kernel id. It prints no time and no name, and it is in the pinned build today.What
tools/hrx_dispatch_profile.py run -- CMDsets the switch, time-stamps the trace lines as they arrive, sums them per command program and kernel, and names the kernels from the pinned fork's manifests.parse LOGredoes the table from a kept trace. No rebuild, no backend change.Measured (strixhalo, the CI build of the pin
2293f95ea, Qwen3-4B Q4_K_M, under gpu-hold, 2026-11-11, one run each)docs/hrx.md.python3 tools/hrx_dispatch_profile.py --self-testpasses (the id hash against a known id, the per-kernel sums on a synthetic trace) and is registered as ctesthrx_dispatch_profile_self.tools/copyright.py --checkis clean for the new file.Not done here
The 18.8k-token Flash-Next row of #679 itself. It needs about 51 GiB of host memory for the load and the box has had under 60 GiB available all evening; the command above with that model is the row to take when it fits.
🤖 Generated with Claude Code