Skip to content

Performance 5/7: Qwen3.5 and Qwen3-Omni - #970

Merged
merceod merged 518 commits into
mainfrom
perf/split-5-language
Nov 3, 2026
Merged

merceod merged 518 commits into
mainfrom
perf/split-5-language

Conversation

@dghosef

@dghosef dghosef commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #969. Two encoders were the gaps here. Qwen3.5-9B trailed vLLM by up to 14% on video: its vision tower's block loop ran eagerly, and cuDNN ran the patch embed as a slow implicit-GEMM conv. Qwen3-Omni trailed SGLang-Omni on audio→text, partly because its audio encoder read its chunk layout off the device, so every step paid host syncs. Compiling the block loop is now #859's. This PR makes the patch embed one GEMM and takes the host syncs out of the AuT encoder, which Qwen3-ASR and Qwen3-Omni share since main's #1072. On the stack before the split, with both tower changes, Qwen3.5-9B was within 1.5% of vLLM on video and MMMU on one H200. On Qwen3-Omni, this PR against its parent cuts first-token latency on single images and 60 s videos by 6–9%.

The patch embed's conv has its kernel as its stride, so it is one GEMM over flattened patches (about 17 ms → 1 ms for a 60 s video's patches on Qwen3.5). It lives in qwen3_vit.VisionPatchEmbed, which main's #1072 shares between Qwen3.5 and Qwen3-Omni, so both towers take it. The AuT encoder takes a list of clips instead of a padded stack, pads only the last conv launch, and builds the trim indices and cu_seqlens on the host, so it has no host sync. Its fp16 residual is clamped. The Talker's masks use device ops only, Thinker and Talker fuse the residual add into RMSNorm, and a Thinker step embeds its decode rows in one call. A deployment now sets the captured prefill ladder in its config (model_kwargs.prefill_token_buckets), which replaces MSTAR_QWEN35_PREFILL_TOKEN_BUCKETS. The 9B configs keep main's 2176 bucket, for a mixed step's decode rows beside four prompts, and capture 3072–7168 in place of main's 4352 and 8704 (with those two as well, the ladder would use Dynamo's whole recompile limit); On 9B TP2, 2176 captures for prefill (batch 1–16) and mixed steps (batch 4–128). The mixed-step buckets (MIXED_TOKEN_BUCKETS) now follow each deployment's ladder: its buckets up to 2176, then its powers of two. A test checks every shipped config that sets prefill_token_buckets, qwen3omni_2gpu_large_kv.yaml included. qwen3omni_2gpu.yaml, meant for 80 GB GPUs, keeps Omni's default 128–2048 text ladder, and qwen3omni_2gpu_large_kv.yaml runs it to 8192 on H200-class GPUs. We also add qwen3_5_9b_dp2.yaml (one replica per GPU), H200 MoE tiles for Omni's two expert shapes, and a Thinker prefill row that rides the speculated decode step (speculation_mixes_walks: {Thinker: true}). Two outputs change by design. Vision outputs of Qwen3.5 and Qwen3-Omni aren't bit-identical to main, because the GEMM rounds bf16 differently, so greedy output on long videos and a few MMMU images can diverge. MMMU-200 accuracy is unchanged on both; Video-MME accuracy was not run. And a sub-second Omni or ASR clip encoded alone differs from HF, because every clip's last chunk is padded to keep the encoder batch-invariant.

