Repository navigation
Stop at max_output_tokens, not one past it - #604
Conversation
This comment has been minimized.
This comment has been minimized.
NSagan271
left a comment
There was a problem hiding this comment.
LGTM, with a note that CI is failing due to the new test requiring a flashinfer dependency. flashinfer either needs to be added to the CI environment, or the test skipped (or the flashinfer import wherever it is used in qwen3-omni can be move within the corresponding function; I think one of the other open PRs does this but I can't recall which one)
Live test: Stop at max_output_tokens, not one past itFixed for max_output_tokens ≥ 2 on Qwen3.5, Qwen3-Omni and Whisper, but max_output_tokens = 1 still returns 2 tokens on all three. Qwen3.5 0.8B throughput is within noise. 1x H200 (Qwen3-Omni: 2x, Claims
PerformanceQwen3.5 0.8B, closed loop, main, PR, PR, main, text tok/s:
Client command
Other
|
Prefill already emitted the first token, so decode step k has emitted k + 2 tokens. The stop checks compared k + 1, which let one extra token through.
|
Rebased onto main. The test/cpu-core.txt line is gone since #581 removed that manifest; test/modular is selected as a whole now, so the new test file is picked up as is. The earlier CPU Core failure was the talker's module-level flashinfer import, which main no longer has. test_max_output_tokens.py: 24 passed locally on the rebased branch. |
8e92be3 to
306e1b9
Compare
|
/review |
Automated review🟡 No findings on Description
What the review checked
Advisory, not a merge gate. Rules: |
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
The five counts are right, but the Qwen3-Omni talker still stops one codec frame late, and the new test can be one assertion per model in an existing test (see the two inline comments).
With this PR applied to main, Qwen3.5-0.8B on the Rust graph runtime streamed exactly 2 and 64 tokens for max_output_tokens 2 and 64.
Where the count could live: (Not for this PR)
- main's
get_loop_capsalready caps each decode loop per request on both graph runtimes, and speculation stops at the cap.- In my runs of your test with each cap at
max_output_tokens - 1and themax_tokenscheck incheck_stopswitched off, all six cases stopped at exactlymax_output_tokensfor 2, 3 and 64 on both runtimes.
- In my runs of your test with each cap at
- So each model could write the count once, in
get_loop_caps. check_stopwould then only look for EOS, and a new model couldn't get the budget right in one place and off by one in the other.
| # prefill emitted the first token, so decode step k emits token k + 2 | ||
| if (not ignore_eos and eos_token_id == token) or \ | ||
| (request_info.dynamic_loop_iter_counts.get("thinker_decode_loop", 0) + 1 >= request_info.max_tokens): | ||
| (request_info.dynamic_loop_iter_counts.get("thinker_decode_loop", 0) + 2 >= request_info.max_tokens): |
There was a problem hiding this comment.
The talker has the same bug: talker_last_prefill samples the first codec frame and streams it to Code2Wav, and TalkerSubmodule.check_stop still stops talker_decode_loop at + 1, so the audio runs one frame past talker_max_output_tokens. It should count + 2 like the thinker.
mstar/mstar/model/qwen3_omni/submodules.py
Line 1633 in 11c910b
|
|
||
| @pytest.mark.parametrize("max_tokens", [2, 3, 64]) | ||
| @pytest.mark.parametrize("model", list(CASES)) | ||
| def test_stops_at_max_output_tokens(model, max_tokens, tmp_path): |
There was a problem hiding this comment.
Main's test_model_kv_layouts.py already runs each of these five models' check_stop over the loop's iteration counts and checks the stop against its get_loop_caps.
One assertion there, that the prefill token plus the cap equals max_output_tokens, guards the same count in a few lines instead of 205.
There was a problem hiding this comment.
Done in c5e53b9, and 6453877 drops this file. By the time I merged main, #858 and #859 had already removed the per-model cap vs check_stop cases for these models, since their check_stop only looks at EOS now and the cap alone sets the budget. So the new test asserts 1 + cap == max_output_tokens directly, one assertion per model at 2, 3 and 64, plus the same check in the Talker's cap test.
The Thinker's last prefill emits the reply's first text token and talker_last_prefill streams the first codec frame to Code2Wav, so a cap of max_output_tokens (talker_max_output_tokens) let each loop put out one more than asked for. The caps leave room for the prefill's token, as the other TokenStop models' caps do.
talker_last_prefill streams the first frame, so decode step k streams frame k + 2. Counting k + 1 ran the audio one frame past talker_max_output_tokens. The stop check now ends the loop on the same iteration as its cap.
The prefill samples the first token and streams it to the SNAC decoder, so a decode cap of max_output_tokens gave the decoder one token more than asked for.
One assertion per model whose prefill samples the first token (Higgs Audio, Orpheus, Qwen3.5, the Qwen3-Omni Thinker, Whisper), and one more in the Talker's cap test. Orpheus and both Qwen3-Omni loops fail it on main. The Talker test drops its flashinfer skip. The Qwen3-Omni modules import without flashinfer, and CPU CI doesn't install it, so the test never ran there.
test_model_kv_layouts.py now checks the same count with one assertion per model, off each model's own loop cap.
|
Merged main (2394b4d) and reworked for the review. Main moved under this PR. #794, #858 and #859 put Qwen3.5, Whisper and Higgs Audio on TokenStop, where check_stop only checks EOS and the cap is max(1, N - 1), so those hunks went away in the merge. What main still had off by one is in the caps. The Omni Thinker and Orpheus capped decode at max_output_tokens, and the Talker as you found.
The new test fails 8 of 25 on main (Orpheus and both Omni loops) and passes here. The Talker test also drops its flashinfer importorskip. The Omni modules import fine without flashinfer and CPU CI doesn't install it, so that test never ran in CI. A script (not in the PR) that runs prefill plus the capped loop through both graph runtimes, with each model's real cap and stop check, gives N + 1 on main for Orpheus, the Thinker and the Talker, and N for all six models here, on Python and Rust. The CPU core paths give 10340 passed here and 10324 on main, with the same 48 environment failures on both (no ftfy or libcuda, HF parity tests that want other weights or transformers). On the aside, agreed, and main is most of the way there. TokenStop models already count only in get_loop_caps. The Talker, BAGEL's decode and the Cosmos3 reasoner still count in check_stop. Moving them is dropping that arm, keeping the cap at max(1, N - 1) and turning their cap vs stop tests into the 1 + cap check. max_output_tokens = 1 is the one case a cap can't do, since the prefill has to end the partition the way TokenStop.prefill_walks does. I'd do it as a follow-up. Left alone here, max_output_tokens = 1 on Omni still gives 2 text tokens. The Thinker has no prefill END_PARTITION, and ending at the prefill would skip talker_last_prefill on audio requests. Same follow-up. Served counts on an H200 to follow. |
|
Served counts on one H200, main (2394b4d) against this branch (6453877). Colocated configs,
Frames are counted off the streamed PCM. W frames decode to W * 1920 - 555 samples, and every reply came out at a whole number of frames. The token ids match between the arms, this branch just stops one sooner (max_output_tokens 2 gives [33, 878] here and [33, 878, 374] on main). Omni at max_output_tokens 1 still gives 2, the gap I mentioned above. |
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
LGTM, with one small point below.
| # talker_last_prefill streamed the first frame, so decode step k streams frame k + 2 | ||
| if is_eos or \ | ||
| (request_info.dynamic_loop_iter_counts.get("talker_decode_loop", 0) + 1 >= max_tokens): | ||
| (request_info.dynamic_loop_iter_counts.get("talker_decode_loop", 0) + 2 >= max_tokens): |
There was a problem hiding this comment.
At talker_max_output_tokens=2 the + 2 arm returns a stop for talker_decode_loop on the talker_last_prefill row, whose walk has no such loop. Both graph runtimes drop it, and the Python runtime logs it as a model bug. Without the arm, get_loop_caps alone gives 2, 3 and 64 frames for 2, 3 and 64 on both graph runtimes in my runs, so TalkerSubmodule.check_stop should check only codec EOS.
There was a problem hiding this comment.
Agreed, thanks. 9fcfe15 drops the arm, so TalkerSubmodule.check_stop only looks for codec_eos and the cap from get_loop_caps alone sets the frame budget. 4a78dae moves the test to 1 + cap == T and adds a case that the talker_last_prefill row stops no loop at T = 2. Head is now 4a78dae.
Through both graph runtimes with the real check_stop, T = 2, 3 and 64 give 2, 3 and 64 frames with no dropped stop. On 6453877 the T = 2 run dropped one stop on both runtimes, and the Python one logged it. Served on an H200 at 4a78dae (Rust graph runtime), talker_max_output_tokens 2, 3 and 64 streamed 2, 3 and 64 frames, 3 requests each, and the server log has no "Tried to stop loop" line.
At talker_max_output_tokens 2 the count arm returned a talker_decode_loop stop on the talker_last_prefill row, whose walk has no such loop. Both graph runtimes dropped it, and the Python runtime logged it as a model bug. The loop's cap from get_loop_caps already ends decode at the frame budget on both runtimes.
The Talker test checks 1 + cap == talker_max_output_tokens and that the stop check never ends decode on a frame that is not codec_eos. A new case checks that the talker_last_prefill row stops no loop at a budget of 2.
Main carries #1130 as a squash, so the branch's history already had most of it. The conductor's check_knobs keeps main's extension check and loses the transport knob; test_conductor_wait runs on the Rust transport only.
Qwen3-Omni and Orpheus returned one token more than
max_output_tokens(65 for 64 on Qwen3-Omni, where HFgenerategives 64), and the Qwen3-Omni Talker one codec frame more thantalker_max_output_tokens. The last prefill emits the first token, and for the Talkertalker_last_prefillstreams the first frame, so a decode loop capped at the whole budget puts out one more than asked for. Main's other TokenStop models already cap decode at max(1, N - 1) (Qwen3.5 and Whisper since #794, Higgs Audio since #859).The Thinker and Orpheus caps are now max(1, N - 1). The Talker's cap is max(1, T - 1), and its stop check only looks for codec_eos, so the cap alone sets the frame budget. EOS stops are unchanged.
test_model_kv_layouts.pyasserts 1 + cap == max_output_tokens for Higgs Audio, Orpheus, Qwen3.5, the Qwen3-Omni Thinker and Whisper at 2, 3 and 64, and the same for the Talker, whose talker_last_prefill row must stop no loop. On main 9 of those 19 fail, here all pass. The Talker test no longer skips without flashinfer, which CPU CI doesn't install. Driven through both graph runtimes with each model's real cap and stop check, main gives N + 1 for Orpheus, the Thinker and the Talker and this branch gives N. ruff is clean. The CPU core paths pass 10340 tests here and 10324 on main, with the same 48 environment failures on both.On one H200 the Talker streams 11 and 65 frames on main and 10 and 64 here for talker_max_output_tokens 10 and 64, and 2, 3 and 64 frames for 2, 3 and 64 at 4a78dae. The Thinker returns 3, 11 and 65 text tokens on main and 2, 10 and 64 here for max_output_tokens 2, 10 and 64, and Qwen3.5-0.8B returns 1, 2 and 64 on both.
max_output_tokens = 1 on Qwen3-Omni still returns 2 text tokens, since the Thinker has no prefill END_PARTITION.