Repository navigation
perf(qwen3.5): prefill the empty think block for single-turn models - #779
Open
manueldomke wants to merge 2 commits into
Open
manueldomke wants to merge 2 commits into
manueldomke wants to merge 2 commits into
Conversation
Without thinking, the chat template ends with an empty think block. Qwen3_5VL::insert() cut those four tokens off before the prefill, so the checkpoint lands where the next turn of a conversation diverges, and generate() then fed them in with one forward() each. Only after those four decode steps came the draw for the answer's first token. A single-turn model never returns to that checkpoint. For such a model the block is now prefilled with the prompt, and the token _shared_insert() draws from the prefill logits is the answer's first token. Multi-turn models keep the old path, because there the checkpoint pays off. So does thinking, where the model writes the block itself. The catalog's "single-turn" label now also sets AutoModel::single_turn. Until now only three classes set the flag themselves, all with the label, so nothing changes for them. It is only ever switched on, never off. Measured on Qwen3.5-2B (NPU2) with the label set, 20 independent one-token prompts, three alternating rounds against main: median wall clock 604 -> 486 ms per request. Prefill time is unchanged (430 vs 431 ms); the four tokens it gains cost nothing measurable. The numbers are not bit-identical. The template's tokens are exactly the four ids generate() used to feed ([248068, 271, 248069, 271]), but they now go through the prefill kernels instead of four decode steps. Against main with the discarded-sample fix (ROCm#776) 1 of 20 answers differs, and that was an exact tie (A and C both at logprob -1.939). One of two 60-token generations diverges after 90 characters. With logprobs applied on top, the log-odds between options move by 0.25 nats median, 0.5 at most. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_shared_insert() skips the prefix a request shares with token_history. If the request matches the history completely, nothing is left to prefill, _chunked_insert() returns an empty buffer, and Sampler::sample() reads it: a segfault at address 0. With the empty think block prefilled, a single-turn Qwen3.5 request puts exactly its own tokens into token_history. The first generated token is never added there. On its own this change cannot hit the bug: with max_tokens=1 the decode loop still draws one more token and appends it. Together with the max_tokens fix from the companion PR the loop no longer runs, and the same request sent twice crashed the server on the second one. Reproduced under gdb: the crashing request logs no "Prefill chunk" line. A complete match is now treated like a divergence: clear and prefill everything. Stepping back by one token is not an option, because the linear attention layers carry recurrent state that cannot be rewound. With both changes applied, eight identical requests in a row pass, and answers and confidences are unchanged. The extra prefill is not measurable, because a single-turn model cleared its context on these requests before anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 task done
This branch has not been deployed
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.
Motivation
Without thinking, the Qwen3.5 chat template ends with an empty think block,
<think>\n\n</think>\n\n.Qwen3_5VL::insert()cuts those four tokens offbefore the prefill, so the checkpoint lands where the next turn of a
conversation diverges.
generate()then feeds them in with oneforward()each. Only after those four decode steps comes the draw for the answer's first
token.
A single-turn model never returns to that checkpoint, so the four decode steps
buy nothing. On Qwen3.5-2B they are ~118 ms of a ~600 ms one-token request.
The decision models from #776 and #777 are single-turn by nature.
Technical Details
single_turn && !enable_think,insert()prefills theblock together with the prompt. The token
_shared_insert()draws from theprefill logits is the answer's first token, and
generate()takeslast_tokeninstead of running the forced block. Multi-turn models keep theold path, because there the checkpoint pays off. So does
think: true."single-turn"label now alsosets
AutoModel::single_turn(in_shared_initialize_model_state()). Untilnow only the hunyuan,
Qwen3VL_Flashand gemma4e classes set the flagthemselves, and their catalog entries already carry the label, so nothing
changes for them. The flag is only ever switched on, never off. Models
without the label run exactly the old path. The label keeps its existing
effects:
/v1/chat/completionsrejects requests with assistant messages,and a context-length override is ignored.
With the block prefilled, a single-turn request puts exactly its own tokens
into
token_history. If the same request comes again and nothing is left toprefill,
_chunked_insert()returns an empty buffer andSampler::sample()reads it, which is a segfault at address 0. This PR alone cannot hit it,
because the decode loop still appends one more token. Together with
#<max_tokens PR> it can. A complete match is now treated like a divergence.
Stepping back by one token is not possible, because the linear attention
layers carry recurrent state.
Numerics. The template's four tokens are exactly the ids
generate()usedto feed:
[248068, 271, 248069, 271], checked with the model's tokenizer. Theynow go through the prefill kernels instead of four decode steps, so the result
is not bit-identical; see Test Result. Neither path is a reference, since the
prompt itself always runs through the prefill kernels.
With #777, the first answer token is now drawn in
_shared_insert(), where#777 does not capture. That is handled in #.
Test Plan
prompts,
temperature: 0, three alternating rounds ofmainagainst thisbranch, with the label set in a copy of the catalog.
main+ fix: don't count a discarded sample as generated text #776.main's own answers aredistorted by the discarded-sample penalty that fix: don't count a discarded sample as generated text #776 removes.
combined, under gdb. Then eight identical requests in a row with the guard.
Test Result
Answers.
main, the first letter differs in 16 of 20 prompts. That is thepenalty fix: don't count a discarded sample as generated text #776 removes, not this change.
main+ fix: don't count a discarded sample as generated text #776, 1 of 20 differs. That one was an exact tie: A and Cwere both at logprob −1.939 on the old path.
at most.
Crash. With this PR and #<max_tokens PR> combined, the second identical
request segfaulted in
Sampler::sample()called from_shared_insert(). Itslog shows no
Prefill chunkline. With the guard, eight identical requests ina row pass, answers and confidences are unchanged, and the extra prefill is
not measurable.
Submission Checklist
🤖 Generated with Claude Code