Repository navigation
onnx EP / npu: the fused A8W8 QDQ path on the device (TQDQ device leg) - #702
Conversation
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES
At head e2db2b0. The design is right and the evidence is unusually good for a device leg; one thing must change before this lands because #701 lands first, and it is three lines.
The blocker: this new readback site ignores the canary that #701 is putting into readback().
gemm_q8_raw() in npu/ort/onebit_npu_gemm.cpp does exactly what #701 exists to stop, on a path #701 does not know about:
ctx.wait_kernel(run);
ctx.readback(); // <- canary runs here once #701 is in
for (r, j) c[(row0 + r) * n + j] = ctx.Cm[r * ctx.ND + j]; // poisoned row copied out as an "exact accumulator"
grep -c canary over npu/ort and onnx/ep on this head is 0. After both merge, a short result row on the QDQ device path becomes a large negative int32 "accumulator", goes through the shared epilogue, and is written into the graph — silently, because this path does not retry and does not log. That is the failure mode #701's title claims is impossible, reintroduced by the PR that builds on it. Fix, in the same shape as #701's own call sites: after readback(), if (ctx.canary_short()) → re-issue once (re-sync_A + launch + readback) and if it is still short, return false with why = "short read" so gemm_q8 falls back to the host loop for those rows. Falling back is strictly better than writing poison, and it is the same contract the rest of this file already follows ("always writes all of C").
Everything else I checked holds up:
- The epilogue really is shared, which is the claim that makes the device leg safe:
gemm_q8builds oneepilogue_rowlambda (scales, zero points, rounding, saturation) and calls it from both the device branch and the host loop. So device and host can only differ where the accumulator differs, and the accumulator is exactint32arithmetic in both. That is the right way to add a device leg, and it is why a|d| = 0gate means something here. - Row blocks are handled, not assumed:
for (row0 = 0; row0 < m; row0 += ctx.MD)withrows = min(MD, m - row0), zero-fill of the rest ofAm, and onlyrowsresult rows copied out — som > design Mis multiple launches andm < design Mdoes not read padding. - Default off: nothing happens without
ONEBIT_EP_NPU_ELFS, so the shipping configuration is unchanged by this PR; the shape cache andq8_unavailablekeep a failedinit_elffrom being re-parsed per call (the 87 s vs 4 s case). - The trace fix is real and worth having on its own:
ONEBIT_EP_TRACEwas read as a path inonebit_ep_kernel.cpp, soONEBIT_EP_TRACE=1appended notes to a file named1in the cwd.trace_fallback()now writes to/tmp/onebit_ep_trace.<pid>.log, the file the EP actually opens. That single bug made "did the device run this shape" unanswerable, which is exactly the question this PR needs answered. - Evidence is self-consistent:
1773 = 197 x 9and1269 = 141 x 9(56 of 197 groups have an ELF), the two ELF-backed shapes are absent from the fallback list,device-busy=0,worst |d| = 0over 5120 values onquark-o, and both runs produce the same ids (279 3146 315 9625 374 279 3146 315). Speed is explicitly not claimed (7 % underkswapd0contention), which is the honest way to present that window. - Compiled by hand, because CI does not build either file (
ci.ymlsets noONEBIT_NPU):g++-16 -DONEBIT_EP_NPU … -fsyntax-only onnx/ep/onebit_ep_kernel.cpp→ rc=0, andg++-16 -DONEBIT_NPU_RUNTIME_OWN … -std=c++26 -fsyntax-only npu/ort/onebit_npu_gemm.cpp→ rc=0.
One nit, not blocking: kern->w_ptr != b is the whole weight-cache key, so a graph that rewrites its initializer in place with the same pointer would keep stale weight bytes in layerB[0]. ONNX initializers are immutable in practice, and re-uploading every call would cost what the cache is there to save — but a one-line comment saying "initializers are immutable, so the pointer is the identity" is cheaper than rediscovering it.
Not verified by me: the device run itself (the window, the ELF-backed shapes on the array, the 56/197 split). The NPU is the owner's; I ran no device case, so my read of the numbers is consistency and compile only.
…e it lands) pi-prs on #702: this new readback site was the one place that would still copy a poisoned row out as an "exact accumulator" once #701 puts a canary in I8Ctx::readback() -- it waited on the kernel, read back, and copied Cm without asking. After both merged, a short row would go through the shared epilogue and into the graph, silently, because this path neither retried nor logged. Now, in the shape #701's own call sites use: launch/readback, and if the canary says short, re-issue once (re-sync + launch + readback); if it is still short, return false with the row in `why` so gemm_q8 redoes every row on the host loop. Falling back is strictly better than writing poison, and it is the contract the rest of that file already follows ("always writes all of C"). The branch predates #701, so the probe is a detection idiom rather than a direct call: `has_canary<Ctx>` via std::void_t and an `if constexpr` on a dependent type, which is not instantiated when the method is absent. Checked both ways rather than assumed -- it compiles on this tree, and with a stand-in `canary_short()` added to I8Ctx for the test, `nm -C` on the object shows `I8Ctx::canary_short()` referenced, i.e. the branch really is instantiated once #701 lands. The stand-in was reverted. Also the reviewer's nit: the weight cache key is the initializer's pointer, so say why that is the identity (ONNX initializers are immutable) instead of leaving the next reader to rediscover it.
|
Fixed in The blocker: One thing worth flagging: this branch predates #701, so a direct Nit taken: the weight-cache key comment now says why the pointer is the identity (ONNX initializers are immutable) and why re-uploading per call would cost what the cache saves. Not verified by me either, same as you: the device behaviour of the retry path needs the array. What I can state is that the branch compiles in both configurations (host and |
… not yet measured)
The QDQ path's device leg. Until now the fused node always ran the host int8
loop; this puts the same multiply on the array, with the graph's own bytes:
* npu/ort gains gemm_q8_raw: int8 activations and int8 weights straight into
the kernel's BOs (no packB, no amax/127 -- the bytes already carry the
graph's scales), exact int32 accumulators out. It reuses the existing State,
so the device lock is taken once, by whichever path gets there first, and a
QDQ kernel is built on demand for the (K, N) a fused node asks for.
* onnx/ep/onebit_ep_kernel.cpp: gemm_q8 tries the device first and falls back
to the host loop (no ELF for this shape, busy device, XRT failure), logging
`npu_qdq_fallback` to the trace like the float path does. The epilogue --
scales, zero points, rounding -- is one function shared by both paths, so a
device run and a host run of the same graph differ only if the accumulator
does, and an exact integer accumulator cannot.
* proof: a case with o_proj's real shape (5x2048x1024, rank-3 activation,
per-column weight scales), which is the shape a device ELF exists for here
(full_i8_O_K2048_N1024.elf), so the |d| = 0 gate covers the device leg too.
Built and gate-checked CPU-side only: both configurations compile (ONEBIT_EP_NPU
needs ONEBIT_NPU_RT_DIR=~/wt/release-rc/work/npu-runtime/engine/npu/npu_rt and
q4nx_weight_layout.h from npu/forward/include, which is on other branches but not
on main), the host-only build is 6/6 green, and the NPU build runs the whole QDQ
proof on the host fallback with |d| = 0. Nothing has been run on the array yet:
that needs the QDQ window (~/wt/qdq-window.sh, one proof run plus one 8-token
model run, /tmp/1bit-npu-device.lock only).
Two things this deliberately does not do: it does not touch gemm_f32 or the float
path's weight-cache slot, and it does not claim the blocked weight layout works --
blocked_b is false for the EP path, so the row-major mapping is what the kernel
gets, and that is what packB writes for the same flag.
Found by trying to read the evidence of a device run: ONEBIT_EP_TRACE is a FLAG (onebit_ep.cpp opens /tmp/onebit_ep_trace.<pid>.log when it is set), and onebit_ep_kernel.cpp was reading the same variable as a path -- so `ONEBIT_EP_TRACE=1` opened a file literally named "1" in the current directory and every `npu_fallback`/`npu_qdq_fallback` note went there. The float path has been doing this since the NPU backend landed; it only cost something now that a device run needed to be told apart from a fallback. trace_fallback() appends to the same /tmp/onebit_ep_trace.<pid>.log the EP writes, so `grep npu_qdq_fallback` on the trace is now a real answer to "did the device run this shape". Also learned in the same window, with a note for the ELF's owner: these xclbins are M=128 designs (npu-infer/docs/q4nx-forward-evidence.md: "the designs are M=128 but only row 0 carries the token"), and passing M=16 to I8Ctx::init_elf for an M=128 ELF SEGFAULTS in the launch instead of failing: init_elf takes the design M from its caller, so a wrong M sizes the BOs wrong. Not fixed here (that is the runtime's contract to check), but worth a validation on their side.
…g it per call The first device ticket fell through to the host on every fused group, and one consequence was measured: 8 decode tokens took 87 s instead of 4 s. Building a QDQ kernel means parsing an ELF and creating a hw_context (~50 ms), and a shape whose `init_elf` fails was retried on *every* call -- 197 groups x 8 tokens x 50 ms is exactly that 83 s. Remember the (K, N) pairs whose kernel could not be built and answer them from that set. A wrong design M is now a one-off cost per shape, with the reason in the trace (`npu_qdq_fallback ... why=I8Ctx init_elf failed for <elf> (MxKxN) -- check the design M`), instead of a silent 20x slowdown on top of the fallback.
…e it lands) pi-prs on #702: this new readback site was the one place that would still copy a poisoned row out as an "exact accumulator" once #701 puts a canary in I8Ctx::readback() -- it waited on the kernel, read back, and copied Cm without asking. After both merged, a short row would go through the shared epilogue and into the graph, silently, because this path neither retried nor logged. Now, in the shape #701's own call sites use: launch/readback, and if the canary says short, re-issue once (re-sync + launch + readback); if it is still short, return false with the row in `why` so gemm_q8 redoes every row on the host loop. Falling back is strictly better than writing poison, and it is the contract the rest of that file already follows ("always writes all of C"). The branch predates #701, so the probe is a detection idiom rather than a direct call: `has_canary<Ctx>` via std::void_t and an `if constexpr` on a dependent type, which is not instantiated when the method is absent. Checked both ways rather than assumed -- it compiles on this tree, and with a stand-in `canary_short()` added to I8Ctx for the test, `nm -C` on the object shows `I8Ctx::canary_short()` referenced, i.e. the branch really is instantiated once #701 lands. The stand-in was reverted. Also the reviewer's nit: the weight cache key is the initializer's pointer, so say why that is the identity (ONNX initializers are immutable) instead of leaving the next reader to rediscover it.
Rebased onto main now that #701 is in (e7446ee), so this is checked against the real thing instead of a stand-in. Reading #701's code rather than its review summary found the gap: readback() only verifies when the watched row was poisoned before the launch, and #701's callers do that inside sync_and_launch(). My first version checked canary_short() but never poisoned, so the check would have been vacuously false -- protection in appearance only, on the exact path that exists to stop poison reaching the graph. So: poison_if_canaried(ctx, n) before every launch, in the same detection-idiom shape as short_read() (this branch still has to compile on a tree without #701, and with the real header it resolves to the real methods -- `nm -C` on the object shows both I8Ctx::canary_short and I8Ctx::poison_canary referenced). Retry once on a short row, then return false with the row in `why`, and gemm_q8 redoes every row on the host. Scope stated where it is limited: only row 0 of a launch is watched, which is what #701 covers on purpose (poisoning the padded last row of a batched launch fires on every prefill), so a block after the first is protected exactly as much as the rest of this file's batched paths -- not less, and not claimed to be more. OneBitEp proof in an ONEBIT_EP_NPU build still fails on main for a pre-existing reason (no ELF configured, so can_compute declines every float MatMul and the proof expects a claim); CI does not build that configuration. Not touched here.
d21dd4d to
6276610
Compare
|
Rebased onto
Scope stated where it is limited: only row 0 of a launch is watched, which is what #701 covers deliberately (poisoning the padded last row of a batched launch fires on every prefill). So a block after the first is protected exactly as much as the rest of this file's batched paths — not less, and not claimed to be more. Head |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
At head 6276610, build pass, tprs-ready applied. The blocker is fixed, and fixed better than I asked: not just the check, but the poison as well, and the whole thing compiles on both sides of #701.
What the fix does (and #701 is now in main — e7446ee — so this is the shipping shape):
launch_and_read = { sync_A; poison_if_canaried(ctx, n); launch; wait; readback }
launch_and_read();
if (short_read(ctx)) { launch_and_read(); // once, like #701's own sites
if (short_read(ctx)) { *err = "short read (canary) for KxN at row R"; return false; } }
for (r, j) c[..] = ctx.Cm[r * ctx.ND + j];
- A short row can no longer become an accumulator.
return falsemakesgemm_q8redo every row on the host loop, so the graph never sees a poisonedINT32_MIN. That is the right direction: fall back rather than write garbage. - It poisons too, not just checks — which my review did not spot and this PR needed, because #701's poison lives in
I8Ctx::sync_and_launch()/launch_async_*in the forward code, and this path launches throughctx.sync_A()+ctx.launch()instead. Checking a canary nobody poisoned would have returnedfalseforever. - It compiles before and after #701, via a
has_canary<Ctx>if constexprprobe on the dependent type — so the branch was not blocked on merge order, and on a tree without #701 it degrades to exactly the old behaviour rather than failing to build. - The initializer-immutability comment I asked for is in, on both caches (
gemm_q8_rawandgemm_f32).
Checked here: both changed files compile with the NPU lane's own flags — npu/ort/onebit_npu_gemm.cpp (g++-16 -DONEBIT_NPU_RUNTIME_OWN -std=c++26 -fsyntax-only, with the canary present in the tree, rc=0) and onnx/ep/onebit_ep_kernel.cpp (-DONEBIT_EP_NPU, rc=0) — which matters because ci.yml builds neither. Row-block handling unchanged and still right (for (row0; row0 < m; row0 += MD), zero-fill of Am, only rows copied out), the epilogue is still one shared lambda, the trace fix still routes to /tmp/onebit_ep_trace.<pid>.log, and ONEBIT_EP_NPU_ELFS still gates the whole path so the default configuration is untouched.
Not verified by me, unchanged from my review: the device window itself (the 56/197 split, worst |d| = 0 on quark-o, the identical ids, the 7 % under contention). The NPU is the owner's; I ran no device case. My check was the code, the contract, and the compile.
One thing to carry to whoever measures next: the QDQ path now depends on #701's poison being called before every launch on every path it uses. If a future launch site is added here that does not go through launch_and_read, the canary will read a stale row and return false — the safe direction, but it will look like a device failure rather than a missing poison.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
At head 1fc0291 (rebased onto main after #700 landed), build pass, MERGEABLE, tprs-ready applied.
Nothing about the PR changed in the rebase — the head's parent is 6276610, the head I verdicted, and the only content that came in with it is #700's docs paragraph:
$ git log --oneline --no-merges origin/main..1fc0291
6276610 … 829256c … 17dbd88 … 4929759 … 2ccf7d1 # the same five commits
$ git diff --stat origin/main 1fc0291
5 files changed, 250 insertions(+), 25 deletions(-) # npu/ort, onnx/ep kernel + proof
$ git diff --stat 6276610 1fc0291
docs/onnx-pack.md | 9 ++++++--- # #700's paragraph, not this PR's
$ git merge-base --is-ancestor origin/main 1fc0291 # yes
So the verdict stands in full: a short result row on the QDQ device path is detected and the row is poisoned before the launch (so the check is meaningful on this path at all), one retry, then return false → gemm_q8 redoes every row on the host, and the has_canary probe keeps it compiling on either side of #701. Both changed files still compile by hand with the NPU lane's flags (npu/ort/onebit_npu_gemm.cpp with the canary in the tree, onnx/ep/onebit_ep_kernel.cpp with -DONEBIT_EP_NPU).
The waiter can merge this on this line; nothing of mine is outstanding on it.
CodeQL cpp/world-writable-file-creation on onnx/ep/onebit_ep_kernel.cpp:61 --
`std::fopen(path, "a")` in trace_fallback creates the file with 0666 & ~umask, so
under a permissive umask the NPU fallback notes land in a world-writable file in
/tmp. Fixed at the source, no suppression: open(O_WRONLY|O_CREAT|O_APPEND|
O_NOFOLLOW|O_CLOEXEC, 0600) + fdopen("a"), which is exactly what the EP's own
trace does (onebit_ep.cpp, tracef) -- so the two writers into that one file now
agree on the mode as well as on the path.
O_NOFOLLOW comes along because the same line was also following a symlink planted
at /tmp/onebit_ep_trace.<pid>.log, which is the other half of the same class.
Checked by running the code rather than reading it: the file comes out 0600,
two appends land in order, and a symlink at the path is refused (the target stays
empty). Host build 6/6, ONEBIT_EP_NPU build compiles and its QDQ proof passes.
|
CodeQL fixed at the source in
const int fd = ::open(path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0600);
if (fd < 0) return;
FILE* f = ::fdopen(fd, "a");which is exactly what the EP's own trace does ( Checked by running the code, not by reading it: the file comes out Host build 6/6; |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
Head aeec4c1d (one commit over the 1fc0291 I passed READY at: aeec4c1 onnx EP: create the trace file 0600, not 0666, onnx/ep/onebit_ep_kernel.cpp +12 −2). Nothing else in the PR moved and main is still 3d24206, so the earlier evidence stands; this re-review is the delta plus one re-check of the whole gate.
What the commit does: trace_fallback() in onnx/ep/onebit_ep_kernel.cpp replaces fopen(path, "a") with open(path, O_WRONLY|O_CREAT|O_APPEND|O_NOFOLLOW|O_CLOEXEC, 0600) + fdopen, closing the fd on the fdopen failure path.
Verified, in this order:
-
Both lanes compile by hand at the head (CI builds neither NPU lane;
.github/workflows/ci.ymlsets noONEBIT_NPU):
g++-16 -std=c++17 -fsyntax-only -DONEBIT_EP_NPU -I onnx/ep -I npu/ort -I npu/forward/include -isystem /usr/include/onnxruntime onnx/ep/onebit_ep_kernel.cpp→ exit 0
same command without-DONEBIT_EP_NPU→ exit 0.<fcntl.h>sits in the unconditional include block, so the plain build seesO_NOFOLLOWtoo. -
The property itself, old against new, via a harness TU that
#includes the real source and callstrace_fallback()withONEBIT_EP_TRACE=1underumask(0000):created mode planted symlink at /tmp/onebit_ep_trace.<pid>.log1fc0291(before)0666 followed: victim file grew 10 → 24 bytes (probe note written through it) aeec4c1d(after)0600 not followed: victim unchanged at 10 bytes, untouchedSo the alert was real (world-writable under a permissive umask, and a symlink was followed into it), and the new code closes both:
0600cannot be world-writable for any umask, andO_NOFOLLOWfails the open instead of writing through the link. -
The comment's claim is true:
onnx/ep/onebit_ep.cpp:97already opens its trace withO_WRONLY|O_CREAT|O_APPEND|O_CLOEXEC, 0600, so the two writers now agree on the mode. (O_NOFOLLOWis extra on the fallback side, not a disagreement.) -
Swept the rest of the tree for the same class: no
0666-with-umask creation left underonnx/,npu/,tools/,scripts/.tools/moe_trace.cpp:234is already 0644 (CodeQL alert 5, fixed).onnx/ep/onebit_ep_qdq_proof.cpp:548opens/proc/self/clear_refsfor write — procfs, not a file it creates.npu/ort/onebit_npu_gemm.cpp:106creates the device lock 0644 withO_NOFOLLOW, not world-writable.
CI at this head: build SUCCESS, Analyze (c-cpp), Analyze (actions), Analyze (python), Analyze (rust) all SUCCESS, hw_gate SKIPPED, pr_agent CANCELLED (coder-llm is off, expected).
Merge gate: this is READY and tprs-ready is on the PR at aeec4c1d. Nothing merges to engine main before the Sunday release except what the orchestrator says.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
Restatement at the orchestrator's request (relayed as "new head aeec4c1"): this is not a new head. aeec4c1d is exactly the head I passed READY at 22:46:30Z, tprs-ready has been on the PR since then, base main is still 3d24206, and the diff is unchanged (+260 −25, 5 files). Nothing was pushed after that review, so nothing is re-opened and nothing is withdrawn — this line exists only so a waiter keyed to a fresh event fires.
State at this head right now: build SUCCESS; Analyze (c-cpp), Analyze (actions), Analyze (python), Analyze (rust) and CodeQL SUCCESS; hw_gate SKIPPED; pr_agent CANCELLED (coder-llm is off, expected).
The commit in question (aeec4c1 onnx EP: create the trace file 0600, not 0666) was verified by running the property, not by reading it: a harness that #includes the real source and calls trace_fallback() with ONEBIT_EP_TRACE=1 under umask(0000), built against both 1fc0291 and aeec4c1d — before: file created 0666 and a planted symlink at /tmp/onebit_ep_trace.<pid>.log was followed (victim grew 10 -> 24 bytes); after: created 0600 and the symlink refused (O_NOFOLLOW), victim untouched. Both NPU lanes compile by hand (CI builds neither). Full evidence, and the tree-wide sweep for the same class, in the 22:46Z review.
Merge gate is satisfied at aeec4c1d; the orchestrator merges.
… why a device open failed Two things the rebuilt strixhalo box exposed, both in the module the QDQ path runs on: - State holds the device lock as a raw descriptor and had no destructor. ORT unloads the provider library when the Env that loaded it is released, which destroys the static State and re-creates it on the next load; the second incarnation then opened /tmp/1bit-npu-device.lock, flock()ed it, and got EAGAIN from the first incarnation's leaked descriptor -- in the same process. Every MatMul after the first fell back to the CPU while the trace blamed 'another process'. Measured with strace on the QDQ proof: 1 flock = 0, 9 flock = EAGAIN, same pid. With ~State() closing the descriptor, 10 of 10 take the lock and the o_proj case (2048x1024, the shape an ELF exists for) runs on the array: 0 of 5120 int8 values differ, worst |d| = 0 LSB. - init() swallowed the runtime's exception when opening the device, so a 64 MiB MAP_LOCKED heap BO failing EAGAIN on RLIMIT_MEMLOCK=8 MB reported as 'no XRT device (amdxdna) found', which reads as a missing driver. The reason from device 0 is now part of the fallback note.
|
Device leg re-verified on the rebuilt strixhalo box ( 1. The device lock was leaked across provider reloads (mine). Evidence ( With 2. A device-open failure was reported as a missing driver (also mine). a 64 MiB locked heap BO against Gates at Note for whoever runs this next: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
Head correction, because it matters for the merge waiter: the relayed head was aeec4c1, but the PR head is now b30629dd — one new commit since my READY at aeec4c1d:
b30629d npu: give the device lock back when the provider is unloaded, and say why a device open failed
npu/ort/onebit_npu_gemm.cpp | +20 −2
So aeec4c1 had a verdict and this head did not. tprs-ready was still sitting on the unreviewed head, so I removed it, verified, and have put it back on b30629dd — that is the only way a stale label cannot merge code nobody read.
What the new commit does, both parts verified here:
~State()closeslock_fd, so ORT unloading the provider library (which destroys the function-staticStateand re-creates it on the next load) gives the flock back instead of leaking the descriptor. That is the bug it describes, and the mechanism checks out on a scratch file, not the NPU's:No double close: theA open=3 flock=0 (first incarnation takes the lock) B open=4 flock=-1 (second fd, same process: EAGAIN -- the reported failure) after close(A): B flock=0 (closing releases the flock, as the destructor now does)flockfailure path already doesclose(s.lock_fd); s.lock_fd = -1;, and it is the only other close.- The device-open loop keeps the first id's exception (
if (dev_why.empty())), so the real cause is reported instead of the last, nonexistent device'sENOENT.
Compiled by hand at b30629dd — CI builds neither NPU lane (.github/workflows/ci.yml sets no ONEBIT_NPU), both rc 0:
g++-16 -std=c++17 -fsyntax-only -DONEBIT_EP_NPU -I onnx/ep -I npu/ort -I npu/forward/include -isystem /usr/include/onnxruntime onnx/ep/onebit_ep_kernel.cpp
g++-16 -DONEBIT_NPU_RUNTIME_OWN -std=c++26 -O1 -fsyntax-only -isystem $PWD/npu/forward/include -isystem $PWD/npu/forward/engine-src -isystem $PWD/npu -I $PWD/npu/ort -fopenmp npu/ort/onebit_npu_gemm.cpp
The lock's open semantics are unchanged and still match app/npu_device_lock.h (engine#640): same path, O_CREAT|O_RDONLY|O_NOFOLLOW|O_CLOEXEC, 0644.
Not verified by me: that ORT actually unloads the provider library when the Env is released, which is the premise the destructor relies on. Exercising it means taking /tmp/1bit-npu-device.lock on a box where the owner's NPU serve is the holder, which is the one thing my lane rules forbid, so that runtime claim stays the author's measurement (the comment states it: 1 case took the lock, 9 refused, on the QDQ proof graph). The static-destruction path itself is standard, and the flock release on close is now demonstrated above.
Two nits, neither blocking: the first comment line in the error-message hunk starts mid-sentence — // a real cause (a 64 MiB is missing its opening words, so the sentence "…into "no XRT device found", which reads as a missing driver" has no subject; and the destructor releases the lock in its body, i.e. before the xrt::device member is destroyed, where the safer order is device first, lock second. Both are one-line edits for the next time this file is touched.
Merge gate: VERDICT: READY + tprs-ready at b30629dd. Prior evidence at aeec4c1d (the 0600 trace file, before/after with a real 0666 file and a followed symlink) still stands — that diff is untouched by this commit — and main is still 3d24206.
…e docs say the device leg is done (#731) Two things #702 left behind, found while answering pi-ortep's question about which designs our |d| = 0 gate actually covers. The proof configured `full_i8_D_K3072_N1024.elf` in every device gate and no case ever used it, so a defect in that design could only have surfaced on a real model run. New case `quark-d`, 5x3072x1024 -- down_proj's own shape, next to the o_proj one that has had a case since the device leg landed: host: case quark-d: 0 of 5120 int8 values differ, worst |d|=0 LSB, path=fused device: case quark-d: 0 of 5120 int8 values differ, worst |d|=0 LSB, path=fused (ONEBIT_EP_NPU_ELFS=128x2048x1024=…O_K2048_N1024.elf,128x3072x1024=…D_K3072_N1024.elf, no `npu_qdq_fallback k=3072 n=1024` in the trace, so the array ran it) The fused-case floor goes 7 -> 8 so the new case cannot silently stop fusing. docs/onnx-pack.md said "Not done: the device leg of the fused path ... runs on the host today", which stopped being true when #702 merged. It is now a "Works" bullet with its measured gate (0 LSB on both shapes, no fallback line) and its two limits: an ELF is needed per (K, N) and only two Qwen3-0.6B projections have one (56 of 197 MatMuls on a real graph run on the array), and it is a correctness result, not a speed one -- the designs are M=128, so a decode step hands the kernel one real row. The two sentences downstream that leaned on the old bullet are corrected the same way. docs/onnx.md gains the paragraph the QDQ device path was missing: `gemm_q8` beside `gemm_f32`, `ONEBIT_EP_NPU_ELFS`, no `packB`/`amax/127`, the shared epilogue, and the fact that `qdq rows=` is written by both paths so the absence of a fallback line is what makes a run a device run. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…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.
…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.
…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.
…l level (measured; cause not isolated) (#1023) 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. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Last piece of the A8W8 QDQ work: the fused group's multiply runs on the array, with the graph's own int8
bytes and the same epilogue as the host path.
What changed
npu/ortgainsgemm_q8_raw: int8 activations and int8 weights written straight into the kernel'sBOs — no
packB, noamax/127, because a quantised graph's bytes already carry its scales — and exactint32 accumulators back, M rows per launch. It reuses the existing
State, so the device lock is stilltaken once per process, and a QDQ kernel is built on demand for a
(K, N).onnx/ep/onebit_ep_kernel.cpp:gemm_q8tries the device and falls back to the host loop (no ELF forthat shape, busy device, XRT failure). The epilogue — scales, zero points, rounding — is one function
shared by both paths, so a device run and a host run of the same graph cannot differ unless the
accumulator does, and an exact integer accumulator cannot.
quark-ocase at o_proj's real shape (5×2048×1024, rank-3 activation, per-column weightscales) — the shape a device ELF exists for here — so the existing
|d| = 0gate becomes the device gatethe moment an ELF is configured.
Two bugs found by doing this, both fixed here:
ONEBIT_EP_TRACEis a flag, butonebit_ep_kernel.cppread it as a path, soONEBIT_EP_TRACE=1wrote every
npu_fallback/npu_qdq_fallbacknote into a file literally named1. The float NPU path hasdone this since it landed.
trace_fallback()now appends to the EP's real/tmp/onebit_ep_trace.<pid>.log, so "did the device run this shape" is answerable.init_elffails was rebuilt on every call (~50 ms of ELF parse +hw_context); measured,that turned a fallback into 8 decode tokens in 87 s instead of 4 s. Failed shapes are now remembered.
Measured (strixhalo, ORT 1.23.2, via
npu-hold -l qdq, ticket…8728418.qdq)Config:
ONEBIT_EP_NPU_ELFS=128x2048x1024=full_i8_O_K2048_N1024.elf,128x3072x1024=full_i8_D_K3072_N1024.elf— these xclbins are M=128 designs (
npu-infer/docs/q4nx-forward-evidence.md: "the designs are M=128 butonly row 0 carries the token"), not the m16 ones the EP docs name.
Correctness — the gate, and the device really ran it:
The two shapes that have an ELF (2048×1024, 3072×1024) are absent from the fallback list, so they went to
the array;
device-busy=0means nothing was refused for a busy device.The real Quark export through it (197 fused groups,
--basic, 8 tokens):qdq runs=1773, fallbacks=12691773 = 197 × 9forward passes;1269 = 141 × 9— i.e. exactly the 56 groups with an ELF (28 o_proj +28 down_proj) ran on the array and the 141 others (q/k/v/gate/up/lm_head, no ELF here) fell back. Both runs
produced the same generated ids (
279 3146 315 9625 374 279 3146 315), so the device path does notchange the model's output.
Throughput is not claimed here. Both runs took ~7 s/token on a box at load average 9 with
kswapd0active (the same host path does 0.48 s/token quiet): 56/197 groups on the array bought 7 % under that
contention, which says nothing about the kernel. A quiet-box measurement is the next step, and the q/k/v
shapes need m16 ELFs before the device share rises above 28 %.
Not in this PR
~/briefs/TORTEP-STATE.mdwhether they exist on strixhalo and what design M each carries.
I8Ctx::init_elfdoes not validate the design it is given: passing M=16 for one of these M=128 ELFssegfaults in the launch instead of failing, because
init_elfsizes the BOs from the M its caller passes.That is the runtime's contract to check, not the EP's; reported, not worked around.
gemm_q8_rawlives innpu/ort/onebit_npu_gemm.cpp(additive;gemm_f32and the float weight-cache slotuntouched) because two modules in one process cannot both take the device flock; pi-ortep can move it.
Report:
~/briefs/TQDQ-STATE.md.