Repository navigation
feat(server): supersede session snapshot chains and expose the budget - #1151
Conversation
The exact-prefix snapshot store had a fixed 512 MiB budget and no way to change it. One conversation snapshot for a 31B-class model runs 300-370 MB, so a second turn's insert LRU-evicted the first turn's snapshot instead of the store recognizing that the longer snapshot had made the shorter one unreachable, and two concurrent conversations evicted each other. Two independent fixes, both needed. Session-chain supersede in `insert_snapshot`. When the incoming token vector strictly extends a stored vector from the same session and the same model/lora/template/multimodal bucket, the stored ancestor is removed explicitly at insert time. The removal runs before the new entry's bytes are accounted, so `enforce_snapshot_caps` sees the freed bytes and can admit an extension that would not have fit on top of its own ancestor. The rule is deliberately narrow: a `None` session key carries no conversation identity and never triggers it, two different session keys never touch each other, and same-session-but-diverging vectors are left alone. The case it gives up is a mid-conversation fork, where the dropped ancestor would still have matched and now costs one re-prefill; that is the price of holding a conversation's steady state at one entry. Superseded entries are counted in a new `snapshot_supersedes` stat rather than folded into `snapshot_evictions_lru`, so capacity tuning can still tell deterministic replacement from real budget pressure. `/v1/cache/stats` surfaces the new counter. CLI and env wiring for the three snapshot knobs. `PromptCacheConfig` already carried `snapshot_capacity_bytes`, `snapshot_max_entries`, and `snapshot_ttl` with a `with_snapshot_limits` builder that nothing called. They are now settable through `--prompt-cache-snapshot-capacity-bytes`, `--prompt-cache-snapshot-max-entries`, and `--prompt-cache-snapshot-ttl` on both `mlxcel serve` and `mlxcel-server`, with `MLXCEL_PROMPT_CACHE_SNAPSHOT_*` env fallbacks following the same CLI-beats-env precedence as the sibling prompt-cache knobs. Scope note on the issue's "model-aware default": config resolution runs during CLI parsing, before any weights are read, and `start_server` receives an already-resolved `PromptCacheConfig`. Deriving the default from the model's per-token state footprint would mean deferring store construction until after model load or introducing two-phase config, which is out of scope here. The knobs plus sizing guidance in `--help` and `docs/environment-variables.md` cover it instead: measure `snapshot_bytes` after one conversation and budget two to three times that. Validated with `cargo test --release --lib prompt_cache::store::tests` (28 passed, 5 new), `cargo test --release --lib server::cli_input` (98 passed, 5 new), and `cargo clippy --lib --tests -- -D warnings` clean. Real-checkpoint run on qwen3.5-0.8b-4bit (a `supports_snapshot_reuse()` family) confirms the flag path and reproduces the reported thrash: startup logs `snapshot_capacity_bytes=40000000` from `--prompt-cache-snapshot-capacity-bytes` and `/v1/cache/stats` reports the same, against a 512 MiB compiled default; one conversation snapshot is 13,246,464 bytes and stays that size across turns, since the state is model-width bound rather than prompt-length bound; and under a 40 MB budget one conversation alone filled the store at three entries and began LRU-evicting from its fourth turn, with a second conversation then evicting the first's entries. Supersede did not fire on that path, and the reason is measured rather than assumed: conversation A donated vectors of 72, 98, 124, and 151 tokens against following prompts of 96, 122, and 149, so the length precondition held and the divergence is in the token ids at the turn boundary. The donated vector ends with a generation prompt plus generated tokens, which the next turn's re-rendered prompt does not reproduce at that offset, so the HTTP path emits no same-session strict extension for this family yet. That boundary is what the sibling boundary-snapshot and warm-up-snapshot issues in this epic address; supersede is the piece that keeps their steady-state footprint at one entry per conversation, and its own behavior is covered by the five new store tests. Closes #1146
…th yet The sizing guidance in `docs/environment-variables.md` and the `--prompt-cache-snapshot-max-entries` help text described per-conversation collapse to a single snapshot entry as current behavior. It is not: the donated token vector is prompt plus generated tokens and ends with a generation prompt that the next turn's re-rendered prompt does not reproduce at that offset, so no same-session strict extension reaches the store yet and `snapshot_supersedes` stays at zero. An operator following the old wording would budget one entry per conversation, size the store two to three times a single snapshot, and hit exactly the thrash issue #1146 set out to remove. Both places now describe the supersede rule as conditional on a turn strictly extending the previous turn's token vector, say that the condition is unmet today, and point at `snapshot_supersedes` on `/v1/cache/stats` as the signal for when collapse starts. Doc and help text only; no behavior change. Verified with `cargo check --lib --tests`, `cargo check --bin mlxcel-server`, and `cargo fmt --check`. Refs #1146
Security and performance reviewReviewed the full diff against the store's existing lock, byte-accounting, and resource-release patterns, plus the CLI/env parse paths. No CRITICAL or HIGH findings. One MEDIUM was fixed in ead5ac9; the rest are recorded below without changes. Lock discipline and complexity: cleanThe supersede scan holds the same write guard it was already holding and touches nothing but The scan is not a new complexity class. Nit, not applied: the length test Byte accounting: no underflow, no double-free
Double-free of one digest's bytes cannot happen for two independent reasons. Mechanically, The Ordering is right in the way that matters most: the oversized rejection returns before the supersede block, so a rejected insert never destroys the ancestor it would have replaced. Resource release: no leak, nothing to stash
Metrics do not drift either: CLI and env parsing: safeNo Two values are accepted without validation, matching the sibling knobs rather than diverging from them: MEDIUM (fixed in ead5ac9): sizing guidance stated collapse as current behavior
That matters because it is the sizing advice an operator acts on. Reading it, they would budget one entry per conversation at two to three times a single snapshot, then hit exactly the thrash #1146 exists to remove. Both places now make the supersede rule conditional on a turn strictly extending the previous turn's vector, say the condition is unmet today, and point at LOW / informational, no change madeThe
Two copies of the same No Verification
|
…on sharing Review follow-up on the session-chain supersede work. Two gaps, neither behavioral. The `/v1/cache/stats` serialization test set `snapshot_supersedes` on the fixture but never asserted the serialized key, so the wire name was unpinned and a rename would have passed CI. Added `"snapshot_supersedes":3` to the asserted key list. The supersede comment said two different sessions never touch each other, which is true of the resolved session key but understates what that key is. A request carrying neither `prompt_cache_key` nor `user` resolves to `ANONYMOUS_SESSION_SENTINEL`, so all anonymous callers share one session bucket in supersede exactly as they already do in `lookup_longest_prefix`. Recorded that, along with why it is not a practical false-supersede risk: it still needs one caller's whole stored vector, prompt plus generated tail, to be a strict prefix of another's, which only holds for a genuine continuation of the same transcript. Validated: `cargo test --release --lib server::prompt_cache::store::tests` (28 passed), `cargo test --release --lib server::routes::cache` (17 passed), `cargo clippy --lib --tests -- -D warnings` clean, `cargo fmt`. Refs #1146
Follow-up to ead5ac9, which corrected the `--prompt-cache-snapshot-max-entries` help text and `docs/environment-variables.md` to say that per-conversation collapse is not live on the donate path yet, but left the sizing advice on `--prompt-cache-snapshot-capacity-bytes` on the old assumption. Both binaries still told operators to budget two to three times a single conversation's snapshot, which only holds once a conversation collapses to one entry. Today every turn keeps its own entry, so that advice under-sizes the store by roughly the turn count and lands the operator in exactly the LRU thrash issue #1146 set out to remove. Both help texts now say to measure `snapshot_bytes` after one turn and multiply by the entries expected resident, and to count turns as well as concurrent sessions until snapshots start superseding each other. Help text only; no behavior change. Verified with `cargo check --bins` and `cargo fmt --check`. Refs #1146
Implementation Review SummaryIntent
Verified against the code, not the descriptionSupersede predicate ( Byte-accounting order. Supersede runs before CLI/env parity across all four surfaces. Flag names, env names, and defaults line up between Counter is not dead. Independent end-to-end check of the env path. Started The "not validated end to end" claim is accurate. Read the five store tests rather than taking the body's word: they cover chain collapse, cross-session isolation, the Fixed on this branch
Remaining, deliberately not changed
Verification
|
## Summary Snapshot-only families (every model reporting `supports_snapshot_reuse()`) reuse a prompt cache only through an exact-prefix match against a stored token vector, and the vector donated at end of generation is `prompt + generated`. Epic #1148 measured that vector failing to prefix the next turn on every family tested, for three independent reasons that all live past the history boundary: templates append generation-prompt-only scaffolds, templates drop the `<think>` block when re-rendering an assistant turn as history, and a sampled token sequence is not the canonical tokenization of its own text. This adds a second snapshot taken during prefill at the history boundary, keyed by the tokenization of the `add_generation_prompt = false` render itself, which is a prefix of every follow-up turn by construction. ## What changed - `src/server/chat_template.rs` — `apply_raw_history_with_kwargs` / `apply_history_with_kwargs` render the same message list with `add_generation_prompt` forced off. The existing `apply_raw_with_kwargs` / `apply_with_kwargs` now delegate to the same inner render with the processor's own flag, so there is one renderer and no behavior change for existing callers. - `src/server/chat_request.rs` — `PreparedChatRequest::history_prompt` carries the history render. Produced only when the prompt cache is on and the request is text-only, dropped when the primary render fell back to `render_simple_fallback` (the fallback is not the template, so its history form describes a prompt the model never saw), and dropped when the render does not come out as a text prefix of the prompt. - `src/server/config.rs` — `PromptCacheRequestContext` gains `history_prompt` and `history_prefix_tokens`, mirroring the `prompt` / `prompt_token_ids` split already used for the prompt itself (issue #633). - `src/server/model_provider.rs` — the dispatch thread tokenizes the history render next to the prompt through the same `tokenize_prompt_for_generation` convention, so the two vectors are comparable and the scheduler thread pays for neither encode. - `src/server/batch/scheduler.rs` — `resolve_history_boundary` clips the history vector to the longest common prefix it shares with the live prompt tokens. That clip is what makes the vector a genuine prefix of the state the snapshot will describe, and it also drops any token a BPE merge across the history/scaffold seam would have made unstable. `capture_history_boundary_snapshot` then runs one forward over `prompt_tokens[prefill_start_offset..boundary]`, snapshots the model state there, inserts it, and advances `prefill_start_offset` so both `execute_full_prefill` and `start_chunked_prefill` process the remaining suffix with no further changes. Total tokens forwarded are unchanged; the cost is one extra graph launch plus the state copy. The snapshot capture and insert previously inlined in `donate_finished_sequence_cache` is now `insert_model_state_snapshot`, shared by both producers, so the end-of-generation donate keeps behaving exactly as before. - `src/server/batch/observability.rs` — `record_prefill_tokens` adds prefill tokens without opening a new sequence, so `total_prefill_tokens` still sums to the prompt when the prefill is split. - `src/server/routes/{chat,responses,anthropic}.rs` — `build_prompt_cache_request_context` takes the history render, wired at all six call sites. - `docs/benchmark_results/history-boundary-snapshot-m1ultra-2026-08-14.md` — the measurement record. Gating: the boundary path is reached only for models reporting `supports_snapshot_reuse()`, on text-only requests, when the store is enabled, and when the clipped boundary is at or above `min_prefix_tokens` and strictly shorter than the prompt. Dense-KV families never reach it, and an eval failure in the extra forward aborts the sequence exactly the way the existing prefill eval failures do. Every other decline leaves the request prefilling the way it did before. Operator kill switch: `MLXCEL_DISABLE_BOUNDARY_SNAPSHOT=1` restores the pre-#1143 prefill without a rebuild. The feature is on by default because it is what makes the prompt cache hit at all here, but it does put an extra graph launch and a full model-state copy on the foreground prefill of every qualifying request, and a deployment serving only single-turn traffic pays that for reuse it will never claim. ## Measurement Apple M1 Ultra, 2026-08-14, both arms on the same box through the production `/v1/chat/completions` path, counters read from `/v1/cache/stats`. Baseline arm is `main` at 9c154ff. Arm attribution is self-checking: `snapshot_inserts` advances once per turn in the baseline arm and twice per turn in the fix arm, so a run that was not exercising the new path could not produce the fix column. qwen3.5-0.8b-4bit (Attention + GatedDeltaNet hybrid, the discriminating fixture: primes `<think>` and drops reasoning from history), identical three-turn conversation with identical prompt lengths per turn in both arms: | Arm | Turn 2 `cached_tokens` | Turn 3 `cached_tokens` | `snapshot_hits` | `snapshot_inserts` | |---|---|---|---|---| | baseline | 0 / 189 | 0 / 214 | 0 | 3 | | fix | 150 / 189 | 184 / 214 | 2 | 6 | The hit lengths are exactly the previous turn's history boundary (turn 1 renders 155 prompt tokens of which 150 are history and 5 are the scaffold; turn 2 renders 189 of which 184 are history). llama-3.2-1b-instruct dense-KV no-regression control, identical in every cell across arms: turn 2 `cached_tokens` 320 / 377, turn 3 416 / 447, KV `hits` 2 of 3, `snapshot_inserts` 0. falcon-h1-tiny-90m-instruct-4bit is in the record but is **not** attribution evidence for this change: the replies this probe generated re-tokenized canonically, so the end-of-generation snapshot already prefixed the next turn and the baseline arm hit too. It establishes only that the boundary snapshot is produced and that adding it does not break a family that was already hitting. ### Greedy output is not bit-exact across the split Third arm pair, same binary, differing only in `MLXCEL_DISABLE_BOUNDARY_SNAPSHOT` (confirmed distinct by counter: one snapshot insert per turn with the switch set, two without). Single greedy turn against qwen3.5-0.8b-4bit, identical 155-token prompt and 120 completion tokens in both arms: the outputs agree for the first 168 characters and then differ by one word (`**Task:**` versus `**Specific Task:**`), staying coherent thereafter. Each arm is deterministic in itself, byte-identical across three repeats. Two forwards do not reduce in the same order as one, so this is the documented #203 / #325 / #326 near-tie flip class, not corruption. It is also already the status quo on this path for two independent reasons: `--prefill-chunk-size` splits prefills the same way, and any prompt-cache hit forwards only the suffix. Called out because the split is on by default and someone comparing outputs across versions deserves to know why they moved. Not measured, and stated as open rather than implied: wall-clock latency and TTFT (the box carried load average 22 for the whole session, so no absolute timing claim would have survived it), peak memory at 31B scale, concurrent-conversation snapshot budget pressure (that is issue #1146), and gemma-4-31b-it-4bit, which is left to the epic-level verification run since this branch was validated on small checkpoints only. ## Review outcomes A review pass over this branch found no CRITICAL issue and confirmed the central invariant: the snapshot's token vector is always `prompt_tokens[..boundary]`, taken from the live prompt rather than from the history render, so no template output or message content can make the key describe state it does not match. Every other prefill entry point was traced for a wrong or double-applied `prefill_start_offset` advance and none was found (batched cohorts and the speculative burst skip the capture entirely, VLM is triple-gated, the distributed handoff carries no context, and the degenerate whole-prompt backoff is caught by the `boundary <= prefill_start_offset` decline). Fixed on this branch since: - The segment forward evaluated its whole `[1, segment_len, vocab]` logits block, which forced a tensor nothing reads to be materialized and held; at 20k tokens and a 150k vocab that is several GB of peak. It now evaluates only the final position, and clears the allocator cache afterwards. Re-validated live with byte-identical hit numbers, which is the proof the captured state did not change. - `MLXCEL_DISABLE_BOUNDARY_SNAPSHOT` was read only in the scheduler, so it removed the extra forward but still paid the second render, the clone, and the second encode. It moved to `prompt_cache::policy` and the route honors it too. With the switch set, the probe now reproduces the `main` baseline cell for cell. - A failed HTTP-side history tokenization no longer drops the rendered string, so the scheduler's encode still gets its turn rather than the request being permanently and silently declined. - The boundary decision is split into `history_boundary_len` and `boundary_capture_applies`, two pure functions with tests covering the min-prefix decline, the whole-prompt decline, seam-divergence clipping, the adopted-prefix case, and the degenerate backoff. - Preemption leaked one `prompt_cache_seq_ctx` entry per evicted chat request (pre-existing, but this branch made each leaked entry proportional to conversation length). Cleared at the eviction site. - Trace spans reported freshly-forwarded boundary tokens as `cached`; they now report `already_cached_tokens` and carry the cursor as a separate `start`. User-facing `usage.cached_tokens` was always correct. - The capture takes the token vector out of the context map instead of cloning the whole context. - `MLXCEL_DISABLE_BOUNDARY_SNAPSHOT` documented in `docs/environment-variables.md`. Handed off rather than fixed here, each with the reasoning recorded on the owning issue: - **Snapshot budget interaction (#1146, and epic #1148).** This inserts a second snapshot per turn, and both share the 512 MiB `snapshot_capacity_bytes` default with oldest-first eviction. Epic #1148 measured single gemma-4-31b conversation snapshots at 307-369 MB, so on that checkpoint the end-of-generation insert is predicted to evict the boundary snapshot before the turn ends. Not observed here (the validated checkpoints hold both trivially and `snapshot_evictions_lru` stayed at 0), so it is arithmetic from the epic's own numbers rather than a measured failure, but it means the gemma-4-31b criterion should not be expected to pass until #1146 lands. The narrow alternative, suppressing the end-of-generation donate when a boundary snapshot exists, was rejected: that entry is not dead weight, it produced the longer hit on falcon-h1-tiny. - **Capability gate (new issue #1153).** Dense-KV deployments pay the second render and encode and have it discarded, because `supports_snapshot_reuse()` lives on the model behind the worker thread and is not published to the HTTP layer. The kill switch is the interim opt-out; the automatic gate needs cross-thread capability plumbing that is its own change. - **Classified rejects (#1147).** The two new silent decline paths (history render is not a prefix; boundary below `min_prefix_tokens`) are exactly the class #1147 is making visible in `/v1/cache/stats`. Not added here to avoid conflicting with that issue's in-flight edits to the reject enum. - **Batched-cold cohorts.** A `BatchedCold` cohort of two or more rows takes no boundary snapshot, since that path forwards every row in one pass with no per-row split point. Marking those rows non-cold would route essentially every snapshot-family chat row to sequential prefill and give up batched prefill for the whole family, which costs more than the missed reuse. Documented at the method. ## Rebase onto the merged siblings: a cross-issue break, found and fixed While this was in review, #1149 (#1147), #1151 (#1146), and #1152 (#1145) merged. Rebasing onto them regressed this feature to nothing, and the regression is worth reading carefully because neither change is wrong on its own. #1146's session-chain supersede rule removes stored snapshots whose token vector is a strict prefix of an incoming one from the same session. A turn's history-boundary vector is **always** a strict prefix of that same turn's completion vector, since the completion vector is `history + generation-prompt scaffold + sampled reply`. Every boundary snapshot was therefore deleted by the completion donate of its own turn, moments after being created. Measured on the same probe, rebased build before the fix: turn 2 `cached_tokens` back to 0 of 189 and turn 3 to 0 of 214, with `snapshot_inserts` still 6 and `snapshot_evictions_lru` 0. The insert count proves the boundary snapshots were being created; the entry count proves they were being removed, and not by byte pressure. The supersede premise, "a longer vector from the same conversation replaces a shorter one", holds within a producer but not across these two: the completion vector is longer and almost never usable (its tail is exactly the divergence this epic documented), while the boundary vector is shorter and always usable. Snapshots now carry a `SnapshotOrigin` and the rule chains only within one origin, so turn N+1's boundary supersedes turn N's boundary and likewise for completions, each chain still collapsing to a single entry. `ModelSnapshotEntry::new` defaults to `Completion` with the origin set through a builder, so every pre-existing caller keeps its prior behavior. Post-fix on the rebased build: qwen3.5-0.8b-4bit back to 150 of 189 and 184 of 214, `snapshot_hits` 2, four live entries (two chains of one); llama-3.2-1b-instruct control unchanged at 320 of 377 and 416 of 447 with zero snapshot activity. One consequence for the epic: #1146 landing does **not** resolve the 512 MiB capacity concern I raised on it and on #1148, because a conversation now legitimately holds two chains rather than one. That comment is still live for the gemma-4-31b verification. ## Test plan - [x] `cargo test --release --lib server::prompt_cache` (142 passed) — includes the new `history_boundary_snapshot_hits_where_the_end_of_generation_one_cannot`, which encodes the epic's shape at the store level: a boundary snapshot and an end-of-generation snapshot coexist for one session, and only the boundary one is reachable from the next turn. - [x] `cargo test --release --lib server::chat_request` (80 passed) — includes the four new history-render tests: the render prefixes the next turn's prompt while the whole prompt does not, and the render is absent with the cache off, on template fallback, and for multimodal requests. - [x] `cargo test --release --lib server::chat_template` (100 passed) - [x] `cargo test --release --lib server::batch::` (353 passed) — includes the new `common_prefix_len` cases. - [x] `cargo test --release --lib server::routes::` (116 passed), `server::model_provider` (51 passed) - [x] `cargo clippy --release --lib --tests --features metal,accelerate -- -D warnings` - [x] `cargo fmt --check` - [x] Real-checkpoint validation through `/v1/chat/completions` against qwen3.5-0.8b-4bit, falcon-h1-tiny-90m-instruct-4bit, and llama-3.2-1b-instruct, both arms, as tabled above. Closes #1143
…lablup#1151) ## Summary The exact-prefix snapshot store had a fixed 512 MiB budget with no operator override, and no notion that a newer snapshot from the same conversation replaces the older one. A single 31B-class conversation snapshot runs 300-370 MB, so the second turn's insert LRU-evicted the first turn's snapshot rather than superseding it, and two concurrent conversations evicted each other. This adds an explicit session-chain supersede at insert time and wires the three existing `PromptCacheConfig` snapshot fields through CLI flags and `MLXCEL_*` env vars. ## What changed - `src/server/prompt_cache/store.rs`: `insert_snapshot` now removes every stored snapshot whose token vector is a strict prefix of the incoming one, within the same session key and the same model/lora/template/multimodal bucket. It runs before the new entry's bytes are accounted, so `enforce_snapshot_caps` sees the freed bytes and can admit an extension that would not have fit alongside its own ancestor. A `None` session key never triggers it (no conversation identity to chain on) and two different session keys never touch each other. - `src/server/prompt_cache/store.rs`, `policy.rs`, `routes/cache.rs`: new `snapshot_supersedes` counter, held apart from `snapshot_evictions_lru` so capacity tuning can distinguish deterministic in-session replacement from real budget pressure. Surfaced on `/v1/cache/stats`. - `src/server/cli_input.rs`: three new `ServerStartupInput` fields, three new `env_fallback_prompt_cache_snapshot_*` helpers, and `build_prompt_cache_config` now chains `with_snapshot_limits(...)` after `with_apc(...)`. Same CLI-beats-env precedence as the sibling prompt-cache knobs. - `src/main.rs`, `src/bin/mlx_server.rs`: `--prompt-cache-snapshot-capacity-bytes`, `--prompt-cache-snapshot-max-entries`, `--prompt-cache-snapshot-ttl` on both binaries, with matching help text; `src/commands/serve.rs` applies the env fallbacks and maps the fields through. - `src/server/prompt_cache/entry.rs`: `ModelSnapshotEntry::new_for_test(tokens, family, size_bytes)`, mirroring `CacheEntry::new_for_test`, so budget tests can control the accounted size without allocating MLX tensors. - `docs/environment-variables.md`: the three new variables, plus how to size the budget from a measured `snapshot_bytes`. - Tests: five new store tests (chain collapse, cross-session isolation, no supersede without a session key, strict-extension requirement, and byte accounting proving supersede runs before the capacity check) and five new `cli_input` tests (CLI override, defaults when unset, and the three env fallbacks including CLI precedence and an unparseable TTL). ## Scope note on the issue's "model-aware default" Proposed Solution (b) also asked to derive the default budget from the model's per-token state footprint. Not done here, and the reason is scope rather than architecture. There is a usable hook point: `PromptCacheStore` is constructed in `start_server` (`src/server/startup.rs:2006`) with `startup.model_path` in hand and before the model loads, and the twenty lines above it already read `config.json` through `detect_hybrid_ssm_from_path` and mutate the same `config.prompt_cache` struct to auto-disable APC for hybrid SSM families. A model-aware default could be computed at that same point without deferring store construction or introducing a two-phase config. What it needs is the part this issue does not carry: a per-family state-size formula (layer count, state dimensions, dtype) for each of the thirteen `supports_snapshot_reuse()` families, validated against measured `snapshot_bytes`. That is its own piece of work, and getting it wrong silently mis-sizes the store, so the default stays at 512 MiB and the knobs plus sizing guidance in `--help` and `docs/environment-variables.md` cover the operator-facing need: measure `snapshot_bytes` and multiply by the entries expected resident. Corrected after review: an earlier version of this note claimed the deferral was forced by config resolution happening during CLI parsing. That is true of `build_prompt_cache_config` but does not establish the constraint, given the `start_server` hook above. ## Test plan - [x] `cargo test --release --lib prompt_cache::store::tests` (28 passed, 5 new) - [x] `cargo test --release --lib server::cli_input` (98 passed, 5 new) - [x] `cargo test --release --lib server::routes::cache` (17 passed) - [x] `cargo clippy --lib --tests -- -D warnings` clean - [x] `cargo fmt` - [x] Real-checkpoint run on `qwen3.5-0.8b-4bit` (a `supports_snapshot_reuse()` family) against `/v1/cache/stats`, evidence below. ## Real-checkpoint evidence Server: `mlxcel-server -m models/qwen3.5-0.8b-4bit --port 18146 --prompt-cache-snapshot-capacity-bytes 40000000`. The flag path works end to end. Startup logs `snapshot_capacity_bytes=40000000 snapshot_max_entries=4096 snapshot_ttl_seconds=7200` and `/v1/cache/stats` reports `"snapshot_capacity_bytes": 40000000`, against the 512 MiB compiled default. The new `snapshot_supersedes` field is live on the same response. One conversation snapshot for this model is `13246464` bytes and stays exactly that size as the conversation grows (turn vectors 72, 98, 124, 151 tokens all cost the same), confirming the sizing guidance: snapshot cost tracks model width, not prompt length, so the budget scales with concurrent conversations rather than with turns. The reported thrash reproduces at HEAD. Under the 40 MB budget, a single four-turn conversation filled the store at three entries and began LRU-evicting from its fourth turn (`snapshot_entries` 1, 2, 3, 3 with `snapshot_evictions_lru` 0, 0, 0, 1), and a second conversation's turns then evicted the first's entries (`snapshot_evictions_lru` reaching 4 while `snapshot_entries` stayed pinned at 3). Supersede did not fire on that path, and the cause is measured rather than assumed. Conversation A donated vectors of 72, 98, 124, and 151 tokens against following prompts of 96, 122, and 149 tokens, so the length precondition held throughout and the divergence is in the token ids at the turn boundary. A donated vector is prompt plus generated tokens and therefore ends with a generation prompt followed by generated content, which the next turn's re-rendered prompt does not reproduce at that offset, so no same-session strict extension reaches the store yet. That boundary is exactly what the sibling boundary-snapshot and warm-up-snapshot issues in epic lablup#1148 address; this issue's own framing is that supersede is what holds their steady-state footprint at one entry per conversation. An identical-request repeat within one session was also checked as an attribution control: it advances `snapshot_inserts` and the LRU counter and leaves `snapshot_supersedes` at 0, confirming the new counter is not double-counting idempotent replacement. Not measured: end-to-end chain collapse and two-conversation coexistence over HTTP, which are unreachable until the boundary snapshot lands. The supersede rule itself, including cross-session isolation and the two-entry coexistence case, is covered by the new store tests. Closes lablup#1146
…up#1150) ## Summary Snapshot-only families (every model reporting `supports_snapshot_reuse()`) reuse a prompt cache only through an exact-prefix match against a stored token vector, and the vector donated at end of generation is `prompt + generated`. Epic lablup#1148 measured that vector failing to prefix the next turn on every family tested, for three independent reasons that all live past the history boundary: templates append generation-prompt-only scaffolds, templates drop the `<think>` block when re-rendering an assistant turn as history, and a sampled token sequence is not the canonical tokenization of its own text. This adds a second snapshot taken during prefill at the history boundary, keyed by the tokenization of the `add_generation_prompt = false` render itself, which is a prefix of every follow-up turn by construction. ## What changed - `src/server/chat_template.rs` — `apply_raw_history_with_kwargs` / `apply_history_with_kwargs` render the same message list with `add_generation_prompt` forced off. The existing `apply_raw_with_kwargs` / `apply_with_kwargs` now delegate to the same inner render with the processor's own flag, so there is one renderer and no behavior change for existing callers. - `src/server/chat_request.rs` — `PreparedChatRequest::history_prompt` carries the history render. Produced only when the prompt cache is on and the request is text-only, dropped when the primary render fell back to `render_simple_fallback` (the fallback is not the template, so its history form describes a prompt the model never saw), and dropped when the render does not come out as a text prefix of the prompt. - `src/server/config.rs` — `PromptCacheRequestContext` gains `history_prompt` and `history_prefix_tokens`, mirroring the `prompt` / `prompt_token_ids` split already used for the prompt itself (issue lablup#633). - `src/server/model_provider.rs` — the dispatch thread tokenizes the history render next to the prompt through the same `tokenize_prompt_for_generation` convention, so the two vectors are comparable and the scheduler thread pays for neither encode. - `src/server/batch/scheduler.rs` — `resolve_history_boundary` clips the history vector to the longest common prefix it shares with the live prompt tokens. That clip is what makes the vector a genuine prefix of the state the snapshot will describe, and it also drops any token a BPE merge across the history/scaffold seam would have made unstable. `capture_history_boundary_snapshot` then runs one forward over `prompt_tokens[prefill_start_offset..boundary]`, snapshots the model state there, inserts it, and advances `prefill_start_offset` so both `execute_full_prefill` and `start_chunked_prefill` process the remaining suffix with no further changes. Total tokens forwarded are unchanged; the cost is one extra graph launch plus the state copy. The snapshot capture and insert previously inlined in `donate_finished_sequence_cache` is now `insert_model_state_snapshot`, shared by both producers, so the end-of-generation donate keeps behaving exactly as before. - `src/server/batch/observability.rs` — `record_prefill_tokens` adds prefill tokens without opening a new sequence, so `total_prefill_tokens` still sums to the prompt when the prefill is split. - `src/server/routes/{chat,responses,anthropic}.rs` — `build_prompt_cache_request_context` takes the history render, wired at all six call sites. - `docs/benchmark_results/history-boundary-snapshot-m1ultra-2026-08-14.md` — the measurement record. Gating: the boundary path is reached only for models reporting `supports_snapshot_reuse()`, on text-only requests, when the store is enabled, and when the clipped boundary is at or above `min_prefix_tokens` and strictly shorter than the prompt. Dense-KV families never reach it, and an eval failure in the extra forward aborts the sequence exactly the way the existing prefill eval failures do. Every other decline leaves the request prefilling the way it did before. Operator kill switch: `MLXCEL_DISABLE_BOUNDARY_SNAPSHOT=1` restores the pre-lablup#1143 prefill without a rebuild. The feature is on by default because it is what makes the prompt cache hit at all here, but it does put an extra graph launch and a full model-state copy on the foreground prefill of every qualifying request, and a deployment serving only single-turn traffic pays that for reuse it will never claim. ## Measurement Apple M1 Ultra, 2026-08-14, both arms on the same box through the production `/v1/chat/completions` path, counters read from `/v1/cache/stats`. Baseline arm is `main` at 9c154ff. Arm attribution is self-checking: `snapshot_inserts` advances once per turn in the baseline arm and twice per turn in the fix arm, so a run that was not exercising the new path could not produce the fix column. qwen3.5-0.8b-4bit (Attention + GatedDeltaNet hybrid, the discriminating fixture: primes `<think>` and drops reasoning from history), identical three-turn conversation with identical prompt lengths per turn in both arms: | Arm | Turn 2 `cached_tokens` | Turn 3 `cached_tokens` | `snapshot_hits` | `snapshot_inserts` | |---|---|---|---|---| | baseline | 0 / 189 | 0 / 214 | 0 | 3 | | fix | 150 / 189 | 184 / 214 | 2 | 6 | The hit lengths are exactly the previous turn's history boundary (turn 1 renders 155 prompt tokens of which 150 are history and 5 are the scaffold; turn 2 renders 189 of which 184 are history). llama-3.2-1b-instruct dense-KV no-regression control, identical in every cell across arms: turn 2 `cached_tokens` 320 / 377, turn 3 416 / 447, KV `hits` 2 of 3, `snapshot_inserts` 0. falcon-h1-tiny-90m-instruct-4bit is in the record but is **not** attribution evidence for this change: the replies this probe generated re-tokenized canonically, so the end-of-generation snapshot already prefixed the next turn and the baseline arm hit too. It establishes only that the boundary snapshot is produced and that adding it does not break a family that was already hitting. ### Greedy output is not bit-exact across the split Third arm pair, same binary, differing only in `MLXCEL_DISABLE_BOUNDARY_SNAPSHOT` (confirmed distinct by counter: one snapshot insert per turn with the switch set, two without). Single greedy turn against qwen3.5-0.8b-4bit, identical 155-token prompt and 120 completion tokens in both arms: the outputs agree for the first 168 characters and then differ by one word (`**Task:**` versus `**Specific Task:**`), staying coherent thereafter. Each arm is deterministic in itself, byte-identical across three repeats. Two forwards do not reduce in the same order as one, so this is the documented lablup#203 / lablup#325 / lablup#326 near-tie flip class, not corruption. It is also already the status quo on this path for two independent reasons: `--prefill-chunk-size` splits prefills the same way, and any prompt-cache hit forwards only the suffix. Called out because the split is on by default and someone comparing outputs across versions deserves to know why they moved. Not measured, and stated as open rather than implied: wall-clock latency and TTFT (the box carried load average 22 for the whole session, so no absolute timing claim would have survived it), peak memory at 31B scale, concurrent-conversation snapshot budget pressure (that is issue lablup#1146), and gemma-4-31b-it-4bit, which is left to the epic-level verification run since this branch was validated on small checkpoints only. ## Review outcomes A review pass over this branch found no CRITICAL issue and confirmed the central invariant: the snapshot's token vector is always `prompt_tokens[..boundary]`, taken from the live prompt rather than from the history render, so no template output or message content can make the key describe state it does not match. Every other prefill entry point was traced for a wrong or double-applied `prefill_start_offset` advance and none was found (batched cohorts and the speculative burst skip the capture entirely, VLM is triple-gated, the distributed handoff carries no context, and the degenerate whole-prompt backoff is caught by the `boundary <= prefill_start_offset` decline). Fixed on this branch since: - The segment forward evaluated its whole `[1, segment_len, vocab]` logits block, which forced a tensor nothing reads to be materialized and held; at 20k tokens and a 150k vocab that is several GB of peak. It now evaluates only the final position, and clears the allocator cache afterwards. Re-validated live with byte-identical hit numbers, which is the proof the captured state did not change. - `MLXCEL_DISABLE_BOUNDARY_SNAPSHOT` was read only in the scheduler, so it removed the extra forward but still paid the second render, the clone, and the second encode. It moved to `prompt_cache::policy` and the route honors it too. With the switch set, the probe now reproduces the `main` baseline cell for cell. - A failed HTTP-side history tokenization no longer drops the rendered string, so the scheduler's encode still gets its turn rather than the request being permanently and silently declined. - The boundary decision is split into `history_boundary_len` and `boundary_capture_applies`, two pure functions with tests covering the min-prefix decline, the whole-prompt decline, seam-divergence clipping, the adopted-prefix case, and the degenerate backoff. - Preemption leaked one `prompt_cache_seq_ctx` entry per evicted chat request (pre-existing, but this branch made each leaked entry proportional to conversation length). Cleared at the eviction site. - Trace spans reported freshly-forwarded boundary tokens as `cached`; they now report `already_cached_tokens` and carry the cursor as a separate `start`. User-facing `usage.cached_tokens` was always correct. - The capture takes the token vector out of the context map instead of cloning the whole context. - `MLXCEL_DISABLE_BOUNDARY_SNAPSHOT` documented in `docs/environment-variables.md`. Handed off rather than fixed here, each with the reasoning recorded on the owning issue: - **Snapshot budget interaction (lablup#1146, and epic lablup#1148).** This inserts a second snapshot per turn, and both share the 512 MiB `snapshot_capacity_bytes` default with oldest-first eviction. Epic lablup#1148 measured single gemma-4-31b conversation snapshots at 307-369 MB, so on that checkpoint the end-of-generation insert is predicted to evict the boundary snapshot before the turn ends. Not observed here (the validated checkpoints hold both trivially and `snapshot_evictions_lru` stayed at 0), so it is arithmetic from the epic's own numbers rather than a measured failure, but it means the gemma-4-31b criterion should not be expected to pass until lablup#1146 lands. The narrow alternative, suppressing the end-of-generation donate when a boundary snapshot exists, was rejected: that entry is not dead weight, it produced the longer hit on falcon-h1-tiny. - **Capability gate (new issue lablup#1153).** Dense-KV deployments pay the second render and encode and have it discarded, because `supports_snapshot_reuse()` lives on the model behind the worker thread and is not published to the HTTP layer. The kill switch is the interim opt-out; the automatic gate needs cross-thread capability plumbing that is its own change. - **Classified rejects (lablup#1147).** The two new silent decline paths (history render is not a prefix; boundary below `min_prefix_tokens`) are exactly the class lablup#1147 is making visible in `/v1/cache/stats`. Not added here to avoid conflicting with that issue's in-flight edits to the reject enum. - **Batched-cold cohorts.** A `BatchedCold` cohort of two or more rows takes no boundary snapshot, since that path forwards every row in one pass with no per-row split point. Marking those rows non-cold would route essentially every snapshot-family chat row to sequential prefill and give up batched prefill for the whole family, which costs more than the missed reuse. Documented at the method. ## Rebase onto the merged siblings: a cross-issue break, found and fixed While this was in review, lablup#1149 (lablup#1147), lablup#1151 (lablup#1146), and lablup#1152 (lablup#1145) merged. Rebasing onto them regressed this feature to nothing, and the regression is worth reading carefully because neither change is wrong on its own. lablup#1146's session-chain supersede rule removes stored snapshots whose token vector is a strict prefix of an incoming one from the same session. A turn's history-boundary vector is **always** a strict prefix of that same turn's completion vector, since the completion vector is `history + generation-prompt scaffold + sampled reply`. Every boundary snapshot was therefore deleted by the completion donate of its own turn, moments after being created. Measured on the same probe, rebased build before the fix: turn 2 `cached_tokens` back to 0 of 189 and turn 3 to 0 of 214, with `snapshot_inserts` still 6 and `snapshot_evictions_lru` 0. The insert count proves the boundary snapshots were being created; the entry count proves they were being removed, and not by byte pressure. The supersede premise, "a longer vector from the same conversation replaces a shorter one", holds within a producer but not across these two: the completion vector is longer and almost never usable (its tail is exactly the divergence this epic documented), while the boundary vector is shorter and always usable. Snapshots now carry a `SnapshotOrigin` and the rule chains only within one origin, so turn N+1's boundary supersedes turn N's boundary and likewise for completions, each chain still collapsing to a single entry. `ModelSnapshotEntry::new` defaults to `Completion` with the origin set through a builder, so every pre-existing caller keeps its prior behavior. Post-fix on the rebased build: qwen3.5-0.8b-4bit back to 150 of 189 and 184 of 214, `snapshot_hits` 2, four live entries (two chains of one); llama-3.2-1b-instruct control unchanged at 320 of 377 and 416 of 447 with zero snapshot activity. One consequence for the epic: lablup#1146 landing does **not** resolve the 512 MiB capacity concern I raised on it and on lablup#1148, because a conversation now legitimately holds two chains rather than one. That comment is still live for the gemma-4-31b verification. ## Test plan - [x] `cargo test --release --lib server::prompt_cache` (142 passed) — includes the new `history_boundary_snapshot_hits_where_the_end_of_generation_one_cannot`, which encodes the epic's shape at the store level: a boundary snapshot and an end-of-generation snapshot coexist for one session, and only the boundary one is reachable from the next turn. - [x] `cargo test --release --lib server::chat_request` (80 passed) — includes the four new history-render tests: the render prefixes the next turn's prompt while the whole prompt does not, and the render is absent with the cache off, on template fallback, and for multimodal requests. - [x] `cargo test --release --lib server::chat_template` (100 passed) - [x] `cargo test --release --lib server::batch::` (353 passed) — includes the new `common_prefix_len` cases. - [x] `cargo test --release --lib server::routes::` (116 passed), `server::model_provider` (51 passed) - [x] `cargo clippy --release --lib --tests --features metal,accelerate -- -D warnings` - [x] `cargo fmt --check` - [x] Real-checkpoint validation through `/v1/chat/completions` against qwen3.5-0.8b-4bit, falcon-h1-tiny-90m-instruct-4bit, and llama-3.2-1b-instruct, both arms, as tabled above. Closes lablup#1143
Summary
The exact-prefix snapshot store had a fixed 512 MiB budget with no operator override, and no notion that a newer snapshot from the same conversation replaces the older one. A single 31B-class conversation snapshot runs 300-370 MB, so the second turn's insert LRU-evicted the first turn's snapshot rather than superseding it, and two concurrent conversations evicted each other. This adds an explicit session-chain supersede at insert time and wires the three existing
PromptCacheConfigsnapshot fields through CLI flags andMLXCEL_*env vars.What changed
src/server/prompt_cache/store.rs:insert_snapshotnow removes every stored snapshot whose token vector is a strict prefix of the incoming one, within the same session key and the same model/lora/template/multimodal bucket. It runs before the new entry's bytes are accounted, soenforce_snapshot_capssees the freed bytes and can admit an extension that would not have fit alongside its own ancestor. ANonesession key never triggers it (no conversation identity to chain on) and two different session keys never touch each other.src/server/prompt_cache/store.rs,policy.rs,routes/cache.rs: newsnapshot_supersedescounter, held apart fromsnapshot_evictions_lruso capacity tuning can distinguish deterministic in-session replacement from real budget pressure. Surfaced on/v1/cache/stats.src/server/cli_input.rs: three newServerStartupInputfields, three newenv_fallback_prompt_cache_snapshot_*helpers, andbuild_prompt_cache_confignow chainswith_snapshot_limits(...)afterwith_apc(...). Same CLI-beats-env precedence as the sibling prompt-cache knobs.src/main.rs,src/bin/mlx_server.rs:--prompt-cache-snapshot-capacity-bytes,--prompt-cache-snapshot-max-entries,--prompt-cache-snapshot-ttlon both binaries, with matching help text;src/commands/serve.rsapplies the env fallbacks and maps the fields through.src/server/prompt_cache/entry.rs:ModelSnapshotEntry::new_for_test(tokens, family, size_bytes), mirroringCacheEntry::new_for_test, so budget tests can control the accounted size without allocating MLX tensors.docs/environment-variables.md: the three new variables, plus how to size the budget from a measuredsnapshot_bytes.cli_inputtests (CLI override, defaults when unset, and the three env fallbacks including CLI precedence and an unparseable TTL).Scope note on the issue's "model-aware default"
Proposed Solution (b) also asked to derive the default budget from the model's per-token state footprint. Not done here, and the reason is scope rather than architecture. There is a usable hook point:
PromptCacheStoreis constructed instart_server(src/server/startup.rs:2006) withstartup.model_pathin hand and before the model loads, and the twenty lines above it already readconfig.jsonthroughdetect_hybrid_ssm_from_pathand mutate the sameconfig.prompt_cachestruct to auto-disable APC for hybrid SSM families. A model-aware default could be computed at that same point without deferring store construction or introducing a two-phase config. What it needs is the part this issue does not carry: a per-family state-size formula (layer count, state dimensions, dtype) for each of the thirteensupports_snapshot_reuse()families, validated against measuredsnapshot_bytes. That is its own piece of work, and getting it wrong silently mis-sizes the store, so the default stays at 512 MiB and the knobs plus sizing guidance in--helpanddocs/environment-variables.mdcover the operator-facing need: measuresnapshot_bytesand multiply by the entries expected resident.Corrected after review: an earlier version of this note claimed the deferral was forced by config resolution happening during CLI parsing. That is true of
build_prompt_cache_configbut does not establish the constraint, given thestart_serverhook above.Test plan
cargo test --release --lib prompt_cache::store::tests(28 passed, 5 new)cargo test --release --lib server::cli_input(98 passed, 5 new)cargo test --release --lib server::routes::cache(17 passed)cargo clippy --lib --tests -- -D warningscleancargo fmtqwen3.5-0.8b-4bit(asupports_snapshot_reuse()family) against/v1/cache/stats, evidence below.Real-checkpoint evidence
Server:
mlxcel-server -m models/qwen3.5-0.8b-4bit --port 18146 --prompt-cache-snapshot-capacity-bytes 40000000.The flag path works end to end. Startup logs
snapshot_capacity_bytes=40000000 snapshot_max_entries=4096 snapshot_ttl_seconds=7200and/v1/cache/statsreports"snapshot_capacity_bytes": 40000000, against the 512 MiB compiled default. The newsnapshot_supersedesfield is live on the same response.One conversation snapshot for this model is
13246464bytes and stays exactly that size as the conversation grows (turn vectors 72, 98, 124, 151 tokens all cost the same), confirming the sizing guidance: snapshot cost tracks model width, not prompt length, so the budget scales with concurrent conversations rather than with turns.The reported thrash reproduces at HEAD. Under the 40 MB budget, a single four-turn conversation filled the store at three entries and began LRU-evicting from its fourth turn (
snapshot_entries1, 2, 3, 3 withsnapshot_evictions_lru0, 0, 0, 1), and a second conversation's turns then evicted the first's entries (snapshot_evictions_lrureaching 4 whilesnapshot_entriesstayed pinned at 3).Supersede did not fire on that path, and the cause is measured rather than assumed. Conversation A donated vectors of 72, 98, 124, and 151 tokens against following prompts of 96, 122, and 149 tokens, so the length precondition held throughout and the divergence is in the token ids at the turn boundary. A donated vector is prompt plus generated tokens and therefore ends with a generation prompt followed by generated content, which the next turn's re-rendered prompt does not reproduce at that offset, so no same-session strict extension reaches the store yet. That boundary is exactly what the sibling boundary-snapshot and warm-up-snapshot issues in epic #1148 address; this issue's own framing is that supersede is what holds their steady-state footprint at one entry per conversation. An identical-request repeat within one session was also checked as an attribution control: it advances
snapshot_insertsand the LRU counter and leavessnapshot_supersedesat 0, confirming the new counter is not double-counting idempotent replacement.Not measured: end-to-end chain collapse and two-conversation coexistence over HTTP, which are unreachable until the boundary snapshot lands. The supersede rule itself, including cross-session isolation and the two-entry coexistence case, is covered by the new store tests.
Closes #1146