Skip to content

deepseek_v4: reuse the KV prefix instead of re-prefilling every turn - #831

Merged
JustVugg merged 1 commit into
devfrom
feat/v4-kv-prefix
Aug 4, 2026
Merged

deepseek_v4: reuse the KV prefix instead of re-prefilling every turn#831
JustVugg merged 1 commit into
devfrom
feat/v4-kv-prefix

Conversation

@JustVugg

@JustVugg JustVugg commented Aug 4, 2026

Copy link
Copy Markdown
Owner

DeepSeek V4 was the only engine in the tree that re-prefilled the entire 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 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_state rather than per-position rows. That is a real difference from GLM: MLA rows there are position-addressed and self-contained, which is why colibri.c can truncate to any shared prefix and even copy rows between slots under COLI_KV_SHARE.

What this state can do is keep going. So the case handled here is exactly the 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. 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_alloc failing is not an error. Per the header's own contract it leaves the record empty, kv_prefix_reuse returns 0, and every request prefills in full.

Tests

tests/test_deepseek_v4_prefix.py speaks the SUBMIT/DATA/DONE protocol 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.

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 11 existing checks still pass.

The DONE frame gained a trailing reuse count so the test can measure it rather than scrape stderr; openai_server.py parses len(fields) >= 7, so older readers ignore it. V4_PREFIX_LOG=1 prints 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).

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>
@JustVugg
JustVugg merged commit ecd4cc8 into dev Aug 4, 2026
14 checks passed
@JustVugg
JustVugg deleted the feat/v4-kv-prefix branch August 5, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant