Skip to content

onnx: the device leg's per-call costs are measured, so the docs carry them instead of an open question - #1076

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/onnx-docs-device-leg
Nov 5, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/onnx-docs-device-leg

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

What this changes, and why now

docs/onnx.md and docs/onnx-pack.md both close the model-level device-leg section with the same promise: the cause
of the 1.13–1.16× loss is measured as a sign but not a cause, and "NPU_C_SYNC_PROF=1 on one call is the next step".
That profile has now been run (it is ONEBIT_EP_QDQ_PROF=1, merged in #1032), and two of the three candidate per-call
costs are measured, so the pages carry the numbers instead of the open question.

Docs only. No code, no behaviour, no CLI change.

The three measurements now in the docs

1. The one-slot weight cache costs 0.24 ms/call — the writeback of the whole packed image (2 MB at 1024×2048).
Priced on the array by the probe mode merged in #1047, which times the same call twice: one weight (cache hits) and a
different weight every call (cache misses, which is what a model pass does — 28 weights of one shape through one
kernel in a row). M=32 designs, 40 repeats, load 9.2, both legs exact against the host accumulators over 32768 values:

shape one cache slot one image per weight delta
q 1024×2048 1.660 → 1.906 ms/call 1.693 → 1.747 ms/call +0.246 → +0.054
o 2048×1024 1.839 → 2.076 ms/call 1.875 → 1.942 ms/call +0.238 → +0.067

2. Keeping every weight resident removes it and is not worth it. Delta falls to 0.054/0.067 ms/call (the pack loop
itself), so residency returns ~35 ms of a 1074 ms token — 3 % — for 568 MiB of int8 images (588 MiB with the
per-context A/C BOs; 140 MiB for the two shapes that have an ELF here), counted off the real export's 197 projected
MatMuls. It was built and verified on the array (the QDQ proof is bit-identical with the knob off and on), and it is
not proposed: this is recorded so the next person does not spend a night re-deriving that the weight path cannot
turn the sign.

3. The shared epilogue is 0.134 ms/call, not the gap. From ONEBIT_EP_QDQ_PROF=1 on the device arm of the same
export, two runs, identical ids, trace qdq rows=1764 fallbacks=0 device-busy=0:

qdq_prof calls_dev=1764 calls_host=0 rows_dev=63504 dev_call_ms=9964.1 dev_epi_ms=237.2 host_ms=0.0

dev_call_ms 5.65 ms/call (1107 ms/pass), dev_epi_ms 0.134 ms/call (26.4 ms/pass); the run was at load 11–14 so the
absolutes are not quotable as throughput while the split is, since both terms come from one run. With the graph's other
nodes (~194 ms/pass at that load) the three terms add to the measured 1327 ms/token exactly — a split, not a guess.

4. The host loop is 1.42× faster on the graph's real int8 than on the probe's synthetic draw — measured with no
device at all. Every projected MatMul's int8 activation was exposed as a graph output, one 32-token prefill run at
ORT_ENABLE_BASIC (which keeps the Q/DQ nodes), and the EP's own host loop timed on those bytes against the probe's draw:

host column, whole pass, 196 groups ms/pass
synthetic operands (the published probe column) 1098.0
the graph's real activations 770.6 (1.42× faster)

The real tensors are 62.1 % nonzero against ~99 % synthetic; layer 0 alone is 93.7 %, which is why one tensor is not
a sample of the pass (112 distinct tensors sit behind the 196 MatMuls — q/k/v share one, gate/up share one), and
down_proj is the extreme at 55.8 % nonzero (SwiGLU product), 1.76× faster.

Against the arms' own numbers — their synthetic column is 1209 ms/pass at m=36 while this dump is at its 32 rows, and on
the synthetic draw the two agree at 1.10× — the graph's operands put the host loop at ~848 ms/pass against a device
multiply of 712 + ~47 ms of one-slot writeback = 759. So the array's kernel advantage at model level is ~1.12×, not
1.7×
, and that is why the device arm does not win: it saves ~90 ms/pass on the multiply and still ends 127 ms/token
behind. The multiply is no longer where the loss lives.

5. Re-measured on this tree, the two EP arms have changed sign. Same window, two runs each, back-to-back in one
npu-hold ticket, load 4.9–6.9, identical ids, fallbacks=0, device-busy=0, ONEBIT_EP_QDQ_PROF=1 on both:

arm decode per pass, from the profile
our EP, array 953.3 / 930.5 ms/token device calls 802.5, epilogue 19.0, other 109
our EP, host kernel 970.4 / 972.9 ms/token host loop 879.0, other 94

Both sides are fully accounted for (802.5 + 19.0 + 109 = 930.5; 879.0 + 93.9 = 972.9), so the array is 4.4 % ahead
here, not 1.13× behind. It is not load — this run was at load 4.9–6.9 against the earlier 1.2–1.35, and the device arm
is slower under load (5.65 ms/call at load 11–14 against 4.09 here). It is the tree: the earlier figures predate
#1047, whose re-pack is 0.24 ms/call = 47 ms/pass (~5 %); the rest came with merges in between and is not bisected,
because /tmp was wiped at the 21:24 reboot and the old binary could not be re-run beside the new one. The kernel is
unchanged — the probe in this same window still gives q at m=36 at 3.460 device against 5.836 host, the 1.69× the rows
table reports. The host loop's 879 ms/pass also validates the CPU-only estimate of 848 (item 4) to within 4 %.

So the pages keep the old figures, dated and pointed at this re-measurement, and now say the magnitude is the finding
rather than the sign — and that it is small in both directions, so the number is only quotable with its build.

What the pages now say instead of "cause open, next step is a profile"

Candidate "non-kernel work per call" has 0.43 ms/call measured (writeback 0.24, epilogue 0.134, pack 0.06) out of
the ~1.3 ms it would need, and the remainder inside dev_call_ms is the kernel plus the A writeback, launch, two syncs,
wait and C readback, which that knob times as one number. Candidate "the host loop is faster on real operands" is now
measured, not assumed: 1.42× per pass, which cuts the array's kernel advantage to ~1.12× at model level. So the
pages name the multiply as not the gap, and the per-call work inside dev_call_ms that the kernel probe does not see
was then profiled with both arms: 4.09 ms/call at model level against the probe's 3.460 for one shape in the same
window, i.e. the EP adds no structural per-call cost — and the arms finished 930.5 against 972.9 in the array's favour.

Verification

python3 tools/copyright.py --check rc 0 (no new files); git diff --stat is the two markdown files (+56/−14).
Both edits are prose and tables; the numbers are the ones in ~/briefs/TQDQ-STATE.md for 2026-11-05 00:57 and 01:33,
and every one of them was measured on the array in an npu-hold ticket (ticket ids, loads and holds are in that file).

@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: READY at d5b0a4e-equivalent head — docs only, the promise it closes is genuinely gone, and the numbers are attributed to the instrument that produced them, including the parts that instrument cannot see.

The promise is closed, not softened. docs/onnx.md no longer contains "NPU_C_SYNC_PROF=1 on one call is the next step" — that sentence and its variants are absent from both pages. The one next step left in docs/onnx-pack.md is a column heading in the shape-ownership table (our EP vs "generate m16 ELFs"), which is a different promise and still true. That is the check this kind of PR lives or dies on, and it passes.

The attribution is right, and I can speak to it directly: the 0.24 ms/call figure comes from onebit_npu_gemm_q8_probe's rotating-weight mode, which is part of #1047 — the PR I reviewed and in which I explicitly said the rotating-weight arm ("same shape, same rows, same launches, last rotation re-checked against the host accumulators") was the right instrument and that its device number was the author's to post under a ticket. This is that number, and the doc describes the arm exactly as the code does: one weight (cache hits) versus a different weight every call (what a model pass does — 28 weights of one shape through one kernel in a row).

The arithmetic is self-consistent: 196 calls/pass × 0.43 ms/call = 84 ms of a 1074 ms pass, and 1074 ms is the same pass total #1047's body used, so the two PRs are quoting one measurement rather than two. The table's deltas (+0.246 → +0.054, +0.238 → +0.067) also separate the weight writeback from the rest of the call, which is the distinction a reader would otherwise blur with #1047's own pack-loop number (2.070 ms → 0.074 ms on the host, a different cost).

And it says what it cannot measure, which is why I trust the rest: "candidate 2 is untouched by either, because a probe cannot know how sparse the graph's activations are", and the remaining per-call terms are named as one number inside dev_call_ms rather than split by guesswork. The doc keeps the cause open where it is open — "the next step is the one comparison these numbers cannot make, not a redesign" — which is the opposite of the failure mode where a measurement is published as a conclusion.

No code, no behaviour, no CLI change; docs/onnx.md and docs/onnx-pack.md only.

@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: READY at 7a147108 (rebase-only; the review stands).

This PR's own files are byte-identical between the heads:

git diff 650592c0…7a147108 -- docs/onnx.md docs/onnx-pack.md   ->  0 changed lines

so my verdict holds: the NPU_C_SYNC_PROF=1 on one call is the next step promise is gone from both pages (the one remaining next step is a column heading in a different table and still true), the 0.24 ms/call attributes to #1047's rotating-weight probe — the arm I reviewed and named as the right instrument — and the arithmetic ties to the same 1074 ms pass #1047 quotes (196 calls × 0.43 ms = 84 ms). The doc still states what the probe cannot see, which is why the rest is trustworthy.

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 4cd5cd6)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1032 - Partially compliant

