Skip to content

[HIP] [Bugfix] Honor num_local_tokens on the MXFP4 inline-sort path - #7083

Closed
ZLkanyo009 wants to merge 2 commits into
mainfrom
lingzha/fmoe-inline-sort-num-local-tokens
Closed

ZLkanyo009 wants to merge 2 commits into
mainfrom
lingzha/fmoe-inline-sort-num-local-tokens

Conversation

@ZLkanyo009

@ZLkanyo009 ZLkanyo009 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The MXFP4 inline-sort path of fused_moe now accepts num_local_tokens. The fused BM16 aux sort (mxfp4_moe_sort) learns to read the real row count on device, and _mxfp4_inline_sort_unsupported stops rejecting calls that pass one. ATOM main always passes num_local_tokens, so the Qwen3.8-Flash-Next rows #6708 tuned had stopped being used there. With this PR they are used again, and ATOM still skips the padded CUDA-graph rows.

Motivation

Two changes interact here:

_mxfp4_inline_sort_unsupported rejects every call that carries num_local_tokens. On ATOM main, all of those tuned rows are therefore discarded:

[aiter] [fused_moe] inline-sort config is unsupported (unsupported option num_local_tokens); using default heuristics
[aiter] [fused_moe] discarding tuned inline-sort config

Each hour of serving logs tens of thousands of these lines. Falling back to the default heuristics costs Qwen3.8-Flash-Next 3–11% p90 ITL interactivity at concurrency 1–8 on AgentX (MI355X, TP1, MTP 3). If ATOM stops passing num_local_tokens, the tuned rows come back, but the padded-row skipping from #2725 is lost.

Changes

The rest of the inline-sort path already handles num_local_tokens:

  • The Opus aux sort runs when 3 * M * topk >= 4 * E. It reads the count on device, routes only the leading rows, prefills reverse_sorted with -1 and writes the count to cumsum_tensor[1].
  • The layout-v2 FlyDSL stage 2 switches to use_valid_token_count when num_local_tokens is given, so it only writes rows below cumsum_tensor[1]. The AOT build already covers both variants.
  • Stage 1 reads activations through m_indices. Padding slots point at row M, past the end of A, and the hardware drops those loads.

The one exception was the fused BM16 aux sort mxfp4_moe_sort (aux_sortzi_* / aux_sortonly_*), which handles small M. It sorted all M host rows, so the -1 ids of padded rows would have been sorted as if they were experts. This PR changes:

  • moe_sort_quant.cuh: sort_subkernel takes an optional device num_local_tokens and sorts only the leading min(max(*num_local_tokens, 0), M) rows. Past those rows, reverse_sorted is -1, and cumsum_tensor[1] holds the count, matching moe_sorting_opus_fwd. Padding slots still point at physical row M. The zero-init variant still clears all M output rows, because stage 2 accumulates into them.
  • The pointer is passed through codegen (gen_instances.py, mxfp4_moe_aux_dispatch.h), mxfp4_moe_sort_kernel, the pybind (num_local_tokens=None) and _adaptive_moe_sort / _moe_sorting_impl. mxfp4_moe_sort_kernel checks that the tensor is int32 with at least one element on the same device, and accepts it only with prologue 0. That is the only prologue the inline-sort path uses, since the path is BM16-only.
  • num_local_tokens is removed from the options that _mxfp4_inline_sort_unsupported rejects.

Calls without num_local_tokens are unchanged: they pass nullptr and run the same kernels.

Related: #7062. For M >= 63, the inline-sort path now also reaches the Opus multi-phase sort with num_local_tokens, since E = 513 on gfx950 always takes multi-phase. That kernel's tail over-read is fixed separately in #7062. The over-read values are never used, ATOM main already hits the same read on its non-inline-sort rows, and this change does not depend on the fix. All runs below include #7062.

Performance

AgentX, Qwen3.8-Flash-Next MXFP4, MI355X, TP1, MTP 3, no offload, 1 h per point, ATOM main fbb82673e. "Before" is aiter main and "after" is this PR, both on the same node.

