Repository navigation
Qwen3-Omni: a faster Thinker decode step - #1111
Conversation
thinker_text_decode counts as a decode walk for the row-length hook that chunked prefill (#916) added.
Main now declares device loop-back and the token stop, so the text walk sits on those instead of its own hooks. Conflicts in docs/models.rst (both bullets), qwen3_omni/submodules.py (the hook block goes; DeviceLoopback, TokenStop with no walk, step_is_input_free stays) and test_model_kv_layouts.py (main's side). The pd_2gpu config main added places the text walk too.
The Thinker's two decode walks both end its loop. walk=None would name the loop for prefill rows too, which sample im_end often, and on the Python runtime each one logs the model-bug error.
Live test: #1111 at
|
| cell | 1 GPU main | 1 GPU PR | Δ | thinker TP2 main | TP2 PR | Δ |
|---|---|---|---|---|---|---|
| I2T c=1 | 200 | 226 | +13% | 168 | 170 | +1% |
| I2T c=4 | 456 | 480 | +5% | |||
| I2T c=16 | 1060 | 1114 | +5% | 1105 | 1117 | +1% |
| A2T c=16 | 392 | 355 | −9.5%* | 383 | 411 | +7% |
| T2S c=1 | 11.7 | 12.6 | +8% | |||
| T2S c=16 | 57.0 | 60.7 | +7% | 69.8 | 69.3 | −1% |
| A2S c=4 | 14.9 | 15.7 | +5% | 18.8 | 20.0 | +6% |
* The A2T c=16 dip didn't reproduce. In the first round, all three PR servers read below all three main servers (347–359 vs 382–395). The slow tail came from p99 inter-token gaps of 185–239 ms vs 53–64 ms, and the p50 was unchanged. In that round A2T ran after the I2T cells on each server, while four other jobs shared the node.
I reran it as the first workload on six fresh servers, three runs back to back each:
| run | main | PR |
|---|---|---|
| 1st | 336, 403, 356 | 361, 375, 441 |
| 2nd | 440, 422, 403 | 380, 405, 473 |
| 3rd | 435, 404, 427 | 458, 486, 468 |
Both arms show the same occasional 150–180 ms p99 gaps. I don't have an explanation for the first round. It could be an order effect that only shows after the I2T cells, or node contention. One way to check is to run I2T then A2T on the same server.
TP2 gains are smaller than the PR's table. I2T c=1 is +1% here, against +24% on B300. Under TP the eager router stays, which the PR states, and the H200 decode step may be bound elsewhere. I haven't profiled it.
Correctness
- Greedy replies: 24 prompts covering audio, image, two images, video, text, and text around attachments, each sent twice per server. All 24 repeats were identical on every server.
- Across arms, every case that differs also differs between two main servers. Text and text-around-attachment prompts matched on all 18 cross-arm pairs.
- The PR is more reproducible across restarts than main on single images: 21/21 vs 11/21. That is consistent with
route_alignbreaking ties toward the lowest expert every time (hypothesis).
- Text and speech replies in flight together: these exercise the new
thinker_text_decodewalk next to the audio walk. No errors; speech came back for 6/6 on every server. Text matched solo on 7–12 of 12 for both arms, the same spread. - Speech abort storm: 3 rounds of 32 requests with 16 dropped. All kept and fresh requests finished on every server and both configs, and
/healthanswered 200 afterwards. - GPU tests on 1× H200: the PR tree passed 43 (
test_fused_moe_route,test_fused_moe_config,test_fused_moe_fp8, Command A+test_fused_kernels); the main tree passed 21 (no route test there). The Command A+ kernel tests pass afterroute_alignmoved. - No recompiles on PR servers: the PR servers logged no recompile lines (main: 3 at 1 GPU, 6 at TP2).
Not covered
- B300 tile tables, since this is H200.
- Command A+, GLM and other MoE models end to end. Only their kernel tests ran. The
get_configlookup changes how the nearest batch size is found but picks the same entry, smaller on a tie. - CPU suites, which CI covers.
|
Thanks for the Hopper run. The branch is being brought onto main after #859 (its device loop-back and token-stop declarations replace the hooks this branch carried on its own), so the table will be refreshed on that base, and I'll put the H200 columns next to the B300 ones. On TP2: under TP on H200 only the text-only decode walk and the plan-cache fix are active (no tile tables, |
|
Ported onto main after #859 at cea7d03: the walk now declares |
NSagan271
left a comment
There was a problem hiding this comment.
Mainly looks good, just a few comments.
| num_valid_tokens: torch.Tensor | None, | ||
| ) -> torch.Tensor: | ||
| """One decode step of a request with no audio output: the sampled tokens.""" | ||
| embeds = self.model.model.embed_tokens(input_ids) |
There was a problem hiding this comment.
Can you also port embed/RoPE-in-graph to thinker_decode (or do they not carry over?) This might also allow one graph to be captured for both decode walks (though then the text decode would pay the cost of computing and returning the thinker states; not sure if that would degrade throughput)
There was a problem hiding this comment.
They carry over, done in 9fe581fe0. A captured thinker_decode step takes ids and builds its embeds and rotary tables in the graph, through the same _decode as the text walk. Only keep_states differs, and the layer-0 row and mask go to the Talker as before.
Parity. One B300, c=1, one Inductor cache: 1,778 Thinker decode steps over 8 T2S and 8 A2S are bit-identical before vs after (token, thinker_states bytes, mask), T2T 32/32, A2T 32/32, T2S and A2S text 8/8. WAV bytes and greedy codec tokens are 0/16, but so is before vs before: the Talker's own step inputs differ across boots from step 2 to 18, with the same text conditioning going in. I haven't traced why.
What it buys. On the Thinker alone, T2T pushed through thinker_decode in a scratch build: eager kernels a step 12.1 to 0.1 at c=1 and 29.4 to 1.9 at c=8, tok/s 278 / 1119 / 2913 to 283 / 1121 / 2943 at c=1/8/32. On audio output it doesn't show, the Talker and Code2Wav set those cells:
| two sessions each | before audio-s/s | after | change | TTFA p50 ms before | after |
|---|---|---|---|---|---|
| T2S c=1 | 17.1, 17.2 | 17.8, 17.4 | +2.4% | 126, 126 | 125, 130 |
| T2S c=16 | 94.4, 97.2 | 97.2, 93.9 | -0.3% | 273, 268 | 266, 276 |
| A2S c=4 | 27.8, 26.7 | 27.1, 26.6 | -1.4% | 285, 293 | 280, 293 |
| A2S c=16 | 59.7, 59.5 | 59.7, 57.9 | -1.4% | 469, 447 | 447, 467 |
One graph. Two ways to read it, and I measured both, T2T tok/s, one session each:
| text walk | text requests on thinker_decode, states shipped |
change | one capture, both walks replay it | change | |
|---|---|---|---|---|---|
| c=1 | 299 | 283 | -5.6% | 297 | -0.6% |
| c=8 | 1131 | 1121 | -0.9% | 1138 | +0.6% |
| c=32 | 3011 | 2943 | -2.2% | 3024 | +0.4% |
Merging the walks is out: it's over 2% at c=1 and c=32, and the declaration can't be met, because the rows-only path needs every output signal of the walk on the device (Worker._all_on_device) and thinker_decode streams thinker_states and thinker_mask to the Talker. Computing the states isn't the cost, that's 2 more kernels, +12 µs a step at c=1 and +26 at c=8. It's the per-request outputs, postprocess and the stream send on the host.
One capture with two walks is your suggestion taken literally, and it's free: replay_graph_walks on thinker_decode's config, the text walk stays rows-only and never reads the states. Same tokens as the head on every set above, and text and audio at once ran with 0 failed. I left it out because it saves six captures and no measurable GPU memory or warm boot (95.3–95.9 GB, 165–174 s across these arms), and the text walk would carry two clones for it. It's a 6-line patch, so say if you'd rather have the one config and it goes in.
| routing_weights = router_top_value.to(router_logits.dtype) | ||
| return routing_weights, router_indices, None | ||
|
|
||
| def route_aligned( |
There was a problem hiding this comment.
The description says no Omni router sets fused_softmax_topk, but the Thinker and Talker both do since #859. So the baseline this replaces is the fused softmax + top-k kernel, not softmax → torch.topk → renormalize → cast. Could the description's kernel accounting be updated to match?
That also leaves TopKRouter with two fused routing paths: fused_softmax_topk inside forward, and route_aligned picked separately in _route. Can route_aligned be the fused_softmax_topk path's branch for batches up to ROUTE_ALIGN_MAX_SLOTS, with the existing fused kernel as the fallback above that? Then there would be one flag and one place to read the routing logic. If there's a reason to keep them separate, a sentence in the docstring would help.
There was a problem hiding this comment.
You're right about the baseline, and yes. Folded in f469d63ea: TopKRouter.route is the one place that reads it. With fused_softmax_topk on CUDA, up to ROUTE_ALIGN_MAX_SLOTS renormalized slots whose block can consume an alignment (one GPU, no masked rows) take route_align, anything else takes router_topk, and with the flag off it's the eager chain. forward calls it with nothing to align against, and _route passes gate_up_proj only where the block can use the result. The Talker has norm_topk_prob=False, so it stays on router_topk.
This also fixes something: route_aligned didn't read the flag, so a default TopKRouter in a ParallelSparseMoeBlock would have taken it. Now it doesn't. GLM's and Command A+'s routers don't set the flag, and test_a_router_without_the_flag_routes_as_it_always_did refuses any fused route for them and for a default TopKRouter.
No token moved, and the head boots off the same Inductor cache in the usual 170 s: the aligned path and the fallback trace as they did before the fold.
Per decode step at T2T, nsys, one server each on one B300, main 53c47cb8c, 491+ and 383+ steady steps at c=1 and c=8, count x µs each = µs a step:
| kernel | c=1 main | c=1 this | c=8 main | c=8 this |
|---|---|---|---|---|
_router_topk_kernel |
48 x 2.44 = 117 | 48 x 2.61 = 125 | ||
moe_align_block_size_kernel |
48 x 3.10 = 149 | 48 x 3.21 = 154 | ||
count_and_sort_expert_tokens_kernel |
48 x 1.33 = 64 | 48 x 1.33 = 64 | ||
| id cast, int64 to int32 | 48 x 0.99 = 47 | 48 x 1.00 = 48 | ||
_route_align_kernel |
48 x 3.69 = 177 | 48 x 5.66 = 272 | ||
| routing and alignment | 377 | 177 | 392 | 272 |
| router GEMM | 48 x 1.87 = 90 | 48 x 1.99 = 96 | 48 x 4.97 = 239 | 48 x 4.99 = 239 |
fused_moe_kernel (B300 tiles) |
96 x 12.89 = 1237 | 96 x 9.69 = 931 | 96 x 45.09 = 4329 | 96 x 39.01 = 3745 |
act_and_mul_kernel |
48 x 1.78 = 86 | 48 x 1.76 = 84 | 48 x 2.21 = 106 | 48 x 2.41 = 116 |
moe_sum_reduce_kernel |
48 x 1.91 = 92 | 48 x 1.87 = 90 | 48 x 2.28 = 109 | 48 x 2.13 = 102 |
| MoE block | 1882 | 1378 | 5174 | 4474 |
So against the real baseline the one kernel saves 200 µs a step at c=1 and 120 at c=8. The tiles save 307 and 584. The description has this table now.
| BLOCK_PAD=triton.next_power_of_2(pad_len), num_warps=4, | ||
| ) | ||
| return weights, ids, MoeAlignment(sorted_ids, expert_ids, post_pad, block_m) | ||
| from mstar.utils.fused_moe.route import ROUTE_ALIGN_MAX_SLOTS, route_align # noqa: F401 (re-export) |
There was a problem hiding this comment.
Command A+ was affected by these changes; can you re-benchmark it if you haven't already?
There was a problem hiding this comment.
Not end to end: it's 437.5 GB with tp4/tp8 configs and this is a 2-GPU box. Here's what I ran instead.
The diff. With softmax off, which is the default and what Command A+ passes, _route_align_kernel computes the same sigmoid weights with the same rounding. The new BLOCK_K only differs from K when top-k isn't a power of two, and Command A+'s is 8, so the row mask, slot numbering and vector widths come out as they were. top_vals starts at -inf instead of 0, which only shows past K. language_model.py has no diff: same route_align arguments, same router_topk past 128 slots, and decode.py has no diff either. Two neighbours it could feel: _nearest_tuned picks the same entry as the old min for every M up to 20,000 on all five tables, GB200's tp4 table included, and fused_experts reads the same get_config values later.
The kernel. Moved vs main's copy on a B300, one process, alternated on the same logits, at the HF config's sizes (128 experts, top-k 8, block 16 at tp4 and tp8, and in GB200's table). µs per call, tp4 (tp8 is within 1 µs):
| tokens | slots | pre-move | moved | past the launch, pre-move | moved | outputs equal |
|---|---|---|---|---|---|---|
| 1 | 8 | 42.7 | 43.7 | 8.3 | 8.3 | 1020/1020 |
| 8 | 64 | 45.3 | 46.6 | 10.3 | 10.6 | 1020/1020 |
| 16 | 128 | 49.7 | 50.9 | 15.1 | 15.0 | 1020/1020 |
| 32 | 256 | 74.7 | 76.8 | 39.8 | 40.7 | 1020/1020 |
| 64 | 512 | 339.2 | 347.0 | 304.3 | 311.0 | 1020/1020 |
The extra µs inside the cap is the Python launch, two more kernel arguments, which a captured graph doesn't pay. 32 and 64 tokens are past the cap with the assert lifted, for the comparison only: the call site takes router_topk there, on both sides.
The tests. mstar/model/command_a_plus/tests on CUDA: 46 passed, 4 skipped (HF and metadata gated). I added one that the re-export is the moved kernel and stays a sigmoid route by default.
If you want the end-to-end number, command_a_plus_tp4.yaml on your node, this branch vs main, is the run.
|
Thanks, taking all three.
|
|
@merceod I set off a run for command A+, though it's on H200 so it won't exercise the new Blackwell block sizes and such. Also a follow-up (I can take) that would be complementary with this PR: if the Engine can have some outputs for a graph walk loop back via on-device buffers and others be streamed / relayed through SHM, then we would theoretically not need a separate graph walk for text decode. |
|
Thanks for setting off the Command A+ run; the B300 tiles only load on a matching GPU name, so H200 is the right check for the move itself. On the follow-up: agreed, that's the cleaner end state. The split exists only because |
|
All three are addressed at Nothing moved tokens: 1,778 Thinker decode steps are bit-identical before vs after, T2T 32/32, A2T 32/32. Against One open item: one server of 8 at this head stalled for good on the mixed audio set at c=16 staggered, 0% CPU, in a runtime compile of the Talker's 1,017-token prefill row ( |
|
On the A2T-after-I2T question from the live test (#1111 (comment)): Ran the I2T-then-A2T check on one B300 with the 1-GPU colocated config. The branch had moved, so this is the head
No order effect on either arm. On the PR, A2T after I2T averages 242.6 RTFx against 235.5 when it runs first. Main reads 216.1 against 222.1, and both differences are well inside the 10-13% spread between passes on a single server. The PR leads main in both orders, +12% after I2T and +6% with A2T first. The long gaps show up on both arms: across the three A2T passes, a server logged 11 or 21 gaps over 150 ms on main and 12 or 31 on the PR. Every server's first A2T pass has a p99 around 110-116 ms, whichever arm or order, so that tail goes with the first pass on a server and not with what ran before it. There were no recompiles after startup and no cgroup throttling during any pass. So I can't reproduce the -9.5% here, and I'd put round 1 down to the shared node. Caveats: this is one server per arm and order, not three, and it's B300, where the tuned tiles load, so it says nothing about the H200 path without them. |
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).
Main's fused_experts now runs up to ROUTED_MAX_TOKENS tokens as per-pair GEMVs, but only when it isn't handed an alignment, so route_align was turning that path off for the Thinker's decode buckets 1, 2 and 4. The router now offers an alignment only where fused_experts reads one; the check is the one fused_experts makes.
The trace test that came in with main cleared _tuned_configs' lru_cache, which this branch replaced with the _tuned_tables dict.
|
Merged main One thing merged clean but needed a call. #1140's per-pair GEMVs only run when What each part gives now, on one B300 with the same harness and pins, from the PR's old base
"Main moved" is against main's two matching sessions, and so is "this on top": main's first session was 9% under the other two at T2T c=1 and decoded different greedy tokens from them, so it looks like a different compile on the same cache (a guess). The rest of the gate, main vs this, two sessions each alternated: T2T c=64 2955/2955 vs 3079/3077 tok/s (+4.2%), A2T c=1 58.3/58.2 vs 60.3/60.2 RTFx (+3.4%), T2S c=1 17.1/16.5 vs 17.5/17.4 audio-s/s (+3.8%), A2S c=1 14.8/14.4 vs 15.3/15.1 (+4.0%). 0 failed anywhere. So #970 and #1140 took most of what this did for c=1 text, and what's left of this PR is mostly the plan cache and the 8 and 16-row route at batch, which A2T c=16 still shows. That split is my reading of the code paths, not something I profiled. Greedy at c=1: this matches main on 7,344 of 7,344 T2T decode steps against one main server, and on 6,196 to 6,537 against the other two, the same range main's own servers give each other. A2T is 200/200 clips on every pair. The two decode walks give the same text on every server. WAV bytes don't repeat across server processes on either arm. Stalls: 0 of 14 servers at the merge and around it (5 main, 8 this branch including the pre-fix merge, 1 old main). The runtime compile of the Talker's long prefill row in CPU Core fails the same ids as main (one more, |
Picks up #986's kernel registry. fused_moe/kernels.py and test_fused_moe_config.py take main's: the registry lookup replaces the branch's plain-dict table read and nearest-size search. runner.py keeps main's one get_config read (dtype, device, tuned) plus runs_per_pair. The B300 tiles move to mstar/kernels/tables/moe_gemm/NVIDIA_B300_SXM6_AC.json with #986's conversion (same tiles at every count), and the two route tests on the old table API go, covered by main's test_get_config_takes_the_row_up_to_m_and_down_overrides and test_compiled_fused_experts_takes_the_tables_tiles.
|
Merged main
Tests:
Served T2T on one B300, one session per arm (main
That's +4.3% at c=1 and +1.7% at c=16, 0 failed. Neither server recompiled anything after startup, and no Main has moved again since (#978, #1159, #932). It merges clean, but #932 reworks the KV plan cache this PR's |
A Qwen3-Omni request with no audio output now decodes on its own walk,
thinker_text_decode: its token stays on the device, nothing goes to the Talker, and the stop check reads one host copy. Both decode walks also get a KV plan cache that matches, a captured decode step builds its embeds and rotary tables in the graph, and a decode batch of 8 or 16 rows routes and aligns its experts in one kernel. Against mainb06ae391f(#970 and #1140 in), on one B300, two or three sessions each alternated: T2T +5.3 / +1.1 / +4.2% tok/s at c=1/16/64 (+2.0 / -0.3% at c=1/16 without one slow main session), A2T +3.4 / +10.2% RTFx at c=1/16, T2S +3.8% and A2S +4.0% audio-s/s at c=1, 0 failed.Merged with main.
f7fceb005merges mainb06ae391finto the branch (no rebase). #970 and #1140 changed the two files this touches, and where both did the same thing, main's version stays: the Thinker's fused add + RMSNorm (keep_statesnow drops main's layer-0 view and layer-N add), a decode row as its token alone with its position from the plan, and a mixed step's decode rows embedded in one call inpreprocess, which now also takes loop-back rows (_decode_tokens). #1140's per-pair GEMVs keep decode batches of up to 7 rows:route_alignhandsfused_expertsan alignment, which turned that path off, so the router now aligns only wherefused_expertsreads one (1efd5666b). This PR's walk, plan-cache fix, in-graph tables, fused route from 8 rows and B300 tiles stay. The text-decode test moved to the Rust runtime with #1130, and main's new tile-table trace test resets this branch's table cache (d117982ee).Merged with #986.
ad1b4e8cfmerges mainf609cac54.fused_moe/kernels.pyandtest_fused_moe_config.pyare main's (the registry lookup replaces this branch's table read and nearest-size search).runner.pykeeps main's oneget_configread plusruns_per_pair. The B300 tiles moved into the registry asmstar/kernels/tables/moe_gemm/NVIDIA_B300_SXM6_AC.json, with the same tiles at every count. Tests: ruff clean; GPU modules 1038 passed, 6 skipped, 2 failed (main's two); CPU Core fails main's ids. T2T on one B300, one session per arm: 325 vs 312 tok/s at c=1 and 1840 vs 1809 at c=16, 0 failed, nothing recompiled after startup.How this sits on #859
#859 declared what used to be each model's own hooks.
ARNodeSubmodule.device_loopback = DeviceLoopback(walk, signal, sampler, buffer)givesuniform_row_inputs,device_loopback_signals,inline_client_signalsandloopback_inputs;ARNodeSubmodule.token_stop = TokenStop(...)givescheck_stopandcheck_stop_batched. For the Thinker it also dropped the per-row host syncs in decode, made decode'sthinker_statesthe layer-0 row, and setfused_softmax_topkon the routers. The Thinker's layer loop itself didn't change, it isn't onrun_layers.What this adds is the walk #859 couldn't declare for
thinker_decode: the rows-only path needs every output of a walk to stay on the device, andthinker_decodestreams states to the Talker on every step. So the text walk declares:On top of the declarations the walk has its graph (
_get_thinker_forwardpicks it fromaudio_output),keep_states=Falsein_decodeso the two Talker states go, and_preprocess_decode, which reads throughloopback_inputs. Prefill now returns row outputs, so a mixed step's text-decode rows find their stop check and inline emit. A captured text-decode step always builds its rotary tables in the graph, as Qwen3.5's decode has since #859, soingraph_decode_ropeplays no part here.Compared with this PR's first version, these went away in favour of the declarations:
_stops,check_stop,check_stop_batched,uniform_row_inputs,device_loopback_signals,inline_client_signals,_device_loopback,reads_device_loopback,_ingraph_ropeand the hand-written slot read in_preprocess_text_decode.submodules.pywent from +198/-62 to +145/-36 against main then; against today's main it's +178/-84, since its decode paths replace the ones #970 rewrote.step_is_input_freestays, since the engine still reads it (engine.py:2969).TokenStop.walkalso takes a set now. Both decode walks end one loop, and the field took one walk or None. None would stop correctly, because no prefill walk carries this loop and the graph runtime drops the stop (check_dyn_loop). But that's the runtime's model-bug path, and prefill rows sampleim_endall the time, since a prompt section tends to end a user turn: two per A2T request in our runs, oneTried to stop loop ... potential logical bugerror each (528 over one guard server's 264 A2T requests on the Python runtime before #1130, 0 with the set; the Rust runtime drops them without a log). A set keeps what main does for a prefill row, which is name no stop.TokenStop.stops_onis the one place that reads the field, and no other declarer changes.qwen3omni_pd_2gpu.yamlis #859's config and placed onlythinker_decodeon the decode rank, so it gains the new walk next toqwen3omni_pd_disaggregated.yaml. A test checks every config that splits the Thinker by walk.The rest of the change
step_is_input_freeon both decode walks. The KV plan cache checksstep.segments is cache.step_segmentsat commit (kv/manager.py:3036). A step redeclared before the forward is a different object, so the epoch bumped every step and_fast_decode_planalmost never matched. Fast plans over a T2T phase pass: 74 of 36,036 on main, 34,101 of 36,084 here (measured on53c47cb8c, before the merge). On requests with audio output it matches about half of the Thinker's steps at c=1 on one GPU: 878 fast plans over 1,740thinker_decodesteps, against 1 over 1,796 on main. Why the rest miss isn't traced.One kernel for routing and alignment.
route_alignmoves from Command A+ tomstar/utils/fused_moe/route.pyand gains a softmax and any top-k.fused_softmax_topk, set on both Omni routers since #859, is the one flag, andTopKRouter.routeis the one place that reads the split: on CUDA, a batch of up to 128 renormalized slots whose block can consume an alignment (one GPU, no masked rows) takesroute_align, unlessfused_expertswould run it as #1140's per-pair GEMVs, which read no alignment (runs_per_pair, the checkfused_expertsitself makes: up to 7 tokens at the Omni expert sizes). Anything else takesrouter_topk, and with the flag off it's softmax,torch.topkand the renormalize. For the Thinker's decode buckets that means 1, 2 and 4 rows run main's per-pair path and 8 and 16 takeroute_align. The Talker hasnorm_topk_prob=False, so it stays onrouter_topk. GLM's and Command A+'s routers don't set the flag and don't reach any of this.So the baseline is
router_topk, the two alignment kernels and the id cast. Per decode step at T2T from a node-traced nsys window, one server each on 1 x B300, before the merge (main is53c47cb8c, which had no per-pair path; 640 and 491 steady steps at c=1, 383 and 414 at c=8), as count x µs each = µs a step:_router_topk_kernelmoe_align_block_size_kernelcount_and_sort_expert_tokens_kernel_route_align_kernelfused_moe_kernel(B300 tiles)act_and_mul_kernelmoe_sum_reduce_kernelThe route saves 200 µs a step at c=1 and 120 at c=8; the tiles save 307 and 584. The decode graph goes from 1167 kernels to 1026.
Since #1140, main runs up to 7 tokens as per-pair GEMVs, and the merge keeps that path where both apply. On B300, the Thinker's MoE block alone (router GEMM, route, experts) at E=128, H=2048, I=768, 48 calls over 8 weight sets captured in one graph and replayed, median of 5 x 100 replays, three runs within 0.1 µs (
scripts/moe_micro.pyin the run directory):route_align+ grouped GEMM, B300 tilesAt 1 to 7 tokens main's route is the per-pair GEMVs and at 8 and 16 it's
router_topkplus the align kernels into the same grouped GEMM. End to end it's a trade, two sessions each alternated on GPU 0, the merge before that commit (f7fceb005, fused route at every size up to 16 rows) against this head:The micro and the cells agree at 4 rows and not at 1, which I haven't traced. The fused route also moves greedy tokens at 1 to 4 rows: its two servers agree 32/32 with each other and 5 or 6 of 32 with main or this head (A2T 192 of 200), where main and this give each other 20 to 32 (A2T 200). Dropping the
runs_per_paircheck inTopKRouter.routeis the one-line way back to it, and a cut between 2 and 4 rows looks like the B300 optimum (a guess from these two cells). I kept main's where the two overlap: its per-pair tiles were swept on H100, and the B300 tiles that make the grouped GEMM fast here don't load on Hopper, so which one wins there isn't measured. At 16 tokensroute_align, one program over 128 slots, is a little slower than the two align kernels in this setup.In-graph tables on both decode walks. A captured
thinker_decodestep takes ids and builds its embeds and rotary tables in the graph, through the same_decodeas the text walk: onlykeep_statesdiffers, and the layer-0 row and mask go to the Talker as before. A decode row brings its id and nothing else, which #970 also made main'sthinker_decoderow do; a mixed step embeds its decode rows inpreprocess, once a step rather than once a row, and reads their positions off the plan, with main's code, which now also reads a loop-back row's token off the sampler's slot master (_decode_tokens). What this adds over main is that a captured decode step builds its embeds and rotary tables in the graph, where main's builds them inpreprocess. Measured before the merge, T2T pushed throughthinker_decodein a scratch build, before and after: eager kernels a step 12.1 to 0.1 at c=1 and 29.4 to 1.9 at c=8, copies 20 to 16, tok/s 278 / 1119 / 2913 to 283 / 1121 / 2943 at c=1/8/32. The Talker and Code2Wav set the audio-output cells, where it doesn't show:One graph for both decode walks. Sending text requests down
thinker_decodecosts too much and can't be declared away: the rows-only path needs every output signal of the walk on the device (Worker._all_on_device), andthinker_decodestreamsthinker_statesandthinker_maskto the Talker. Keeping both walks and letting them replay one capture does work, and I left it out (measured before the merge): it saves six captures but no measurable GPU memory or warm boot (95.3–95.9 GB, 165–174 s across these arms), and it makes the text walk carry two clones.thinker_decode, states shippedThe kernels aren't the cost of the first: the graph gets 2 more, +12 µs a step at c=1 and +26 at c=8 (0.4%). The rest is the host's per-request outputs,
postprocessand the stream send. The one-capture build matches the head on every greedy set (T2T 32/32, A2T 32/32, T2S and A2S text 8/8, 1,778 decode steps bit-identical), and text and audio requests at once ran with 0 failed.Tuned tiles for B300.
moe_gemmregistry entries for the Thinker's and Talker's expert sizes (mstar/kernels/tables/moe_gemm/NVIDIA_B300_SXM6_AC.json), swept with the pre-#986 tuner and converted to the registry's format. They serve the grouped GEMM: prefill, and decode from 8 rows.Gate against main
Main is
b06ae391f, with #970, #1140 and #1127 (resident planning, which Omni doesn't declare, so it stays off for both arms). 1 x B300 SXM6 (sm_103), torch 2.12.1+cu132, FlashInfer 0.7.2.post1,-O2,qwen3omni_colocated.yaml, knobskv_plan_cache: 1, ingraph_decode_rope: true, gc_gen0_threshold: 50000, prefix caching off, server on 8 cores, client on 4, an idle-priority spinner on each of those cores so they don't park. Two server sessions per arm in the order main, this, this, main, on one Inductor cache, and a third main session for T2T c=1/16 and A2T c=16, run after the route check above. T2T isbenchmark.runner, closed loop,--ignore-eos, 257 tokens a request, 64/160/640 requests at c=1/16/64. A2T is 200 LibriSpeech test-clean clips over/generate, greedy, capped at 256. T2S and A2S are 32 of those prompts and clips answered in speech, greedy Thinker, seeded Talker. Each cell warms 5 waves first. ITL is (e2e - TTFT) / (tokens - 1), p50 per request.Failed requests: 0. This is steady across its sessions (T2T c=1 315 and 318 in two more, in the route check above). Main's first session is 9% under its other two at T2T c=1, which looks like a different compile on the same cache (a guess, not traced). Leaving it out, T2T is +2.0% at c=1 and level at c=16 (-0.3%), and A2T c=16 is +10.4%. TTFT at c=64 is 2.6 to 2.7 s on both arms; I haven't looked at where it queues. For scale, one session of the old base
53c47cb8con this harness read 241 / 1628 tok/s at T2T c=1/16 and 205 RTFx at A2T c=16, so main itself moved +29 / +10 / +21% since then.The c=1 gain is smaller than against
53c47cb8c(+18%) for one reason I can point to: a 1-row decode step now runs main's per-pair MoE on both arms, where before the merge the route and the tiles saved this PR 507 µs a step at c=1 (the nsys table above). That #970's own host work also overlaps with this PR's is a guess I haven't measured. A2T c=16 keeps most of its gain, +10.2% against +12.8% before.The rest of this section ran before the merge, against
53c47cb8c, and isn't rerun.worker_phasesat T2T, untimed, on the second GPU, mean ms a step.event_syncis the GPU wait, so the CPU side is what shrinks, and at c=32 the wait takes up the difference:iter_totaliter_total.cpuworker.postprocess.event_syncworker.gpu_thread.total.cpuworker.gpu_thread.prepare_inputsengine.preprocessengine.collect_outputsWith the route and tiles off, the walk and the plan cache alone gave T2T 249 / 959 tok/s at c=1/8 in an untimed guard, where main gave 220 / 936 and this PR 287 / 1043 in the same setup. That's one run of 32 requests each, so read it for direction only.
vLLM-Omni 0.30.0 the same day as that gate, stock deploy (
qwen3_omni_moe.yaml: Thinker on GPU 0, Talker and Code2Wav loaded on GPU 1 and idle for text, Triton MoE, FlashInfer attention, prefix caching off), same cores and the samebenchmark.runnercells at temperature 0 and 256 tokens. It goes through/v1/chat/completionswith the harness's system message and M* through/generate, so the prompts aren't byte-equal. It holds 247 GB on the Thinker's GPU against M*'s 96 GB.Before the merge, main sat 7 to 10% behind vLLM-Omni on T2T, and this closed that at c=8 and c=32 and passed it at c=1. Main has moved since (#970), so that column is old.
Correctness
Greedy, exact text at c=1 on the merged head, one Inductor cache: the 32 T2T prompts (natural EOS, 256 tokens max) and the 200 A2T clips, on the gate's servers unless the row says otherwise:
Counted in Thinker decode steps up to each prompt's first differing token: this vs main matches 7,344 of 7,344 against one main server, 6,537 of 7,316 against the second and 6,196 of 7,340 against the third, and main's servers give each other 6,196 to 6,537. The A2T steps match on every pair, 5,605 of 5,605. So the merged head moves no token that main doesn't move between its own boots. That isn't the "route and tiles move bf16 rounding" picture from before the merge: a 1-row step now takes main's per-pair route on both arms, and the fused route at 1 to 4 rows was what moved the tokens (the merge without
1efd5666bgives 5 or 6 of 32 against main, above). The tiles still run this PR's prefill, and on these sets that moved nothing.Before the merge, at
f4cbbeb0b, not rerun: the route and tiles off vs main 32/32 and 32/32, loop-back on vsMSTAR_DEVICE_LOOPBACK=032/32 and 32/32, before vs after the port 32/32 and 32/32, and the 200-clip A2T WER at c=8 (5.30–5.37% on main, 5.30–5.54% here).Audio output. T2S and A2S text match as T2T and A2T do above (A2S 32/32 on every pair). PCM bytes are 0/32 on every pair, main vs main and this vs itself included: the seeded Talker's audio still differs across server processes, as before the merge.
Mixed steps with audio. The 28 short prompts and 4 of 2,600–6,500 tokens at c=16 staggered 150 ms, answered in speech, on two servers per arm (the first of each a cold compile, and the first one here before
1efd5666b): 32/32 with audio, 0 failed, about 2,020 s of audio a pass on both.Stalls. None in 13 servers at this merge, 0 of 7 on main and 0 of 6 here. The old head's one stall was a runtime dynamo compile of the Talker's long prefill row in
fused_experts. Here that compile happens on both arms, once per server, only on the long prompts answered in speech: 20 recompiled frames on main (4 of themtorch_dynamo_resume_in_fused_experts_*) and 18 here (2), over 4–10 s each, and every one finished. The gate servers recompiled nothing after startup. So it isn't specific to this PR, and the one stall stays unexplained.Tests
ruff check .: passes.1efd5666b(CUDA hidden, xdist, 2 x B300 host): 11003 passed, 997 skipped, 18 failed; mainb06ae391fon the same host 10946, 974, 17. The same 17 by id; the 18th (test_eval_compare_multigpu::test_a_head_that_hangs_is_cut_at_the_cap_with_stacks_and_fails) fails alone on main too.d117982eechanges a CUDA-only test. The TP lockstep sim and the piecewise signature module, run serially: 27 passed.test_fused_moe_route,test_fused_moe_config,test_fused_moe_decode,test_fused_moe_align,test_moe_router, the Omni modules,test_worker_device_loopback,test_inline_tokens,test_stop_policies,mstar/model/command_a_plus/tests): 989 passed, 6 skipped, 2 failed. The two failures aretest_fused_experts_matches_naive_and_precomputed_alignment[1]and[3], which fail the same way on main: since Per-model perf and serving fixes for Qwen3.5, Qwen3-Omni, Fish, OmniVoice and others #1140,fused_expertsruns 1 and 3 tokens per pair without an alignment and grouped with one, so the two aren't bit-equal.test_fused_moe_route.pygains a test that a batchfused_expertsruns per pair takesrouter_topkand the next size up takesroute_align. The rest as before: the split at the cap, no renormalize and no alignment target takingrouter_topk, a block offering its weights only where it can use the alignment, and a defaultTopKRouter, GLM'sNoAuxTcRouterandCommandAPlusRouterrefusing any fused route.test_qwen3_omni_text_decode.pycovers both decode walks: ids only on a routed row, both captures taking ids, in-graph tables, the states and mask onthinker_decode, no host read, a mixed step's planned positions, and the stop on either walk through the Rust runtime.language_model.pyhas no diff; its tests are in the GPU run above.route_alignmoved vs pre-move on B300 at E=128, K=8 (before the merge): outputs equal on 1020/1020 calls per size, 42.7 / 45.3 / 49.7 µs a call pre-move vs 43.7 / 46.6 / 50.9 moved at 1 / 8 / 16 tokens, and the extra µs is the Python launch, which a captured graph doesn't pay.Not in this PR
worker_phases, nsys, and the 2-GPU configs. Those ran before the merge.thinker_decodestill computeslayer_n_hidden(main's residual add now), which decode doesn't read. Dropping it touches the audio path, so it's a separate change.route_alignshould stop at 64 slots. Both want a check on Hopper too.On H200
NSagan271's live test of the first version (
101f9feagainst its merge base9b90e81, H200, Rust runtime, torch 2.10, median of 3 servers per arm) is the Hopper picture. The B300 tile tables don't load there, so what carries over is the text walk, the plan cache androute_alignon 1 GPU (it is off under TP).The A2T c=16 dip came with p99 inter-token gaps of 185–239 ms in a round where A2T ran after the I2T cells on each server, and did not show when A2T ran first; both arms show the same occasional gaps. An I2T-then-A2T run on one server with this head is queued on our side. Greedy replies there were identical across repeats on every server, and more reproducible across restarts than main on single images (21/21 vs 11/21).