Skip to content

Restore an evicted session from SSD, and release near-prefix candidates the lane does not keep - #609

Open
jvmenen wants to merge 5 commits into
youssofal:mainfrom
jvmenen:fix/near-lane-followups
Open

jvmenen wants to merge 5 commits into
youssofal:mainfrom
jvmenen:fix/near-lane-followups

Conversation

@jvmenen

@jvmenen jvmenen commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Why

When a conversation has been evicted from memory to the SSD cache and comes back, MTPLX often does not restore it. It reuses only the system prompt it shares with another conversation still in memory and computes the rest again: about 145 s for a 50K conversation instead of a few seconds. Sometimes the request fails with a 507 instead, because a conversation read from SSD and then refused stays in memory during that long prefill.

How it works, in plain terms

Before the normal restore, a near-prefix lane looks for the best partial match. It asked the SSD tier, read and decoded the conversation's own full state, then refused it, because a full prefix belongs to the exact restore. The next candidate, a neighbour's system prompt in RAM, was accepted and the lane stopped, so the exact restore never ran. This PR changes three things:

  • The SSD tier reports from its manifest, without reading tensors, how long a full prefix it holds; the near lane only accepts a match that beats it, so the exact restore takes over. A hybrid row whose recurrent checkpoint lies below the lane's floor is refused from payload.json, also before reading tensors.
  • The near lane lets go of every candidate it tried or skipped before it starts the prefill, and of an SSD candidate once it has been cloned into the new cache, so neither stays in memory next to the prefill.
  • The Gemma 4 near lane gets the same release.

What changes

Five commits:

  1. An exact prefix on SSD wins over a neighbour's system prompt in RAM.
  2. Refused and untried near candidates are released before the served prefill.
  3. A served SSD candidate is released after the clone, before its suffix prefill.
  4. The same release in the Gemma 4 near lane.
  5. A hybrid SSD row below the caller's floor is refused before decoding (new miss reason ssd_prefix_boundary_below_floor, with a line in CacheExplanation.swift).

Results

Qwen3.8-27B, a 50K and an 80K conversation, eviction to SSD forced by the bank cap, two runs. The turn after eviction restored the 50K conversation from SSD in about 4 s in both runs, where it took 143 to 146 s (or failed) before. Each SSD restore now decodes the state once instead of twice. The release commits are checked by unit tests: refused, untried and served SSD candidates are gone during the prefill.

Tests

tests/test_near_lane_spilled_exact_prefix.py, tests/test_near_lane_releases_refused.py (including the served SSD candidate, checked with weak references), tests/test_gemma4_near_lane_releases.py and tests/test_cold_tier_restore_point_floor.py; each fails without its commit. Full suite passes.

🤖 Generated with Claude Code

Jeroen van Menen added 5 commits October 9, 2026 22:20
…ompt

With no exact prefix in RAM, the near-prefix lane ran first and asked the
SSD tier for its best candidate. For a session the bank had spilled, that
was the session's own entry, a whole prefix of the prompt: the lane
hydrated it, refused it (it serves only matched < prefix_len; whole
prefixes belong to the exact lane), and served another conversation's
checkpoint at the shared system prompt. session_bank.restore(), which reads
the spilled entry back (ssd_clone), never ran.

Measured 2026-10-09 (Qwen3.8-27B, 7 GB bank, two conversations sharing a
4,019-token system prompt): the turn after the spill restored 4,019 of
58,529 tokens and re-read the rest in 143 s, while 57,989 tokens sat on
disk; the hydrated 5.4 GB copy was held through that prefill. After a
restart, with RAM empty, the same turn restored from SSD.

The cold tier now reports the longest whole prefix it holds from the
manifest alone (no tensor read), and the near lane must beat it when RAM
has no exact prefix. Below that floor the cold lookup skips hydration
through the existing min_useful_matched_tokens gate, and the exact lane
serves the turn. If the exact lane declines, the second near-lane call
keeps its floor of 0, as before.
The near-prefix lane iterated over the bank's candidate list and ran the
served candidate's suffix prefill inside that loop, so the list kept every
refused and untried candidate alive through the prefill. A cold-tier
candidate is decoded before the lane can refuse it: on 2026-10-09 a
spilled conversation's whole prefix (5.4 GB) was decoded, refused as out
of range, and held while a neighbour's checkpoint at the shared system
prompt was served and 54K tokens were re-read; the prefill guard then
stopped that prefill under its floor.

The lane now takes each candidate off its list as it tries it and drops
the untried rest before the served candidate's suffix prefill. Once
unreferenced, the decoded buffers go back to the allocator pool, which the
prefill guard already counts as reclaimable.
A near-prefix candidate decoded from the SSD tier is private to the
request: the bank does not keep it. Its restore clones the decoded state
into the new cache up to the restore point, yet the lane kept the
candidate itself until the suffix prefill returned, so a long suffix was
read with the restored prefix in memory twice (or more, when the decode
covered the whole entry).