Three groups were run: "before", "after", and "no count", which is ATOM main patched to stop passing num_local_tokens (pre-#2725 behaviour) on aiter main. "No count" is the upper bound this PR should reach. In each group, the four concurrencies run at the same time on four GPUs, and the groups run back to back. There were two rounds: round 1 ran after → before → no count, and round 2 ran before → no count → after with the GPU assignment reversed. Averages over the two rounds:

Conc p90 ITL interactivity, before → after Δ p90 norm interactivity Δ tput/GPU Δ "No count" vs before (ITL) After vs "no count" (ITL)
1 212.7 → 222.1 +4.4% +0.1% −0.3% +3.9% +0.5%
2 210.6 → 217.2 +3.1% +2.6% −5.6%* −0.4% +3.6%
4 152.6 → 170.7 +11.9% +6.8% +6.3% +6.8% +4.7%
8 135.6 → 147.1 +8.5% +9.3% +1.9% +8.8% −0.3%

Per round, p90 ITL interactivity before → after:

Conc Round 1 Round 2
1 212.5 → 220.6 (+3.8%) 212.8 → 223.6 (+5.1%)
2 209.9 → 216.3 (+3.1%) 211.2 → 218.0 (+3.2%)
4 140.0 → 164.0 (+17.2%) 165.1 → 177.3 (+7.4%)
8 136.3 → 146.6 (+7.5%) 134.9 → 147.7 (+9.5%)

All 24 points had 0 errors. The "after" group is at or above "no count" except for c8, where the two are within 0.3%. *The c2 throughput gap comes from round 1's replay alone. There, the "before" and "no count" runs replayed nearly the same requests (605 requests, 59.8M prompt tokens), while the "after" run replayed 574 requests and 53.3M prompt tokens. In round 2 the three groups replayed 586–588 requests each, and c2 throughput is −0.1%.

Single-layer fused_moe on the same shape (all rows real, i.e. the c ≤ 16 decode case), replayed in a CUDA graph, in µs:

M main, with num_local_tokens main, without this PR, with this PR, without
1 25.3 13.9 14.1 14.1
4 36.2 25.0 25.9 25.6
16 74.5 61.4 62.9 62.8
32 116.9 102.0 102.4 102.0
64 186.2 159.5 159.4 158.8
256 260.0 221.3 221.6 221.3

With this PR, passing the count costs at most 0.6 µs per layer, and each MoE layer is 11–39 µs faster than main whenever a caller passes num_local_tokens.

Testing

  • Unit tests added/updated
    • op_tests/test_moe_sorting_mxfp4.py gets new _check_inline_sort_* cases (E/topk 513/11 and 896/16; rows/count (1,1), (4,0), (4,3), (16,5), (48,47), (48,48)). The sort with a count must match a sort of only the leading rows, including padding slots, the reverse_sorted tail (-1), cumsum_tensor[1] (= count) and the zeroed moe_buf. A CUDA graph replayed with new counts is also checked. All cases pass.

    • New op_tests/test_moe_inline_sort_num_local_tokens.py runs fused_moe on the Qwen3.8-Flash-Next TP1 shape (E=513, 2560/640, topk 11, random MXFP4 weights) with padded rows: NaN activations, -1 ids and NaN weights. It covers rows/count 1/1, 4/1, 4/3, 16/5, 16/16, 44/1, 44/43, 128/64, 128/127 and 256/200, which exercises both the fused BM16 sort and the Opus branch. It checks that:

      • the tuned inline-sort row stays selected;
      • the leading rows are finite and match a call where every row is real (relative L2 error 0.35–0.57%);
      • a captured graph replayed with counts M, M/2+1 and 1 matches eager (0.31–0.55%).

      Two identical calls already differ by 0.32–0.43% because of the stage 2 bf16 atomic order, so these errors are within run-to-run noise. The test skips off gfx950 and when cu_num != 256, because the tuned rows are pinned there. On main it fails as expected (M=1: tuned inline-sort row not used).

  • Performance benchmarks run (above)
  • GSM8K (5-shot, limit 200, real MTP verification, ATOM main): strict-match is 0.99 on both main and this PR, and MTP acceptance is 0.729 vs 0.728. With this PR, the server log has no inline-sort fallback lines (main: 6,288), and every MoE bucket from 1 to 512 tokens runs the [HIP] Tune Qwen3.8-Flash-Next MXFP4 a4w4 MoE on gfx950 and add its aux shape #6708 inline-sort row.
  • The new fused_moe test also passes with AITER_MOE_SORT_BACKEND=opus, where small M takes the Opus aux sort too.
  • Tested on MI355X (gfx950) only, because the inline-sort rows are gfx950-only. The benchmarks above ran on base 004245be3 (plus [Test] Regression test for topk_ids tail reads in multi-phase MoE sorting #7062). The branch has since been rebased onto bbef5a6f2 without conflicts, and both op tests pass again on the rebased head (plus [Test] Regression test for topk_ids tail reads in multi-phase MoE sorting #7062).

Dependencies

  • No new third-party dependencies added

Breaking Changes

None. mxfp4_moe_sort gains a trailing optional argument.

cc @valarLip (ROCm/ATOM#2725) @yzhou103 (#6708)

ZLkanyo009 and others added 2 commits October 28, 2026 08:27
Co-authored-by: Cursor <cursoragent@cursor.com>
test_moe_sorting_mxfp4.py checks the fused BM16 aux sort against a sort over
the leading rows only, including a CUDA graph replayed with new counts.
test_moe_inline_sort_num_local_tokens.py runs fused_moe on the tuned
Qwen3.8-Flash-Next rows with padded rows (NaN activations, -1 routes) and
checks that the tuned inline-sort config stays selected and that the leading
rows match an all-real call.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ZLkanyo009
ZLkanyo009 requested a review from a team October 28, 2026 08:28
@github-actions github-actions Bot added the HIP label Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every PR:

  • ✅ Pre-checks (submodule verification, code formatting)
  • ✅ Aiter op tests (gfx942 + gfx950)
  • ✅ Triton tests on MI35X (only when aiter/ops/triton/** or related paths are changed)

Extended tests (opt-in via labels):

Label Tests
ci:gfx1250-ffm-triton Run the five-shard gfx1250 FFM Triton test suite
ci:triton-300x Run an additional Triton test job on MI300X in PRs (added automatically when gfx942 configs change); main branch always runs both MI35X and MI300X
ci:triton-355 Run the full Triton test suite on MI35X, not only the tests the change affects
ci:windows Windows JIT op smoke test on the gfx1201 ROCm runner
ci:multi-gpu Aiter multi-GPU tests on the 8-GPU runner
ci:sglang SGLang integration tests: DeepSeek-R1-MXFP4 accuracy, Qwen 3.5 accuracy
ci:atom ATOM benchmark: DeepSeek-R1-0528, GPT-OSS-120B
ci:atom_full ATOM accuracy suite for PR and main models from ATOM models_accuracy.json
ci:vllm vLLM benchmark: DeepSeek-V4-Pro, MiniMax-M3-MXFP4
ci:all All standard extended tests (excludes ci:atom_full)

Only add ci:atom_full for FlyDSL or Triton upgrades.
Add labels via the sidebar or gh pr edit 7083 --add-label <label>

One backend per PR:
A PR changes one kernel backend: [Triton/Gluon] (Triton and Gluon count as one), [HIP], [ASM], [CK], [OPUS] or [FlyDSL]. If the title ends up with two backend tags, split the PR -- as stacked pull requests when one part cannot merge without the other.

PR title tags & labels:
Component tags ([Triton/Gluon], [HIP], [CK], [ASM], ...) are added to the PR title and as PR labels automatically from the changed files and re-synced on every push — change-type tags like [fix]/[Perf], op tags like [MLA], and human labels (ci:*) are left untouched. Add the no-auto-title label to stop the title rewrites; labels stay in sync either way.

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@valarLip @yzhou103 Update: this is now covered by main. #7020 (f2d9434bb) makes the MXFP4 inline-sort path accept num_local_tokens, and #7085 (c90842add) keeps the quant-sort and multi-phase sort in bounds under it.

On aiter main 4d1980603 (without this PR), MI355X:

So the low-concurrency regression this PR targeted (ROCm/ATOM#2725 × #6708) is fixed on main. The only thing main does not have is this PR's fused-MoE test with Qwen3.8 shapes. I can trim the PR down to that test if it is useful; otherwise it can be closed.

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Closing this one: main now covers it. #7020 makes the MXFP4 inline-sort path accept num_local_tokens, and #7085 keeps the sorts in bounds.

We confirmed it end to end with Qwen3.8-Flash-Next on MI355X, on a tree with aiter main (with #7020 / #7085) and without this PR:

  • In all 28 servers, there were 0 inline-sort config is unsupported fallbacks and 0 discarded tuned configs. Every MoE token bucket from 1 to 512 picks the same [HIP] Tune Qwen3.8-Flash-Next MXFP4 a4w4 MoE on gfx950 and add its aux shape #6708 tuned row as with this PR.
  • The AgentX curve is within noise of the run with this PR. A same-node A/B at c1 / c2 / c4 / c8 gave −1.1% to +0.1% throughput and −1.6% to +1.9% p90 ITL interactivity.
  • GSM8K is 0.9795 for the full set.

Thanks @valarLip @yzhou103 for the reviews.

@ZLkanyo009 ZLkanyo009 closed this Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants