server: avoid full prefix rewind when speculative decode stops - #1000
server: avoid full prefix rewind when speculative decode stops#1000aaa2015 wants to merge 1 commit into
Conversation
| if (stop_decode) break; | ||
| if (stop_decode) { | ||
| if (kept < ntok && !text_stop && !job_cancelled(j) && | ||
| ds4_engine_is_glm_dsa(s->engine)) { |
There was a problem hiding this comment.
This condition on GLM seems odd and AI found a counterexample:
A DeepSeek speculative block returns [CLOSE, EXTRA], where CLOSE completes a tool call and EXTRA is a subsequent token. Both have already been evaluated and committed to the session.
The server consumes CLOSE, detects the tool end, and stops with:
kept = 1
ntok = 2
stop_decode = true
In the final PR revision, stop_decode skips server_generation_rewind(), and the direct terminal rewind is GLM-only. DeepSeek therefore retains EXTRA, even though it was never consumed into the response.
For a successful Responses tool turn, finalization binds the tool-call ID to that longer session. A subsequent matching tool-result request copies the entire live history:
Expected: [prior history, CLOSE, tool result]
Actual: [prior history, CLOSE, EXTRA, tool result]
The response can finish quickly and the next request can report a cache hit, while still conditioning generation on the discarded EXTRA.
There was a problem hiding this comment.
Good catch, thank you! You are completely right.
Restricting the terminal rewind to GLM in 0cb5c28 was an attempt to avoid invalidating the DeepSeek recurrent checkpoint (which happens because DeepSeek's compressed DSA cache cannot be truncated in-place). However, as your counterexample demonstrates, leaving EXTRA in slot->session causes silent state poisoning across tool-result turns in the Responses API.
I have pushed commit 472c63d to remove the GLM-only guard so that all backends properly rewind to block_start + kept on stop_decode.
This ensures:
- Discarded speculative draft tokens (
EXTRA) are strictly excluded from the session before binding tool-call continuation handles. - The current streaming request exits cleanly with 0 tail latency (without freezing for 14-28s doing a synchronous full-prompt rebuild before emitting
[DONE]).
There was a problem hiding this comment.
Note I can't merge PRs, but I will look over this later today or tomorrow.
I thought a bit about speculative decoding and this token boundary and found another approach to speed things up (see PR #1003). This may solve your problem too, but I haven't yet checked.
There was a problem hiding this comment.
Thank you @emilianbold! PR #1003 is brilliant and a huge leap forward.
I looked closely at PR #1003 (Reuse DSpark snapshots for immediate session rewinds). Retaining the weak handle to the DSpark verification snapshot turns what used to be a full prefix rebuild (10s to 100s on long contexts) into an instantaneous ~100ms restore and replay. That fundamentally solves the recurrent DSA rollback bottleneck on DeepSeek!
The two PRs actually complement each other nicely across two different layers:
- PR server: avoid full prefix rewind when speculative decode stops #1000 operates at the server lifecycle layer: it ensures that once decoding has terminated (
stop_decode == true, e.g. on EOS, client stop sequence, or tool-end), the server exits cleanly without attempting mid-stream resampling or delaying the emission of[DONE]. - PR Reuse DSpark snapshots for immediate session rewinds #1003 operates at the engine session rewind layer: when
ds4_session_rewind()is invoked (whether at the terminal boundary from server: avoid full prefix rewind when speculative decode stops #1000 or during mid-stream syntax resampling), it restores the snapshot directly and preservescheckpoint_valid = truerather than invalidating the checkpoint.
Together, they make the speculative tool-calling lifecycle both mathematically sound (no phantom EXTRA draft tokens leaked across tool-result turns) and lightning fast (0-delay stream termination + ~100ms checkpoint preservation).
Really appreciate your sharp review on #1000 and the fantastic follow-up in #1003!
472c63d to
6033d1f
Compare
Speculative decode commits a whole block, so a stop token can leave accepted-but-unreported draft tokens past the boundary. The old code only rewound when the model had a rollback frontier, which skipped DeepSeek: a live tool-result continuation then appended after a token the client never saw and corrupted the conversation history. Rewind unconditionally. DeepSeek has no frontier and drops its checkpoint here, costing one rebuild on the next request -- the correct trade against a wrong prefix. Also stop when the completion budget or the context window is exhausted, so an out-of-range draft is discarded rather than reused.
6033d1f to
39cb595
Compare
|
Heads-up before you take a look: the branch has been rebased onto current The mechanism is the same one described above — gate the mid-stream resample at the terminal boundary, and rewind the session in place instead — with one addition: the terminal rewind is now skipped when For your counterexample the net effect is unchanged: To be explicit about the cost, since I glossed over it before: on DeepSeek without #1003, |
|
I just had my AI do a quick pass and found another counterexample:
|
|
Sharp catch, @emilianbold! Thank you for the detailed trace and the model-free reproduction. You are completely right. I traced the failure mode you described:
This also explains why commit 5b3cc8b originally added This highlights why your PR #1003 (
When tested with PR #1003 applied,
For environments where #1003 is not yet present (e.g. non-Metal backends or snapshot misses), we have two possible strategies:
What are your thoughts on landing #1003 first / in tandem, or would you prefer a fallback guard in #1000 for engines without snapshot rollback? |
Summary
Commit 5b3cc8b introduced
server_generation_rewind()to resample across syntax boundaries and discard unconsumed speculative tokens. However, the rewind was called wheneverkept < ntok, without guarding against the case where generation had already stopped (stop_decode == true).On architectures where rewind cannot be performed in place (such as DeepSeek V4 recurrent/compressed DSA cache),
ds4_session_rewind()invalidates the checkpoint, which causesserver_generation_rewind()to detectrebuild = trueand synchronously re-prefill the entire prompt context from token zero before breaking out of the decode loop.This resulted in a 10–28 second freeze at the tail of streaming responses right after text output finished, right before emission of
[DONE].Observed Behavior & Profiling
On macOS (Apple Silicon / Metal), running
DeepSeek-V4-Flash-Vision-Expwith--dspark:saw_tool_end),stop_decodewas set totrue.kept < ntok).!stop_decode,server_generation_rewind()triggered a full prompt re-prefill (5,000–10,500 tokens, taking 14–28 seconds).if (stop_decode) break;ran, exiting the decode loop. The newly prefilled session was unused, and only then did the server flush[DONE].Fix
server_generation_rewind()with!stop_decodeso it only runs during active mid-stream resampling boundaries.stop_decodeis true andkept < ntok, rewind the session position toblock_start + keptwithout rebuilding/re-prefilling, then break immediately.stop_decode = truewhen reachingmax_tokensor the context limit.Discarding the unconsumed draft is not optional
Skipping the rewind entirely at the stop boundary is not equivalent. A block
such as
[CLOSE, EXTRA]whereCLOSEcompletes a tool call leavesEXTRAcommitted to the session although the client never saw it (
kept = 1,ntok = 2,stop_decode = true). Finalization then binds the tool-callcontinuation to that longer session, so the next matching tool-result request
replays
[prior history, CLOSE, EXTRA, tool result]instead of[prior history, CLOSE, tool result]— silent state poisoning across turns.The stop branch therefore rewinds unconditionally, on every backend. DeepSeek
has no rollback frontier and drops its checkpoint here, which costs one rebuild
on the next request; that is the correct trade against a wrong prefix. On
engines that can roll back in place (GLM, and DeepSeek once the DSpark snapshot
reuse lands) the same call is cheap.
Validation
Machine: Apple M4, macOS 26.4.1, 16 GB. Backend: Metal.
--dspark:--logprob-vectors,--long-context, ...) were notrun because this machine has no GGUF checked out.