Skip to content

Work around all-zero fused MoE output at block_m=32 - #4451

Closed
jatseng-ai wants to merge 1 commit into
ROCm:mainfrom
jatseng-ai:fix/moe-2stage-blockm32-zero-output
Closed

jatseng-ai wants to merge 1 commit into
ROCm:mainfrom
jatseng-ai:fix/moe-2stage-blockm32-zero-output

Conversation

@jatseng-ai

Copy link
Copy Markdown

Summary

The CK 2-stage fused-MoE kernel returns an identically zero output tensor at block_m=32 on gfx942. It does not raise, does not produce NaNs, and logs nothing — fused_moe simply returns all zeros.

get_block_size_M selects block_m=32 for token counts <= 32, which in serving means every decode step. Prefill uses larger tiles and is correct. The result is a MoE model that reads context correctly and then degenerates during generation, because the entire routed-expert contribution is silently dropped while attention and (unfused) shared experts keep the output superficially fluent.

This PR clamps block_m to >= 64 on the 2-stage path and adds a regression test. It is a workaround, not a root-cause fix — see "Open question" below.

Reproducer

Standalone, ~2 min, no model download. bf16 / QuantType.No, model_dim=4096, inter_dim=512, E=128, topk=8, identical seeds on both versions:

token | v0.1.18 absmax | state | v0.1.13.post1 absmax | selected block_m -- | -- | -- | -- | -- 1 | 0.000000 | all zero | 0.012573 | 32 8 | 0.000000 | all zero | 0.015259 | 32 32 | 0.000000 | all zero | 0.020142 | 32 33 | 0.022949 | ok | 0.022949 | 64 128 | 0.020752 | ok | 0.020752 | 128

Test

op_tests/test_moe_blockm_invariance.py asserts that fused_moe results do not depend on block_m, which is a scheduling knob and must not change what is computed. It covers bf16 and fp8/per_Token at token 1 (decode) and token 64 (to decouple tile size from token count).

Two details that matter for anyone extending it:

  • The failure mode is an all-zero tensor, not an exception or a NaN, so a test that only checks for a successful launch or for finiteness passes straight through it. The test asserts non-zero output explicitly.

  • Kernel selection is functools.lru_cached, so changing block_m in-process without clearing those caches silently reuses the first decision and the test passes vacuously. The helper clears them between runs.

Open question for maintainers (the actual root cause)

I could not find the source-level cause, and the evidence suggests it is not in the algorithm:

  • csrc/py_itfs_ck/ (including moe_ck_2stages_kernel.cu and moe_ck_2stages_gemm_impl/) is byte-identical between v0.1.13.post1 and v0.1.18 — diff -rq reports no differences for the MoE kernels.

  • get_block_size_M is byte-identical as well, so both versions select the same tile for the same shape.

  • The csrc/ck_gemm_moe_2stages_codegen/ directory differs only in gemm_moe_tune.py.

Identical sources therefore produce a working kernel in one build and a silent no-op in the other, which points at codegen or build configuration rather than the kernel logic. Someone familiar with the CK instantiation path can likely find this quickly; the reproducer above should make it a short loop.

Scope of validation

Stated plainly so reviewers can judge:

  • gfx942 only. gfx950 untested.

  • One shape (E=128, topk=8, model_dim=4096, inter_dim=512). Other shapes untested.

  • block_m=16 untested — the clamp raises it to 64 on the assumption that a smaller tile is at least as broken, which is unverified.

  • The clamp gives small batches a larger tile than the cost heuristic chose. A decode-throughput A/B is in progress; numbers will be added here.

The CK 2-stage MoE kernel writes nothing at block_m=32: fused_moe returns an
identically zero tensor rather than failing or producing NaNs. Reproduced on
gfx942 for bf16/QuantType.No and fp8/QuantType.per_Token.

The fault depends on block_m alone, not on the token count. Forcing block_m=32
at token=64, a size that is otherwise correct, also yields zeros, while 64 and
128 are correct at every size tested.

This is severe in serving because get_block_size_M selects 32 for token <= 32,
i.e. every decode step, while prefill takes larger tiles and stays correct. A
MoE model then retrieves context correctly and degenerates into repetition
while generating, since the routed-expert contribution is silently dropped and
only shared experts and attention still contribute. On an internal long-context
accuracy suite this showed up as 9/10 degenerate completions at 4k and ~23/50
at 131k, with the retrieval target still found in every case.

The clamp is placed after both the heuristic and the tuned-CSV branch assign
block_m, because either can select 32 and 469 of the 617 rows in
configs/tuned_fmoe.csv pin block_m=32 with run_1stage=0. AITER_MIN_BLOCK_M
restores the original selection for anyone bisecting the underlying kernel bug.
1stage and asm paths are untouched.

