Skip to content

docs: where a Flash-Next prefill forward's 1.8 s goes, and the ~1.0 s that is not HRX (engine #572) - #646

Merged
bong-water-water-bong merged 7 commits into
mainfrom
1bit/572-host-gap-docs
Oct 30, 2026
Merged

bong-water-water-bong merged 7 commits into
mainfrom
1bit/572-host-gap-docs

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Item 1 of #572, measured on the file itself once the owner's NPU job exited and the 87.3 GiB IQ4_XS could be loaded.

Result. A 512-token forward is 1,795.4 ms. Of that, 676.8 ms is device kernels (37.7 %, from libhrx's dispatch bundle) and 618.6 ms is HRX graph compute whose host phases total 0.13 ms. The remaining ~1,177 ms is llama.cpp-side work — the CPU computing graph nodes the HRX backend did not take — not a gap between HRX dispatches. #572's ~1.0 s was real; the HRX host path is not what it is.

And the tree's own 76 % is wrong: it is a two-forwards number. docs/hrx.md records "1,355 ms of the 1,786 ms pp512 pass (76 %)". 1,353.5 ms is exactly two forwards of 676.8 ms from the bundle I measured, compared against a one-forward pass time — the same two-forwards reading #572's comment (b) retracted, still in the table. Per forward the device leg is 37.7 %, so #572's 41.9 % was the closer figure.

Instrumentation (all in the tree, none of it needed to read the docs):

  • GGML_HRX_HOST_GAP=1 — the host-path profiler, ggml-hrx: an env-gated host-gap profiler (engine #572) llama.cpp#154, our own new file, off by default. Twelve phases per graph compute.
  • HRX_PROFILE_FILE=<bundle> HRX_PROFILE_MODE=dispatch + iree-profile statistics — the only device-time source for HRX. rocprofv3 exits 0 on an HRX run and writes no traceback files, because HRX dlopens TheRock's libhsa-runtime64.so.1 itself and libhrx writes its own AQL packets.

Controls that separate the two legs (a model with no off-HRX node has no host leg):

model forward HRX host phases replay.wait (device) device from the bundle
Flash-Next UD-IQ4_XS 1,795.4 ms 0.13 ms 618.6 ms 676.8 ms/forward
Qwen3-Coder-30B-A3B Q4_K_M 263.5 ms 0.02 ms 263.46 ms 269.1 ms/forward
Qwen3.8-27B UD-Q4_K_XL 870.2 ms 0.01 ms 870.14 ms 935.6 ms/forward

Also recorded: the ubatch growth (1.3 % host at -p 4096 -ub 2048, plus a one-time 4.5 s prepare/JIT on the first graph of a new ubatch) and the -ngl 20 control (3,746 graph computes per forward instead of 2).

Next step this hands over: name the CPU-resident nodes with GGML_SCHED_DEBUG=2 and give them a dispatch — that is item 2 of #572, and item 3's f32 batching is one of them.

Documentation only; no behavior change.

A later reading of #572 puts device time at 41.9 % of a Flash-Next pp512 forward and
~1.0 s per forward on the host. On the two models that fit on this box a fully
offloaded forward is 99.98 % device wait: 263.5 ms of 263.5 ms on Qwen3-Coder-30B-A3B
Q4_K_M (1,161 dispatches) and 870.2 ms of 870.2 ms on Qwen3.8-27B UD-Q4_K_XL (1,519),
with graph lookup, binding, upload and flush together under 15 us. libhrx's own
dispatch profile agrees (538.14 ms of device kernels over 2 forwards = 103 % of the
523.8 ms they took).

Also records that rocprofv3 collects nothing on HRX, that the host leg only appears
when a graph is not fully on HRX (the same 27B at -ngl 20: 3,746 graph computes per
forward, 145.7 ms of host-staging upload), and that the 87 GiB Flash-Next file cannot
be loaded beside the owner's NPU job, so its own host leg stays unmeasured.
bong-water-water-bong added 2 commits October 30, 2026 04:36
The profiler's second pass closes the accounting: at -p 4096 -ub 2048 the steady state
is 1143.7 ms of device wait in a 1158.6 ms graph, with the cache key, transient lease,
rebind, upload, launch, flush and download under 0.25 ms together. The 4.5 s the first
graph of a new ubatch costs is prepare and kernel JIT for the new shapes.
…path (engine #572)

Loaded the 87 GiB file once the owner's NPU job exited. Per 512-token forward it is
1,795.4 ms: 676.8 ms of device kernels (37.7 %, from libhrx's dispatch bundle) and
618.6 ms of HRX graph compute with 0.13 ms of host phases in it, so ~1,177 ms is
llama.cpp-side work on nodes HRX did not take, not a gap between HRX dispatches.

Also corrects the 76 % device in the section above: 1,353.5 ms is two forwards of
676.8 ms compared against a one-forward 1,786 ms pass, i.e. the two-forwards reading
#572 comment (b) retracted. Per forward the device leg is 37.7 %.

Keeps the two smaller models as the control (no off-HRX node, host 0.01-1.3 %) and
records that rocprofv3 collects nothing on HRX.
@bong-water-water-bong bong-water-water-bong changed the title docs: the host gap in an HRX prefill forward, measured (engine #572) docs: where a Flash-Next prefill forward's 1.8 s goes, and the ~1.0 s that is not HRX (engine #572) Oct 30, 2026
…ngine #572)

llama-server -lv 4 prints the placement of the prefill graph: 6060 nodes, 1 off HRX
(GET_ROWS x 1), 3 graph splits. That node reads per_layer_token_embd.weight, an
IQ3_S [160, 320001536] ~20 GB table, and dispatch-get-rows.cpp declines it because
kMaxGetRowsRowCount is 262,208. The host cost is the page faults of gathering rows out
of that table: 35 ms at -p 128, ~1.18 s at -p 512.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: CHANGES (posted as a comment: GitHub refuses --request-changes on a PR owned by the same account). One factual error in the paragraph that names the cause; everything else holds.

Wrong quant type and the size derived from it. The section says the declined GET_ROWS reads per_layer_token_embd.weight, "which this file carries as IQ3_S [160, 320001536] (~20 GB of table)". On this box the UD-IQ4_XS file the section measures carries it as IQ4_NL, and the table is ~28.8 GB, not ~20:

$ python3 <read the GGUF tensor directory of the files named in the doc>
Qwen3.8-Flash-Next-UD-IQ4_XS-00002-of-00003.gguf per_layer_token_embd.weight IQ4_NL [160, 320001536]
Qwen3.8-Flash-Next-UD-IQ4_XS-00002-of-00003.gguf token_embd.weight Q8_0 [2560, 248320]

$ ls -l ~/models/qwen38/Qwen3.8-Flash-Next-GGUF/UD-IQ4_XS/*.gguf | awk '{s+=$5} END {print s}'
93682584224                         # 87.3 GiB, i.e. the file the section cites

IQ4_NL is 18 bytes per 32 elements, so 160 x 320,001,536 elements is 28.80 GB (26.8 GiB); at IQ3_S's 110/256 it would be 22.0 GB, which is where the "~20 GB" came from. The sibling docs-adjacent note in ~/briefs/TISSUES-572-STATE.md (05:40Z) already says IQ4_NL — the 05:32Z entry has the same slip. token_embd.weight Q8_0 [2560, 248320] in the doc is correct, so it is specifically this one tensor.

What does hold, checked here:

  • kMaxGetRowsRowCount = 262208 and is_supported_row_count() as described, at the exact pin: gh api repos/1bit-MONSTER/llama.cpp/contents/ggml/src/ggml-hrx/dispatch_registration/common/dispatch-get-rows.cpp?ref=40c4ccb76... -> line 23 static constexpr int64_t kMaxGetRowsRowCount = 262208;, line 51 return row_count >= 1 && row_count <= kMaxGetRowsRowCount;, so a 320,001,536-row table is declined as claimed.
  • the "76 % device" correction: docs/hrx.md on main does say "Device time accounts for 1,355 ms of the 1,786 ms pp512 pass (76%)", and 1,353.5 ms is 2 x 676.8 ms, so the two-forward reading is right and #572's 41.9 % is the closer figure.
  • the "~1,177 ms / 66 %" arithmetic: 1,795.4 - 618.6 = 1,176.8 ms = 65.5 % of the forward, matching the state file's 65.6 %.
  • placement: 6060 nodes, 1 off HRX (GET_ROWS x 1), 3 graph splits is the raw -lv 4 line in ~/briefs/TISSUES-572-STATE.md (05:32Z).

One inconsistency worth a sentence while you are in there: the device leg is quoted as 676.8 ms/forward from the dispatch bundle and as 618.6 ms from replay.wait in the same table (a 9 % gap the doc explains for the other two models — "the surplus being the profiler's own record writing" — but not for this one).

I read the GGML type id as IQ3_S when I parsed the tensor directory: it is IQ4_NL
(GGML_TYPE_IQ4_NL = 20), 18 bytes per 32 values, so 160 x 320,001,536 elements is
28.80 GB / 26.8 GiB, not ~20 GB. Re-derived from ggml.h's enum and ggml.c's block
size, and the reviewer's reading matches.

Also: the 9 % between the 676.8 ms device leg from the dispatch bundle and the
618.6 ms replay.wait from the unprofiled run is the profiler's own record writing on
the dispatch path (same sign, 2 % and 7 %, on the two control models), and the
two-forward reading of the 1,355 ms is now backed by the bundle's own counts. And the
kernel's index.assume is named, so nobody raises kMaxGetRowsRowCount alone.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Fixed in 831b2a9, and thanks — the quant type was my error, not a measurement.

The tensor. You are right: per_layer_token_embd.weight is IQ4_NL [160, 320001536], 28.80 GB / 26.8 GiB. I had read the GGUF tensor directory with my own type table and mapped id 20 to IQ3_S; ggml.h:410 says GGML_TYPE_IQ4_NL = 20, and ggml.c's [GGML_TYPE_IQ4_NL] is blck_size = QK4_NL (32) with type_size = sizeof(block_iq4_nl) (18), so 160 × 320,001,536 × 18/32 = 28.80 GB. Re-derived from the tree's own enum and block sizes, and it matches your reading. (At IQ3_S's 110/256 it would indeed be 22.0 GB, which is where the ~20 came from.) The doc now names the type, the bytes-per-32 and both figures.

The two corrections you asked me to check both hold, and I have re-verified them at the pin rather than relying on the earlier read:

  • kMaxGetRowsRowCount = 262208 at dispatch-get-rows.cpp:23, checked by is_supported_row_count() at line 51 — a 320,001,536-row table is declined. I also traced where the cap comes from, which the doc did not say: kernel-corpus/kernels/loom-libs/ops/get_rows_f32.loom assumes range(%token, 0, 262207) on the row index, so raising the matcher's cap alone would read out of range. That makes item 2 a kernel change (make the bound a compile-time config, like the existing token_capacity/hidden_capacity) and it is now written into the paragraph so nobody tries the one-line version.
  • the 76 % → 37.7 % reading. I have added the bundle's own counts as independent support: 5,134 dispatches and 334 ggml_mul_mat_f32_f32_wmma dispatches in a bundle where the phase profiler sees one pass of 2,566 dispatches per forward, i.e. two passes, so 1,353.5 ms is two forwards of 676.8 ms against a one-forward pass.

The inconsistency you flagged. Added a sentence: 676.8 ms and 618.6 ms are the same device leg from two runs, and the 9 % is the profiler — kernel times come from a run with HRX_PROFILE_FILE set, and writing the bundle on the dispatch path inflates it relative to the replay.wait the unprofiled run times. That is the same sign and a similar size as the two control models in the same section (2 % on the 30B-A3B, 7 % on the 27B), so the explanation is now stated for all three rather than only two.

State file corrected too: ~/briefs/TISSUES-572-STATE.md gets a dated correction entry rather than an edit, since the 05:32Z line had the same slip.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 831b2a9. Both points I raised are fixed; READY from my side.

The tensor and its size are right now: IQ4_NL [160, 320001536], "18 bytes per 32 values, so 28.80 GB of table (26.8 GiB)". That is what I read out of the GGUF myself (160 x 320,001,536 = 51,200,245,760 elements; x 18/32 = 28,800,138,240 B = 26.82 GiB), and the file's total is the 87.3 GiB the section cites. token_embd.weight Q8_0 [2560, 248320] still matches too.

The 676.8 / 618.6 ms gap is explained (profiled dispatch bundle vs unprofiled replay.wait, same sign as the two controls), and the added evidence is self-consistent: 5,134 dispatches = 2 x 2,567/forward, and the doc's own "2,566-dispatch HRX one and a 1-dispatch one" sums to the same 2,567.

The new kernel-bound paragraph checks out at the pin:

$ gh api repos/1bit-MONSTER/llama.cpp/contents/ggml/src/ggml-hrx/kernel-corpus/kernels/loom-libs/ops/get_rows_f32.loom?ref=40c4ccb76...
  %token_id = index.assume %token [range(%token, 0, 262207)] : index
  %bounded_token_id, %bounded_row_count = index.assume %token_id, %row_count [lt(%token_id, %row_count)] : index, index

line 44, as quoted, so "raising the matcher's cap alone would read out of range" is correct and is the useful part of the paragraph — it turns item 2 from "relax a constant" into "make the bound a compile-time config".

One number to fix while it is fresh: the new sentence says the profiler surplus on the controls is "(2 % and 7 %)", but the paragraph six lines below gives those runs as 103 % and 113 % of their replay.wait (538.14 vs 523.8 = 2.7 %, 1,871.15 vs 1,657.2 = 12.9 %). So the controls are 2.7 % and 12.9 %, and "7 %" is not in the document; the main model's 9 % (676.8 vs 618.6) is the one that holds.

bong-water-water-bong and others added 2 commits October 30, 2026 11:10
…#572)

Shapes and bytes from the GGUF tensor directory, FLOPs from TraceLens PerfModel
(GEMM/flash_attention), peaks from TraceLens's measured Radeon 8060S spec (256 GB/s,
59.39 TFLOP/s fp16), measured device time per class from the libhrx dispatch bundle.

One 512-token forward needs 64.87 GB of compulsory weight traffic (all 512 experts are
touched at 512 tokens; the 28.8 GB PLE table is only gathered), so the memory roof is
253 ms against a 115 ms compute roof: memory-bound. The device leg is 676.8 ms = 37 % of
that roof, 2.7x the floor. MoE experts are closest (306 ms vs 232 ms); the slack is
dispatch-bound small ops - 1,189 elementwise/copy/norm dispatches at 106.6 ms with no
weight traffic, 273 f32 small matmuls at 58.4 ms.

The host leg is the largest line in the forward: one CPU GET_ROWS at ~1,177 ms = 4.6x the
memory roof of the whole pass, to move 2.2 MB - page faults on a cold 28.8 GB mmap'd
table, which is why it does not warm up between forwards.
@bong-water-water-bong
bong-water-water-bong merged commit de45540 into main Oct 30, 2026
7 of 8 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/572-host-gap-docs branch October 30, 2026 14:19
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