Repository navigation
onnx: the QDQ device leg is 1.13x slower than the host kernel at model level (measured; cause not isolated) - #1023
Conversation
|
#1023 ready for review at 673c854. Docs-only: the QDQ device leg's model-level number (#702's deferred item) — 1073.7/1105.1 ms/token on the array against 947.4/950.6 for the host kernel it replaces, same ids and same KL, trace qdq runs=1764 host-rows 0 fallbacks 0; plus the -O0 trap that voids any timing from a bare cmake build (~25x on the device-against-host ratio). Every number is from one quiet window (load 1.17-1.35) on 434db3d, three short npu-hold -l qdq tickets, device-busy=0 in all three. |
PR Reviewer Guide 🔍(Review updated until commit 20b72fa)Here are some key observations to aid the review process:
|
673c854 to
d9d3f59
Compare
|
#1023 head moved 673c854 -> d9d3f59, pre-verdict, and the change is a correction of my own claim: the first version said dispatch costs the model the win. It cannot: the probe's host column that makes 'the kernels are 1.5-1.9x ahead' true is measured on synthetic int8 operands, and its sum (1209 ms/pass at m=36) is above the whole host arm's 947 ms/token — so the same data that shows a kernel-level win is what shows I cannot use it to attribute the model-level loss. The docs now state the two candidates (196 dispatches per pass; or the host loop being faster on real than on synthetic operands), say the measurements do not separate them, and name the profile that would (NPU_C_SYNC_PROF=1 plus a per-call timer inside gemm_q8). The measured claims are unchanged: 1073.7/1105.1 ms/token on the array against 947.4/950.6 for the host kernel, same ids, same KL, trace qdq runs=1764 host-rows 0 fallbacks 0 device-busy 0. |
|
Persistent review updated to latest commit d9d3f59 |
d9d3f59 to
c92c147
Compare
…l level (measured; cause not isolated) The device leg was documented as correctness-only because a device call is dispatch-bound; #702 deferred the model-level number. This measures it on a Release build of this tree, the real Quark A8W8 export of Qwen3-0.6B, one thread, a 32-token prompt and 8 generated tokens, quiet box (load 1.17-1.35): arm prefill 32 tok decode ms/token tok/s float32, CPU EP 207 / 212 225.6 / 226.0 4.43 A8W8, CPU EP 635 / 645 625.1 / 628.1 1.59-1.60 our EP, host kernel 843 / 845 947.4 / 950.6 1.05-1.06 our EP, array (196/196 groups) 805 / 809 1073.7 / 1105.1 0.90-0.93 So the array is 1.13-1.16x slower than the host kernel it replaces, 1.7x behind the A8W8 CPU EP and 4.8x behind float32; the 32-token prefill is a wash. Correctness is unchanged and exact: the device arm generated the same ids as the host arm and the same KL against float (5.6787 nats both), trace qdq runs=1764 (=196x9), host-rows policy=0, fallbacks=0, device-busy=0. The 5-token counter-example is all 1764 runs host-routed by design, which is the other half of the claim that nothing was dressed up as a device run. The cause is NOT claimed. Two explanations fit every number and the measurements do not separate them: 196 dispatches per pass each paying BO writes, two syncs and a wait; or the host loop being faster on the graph's real operands than on the probe's synthetic ones, which would shrink the "kernels are 1.5-1.9x ahead" result that holds in isolation. The next step is a profile (NPU_C_SYNC_PROF=1 plus a per-call timer), not a redesign. Also records the trap that cost this measurement a window: the EP targets carry no -O2 of their own, so a bare cmake -S onnx -B compiles the host loop unoptimized and inflates the device-against-host ratio by ~25x (measured, 129.6 ms against 5.0 ms for one probe call). Every number here is a Release build; correctness gates stay on the unoptimized one. No code change: docs/onnx-pack.md and docs/onnx.md only.
c92c147 to
74d1952
Compare
|
Head now 74d1952 (still pre-verdict; this is the last of the correction, not a new claim). Added the one bound the data does support, because it is what makes the open-cause statement quantitative rather than hedged: the probe's host column sums to 1209 ms/pass while the whole host arm — kernel plus every other node — is 947 ms/token, so the graph's real operands make the host loop at least 1.28x faster than the probe's synthetic ones. The device's 1.7x kernel-level advantage is therefore at most 1.33x and could be none. Both candidate causes stay listed; the profile (NPU_C_SYNC_PROF=1 plus a per-call timer in gemm_q8) is what decides. |
|
Persistent review updated to latest commit 74d1952 |
|
#1023 ready for review at 74d1952. Docs-only ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 74d19520
Docs-only (+79/−6, docs/onnx.md and docs/onnx-pack.md), and the claim is a negative one, so what matters is that its arithmetic holds and that nothing else on the page still contradicts it.
Every derived number re-computes from the printed cells. From the per-shape table and the four arms:
device ms/pass 102.6 + 93.5 + 296.1 + 92.2 + 127.7 = 712.0 (page: 712.0)
host ms/pass 157.6 + 160.9 + 491.1 + 157.9 + 241.5 = 1209.0 (page: 1209.0)
ratio 1.70x ; array vs host-kernel arm 1073.7/947.4 = 1.133x and 1105.1/950.6 = 1.163x (page: 1.13-1.16x)
array vs A8W8 CPU EP 1.72x ; vs float32 4.76x (page: 1.7x, 4.8x)
bound: host arm 947.4 / device kernel 712.0 = 1.331x (page: at most 1.33x)
trace 196 groups x 9 passes = 1764 (page: qdq runs=1764) ; 1073.7 ms/token = 0.93 tok/s (page: 0.90-0.93)
The reasoning in "Which means the cause ... is not established" is the part I checked hardest, because it is the part that could be overclaiming: it does not assert dispatch is the cause, it gives two explanations that both fit and then states the one bound that is rigorous (host arm total 947 < probe host 1209 => the graph's operands make the host loop >= 1.28x faster than the probe's, so the device's 1.7x kernel advantage is <= 1.33x — 947.4/712.0 = 1.331, and 1209.0/947.4 = 1.276, so "at least 1.28x" is a rounding up by 0.004; with the second run, 1209.0/950.6 = 1.272).
The release-build caveat is structural, and I confirmed it in the build files. onnx/ep/CMakeLists.txt carries no -O flag at all and the host loop (gemm_q8, onnx/ep/onebit_ep_kernel.cpp) is built by it; onnx/CMakeLists.txt only sets -O2 on its own targets (onebit_ort_run, ort_info, …). So a bare cmake -S onnx -B really does build the EP unoptimized and the EP-vs-host comparison inverts. My own attempt to re-derive the "~25x" on a toy int8 loop gave only 4.3x (32x2048x1024, O0 111.72 ms vs O2 25.94 ms — the gap is vectorization, and my layout is not yours), so I am not endorsing the magnitude, only the direction: an -O0 host loop against an -O2 device arm is not a comparison, and printing that sentence next to the table is the right call.
Two things I could not check, and one nit:
- No artifacts for the four arms on this box. Nothing under
~/lbcarries aqdq runs=trace or these arms (searched~/lbfor the trace strings; the newest dirs are other lanes'). The tables are the author's run logs only, so "re-run this later" is not currently possible from the repository or the box — if the numbers are to survive as the page of record, the fourort_runinvocations and their trace lines belong in the body or a run dir. - Pre-existing text this PR leaves standing is consistent:
docs/onnx-pack.md:295still says the device leg runs the epilogue "only as a correctness result so far, so it is not where the 1.5x-per-projection argument lands either". Good — the two pages now say the same thing. - Nit: the per-shape table's host column is explicitly a unit measurement (1209 ms/pass > the 947 ms whole host token), and the next paragraph leans on that. A one-line "do not subtract" note is already there; add "(sums to more than the arm because it is per-unit)" to the table caption too, since the table is what gets quoted.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 20b72fa35
Rebase only: own contribution (merge-base with origin/main..head, +/- lines only) hashes to 3e8ffa947c21 at this head and at 74d19520f, the head I reviewed — 2 files, +79/−6, unchanged. Every number in the two tables was re-computed in that review, and the structural half (onnx/ep/CMakeLists.txt carries no -O flag at all) still holds.
|
Persistent review updated to latest commit 20b72fa |
…1032) #1023 measured the device leg 1.13-1.16x slower than the host kernel at model level (1073.7/1105.1 vs 947.4/950.6 ms/token, same ids, same KL) while the per-design probe, at the same shapes, had the device kernels 1.7x ahead (device 712 ms/pass vs host 1209). Both readings cannot be the whole story, and nothing separated the two candidates: per-call dispatch, or the probe's host column being measured on synthetic int8 operands while the graph's own activations are not (its host column sums to 1209 ms/pass, above the entire 947 ms/token host arm, so it cannot be subtracted from anything). This is the instrument that separates them, without a profiler: one running total per process of where gemm_q8's wall time went, split as the device call (BO writes, launch, both syncs, the wait), the epilogue both legs share, and the host loop. Two arms that differ only in the leg then price that leg on the graph's own int8 bytes. ONEBIT_EP_QDQ_PROF=1 -> qdq_prof calls_dev=... rows_dev=... dev_call_ms=... dev_epi_ms=... host_ms=... (one line, at exit) Verified without a device: - unoptimized host build 6/6 green, and 6/6 green with the knob on, with the fused proof's gate unchanged (worst |d| = 0 LSB per-tensor / per-axis / asymmetric / asymmetric-per-axis, per-K scale still refused); - the knob's three cases run: off -> zero qdq_prof lines; on with a trace -> one line in /tmp/onebit_ep_trace.<pid>.log; on without a trace -> the line on stderr; - Release (ONEBIT_EP_NPU=ON) build: onebit_ep_qdq_proof passes; ctest there is 8 tests of which onebit_ep_proof fails and the two NPU probes skip, exactly as main already does for an NPU build with no ELF configured; - the same 17 calls cost 79.8 ms at -O3 and ~2000 ms at -O0 (25x), the -O0 trap from 2026-11-04 15:45 seen from the other side. trace_fallback is split into trace_line (the writer, unchanged 0600 + O_NOFOLLOW) plus the formatting wrapper, so every note this file emits has one writer and the prof line cannot go silent when ONEBIT_EP_TRACE is off -- the same footgun as this file's old path-vs-flag bug. The measurement this is for is armed on the NPU queue (one short npu-hold ticket, sized to finish inside its hold); its numbers will be reported on #1023 and in ~/briefs/TQDQ-STATE.md, not in this diff. No device hardware was touched to write or gate this. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
The QDQ device leg was documented as correctness-only because a device call is dispatch-bound; #702
deferred the model-level number. This PR measures it and records what it is worth, including the fact that
the sign is not what a "speedup" claim would say.
The measurement
Four arms, one graph — the real Quark A8W8 export of
qwen3-0.6b.onnx(196 fused groups; the 197th,lm_head, keeps the host kernel because its weight is aCast) — Release build of this tree, onethread, 32-token prompt, 8 generated tokens, quiet box (load 1.17–1.35 for every arm), two runs each, the
second quoted:
32 + 4) and 3.1× at a 32-row block — and that advantage does not survive the model.
The cause is not claimed. Two explanations fit every number here and these measurements do not separate them:
(a) dispatch — 196 calls per pass, two launches each at 33–40 rows, each paying the A upload, two syncs and a
wait (at ~1.3 ms of non-kernel work per call, ~250 ms of a 1074 ms pass); or (b) the host loop is faster on the
graph's real operands than on the probe's — the probe's host column is what makes "kernels are 1.5–1.9× ahead"
true, and if the graph's sparser activations let the
aik == 0skip cut it by 30 %, the host arm's kernel timefalls to ~850 ms/pass and the device's 712 ms is no win at all. Both land on the arms as measured. The next step
is a profile —
NPU_C_SYNC_PROF=1(engine#988) plus a per-call timer insidegemm_q8— not a redesign. What isestablished is the sign, and that the model-level claim must be arm-to-arm, not kernel-to-kernel.
Correctness is unchanged and exact: the device arm generated the same ids as the host arm and the same KL
against float (5.6787 nats both; the A8W8 CPU EP's arm scores 7.2085 with a different continuation), trace
qdq runs=1764(= 196 × 9 passes),host-rows policy=0,device fallbacks=0,device-busy=0. The5-token counter-example is the other half of that claim: same binary and designs, 8 tokens in 2159 ms
(3.70 tok/s), trace
host-rows policy=1764and no device call at all — a 5-token prompt never reacheskDeviceMinRows.The probe cross-check ties this to the published rows table: at m=32, q 1024×2048 measures
device 1.632 / host 5.166against the documented1.566 / 5.062(4 % and 2 %).The trap this measurement walked into, because it will happen to the next person
The EP targets carry no
-O2of their own (onlyort_run/ort_infodo), so a barecmake -S onnx -Bcompiles the host int8 loop unoptimized: one probe call measured 129.6 ms where theRelease build measures 5.0 ms, i.e. a
-O0build inflates the device-against-host ratio by ~25×. Thefirst take of these arms was such a build and is not quoted. The rule now written into the docs: timing
numbers come from a Release build, correctness gates stay on the unoptimized one (this lane has both
directions on record — unoptimized builds are the stricter gate, and the worse measurement).
Shape of the change
docs/onnx-pack.md(the device-leg bullet: "correctness result" gains its measured model-level qualifier andthe four-arm figures) and
docs/onnx.md(a new section, "What the device leg is worth at model level", withboth tables, the accounting, the counter-example, and the statement that the probe's host column is a unit
measurement — it sums to 1209 ms/pass at m=36, above the whole host arm's 947 ms/token, so it must not be
subtracted from the device column). No code change, no behaviour change.
Amended pre-verdict (the head moved from
673c854tod9d3f59) because the first version of the sentence abovenamed dispatch as the cause, which the numbers do not support: the probe's host column is inflated by synthetic
operands, so "the kernels are 1.7× ahead" is a synthetic-data result and cannot be used to conclude that per-call
overhead is what eats it. The docs now state both candidates and the profile that would decide.
python3 tools/copyright.py --check→ rc 0 (no new files). Every number in both tables is from~/wt/qdq-model-gate.sh,~/wt/qdq-model-arms.shand~/wt/qdq-kernel-times.shin one quiet window on434db3d, three separate shortnpu-hold -l qdqtickets,device-busy=0in all of them.