fix: thinking_token_budget has no effect when async scheduling is enabled - #1766
Open
Nakanokensetsu wants to merge 1 commit into
Open
fix: thinking_token_budget has no effect when async scheduling is enabled#1766Nakanokensetsu wants to merge 1 commit into
Nakanokensetsu wants to merge 1 commit into
Conversation
…bled
ThinkingBudgetStateHolder tracks each request's <think>/</think> state and
forces the reasoning end tokens onto the logits once thinking_token_budget
is exceeded, via two methods:
- update_state(): advances the per-request think/end state machine using
the latest sampled output tokens. This was only ever called from
sync_batch() (batch add/remove/move bookkeeping), never once per decode
step, so a request's think state was never advanced past the moment it
entered the batch. Budget overrun was therefore never detected once
generation was under way.
- apply_to_logits(): forces the reasoning end token(s) into the logits.
This was only wired into rejection_sampler.py, the speculative-decoding
sampler path. Sampler.forward(), used whenever speculative decoding is
not active, never called it, so even a correctly-tracked budget overrun
was never forced onto the logits.
Together these mean thinking_token_budget silently had no effect for any
request using the normal (non-speculative-decoding) sampling path once
async scheduling was enabled -- which it is by default for compatible
executors (AphroditeConfig.__post_init__, scheduler_config.async_scheduling
is None -> True). Reasoning would run unbounded regardless of the budget
value, up to max_tokens.
The existing tests in test_thinking_token_budget.py did not catch this
because their server fixtures explicitly pass --no-async-scheduling. Under
that config, _make_sampling_metadata() happens to call update_state() from
a different, sufficient path, masking the bug. Manual reproduction against
Qwen/Qwen3-0.6B with async scheduling left at its default (enabled)
confirmed the failure and the fix:
thinking_token_budget=5, max_tokens=100
before: reasoning_token_count=None, total_decode_tokens=100 (budget
ignored entirely, ran to max_tokens without closing </think>)
after: reasoning_token_count=5, total_decode_tokens=17 (budget
respected exactly, natural completion)
Fix: call update_state() every decode step from GPUModelRunner._sample(),
and call apply_to_logits() from Sampler.forward() mirroring the existing
rejection_sampler.py call, so both paths are covered regardless of
speculative decoding or async scheduling state.
Also adds a new "async_scheduling" server fixture/param to
test_thinking_token_budget_limits_reasoning that leaves async scheduling
at its default instead of disabling it, so this regression is covered
going forward.
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.
Summary
ThinkingBudgetStateHoldertracks each request's<think>/</think>state and forces the reasoning end tokens onto the logits oncethinking_token_budgetis exceeded, via two methods. Both had a wiring gap:update_state()(advances the per-request think/end state machine from the latest sampled tokens) was only ever called fromsync_batch()(batch add/remove/move bookkeeping), never once per decode step. A request's think state was never advanced past the moment it entered the batch, so budget overrun was never detected once generation was under way.apply_to_logits()(forces the reasoning end token(s) into the logits) was only wired intorejection_sampler.py, the speculative-decoding sampler path.Sampler.forward(), used whenever speculative decoding is not active, never called it, so even a correctly-tracked budget overrun was never forced onto the logits.Together,
thinking_token_budgetsilently had no effect for any request on the normal (non-speculative-decoding) sampling path once async scheduling is enabled — which is the default for compatible executors (AphroditeConfig.__post_init__,scheduler_config.async_scheduling is None -> True). Reasoning ran unbounded regardless of the configured budget, up tomax_tokens.The existing tests in
test_thinking_token_budget.pydidn't catch this because their server fixtures explicitly pass--no-async-scheduling. Under that config,_make_sampling_metadata()happens to callupdate_state()from a different, sufficient path, masking the bug.Reproduction
Manual repro against
Qwen/Qwen3-0.6Bwith async scheduling left at its default (enabled),thinking_token_budget=5,max_tokens=100:Fix
update_state()every decode step fromGPUModelRunner._sample().apply_to_logits()fromSampler.forward(), mirroring the existingrejection_sampler.pycall.Both paths are now covered regardless of speculative decoding or async scheduling state.
Test plan
test_thinking_token_budget.pysuite (6 non-MTP cases,default/auto_configparams) passes unchangedasync_schedulingserver fixture/param totest_thinking_token_budget_limits_reasoningthat leaves async scheduling at its default instead of disabling it, so this regression is covered going forward🤖 Generated with Claude Code