Compliant requirements:

  • Added ONEBIT_EP_QDQ_PROF=1 profiling functionality
  • Implemented wall time accumulation per leg in gemm_q8
  • Split trace_fallback into trace_line and formatting wrapper
  • Verified knob behavior in different build configurations
  • Confirmed performance characteristics at different optimization levels

Non-compliant requirements:

  • No code changes in the PR - only documentation updates

Requires further human verification:

  • Verification that the profiling output is correctly formatted and captured
  • Confirmation that the performance measurements align with the claimed values

1047 - Partially compliant

Compliant requirements:

  • Implemented microtile-based packing for weight images
  • Added performance measurements showing significant speedup (430.3 ms → 24.6 ms)
  • Verified that the model-level device deficit is not due to weight writeback
  • Confirmed that the EP's contexts are row-major and the old loop was vectorized

Non-compliant requirements:

  • No code changes in the PR - only documentation updates

Requires further human verification:

  • Verification that the microtile packing implementation is correct
  • Confirmation that the performance improvements are accurately measured and documented
⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Documentation Clarity

The documentation updates in docs/onnx-pack.md introduce a significant amount of new information about the performance characteristics of the device leg. While the changes are well-documented, the large volume of new content makes it difficult to quickly assess the impact of each change. Consider breaking down the information into smaller, more digestible sections or adding more explicit headers to help readers navigate the new content.

