server: on-disk KV cache (--kv-cache-dir) — restore a conversation instead of re-prefilling it - #16
server: on-disk KV cache (--kv-cache-dir) — restore a conversation instead of re-prefilling it#16dzannotti wants to merge 4 commits into
Conversation
Prefill on a Strix Halo costs minutes where the same KV state reads back from NVMe in seconds, so it is worth spending disk to never prefill a prefix twice. Measured on gemma-4-26B-A4B at q8_0 KV: 15.4 KiB/token, so a 22.9k-token conversation is 344 MiB that saves in 45 ms and restores in 47 ms, against 18.4 s to re-prefill it. A full 262k-token slot is 4.13 GB: 1.9 s to restore, ~3.5 min to re-prefill. Each conversation is fingerprinted with sha1(model tag + serialized prompt tokens) and its sequence state written under that name; a returning conversation is restored into a free slot instead of being reprocessed. The model tag is the weights path plus the arch description rather than --alias, so renaming an alias does not invalidate a cache and two models sharing an alias cannot read each other's state. An entry is three files: <key>.kv target context state (llama_state_seq_save_file, carries the token list) <key>.dft draft context state, when speculative decoding is on <key>.idx index record, written last and deleted first, so it is the commit marker State blobs are multi-GiB, so .idx keeps the tokens resident: a lookup measures the longest common prefix against every stored conversation without reading any state. That matters because an exact whole-conversation hash can never hit on turn 2 - the next request appends a new user message - so lookup is prefix-anchored while the key stays a sha1 of the conversation. Selection reuses the in-memory tier's rule (only move if it both keeps more of the stored context and covers more of the incoming prompt), and skips entries below f_keep 0.25, since restoring gigabytes to reuse a sliver is slower than prefilling it. Writing an entry supersedes any stored prompt that is a prefix of it, so a conversation does not leave one file per turn. Eviction is least-recently-used against --kv-cache-max. Storing happens on slot reset, while the KV is still resident, so a finished turn is persisted immediately rather than when the next request happens to arrive. It runs on the inference thread because reading sequence state has to. --cache-ram 0 previously left no cache object at all; it now disables only the in-memory tier, and alloc() returns early rather than reading limit_size 0 as "no limit". Claude-Session: https://claude.ai/code/session_01JDKcT3SjBYaJKmpRJqTPmq
Replaces the token-prefix index with the exact-hash scheme this was meant to be.
An exact sha1 of the *arriving* conversation can never hit: every request carries one
more message than anything already stored. So the lookup hashes the conversation
truncated at its last assistant message, which is byte-identical to what the previous
turn saved:
turn 1 [sys, u1] -> no assistant message, no lookup
generate A1, store under key([sys, u1, A1])
turn 2 [sys, u1, A1, u2] -> truncate -> key([sys, u1, A1]) HIT
generate A2, store under key([sys, u1, A1, u2, A2])
One exact key, an O(1) map lookup, no resident token index and no longest-common-prefix
scan. The keys are built at the chat route, where the messages are still visible, and
travel to the slot on the task. The reply is hashed as the client will echo it back
(parsed content, reasoning stripped), since that is what the next turn will send.
Storing moved to send_final_response, because it has to happen before generated_text is
moved into the response - callback_on_reset sees it already empty.
Eviction now runs before every lookup, and drops entries by age (--kv-cache-ttl,
default 3 days) before falling back to least-recently-used against --kv-cache-max.
Two conversations that really are identical collapse onto one entry and share it, which
is correct: they restore the same prefix and then prefill their own continuations.
Claude-Session: https://claude.ai/code/session_01JDKcT3SjBYaJKmpRJqTPmq
Restoring a conversation was measurably worse than not caching at all on gemma4: turn 2 went from cache_n 1224 / prompt_n 13 with the cache off, to cache_n 0 with it on. gemma-4 is a sliding-window model. llama_state_seq_save_file only persists the SWA window, so after a restore llama_memory_seq_pos_min sits above pos_min_thold and update_slots will only reuse the prefix if a context checkpoint reaches further back. disk_load cleared prompt.checkpoints and the state file carried none, so the checkpoint search found nothing, do_reset fired and the entire restored prefix was thrown away. This is the same reason the in-memory tier works: alloc() copies prompt.checkpoints alongside the state blob. Store them in a fourth file, <key>.ckpt, and load them back. Claude-Session: https://claude.ai/code/session_01JDKcT3SjBYaJKmpRJqTPmq
|
putting it as draft as i want to run a couple more tests on 27b and flash also |
|
putting this back as open, i've verified against gemma4, ornith 1.5, qwen 3.8 flash and 3.8 27B and all displayed a reduced number of tokens being prefilled |
disk_load restored unconditionally whenever the key was found on disk, without ever looking at what the slot already contained. On a returning turn that lands back on the slot holding its own state -- which --slot-prompt-similarity makes likely -- that reads hundreds of MiB off disk to install a byte-identical copy, and drops the checkpoints built since the last restore on the way through. On qwen38-27b, whose entries are about 2 GiB, that is 2 GiB read to change nothing. Track the conversation key whose KV a slot currently holds, set it both when a turn is stored and when one is restored, clear it in prompt_clear(), and skip the restore when it already matches. The key stored after generation is exactly the key the next turn looks itself up by, so the comparison is direct. Both directions of a stale key are safe: a missed skip costs one needless read, and a missed restore falls back to the ordinary common-prefix path and prefills more. Neither can produce wrong output. The existing 1,2,3,4 / 1,2,4,3 verification cannot catch this, because four conversations through two slots evict between every turn, so the slot never already holds the one being asked for. Added a 1,2,1,2 case, where both conversations stay resident. The broker this replaced had the same guard (`previous == key`), and it was dropped in the port. Claude-Session: https://claude.ai/code/session_01JDKcT3SjBYaJKmpRJqTPmq
|
Pushed a fourth commit: do not restore a conversation the slot already holds.
The fix tracks the conversation key whose KV a slot currently holds, sets it on both store and restore, clears it in Both directions of a stale key are safe: a missed skip costs one needless read; a missed restore falls back to the ordinary common-prefix path and prefills more. Neither can produce wrong output. Why it was not caught earlier: the verification workload is 4 conversations x 2 turns interleaved Verified on gemma-4-26B-A4B, Zero disk reads, and reuse is unaffected because the ordinary prefix logic handles the resident case. Compile-checked against this fork's base, as with the other three commits. |
Prefill on a Strix Halo costs minutes where the same KV state reads back from NVMe in seconds, so it is worth spending disk to never prefill a conversation twice.
Cherry-picked onto this fork's
masterrather than PR'd from an upstream-based branch, so the diff is the feature only. Compile-checked against this base.Keying
An exact sha1 of the arriving conversation can never hit — every request carries one more message than anything already stored. So the lookup hashes the conversation truncated at its last assistant message, which is byte-identical to what the previous turn saved:
One exact key, an O(1) map lookup, no resident token index and no longest-common-prefix scan. Keys are built at the chat route where the messages are still visible and travel to the slot on the task. The reply is hashed as the client will echo it back (parsed content, reasoning stripped). Keys are bound to the weights path + arch description rather than
--alias, so renaming an alias does not invalidate a cache and two models sharing one cannot read each other's state.An entry
.kv/.dftare written under.tmpand renamed;.idxgoes last. A crash mid-write leaves a.tmpthat startup sweeps; eviction deletes.idxfirst.Checkpoints are not optional on SWA models
Without
.ckptthis feature is worse than no cache.llama_state_seq_save_fileonly persists the SWA window, so after a restorellama_memory_seq_pos_minsits abovepos_min_tholdandupdate_slotswill only reuse the prefix if a context checkpoint reaches further back. Measured on gemma-4-26B-A4B, turn 2:Same reason the in-memory tier works —
alloc()copiesprompt.checkpointsalongside the blob.Eviction
Runs before every lookup, so an expired conversation is never resurrected. Age first (
--kv-cache-ttl, default 3 days), then least-recently-used against--kv-cache-max.Flags
--kv-cache-dir PATH— enable (default off)--kv-cache-max N— MiB, LRU budget, 0 = no limit--kv-cache-ttl N— seconds, default 259200--kv-cache-min-tokens N— default 256--cache-ram 0previously left no cache object at all; it now disables only the in-memory tier, andalloc()returns early rather than readinglimit_size 0as "no limit".Measured
4 conversations x 2 turns interleaved
1 2 3 4 / 1 2 4 3through 2 slots of 262144,kv_unified=false,--cache-ram 0, gfx1151/ROCm 10:Restore measured at 47 ms for a 22.9k-token conversation vs 18.4 s to re-prefill; ~1.9 s vs ~3.5 min for a full 262k slot.
Cross-contamination: 8/8, covering four conversations whose system prompts differ by a single token, and four that are byte-identical after turn 1 (so they collapse onto one shared entry) then diverge. No conversation ever produced another's secret.