Skip to content

fix(qwen3.5): stop at max_tokens instead of one token past it - #778

Open
manueldomke wants to merge 1 commit into
ROCm:mainfrom
manueldomke:fix-qwen35-max-tokens
Open

manueldomke wants to merge 1 commit into
ROCm:mainfrom
manueldomke:fix-qwen35-max-tokens

Conversation

@manueldomke

Copy link
Copy Markdown

Motivation

Qwen3_5VL::generate() draws the first token before its decode loop and
checked max_tokens only at the bottom of the loop, after the next draw. Every
request that runs into the limit gets one token more than it asked for and pays
one more forward() for it: max_tokens=1 returns "A\n" with
completion_tokens: 2.

For a model that answers with a single token, that is the whole request: an
extra decode step, and a trailing token the caller has to strip. The Qwen3.5
fine-tunes from #776 and #777 hit it on every call:

Technical Details

The loop is now while (true) and checks the limit first, before it draws. It
has the same shape as _shared_generate(): the context limit is checked inside
the loop, and reaching max_tokens still wins over it. So finish_reason is
unchanged even when both limits are hit at once.

Independent of #776 and #777; applies to main as is.

Scope. Only Qwen3_5VL::generate(). Two other places have the same
pattern. They are left for separate changes:

  • Qwen3_6_MOE::generate() has the same loop, with the limit checked after the
    draw. I found this by reading only; it is not changed here, because I could
    not test that model.
  • _shared_generate() emits the token drawn in _shared_insert() without
    counting it. There max_tokens=N returns N+1 tokens while usage reports N.

Test Plan

Qwen3.5-2B (decider-2b-NPU2) on a Ryzen AI 7 PRO 350, platform_profile
performance. The prompt set is 20 independent one-token decision prompts with
no shared prefixes, temperature: 0. Three alternating rounds of main
against this branch, one server start per variant and round, wall clock per
request. Plus two 60-token generations, compared character by character.

Test Result

                    main      this PR
median wall         604 ms    574 ms
completion_tokens   2         1
content             "B\n"     "B"

Per-round medians: 604 / 605 / 602 ms against 574 / 574 / 575 ms. The first
token of all 20 answers and both 60-token generations are identical to main.

Submission Checklist

🤖 Generated with Claude Code

Qwen3_5VL::generate() draws the first token before its decode loop and
checked the limit only at the bottom of the loop, after the next draw. So
every request that ran into max_tokens got one token more than it asked for,
and paid one more forward() for it: max_tokens=1 returned "A\n" with
completion_tokens 2.

The loop now checks the limit first, before it draws. Its shape follows
_shared_generate(): the context limit is checked inside the loop, and
reaching max_tokens still wins over it, so finish_reason is unchanged.

Measured on Qwen3.5-2B (NPU2), 20 independent one-token prompts, three
alternating rounds against main: median wall clock 604 -> 574 ms per request,
completion_tokens 2 -> 1. The first token of every answer and two 60-token
generations are identical to main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

This branch has not been deployed

No deployments
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