Repository navigation
lanes: an unstarted resumed turn is ranked by its uncached tail - #2276
Conversation
ashhart
left a comment
There was a problem hiding this comment.
Thanks @devopsfortunato. This is a clean fix, and it does what #2256 describes.
We ran it on an M3 Ultra: Flash Next MLX 4-bit MTP, --parallel 2, prompt cache on. A turn resuming a 42.6K-token conversation, with 42,561 tokens cached, was sent 1 s after a cold prompt of about 39.5K tokens. We used two fresh servers for each side and three repeats on each.
| First token | main | this PR |
|---|---|---|
| The resumed turn, beside the cold prompt | 28.4-28.8 s | 5.2-5.4 s |
| The cold prompt | 29.0-29.4 s | 29.7-30.0 s |
| The resumed turn alone | 0.21-0.22 s | 0.21-0.22 s |
Every reply, from the resumed turns and the cold prompts alike, is the same on both sides.
So the resumed turn no longer waits behind the whole cold prompt, and the cold prompt pays about 0.6 s (2%) for it. That's the trade we want. It goes in the landing queue once CI is green on this head.
dffb28b to
9abdd5a
Compare
A job whose prompt pass has not started is ranked by max(cached, reuse.at) instead of cached alone: the prompt-cache hit is known at admission, while cached stays 0 until the backend restores the kept state in the job's first chunk. A long cached history with a short new tail no longer waits behind cold prefills as if it were a whole cold prompt. A rejected restore (reuse_failed) ranks the whole prompt again. The two test harnesses now define the stream fields the ranking reads. The short() class boundary is unchanged.
580401a to
23430fa
Compare
|
Thank you @devopsfortunato. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes. |
What this changes
23430faecarries the branch; at approval time main was09e2f7fa6, later rebases merge lanes: a reply that ends soon goes first beside a long prompt, and 27B on Metal does so by default #2224 and friends underneath)atc overlap --json, after the final rebase (2026-11-05, base36f849362): 4 entries carryconflict: "conflicts"— qwen27: a masked key's non-finite value adds nothing to the rows that must not see it #2106, nemotron metal: one decode attention partial source for its 16 host names (#2251) #2263, gemma4 Metal: Gemma 4 E4B (NVIDIA Nemotron 3.5 Bolt E4B) on M1 to M4, per-layer inputs and shared KV #2273, CI: a pull request reuses main's test passes and C compiles, so it runs only what its diff changed #2274 — and every one of them conflicts onCHANGELOG.mdalone (the shared top-bullet slot under## Unreleased; each touches no other file of this PR). The rest of the 485 entries arecleanorunknown(ATC reportsinvalidPulls,pullFilesLimit,pullFilesUnavailable,timeLimitfor this snapshot). Of theunknownones, the two open PRs that also editlane_host_fill.zig— Clef-Flash: joint-schema decisions on /v1/systemone (HIP) #2048 (movesfillAtinto the file, editsstep) and lanes: a reply that ends soon goes first beside a long prompt, and 27B on Metal does so by default #2224 (editssrptStep) — were diffed by hand: neither touchesremaining()(lines 41-47), so their hunks and this one do not overlap. No rebase is needed for any of them.A prompt that was admitted with a prompt-cache hit but whose pass has not started yet is ranked by its whole prompt length, because
remaining()counted onlystream.cached, which stays 0 until the backend restores the kept state during the job's first prompt chunk. So a resumed agent turn with a 200K-token history and a 2K new tail counted as 200K rows of work and waited behind long cold prefills (up to the 8-round aging bound), and the ≤512-rowshort()rule never applied to it.The admission-time cache hit is already known:
stream.reuse.atis set when the lookup finds the kept state (lane_stream.zig). For a job that has not begun filling and whose restore has not failed,remaining()now counts@max(stream.cached, stream.reuse.at)as done, so the turn is ranked by its uncached tail. If the restore then fails (stream.reuse_failed), the backend prefills from 0, so the whole prompt ranks the job again — a failed restore cannot starve other prompts, and the existing aging bound is untouched. Jobs already filling are unchanged (@max(cached, fill_at)when the backend reports fill progress,cachedotherwise). Deliberately out of scope: theshort()class boundary still usesprompt_len -| cached, so an unstarted resumed turn keeps taking the long-prompt path (aged steps, budget accounting); that is a separate admission path with its own safety argument for a restore that fails mid-pass, and this PR follows the issue's proposal (the ranking estimate) only.A side effect worth stating:
step()'s before/after rows andsrptStep()'sdecodeFirstsample stop charging a resumed turn's restored prefix as computed rows on its first chunk. That removes an overcount ofTENSORFOLD_PREFILL_BUDGETspend and matches howlane_host_load.zigalready sizes the same chunk (start = if (reuse_failed) 0 else @max(cached, hint)).Checks on this head
Head commit:
fd3581485(the maintainer's rebase of this branch onto current main;remaining()was byte-identical on every base along the way, the three Zig hunks never moved, and only the CHANGELOG bullet was re-slotted under## Unreleased, keeping the displaced one). Head23430faeon top is an empty commit pushed only to retrigger CI after a cancelled runner; it carries no content.Lean checker,
python3 tools/zig/lean_check.py, base vs head (the skill'slean_new.pyprocedure): 425 problems on the base, 425 on the head, 0 new, measured against every base this branch sat on (36f849362at 5530 files,09e2f7fa6at 5532; the final rebase's base adds no findings — the changed files are untouched by it).Tests run, with the command and pass, fail and skip counts:
zig build test-lane-api-host --summary all— the real lane host and engine API against the fake backend, no GPU, includes both changed test harnesses: 497 passed, 0 failed.remaining()and this head's tests, the same step is 496 pass, 1 fail — the only failure is the new test ("expected 0, found 1"); with the change, 497/497. So the new test fails before the change and passes after it.zig build test --summary all, full suite, on the pre-rebase tree (basefb8d9fcff, same hunks): 492/494 steps succeeded; 4048 passed, 10 skipped, 1 failed. The single failure isroce_inflight_test "the region is DONTFORK and NOHUGEPAGE before registration":madvise(MADV_NOHUGEPAGE)returns EINVAL on this host's kernel (6.18.50+rpt-rpi-v8, aarch64) for every anonymous mapping — reproduced with a plain pythonctypesprobe on a freshmmap, no TensorFold code involved, at every mapping size — and the same step passed in the pre-change baseline run on this host earlier the same day. Inherited environment problem, not caused by this change.zig fmt --checkon the three changed Zig files andgit diff --check: clean.CI on this head (
23430fae, the branch rebased by the maintainer onto main with lanes: a reply that ends soon goes first beside a long prompt, and 27B on Metal does so by default #2224 and friends merged): all 7 checks pass —zig(11m52s),zig-windows(17m42s),zig-macos-dev-tools(12m3s),fmt,release-paths (ubuntu-24.04),benchmark-clients (ubuntu-latest),benchmark-clients (windows-latest)(run). Earlier heads were green too:dffb28b86passed the same 7 checks (run 42099255802); one intermediate run lostzig-windowsto the runner's 1-hour cap after a cache miss with no test failure in the log — infrastructure, and the rerun on this head passes in 17m42s.Not run, and why: no Metal, CUDA, HIP or XPU device and no checkpoint on this host, and the change touches no kernel, weight, checkpoint or arithmetic path — device and model gates do not apply; the Windows and macOS host suites are CI's jobs.
Receipt
The change reaches inference only as scheduling order: it decides which waiting prompt pass gets the next chunk. It touches no token, sampling, drafting, arithmetic, storage or wire path, and each stream's pass is unchanged, so the per-stream exactness contracts (drafted =
"draft": false, resumed = fresh, concurrent = solo) are order-independent and are pinned by the unchanged suite.fb8d9fcffand rebased base36f849362, headdffb28b86; Zig 0.17.0 per.zig-version; Linux aarch64 host (4 cores, 7 GiB RAM, kernel 6.18.50+rpt-rpi-v8), no GPU, no checkpoint — the lane tests run againstlanes.fake.Fake.zig/src/core/lane_host_fill.zigis the shared prefill scheduler under every backend's lane host (Metal, CUDA, HIP, XPU and host fake alike), so the ranking change reaches all of them; I ran the host suites on Linux aarch64 (above), CI compiles and runs the host suites on Windows and macOS.preference_rounds; a failed restore ranks by the whole prompt again) are pinned by the new test and the existing aging test.Checklist
remaining(), only the new test failing) and pass after (497/497); the test has no missing dependency to skip.For a change that reaches inference:
@max(cached, reuse.at) == cachedthere; aging still bounds every wait.