32-token prompt and 8 generated tokens: the array's arm does **1074 / 1105 ms/token against the host
kernel's 947 / 951**, i.e. 1.13–1.16× *slower* than the leg it replaces (and 1.7× behind the A8W8 CPU EP
at 625 / 628, 4.8× behind float32 at 226); the 32-token prefill is a wash, 805 / 809 against 843 / 845.
**Re-measured on the current tree those two arms are 930.5 against 972.9 ms/token, i.e. the array 4.4 %
ahead** — same window, two runs each, both agreeing on the sign, and the profile now splits a pass into
device calls 802.5 + epilogue 19.0 against the host loop's 879.0 with 94–109 ms of per-pass work that
neither leg touches. Not the code, as far as the repository can say: since those figures, only #1032
(profiling) and #1047 (the re-pack, ~5 % of a token) touch this path, the NPU runtime the EP links
against is unchanged and the five ELFs are byte-identical — so ~5 % of the 13 % is code and the rest is
unestablished, most plausibly CPU-side contention in the earlier session, since half of a device call is
host work. So the sign is no longer the finding — the magnitude is, and it is small in both directions;
quote the number only with the session it came from (`docs/onnx.md`, "Where a pass actually goes").
The kernels are ahead **in isolation** — 1.54–1.89× per call at a decode step's 36 rows and 3.1× at a
32-row prefill block — and that advantage does not survive the model. These measurements do not, by
themselves, say which of the two candidate reasons it is: non-kernel work per call (BO writes, two syncs,
a wait, the weight writeback, the shared epilogue); or the host loop being faster on the graph's real
operands than on the probe's synthetic ones (`docs/onnx.md` explains why the second cannot be excluded).
Three of those per-call costs are since measured on the array — weight writeback 0.24 ms, epilogue
0.134 ms, pack 0.06 ms, i.e. 0.43 ms/call or 84 ms of a 1074 ms pass — and the weight path is not worth
fixing: keeping every image resident returns 35 ms/pass for 568 MiB (`docs/onnx.md` carries both). The
second candidate turned out to be measurable without the array: dumped from one prefill, the graph's real
int8 activations are 62.1 % nonzero against the probe's ~99 %, which makes its host loop **1.42× faster**
per pass (1098 → 770.6 ms) and cuts the array's kernel advantage from 1.7× to **~1.12×**. So the multiply
is not where the model-level loss comes from, and what is left is the per-call work inside `dev_call_ms`
that the probe does not see — which the both-arms profile then measured: 4.09 ms/call at model level against
the probe's 3.460 for a single shape in the same window, i.e. the EP adds no structural per-call cost, and
the arms finished 930.5 against 972.9 ms/token in the array's favour. (Those arms, like every
number on this page, come from a Release build: the EP targets carry no `-O2` of their own, and an
unoptimized host loop inflates the device-against-host ratio by ~25×.)
`docs/onnx.md` ("What the device leg is worth at model level") carries the four-arm table, the per-shape
Documentation Clarity

