Repository navigation
feat(core): shared snapshot serialization for KVCache / RotatingKVCache / ChunkedKVCache so Gemma 3, AFMoE and Llama 4 join exact-prefix prompt-cache reuse #1335
Description
Summary
Prompt-cache reuse in the server has two producers: detached per-layer KVCache sets for families whose KV lives in the scheduler's CachePool (radix prefix matching, partial-block adoption), and exact-prefix ModelStateSnapshot copies for families that own their caches and opt in through LanguageModel::supports_snapshot_reuse(). The opt-in currently requires each family to hand-write per-cache-type serialization, and only Gemma 4 and Muse Glimmer have done so for the attention cache types. Gemma 3, AFMoE (which reuses Gemma 3's Cache enum) and Llama 4 hold ordinary KVCache / RotatingKVCache / ChunkedKVCache state in ModelOwnedSequenceState and are silently excluded from every prompt-cache path, so every multi-turn request on those families re-prefills the whole conversation. This change moves the KV-type serialization into one shared module and opts those three families in, with the same truncation rule Gemma 4 already uses.
Current behavior
Donation is skipped for any model-owned family that does not declare snapshot reuse (src/server/batch/scheduler.rs:2982-2996):
if self.model.supports_snapshot_reuse() {
self.insert_model_state_snapshot(seq_id, &ctx, tokens, SnapshotOrigin::Completion);
return;
}
let backend = self.cache_pool.get_mut(seq_id).map(|s| s.backend)
.unwrap_or(SequenceStateBackend::ModelOwned);
// Other `ModelOwned` families carry no detachable cross-request KV.
if backend == SequenceStateBackend::ModelOwned {
return;
}Lookup is gated the same way (src/server/batch/scheduler.rs:2026-2036): supports_snapshot_reuse() == false yields SnapshotLookupOutcome::NoCandidate before the store is consulted.
Families affected, with the cache types they own:
| Family | Layout | Cache types | Snapshot opt-in |
|---|---|---|---|
gemma3 (src/models/gemma3.rs:1222-1228) |
SequenceStateLayout::model_owned, batching on |
gemma3::Cache::{Standard(KVCache), Rotating(RotatingKVCache)} (src/models/gemma3.rs:644-647) |
none |
afmoe (src/models/afmoe.rs:114, :1149) |
model-owned, reuses gemma3::Cache |
same | none |
llama4 (src/models/llama4.rs:38-44, :1660, :1721) |
model-owned | Llama4Cache::{Chunked(ChunkedKVCache), Regular(KVCache)} |
none |
Gemma 4 already carries a complete, tested serializer for Standard and Rotating caches (src/models/gemma4.rs:1196-1350, truncation rule at :1370-1440), and Muse Glimmer carries a near-identical copy (src/models/muse_glimmer_cache.rs:58-206). The scalar tags kv_cache_mode_to_i32 / kv_cache_mode_from_i32 are private to gemma4.rs:1139-1160. No serializer exists for ChunkedKVCache anywhere (grep -rn "chunked\." src/models/ is empty).
Cache types that are therefore not prefix-cacheable today, for the record:
KVCache/RotatingKVCacheinside a model-owned enum of gemma3, afmoe, gpt_oss, exaone4, ministral3, step3p5 (the last four hold them inRefCell<Vec<Cache>>, seesrc/models/gpt_oss.rs:1107,src/models/exaone4.rs:685,src/models/ministral3.rs:645,src/models/step3p5.rs:1026, and are single-stream).ChunkedKVCache(llama4 only).KimiDeltaCache(src/models/kimi_linear.rs:395),RGLRUCache(src/models/recurrent_gemma.rs:127),Rwkv7Cache(src/models/rwkv7.rs:78; hassnapshot_intobut the family never opts in). These three are blocked by fix(models): RWKV7, RecurrentGemma, and KimiLinear rebuild their recurrent state every decode step, so generation runs with no history #1220 (their state is rebuilt every forward) and stay out of scope here.RingSlidingKVCache(src/lib/mlxcel-core/src/cache/ring.rs:54, Unlimited-OCR only), by design.
DetachedRotatingKVCache (src/lib/mlxcel-core/src/cache/detach.rs:621) exists but has no caller outside its own module; the CachePool detach path only handles Vec<KVCache>.
Expected behavior
- A shared module
src/models/kv_snapshot.rsexposes, for the three standard attention cache types,snapshot_into(cache, snapshot, prefix) -> Result<(), String>,restore_from(cache, snapshot, prefix) -> Result<(), String>andsnapshot_truncatable_to(snapshot, prefix, target_len) -> bool, pluskv_cache_mode_to_i32/kv_cache_mode_from_i32moved out ofgemma4.rs. Gemma 4 and Muse Glimmer call the shared functions; their behavior and tensor names do not change. - Serialization contract (all scalars stored through
recurrent_snapshot::push_i32, all tensors copied throughModelStateSnapshot::push_tensor, which already deep-copies; onlyKVCacheMode::Fp16is accepted, any other mode returnsErrso the donation is skipped, exactly as Gemma 4 does today):KVCache:{prefix}.standard.keys[B, H_kv, T_alloc, D],{prefix}.standard.values[B, H_kv, T_alloc, D],{prefix}.standard.offset(i32, logical length;T_alloc >= offsetbecause buffers grow bystep),{prefix}.standard.mode(i32 tag). Restore setskeys,values,offset,mode = Fp16. Truncation totarget_len <= offset: always allowed (restore thenKVCache::trim(offset - target_len)).RotatingKVCache:{prefix}.rotating.keys,{prefix}.rotating.values(physical ring buffers,[B, H_kv, min(offset, max_size) or the over-window prefill length, D]),{prefix}.rotating.max_size,{prefix}.rotating.buffer_size,{prefix}.rotating.offset,{prefix}.rotating.start_position,{prefix}.rotating.idx,{prefix}.rotating.step,{prefix}.rotating.mode,{prefix}.rotating.turbo_seed. Restore goes throughRotatingKVCache::restore_fp16_snapshot_state(RotatingKVCacheSnapshotState, keys, values)(src/lib/mlxcel-core/src/cache.rs:5260). Truncation allowed only whenRotatingKVCacheSnapshotState::can_truncate_to(target_len)(cache.rs:4185) holds, i.e.buffer_size == 0 && idx == offset && offset <= max_size && target_len <= offset: the ring has not wrapped so logical tokentsits at slott.ChunkedKVCache(new):{prefix}.chunked.keys,{prefix}.chunked.values([B, H_kv, offset - start_position, D]aftermaybe_trim_front),{prefix}.chunked.chunk_size,{prefix}.chunked.offset,{prefix}.chunked.start_position. Restore assigns the three scalars and the two tensors (thestepfield stays at its constructor default of 256; it only affects future growth). Truncation totarget_lenallowed only whenstart_position == 0 && target_len <= offset: once the front has been trimmed the window a fresh prefill would hold,[target_len - chunk_size, target_len), is wider than[start_position, target_len), so a truncated restore would attend over fewer tokens than a cold prefill and is not exact. When allowed, restore then slicekeys/valuesto[.., .., :target_len, ..]and setoffset = target_len.
- Gemma 3 and AFMoE:
gemma3::Cachegainssnapshot_into/restore_from/snapshot_truncatable_to/truncate_todelegating to the shared module with the samelayer{idx}prefix scheme Gemma 4 uses, andGemma3Model/AfmoeModelimplementsupports_snapshot_reuse() -> true,snapshot_sequence_state,restore_sequence_state,snapshot_truncatable_to,restore_sequence_state_truncatedfollowing the Gemma 4 template atsrc/models/gemma4.rs:5968-6070. Family tags:"gemma3","afmoe". - Llama 4:
Llama4Cachegains the same four methods (Chunkedarm uses thechunked.*contract,Regulararm thestandard.*contract) andLlama4Modelopts in with family tag"llama4". - A second request whose token vector extends a finished request's
prompt + generatedvector on any of these families restores the snapshot and prefills only the appended tokens; the generated text is token-identical to a cold run at temperature 0.
Implementation plan
- Create
src/models/kv_snapshot.rs(register insrc/models/mod.rs). Movekv_cache_mode_to_i32/kv_cache_mode_from_i32fromsrc/models/gemma4.rs:1139-1160into it (make the error message family-neutral). Add:pub(crate) fn snapshot_standard(cache: &KVCache, snapshot: &mut ModelStateSnapshot, prefix: &str) -> Result<(), String>andrestore_standard(cache: &mut KVCache, snapshot, prefix) -> Result<(), String>: lift theSelf::Standardarms ofgemma4.rs:1204-1229and:1283-1308verbatim.snapshot_rotating/restore_rotating: lift theSelf::Rotatingarms ofgemma4.rs:1230-1273and:1309-1349.snapshot_chunked(cache: &ChunkedKVCache, ...)/restore_chunked(cache: &mut ChunkedKVCache, ...)per the contract above.ChunkedKVCache.stepis private (src/lib/mlxcel-core/src/cache.rs:5497); leave it at the constructor value.standard_truncatable_to,rotating_truncatable_to(liftgemma4.rs:1370-1440),chunked_truncatable_to(snapshot, prefix, target_len) -> boolreturningstart_position == 0 && 0 <= target_len <= offset.truncate_standard(cache, target_len)(callsKVCache::trim),truncate_rotating(reuse the Gemma 4Cache::truncate_tologic at the rotating arm),truncate_chunked(slicekeys/valueson axis 2 totarget_len, setoffset = target_len).- Use
recurrent_snapshot::{push_i32, push_optional, restore_i32, restore_optional}(src/models/recurrent_snapshot.rs:24-51) for every field.
- Replace the bodies of
gemma4::Cache::{snapshot_into, restore_from, snapshot_truncatable_to, truncate_to}andmuse_glimmer_cache::MuseCache::{snapshot_into, restore_from, ...}with calls intokv_snapshot. Keep the existing tensor names and error strings sosrc/models/gemma4_tests.rsandsrc/models/muse_glimmer_tests.rssnapshot tests pass unchanged. src/models/gemma3.rs: add toimpl Cache(:644) the four methods delegating tokv_snapshot. Inimpl LanguageModel for Gemma3Model(:1210-1240), addsupports_snapshot_reuse,snapshot_sequence_state(iterateself.sequence_state.with_sequence_state_ref(seq_id, ...), prefixlayer{idx}, returnNoneon anyErrafter atracing::warn!),restore_sequence_state(checksnapshot.family() == "gemma3", buildself.model.make_caches(),restore_fromper layer,self.sequence_state.replace_sequence_state(seq_id, state)),snapshot_truncatable_to(all layers agree),restore_sequence_state_truncated(re-check, restore, thentruncate_toper layer). Mirrorsrc/models/gemma4.rs:5968-6070.src/models/afmoe.rs: same five trait methods onAfmoeModel(:1658-1690region), family tag"afmoe", using thegemma3::Cachemethods from step 3.src/models/llama4.rs: addsnapshot_into/restore_from/snapshot_truncatable_to/truncate_toonLlama4Cache(:38), then the five trait methods on the model wrapper (:1713-1740region), family tag"llama4". The VLM wrapper around Llama 4 (if it forwardsLanguageModelcalls) must forward the five new methods too; checksrc/loaded_model.rsdispatch macro at:370picks them up automatically (it forwards every trait method).src/server/prompt_cache/hybrid_ssm.rs: no change; gemma3 / afmoe / llama4 are not inHYBRID_SSM_MODEL_TYPES, so APC block hashing stays enabled for them. The snapshot path does not use block hashes, so nothing else insrc/server/prompt_cache/changes.docs/turbo-kv-cache.md, section "Exact-prefix snapshots for recurrent state": list the snapshot-capable families and the per-type truncation rule (standard: always; rotating: unwrapped only; chunked: untrimmed only).
Validation
(a) Unit tests, src/models/kv_snapshot_tests.rs:
standard_round_trip_preserves_keys_values_offset: build aKVCache, push 37 tokens of random K/V, snapshot, restore into a fresh cache, assertoffset == 37andupdate_and_fetchon one more token returns K/V whose first 37 positions are bitwise equal.rotating_round_trip_preserves_ring_state_after_wrap:RotatingKVCache::new(16), push 40 tokens one at a time, snapshot, restore, assertsnapshot_state()equals the original (offset,idx,max_size) and that the nextupdate_and_fetchreturns the same 16-token window as the original cache.rotating_truncatable_only_while_unwrapped: at 10 of 16 tokenstruncatable_to(7) == true; at 40 tokens== false.chunked_round_trip_after_front_trim:ChunkedKVCache::new(8), push 20 tokens,maybe_trim_front, snapshot, restore, assertstart_position == 12,offset == 20, keys shape[1, H, 8, D].chunked_truncatable_only_before_front_trim: at 6 of 8 tokenstruncatable_to(4) == true; at 20 tokens== false.non_fp16_mode_is_rejected: aKVCache::new_with_mode(KVCacheMode::Int8)snapshot returnsErr.src/models/gemma3_tests.rs:snapshot_restore_matches_cold_decode: run a 24-token prefill plus 8 decode steps throughGemma3Modelwith a synthetic config (sliding_window = 8,sliding_window_pattern = 2, 2 layers), snapshot at 32, restore, decode 4 more tokens, compare logits to a cold 36-token run at the same positions (allclose, atol 1e-3 in f16).src/models/llama4_tests.rs: same shape withattention_chunk_size = 8.
(b) Real checkpoint:
./target/release/mlxcel-server -m models/gemma-3-4b-it-4bit --prompt-cache --port 8080
# turn 1
curl -s localhost:8080/v1/chat/completions -d '{"model":"gemma-3-4b-it-4bit","messages":[{"role":"system","content":"<a 300-token system prompt>"},{"role":"user","content":"List three facts about Jupiter."}],"max_tokens":64,"temperature":0}'
# turn 2: same system prompt + turn-1 assistant text + a new user message
curl -s localhost:8080/v1/chat/completions -d '{... turn-1 messages + assistant reply + {"role":"user","content":"Now Saturn."} ...,"max_tokens":64,"temperature":0}'
curl -s localhost:8080/v1/cache/statsAcceptance: /v1/cache/stats reports one snapshot hit for turn 2 with matched_len equal to the turn-1 prompt + generated length; turn-2 completion text is byte-identical to the same two requests served with --prompt-cache off; prompt_tokens time for turn 2 drops to the appended-tokens cost. Repeat with mlx-community/Llama-4-Scout-17B-16E-Instruct-4bit (chunked cache) and the AFMoE validation checkpoint mlx-community/Trinity-Nano-Preview-4bit. For Gemma 3 also run a third turn after the conversation exceeds sliding_window (1024) tokens and confirm the store still hits (exact restore) while snapshot_truncatable_to returns false for a shorter divergent prompt (a SnapshotDiverged reject, not a corrupt restore).
Acceptance criteria
-
src/models/kv_snapshot.rsowns the standard / rotating / chunked serializers and truncation rules;gemma4.rsandmuse_glimmer_cache.rsdelegate to it with unchanged tensor names. - Gemma 3, AFMoE and Llama 4 return
supports_snapshot_reuse() == trueand implement snapshot / restore / truncatable / truncated-restore. - Turn-2 snapshot hit on
mlx-community/gemma-3-4b-it-4bitandmlx-community/Llama-4-Scout-17B-16E-Instruct-4bitwith byte-identical output versus prompt cache off. Partly met in PR feat(core): share KV snapshot serialization across attention cache families #1752: the turn-2 hit and exact restore are confirmed on both, including a wrapped-ring Gemma 3 turn over the sliding window, but comparing against--no-prompt-cachedoes not isolate the snapshot (the server defaultspreserve_thinkingdifferently and the cold prefill path itself differs), so the comparison was run with the cache on in both arms. Two gaps remain:trinity-nano-preview-4bitcannot be judged by token identity because it is a documented degenerate preview checkpoint, and Gemma 3 diverges reproducibly when the stored snapshot covers exactly one decoded token. - Non-Fp16 KV modes are refused at snapshot time with a warning, never restored with a mismatched layout.
-
docs/turbo-kv-cache.mdlists the snapshot-capable families and per-type truncation rules. - cargo test --workspace --profile test-fast --features metal,accelerate passes
- cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all -- --check pass
Out of scope
- gpt_oss, exaone4, ministral3, step3p5: they keep caches in a
RefCell<Vec<Cache>>that is not keyed bySequenceId; migrating them toModelOwnedSequenceStateis a separate refactor and a prerequisite for snapshot reuse. - kimi_linear, recurrent_gemma, rwkv7: blocked by fix(models): RWKV7, RecurrentGemma, and KimiLinear rebuild their recurrent state every decode step, so generation runs with no history #1220.
- Block-level (radix / APC partial) reuse for rotating or chunked caches. Exact-prefix plus the unwrapped-only truncation rule is the correct contract for windowed caches.
- Snapshots of Int8 / Turbo KV modes (tracked separately in
prompt-cache-snapshot-non-fp16-kv-mode-gate).
Activity
fix(server): stop the prompt cache donating and adopting KV-less shad…
5 remaining items
docs(supported-models): note snapshot prompt-cache reuse for #1335 fa…
fix(cache): bound the rotating snapshot idx by the physical buffer
feat(core): share KV snapshot serialization, opt in three families
fix(server): apply the whole-entry policy to the snapshot adopt branch
fix(core): bound KV snapshot restores by their own buffers
test(server): the model-owned gate test now expects a snapshot donation
test(models): close snapshot validation and family-hook gaps for #1335
docs(supported-models): note snapshot prompt-cache reuse for #1335 fa…
docs(report): add the bilingual technical report for PR #1752
feat(core): share KV snapshot serialization across attention cache fa…
fix(speculative): harden prompt-lookup rollback gate, limits and CLI
fix(server): stop the prompt cache donating and adopting KV-less shad…