Skip to content

feat(server): supersede session snapshot chains and expose the budget - #1151

Merged
inureyes merged 5 commits into
mainfrom
feature/issue-1146-snapshot-capacity-hygiene
Aug 14, 2026
Merged

inureyes merged 5 commits into
mainfrom
feature/issue-1146-snapshot-capacity-hygiene

Conversation

@inureyes

@inureyes inureyes commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

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

  • 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 warnings clean
  • cargo fmt
  • 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 #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 #1146

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
@inureyes inureyes added status:review Under review type:enhancement New features, capabilities, or significant additions priority:high High priority area:inference Generation, sampling, decoding (incl. speculative, DRY) area:cli Command-line interface / CLI flags labels Aug 14, 2026
…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
@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review

Reviewed 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: clean

The supersede scan holds the same write guard it was already holding and touches nothing but Inner. Inner::remove_snapshot is a plain &mut self map removal, so there is no nested acquisition of self.inner and no path back into a &self store method. The one re-acquisition in insert_snapshot (self.inner.read() for the oversized-error payload) is pre-existing and happens after drop(guard).

The scan is not a new complexity class. lookup_snapshot_prefix already walks every snapshot slot and runs slot.entry.tokens.as_slice() != &tokens[..len] on each same-session candidate, on the hotter lookup path; evict_oldest_snapshot is an O(N) min_by_key per eviction inside enforce_snapshot_caps, and sweep_snapshot_ttl collects O(N). The new filter also short-circuits well: starts_with runs only after the sessionless-bucket, session-key, and length tests have all passed, so the token comparison is reached by roughly the entries in the same conversation, not by all N.

Nit, not applied: the length test slot.entry.tokens.len() < new_tokens.len() is the cheapest predicate in the chain (one deref plus an integer compare) and currently runs third, behind two String comparisons that in a single-model server always run to full length on every entry. Moving it first would cut the common case for free.

Byte accounting: no underflow, no double-free

snapshot_bytes is mutated in exactly three places, all under the write lock: saturating_sub in remove_snapshot, saturating_add at insert, and the = 0 in clear next to snapshots.clear(). Underflow is structurally impossible, and the accounting matches remove_snapshot's existing pattern.

Double-free of one digest's bytes cannot happen for two independent reasons. Mechanically, remove_snapshot returns None on the second call for the same key, so a repeated removal subtracts nothing; the superseded vector also comes from a HashMap iteration and so carries unique digests. Logically the two paths are disjoint anyway: the scheduler builds the key from entry.tokens itself (compose_prompt_cache_key(&ctx, &key_tokens) with key_tokens = entry.tokens.clone()), so the incoming digest hashes the same token vector the supersede predicate tests, and a slot that is strictly shorter can never carry the incoming digest.

The guard.snapshots.insert(...) at the end is a raw map insert that would silently orphan an existing slot's bytes, but it is unconditionally preceded by remove_snapshot(&digest), which is what keeps that safe. Worth keeping the two lines adjacent in any future edit.

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

ModelSnapshotEntry wraps ModelStateSnapshot, which owns Vec<ModelStateTensor> where each tensor is a UniquePtr<MlxArray> produced by ffi::copy. Those are owned copies with no pool block pins, no file handles, and no borrowed model state, so Drop is the complete release and the stash_paged_pins_for_release treatment the paged detached-KV path needs does not apply here. Dropping under the write lock is what evict_oldest_snapshot and sweep_snapshot_ttl already do, so the supersede path introduces no new lock-held work of a kind the store did not already perform. A concurrent reader holding an Arc from lookup_snapshot_prefix keeps its snapshot alive past the supersede, so there is no use-after-free window.

Metrics do not drift either: record_snapshot_* are monotonic counters rather than gauges, and the scheduler's gauge update reads live store.bytes() / store.len() after the insert returns, so supersede-freed bytes are reflected without a hook.

CLI and env parsing: safe

No unwrap, expect, or panicking conversion on any user-controlled value. apply_optional_usize_env_fallback and the hand-rolled env_fallback_prompt_cache_snapshot_ttl both match on parse() and warn-log-and-ignore the error arm, leaving the value None so the compiled-in default applies. That is not a silent zero: unwrap_or(defaults.*) runs against PromptCacheConfig::default(). The TTL helper is a faithful copy of the existing env_fallback_prompt_cache_ttl, including the is_some() precedence branch and the info! note when both are supplied. Clap rejects a malformed --prompt-cache-snapshot-* value with its own usage error before any of this runs. Duration::from_secs accepts the whole u64 range, so no overflow path exists.

