pool: frozen pages, a token-keyed prefix tree, a host tier; ablated to what kern-serve uses and gated by one GPU e2e - #17
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
kern/crates/kern-serve/src/scheduler.rs
Line 563 in ece29fd
When a parked prefix fits in the currently free pages but the request's full worst allocation does not because active rows hold the remainder, the wake succeeds, then lease_from returns Busy here. make_room can park the newly woken snapshot—the only resident checkpoint—so the next retries wake and park the same multi-GiB prefix repeatedly instead of waiting for an active row to finish, consuming transfer bandwidth and delaying admission. Preserve the woken resident snapshot on this Busy path or reserve the full request capacity before starting the wake.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| plan.pages.push((dev.page, at)); | ||
| let n = Node::new(at, chain.take(), self, ()); | ||
| *lock(&dev.twin) = Arc::downgrade(&n); |
There was a problem hiding this comment.
Publish host twins only after their copy is committed
When callers use the documented two-phase API to reserve several checkpoints from the same runtime, two checkpoints may share a device node: room(cp1) publishes this weak twin before any transfer is queued, so room(cp2) reuses it and omits that page from its copy plan. If the first room is then dropped or refused while the second is parked, the second room keeps the host node alive but no operation ever initializes its bytes, and a later wake silently restores stale memory. The twin must not become reusable until the room that owns its copy is committed, or dependent rooms must retain that copy obligation.
Useful? React with 👍 / 👎.
`Prefix::evict` dropped the coldest parked entries until the host had room, then reported that count only when the resident entry was dropped too; a park that succeeded after dropping lost it, and the scheduler's `host_evictions` counted none of those. Both variants of `Evicted` now carry `dropped`, and the scheduler and the replay add it whichever way the eviction went. `Prefix::remove`'s unread result goes. Found by the design review of PR #17; reproduced first in tests/prefix.rs (`a_full_host_drops_its_coldest…`: the park of `c` after `a` is dropped from the host says `dropped: 1`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
An off-path candidate sharing fewer whole pages than one with the prompt contributes no hit, yet `lookup` restamped it as the most recently used: a query that found nothing made the coldest entry warm, and the next eviction took the wrong one. Only a candidate sharing at least a page is touched now. The stray `pool_of` call keeping an import alive in tests/prefix.rs goes with it. Found by the design review of PR #17; reproduced first in tests/prefix.rs (`a_lookup_that_uses_nothing_touches_nothing`: after two lookups sharing nothing and one token with `b`, `b` is still the coldest). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
gen.py handed prefill and decode the same `fold` call, its rows bound to the `tokens` var: in a decode batch that is the batch's row count, so every group folded every other group's row into its own line. One request at a time never showed it (one row is one group); twelve at once gave twelve wrong answers, which the e2e reported as "12 finished, 0 identical to conc1" and did not gate. Found by the design review of PR #17; reproduced first by gating `concurrent` under the exact oracle (tray06, `FAIL: concurrent`, 0/12 on toy-stateful), passing with the fold taking a literal 1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
…act oracle can The design review of PR #17 read the driver's verdicts and found what each let through; every one is now a pure function with a test in tools/e2e/test_e2e.py (run by CI) that failed before the fix: - A near-tie excused the whole answer: nothing after the first divergence was compared. `Same` now excuses that token alone, takes the oracle's continuation from the server's choice and goes on comparing, at most EXCUSED_MAX times per answer. On qwen3-4b a repeat answer's token 46 (two logits equal at 21.5) is excused and the remaining 18 match. - The bf16 ulp was `int(x).bit_length()`, wrong below 0.5; `ulp_of` is `math.frexp`'s exponent. - `concurrent` reported identity and gated only completion: under an exact oracle it is identity (which is what showed the toy's fold bug); under `kern run` the count stays a report. - `turn2_hit` excused as many misses as there were `not kept` lines in the whole log; a miss now pairs with its own first turns' request ids (the frontend numbers them in arrival order, the driver counts along). - `spec_acceptance` was a fixed 20%; it is half of the rate one request at a time, inside the bounds an exact oracle states. - A single-rank target without an oracle degraded to a report; it fails. With `--reference`, `kern run` on the spare GPU is itself held to the reference (`run_equals_reference`). - A run in which every target was skipped exited 0; `summarize` returns nonzero when nothing ran. Gates: toy (tray06, GPUs 0,1) five of five; qwen3-4b (tray06, GPUs 2,3) nine of nine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
A review found that a checkpoint taken inside a page shared that page with the lease that kept writing it, and the host tier, deduplicating parked pages by device node, reused the stale copy. That was a missing invariant, not a typo: nothing in the types said a chain's page is frozen. Now it is. A `Node`'s page is never written after the node exists: whole pages are shared through the chain, the page a checkpoint ends inside is copied for it, and the only writer of any page is a live lease into its own pages (`docs/pool.md`). The accounting is one set of types over both tiers. `Storage` is the sealed trait `Pool` and `Host` implement, `Store<T>` a prefix's bytes in one of them (`Checkpoint = Store<Pool>`, `Parked = Store<Host>`), held by value: a clone is another holder, the last drop returns what nobody else holds. A device node keeps a `Weak` twin of its host copy, so parking a session again copies only the new pages, and a woken checkpoint is born twinned and parks again for free. `wake` gives a resident `Checkpoint` back rather than a lease: the scheduler indexes it and continues from it like any other hit, so several requests can share a woken prefix. Nothing crosses a module boundary as an id; `Chain`, `Checkpoint::nodes()`, `Park` and `Pool::wake` are gone. The prefix index is a radix tree over tokens. Entries own their stores, so the tree branches at any token while no page is ever split; a `Hit` holds a clone of what it found and survives whatever `make_room` does next. A lookup takes the longest usable entry, a resident one first at equal length, counting stateless entries off the prompt's path through per-node subtree counts. kern-serve's stats line and the AgentX replay report hits by tier (`resident_hits`, `host_hits` and their tokens), so a host tier that is never hit is visible. Gate: kern-pool's property tests (byte-level lease / checkpoint / restore / fork / retire against a position model, park / wake bytes and host partition, the index against a linear scan run twice) pass; the old `checkpoint` fails the first one. AgentX replay in the qwen3.8-27b shape (page 784, 147 MiB slot, 250 GiB, concurrency 32): 93.1% without a host tier, unchanged; with 512 GiB 95.6% -> 96.4%, p99 extend 208k -> 130k, 1,276 requests woken from the host (~/bench_results/2026-09-14-pool-radix-replay). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
…hat it hands out
Trimmed against the one consumer, kern-serve: the make-room loop the
scheduler and agentx_replay each carried (coldest resident → park, drop
the coldest parked until it fits, else drop) is `Prefix::evict`, which
names the entry it took (`Evicted::Parked(key)` / `Dropped { key,
parked }`); `coldest`, `park` and `remove` are private, `Hit::tier /
resident / parked`, `Prefix::resident / parked / is_empty`, `Store::pages
/ paged`, `Host::bytes / pages / page_bytes / slot_bytes`,
`Parked::slot_offset`, `Remap::is_empty`, `Waking::tokens` and
`Tray::fork` had no caller outside tests and are gone; `Pooled::objects`
is private.
The three `mod tests` that read the ledger (chunks, the pool's statuses,
the tree's edges) are gone with the `cfg(test)` accessors they needed.
What they checked is checked one level up, through the public API: the
pool property tests land every planned `Remap` into a model of the
chunks (a chunk is free or at one position, whatever a handle names is
mapped whole, the counters say what the plans said), the prefix model
learns what `evict` took from the key it names, and `runs` is an
integration test. kern-pool: 40 tests, no GPU.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB
Signed-off-by: JinYan Su <751080330@qq.com>
The e2e driver compares a served answer with kern run's on the same tokens; a prompt given as text tokenizes the same on both sides only until an answer's text is fed back (it need not tokenize to its ids). Given ids, the oracle runs exactly what the server ran. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
A speculative round accepts tokens the client never gets: past max_tokens, or past the stop token. With a recurrent state they are in the sequence's state, and the request-end snapshot keyed by them is a prefix no next turn sends; it held a slot until evicted and never hit (e2e turn2 on DSv4.1: 2 of 4 misses, dflash2: 1 of 4). Such a state now returns to the pool at once, with a debug line; a state ending exactly at the stop token is kept, a chat's next turn ends the answer with it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
The scheduler thread unwinding left the port open and every request hanging until the client's timeout; the e2e driver sat 15 minutes on one. The process exits 101 instead, the panic message already printed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
One driver, one kern.toml, every target through the same scenarios over the binaries a user has: kern test, conc1 against kern run, the hits the checkpoints allow (repeat, turn2 as ids), warm against kern run and against a cold server, twelve at once, a client hanging up mid-stream, a small pool with a host tier filled until the server parks, a stateful manifest growing its slots from two, a speculative manifest at --rows 1. Byte identity gates; a divergence on a single-rank target is excused only by the logits of that very step (kern run --prompt-ids over the shared context, both tokens within 4 ULPs of the top), and a tray target reports what crossed numerical paths instead of gating it. Gate on 2026-09-14 (tray06 / tray07 / tray09): qwen3-4b, its dspark draft, qwen3.8-27b, its dflash2 draft, DeepSeek-V4.1-Flash EP4 and Kimi-K3 4 layers EP4 all pass; numbers in docs/serve.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
A manifest that is not a model: each token slot holds 64 marks of its token and position, the next token is decided by the sum over every word of every slot the sequence holds (plus a line's ordered fold for a per-sequence state), a speculative round chains its own rows and takes 1 + S % rows of them. tools/toy/model.py is the same arithmetic, so a served answer has one right value and a page served stale, a slot remapped wrong or a line restored from the wrong copy is a wrong token, not a near-tie. gen.py builds the cubin (256 and 128 threads, the pair kern test A/Bs), six manifests (paged, stateful, spec, stateful spec, a 64 KiB/token big one), a byte-level tokenizer, the salt weight and a kern.toml. kern run agrees with the reference on every one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
--reference <module.py> makes a Python generate(ids, max_tokens, manifest) the oracle for every target whatever its ranks; it excuses no divergence. The kern run oracle now replays ids (--prompt-ids) for every answer, as it already did for turn2 and the probe, so one code path feeds both oracles. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
`--capacity <tokens>` became chunks (one per state at least) and the pool made every page the chunks hold: with the 64 MiB chunk of bb50189, `--capacity 2832` on a 4 KiB/token manifest gave 1023 pages, not 177, and the toy gate's small pool parked nothing. Pool::new takes the tokens asked for and makes that many pages at most; the chunk's tail stays empty, as a chunk is whole and a capacity is not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
Five toy targets through the whole scenario set on one GB300 in under three minutes, every gate exact, including the ones a real model can only report; the page-cap regression it caught. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
A node's page is never written after the node exists. Every other holder was already kept out by the types (a node's page id is private, a restored lease starts past its prefix); the lease that moved the page into a chain was kept out by convention only, `slot(pos)` checking the prefix and not the pages shared since. It now refuses those too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
`Prefix::evict` dropped the coldest parked entries until the host had room, then reported that count only when the resident entry was dropped too; a park that succeeded after dropping lost it, and the scheduler's `host_evictions` counted none of those. Both variants of `Evicted` now carry `dropped`, and the scheduler and the replay add it whichever way the eviction went. `Prefix::remove`'s unread result goes. Found by the design review of PR #17; reproduced first in tests/prefix.rs (`a_full_host_drops_its_coldest…`: the park of `c` after `a` is dropped from the host says `dropped: 1`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
An off-path candidate sharing fewer whole pages than one with the prompt contributes no hit, yet `lookup` restamped it as the most recently used: a query that found nothing made the coldest entry warm, and the next eviction took the wrong one. Only a candidate sharing at least a page is touched now. The stray `pool_of` call keeping an import alive in tests/prefix.rs goes with it. Found by the design review of PR #17; reproduced first in tests/prefix.rs (`a_lookup_that_uses_nothing_touches_nothing`: after two lookups sharing nothing and one token with `b`, `b` is still the coldest). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
`lease.slots(0..pos)` names positions inside pages the lease sealed by checkpointing, which `Lease::slot` refuses since a377c67; the harness only wants the pages' bytes, and `page_ids()` is the read-side accessor for exactly that. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
The perf sweep's points went up to the manifest's `tokens.max`; the test leases `--capacity` positions (4096 by default), so a manifest allowing wider chunks (toy-big: 8192) staged past the lease and panicked. No real model's `tokens.max` exceeded 4096, which is why the toy found it the day toy-big got a `-ref` manifest and `kern test` ran on it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
gen.py handed prefill and decode the same `fold` call, its rows bound to the `tokens` var: in a decode batch that is the batch's row count, so every group folded every other group's row into its own line. One request at a time never showed it (one row is one group); twelve at once gave twelve wrong answers, which the e2e reported as "12 finished, 0 identical to conc1" and did not gate. Found by the design review of PR #17; reproduced first by gating `concurrent` under the exact oracle (tray06, `FAIL: concurrent`, 0/12 on toy-stateful), passing with the fold taking a literal 1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
… order The kernels touched 512 B of a 4 KiB (or 64 KiB) slot and 16 B of a 1 MiB line, so a page copied short, a line restored at the wrong stride or a slot remapped by half went unseen; and the sum over positions was commutative, so a page table listing two pages swapped read the same. A slot's tail is now a function of its head and a line's of its carry, written whole and checked whole by the reader (a mismatch poisons the sum), and every head word is rotated by its position before summing, so the order of pages is in the answer. The reference computes the head sum only; the tails are the kernels' own check and cost it nothing. Every shape now has a `-ref` manifest (the 128-thread build), so `kern test` holds every toy kernel to its twin, not only the paged one; this is what found kern test's sweep past the lease on toy-big. The model states its expected acceptance rate (`ACCEPT_PCT`, half the rows) for the e2e to hold the count path to. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
…act oracle can The design review of PR #17 read the driver's verdicts and found what each let through; every one is now a pure function with a test in tools/e2e/test_e2e.py (run by CI) that failed before the fix: - A near-tie excused the whole answer: nothing after the first divergence was compared. `Same` now excuses that token alone, takes the oracle's continuation from the server's choice and goes on comparing, at most EXCUSED_MAX times per answer. On qwen3-4b a repeat answer's token 46 (two logits equal at 21.5) is excused and the remaining 18 match. - The bf16 ulp was `int(x).bit_length()`, wrong below 0.5; `ulp_of` is `math.frexp`'s exponent. - `concurrent` reported identity and gated only completion: under an exact oracle it is identity (which is what showed the toy's fold bug); under `kern run` the count stays a report. - `turn2_hit` excused as many misses as there were `not kept` lines in the whole log; a miss now pairs with its own first turns' request ids (the frontend numbers them in arrival order, the driver counts along). - `spec_acceptance` was a fixed 20%; it is half of the rate one request at a time, inside the bounds an exact oracle states. - A single-rank target without an oracle degraded to a report; it fails. With `--reference`, `kern run` on the spare GPU is itself held to the reference (`run_equals_reference`). - A run in which every target was skipped exited 0; `summarize` returns nonzero when nothing ran. Gates: toy (tray06, GPUs 0,1) five of five; qwen3-4b (tray06, GPUs 2,3) nine of nine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
… when its port answers Two races in the driver, both found by running the gate twice: the small-pool fill stopped at "four `parked` lines in the log", and whether the fifth (the checkpoint turn2 hits) had been written by the time the log was read decided wakes=1 or wakes=0; and `serving` is logged before pegainfer binds the port, so a fast-loading model (qwen3-4b, 0.7 s) got its first request refused once. The fill now runs until every checkpoint turn2 hits (the lengths the warm turn2 got) has parked, or the cap; a server is ready when `/v1/models` answers. Gates: toy five of five (tray06, 189 s), qwen3-4b nine of nine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
… serve.md records the TDD round pool.md: `slot(pos)` refuses the sealed pages, `Pool::new`'s `tokens` caps the pages whatever the chunks hold. runtime.md: the same rule at the lease. serve.md: the toy section's first table claimed five targets identical while `concurrent` was not gated and toy-stateful's twelve concurrent answers were wrong; the section now says what the review found, what each test reproduced, and the round of 2026-09-15 on tray06. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
…e over the same prompts Half was slack for the wrong reason: the serial baseline was one 5 s window, whichever held the most steps, so it covered a few prompts while the burst covered all twelve, and acceptance differs by prompt more than by load. The baseline is now every serial window weighted by its steps (the same twelve prompts), and a batch only changes reduction order, not what a sequence accepts, so the burst must reach 0.9 of it. Measured: toy-spec 50 vs 49.3, toy-stateful-spec 50 vs 51.6, qwen3-4b-dspark (7 rows) 24 vs 25.0; all pass (tray06, 2026-09-15). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
…ke to one park A pool holding exactly one worst-case second turn (plus the pad's page), `--host-gib 2`: the first turn runs, its second turn follows at once. A stateful manifest's checkpoint ends inside a page, so the continuation is one page more than the pool: the checkpoint must park, and the turn must wake it into its own row and finish within 120 s with the warm answer, at most 4 `parked` lines. A paged-only checkpoint ends at a page and continues in place. Red on the previous kern-serve: DSv4.1 EP4's host session on tray06 (`--chunk 128 --max-seqs 16`) hung in park_wake's second turn with 2.36M `parked tokens=86` lines; toy-stateful under this scenario, 6,075,401 lines (`~/bench_results/2026-09-15-toy-e2e/results/tray06-r12-wake-red`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
`Host::restore(&Parked, &Pool, len, tokens)`, the shape of `Pool::restore`: the pages `tokens` need taken once, the first ceil(len/unit) copied in from the host, the whole ones sealed as nodes twinned with their host pages (the next checkpoint parks them for free), the half page the lease's own. `Runtime::wake(p, len, tokens)` and `awake` carry a `Lease`; `Tray::wake` a `Rising` that lands as a `Row`; the scheduler admits a landed wake at once. The parked entry stays in the index; the request's own finish indexes the longer context. Gone: `Host::wake`, the woken snapshot's insert and re-queue, the scheduler's `woken` list. Why: waking a checkpoint and then leasing from it decided room twice. DSv4.1 EP4's second turn hit a parked entry whose woken pages, half-page copy and continuation were one page more than the pool; the only resident `make_room` could park was the one just woken, so wake and park alternated without end (2.36M `parked` lines; toy-stateful under e2e's wake_room, 6.08M). Gate: kern-pool tests (host.rs rewritten around restore, the property test restores from either tier); toy gate five of five, qwen3-4b and -dspark all pass, DSv4.1 EP4 on tray06 8 gated all pass — wake_room parks once and wakes into a 2-page lease (`~/bench_results/2026-09-15-toy-e2e/results/tray03-r14-wake-green`, `2026-09-14-e2e-gate/results/tray06-r9-dsv41-wake`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
`Tray::max_seq_tokens` is the pool's row cap within the pages beside the rank's pad, and a request whose worst case is over it is rejected (ContextLength) before any lease. Before, `max_request_tokens` counted the pad's page: a request filling the pool exactly was neither rejected nor ever seated — wake_room on every target waited its 120 s out with one `parked` line (`~/bench_results/2026-09-15-toy-e2e/results/tray03-r13-wake-green`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB Signed-off-by: JinYan Su <751080330@qq.com>
Why
A review found a P1 in the host tier: a checkpoint taken inside a page shared that page with the lease that kept writing it, and
Host::park, deduplicating parked pages by device node id, reused the stale copy. It was a missing invariant, not a typo: nothing in the types said a chain's page is frozen. This PR puts the invariant into the types, rebuilds the index and the host tier on top of it, then cuts the crate back to what kern-serve uses and closes the gate with one GPU e2e over every target. Design record:docs/pool.md(validated against Dynamo KVBM / kv_router and SGLang's radix caches).Pool
Node's page is never written after the node exists. Whole pages are shared through the chain, the page acheckpointends inside is copied for it, and the only writer of any page is a live lease into its own pages.restoreandforkalready did this;checkpointwas the exception.Storage(sealed;PoolandHost),Store<T>= a prefix's bytes in one tier,Checkpoint = Store<Pool>,Parked = Store<Host>. Value semantics: a clone is another holder, the last drop returns what nobody else holds. No ids cross a module boundary.Weakto its host copy: parking a session again copies only the new pages, and a woken checkpoint is born twinned and parks again for free (the lessons.md gap).Host::restore(&Parked, &Pool, len, tokens)has the shape ofPool::restore: the pages the request needs taken once, the parked prefix copied into them (whole pages sealed and twinned, so the next park is free), and the scheduler admits the landed row at once. The parked entry stays in the index; the request's finish indexes the longer context.Hitholds a clone and survivesmake_room. Longest usable entry wins, resident first at equal length.Prefixmakes room itself (evict(park)→Evicted::Parked/Dropped);coldest/park/removeare no longer public,Storeexposestokens/has_slotonly,Hostis built from bytes,Waking::tokensandTray::forkare gone. Nomod tests: the accounting is tested through what it hands out (tests/pool.rsreferenceMappingmodel landingRemapplans,tests/prefix.rsbrute-force model learning tiers fromEvicted,tests/host.rs).resident_hits/resident_hit_tokens/host_hits/host_hit_tokens.One GPU e2e over the four spec families
tools/e2e/e2e.pydrives every target of a kern.toml through kern-serve the way a client does (README in the dir):kern test, conc1 vskern run, repeat / turn2 hits, warm vskern runand vs a cold server, twelve at once, a client hanging up mid-stream, a small pool with a host tier filled until the server parks, slot growth from two slots,--rows 1. Byte identity gates; a divergence on a single-rank target is excused only by the logits of that step (kern run --prompt-ids <shared context> --rows 1 --probe-dir, both tokens within 4 ULPs of the top); tray targets report cross-path identity instead of gating it.Writing the scenarios found three interface problems, fixed here:
kern run --prompt-ids: an answer's text need not tokenize back to its ids, so turn2 is sent as ids and the oracle replays them.max_tokensor the stop left a request-end snapshot keyed by tokens no next turn sends (DSv4.1 2/4, dflash2 1/4 turn2 misses, a slot held until evicted). Not kept any more (not keptdebug line); a state ending exactly at the stop token is.A toy gate over the same path, exact, in three minutes
tools/toy: manifests that are not models. Each token slot holds 64 marks of its token and position; the next token is the sum over every word of every slot the sequence holds (plus a line's ordered fold for a per-sequence state); a speculative round chains its own rows and takes1 + S % rows.tools/toy/model.pyis the same arithmetic, soe2e.py --reference tools/toy/model.pygates every scenario token-exactly, including warm vs cold and woken vs cold that a real model can only report. Six manifests (paged, stateful, spec, stateful spec, a 64 KiB/token 281 GiB one, and a 128-thread build askern test's A): all pass on one GB300 in 2 min 54 s with 256 tokens per request. On its first day it caught a regression: master's 64 MiB chunk cap (bb50189) turned--capacity <tokens>into whole chunks (2832 tokens → 1023 pages, the small-pool scenario parked nothing), invisible on real models whose pages exceed a chunk;Pool::newnow takes the tokens asked for and caps the pages. (The kern run / kern-serve disagreement on a weights file's checkpoint directory it also hit was fixed on master the same day.)The review, fixed test-first
An Opus design review of this PR read ten confirmed defects. Each got a test that failed on the code as it was, then the fix (commit per finding):
concurrentwas not a gate, and under the exact oracle toy-stateful's twelve concurrent answers were all wrong: gen.py bound the decodefold's rows to the batch'stokens, so every group folded the others' rows into its line. Gated →FAIL: concurrent0/12 → decode folds a literal 1.Samenow excuses that token alone, takes the oracle's continuation from the server's token and compares on (≤ 3 per answer); qwen3-4b's repeat answer has an exact tie at token 46 (two logits at 21.5), excused, the remaining 18 match. The bf16 ulp isfrexp's exponent, notint(x).bit_length().turn2_hitpaired misses with a count ofnot keptlines; a miss now pairs with its own first turns' request ids.spec_acceptancewas a fixed 20%; it is half of the serial rate, inside the bounds an exact oracle states (toy: 40–60). A single-rank target with no oracle fails instead of reporting; all-skipped exits nonzero; with--reference,kern runon the spare GPU is itself held to the reference. The verdicts are pure functions,tools/e2e/test_e2e.py, run by CI.Prefix::evict's successful park lost the count of parked entries dropped for the room (host_evictionsundercounted);lookuprestamped an off-path candidate sharing no whole page, so a query that found nothing made the coldest entry warm. Both reproduced intests/prefix.rsfirst.-refforkern test. That foundkern test's prefill sweep going totokens.max(8192) past the 4096-token lease, and the k3 golden harness reading slots a lease had sealed.parkedlines" and a log-read race decided whether turn2 woke anything;servingis logged before pegainfer binds the port. The fill runs until the checkpoints turn2 hits have parked; ready is when the port answers.Toy gate 2026-09-15 tray06 (two GPUs): five of five in 3 min 09 s. qwen3-4b same day: nine of nine. Docs:
docs/serve.md"toy 门禁",docs/pool.md(sealed rule,Pool::new(.., tokens)).The pre-merge DSv4.1 run found a livelock, fixed the same way
DSv4.1 EP4 on tray06 (
--chunk 128 --max-seqs 16) hung in the host session's second turn: 2.36Mparked tokens=86lines. The wake path decided room twice — wake the checkpoint (one allocation), index it, lease from it (another) — and when the woken pages plus the half page's copy plus the continuation were one page more than the pool, the only residentmake_roomcould park was the one just woken. A new e2e scenario,wake_room(a pool sized for exactly one worst-case second turn), reproduces it on toy-stateful in 30 s (6.08M lines), then:Host::wakeand the scheduler'swokenlist are gone.max_request_tokenscounted the pad's page — a request filling the pool exactly was neither rejected nor ever seated.Tray::max_seq_tokensleaves the pad its page and an over-cap request is a ContextLength reject before any lease.Rerun: toy five of five (stateful targets park once and wake into the row), qwen3-4b / -dspark all pass, DSv4.1 EP4 8 gated all pass (
wake_roomparks=1, woken into a 2-page lease).docs/lessons.md: "一个请求的房间只问一次".Gate
cargo test -p kern-pool: property tests against reference models, green; workspace tests, clippy, fmt, hawk green on the host; kern-serve builds, tests and clippy clean in the kernel-lab container.docs/serve.md"e2e 门禁". Toy gate 2026-09-15 (tray06, two GPUs, after the review round): five targets, all pass; qwen3-4b under the same rules, all pass.Open, noted in serve.md: K3 4-layer at the default budget is 131k chunks of 2 MiB (chunk = half the smallest object), 40–60 s of mapping per rank and one
cuMemSetAccessOOM — the chunk should scale with the budget. The review's P2 (rebalanceover-counting shared chunks) is orthogonal and still open.🤖 Generated with Claude Code
https://claude.ai/code/session_01G6hPjvcgRZcdFcymLwFchB