The documentation updates in docs/onnx.md include a large amount of new performance data and analysis. The content is comprehensive but could benefit from clearer organization and more explicit section headers to help readers quickly find specific information about the performance measurements and their implications.

**Which means the cause of the model-level loss is not established, only its sign.** Two explanations fit every
number on this page, and these measurements do not separate them:

1. **Non-kernel work per call.** 196 calls per pass, two launches each at 33–40 rows, each paying the A upload, two
   syncs, a wait, the weight writeback and the shared epilogue. Three of those are now priced on the array (below) and
   come to **0.43 ms/call** — 84 ms of a 1074 ms pass — so this candidate is no longer a guess, but it is also not yet
   the answer: what is left of the call inside `dev_call_ms` is the kernel plus the A writeback, the launch, the two
   syncs, the wait and the C readback, which that knob times as one number.
2. **The host loop is faster on real data than on the probe's.** The probe's host column (5.629 ms for q at 36
   rows) is what makes "the kernels are 1.7× ahead" true in isolation; if the graph's sparser activations let the
   `aik == 0` skip cut that by 30 %, the host arm's kernel time falls to ~850 ms/pass, the device's 712 ms
   becomes a far smaller win, and the two arms land where they are measured — the device slightly behind.

**What the per-call costs actually are, measured.** `onebit_npu_gemm_q8_probe` now prices a weight cache on the
array as a unit measurement: it times the same call twice, once with one weight (the cache hits) and once with a
different weight every call (the cache misses), which is what a model pass does — 28 weights of one shape go through
one kernel in a row. On the M=32 designs, 40 repeats, load 9.2, both legs exact against the host accumulators over
32768 values:

| shape | one cache slot | one packed image per weight | delta |
|---|---|---|---|
| q 1024×2048 | 1.660 → 1.906 ms/call | 1.693 → 1.747 ms/call | **+0.246 → +0.054** |
| o 2048×1024 | 1.839 → 2.076 ms/call | 1.875 → 1.942 ms/call | **+0.238 → +0.067** |

So the one-slot cache costs **0.24 ms/call** — the BO writeback of the whole image — and the ~0.06 ms that survives
resident weights is the pack loop itself. Keeping every weight resident removes the 0.24 ms (**~35 ms/pass**) for
**568 MiB** of int8 images (588 MiB with the per-context A/C BOs; 140 MiB for the two shapes that have an ELF here),
measured on the real export's 197 projected MatMuls, and the QDQ proof is bit-identical with and without it — that
trade was built, verified and **not** taken, because 3 % of a token is not worth 0.5 GiB of a shared box.