Two values are accepted without validation, matching the sibling knobs rather than diverging from them: --prompt-cache-snapshot-capacity-bytes 0 turns every insert into an oversized rejection (counted in snapshot_rejections_oversized, no panic), and --prompt-cache-snapshot-max-entries 0 evicts each entry immediately after admitting it. Both terminate; neither is a hang or a crash. Worth a range check across the whole family some day, not in this PR.

MEDIUM (fixed in ead5ac9): sizing guidance stated collapse as current behavior

docs/environment-variables.md said "Turns within one conversation cost nothing extra, since each turn's snapshot supersedes the previous one and snapshot_entries stays at one per conversation", and the --prompt-cache-snapshot-max-entries help text called the cap "effectively a concurrent-conversation limit". This PR's own measurement section says the opposite for the current donate path: the stored vector is prompt plus generated tokens and ends with a generation prompt the next turn does not reproduce, so no same-session strict extension reaches the store and snapshot_supersedes stays at 0.

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 snapshot_supersedes on /v1/cache/stats as the signal for when collapse begins.

LOW / informational, no change made

The None-session guard is unreachable in production. compose_prompt_cache_key always passes Some(ctx.session_key), and resolve_session_key falls back to ANONYMOUS_SESSION_SENTINEL when the request carries neither prompt_cache_key nor user. So the code comment's "a None session carries no conversation identity to chain on" describes a test-only branch, and in a deployment where clients set neither field, every conversation shares one session bucket. The strict-prefix predicate is what actually does the conversation identification there, and it holds: a second conversation's vector is a strict prefix of another only when the earlier conversation, generated tokens included, is a literal prefix, which in practice means it is the same conversation or an exact replay. The cost when it does misfire is the documented fork case, one re-prefill. No new trust boundary is crossed either, since the store already reuses prefixes across sessions by design and an attacker who knows a victim's session key could already force LRU eviction in that bucket. Suggest only that the comment name the sentinel so the next reader does not infer that unauthenticated traffic is excluded.

ServerStartupInput gains three public fields on a pub struct that is not #[non_exhaustive], which is technically semver-breaking for any downstream code constructing it as a literal. Same shape as how the sibling prompt-cache knobs were added, so this is consistency rather than regression.

Two copies of the same u64 env-fallback body now exist (env_fallback_prompt_cache_ttl and the new snapshot one). An apply_optional_u64_env_fallback mirroring the usize helper would remove the duplication.

No unsafe blocks in the diff. No injection surface, no secret in the warn-log path (the logged value is an operator-set numeric env var).

Verification

  • cargo check --lib --tests clean
  • cargo check --bin mlxcel-server clean
  • cargo clippy --lib --tests -- -D warnings clean
  • cargo test --release --lib prompt_cache::store 28 passed, including all five new supersede tests
  • cargo test --release --lib server::cli_input 98 passed
  • cargo fmt --check clean