Unless a row says otherwise: H200 on Eras, torch 2.12.1+cu132, ABBA with 3 boots per arm and a fresh Inductor cache per timed boot. Rows marked "pre-split" ran on the stack before it was split (base 29bf80124, #860's head then), where this PR also carried the compiled block loop. Rows marked "earlier stack top" ran #972's head 0d242a90a against main bb0e0a8d7, before many of main's changes since (among them #963, #919, #939, #1072, #1075, #858 and #920). The whole-stack Qwen3-Omni and Qwen3.5-9B TP2 numbers are in #972.

Check Hardware Arms Result
Qwen3-Omni image and video in, qwen3omni_2gpu.yaml 2× H200 this PR at 3e49609ee (this PR's Omni and vision changes as at this head; main's Omni code has moved since) vs its parent #969 e2a8352b9; the whole PR, not the GEMM alone TTFT p50: MMMU c=1 −8.6%, 60 s video c=1 −6.2% (both outside spread); 60 s video c=4 −18.3%, MMMU c=16 / 32 −6.9% / −5.9% (within spread). Tok/s: 60 s video c=4 +6.1%, MMMU c=32 +10.8%. MMMU-200 accuracy 0.630 vs 0.625. 60 s video greedy, 8 prompts: 0.89 identical across arms vs 2.33 and 3.33 within each arm. 0 boot hangs, 0 failed
Qwen3.5 patch-embed GEMM, greedy output H200 earlier stack top vs the same tree with only the patch embed reverted The deterministic video and MMMU shifts trace to it: reverting it returns 3 of 5 shifted videos and the shifted MMMU image to the parent's exact output
Qwen3.5-9B video 60 / 30 / 8 s and MMMU against vLLM, c=16; c=32 1× H200 pre-split Gap to vLLM at c=16 −11 / −9 / −4 / −1% → +1.3 / +0.3 / +0.4 / +0.5% (vLLM 765 / 1193 / 1960 / 2260 tok/s); at c=32 −14 / −13 / −8 / −4% → −0.4 / −1.2 / −1.5 / −0.5% (vLLM 893 / 1527 / 2985 / 3658)
Qwen3.5-9B prefill ladder H200 pre-split c=64 +1.9% and +4.5% (two A/Bs), c=128 +1.9%, c=16 flat. 0.8B measured −5% and keeps the default
Qwen3.5-9B quality H200 pre-split MMMU-200 44.5% → 44.0–45.0%, 195–197 answers identical; GSM8K 93.4 → 94.0%
Qwen3.5-9B DP2 against TP2 (TP-async on), c=16 / 32 2× H200 earlier stack top, before #920's KV-aware routing Video: DP2 ahead by 12.5–44% (30 s and 60 s clips). MMMU: DP2 16.5% behind at c=16, within spread at c=32. Repeated-prompt TTFT ~60 ms once per replica, then ~16–19 ms
Qwen3.5-9B DP2 against vLLM, c=32 2× H200 pre-split; vLLM DP2 and TP2 60 s video 1520 / 1524 tok/s (mstar TP2 877–1016; vLLM DP2 1504 / 1513, TP2 1324 / 1321). MMMU 4457 / 4500 (mstar TP2 3082–3107; vLLM DP2 4313 / 4540, TP2 5236 / 5121). The TP2 arm's TP-async setting was not recorded
Qwen3.5-9B DP2 under main's #920 routing, c=32 2× H200 qwen3_5_9b_dp2.yaml at #972's earlier head a2b96f6e6 (this PR's config, before it took the 2176 bucket) vs main 3e98682a8 with its 9B config and node_groups ranks [0, 1] (main has no DP2 config), ABBA with 2 boots per arm Tok/s: 60 s video 1349 / 1336 → 1479 / 1489 (+10.6%), MMMU 4038 / 4052 → 4442 / 4465 (+10.1%). TTFT p50: 60 s video 2326 / 2287 → 2077 / 2063 ms, MMMU 230 / 240 → 155 / 125 ms. All outside spread; 4/4 boots clean, 0 failed. The docs/models.rst numbers (1520 / 4457 tok/s, from load-only routing) are within 2.4% / 0.1% of these. We did not run an arm with the old routing
Qwen3.5-9B TP2 with the 2176 bucket, ShareGPT c=16 2× H200 stack top b27e0609e (the same code as #972's head) vs main eceea1bee, ABBA with 4 boots per arm, with a profile pass 2176 captures for prefill (batch 1–16) and mixed steps (batch 4–128): 276 captures, at most 41 compiles of one function (limit 84), no recompile-limit hits. Tok/s median 3219 vs 3190 (+0.9%, within spread); TPOT p50 3.889 vs 3.945 ms; TTFT p50 18.7 vs 19.5 ms. Iteration p50 3.71 vs 3.70 ms, GPU-bound on both. 0 failed
Qwen3-Omni text→text, c=1 / 4 / 16 / 32 2× H200 pre-split; SGLang-Omni from the Oct-21 benchmark 247 / 556 / 1144 / 1763 → 252 / 580 / 1299 / 2010 tok/s (SGLang-Omni 278 / 633 / 1350 / 2087)
Qwen3-Omni MoE tiles; Thinker prefill riding decode steps 2× H200 pre-split Tiles: audio→text c=16 753 → 876 tok/s (TTFT 100 → 76 ms), text→text c=32 1955 → 2010. Mixed steps: audio→text c=16 +12%, c=32 +15%; text→text +4–5%
Qwen3-Omni 8192 text-prefill ladder, first text→text c=32 burst 2× H200 pre-split, when qwen3omni_2gpu.yaml carried the ladder that qwen3omni_2gpu_large_kv.yaml has now 1786–1806 → 2029–2030 tok/s; TTFT p99 3.5–3.8 → 0.43–0.45 s
Qwen3-Omni audio→speech, c=8; quality 2× H200 pre-split 45 → 51 audio-s/s, TTFA 446 → 235 ms (text→speech unchanged). GSM8K (250, 5-shot) 0.964 → 0.960 (SGLang-Omni 0.948); LibriSpeech WER 1.0–1.15% on every arm
qwen3omni_2gpu.yaml captures 2× H200 #972's earlier head 7cb636390 vs main ad0a21eca Thinker 68/68, Talker 24/24, Code2Wav 6/6 buckets captured on every boot, with no 8192 ladder; peak 90–91 GB on the Thinker's GPU on both arms
Qwen3-Omni recompile limit, qwen3omni_2gpu_large_kv.yaml 2× H200 stack top b27e0609e vs main eceea1bee, and the stack with Omni's default ladder; one boot each Every arm hits Dynamo's recompile limit once in the Thinker's batched forward, from a guard on request-id values, so main does too, with any ladder. With the 8192 ladder, the Thinker's 80 prefill captures all come after that point, against 62 on main's config. ShareGPT c=8: 0 failed on every arm
Qwen3-Omni read-aloud speech (qwen3omni_2gpu.yaml): Whisper-large-v3 WER over 24 sentences 2× H200 main ad0a21eca, #859 576d05f4e, #969 e2a8352b9 and this PR at 3e49609ee, 3 cold boots per arm, one seed shared by all 24 sentences WER mean: main 1.14% (0.95–1.33%), #859 1.65%, #969 1.84%, this PR 2.91%, against a bar of 2.14% (main's mean plus 1 point). A fourth cold boot each: #969 45% (a 48 s loop on one sentence), this PR 0.95%. The first spoken word is wrong on about 0.3 of 24 sentences per boot on main, 1.3 on #859, 2 on #969 and 4 here; the Thinker's text is correct on every boot. Runaway samples (20–48 s of audio) appear only on stack trees. The earlier Talker runaways on one prompt sit in the same spread (under the same long request history, main has 0 of 24 seeds of that prompt over 20 s). Reverting 98691fc or ad54171 (padding-row positions) or 15f7a20 (the Talker on run_layers) alone, the MoE tile files or speculation mixing changes none of it. These boots sent one seed (7) for all 24 sentences, and the paragraph below the table shows that the shared seed is what separates the arms: with a distinct seed per sentence, this PR is at main's level
Qwen3-Omni: a LibriSpeech question clip answered instead of transcribed 2× H200 main ad0a21eca and the stack's heads then (this PR at 3e49609ee), each after the same long timed history of audio, text and speech requests unless noted Main, #859, #860 and #969 transcribe it; this PR, #971 and #972 answer it (#972 on 3/3 boots). It needs both the Thinker prefix cache and the long history: with prefix_cache: false, this PR and #972 transcribe it (2/2). Cold boots transcribe it whichever order the clips run in, so the reading that its KV stays frozen from a batched first serving was not confirmed. It is not from the MoE tile files, speculation mixing, or 98691fc, ad54171 or 15f7a20 (each reverted alone); the paragraph below the table has the verdict. Main answers the other 4 of the 5 question clips too; without the five, audio→text WER is equal (#972 1.18–1.29%, main 1.22–1.26%). We could not read the margin: Omni chat drops logprobs and top_logprobs without an error
ruff check .; CPU Core; Rust tests Eras Linux CPU box, with --rust this head 7c9b2f30b vs main eceea1bee; Rust at 0fac73afb vs main fdfa1239a ruff clean. Core: no failures beyond main's (main's 35 come from the box's environment). Rust (test/rust/): 664 passed

Both Qwen3-Omni items above were followed up, and neither is a regression. The read-aloud drift came from the probe sending seed 7 on all 24 sentences. Since #969, fused_sample draws per-row Philox from the request's seed, so all 24 sentences replayed one random stream on every boot. With a distinct seed per sentence (4 cold boots per arm on this PR's earlier head ecba1a4dd), WER is 1.19% with fused_sample and 1.43% without it, and wrong first words are 0.0 vs 1.0 per boot. Main measured 1.38% in the same round. With no seed, WER is 2.19% vs 2.14%. Only the shared seed separates the arms (4.28% vs 1.47%). fused_sample's draws on 69 real Talker and 1019 real code-predictor rows match FlashInfer's at the FlashInfer-vs-FlashInfer noise floor, and the repetition penalty matches to 3.6e-7. Unseeded requests get a per-request seed, so serving traffic doesn't share a stream. The question clip is a near-tie between transcribing and answering: the answer responds to that clip's own question, and main also answered it on 1 of 3 boots of an earlier head.

Not yet run on GPU at this head (7c9b2f30b): Qwen3-ASR on the AuT encoder changes (transcripts against base, c=16 throughput, long-form batches, test_qwen3_asr_parity.py); Qwen3-Omni audio→text with this PR's encoder changes alone (audio_embeds against base for 5 s, 60 s and 10 min clips, encoder phase time); 8 concurrent 60 s clips through audio_attn, and fp16 on a 10 min clip; peak memory for 1 × 600 s + 7 × 2 s clips under the last-launch padding rule; qwen3omni_2gpu.yaml on an 80 GB GPU (whether every capture fits there is not measured); Talker codes and WER against base on DecoderLayer; Thinker padded decode and prefill captures at c=3 / 5 / 7 / 24; Video-MME accuracy.

🤖 Generated with Claude Code

dghosef and others added 30 commits October 29, 2026 17:08
…speech-language

Brings in #858's new commits: TokenStop leaves the token budget to the
decode loop's cap (generated_key is gone); the step closing a reply that
ended at prefill withholds its token through StopCheck / BatchedStopCheck
instead of the engine's token_stop pop, and the inline client emit gives a
withheld row None in both graph runtimes; the ragged-attention op registry
names each manager uniquely; a replica count that fails leaves warmup at one
copy; and docs (the worker's MSTAR_PY_SWITCH_INTERVAL_SEC row, warmup's solo
run, one_row_per_step, comments that name the loop cap as the budget).

Semantic resolutions: TokenStop drops generated_key and keeps this branch's
loopback_signal and withhold. Chatterbox's T3 and the Qwen3-TTS Talker drop
their generated_key; their "generated" / "generated_frames" counters stay,
since the T3's speech position and the Talker's min_new_tokens read them.
Every TokenStop adopter this branch adds already caps its decode loop at the
budget (Chatterbox and Qwen3-TTS at max_new_tokens - 1, the prefill's token
counted; Orpheus, Higgs Audio and the Qwen3-Omni Thinker at
max_output_tokens), so none needed a new cap; the two loop-cap comments that
said the stop check counts the prefill's token now say the prefill samples
it. In check_stop and check_stop_batched the step closing a reply that ended
at prefill withholds buffer and withhold together, and a stop-id row still
withholds withhold, ignore_eos or not. The cap-vs-stop-check comparisons for
this branch's adopters (Orpheus, Chatterbox, Higgs Audio, the Qwen3-Omni
Thinker, Qwen3-TTS) are deleted, as #858 deleted its own, with the Qwen3-TTS
test that its stop check counts the prefill's frame; the Chatterbox, Orpheus,
Higgs Audio and Qwen3-TTS stop-check tests now expect a row at its budget to
go on. api_switch_interval_s and Model.API_SWITCH_INTERVAL_S stay: #858 kept
the API server's key and only documented the worker's variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or inline (R3.1, R3.2)

The data-worker thread publishes how many of its taken-up inputs are not
being prepared (waiting_inputs), and queue_depth adds that to the queue,
so one request preparing reads the same with or without a pool and the
gauge no longer reads the thread's private deque. The four descriptions
of the gauge now say the same thing, and that client-stream appends count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…comment (R3.3, R3.4)

A class value that is not a positive integer now stops startup naming the
model class, instead of a config value building a pool for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… into perf/split-3-generation

Brings in #859's merge of #858's new commits (8266de2): TokenStop leaves
the token budget to the decode loop's cap (generated_key is gone), the step
closing a reply that ended at prefill withholds its token through StopCheck /
BatchedStopCheck instead of the engine's pop, the inline client emit gives a
withheld row None in both graph runtimes, the ragged-attention op registry
names each manager uniquely, a replica count that fails leaves warmup at one
copy, and docs.

No semantic resolution was needed: this branch adds no TokenStop adopter, no
generated_key, no cap-vs-stop-check test for a TokenStop adopter, and no
withheld, inline-emit or Rust inline-value change; its own decode-style loops
(BAGEL's denoise walks, V-JEPA 2's rollout) keep their own stop checks and caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…artup comment (R4.2, R4.3)

_positive_int moves from entrypoint.py into data_worker.py (entrypoint
already imports from it) and checks both the class PREPROCESS_WORKERS and
the config's preprocess_workers, replacing their two inline copies. The
messages are unchanged.

The comment above the check claimed it runs first, but check_media_limits()
runs before it; it now gives the reason: nothing to close yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ngth (R4.4)

_publish_waiting() runs before every hand-off and rescanned the whole
_preparing deque each time, k^2/2 per pass. Without a preprocess pool no
entry there is ever being prepared (the inline one is popped first), so
len() is exact; the scan stays only with a pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The worker passes the value to sys.setswitchinterval, which refuses only
values of 0 or below, so `nan` and `inf` are applied rather than ignored
with a warning, as the row implied. The row now names what is ignored: a
value float() cannot parse, or one of 0 or below.

_env_number's docstring cited this variable as raising a too-small value
to a minimum; it ignores one instead, so only MSTAR_RECOMPILE_LIMIT is
left as the precedent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iable

The key sets the API server's GIL switch interval only. The worker
processes read theirs from MSTAR_PY_SWITCH_INTERVAL_SEC, whose row
already points back to this key; the serving config row now says so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a hand-off (R4.1)

The pool case of test_queue_depth_counts_requests_waiting_for_the_data_worker
could pass on the moment both requests were still in in_queue; it now also
reads the gauge after the wait, when the pool has prepared #1 and it waits
behind "hold". test_a_prepared_request_waits_while_a_peer_cannot_take_more
records waiting_inputs at each hand-off, which pins the publish before it.

Checked against mutated code: counting only unprepared entries reads 1 at
the new assert (the old _wait still passed); dropping the publish before the
hand-off records [2, 1].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it-4-shared

This brings in main's #886 (StopCheck, BatchedStopCheck, TokenStop.withhold
and the worker's withheld_outputs), which this branch carried as the same
code, and #927's dense attention manager, which replaces this branch's
per-segment FA3 path.

Conflicts:
- model/submodule_base.py: both sides now return set[str] | StopCheck; kept
  this branch's read-only Mapping annotation and its note on lazy host copies.
- attn/dense.py, test_dense_attention.py, docs/models.rst: main's. The
  per-segment flash_attn_func branch ran ahead of #927's wrapper choice, so
  wherever the FA3 wheel imports it would bypass the FlashInfer wrapper #927
  picks for a GPU FA3 has no kernel for. Main's
  test_dense_matches_sdpa_over_prefix_and_fresh covers what the dropped test
  did, on both wrappers.
- linear_attn/wrappers.py: both; #877's _plan_snapshots beside this branch's
  _unpadded.
- docs/adding_models.rst: main's StopCheck paragraph, which already says what
  this branch's did.
- test_withheld_outputs.py: main's (its fake check_stop_batched takes no
  step_walk).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…split-2-speech-language

Brings in #858's two merges of origin/main: the Fun-CosyVoice3 model
(#495, with its OpenAI adapter, sentence splitting for long speech input,
and a Chatterbox reference-audio error that names no server path), the
admit fence that waits out a slow lockstep admit (#882), and the KV
snapshot directory sweep keyed by launch token and lock (#637); and
#858's docs for MSTAR_PY_SWITCH_INTERVAL_SEC and api_switch_interval_s.

No conflicts. The MSTAR_PY_SWITCH_INTERVAL_SEC row now names this
branch's model default as well: the API server's interval is the serving
config's api_switch_interval_s, else the model's
Model.API_SWITCH_INTERVAL_S.

CosyVoice3 needs no change for this branch's shared-code edits: its LM
declares no TokenStop and keeps its own check_stop, it declares no
compile_regions or piecewise regions, and it calls none of the removed
helpers (reads_device_loopback, lengths_to_mask, _non_pad_mask, the
decode attention kernels). The Chatterbox parts it reuses (Snake, the S3
tokenizer, VoiceCache, _decode_audio) keep their interfaces. This branch
changes nothing in the worker, the KV transfer code or the OpenAI
adapters, so #882, #637 and the adapter edits arrive as they are on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_a_compiled_region_attends_through_a_rebuilt_resource still said the
op names its manager by resource key, with a rebuilt resource taking over
that key. Each manager now registers under its own op key, the resource
key plus a number unique in the process. The docstring now says what the
test checks: a region compiled against one manager, then called with a
manager rebuilt under the same resource key, attends through the rebuilt
one's plan while the old one is still registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A withheld output has to leave by a streaming edge that no node this worker
speculates into reads, and cannot be the loop-back signal. The startup check
enforces this for TokenStop.withhold; a StopCheck returned from check_stop
(the Qwen3-Omni Talker, Fish S2 Pro's AR) is not checked, so the paragraph
that introduces StopCheck now states the constraint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ignature

_prematerialize_for_check_stop returns _LazyHostOutputs, which copies an
output name the first time it is read, not a dict of host copies of every
tensor; its annotation and docstring now say so. Engine.extend_prefix_chains
is annotated with the Mapping its body already checks for, and the resource
runner's per-request fallback checks Mapping too, so one contract is checked
one way (today only plain dicts reach that fallback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ead ones

The API server's message loop now waits in poll_for_messages instead of
sleeping, so a stand-in that returns at once made a loop running on a thread
spin on the GIL. api_server_helpers.idle_poll waits out the timeout as the
socket's poll does, and every stand-in for the API server's loop uses it;
their mis-indented and blank-padded copies go with them.

The data-worker thread and the Rust-frontend bridge never poll, so the
poll_for_messages entries on their stand-ins are removed. The conductor
stand-in in test_kv_layout_intake.py had none, so each pass of the main loop
raised AttributeError and was logged; it now answers as the other conductor
stand-ins do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not by rewriting declare_step

attn_key used to overwrite the config's public declare_step field with a
bound method in __post_init__. A dataclasses.replace of such a config then
passed both fields and raised, and a shallow copy kept declaring through the
original's method. The runner now asks the config for the region's step
(region_step), which answers from attn_key or declare_step on the instance
it is called on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…step that leased it

A region that takes its slot before the outer declaration must replay only
on a step that leased it: unleased, the step's own declare_step covers the
region's resources, and a replay would declare, plan and commit them a second
time. Each forward used to enforce this itself, against
ModelInputsFromEngine.piecewise_leases (Qwen3.5 vision, Qwen3-TTS,
V-JEPA2, Omnilingual, OmniVoice) or against engine_inputs.step being None
(Qwen3-ASR, nemotron duplex). The engine now leaves an unleased region out of
the runners it hands the forward (SubmoduleManagement.runners_for), so a
forward replays whenever its runner is there. The piecewise_leases field on
ModelInputsFromEngine and the per-model checks and their tests go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…top field

TokenStop.loopback_signal bundled two concerns into the stop policy: which
token ends the loop, and how a decode walk reads its token back on the device.
Declaring it also made the walk's step input-free, which silently turned on
pre-plan reuse for Whisper, Qwen3-ASR and Higgs decode, whose measured numbers
predate that. Loop-back is now ARNodeSubmodule.device_loopback, a
DeviceLoopback(walk, signal, sampler, buffer); the loop-back hooks
(uniform_row_inputs, device_loopback_signals, inline_client_signals) follow
from it, and one base read, loopback_inputs, replaces the four copies of the
slot-master read and the routed-row overwrite (Whisper passes its timestamp
rule state as a declared loop-back state). step_is_input_free is again each
node's own answer: Qwen3.5 decode says so, the others keep the default.
A walk is now a required field, so the engine's check for a missing one goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the loop-back signal too

When a prefill walk samples a stop id, the loop's first step only closes the
reply, and its outputs are withheld. It withheld the token's buffer
(new_token), but a device loop-back node's client edge carries its loop-back
signal (text_inputs), inline off that buffer: the worker nulls an inline value
only when the signal itself is withheld, so the token sampled after the stop
id reached the client. On the tensor path the routed signal leaked the same
way. The closing step now withholds the node's DeviceLoopback signal beside
the buffer, so prefill_walks and device loop-back compose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t raising (R1.18)

The engine asks `can_batch` with whatever rows survived `prepare_inputs`, so
a step whose rows all failed reached `layout([])`, whose `max()` of no chunk
widths raised and turned the rows' own failures into a step exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l ends the reply

These prefills sample the reply's first token, but their TokenStop declared
no prefill_walks. A stop id sampled there was either ignored (Higgs,
Qwen3-ASR: only a decode row could stop) or a stop for a loop not yet
entered, which the runtime drops (T3), so decode ran on to the cap. They now
list their prefill walks: the loop's first step closes the reply and its
outputs are withheld. As TokenStop says, that first token counts toward the
budget, so Higgs and Qwen3-ASR now give the loop max_output_tokens - 1 and a
capped reply is max_output_tokens long, not one more (T3 already did).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… rows bring

A captured decode builds its rotary tables inside the graph from the planned
positions, so preprocess skipped them, but only on the loop-back branch. A
step whose rows all brought their token routed (each request's first decode
step, or every step with MSTAR_DEVICE_LOOPBACK=0) still built the 3D positions
and cos/sin, and the capture held two static buffers for them, all unread.
preprocess now returns before them on any captured decode step, and the
capture test reads engine_inputs.captured instead of probing the step's
context for a slot lease.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1, R1.5)

Omni's encoder node drove transformers' Qwen3OmniMoeAudioEncoder internals
from mstar code, a second implementation of `components/aut_encoder.py`,
which Qwen3-ASR already runs packed, windowed and captured. Omni now builds
its tower as `AuTEncoder`, loaded from `thinker.audio_tower.*` as Qwen3-ASR
loads it, and its config is `AuTEncoderConfig` (the Omni-only
`AudioEncoderConfig` is gone).

The frontend improvements move into the shared component, so Qwen3-ASR
gets them too:
- the trim back to each chunk's valid rows gathers by host-built indices
  (a pinned, non-blocking upload) instead of a pageable H2D plus boolean
  indexing, both host syncs;
- the chunk count pads with empty chunks to a power of two (cuDNN plans
  each new conv shape);
- token counts follow the checkpoint's `n_window` instead of 100 frames;
- the eager path passes the layout's host boundaries, not a device tensor.

The submodule keeps its packing policy (one replay a step, `can_lease` and
the all-or-nothing `can_batch` past the largest bucket). Deleted with the HF
copy: `AudioLayout`, `layout`, `_frontend`, `_layers`, the `_attn_fns` memo,
the hand-written region declaration (now `attn_key=AUDIO_ATTN`), the
double-clone of the leased output, the per-row layout lists rebuilt three
to four times a step, the HF FA2 construction and its stale comments.

GPU-unverified: compare audio_embeds against the base on real weights.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ath only when x, residual and weight share a dtype

FlashInfer's fused add+RMSNorm reads the residual and the weight in x's
dtype, but the kernel branched on x's dtype alone. mstar::rms_norm casts its
weight; this op, which run_layers hands every decoder stack, did not, so a
model whose norm weight or residual stream was in another dtype would have its
buffers read as the wrong type. Any mix now takes the unfused add and norm,
which casts the weight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine's private _cudnn_benchmark, added for capture_cudnn_benchmark, was
a line-for-line copy of wan_vae.cudnn_benchmark. The helper now lives once,
in mstar.engine.fp32_precision next to fp32_precision_scope, the other
process-wide backend flag the engine sets around a node's warm-up and capture.
wan_vae imports it from there, so Cosmos3's VAE decode keeps calling it
through wan_vae.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rce's ceiling (R1.2)

`_fits_capture` compared a step's windows with the class constant, while the
captured wrapper is sized by the resource's `max_segments_per_request`, which
a deployment may override under `resources: audio_attn:` (docs/serving.rst).
An override below the constant let a step lease a replay whose plan raised
for too many segments, failing every clip in the step; one above it was
ignored. It now reads the bound resource's ceiling, as the Qwen3.5 vision
tower and Omnilingual do.

The declared ceiling is derived from the encoder's geometry (the top
bucket's full windows plus a short last one per clip) instead of a literal
that assumed `n_window_infer` = 800.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and captured

Qwen3-Omni's audio encoder ran transformers' Qwen3OmniMoeAudioEncoder
eagerly, about 10 ms a call and mostly launches, with its own step splitter
around it. That is the same AuT architecture, on the same thinker.audio_tower
weights, that Qwen3-ASR already runs natively: every attention window of every
clip is a segment of a ragged attention resource, and the 32-layer stack
replays a CUDA graph by packed token count. The audio node is now that node
(Qwen3ASREncoderSubmodule), built without a spectrogram config so it takes the
API server's log-mel features as they arrive, over an AuTEncoder loaded from
the checkpoint and a RaggedAttentionSpec sized for the longest clip
(MAX_AUDIO_SECONDS). Omni's audio config is AuTEncoderConfig. The HF wrapper,
its frame-budget splitter, its return_dict probe and their tests go; the
host-sync test now checks the node Omni runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_per_step

A region that replays a whole step as one row (one_row_per_step) captures at
batch size 1, so its plan held max_segments_per_request segments for every
request of the step: Qwen3.5's vision tower and Omnilingual set a "per
request" ceiling that meant per step, Omnilingual's to its max batch size.
RaggedAttentionConfig.max_segments_per_step now names that ceiling (and a
deployment can override it like the others); when set, it sizes every
captured plan of the resource. Both adopters declare and read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef and others added 8 commits November 2, 2026 09:20
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-request fallback aliased text_inputs as new_token with a dict splat,
which reads every output name, so the lazy host outputs copied all of them
to the host. A ChainMap over the outputs reads only what the check reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ladder tests read only configs/qwen3_5*.yaml, so the Qwen3-Omni large_kv
config's ladder was never checked. Every config that sets
model_kwargs.prefill_token_buckets is now built into its own model's prefill
capture; the Qwen3.5-specific checks keep to Qwen3.5 configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every shipped ladder's model must take prefill_token_buckets as its own
argument (it would otherwise vanish into **kwargs); the Thinker's capture
check stays for Qwen3-Omni, whose ladder no other test builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Nov 2, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@merceod merceod left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one can't go in as it stands. Once #969 lands, main conflicts with it in the Omni Thinker's audio and vision prefill (#1110), and the deepstack slice here is wrong on #1110's spliced rows, so the merge needs care and the Omni checks need a rerun on the merged tree. It also hasn't had CI, because the base isn't main. Two of the config changes don't seem to do anything on this base, the 9B rungs above 2176 and the Thinker mixing key, since #916's chunk budgets came after those numbers. The AuT changes reach Qwen3-ASR as well, so I'd like its parity test and a WER pass at this head. The model work itself (patch-embed GEMM, sync-free talker mask and AuT trim, one embed call for decode rows) looks good to me.

  • mstar/model/qwen3_omni/submodules.py:615 — Heads up for the merge with main. #1110 builds a media row as the text before, the attachment, then the text after (_spliced_row), so [1:-1] only lines up when there's no text around the image. On a spliced row the media starts at len(before) + 1, so something like start = len(before) + 1 and full_deepstack[start:start + vision_len] = deepstack_inp keeps it host-known and works for both. Main's audio and vision blocks still do mm_mask[[0, -1]] = 0 too, so the scalar writes need to move over.
  • mstar/model/components/aut_encoder.py:260 — Qwen3-ASR runs this encoder too since #1072, and the PR lists its parity test and transcripts as not run at this head. Could you run test_qwen3_asr_parity.py and a LibriSpeech pass on Qwen3-ASR before this goes in?
  • mstar/model/components/aut_encoder.py:305 — mstar.utils.h2d.to_device_nonblocking already does the pinned copy, and it asks DeviceCapability instead of x.is_cuda. On XPU this branch ends up doing a pageable non_blocking copy, which syncs.
  • configs/qwen3_5_9b.yaml:8 — With chunked prefill in the base, a prefill_text step is cut at MAX_BATCH_TOKENS (2048) and a mixed step tops out at 2176, and these configs don't raise max_batch_tokens. So as far as I can tell 3072 to 8192 never replay. The +1.9/+4.5% at c=64 is from the pre-split tree, before #916, and the stack-top TP2 A/B at +0.9% within spread fits that. Can you show a step landing on one of them? If not I'd keep 2176 and drop the rest, since they cost captures and startup on every 9B boot.
  • configs/qwen3omni_2gpu_large_kv.yaml:8 — Same question. The Thinker's budget is 1024, so a burst of prompts gets cut into steps of about 1024 plus decode rows, and the 3.5 s to 0.45 s TTFT p99 is from before #916. Does anything reach 4096 to 8192 now?
  • configs/qwen3omni_2gpu.yaml:26 — I think this is already the default. speculation_mixes turns on for any walk whose combined walk has a budget, and THINKER_MIXED gets 1024 from get_chunking_policies. So this pins the default rather than turning mixing on, and the +12/+15% mixed-step row predates #916 making it the default. I'd drop it, or say in the comment that it pins the default.
  • mstar/model/qwen3_omni/components/thinker.py:251 — The stream starts from input_embeds, which under capture is the static buffer, so the fused norms write into it and the _stage docstring had to change to allow that. forward_residual's own docstring and run_layers start from a copy. You already clone once for layer_0_embed, so could the stream start from the clone and layer_0_embed take input_embeds instead? Same copy count, and static inputs stay read-only, assuming the runner is fine with an output that aliases a static input. With mask_padding off, which these configs use, padding rows also go through the router, so whatever is left in them now carries over between replays.
  • docs/models.rst:435 — The MMMU c32 4457 vs ~3100 is from the TP2 arm whose TP-async setting wasn't recorded. Your TP-async-on A/B has DP2 16.5% behind on MMMU at c=16 and within spread at c=32, so I'd name the setting or drop the MMMU part.
  • mstar/model/qwen3_5/submodules.py:115 — Someone who still sets MSTAR_QWEN35_PREFILL_TOKEN_BUCKETS now silently gets the default ladder. A one-line warning at load if it's set would help.

dghosef added a commit that referenced this pull request Nov 3, 2026
The shared part of the perf stack that #970-#972 build on: less host overhead between steps in the API server and worker (lazy host copies of a step's outputs, a stop check that copies only what it reads, a message loop that survives a transport error), a startup check for preprocess_workers and TokenStop.withhold, and fewer, fused kernels in the shared engine resources (GDN prefill, ragged attention with strided q/k/v, fused MoE tuned tiles under trace, fused_sample for small vocabularies, recurrent slot zeroing).

Review fixes: lazy host copies land every routed name with one sync, the MoE launch drops num_warps/num_stages only when they are symbolic, the message loop logs one traceback and backs off to 1 s, GDN normalizes q and k in one compiled call, and serving.rst says what a shared seed means and that preprocess_workers must be a positive integer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from perf/split-4-shared to main November 3, 2026 06:19
dghosef and others added 5 commits November 3, 2026 06:53
Main carries #969's squash (ce5f431); the files #969's final review
changed take main's version, as this branch made no changes of its own
there. In the Qwen3-Omni Thinker, #1110 splices an attachment's row
between the text around it, so a vision row's deepstack goes to the
host-known slice after the text before and the BOS, not [1:-1]; the
media masks keep this branch's scalar sentinel writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shared helper pins and copies without a host stall, in place of a
hand-rolled pin_memory + non_blocking copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion default

Qwen3.5-9B's 2048-token step budget caps prefill and mixed steps, so
its ladder stops at 2176; the Thinker's is 1024, so
qwen3omni_2gpu_large_kv.yaml goes back to Omni's default ladder.
speculation_mixes_walks {Thinker: true} restated the default (the
Thinker's mixed walk has a token budget). The docs drop the claims
measured on those rungs, and the DP2 note keeps to video, with the
TP-async-on numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fused add + norms update the stream in place, so started from
input_embeds they wrote into a captured graph's static input. The copy
moves from layer_0_embed to the stream (same count), and _stage's
docstring goes back to main's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It is no longer read; the warning names model_kwargs.prefill_token_buckets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dghosef

dghosef commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

@merceod thanks - all done, and main is merged in (1c6ac24), so CI runs against main now.

  • submodules.py:615 deepstack on spliced rows: now start = len(before) + 1, and the scalar mm_mask writes moved over to main's audio and vision blocks (in the merge, 1c6ac24). Added a text-before-and-after image case to test_qwen3_omni_prefix_cache.py that fails with [1:-1].
  • Qwen3-ASR: test_qwen3_asr_parity.py passes at this head, and LibriSpeech test-clean (200 clips) is 1.64% / 1.62% WER at c=1 / c=16 vs main's 1.62-1.64% (H100). Transcripts agree on 192-195/200, about as much as main at c=1 vs c=16.
  • aut_encoder.py:305: uses to_device_nonblocking now (39d3f72).
  • 9B rungs above 2176 and the large_kv ladder: dropped, and the docs claims that came from before [Chunked Prefill] Chunked prefill as a graph Loop (replaces #365) #916 (77834bc).
  • Thinker mixing key: dropped from both configs, it pinned the default (77834bc).
  • thinker.py:251: the stream starts from a copy and layer_0_embed takes input_embeds, so static inputs stay read-only; the _stage docstring is back to main's (4b9400d). Decode returns layer_0_embed as thinker_states, which is cloned right after the replay, so the alias is fine.
  • models.rst:435: the MMMU part is gone, the DP2 note is video only with the TP-async-on numbers (77834bc).
  • MSTAR_QWEN35_PREFILL_TOKEN_BUCKETS: warns at load and names model_kwargs.prefill_token_buckets (849de9e).

Omni rerun on the merged tree (H100, one boot per arm, vs main): greedy image/video/audio outputs that differ all split on a near-tie in the HF reference (logit gap 0-0.375), and this branch matches HF on more tokens than main (488 vs 431). Audio WER 2.1% vs 1.9% (one word). a2t tok/s and TTFT within 1.5% at c=1 and c=8. Note qwen3omni_2gpu.yaml OOMs at boot on H100 on both arms; #1140 adds the H100 config.

@merceod merceod left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All addressed, thanks. The spliced-row deepstack start with its test, the AuT index through to_device_nonblocking, the ladders and the mixing key trimmed along with the docs, the stream starting from a copy with the test that input_embeds stays as staged, the warning for the old env var, and the ASR parity and WER pass. CI is green on main. This one goes first, then #971, #972 and #1140 as their bases retarget.

@merceod
merceod merged commit 90cb8ce into main Nov 3, 2026
6 checks passed
@merceod
merceod deleted the perf/split-5-language branch November 3, 2026 09:20
merceod added a commit that referenced this pull request Nov 3, 2026
Conflicts in the Thinker's forward and in ThinkerSubmodule, where main's
#970/#1140 landed beside the decode walk:

- thinker.py: main's fused add + norm and its residual-stream copy stay;
  keep_states still drops layer_0_embed and the layer-N add when nothing
  reads them.
- prepare_inputs: both made a decode row its token alone. Kept main's row
  and its later start_pos read; the loop-back row stays in front of it.
- preprocess: both embedded a mixed step's decode rows in one call. Kept
  main's code; the tokens now come from _decode_tokens, which also reads
  loop-back rows off the sampler's slot master. Decode steps keep their
  own path, so the captured tables stay in the graph.
- decode captures: one config per decode walk, with main's ids input.

The text-decode runtime test moves to the Rust runtime (#1130 removed the
Python one).
arun-elr added a commit to arun-elr/mstar that referenced this pull request Nov 3, 2026
mstar-project#845's branch with upstream/main merged in. No conflict here: main's changes
to this PR's files (mstar-project#970 in the data worker, mstar-project#1126 and mstar-project#1159 in the API
server, mstar-project#1166 in the Rust frontend, mstar-project#1131 in the conductor and the runtime
wrapper) are all away from the version plumbing. This PR's own change over
mstar-project#845 is unchanged by the merge.

@Gaurav-Shah05 Gaurav-Shah05 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On CPU the AuT encoder matches the parent exactly for packed batches and clips of a second or more, and the fused add and RMSNorm chain matches the unfused one to 1.4e-6 in fp32, deepstack included.

Overall lgtm, and I have only a comment on the prefill ladder.

# ``self.PREFILL_TOKEN_BUCKETS``, or the override is silently ignored.
PREFILL_TOKEN_BUCKETS: list[int]

def __init__(self, prefill_token_buckets: Sequence[int] | None = None):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only Qwen3.5, the Qwen3-Omni Thinker and GLM-5.2 pass this through, so model_kwargs.prefill_token_buckets on Orpheus, Whisper, Qwen3-ASR or the five other AR models that read self.PREFILL_TOKEN_BUCKETS is dropped by the model's **kwargs with no error, a case this class's comment doesn't cover.

The engine should apply a per-node, per-walk ladder from the serving config to the packed capture config, as ChunkingManagement.set_budgets applies max_batch_tokens, refuse a walk with no packed capture, and drop the rungs past the first one that holds the walk's budget, so no model plumbs the key and no config lists rungs that never replay.

# kernel at ~17 ms for a 60 s video's patches, ~1 ms as a matmul.
weight = self.proj.weight
x = x.reshape(-1, weight[0].numel()).to(weight.dtype)
return nn.functional.linear(x, weight.view(self.embed_dim, -1), self.proj.bias)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forward now reads proj.weight directly, so module_lora_spec still binds patch_embed.proj as a patch conv, and an adapter's patch_embed.proj factors on the Qwen3.5 or Omni vision tower would validate and add nothing.

VisionPatchEmbed should declare proj in lora_unservable as SiglipEmbeddings in command_a_plus/components/vision.py does, with an ANNOTATED entry in test_lora_targets.py like bagel_converted_patch_embed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants