Repository navigation
tests: serve_e2e and anchor_rerun take the port serve reports; the census moves with them (refs #1485) - #1545
Conversation
…nsus moves with them Batch 3 of #1485, and this time the census moves in the same commit - that is what #1537's CI failure taught. - `serve_e2e.sh`: `--port 0` and `serve_port_wait`, with a generous budget (12000 tenths) because this one loads a real model before it is ready; its own health window is 600 s. - `anchor_rerun.sh`: same, budget 18000 to match the script's own 30-minute health window, and a serve that never reports a port now names the alias and tails the log instead of continuing to poll a port nobody is listening on. - `_onebit_route_tests` drops `serve_e2e_cpu`/`serve_e2e_hrx`: they no longer pre-pick, so the lock has nothing to serialise. `hrx_cpu_weight_parity` stays and the comment says why - it starts `llama-server` itself, whose port is not ours to choose, which is also why it is not in this migration at all. Checked before touching it: `hrx_cpu_weight_parity` is **not** a `1bit serve` script (it runs `$server`, i.e. `llama-server`), so the ready-line parse would be wrong there. It stays as it is. Rehearsed the census against a stand-in `ctest` built from the new lists: 3/3 cases pass. `bash -n` clean on both scripts; shellcheck warning counts unchanged against main (serve_e2e 2, anchor_rerun 0).
PR Reviewer Guide 🔍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 ed3bbb4f.
Both scripts migrated, and the arithmetic on the budgets — which is the part that decides whether a slow start fails a correct build — checks out for one
and is a 2x margin on the other:
anchor_rerun health wait 3600 x sleep 0.5 = 1800 s = 30 min port budget 18000 tenths = 1800 s exact match
serve_e2e health wait 1200 x sleep 0.5 = 600 s = 10 min port budget 12000 tenths = 1200 s 2x the window
no pick-and-release left in either; --port 0 + serve_port_wait in both; bash -n clean
shellcheck per file identical to main (serve_e2e 2 SC2034, anchor_rerun none)
The one thing to adjust or explain is serve_e2e's 12000. The ready line is printed before the health loop, so a port budget of 1200 s is twice the
600 s the test then waits for health — and a serve that is alive but never ready costs 20 minutes before this row says anything, against the 10 the rest
of the script is willing to spend. If the 2x is deliberate (a cold start on a loaded runner can be slow, and the failure it guards is "no line at all"),
one clause in the comment says so and the number is fine; if it was meant to mirror the health window, 6000 is the number that matches. Not blocking either
way — the margin errs toward waiting rather than failing a good build, and serve_port_wait's kill -0 covers the dead-serve case at once.
And the census moved in the same commit, which is the lesson from #1537 applied rather than repeated —
- … pm_laya_route serve_e2e_cpu serve_e2e_hrx hrx_cpu_weight_parity
+ … pm_laya_route hrx_cpu_weight_parity
with the reason written where the list is ("serve_e2e_ left this list with engine #1485 … it picks nothing and there is no window for the lock to
close"). hrx_cpu_weight_parity staying is right and I checked why: the script runs llama-server itself (3 references), whose port is not ours to
choose, so it is out of the migration and keeps its lock — the distinction the comment draws is the one that decides it.
anchor_rerun was the one I flagged as unconfigured in #1537 — it is still not in CMakeLists, so it is migrated now but still outside the census; when
it is wired in it should be added to _onebit_route_tests? No: it now takes --port 0, so it should not be — it simply needs to be configured, and the
migration means it arrives without a window. That ordering (migrate, then configure) is the right one and worth keeping.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES — one line, and it is the exact hazard this fleet has already paid for once.
serve_e2e.sh leaks the serve process on the failure path it just added. Current order in the file:
44 pid=$!
45 port=$(serve_port_wait "$log" "$pid" 12000) || { echo "FAIL serve never reported a port"; cat "$log"; exit 1; }
46 cleanup() { kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; rm -f "$log"; }
47 trap cleanup EXIT
trap cleanup EXIT is registered two lines after the exit 1 it is supposed to cover, so on the readiness-failure path the trap does not exist yet and the script exits leaving serve running. That is the worst case for the leak, not an edge case: this branch fires when serve never became ready, i.e. while it is still loading a model and holding the GTT its weights need — the 51 GiB orphan class from 11-08 that knocked out the PR-agent LLM. anchor_rerun.sh is safe in this same PR because it kills in the branch (kill "$pid" … ; continue), which is why I think this is an ordering slip rather than a design choice.
Two ways to fix, both one line: hoist cleanup and trap cleanup EXIT above line 44 (so the trap covers every exit after the spawn — my preference, since it also covers a set -e abort anywhere later), or add kill "$pid" inside the failure branch as anchor_rerun does. Either way the property to state is: after pid=$!, every exit path kills the child.
Everything else in the PR is right, and two things are better than right:
>"$log" 2>&1givesserve_port_from_lograw serve output, which is the precondition #1531's parse actually has (it anchors^1bit serve:and indexes$5, so a prefixed log yields nothing and would look like "serve never reported a port"). Both migrations satisfy it, and the failure branch now names the alias and tails the log instead of polling a port nobody is listening on — that is the difference between a 30-minute stall and a readable failure.hrx_cpu_weight_parityis kept, with the reason in the CMake comment — it startsllama-server, whose port is not ours to choose. That is exactly the distinction a mechanical "migrate all the route scripts" sweep would have destroyed silently, and putting the reason next to the list is what stops the next sweep from destroying it.
Also good: the census moving in the same commit (the rule from #1537), shellcheck counted against main rather than against zero (a warning count of 2 is not a regression if main is 2), and the end-to-end runs declared as not possible on this box. And the deferred batch is deferred for the right reason — the remaining laya/mem-admit scripts mix real serves with deliberate-failure runs, where --port 0 would change what the test is asserting (a refusal path needs a port that is genuinely taken, which is the one place free_port() is still correct).
|
Still live at My reason for pressing on one line: this is the same shape as engine#1624, which I fixed in #1629 the same day — there the watchdog was killed before its own kill completed, and here Everything else in the PR still stands approved from my earlier verdict: raw |
…process that holds the memory Review finding on this PR, and it is right: `1bit serve` spawns `llama-server` as a child, and the child is where the model and its GTT live. Live on this box, unchanged all night: 4642 ppid 2510 1bit serve -m … --device hrx 4644 ppid 4642 …/hrx/llama/bin/llama-server -m … --device HRX0 -ngl 99 so `kill "$pid"; wait "$pid"` reaps the wrapper and leaves the only process that matters — the orphan shape from 11-08, one level down, produced by a test's own teardown. My first version ordered the trap correctly and then killed the wrong thing, which is a good reminder that "nothing left to reap" has to be true of the memory holder. `tests/serve_port.sh` gains `serve_descendants`/`serve_kill_tree`: snapshot the tree BEFORE signalling (once the root dies its children are reparented, so a `ps --ppid` walk afterwards finds only the corpse — engine #1624), TERM it, wait on ANY survivor rather than on the root, then KILL the same list. `serve_e2e.sh`'s cleanup calls it. `tests/serve_kill_tree_test.sh` (ctest `serve_kill_tree`) drives it against a synthetic wrapper whose child ignores TERM, so it needs no model and no device. Fails on the kill-only teardown it replaces: $ bash tests/serve_kill_tree_test.sh # helper with serve_kill_tree = kill "$1"; wait "$1" FAIL the descendant (917608) survived — this is the process that would keep the GTT $ bash tests/serve_kill_tree_test.sh # this commit ok the whole tree ended, including a descendant that ignores TERM One thing the test itself got wrong first, worth writing down: it checked `kill -0` immediately after the KILL pass and reported a survivor that was already dead — a SIGKILLed process is a zombie until reaped, and `kill -0` is true for a zombie. It now polls for the pid to disappear (or to reach state Z) instead, because the property is "not running", not "not signalled". `scripts/gpu-hold`'s grace loop has the same zombie-is-alive characteristic when it uses `kill -0` to decide whether to keep waiting; harmless there, since it only makes the wait longer. Refs #1545, #1624.
…server bound (engine#1485) The driver picked its port with the bind(0)-and-release one-liner, so between closing that socket and llama-server binding it any other test on a busy box could take it -- engine#1485's race, which every other serve test has already migrated away from (#1525, #1545). Its own census lists this file as covered by #1637, which until now was true only of the orphan fix and not of the port. It is now the other shape: `--port 0` and read the bound port back. llama-server handles 0 by binding any port (`tools/server/server-http.cpp`) and prints `listening on http://host:port`, so there is no window to lose. `wait_bound_port()` parses that line and the caller gets None -- with the log tail -- if the server dies or never prints it, rather than guessing. `free_port()` is deleted: nothing else in the file used it. Two details the parser had to get right, both measured: the capture is greedy up to the **last** colon and anchored at end-of-line, because the non-greedy form matched the `1` of `http://[::1]:44444`; and a `unix://` ready line matches nothing, which is the correct answer for a socket curl cannot reach. Checked against four synthetic logs (IPv4, IPv6, unix, no line) plus the orphan test.
…server bound (engine#1485) The driver picked its port with the bind(0)-and-release one-liner, so between closing that socket and llama-server binding it any other test on a busy box could take it -- engine#1485's race, which every other serve test has already migrated away from (#1525, #1545). Its own census lists this file as covered by #1637, which until now was true only of the orphan fix and not of the port. It is now the other shape: `--port 0` and read the bound port back. llama-server handles 0 by binding any port (`tools/server/server-http.cpp`) and prints `listening on http://host:port`, so there is no window to lose. `wait_bound_port()` parses that line and the caller gets None -- with the log tail -- if the server dies or never prints it, rather than guessing. `free_port()` is deleted: nothing else in the file used it. Two details the parser had to get right, both measured: the capture is greedy up to the **last** colon and anchored at end-of-line, because the non-greedy form matched the `1` of `http://[::1]:44444`; and a `unix://` ready line matches nothing, which is the correct answer for a socket curl cannot reach. Checked against four synthetic logs (IPv4, IPv6, unix, no line) plus the orphan test.
…failed readiness does not leave serve running (#1653) * serve_e2e: register the cleanup trap before the readiness wait, not after it The branch that fires when serve never reported a port ran `exit 1` two lines above `trap cleanup EXIT`, so on that path the trap did not exist yet and the test exited leaving `serve` running. That branch is the worst case for the leak rather than an edge case: it fires when serve *never became ready*, i.e. while it is still loading a model and holding that model's GTT. I raised this twice on #1545 (which merged without it) and the same shape is engine#1624, fixed the same day in gpu-hold: a teardown that does not cover the failure path is invisible in a green run and only shows up as an orphan. The invariant to state is that after `pid=$!`, every exit path kills the child. `anchor_rerun.sh`, migrated in the same PR, already does this correctly by killing inside its failure branch -- so this is an ordering slip in one of the two scripts rather than a design choice. Verified: `bash -n` clean, and mechanically that the trap registration now precedes every real `exit 1` after the spawn (ignoring comments, since a comment that mentions `exit 1` is not a path -- my first version of that check matched the prose I had just written, which is the same class of mistake). Refs #1545, #1624. * serve: end the whole tree, not the wrapper — `1bit serve` is not the process that holds the memory Review finding on this PR, and it is right: `1bit serve` spawns `llama-server` as a child, and the child is where the model and its GTT live. Live on this box, unchanged all night: 4642 ppid 2510 1bit serve -m … --device hrx 4644 ppid 4642 …/hrx/llama/bin/llama-server -m … --device HRX0 -ngl 99 so `kill "$pid"; wait "$pid"` reaps the wrapper and leaves the only process that matters — the orphan shape from 11-08, one level down, produced by a test's own teardown. My first version ordered the trap correctly and then killed the wrong thing, which is a good reminder that "nothing left to reap" has to be true of the memory holder. `tests/serve_port.sh` gains `serve_descendants`/`serve_kill_tree`: snapshot the tree BEFORE signalling (once the root dies its children are reparented, so a `ps --ppid` walk afterwards finds only the corpse — engine #1624), TERM it, wait on ANY survivor rather than on the root, then KILL the same list. `serve_e2e.sh`'s cleanup calls it. `tests/serve_kill_tree_test.sh` (ctest `serve_kill_tree`) drives it against a synthetic wrapper whose child ignores TERM, so it needs no model and no device. Fails on the kill-only teardown it replaces: $ bash tests/serve_kill_tree_test.sh # helper with serve_kill_tree = kill "$1"; wait "$1" FAIL the descendant (917608) survived — this is the process that would keep the GTT $ bash tests/serve_kill_tree_test.sh # this commit ok the whole tree ended, including a descendant that ignores TERM One thing the test itself got wrong first, worth writing down: it checked `kill -0` immediately after the KILL pass and reported a survivor that was already dead — a SIGKILLed process is a zombie until reaped, and `kill -0` is true for a zombie. It now polls for the pid to disappear (or to reach state Z) instead, because the property is "not running", not "not signalled". `scripts/gpu-hold`'s grace loop has the same zombie-is-alive characteristic when it uses `kill -0` to decide whether to keep waiting; harmless there, since it only makes the wait longer. Refs #1545, #1624. --------- Co-authored-by: agent <agent@local>
…s thread-safe (refs #1624, #698) (#1637) * tests: the handoff driver's provider dies with it too, not just the server #1524 gave the shared-KV driver's *server* PR_SET_PDEATHSIG, so a SIGKILL of the driver cannot leave an HRX decoder holding a context. Its **provider** was still spawned with `subprocess.run(...)` and no such hook -- and that is the one that runs for up to 1800 s on a 16k prompt, so a driver killed inside that window left an orphan with the region mapped. Same class as engine#1624 (kill_tree recomputing descendants after the root died), in this lane's own file. The provider now spawns as `Popen` with `start_new_session=True` and `preexec_fn=die_with_parent`, then `wait(timeout=1800)`; on the deadline it reaps **the group**, which `run()` could not do (it blocks and leaves the child in the driver's process group, which the teardown does not signal). argv, env, `pass_fds` and the redirected log are unchanged, so the provider sees exactly the invocation it saw before. One thing the multi-threaded shape forced: the driver runs a socket-listener thread, so the fork that spawns a child happens in a threaded process, where anything the child does before `exec` that allocates or imports can deadlock on a lock another thread held. `die_with_parent` therefore resolves libc and the signal number once at **import** time and leaves the child a single C call that allocates nothing (it used to `import ctypes` inside the forked child). tests/kvshare_orphan_test.sh gains the source assertion for both spawns -- the functional path needs an HRX server and a model, so a grep is the only device-free way to see that a later edit did not drop one of the two hooks -- and stays at 2.5 s, 4/4 checks. Gate: `kvshare_orphan` 4/4 and `ctest` green (142/142) on this tree at low load; the route-test reds reappear at load 62 and pass in isolation, which is the #1485 contention class and not this diff. The **device** e2e was not re-run: its two inputs (the HRX llama-server and the reference provider) went with the build trees in the 11-08 disk cleanup, and rebuilding them is ~40 minutes of CPU; the invocation is byte-identical apart from the process attributes, so what could differ there is the lifecycle this test already pins. Refs #1624 (the same orphan class), #698 (the gate), #1524 (the server-side half). * tests: the handoff driver stops picking a port and reads the one the server bound (engine#1485) The driver picked its port with the bind(0)-and-release one-liner, so between closing that socket and llama-server binding it any other test on a busy box could take it -- engine#1485's race, which every other serve test has already migrated away from (#1525, #1545). Its own census lists this file as covered by #1637, which until now was true only of the orphan fix and not of the port. It is now the other shape: `--port 0` and read the bound port back. llama-server handles 0 by binding any port (`tools/server/server-http.cpp`) and prints `listening on http://host:port`, so there is no window to lose. `wait_bound_port()` parses that line and the caller gets None -- with the log tail -- if the server dies or never prints it, rather than guessing. `free_port()` is deleted: nothing else in the file used it. Two details the parser had to get right, both measured: the capture is greedy up to the **last** colon and anchored at end-of-line, because the non-greedy form matched the `1` of `http://[::1]:44444`; and a `unix://` ready line matches nothing, which is the correct answer for a socket curl cannot reach. Checked against four synthetic logs (IPv4, IPv6, unix, no line) plus the orphan test. --------- Co-authored-by: t <t@e.com>
…to cover it Two review points from #1621, both about what "ready" means to a caller. 1. **`warming` before the pass, `ready` after it.** A caller polling for `ready on` could not tell "the cache is warming, give it a minute" from "hung". Measured cost is 44.5 s on a folded 27B pack, so the distinction is the difference between a readable log and "serve never reported a port". 2. **`tests/serve_port.sh`'s default budget is now 90 s, not 30.** #1621 moves 44.5 s in front of the ready line, and the helper's `tries=300` x 0.1 s would time out on exactly the configurations these tests exercise, with the message #1531/#1545 spent effort removing. The default carries a comment saying why it is not 30 s, and a caller can still pass its own bound; with (1) in place a short budget now fails next to a `warming` line rather than silently. The breakdown of the 44.5 s is still owed and is the other review point -- the arithmetic in the PR body (112 first touches x ~4.3 ms) does not add up to 44.5 s on the folded route, which the reviewer was right to notice. Measuring it next.
…5 s it was hiding) (#1621) * npu: dump each layer's residual stream so a per-layer cosine is possible at all `NPU_INFER_DUMP_LAYERS` prints mean|x| and absmax per layer, which can tell that a forward drifted but not where: two different bugs with the same mean look identical. This adds `NPU_INFER_DUMP_DIR=<dir>`, which writes layer_<l>.f32 (M x H floats, row-major) at the end of each layer, after the DeepStack add, so the tensor a reference trace compares against exists. Written with plain fopen/fwrite, off unless the variable is set, one stderr line announcing the directory. No behaviour change when it is unset, and nothing in the compute path reads it. First use, and the reason it was worth writing: engine #1416's gate-2 follow-up needs per-layer agreement with a reference trace, and the first comparison (Qwen3-VL-2B text half, 19 rows, our q4nx int8 pack against a TorchLens capture of the same ids/positions/embeddings) runs 0.9912 at layer 0 to 0.9803 at layer 26 -- a smooth quantisation drift with no step change -- which is a result this instrument could not report before. * npu: the profiler dumps for a prefill-only run (it required a decode launch first) Both dump sites were `if (prof_dec_n_ && (prof_dec_n_ + prof_pre_n_) % 100 == 0) prof_dump();`. The guard makes a prefill-only process print nothing at all: no decode launch has happened, so the condition is false however many prefill launches run. That is the case that matters when a request stalls. engine #1605 has an arm where an 8-token request on the pb16i route returns nothing in 420 s, and the run printed no profiler rows -- which cannot be read as "no launches happened", because on that path no dump could have happened either way. A silent instrument that looks like a negative result is worse than no instrument. Verified on the t2s pack (which serves normally): with the guard removed, a request that only prefills prints its rows. [prof] pre D 128 4 0.30 0.21 3.806 0.011 4.328 [prof] pre QKV 128 3 0.13 0.15 2.936 0.018 3.233 [prof] pre QKVFAQ 16 2 0.13 0.20 2.111 0.012 2.457 Four launches per role is far below the 100-launch window, so these rows exist only because the prefill family now counts -- which is the change. * serve: forward-serve warms the weight cache before it says "ready" A first request has to touch every (layer, role) weight once. On the non-folded int8 route that means `q4nx_assemble_B` building the weight matrix in float32 and `packB_into` quantising it, ~112 projections -- measured on this box at **44.5 s** even on the *folded* pack, where each first touch is only ~4.3 ms, because there are 112 of them and the folded route's own first touches add up. Left inside the request it looks like a hang: engine #1605 has an 8-token request returning nothing in 150-420 s, and the "ready on http://..." line is technically true and practically a lie about the next minute. The warm-up runs one four-token batch pass through the same forward before binding is announced, resets the state, and prints what it cost: 1bit forward-serve: warm-up pass 44483.6 ms (weight cache warm; NPU_SERVE_NO_WARMUP=1 to skip) 1bit forward-serve: bonsai2-t2s2-perrole876 ready on http://127.0.0.1:18099 (full ELF) [npu-target=xdna] `NPU_SERVE_NO_WARMUP=1` keeps the previous behaviour, and a failed warm-up is reported and does not stop the server -- the first request then pays it, exactly as before. After it, a 130-token request on the same pack completes in 20 s wall with `[prof] pre` rows at `pack` 0.10-0.27 ms and `wait` 2.9-3.4 ms per launch -- which is what the steady state looks like when nobody is measuring the first touch. * serve: the warm-up's log line names what it does not cover (routed MoE experts) Review point, taken: the warm-up touches the token-invariant weights (`wkey >= 0`, "packed once and reused") and those are the ones a first request reads on a dense pack. Routed-expert weights are `wkey < 0` and are deliberately never cached ("whose weights change per token"), so on a MoE pack the warm-up is not the whole first-touch cost and the 44.5 s measured here will not disappear by the same amount. Said in the log line rather than only in the PR body, because the person who needs it is reading the log of a MoE pack wondering why the warm-up "did not work": 1bit forward-serve: warm-up pass 44740.0 ms (token-invariant weights only -- routed MoE experts are never cached, so a MoE pack still pays per-expert packing; NPU_SERVE_NO_WARMUP=1 to skip) Re-measured after the edit on the same pack: 44.7 s, unchanged. * serve: announce the warm-up, and raise the readiness helper's budget to cover it Two review points from #1621, both about what "ready" means to a caller. 1. **`warming` before the pass, `ready` after it.** A caller polling for `ready on` could not tell "the cache is warming, give it a minute" from "hung". Measured cost is 44.5 s on a folded 27B pack, so the distinction is the difference between a readable log and "serve never reported a port". 2. **`tests/serve_port.sh`'s default budget is now 90 s, not 30.** #1621 moves 44.5 s in front of the ready line, and the helper's `tries=300` x 0.1 s would time out on exactly the configurations these tests exercise, with the message #1531/#1545 spent effort removing. The default carries a comment saying why it is not 30 s, and a caller can still pass its own bound; with (1) in place a short budget now fails next to a `warming` line rather than silently. The breakdown of the 44.5 s is still owed and is the other review point -- the arithmetic in the PR body (112 first touches x ~4.3 ms) does not add up to 44.5 s on the folded route, which the reviewer was right to notice. Measuring it next. --------- Co-authored-by: pi-npufeat <npufeat@strixhalo>
Refs #1485, batch 3. Same rule as #1537, applied in the same commit this time: a migration moves the census with it.
What
tests/serve_e2e.sh:--port 0+serve_port_waitwith a 12000-tenths budget (this one loads a real model before it is ready; its own health window is 600 s).tests/anchor_rerun.sh: same, 18000 tenths to match the script's own 30-minute health window. A serve that never reports a port now names the alias and tails the log, instead of continuing to poll a port nobody is listening on._onebit_route_testsdropsserve_e2e_cpu/serve_e2e_hrx— they no longer pre-pick, so the lock serialises nothing. The census floor is unchanged at 12: those two only exist in model-enabled builds and were never part of the bare-build count.Two things I checked rather than assumed
hrx_cpu_weight_parityis not a1bit servescript. It runs$server— i.e.llama-server— so--port 0and the ready-line parse do not apply to it; it keepsfree_port()and its lock. That is also why the lock-list comment now says so: the next person migrating "all the route scripts" would otherwise break it silently.out=$("$bin" serve … 2>&1)expecting a refusal), so they need individual reading rather than a mechanical--port 0. Deliberately not in this batch — see below.Verification
bash -nclean on both scripts; shellcheck warning counts unchanged against main (serve_e2e 2, anchor_rerun 0) — the measure the review on tests: five more scripts take a port serve reports instead of guessing one (refs #1485) #1537 asked for, counted against main rather than against zero.ctestbuilt from the new lists: 3/3 cases pass.serve_e2eneeds a model and a device,anchor_rerunneeds the anchor models; neither exists on this box. CI runs the census (which is the part of this batch it can run) and will say so loudly if the lists disagree.Remaining in #1485
laya_gguf_route,laya_models_e2e,laya_route_e2e,mem_admit_route,npu_repack_path— all1bit serve, but each mixes real serves with deliberate-failure runs; andkvshare_fd_handoff.py, which is python and spawns serve itself.hrx_cpu_weight_parityis explicitly out (llama-server).