…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
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Session-chain supersede in insert_snapshot (before byte accounting), plus CLI/env wiring for the three previously unwired PromptCacheConfig snapshot fields (#1146, epic #1148).

Verified against the code, not the description

Supersede predicate (src/server/prompt_cache/store.rs:698-741). The filter is exactly the four conditions the issue asks for: key.session_key is Some, slot.bucket.session_key.as_deref() == Some(session_key), slot.sessionless == sessionless (model / lora / template_sig / mm_digest, per SessionlessBucketKey in types.rs:87), and slot.entry.tokens.len() < new_tokens.len() && new_tokens.starts_with(slot.entry.tokens). Equal-length and overlapping-but-diverging vectors are both excluded by construction. It also sits after the oversized-entry early return, so a rejected insert cannot destroy a chain it was never going to replace.

Byte-accounting order. Supersede runs before guard.snapshot_bytes = ...saturating_add(entry_bytes) and before enforce_snapshot_caps, and remove_snapshot subtracts size_bytes on the way out, so the freed bytes genuinely admit the successor. supersede_frees_bytes_before_the_capacity_check discriminates this correctly: under an 8000 + 9000 vs 10000 budget, an implementation that superseded after enforce_snapshot_caps would evict via LRU and trip the snapshot_evictions_lru == 0 assertion.

CLI/env parity across all four surfaces. Flag names, env names, and defaults line up between ServeArgs (src/main.rs:1770), ServerArgs (src/bin/mlx_server.rs:1155), both build_startup_input mappings, and cli_input.rs. Defaults quoted in help text match what is compiled: 512 MiB / 4096 / 7200 s, from DEFAULT_SNAPSHOT_CAPACITY_BYTES = DEFAULT_CAPACITY_BYTES / 4, DEFAULT_MAX_ENTRIES * 4, DEFAULT_TTL_SECONDS * 2. env_fallback_prompt_cache_snapshot_ttl is a line-for-line mirror of the existing env_fallback_prompt_cache_ttl, including the CLI-precedence info log and the warn-and-ignore on an unparseable value.

Counter is not dead. snapshot_supersedes runs Inner -> PromptCacheStats -> build_stats_response -> CacheStatsResponse, on both the enabled and disabled branches.

Independent end-to-end check of the env path. Started mlxcel-server from the built binary with all three MLXCEL_* variables set and no CLI flags. Startup logged snapshot_capacity_bytes=41943040 snapshot_max_entries=7 snapshot_ttl_seconds=123, and /v1/cache/stats reported the matching snapshot_capacity_bytes and snapshot_max_entries with snapshot_supersedes live on the payload. Both --help surfaces confirmed on freshly built binaries (mlxcel serve --help and mlxcel-server --help each list all three flags).

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 None-session case, the fork case, and the byte accounting, and nothing in the PR claims HTTP-level supersede. That matches the epic, where #1143 is the load-bearing fix that makes the donated vector prefix-stable and is still open, so supersede cannot fire on the donate path yet. Deferring integration onto a sibling in the same epic is what the project convention allows.

Fixed on this branch

  • 3efebe30 The --prompt-cache-snapshot-capacity-bytes help text on both binaries still told operators to budget two to three times a single conversation's snapshot. ead5ac9f had corrected that assumption for --prompt-cache-snapshot-max-entries and the docs but not here, so the capacity advice under-sized the store by roughly the turn count and pointed at the exact thrash this issue removes. Now: measure snapshot_bytes after one turn, multiply by the entries expected resident, count turns as well as concurrent sessions.
  • 8af676e4 snapshot_supersedes was set on the /v1/cache/stats serialization fixture but absent from the asserted key list, leaving the wire name unpinned. Added. Also recorded in the supersede comment that "session" means the resolved key: callers setting neither prompt_cache_key nor user all land on ANONYMOUS_SESSION_SENTINEL and share one bucket here exactly as they already do in lookup_longest_prefix. Not a practical false-supersede risk, since it still needs one caller's whole stored vector to be a strict prefix of another's, but the previous wording implied per-caller isolation.
  • PR body: the scope note justified deferring the model-aware default with "config resolution runs during CLI parsing, before any weights are read". True of build_prompt_cache_config, but it does not establish the constraint: the store is built in start_server (src/server/startup.rs:2006) with model_path in hand, twenty lines below a detect_hybrid_ssm_from_path call that already reads config.json and mutates the same config.prompt_cache to auto-disable APC. Rewritten to give the real reason, which is that per-family state-size formulas for thirteen families are their own piece of work.

Remaining, deliberately not changed

  • Proposed Solution (b)'s model-aware default is deferred, now honestly justified. Worth a follow-up issue rather than growing this PR.
  • Supersede emits no PromptCacheMetrics event, so Prometheus /metrics shows snapshot inserts climbing with no removal signal; only /v1/cache/stats carries the counter. Adding one means extending the trait and both impls, and nothing in the issue asks for it.
  • The supersede scan is O(snapshot entries) per insert with string comparisons in the filter. At the 4096 default cap this is nothing against prefill cost, but it is the only full scan on that path.

Verification

  • All stated acceptance criteria implemented (supersede rule, its boundaries, byte ordering, three knobs, tests beside the store)
  • No placeholder or mock code; ModelSnapshotEntry::new_for_test is #[cfg(test)] and mirrors the existing CacheEntry::new_for_test
  • Integrated into the production code flow: insert_snapshot is the donate path in scheduler.rs:2275 and compose_prompt_cache_key always supplies Some(session_key). Firing awaits feat: Donate a history-boundary snapshot during prefill for snapshot-only families #1143, per the epic's own dependency order
  • Project conventions followed: _tests.rs siblings, no unwrap/expect added outside tests, no shared hotspot from docs/code-guidelines.md touched
  • Existing modules reused: the env-fallback helpers, with_snapshot_limits, and the new_for_test pattern all follow their existing siblings
  • No unintended structural changes; every edit is additive except the pub use re-export list
  • Tests pass: cargo check --lib --tests, cargo clippy --lib --tests -- -D warnings clean, cargo fmt --check clean, store tests 28 passed, server::cli_input 98 passed, server::routes::cache 17 passed

@inureyes inureyes added status:done Completed and removed status:review Under review labels Aug 14, 2026
@inureyes
inureyes merged commit 909676c into main Aug 14, 2026
8 checks passed
inureyes added a commit that referenced this pull request Aug 14, 2026
## 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
@inureyes
inureyes deleted the feature/issue-1146-snapshot-capacity-hygiene branch August 19, 2026 10:49
@inureyes inureyes self-assigned this Aug 27, 2026
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
…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
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli Command-line interface / CLI flags area:inference Generation, sampling, decoding (incl. speculative, DRY) priority:high High priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Snapshot capacity hygiene: session-chain supersede and configurable budget

1 participant