Skip to content

[Kernel] Wait for the PDL dependency before loading the router bias (Triton + radix) - #38290

Merged
Jiminator merged 2 commits into
qwen4-main-squashedfrom
fix/moe-fused-gate-gdc-wait-before-bias
Sep 7, 2026
Merged

Jiminator merged 2 commits into
qwen4-main-squashedfrom
fix/moe-fused-gate-gdc-wait-before-bias

Conversation

@Jiminator

@Jiminator Jiminator commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Targets qwen4-main-squashed, on top of #38308 (the cherry-pick of #36811, merged).

Motivation

Two PDL-launched routing kernels load their bias before the programmatic-dependent-launch wait and load scores after it:

  • python/sglang/kernels/ops/moe/moe_fused_gate.py::_router_triton_kernel (Triton, launch_pdl=True on compute capability >= 9)
  • python/sglang/kernels/jit/csrc/moe/route_radix.cuh::route_radix_block (the radix fast path that moe_fused_gate dispatches covered sigmoid inputs to; also reused by route_quant_fused.cuh)

Under PDL a grid may start once the preceding kernel's blocks have exited, before that kernel's stores are visible; the wait (griddepcontrol.wait) is what establishes completion and visibility. A load placed before it must not depend on prior work. Both kernels assumed the bias is a frozen weight, but the callers do not guarantee that: fused_topk's sigmoid path passes correction_bias.to(torch.float32) (a fresh tensor when the parameter is fp16/bf16) or a fresh torch.zeros when there is no correction bias (topk.py:1047-1054), the grouped and ungrouped wrappers cast as well, and before #36811 the softmax path passed a fresh torch.zeros on every call. The compiled sm_121 Triton kernel confirms the pre-fix order: four LDG.E.128 bias loads precede ACQBULK, the score loads follow.

This is the mechanism behind #37111 (Qwen3.8-Flash-Next NVFP4 with NEXTN on DGX Spark, output collapsing to repeated !), confirmed by capturing the values the kernel consumed during an organic collapse. With an instrumented copy of the unmodified kernel that additionally stores, per row, the bias it loaded before the wait, the scores it loaded after the wait, and a volatile re-read of the bias after the wait, an onset step (32 rows, 23 with NaN top-k weights) showed for every bad row: the bias loaded before the wait contained NaN (256 of 512 elements in most rows; in others the first 64 were zero and the next 64 to 128 NaN), the bias re-read after the wait was all zero, and the scores loaded matched the settled logits exactly. Every clean row had seen an all-zero bias before the wait. Upstream of the router the block input and the gate logits were finite; downstream, the expert kernel produced NaN in exactly those rows and the batch collapsed. #36811 already removed the fresh-zero trigger from plain softmax routing; dynamic-bias paths still need correctly ordered loads.

Modifications

Move the wait above the bias load in both kernels so every dependent load sits after it. No change to routing semantics, dispatch, launch geometry, or the outgoing gdc_launch_dependents(); the HAS_BIAS=False register-zero path is unaffected.

Accuracy / performance

On 1x DGX Spark (GB10), RadixArk/Qwen3.8-Flash-Next-NVFP4, NEXTN 3/1/4, radix cache off, 8 running requests, GSM8K 1319 (chat, greedy, 8192 max tokens) plus a churn client, on the pre-#36811 build 9b2aee2283 where the collapse reproduced within 2 to 25 minutes in most runs: with only the Triton reorder applied (PDL on, zero-bias still allocated) the full set completed at 0.967 with 0 invalid answers, 0 NaN events, and 0 routing mismatches against a reference top-k recomputed after the kernel on the same stream. Two workarounds that also remove the dependency (PDL off for the kernel; SGLANG_OPT_USE_JIT_KERNEL_FUSED_TOPK=0) each completed the same set clean (0.970 / 0.971). Author-reported; the radix change was not exercised by these runs (softmax routing does not dispatch to radix).

Cost: the bias load no longer overlaps the tail of the previous kernel, so a program pays that load's latency after the wait instead of hiding it. Per launch, not per row (rows run concurrently); zero on the softmax path with #36811 since no bias is loaded. Not measured here.

Follow-ups (not in this PR)

  • A deterministic regression check: compile a bias-present specialization and assert every ld.global in the PTX comes after griddepcontrol.wait.
  • moe_fused_gate.py grouped routing removes all tied group maxima when computing the second maximum (vals2 = tl.where(vals >= top1, ...)), so tied top-1 values yield the wrong group score. Pre-existing, unrelated to this change.
  • kernels/jit/include/sgl_kernel/utils.cuh:170-172 describes the PDL trigger as covering writes issued before it; it is a launch hint, and consumers must wait.

Checklist

  • CI kernel tests (test/registered/kernels/ops/moe/test_moe_fused_gate.py, test/registered/kernels/ops/kimi_k3/test_compute.py)

CI States

Latest PR Test (Base): ❌ Run #34108740533
Latest PR Test (Extra): ❌ Run #34108740503
Latest PR Test (AMD ROCm 7.2): ❌ Run #34108740687