This is a workaround, not a fix. The CK 2-stage sources under csrc/py_itfs_ck
and get_block_size_M itself are byte-identical between v0.1.13.post1 and
v0.1.18, so the same source produces a working kernel in one build and a no-op
in the other, which points at codegen or build configuration rather than the
algorithm.

Add op_tests/test_moe_blockm_invariance.py asserting that fused_moe results do
not depend on block_m. The existing MoE tests miss this because they only
exercise whichever tile the heuristic happens to pick, and the failure mode is
an all-zero output rather than an exception, so finiteness checks pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jatseng-ai
jatseng-ai requested a review from a team July 30, 2026 06:40
@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:triton-300x Run an additional Triton test job on MI300X in PRs; main branch always runs both MI35X and MI300X
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: GPT-OSS-120B, DeepSeek-R1-0528, Kimi-K2.5
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 4451 --add-label <label>

@zufayu
zufayu self-requested a review July 31, 2026 01:18
@zufayu

zufayu commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review: PR #4451 — clamp block_m ≥64 to mask CK 2-stage all-zero bug

⚠️ NEEDS WORK — real bug, honest write-up, solid test, but it changes a core MoE contract via an over-reaching clamp + a rejected env knob, with downstream CI skipped and no perf/TP data.

  • Owner sign-off (don't self-merge): fused_moe.py is a stable downstream contract. → @-mention the MoE owner (@valarLip) for explicit sign-off before merge.
  • Downstream CI skipped: PR has no labels, so Atom Test / Kimi Downstream / Sglang don't run — yet this changes DSv3/Kimi decode MoE and can merge green, then break downstream. → Add ci:all (or ci:atom+ci:vllm) and require green.
  • Env-var knob: AITER_MIN_BLOCK_M is a new permanent env flag — aiter rejects these (AITER_MOE_FORCE_BF16_ACT in MOE: add AITER_MOE_FORCE_BF16_ACT to force bf16 activations (opt-in) #3593 was reverted by Revert "MOE: add AITER_MOE_FORCE_BF16_ACT to force bf16 activations (opt-in)" #4225), and here it re-enables a silently-corrupting path. → Drop it; derive the safe floor from dtype/arch/shape, or keep only as a clearly-temporary rollback tied to the root-cause fix.
  • Over-reach: if not run_1stage also catches per_1x128 decode (block_m 16→64, fused_moe.py:1928-1947) — the DSv3/Kimi fp8 block-scale path, contradicting "asm unaffected" and never validated. → Narrow to the CK 2-stage path or validate per_1x128; fix the comment.
  • Perf + shapes: clamp overrides 469/617 tuned rows and the decode A/B is still pending; test is one toy shape (E=128/topk=8/4096/512), no TP=4/8 split, no prefill. → Post decode numbers and add production/TP shapes before merge.
  • Test gap: covers only No/per_Token, not per_1x128; and with the fix, forced block_m=32 is clamped before the kernel, so it tests the clamp, not the kernel at 32. → Add a per_1x128 case.
  • Root cause live: CK all-zero bug unfixed; gfx950 untested. → Track the codegen root cause.

@zjin-lcf

zjin-lcf commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Root cause found — and it's already fixed upstream in CK; this can be a CK-submodule bump instead of a block_m clamp.

The all-zero output isn't a block_m=32 dispatch/tiling issue — it's a miscompile-free copy-paste bug in a CK refactor.

Already fixed upstream. ROCm/composable_kernel develop commit f33252ce
(ROCm/composable_kernel@f33252c)

That commit is a descendant of the CK submodule this branch pins (b675945), so this tree predates the fix — hence the all-zero decode.

Suggested change. Bump the CK submodule to include f33252ce (or cherry-pick it) and drop the block_m≥64 clamp. This also covers the fp8-per-token and blockscale gate-up paths, which the clamp only helps incidentally.

Verification (gfx950, standalone CK repro of the 32-tile gemm1 gate-up instance): device_absmax goes 0 → 302.25 with the fix; the block_m=128 control is unaffected.

@jatseng-ai

Copy link
Copy Markdown
Author

Closing this workaround PR — it is superseded.

aiter main already pins CK at the SHA that addresses the root cause (CK bump f33252ce, per review from @zjin-lcf). With that fix landed upstream, the block_m=32 all-zero fused MoE output workaround here is no longer needed.

Thanks for the review and pointer to the proper fix!

@jatseng-ai jatseng-ai closed this Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants