Repository navigation
npu: a refused context is queued, not failed — --load-queue-ms (decision 2026-11-05, #1085 item 2) - #1100
Conversation
…ion 2026-11-05, engine #1085 item 2) With the per-client partition fix a second NPU client is refused at context creation, and the refusal carries the SAME signature a dead array answers: `code 102` / `-ENODEV` from `aie2_message.c`. The code alone cannot say which it is — only time can, because a client holding the array frees it when it exits and a dead array never does. So a refusal is now *queued* instead of failed on the first window: app/npu_load_retry.h LoadRetryPolicy gains the refusal schedule (45 s window, 2 s max delay, 60 attempts) beside the existing 2.5 s evict-race window; load_retry_delay/more take the schedule explicitly (the old one-argument forms remain, so existing callers and tests are untouched); open_with_retry classifies EACH failure — a refusal queues on the long window, anything else keeps the short one — the queued retry line says "queued: another NPU client may hold the array", and the give-up hint names both causes in the order to check them (another client first; then the driver reload). app/serve.cpp --load-queue-ms N (default 45000, 0 = refuse at once = pre-#1085 behaviour), parsed at serve level and forwarded into all three `run_unified` argv builders — serve constructs that argv itself, so a flag only unified understood would have been unreachable from `1bit serve`. app/unified.cpp the flag, its validation and help, and one LoadRetryPolicy carrying it into both NPU open sites (private route and fast lane). docs/npu.md "#### A refusal is a queue, not a fault" under "When a load follows an evict": why code 102 is ambiguous, what the budget does, and that it is the load-side counterpart of --queue-ms. tests/ four new cases in npu_load_retry_test: a refusal retries even when window_s = 0 (so the queue window is what acted), a never-clearing refusal stops at the queue cap and names the window and the other client, --load-queue-ms 0 is one attempt, and an ordinary failure still obeys the short policy's cap so a broken model cannot hang a load for the queue budget. Verified (no device, no ticket): ./build/npu_load_retry_test -> PASS (all 10 cases) 1bit serve -m <stub NPU dir> --help -> the --load-queue-ms lines 1bit serve -m <stub NPU dir> --load-queue-ms -5 -> "1bit serve: --load-queue-ms takes 0 (refuse at once) or more, not -5" (before any device work) 1bit serve -m <stub NPU dir> --load-queue-ms 1234 -> reaches open_with_retry; the fake-kernel error is NOT a refusal, so it keeps the short window ("retrying in 150 ms") — the classification, observed build: ONEBIT_NPU=ON -DONEBIT_NPU_RUNTIME=own -DONEBIT_NPU_RT_DIR=<npu_rt>, onebit target -> rc 0, 0 errors, 0 warnings tools/copyright.py --check -> rc 0 Out of scope on purpose: `1bit forward-serve` has no retry path at all (only Lemonade's unified serve does), and the flag is not wired into the appliance TUI. #1085 (the driver half) is still held, so this is the engine's half of the decision and its refusal path is exercised with stubs, not on a patched module.
d790489 to
84fc1b3
Compare
PR Reviewer Guide 🔍(Review updated until commit 95c7773)Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 84fc1b38 — I built and ran the test myself (it is header-only, so no lane build needed), and the one risk this design carries is the one it tests.
The test, run by me:
$ gh api …/app/npu_load_retry.h > npu_load_retry.h ; g++ -std=c++17 tests/npu_load_retry_test.cpp
$ ./a.out
npu_load_retry_test: PASS
Ten cases at this head, and the three that decide whether the design is safe are:
- 8 — a refusal that never clears runs the whole queue budget, then gives up naming both causes;
- 9 —
--load-queue-ms 0is one attempt, i.e. the operator can refuse to queue (this is what makes the default survivable); - 10 — "the queue budget is for refusals only: an ordinary failure still obeys the SHORT policy", with
p.max_attempts = 3andp.refusal_max_attempts = 9set apart deliberately.
Case 10 is the risk I would otherwise have written into the review: a 45 s budget applied to every load failure would make a genuinely broken model take 45 s to fail. Splitting the schedule by npu_contexts_refused() is the correct shape, and the test pins it so a later refactor cannot quietly collapse the two.
The arithmetic closes on the defaults (refusal_window_s = 45.0, refusal_max_delay_s = 2.0, refusal_max_attempts = 60, --load-queue-ms default 45000 in both app/serve.cpp and the in-process run_unified): with a 2 s cap the window is what binds at ~45 s and the 60-attempt cap is a safety net, not the limit — so the two knobs cannot disagree into a longer-than-advertised wait.
The trade this makes, stated so nobody discovers it during a demo: a genuinely dead array now answers "reload the driver" after ~45 s instead of 2.5 s, because the code cannot tell dead from busy — that is the decision's whole premise, not an oversight. What makes it acceptable is that the new hint puts the right first move in front of the operator ("another client may hold the array (wait for it, or stop it); if nothing else is serving, reload the driver"), where the old string led with modprobe -r amdxdna — advice that is actively wrong when the array is merely busy. The ordering of that sentence is the fix.
Also checked: the queue applies on both routes (the --load-queue-ms flag and its help text are in run_unified as well, with the code-102 ambiguity written into the help rather than hidden in a source comment).
Where this sits in the decision: with #1085 (driver, per-client) and lemonade #34 (product, evict-on-second), this is the engine half of item 2 — the part that turns a refusal into a wait. All three read consistently to me; the only piece still unproven anywhere in the trio is hardware evidence for #1085's partition ownership, and #34's router-level row.
…ts are 23 + 4 (not 22 + 3) Two rows in this file had drifted from what the fork actually ships: * **The pin.** `Lemonade (fork) | 27abcc24da99` names the *v2026.43 candidate*, two releases behind what the engine builds. It is now the fork's `main` tip, `e58c9f111`, with the standing caveat that `bump-lemonade` re-pins the submodule to whatever `main` is at cut time, so the row is re-read from `git ls-tree` then rather than trusted. * **The catalog.** "22 HRX entries ... and 3 NPU entries" is the pre-flint, pre-35B count. The fork's `server_models.json` carries **27 onebit rows: 23 non-NPU + 4 NPU** (verified against the file). The paragraph also gains what this pin changed, because a release note that omits it would not describe the shipped behaviour: * the port target is now **`v2026.45.3` (GA, fork #29)**, so the suite evidence for this pin is a run against the *ported* `lemond` — `Ran 31 tests in 110.6s`, `OK (skipped=2)`, measured at load 28.5 with two foreign HRX clients resident; * **an NPU model switch evicts the previous NPU model before the new one starts** (fork #34, the fleet's NPU-exclusivity decision: the array runs one hardware context at a time); * a **refused** context is queued on a budget instead of being declared a dead array (engine #1100, `1bit serve --load-queue-ms`). Counts verified from the fork: 27 onebit rows, 23 with no `onebit_backend: npu` and 4 with it (`Qwen3-0.6B/1.7B/4B-NPU-1bit`, `Qwen3.6-35B-A3B-NPU-1bit`). `tools/copyright.py --check` rc 0.
… port, not a candidate (#1123) * docs(releases): the Lemonade pin is the GA port, and the catalog counts are 23 + 4 (not 22 + 3) Two rows in this file had drifted from what the fork actually ships: * **The pin.** `Lemonade (fork) | 27abcc24da99` names the *v2026.43 candidate*, two releases behind what the engine builds. It is now the fork's `main` tip, `e58c9f111`, with the standing caveat that `bump-lemonade` re-pins the submodule to whatever `main` is at cut time, so the row is re-read from `git ls-tree` then rather than trusted. * **The catalog.** "22 HRX entries ... and 3 NPU entries" is the pre-flint, pre-35B count. The fork's `server_models.json` carries **27 onebit rows: 23 non-NPU + 4 NPU** (verified against the file). The paragraph also gains what this pin changed, because a release note that omits it would not describe the shipped behaviour: * the port target is now **`v2026.45.3` (GA, fork #29)**, so the suite evidence for this pin is a run against the *ported* `lemond` — `Ran 31 tests in 110.6s`, `OK (skipped=2)`, measured at load 28.5 with two foreign HRX clients resident; * **an NPU model switch evicts the previous NPU model before the new one starts** (fork #34, the fleet's NPU-exclusivity decision: the array runs one hardware context at a time); * a **refused** context is queued on a budget instead of being declared a dead array (engine #1100, `1bit serve --load-queue-ms`). Counts verified from the fork: 27 onebit rows, 23 with no `onebit_backend: npu` and 4 with it (`Qwen3-0.6B/1.7B/4B-NPU-1bit`, `Qwen3.6-35B-A3B-NPU-1bit`). `tools/copyright.py --check` rc 0. * docs(releases): the Lemonade pin row follows the bump — f44ff953, the sha #1126 pins The row was written against the fork tip before `bump-lemonade` ran. #1126 pins f44ff953bcd8 (which is e58c9f111 + #36, the LayaClientTest ctest fix), so name that sha and say where it comes from, rather than a tip that moves under the reader. * docs(lemonade): the HRX checklist row is the ported lemond, not the c22 package The row is what a future pin is verified against, and it named `lemonade-onebit-rc-2026-11-03-c22` — a package built before the v2026.45.3 port. Since the port is what ships now, the evidence has to be a run of the ported server: 31/31, 2 skipped, exit 0, `Ran 31 tests in 110.605s`, against `lemond 2026.47.0~1759.f44ff953`, measured at load 28.5 with two foreign HRX clients resident. The row also said "29 / 29 run" against a 31-test suite, which reads as two tests never counted; it is 31. --------- Co-authored-by: agent <agent@local>
|
Persistent review updated to latest commit 95c7773 |
Orchestrator decision 2026-11-05, item 2's engine half: "The engine maps code 102 to the queue (wait, then retry) and only errors after the
--queue-msbudget."The decision's own item 1 and #1085's body pin the signature down: after the per-client partition fix a second client is refused at context creation with
code 102/-ENODEV— which is the same signature a dead array answers (aie2_message.c,aie2_ctx.c). So the code cannot say which it is. Only time can: a client that holds the array frees it when it exits; a dead array never does.What was wrong before
app/npu_load_retry.halready retried a refused context — but on a 2.5 s window, sized for the evict-then-load race (engine #1010). Two seconds is right for "the last backend is still tearing down" and wrong for "another client is serving a model right now": after the 2.5 s the engine declared the driver dead and printed "reload the driver (modprobe -r amdxdna && modprobe amdxdna)", which is the one piece of advice that is wrong when the array is merely busy.What this does
app/npu_load_retry.hLoadRetryPolicygains a refusal schedule beside the race window:refusal_window_s45 s, 2 s max delay, 60 attempts.load_retry_delay()/load_retry_more()take the schedule explicitly (the old one-argument forms are kept, so every existing caller and test is untouched).open_with_retry()now classifies each failure: a refused context queues on the long schedule, anything else keeps the short one. The queued retry line says "queued: another NPU client may hold the array", and the give-up hint names both causes in the order to check them — another client first (wait for it, or stop it), the driver reload second.app/serve.cpp--load-queue-ms N(default 45000,0= refuse at once = pre-#1085 behaviour), validated, in the help, and forwarded into all threerun_unifiedargv builders. That last part matters:servebuilds that argv itself, so a flag onlyunifiedunderstood would have been silently unreachable from1bit serve— which is the only path Lemonade and the appliance use.app/unified.cppLoadRetryPolicycarrying it into both NPU open sites (private route and fast lane).docs/npu.md--queue-ms(a request can be told it is queueing — an empty delta a second — and a load cannot).tests/npu_load_retry_test.cppVerification, device-free (no NPU ticket, no patched module)
The four new test cases are the ones that make the behaviour, not the refactor, the subject:
window_s = 0— proof that the queue window is what acted, not the old one;refusal_max_attempts), and its give-up line names both the window it spent (200 ms queue window) and the other client before the reload advice;--load-queue-ms 0is exactly one attempt;Ordering, and what is not here
1bit forward-servehas no retry path at all (only the unifiedservedoes), and the flag is not wired into the appliance TUI's settings.