deepseek_v4: reuse the KV prefix instead of re-prefilling every turn - #831
Merged
Conversation
DeepSeek V4 was the only engine in the tree that re-prefilled the whole context on every request. colibri.c has done same-slot prefix reuse since it had a serve path; inkling.c (#786) and kimi_k3.c (#787) got it through the shared kv_prefix.h. This adds the third user of that header. The visible effect is the one people report as "the second message is slower than the first": turn ten was paying for turns one through nine again, with the dense tensors and expert cache already warm. ## Why the reusable case is narrow, and why that is enough The window attention state cannot be truncated to an arbitrary position. The sliding window is a ring, and the compressor carries recurrent kv_state/score_state rather than per-position rows -- unlike GLM's MLA rows, which are position-addressed and self-contained, so colibri.c can truncate to any shared prefix and even copy rows between slots. What this state can do is keep going. So the case handled here is the exact one a conversation produces: turn N+1's prompt begins with every id turn N fed, prompt and reply alike, and only the tail is new. kv_prefix_reuse returns 0 unless the record is a strict prefix of the new prompt, so an identical prompt, a shorter one, or a divergent one all fall back to a full reset and prefill. ## Correctness Positions stay absolute: the fresh tail is prefilled with start=reuse, so every token sees the same position it would have in a cold run. Only the batch indexing shifts, since the batch now holds the tail alone. The record tracks what was actually fed, not what was asked for. The last generated token is emitted but never fed back, so it is recorded inside the decode loop as each token enters the state rather than in bulk afterwards -- recording after the loop would claim one token too many and corrupt the next turn. Every failure path taints the record, because a half-updated attention state matches neither the old ids nor the new. kv_prefix_alloc failing is not an error. Per the header's contract it leaves the record empty, reuse returns 0, and every request prefills in full -- exactly the behaviour before this change. ## Tests tests/test_deepseek_v4_prefix.py drives the SUBMIT/DATA/DONE protocol directly and runs each second turn twice: once continuing a warm session, once against a freshly started engine. It asserts they agree token for token, so the test fails if reuse changes the output, and separately that reuse actually fired -- an optimisation that silently never engages would otherwise pass every correctness check. PASS prefix reuse: 11 tokens reused, output identical to a cold prefill PASS prefix repeat: identical prompt re-prefills, answer unchanged PASS prefix reset: divergent prompt re-prefills and matches cold Wired into `make deepseek-v4-tiny-check`; the existing 11 checks still pass. The DONE frame gained a trailing reuse count so the test can measure it; openai_server.py parses `len(fields) >= 7`, so older readers ignore it. V4_PREFIX_LOG=1 prints the reuse length per request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DeepSeek V4 was the only engine in the tree that re-prefilled the entire context on every request.
colibri.chas done same-slot prefix reuse since it had a serve path;inkling.c(#786) andkimi_k3.c(#787) got it through the sharedkv_prefix.h. This makes DeepSeek the third user of that header.The visible symptom is the one people report as "the second message is slower than the first". The engine, dense tensors, head and expert cache all stay warm across requests β but turn ten was still paying to prefill turns one through nine.
Why the reusable case is narrow, and why that is enough
The window attention state cannot be truncated to an arbitrary position. The sliding window is a ring buffer, and the compressor carries recurrent
kv_state/score_staterather than per-position rows. That is a real difference from GLM: MLA rows there are position-addressed and self-contained, which is whycolibri.ccan truncate to any shared prefix and even copy rows between slots underCOLI_KV_SHARE.What this state can do is keep going. So the case handled here is exactly the one a conversation produces:
kv_prefix_reusereturns 0 unless the record is a strict prefix of the new prompt, so an identical prompt, a shorter one, or a divergent one all fall back to a full reset and prefill. No new failure mode: the fallback is the current behaviour.Correctness
Positions stay absolute. The fresh tail is prefilled with
start=reuse, so every token sees the position it would have seen in a cold run; only the batch indexing shifts, because the batch now holds the tail alone.The record tracks what was actually fed, not what was asked for. The last generated token is emitted but never fed back, so tokens are recorded inside the decode loop as each one enters the state β recording in bulk afterwards would claim one token too many and corrupt the following turn. Every failure path taints the record, because a half-updated attention state matches neither the old ids nor the new ones.
kv_prefix_allocfailing is not an error. Per the header's own contract it leaves the record empty,kv_prefix_reusereturns 0, and every request prefills in full.Tests
tests/test_deepseek_v4_prefix.pyspeaks theSUBMIT/DATA/DONEprotocol directly and runs each second turn twice: once continuing a warm session, once against a freshly started engine. It requires the two to agree token for token.It also asserts that reuse actually fired. That check matters more than it looks: an optimisation that silently never engages passes every correctness test ever written for it. The first version of this test did exactly that β it built a second turn shorter than the recorded sequence, reuse correctly declined, and the only thing that caught it was asserting on the count.
Wired into
make deepseek-v4-tiny-check. The 11 existing checks still pass.The
DONEframe gained a trailing reuse count so the test can measure it rather than scrape stderr;openai_server.pyparseslen(fields) >= 7, so older readers ignore it.V4_PREFIX_LOG=1prints the reuse length per request.What this does not do
No cross-slot sharing β V4 serve is single-slot today. No arbitrary-position truncation; that would need a compressor snapshot at the reuse boundary, which is worth doing only if a workload turns up that needs it. Follow-up to #165 (@DrewZt).