The lane now drops the candidate once it is served, just before the
suffix prefill. A RAM entry stays with the bank and still gets its hit
and access time after the prefill.
The Gemma 4 near-prefix lane iterated over the bank's candidate list and
ran the served candidate's tail forward inside that loop, so the list
kept every refused and untried candidate alive through it. When nothing
was served, the last candidate tried stayed bound through the cold
prefill. A candidate from the SSD tier is decoded before the lane can
refuse it, so either way a whole decoded entry could sit next to the
prefill.

The lane now takes candidates off its list as it tries them, drops the
untried rest and a served SSD candidate (already cloned into the new
cache) before the tail forward, and lets go of the last candidate before
the cold prefill. Same change as the Qwen lane.
A hybrid entry restores its prefix only at its newest recurrent boundary
at or below the match. The near-prefix lane refuses a candidate whose
boundary does not beat its floor (its exact prefix in RAM, or the spilled
SSD entry the exact lane serves), but the cold tier only received the
floor folded into min_useful_matched_tokens, which compares the match. A
row matching above the floor with its boundary below it was decoded and
then refused.

The boundary is in the row's payload.json, which _restore_row already
reads before any tensor. lookup_prefix_boundary takes min_restore_point,
and a row whose boundary lies below it is refused undecoded; the next
ranked row is tried, as for a row without a boundary. SessionBank passes
the near lane's floor when the tier advertises SUPPORTS_MIN_RESTORE_POINT.
A boundary at the floor itself still decodes: the batched lane serves it.

The app explains the new miss reason with the sentence it shows when
the SSD copy is not better than the one in memory.
@jvmenen
jvmenen requested a review from youssofal as a code owner October 9, 2026 22:11
jvmenen pushed a commit to jvmenen/MTPLX that referenced this pull request Oct 9, 2026
…#608), eviction restore erratic (VONDSTEN 126, youssofal#609), segmented KV round 2 on the production candidate, sustained-profile fix and Mistral (VONDSTEN 122)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jvmenen pushed a commit to jvmenen/MTPLX that referenced this pull request Oct 10, 2026
…tion, segments under any profile); segmented KV round 3

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Vardaan97

Copy link
Copy Markdown

A data point for #608 and #609 together, on Flash-Next. Short version: no errors, the same cache hits as 2.12.2, and fully repeatable outputs.

Build

Setup

  • M5 Max with 128 GB, macOS 27.0.1, Flash-Next Optimized-Speed.
  • --profile turbo --depth 3, context 262144, --max-active-requests 1, MTPLX_ONE_COPY=0, bank 8 GB, MTPLX_MEMORY_LIMIT_BYTES=100G.
  • Workload: 39 requests replayed at temperature 0 from a recorded coding-agent session (dsh, plain /v1/chat/completions, anon sessions). Prompts run from 3,968 to 165,052 tokens.
  • Each run started from a fresh server, one server at a time. Run order: stock, PR, PR, PR, stock.
stock 2.12.2 (2 runs) PR build (3 runs)
requests OK 39/39, 39/39 39/39 ×3
prompt tokens from cache 95.9% 95.9%
restores per run: ssd_block_prefix_boundary_clone 12, 12 12, 12, 12
restores per run: ssd_clone 0, 0 1, 1, 1
median decode, requests ≥300 tokens (tok/s) 68.3, 62.4 73.1, 70.7, 70.2
peak GPU memory in use, system-wide (ioreg, 2 s samples), GB 102.8, 99.4 99.1, 100.9, 100.5

The restore counts are taken from request-log-8080.jsonl over the replayed requests only (38 or 39 of the 39 matched per run), leaving out the launcher's readiness probes. None of the matched requests restored cold in either build.

Repeatability (byte-identical reasoning, content and tool calls)

  • PR build vs itself: 39/39 in all three runs.
  • Stock vs itself: 38/39.
  • PR build vs stock: 18/39 identical, with 29/39 matching tool calls.

So at T=0 the build changes outputs, but it's deterministic for a given request history.

About speed: decode followed run order more than build (the two stock runs were first and last), so I read it as no regression rather than a gain.

I didn't isolate #608 from #609, and with one-copy off this doesn't exercise #619's path. I can run a narrower A/B if that's useful.

@jvmenen

jvmenen commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for running this. On #607 not installing on Flash-Next: that's by design. qwen4_exp already does the same natively since 262592b: during wide prefill it flushes the hidden stream plus the finished group's recurrent states every four layers (MTPLX_QWEN4_PREFILL_MIDLOOP_EVAL, on by default). #607 brings that to the qwen3_5 family, which has no such loop of its own. I'll add a line to the description.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants