Reuse the KV prefix in thinking mode on the continuous path#4
Conversation
The session path keeps a "visible transcript" checkpoint so a thinking
conversation can continue from live KV even though the next prompt omits the
hidden reasoning. The continuous batching path never got that treatment:
cont_warm_retire() dropped the record for every thinking row, so no follow-up
request could ever match one.
Two things follow from that. A thinking conversation re-prefills its entire
prompt on every turn, and because a bank without a record never becomes
reuse-trustworthy, its KV is never written to the disk cache either. The second
looks like "disk caching is broken" but has the same cause.
Measured on a DGX Spark (GB10, ctx 524288) with a coding agent at a 29,234
token prompt, on byte-identical requests: turn two took 40.86s with thinking
on and 0.80s with thinking off. Every thinking turn logged
"cont admit pos_base=0 suffix=<whole prompt>".
A raw text key is only wrong when the next rendering drops the reasoning, and
prompt_preserves_reasoning already tells us when that happens:
- reasoning preserved: keep the raw key, it is exactly what comes back.
- otherwise: key on the visible transcript, as the session path does. The
payload stays the bank's committed frontier, so the hidden reasoning stays
in KV and is never re-prefilled.
The visible-key branch uses the same gates as
should_remember_thinking_checkpoint(): chat only, no tools, a clean finish and
a closed thinking block. Anything else retires without a record, as before.
After the change, on the same box, turn two reuses in every thinking shape:
no tools "fork admit cached=3419 suffix=9", 35 tools "partial truncate admit
cut=5267 suffix=21", reasoning echoed back "warm admit cached=3443 suffix=9".
Both banks now persist to the disk cache at shutdown. Non-thinking requests
are unchanged, and a needle hidden in the reused prefix is still answered
exactly. make test passes.
|
Excellent find, and a genuinely well-built fix. Thank you for the depth of I verified the change independently before merging: Code review. The mechanism checks out end to end against the tree. Behavioral gate (DGX Spark GB10, your branch at
Zero cont failures, zero serial fallbacks, zero illegal accesses across Merging now that the v0.4.1 tag has landed (a speculation-controller |
|
Happy to help. Thanks for the work done here. I'm having a lot of fun running DeepSeek v4 Flash on a GB10 system thanks to this project. The performance and quality of the model is incredibly good. |
I've been chasing this issue after playing with v0.2.3 yesterday and v0.4.0 today. My sessions on pi and Open WebUI were clearly re-processing the whole conversation on each turn.
This PR addresses the issue following an approach already used upstream in antirez/ds4, which this fork's newer continuous-batching path never inherited. I think it also explains Entrpi/ds4-on-spark#4, opened earlier today with little detail and no logs.
Code was prepared with assistance from Claude, but led and reviewed by me. It's worth a second look, though.
What happens today
On the continuous batching path, a conversation in thinking mode re-prefills its entire prompt on every single turn. The prefix cache never hits.
It shows up in the serve log as an unbroken run of cold admissions, where the conversation only ever grows by a few tokens per turn:
pos_base=0on every turn means nothing was reused. The same conversation sent to the non-thinkingdeepseek-chatalias reuses normally:That is 40.86s versus 0.80s for turn two, on byte-identical prompts, with only the thinking mode differing. Measured on a DGX Spark (GB10) at ctx 524288 with a real coding agent whose preamble is 29,234 tokens.
There is a second symptom with the same cause. A bank that never gets a warm record never becomes reuse-trustworthy, so it is never written to the disk KV cache either, not even on a clean shutdown. With
--kv-disk-dirconfigured and a 64 GiB budget, the directory stayed completely empty across a full day of thinking-mode traffic and four clean stops. This is very likely what Entrpi/ds4-on-spark#4 ("nothing is getting written to disk, and it's causing a lot of prompt reprocessing") is actually reporting: two symptoms, one cause.Cause
cont_warm_retire()inds4_server.cbailed out for every thinking row:so no warm record was ever built and
cont_warm_pick()had nothing to match.The session path already solves this problem with visible-live checkpoints (
build_toolless_thinking_visible_text,thinking_live_visible_prefix_prompt): it keys the checkpoint on the transcript the client will replay, while the payload stays the exact sampled frontier including the hidden reasoning. The continuous batching path, added later, never inherited that idea.It is not a recent regression.
git tag --containsputs the guard inv0.1.0onward.The fix
A raw text key is only wrong when the next rendering drops the reasoning bytes, and
prompt_preserves_reasoningalready tells us when that happens:render_chat_prompt_textreplays it): the raw key is exactly what comes back, so retire normally.cont_warm_pickalready pairs with a tokenized text suffix, so the hidden reasoning stays resident in KV and is never re-prefilled.The visible-key branch takes the same conservative gates as
should_remember_thinking_checkpoint(): chat only, tool-less, a clean finish and a closed thinking block. Anything that does not qualify retires without a record, exactly as before.48 insertions, 13 deletions, one file.
Testing
Machine: NVIDIA DGX Spark (GB10, sm_121, 128 GB unified memory), CUDA backend, driver 580.159.03 / CUDA 13.0, Ubuntu.
Model:
DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf(IQ2_XXS/Q2K experts, Q8 dense), DSpark drafter armed, ctx 524288.Build:
make cuda CUDA_ARCH=sm_121with-DDS4_CUDA_SPARK_HBM_CACHE=1.DS4_TEST_MODEL=.../DeepSeek-V4-Flash-...-imatrix.gguf make testpasses in full against the patched build, with the model loaded and the GPU otherwise idle (4m28s). Tensor equivalence is exact across all five cases:
top1_mismatch=0 worst_rms=0 worst_max_abs=0.Turn two now reuses in every thinking shape that was previously broken:
pos_base=0fork admit cached=3419 suffix=9pos_base=0partial truncate admit cut=5267 suffix=21pos_base=0warm admit cached=3443 suffix=9The disk tier now receives thinking-mode checkpoints, which it never did before:
Correctness was checked rather than assumed: a needle placed deep in the prompt and asked for only on the reused turn (
pos_base=8448, so genuinely served from reused KV) came back exact.Verified live on the serving stack
The numbers above swap the model id to isolate thinking mode. This is the cleaner comparison: the same real coding agent, the same 29,244 token preamble, thinking mode on both runs, only the binary differs.
Before, on the stock build:
After, with the patch, same client and same conversation shape:
Turn one is identical to the token, as it should be. Turn two goes from 39.40s to 0.80s, and reuse holds as the conversation grows, through both admit paths (fork into a free bank, then truncate in place). This client sends 35 tool schemas, so it exercises the
prompt_preserves_reasoningbranch; the tool-less visible-transcript branch is covered by the table above.Notes on the other regression tracks
make cpufails on this branch, but it already fails identically at the unmodified base commit (4880163), so it is not something this change introduced:I did not run the
ds4-benchspeed sweep. This change touches server-side warm record bookkeeping at job retirement, not any inference backend or kernel: the only added work is one detokenization pass over the generated tokens for thinking rows, which the non-thinking path already performed on every turn. Happy to run a before/after sweep if you would like it on the record.Scope and limits
lengthretire without a record, matching the session path's existing conservatism. Loosening that is a separate question.