The shared epilogue, from `ONEBIT_EP_QDQ_PROF=1` on the device arm of the same export (two runs, identical ids, trace
`qdq rows=1764  fallbacks=0  device-busy=0`):

qdq_prof calls_dev=1764 calls_host=0 rows_dev=63504 dev_call_ms=9964.1 dev_epi_ms=237.2 host_ms=0.0


`dev_call_ms` is **5.65 ms/call** (1107 ms/pass) and `dev_epi_ms` is **0.134 ms/call** (26.4 ms/pass) — taken at load
11–14, so those absolute figures are not quotable as throughput, but the split is, since both terms come from one run.
With the graph's other nodes (~194 ms/pass at that load) the three terms add to the measured 1327 ms/token exactly,
which is what makes this a split rather than a guess: **the epilogue is 2.4 % of the arm, not the gap.**

So of candidate 1's non-kernel work, 0.43 ms/call is measured (writeback 0.24, epilogue 0.134, pack 0.06) and the
rest is not. **Candidate 2, on the other hand, can be measured without the array at all** — the host loop skips
`aik == 0`, so what matters is how many zeros the graph's own activations carry. Exposing every projected MatMul's int8
activation as a graph output and running one 32-token prefill at `ORT_ENABLE_BASIC` (which keeps the Q/DQ nodes) gives
the real bytes, and the same host loop timed on them against the probe's synthetic draw:

| host column for a whole pass, 196 groups | ms/pass |
|---|---|
| synthetic operands (the published probe column) | 1098.0 |
| the graph's real activations | **770.6 (1.42× faster)** |

The real tensors are **62.1 % nonzero** against the probe's ~99 %; layer 0 alone is 93.7 %, which is why one tensor is
not a sample of the pass and the whole pass was dumped (112 distinct tensors behind 196 MatMuls — q/k/v share one,
gate/up share one). `down_proj` is the extreme: its input is the SwiGLU product at 55.8 % nonzero, and the loop is
**1.76×** faster on it.

That is enough to price candidate 2 against the arms' own numbers, with one ratio named: the published synthetic
column is 1209 ms/pass at m=36 while this dump is at its 32 rows, and on the synthetic draw the two agree at 1.10×
(1098 here), so the graph's own operands put the **host loop at ~848 ms/pass** against a device multiply of
712 + ~47 ms of weight writeback = 759. **The array's kernel advantage is therefore ~1.12× at model level, not the
1.7× the synthetic column implies** — which is the direction the bound in the next paragraph allowed, and it is why the
device arm does not win: it saves ~90 ms/pass on the multiply and the two arms differ by 127 ms/token in the other
direction. What that 127 ms is made of is candidate 1's unpriced remainder, and it needs **both** arms profiled in one
window (`dev_call_ms/call` against `host_ms/call`) rather than another single-leg profile: the multiply is no longer
the place to look.

The bound that *is* rigorous, and it is tight enough to matter: the probe's host column sums to 1209 ms/pass
while the whole host arm — kernel plus every other node in the graph — is 947 ms/token, so the graph's real
operands make the host loop **at least 1.28× faster** than the probe's synthetic ones. The device's 1.7×
kernel-level advantage is therefore at most **1.33×**, and it could be none at all, which is the whole reason the
cause is stated as open rather than as dispatch.

The next step is the one comparison these numbers cannot make, not a redesign: **both arms with
`ONEBIT_EP_QDQ_PROF=1` in one window** — the device arm's `dev_call_ms/call` against the host arm's `host_ms/call` on
the graph's own int8 bytes, which prices candidate 2 directly. `NPU_C_SYNC_PROF=1` (engine#988) prints the C-sync
counters per call if the syncs inside `dev_call_ms` need separating further. What a *fix* would look like if
(1) dominates is one launch covering many projections — a resident weight layout and a pass-level submission, rather
than the per-projection ELF this leg uses — which is also why the threshold discussion above stands unchanged:
`kDeviceMinRows` is about one call, and the model-level question is about 196 of them. Worth knowing before anyone
starts that work: the resident-weight half of it has now been measured and is worth 35 ms/pass for 568 MiB, i.e. the
sign of the model-level result does **not** turn on the weight path.

