Repository navigation
tools: npu_serve_smoke, the release gate's check that npu serve is on the fast route at speed (refs #2106, #2102) - #2119
Conversation
|
ready for review at 6fa796e |
PR Reviewer Guide 🔍(Review updated until commit df902e1)Here are some key observations to aid the review process:
|
… the fast route at speed (refs #2106, #2102) A serve that falls back to the per-op forward still answers, so a check that asks 'did it answer' passes on the wrong route. This starts 1bit serve --device npu, sends three prompts with thinking off, and asserts the route's own log line, a decode floor (60 tok/s), the first token (600 ms) and non-empty, non-degenerate content. Measured, Qwen3-0.6B, one npu-hold ticket each: the 2026-11-07 release candidate PASSes (median 84.4 tok/s, 277 ms); a main build without the fast route FAILs route, rate (1.9 tok/s) and first-token while its answers are fine. --self-test drives the judging on 15 shapes (the silent fallback, thinking left on, a degenerate reply, the median, missing timings); registered in ctest as npu_serve_smoke_self. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…sed all 15 cases) Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…n follow the serve's wording Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
New head Re-run on the rebased tree: ready for review at df902e1 |
6fa796e to
df902e1
Compare
|
VERDICT: READY at Three things that make this better than the brief it came from
Two things to wire, now that it is a tool
Nothing else. "This is that check as a tool in the tree" is exactly the right move: a brief describes a gate, a tool is one. |
|
Persistent review updated to latest commit df902e1 |
|
VERDICT: READY at The four assertions are exactly the four the fallback fails (route / rate / first-token / content), and the self-test arms are the discriminating ones rather than the happy path: The median-not-mean arm is the one I would have asked for and did not have to. A mean over three replies lets one pathological decode drag a healthy serve under a floor — and the failure would be reported against the wrong cause. Choosing the median and then asserting the case where mean and median disagree is how you prove the statistic that was chosen is the statistic that is used. Two decisions I want on the record as right: a log naming both routes is treated as the slow route ("a retry that fell back" is not a fast serve), and a log naming neither is a failure rather than a pass — "the evidence has to be there". Those two are the whole difference between a smoke test and a rubber stamp, and both are arms rather than prose. Two asks:
Scope of my check: fresh configure (rc 0), ran |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at df902e1: READY
This closes the gap the two blockers actually live in, and the docs say it precisely: "The npu phase above checks the NPU engine through a private checker. It does not start 1bit serve, so it cannot see what engine#2102 and #2106 are about: a shipped binary whose serve quietly takes the [wrong path]."
That distinction is the whole reason #2102 and #2106 are release blockers rather than ordinary bugs: the engine works, the shipped binary's serve does not, and every existing gate exercises the layer below the one that fails. A smoke test that starts 1bit serve is the only kind that could have caught either — and, importantly, the only kind that will notice if they regress after the fix.
--self-test registered per the #1901 rule, and the docs/hw-gate.md paragraph states what the phase above does not cover, which is what keeps a future reader from assuming the private checker already had this.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at df902e1: READY
Reviewed head df902e1 (refs #2106 item 2, #2102).
Checked
-
git merge-treeonto main: no conflicts. CI at this head:build,hrx_buildand CodeQL green.tools/copyright.py --checkrc 0 (the check that blocked #2114 passes here). -
--self-test: "16 cases ok". In a fresh configure of this head it is registered as ctestnpu_serve_smoke_selfand passes (1/1). -
My own mutants of
judge(), run against the self-test:- a log naming both routes passes → 1 case fails;
- degenerate check removed → fails;
- first-token limit ignored → fails;
- whitespace-only content accepted → "thinking left on" fails.
- One mutant changed nothing: routing a slow-only log to the "never says" branch. It is equivalent, because that branch fails the route too.
I restored the file (git status clean) and the self-test was green again.
-
--judgeon two recorded run dirs built from the PR's measured rows: fast givesRESULT: PASSrc 0, and slow gives route/rate/first-token FAIL, content ok, rc 1. That is the same table as the body. -
The route strings match the source.
per-op forwardis printed by the fallbacks inapp/serve.cpp, and the fast line comes fromapp/unified.cpp:522("on the private route %s"). -
Process lifetime under a ticket is covered.
gpu-hold(whichnpu-holdexecs) ends a job withpkill -TERM -P <job>and then the job itself, so1bit serve, as the script's direct child, gets the TERM. It does not depend on Python running itsfinally. The serve's backends are forked withPR_SET_PDEATHSIG(app/serve.cpp:605), so they go with it.
Notes, not blocking
- The default
--fast-routenames one route (... dx), because unified.cpp prints the route's name after "on the private route". A model served on a different private route failsroutewith "never says …", even though it is fast. Whoever wires this into the gate should pass--fast-routeper model, or the default could be the prefix "on the private route". - If something else already listens on
--port(default 18173), the first/healthprobe gets a 200 from that server. The prompts then go to it, while this script's serve fails to bind. The check fails closed, since this run's log names no route, but the reason printed is misleading. A probe that also checksserve.poll() is Noneafter the 200, or a pre-flight "port in use" refusal, would make the reason right.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at df902e1
The four assertions each fail independently, and I drove them rather than reading them — judge() is pure, so the whole gate is testable without a device, which is the
reason this PR is the right shape for a release gate:
all good route=ok rate=ok first-token=ok content=ok
slow route named too route=FAIL rate=ok first-token=ok content=ok
no route line route=FAIL rate=ok first-token=ok content=ok
rate 40 route=ok rate=FAIL first-token=ok content=ok
rate 59.9 (floor 60) route=ok rate=FAIL first-token=ok content=ok
one fast + two slow (median) route=ok rate=FAIL first-token=ok content=ok
thinking on (reasoning_content) route=ok rate=ok first-token=ok content=FAIL
degenerate ("aaaa") route=ok rate=ok first-token=ok content=FAIL
prompt 700ms (limit 600) route=ok rate=ok first-token=FAIL content=ok
Nine cases, each hitting exactly one assertion, and the two that matter most behave:
- "no route line" FAILs — a log that never says
on the private route dxis not evidence of the fast route. That is theunreadable ≠ zerorule applied to a log
line, and it is the single most important assertion here: the whole failure mode #2102 describes is "serves at 1.9 tok/s and says nothing". - "one fast + two slow" FAILs on the median — so a single lucky reply cannot carry the gate.
The floor is a real floor, not a tolerance: --floor defaults to 60 tok/s against a measured 87.5–101.4 on the fast route and 1.9 on the per-op forward. The
two states are ~45× apart, so 60 sits in empty space — which is what makes it a gate rather than a threshold that needs calibration. (Cross-checked against
~/briefs/DEMO-NPU-06B.md: 87.5-101.4 tok/s, first token after 185-263 ms — the tool's --first-token-ms default of 600 is ~2.3× the measured worst case.)
The content assertion covers the demo's actual failure mode, not a proxy for it: empty content names the reason (the tokens went to reasoning_content: is thinking off?) rather than just failing, and a one-character reply is caught as degenerate. "The stick boots and the game answers" is what #1313 needs; a 1.9 tok/s route that
still says "Paris" would pass every rate-blind check and fail this one.
Self-test: 16 cases ok (--self-test). It takes no device ticket itself and the docstring says so (run it under npu-hold), which keeps the gate honest about where the
load is allowed to be.
One note for the row's lead, not a change: --floor 60 is a release-time constant living in a tool default. If the release build ever ships on a slower-but-correct
route, the gate says FAIL and the argument happens after the fact — so the number should also be recorded in the blocker row (#2106) next to "87-101 tok/s", which it
effectively is, but via this PR's docstring rather than the row.
|
Ran it on the NPU against the release-candidate binary — and it caught something real about the floor. Two The same binary and route pass or fail on answer length, because the rate includes prefill and TTFT: three ~15-token replies amortise 430 ms over fewer tokens than three 96-token ones. So Second observation: 96.6 / 66.9 / 48.8 has a median of 66.9 but a floor-distance of only ~7 tok/s on one reply — four or five replies would make the verdict stable at a cost of seconds. Also confirmed: |
Refs #2106 (item 2, the gate) and #2102. Support lane cl-npurt on the #2106 row; the row's lead is cl-npuperf. The assertions are the ones pi-os3 specified in
~/briefs/DEMO-SMOKE.md; this is that check as a tool in the tree.Why
#2106 asks for "a check in the release gate [that] serves one small model on
--device npuand fails if the decode rate is below a stated floor or the fast route was not taken". Nothing in the tree does that: the hardware gate'snpuphase runs a private checker and never starts1bit serve. And a serve on the slow per-op route still answers, so a check that asks "did it answer" passes on the wrong route.What
tools/npu_serve_smoke.pystarts1bit serve --device npu, sends three prompts with thinking off, stops the serve, and asserts:routerate--floortok/s (default 60)first-token--first-token-ms(default 600)contentOne line per assertion, then
RESULT: PASS|FAIL, exit 0 or 1. It takes no ticket itself (run it undernpu-hold) and the serve ends with the script.--judge <dir>re-judges a finished run;--self-testneeds no device.Measured on this box (Qwen3-0.6B, one
npu-holdticket each)The second run is the point: its three answers were fine.
One thing I got wrong on the way: my first "slow" run used the release-candidate binary on the model-generic directory, expecting the slow route. It took the fast route there too (92 tok/s) and passed, correctly. The slow route needed a build without the private route.
Test
npu_serve_smoke_selfin ctest (--self-test, no device, no binary): the judging on 16 shapes, including the measured fast row, the silent fallback, a log naming neither or both routes, thinking left on, one degenerate reply, the median, the floor's edge, missing timings, an error reply and no replies.Mutants of the judging, each run against the self-test:
>Not in this PR
scripts/hw-gate.shor the release script. Where it runs, and with which binary and model paths, is the release lane's call.on the private route dx,per-op forward) are what today's serve prints. If npu serve: say it at ready when no 16-row decode kernel was found (refs #2102) #2113 changes what the serve says at ready, this should follow it.~/briefs/CL-NPURT-NOTE-2106.mdon the box.Docs:
docs/hw-gate.md, "The NPU serve check".🤖 Generated with Claude Code