@Jiminator Jiminator changed the title [Kernel] moe_fused_gate: wait for the PDL dependency before loading the bias [Kernel] Wait for the PDL dependency before loading the router bias (Triton + radix) Sep 7, 2026
@Jiminator
Jiminator marked this pull request as ready for review September 7, 2026 09:47
@Jiminator
Jiminator force-pushed the fix/moe-fused-gate-gdc-wait-before-bias branch from d37b5d6 to 01db521 Compare September 7, 2026 09:52
Jiminator and others added 2 commits September 7, 2026 02:55
The Triton router kernel prefetched `bias` before `gdc_wait()` and loaded
`scores` after it. The bias is not an immutable input: `fused_topk` passes
a buffer written by the immediately preceding kernel (a dtype cast of the
correction bias today; a fresh `torch.zeros` before #36811). Under
programmatic dependent launch the router can start once that kernel's
blocks exit, before its stores are visible, so the pre-wait load could read
the buffer's previous contents. On DGX Spark (GB10) with Qwen3.8-Flash-Next
NVFP4 + NEXTN this surfaced as batch-wide NaN routing weights and an output
collapse to token 0 (#37111). Move the wait above the bias load so every
dependent load sits after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same ordering defect as in the Triton router: route_radix_block prefetched
the bias before PDLWaitPrimary(), assuming a frozen weight, but the public
moe_fused_gate dispatches covered sigmoid inputs here with a bias that can
be a fresh fp32 cast or a fresh torch.zeros. Move the wait above the load.
Also shorten the Triton kernel comment to state only what is established.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Jiminator
Jiminator force-pushed the fix/moe-fused-gate-gdc-wait-before-bias branch from 01db521 to 7e2a26d Compare September 7, 2026 09:55

@BBuf BBuf 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.

LGTM.

@Jiminator
Jiminator merged commit 4ccff14 into qwen4-main-squashed Sep 7, 2026
80 of 90 checks passed
@Jiminator
Jiminator deleted the fix/moe-fused-gate-gdc-wait-before-bias branch September 7, 2026 10:02
Jiminator added a commit that referenced this pull request Sep 7, 2026
…-set GSM8K scores

The four low-latency (NEXTN) DGX Spark cells collapsed sporadically to token 0
on the 9b2aee2 build: the Triton router kernel loaded its zero-filled bias
before its PDL wait and could read stale NaN bytes. qwen4-main-squashed now
carries #36811 (no zero-bias allocation) and #38290 (wait before the bias load),
and dev-qwen38-next-local is rebuilt from 4ccff14. Full GSM8K (1,319, chat
API, greedy, 8,192-token budget) on that image: 97.0 / 97.1 / 97.0 / 97.1 for
RDXA 2x, RDXA 1x, NVDA 2x, NVDA 1x, all answers finished, no collapsed batches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Jiminator pushed a commit that referenced this pull request Sep 7, 2026
…dev-qwen38-next-local image (4ccff14)

The image now carries the two MoE router fixes for the NEXTN "!" collapse
seen on GB10 (#36811 cherry-picked as c8457de, #38290 as 4ccff14).
Each RTX cell was booted from the rebuilt image exactly as the command
generator emits it and re-measured on 2026-09-07:

- Full GSM8K, chat protocol (thinking off, 8,192-token budget, the card
  figure): RDXA 96.9% / 96.9%, NVDA 97.3% / 97.0%; every answer finished.
- Full GSM8K via sglang.test.run_eval (5-shot, thinking on): RDXA 97.7% /
  97.8%, NVDA 97.4% / 97.7%; MTP accept length 3.16 / 3.15.
- 1024-in/256-out random bench re-run; throughput rows updated (input plus
  output tok/s per GPU). No error or NaN in any server log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5B2WK8ciABmJ8pMBtGcgN
Jiminator pushed a commit that referenced this pull request Sep 7, 2026
…re-verification

Follow-up to b4312c6, which updated only the benchmark rows: the RTX PRO
6000 notes accordion and the two cell comment blocks now cite the rebuilt
image (4ccff14, #36811 and #38290 router fixes) and its numbers: full
GSM8K chat 96.9 / 96.9 (RDXA) and 97.3 / 97.0 (NVDA), run_eval 97.72 /
97.79 and 97.41 / 97.72, 1024/256 bench 6.0 / 11.4 ms TPOT at 1 request,
685 / 561 output tok/s at 16, 909 at 64 without MTP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5B2WK8ciABmJ8pMBtGcgN
joev-harmonic added a commit to joev-harmonic/sglang that referenced this pull request Sep 10, 2026
Port merged upstream PR sgl-project#38290 onto the internal Qwen3.8 serving stack.
joev-harmonic added a commit to joev-harmonic/sglang that referenced this pull request Sep 11, 2026
Port merged upstream PR sgl-project#38290 onto the internal Qwen3.8 serving stack.
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.

2 participants