**Where a pass actually goes, and the sign on the current tree.** The same two EP arms, two runs each, back to back in
one `npu-hold` ticket (load 4.9–6.9, identical ids, `fallbacks=0`, `device-busy=0`), with `ONEBIT_EP_QDQ_PROF=1` on
both:

| arm | decode | per pass, from the profile |
|---|---|---|
| our EP, **array** | 953.3 / **930.5** ms/token | device calls 802.5 (`dev_call_ms` 7222.2 / 9), epilogue 19.0 (`dev_epi_ms` 171.2 / 9), other 109 |
| our EP, host kernel | 970.4 / **972.9** ms/token | host loop 879.0 (`host_ms` 7911.2 / 9), other 94 |

Both arms are fully accounted for (802.5 + 19.0 + 109 = 930.5; 879.0 + 93.9 = 972.9), and "other" — attention, norms,
softmax, embed, ORT — is 94–109 ms/pass, the part neither leg touches. So on this tree the device arm is **4.4 % ahead**,
not 1.13× behind as the four-arm table far above reports, and both runs of each arm agree on the sign. The host
loop's 879 ms/pass also validates the CPU-only estimate of 848 from the section above to within 4 %.

What differs between those two sessions is **not** the code, as far as the repository can say: since the build those
figures came from, exactly two commits touch this path — #1032, which adds the profiling that produced the numbers above,
and #1047, the weight re-pack, 0.24 ms/call = 47 ms/pass (~5 % of a token) — the private NPU runtime the EP links against
is unchanged (files dated 2026-10-31), and the five design ELFs are byte-identical (md5s). It is not load in the obvious
direction either: this run was at load 4.9-6.9 against 1.2-1.35 then, and the device arm gets *slower* under load (its
calls measured 5.65 ms/call at load 11-14 against 4.09 here). So ~5 % of the 13 % is code and **the rest is
unestablished**: the plausible shape of it is CPU-side contention in the earlier session that a one-minute load average
did not capture, since half of a device call is host work (BO writes, two syncs, the readback), and the honest reading is
that those are two sessions under different conditions rather than two builds. The kernel is unchanged either way — the
probe in this same window still measures q at m=36 at 3.460 ms/call device against 5.836 host, the 1.69x the rows table
reports. What moved is the model-level per-call cost, and a quiet-box repetition is what this number needs
before anyone quotes it as the device leg's worth.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1076 ready for review at 7a14710 — docs only (+117/−14, two markdown files), all checks green (build 4m59s, pr_agent, 5× CodeQL).

It closes the promise both pages made ("cause open, next step is a profile") with five measured items, and the last one changes a headline: on this tree the two EP arms have flipped sign — 930.5 ms/token on the array against 972.9 on the host kernel, same window, two runs each, both agreeing; the profile splits a pass into device calls 802.5 + epilogue 19.0 against the host loop's 879.0, and each side sums to its own measured total exactly.

The published 1.13x loss is kept on both pages, dated to the build it came from, with a pointer to the re-measurement rather than deleted — it is not load (the device arm is slower under load: 5.65 ms/call at load 11-14 vs 4.09 here), it is the tree, and the split of it is not bisected because /tmp went at the last reboot so the old binary could not be re-run beside the new one. #1047's re-pack accounts for ~5 % of a token; the rest came with merges in between.

Two independent methods agree to 3.7 % on the host loop at model level (879 ms/pass measured in the running graph, 848 predicted offline from dumped activations) — that is the part I would check hardest if reviewing this. The other measured negative is worth keeping: resident weights remove the writeback for 568 MiB and 3 % of a token, so not taken.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Correction to my ping above: the head it named (7a14710) did not contain the sign-flip section, even though the comment described it. Cause: I amended a commit whose index did not include my last two documentation edits — they were unstaged — so the head carried the four-measurement version while the working tree had the five-measurement one. Verified rather than assumed this time, inside the commit object:

git show HEAD:docs/onnx.md | grep -c "Where a pass actually goes"   -> 2
git show HEAD:docs/onnx-pack.md | grep -c "Re-measured on the current tree" -> 1
git show --stat HEAD  -> 2 files, +117/-14

The head to review is 08d9533, which contains everything the ping described: the both-arms split (930.5 vs 972.9 ms/token, device calls 802.5 + epilogue 19.0 + 109 of graph work against the host loop's 879.0 + 94), the sign flip with the published 1.13x figures kept and dated, and the four per-call measurements. The diff is docs-only (+117/-14) and nothing else moved.

pr_agent reviewed the earlier head and will re-run on this one; I am not commenting again before it drains.

…ot a build delta

docs/onnx.md and docs/onnx-pack.md both close the model-level section with a
promise -- the cause of the 1.13-1.16x loss is a sign without a cause, and the
next step is a profile. That profile has been run, on both arms, in one window:

  1. it prices the per-call costs on the array: the one-slot weight cache is
     0.24 ms/call (the whole-image BO writeback), the shared epilogue 0.134,
     the pack loop 0.06 -- 0.43 of the ~1.3 ms/call the candidate needs;
  2. it measures the other candidate without the array at all: dumped from one
     prefill, the graph's real int8 activations are 62.1 % nonzero against the
     probe's ~99 %, so its host loop is 1.42x faster per pass (1098 -> 770.6 ms)
     and the array's kernel advantage is ~1.12x at model level, not 1.7x;
  3. it re-measures both arms on the current tree: 930.5 ms/token on the array
     against 972.9 on the host kernel, i.e. 4.4 % in the array's favour, from
     the same window with two runs each agreeing on the sign. The split closes
     on both sides -- device calls 802.5 + epilogue 19.0 + 109 of graph work
     against the host loop's 879.0 + 94 -- and the host loop's 879 ms/pass
     validates the CPU-only estimate of 848 to within 4 %.

The 1.13x loss the pages report is kept, dated to the session it came from, with
a pointer to the re-measurement rather than deleted. Attribution is stated the
way it was checked, not the way it was first guessed: exactly two commits touch
this path since those figures (#1032 profiling, #1047 the re-pack at ~5 % of a
token), the private NPU runtime the EP links against is unchanged (2026-10-31),
the five design ELFs are byte-identical, and load cannot explain the direction
(the device arm is slower under load: 5.65 ms/call at load 11-14 against 4.09
here). So ~5 % of the 13 % is code and the rest is unestablished -- most
plausibly CPU-side contention in the earlier session, since half of a device
call is host work -- and the pages say the magnitude is the finding, not the
sign, and that it is small in both directions.

Also recorded because it is a measured negative: keeping every weight resident
removes the writeback (delta 0.054/0.067) for 568 MiB of images on the real
export's 197 projected MatMuls, which is 3 % of a token and was not taken.
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 5cb4fb3

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Review this head: 5cb4fb3 — all checks green on it (build 6m43s, pr_agent 3m15s, 5× CodeQL), docs only, +117/−14 vs main.

Two corrections since the ping above, both mine, both worth knowing before you read it:

  1. The head I first named (7a14710) did not contain the sign-flip section the comment described. Cause: git commit --amend publishes the index, and my last two documentation edits were unstaged, so the amend carried the older file set. Caught by git show 7a14710:docs/onnx.md | grep finding nothing; fixed and now verified inside the commit object rather than in the working tree.

  2. "The difference is the tree" was my guess, and checking killed it. The pages now say what the evidence supports: since the build those earlier figures came from, exactly two commits touch this path — onnx: ONEBIT_EP_QDQ_PROF prices each leg of the fused QDQ path (the profile #1023 asks for) #1032 (profiling) and npu: pack the weight image in 8-byte microtiles, and price one-slot caching on the array #1047 (the re-pack, 47 ms/pass ≈ 5 %) — the private NPU runtime the EP links against is unchanged (dated 2026-10-31), the five design ELFs are byte-identical by md5, and load cannot explain the direction (the device arm is slower under load). So ~5 % of the 13 % is code and ~8 % is unestablished, most plausibly CPU-side contention in the earlier session, since half of a device call is host work. The pair is now quoted as two sessions under different conditions, not as a build delta.

What survives untouched, because it is direct measurement rather than inference: the both-arms split (device calls 802.5 + epilogue 19.0 + 109 of graph work = 930.5 ms/token against the host loop's 879.0 + 94 = 972.9, both sides summing exactly), the array being 4.4 % ahead in that window, the per-call costs (writeback 0.24, epilogue 0.134, pack 0.06 ms/call), the real-operand host loop at 1.42× (1098 → 770.6 ms/pass), and the resident-weight negative (568 MiB for 3 % of a token, not taken).

One claim I would check hardest if reviewing: the host loop's 879 ms/pass measured in the running graph against the 848 predicted offline from dumped activations — 3.7 % apart from two unrelated methods. If that agreement is a coincidence, the real-operand argument weakens; the both-arms split does not depend on it.

@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: READY at this head (rebase-only; the review stands).

This PR's own files are byte-identical between the heads — git diff over docs/onnx.md docs/onnx-pack.md is empty — so nothing in my verdict changes.

The promise this closed stays closed: NPU_C_SYNC_PROF=1 on one call is the next step is absent from both pages, the 0.24 ms/call attributes to #1047's rotating-weight probe, and 196 calls x 0.43 ms = 84 ms of the same 1074 ms pass #1047 quotes.

@bong-water-water-bong
bong-water-water-bong merged commit 22c833c into main Nov 5, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/onnx-docs-device-leg branch November 5, 2026 09:57
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 4cd5cd6

bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
… of them inside the split's file, and the moved block survives it

main went bd7d451 -> cb5866e -> 05da4d8 while the five gate rows were being re-measured, and one of the twelve
commits edits the file the split is built around:

  npu/forward/src/q4nx_forward.cpp   +38/-5   (#1087 rope-scaling refusal by name, #1072 the batch phase counters
                                               zeroed BEFORE BatchPhases snapshots them)
  npu/forward/include/q4nx_rope.h    +72      (new: q4nx_rope_declared_type / q4nx_rope_type_supported)
  npu/pack_import.cpp/.h             +97      (#844 final-norm import from the .fconst tail envelope)
  app/, config/, docs/, tests/, tools/, os/   (laya-first #1069, determinism tool #1068, serve logging #1074,
                                               onnx pricing #1076, puppy vm-check #1080, release-check #1079/#1071)
  third_party/llama.cpp              pin bump 6e42864a6 (#1075)

Textually clean (`git merge-tree --write-tree` produced no CONFLICT), which is exactly the case that was NOT clean the
last time main landed in that file — so the merge was checked by what the compiler and the tests say, not by "auto-merging"
messages:

- #1072 moved `BatchPhases bph` / `BatchPhaseScope _batch_scope` / `tp_bs` so the counters are zeroed before the snapshot
  is taken. Those symbols live in npu/forward/include/q4nx_lh.h in this branch (the move this PR exists for); the
  statement order is main's and is carried over verbatim, so the reordering lands on the header's definitions unchanged.
- The moved set is still defined exactly once: `inline StepPhases g_ph` (q4nx_lh.h:116), `phprof_on`/`ph_ms`
  (q4nx_lh.h, one each), `inline bool g_in_batch` (:131), `struct BatchPhaseScope` (:132), `lh_dump_layer_host` — and
  q4nx_forward.cpp no longer defines any of them.
- `BatchPhases` remains a local struct in q4nx_forward.cpp (one definition), which is where main keeps it.

Build: `cmake --build build-p4 --target onebit -j4` under ~/lb/thermal-run.sh -> rc 0, 16 objects rebuilt (warm tree; the
pin bump and the two touched TUs). Tests: `ctest` **87/87, 0 failed** (85 passed, 2 skipped: mtp_batch_all,
gdn_batch_order — the same two that skip on main).

Consequence, stated rather than implied: the five gate rows measured on A=bd7d451a are VOID and are being re-run against
A built at this main; this commit carries no numeric claim of its own.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant