[HIP] [CK] [MoE] Reject non-int32 index buffers in the topk kernels - #5255
Conversation
`topk_softmax`, `grouped_topk` and `biased_grouped_topk` write their outputs
through `reinterpret_cast<int*>(...)` without checking the tensor's dtype.
A caller that allocates a 64-bit index buffer therefore gets only the low
half of every slot written; the rest keeps whatever the allocation happened
to contain. Nothing fails -- the call returns normally and hands back a
plausible-looking tensor.
Measured on gfx950 (MI350X) with one direct `topk_softmax` call, buffer
pre-filled with -999999 so an untouched slot is unmistakable, 256 tokens,
topk 6, 64 experts:
buffer dtype ids landing in [0, 64) min / max
torch.int32 1536 / 1536 (100%) 0 / 63
torch.int64 14 / 1536 (0.9%) -999999 / 270582939709
The surviving poison is the proof that those slots were never written.
Downstream this corrupts whatever consumes the ids. In vLLM's ROCm
expert-parallel path it reached DeepEP V2, which indexes with them: an
out-of-bounds write ("Memory access fault ... Write access to a read-only
page"), and -- once the obviously invalid ids were clamped -- duplicate
expert ids per token tripping DeepEP's own device assert and aborting with
HSA_STATUS_ERROR_EXCEPTION. Two unrelated-looking crash signatures, one
cause, and a long way from the kernel that produced it.
`topk_gating` already validates exactly these arguments:
AITER_CHECK(topk_weights.dtype() == AITER_DTYPE_fp32, "topk_weights must be float32");
AITER_CHECK(topk_indices.dtype() == AITER_DTYPE_i32, "topk_indices must be int32");
so this only brings its siblings in line rather than introducing a new
contract.
No behaviour change for correct callers: every allocation in this repository
already uses int32 (`fused_moe.py:4261,4279`, `fused_moe_bf16_asm.py:608`),
as do the op_tests (`test_topk_softmax.py:22-23`,
`test_moe_topk_gating.py`).
The caller-side fix for the vLLM case is vllm-project/vllm#55147; this change
is what turns the same mistake into an immediate, attributable error for the
next caller instead of silent memory corruption.
Signed-off-by: Ilia Kosarev <ilia.kosarev@amd.com>
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
PR title tags & labels: |
There was a problem hiding this comment.
🟡 Changes recommended
The new fail-fast behavior is not covered by negative tests (e.g., asserting int64 index buffers raise), which risks regressions back to silent corruption.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds runtime dtype validation to MoE top-k routing kernels to prevent silent memory corruption when callers provide non-int32 index output buffers, aligning behavior with existing topk_gating contract checks.
Changes:
- Add
AITER_CHECKdtype guards totopk_softmaxfortopk_weights(fp32),topk_indices(int32), andtoken_expert_indices(int32). - Add
AITER_CHECKdtype guards togrouped_topk/biased_grouped_topkfortopk_weights(fp32) andtopk_ids(int32).
File summaries
| File | Description |
|---|---|
| csrc/kernels/topk_softmax_kernels.cu | Adds fp32/int32 dtype checks before writing outputs via reinterpret_cast<int*>. |
| csrc/kernels/topk_softmax_kernels_group.cu | Adds fp32/int32 dtype checks for grouped routing variants that write topk_ids via reinterpret_cast<int*>. |
Review details
Suppressed comments (1)
csrc/kernels/topk_softmax_kernels_group.cu:1297
- Similarly for
grouped_topk, there’s no test coverage asserting that passing a non-int32topk_idsbuffer fails fast. Adding an explicit negative test would help ensure this ABI constraint doesn’t accidentally get relaxed or broken later (especially since the kernel writes throughreinterpret_cast<int*>).
const aiter_tensor_t& correction_bias = topk_ids;
AITER_CHECK(topk_weights.dtype() == AITER_DTYPE_fp32,
"topk_weights must be float32");
AITER_CHECK(topk_ids.dtype() == AITER_DTYPE_i32,
"topk_ids must be int32");
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
Related ASM, sigmoid, and fused-gate routes remain unguarded, and the rejection behavior lacks tests.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
csrc/kernels/topk_softmax_kernels.cu:804
- The existing
op_tests/test_topk_softmax.pycoverage only allocates the three outputs with their valid dtypes, so none of these new rejection contracts is exercised. Add negative tests usingpytest.raises(RuntimeError, match=...)for non-float32 weights and non-int32 values of each index buffer; otherwise these guards can regress while the current correctness matrix stays green.
AITER_CHECK(topk_weights.dtype() == AITER_DTYPE_fp32,
"topk_weights must be float32");
AITER_CHECK(topk_indices.dtype() == AITER_DTYPE_i32,
"topk_indices must be int32");
AITER_CHECK(token_expert_indices.dtype() == AITER_DTYPE_i32,
"token_expert_indices must be int32");
csrc/kernels/topk_softmax_kernels_group.cu:1243
- The grouped-top-k tests in
op_tests/test_moeTopkSoftmax.py:293-307,403-416only pass float32 weights and int32 IDs, so the newly added failure behavior for both grouped entry points is untested. Add explicit invalid-dtype tests, including the large-token publicbiased_grouped_topkfallback route, to verify every dispatch path rejects before launch.
AITER_CHECK(topk_weights.dtype() == AITER_DTYPE_fp32,
"topk_weights must be float32");
AITER_CHECK(topk_ids.dtype() == AITER_DTYPE_i32,
"topk_ids must be int32");
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Balanced
The first pass covered topk_softmax, grouped_topk and biased_grouped_topk, but
an int64 id buffer is still accepted by three other reachable entry points,
each of which writes 4 bytes per element and leaves the rest of every slot
holding whatever the caller allocated:
* topk_sigmoid - forwards topk_indices.data_ptr() unchecked
* topk_softmax_asm - hard-codes out_stride * 4
* moe_fused_gate - reinterpret_cast<int32_t*>, and biased_grouped_topk
re-dispatches to it purely on token count, so the public
call that is guarded on one path is unguarded on the other
Add a negative test per entry point. They run in subprocesses: AITER_CHECK in
these translation units resolves to check_fail, which prints to stderr and then
aborts unless g_aiter_can_throw is set, and none of these files opt into the
ctypes error translation that sets it. That is pre-existing behaviour shared
with the ~34 AITER_CHECKs already in these files, but it does mean
pytest.raises cannot observe them; asserting on the message the process died
with is correct either way.
There was a problem hiding this comment.
🟡 Changes recommended
The new test file lacks an entry point, so the standard CI invocation executes none of its tests.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Balanced
aiter_test.sh runs op_tests files as `python3 <file>`, so a module containing only pytest functions defines nine tests, runs none, and exits 0 -- reported as a pass. Which is precisely the failure mode this file exists to rule out.
There was a problem hiding this comment.
🔵 Needs a closer look
The new sigmoid fp32 restriction rejects fp16 and bf16 weight outputs that its existing CK trait dispatch explicitly supports.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
csrc/py_itfs_ck/topk_sigmoid_kernels.cu:25
- This rejects output dtypes that this CK wrapper explicitly supports: lines 43-66 map
topk_weights.dtype()across fp16/bf16/fp32 and pass it asweight_precso the kernel writes with the selected width. Consequently, existingtopk_sigmoidcallers using fp16 or bf16 weights now fail even though only the index buffer has the fixed 32-bit contract. Keep the int32 index check, but remove this fp32-only restriction and update the comment accordingly.
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…check Unlike the other three entry points, this wrapper does not hard-code the weight width: weight_prec is derived from topk_weights.dtype() and mapped across fp16/bf16/fp32, and the kernel writes at the selected width. Requiring fp32 would have broken existing callers. Only topk_indices has the fixed 32-bit contract here.
Kept the AITER_CHECK/TORCH_CHECK additions across the six entry points -- those are the actual fix. Dropped op_tests/test_topk_index_dtype_contract.py per @valarLip's review (op_tests/test_topk_index_dtype_contract.py, request: 'please remove this one').
There was a problem hiding this comment.
🟡 Changes recommended
The ASM path unnecessarily rejects non-int32 token_expert_indices despite never consuming that buffer.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
KernelArgs only forwards ptr_T (topk_indices), ptr_W (topk_weights) and ptr_A (gating_output); token_expert_indices is accepted as a parameter and never assigned into args, so this kernel neither reads nor writes it. The check therefore rejected callers on a buffer the partial-write fix has nothing to do with -- confirmed against op_tests/test_moeTopkSoftmax.py's test_asm, which discards that argument with 'Not used. Will be used in the future.'
There was a problem hiding this comment.
🔵 Needs a closer look
The current tree lacks regression tests exercising the newly added rejection paths.
Review details
Suppressed comments (1)
csrc/kernels/topk_softmax_kernels.cu:804
- The promised regression test is absent from the current tree:
op_tests/test_topk_index_dtype_contract.pyno longer exists, and the existing top-k tests only allocate int32 outputs. Consequently none of the new rejection paths—including the ASM, CK sigmoid, grouped, and fused-gate routes—is exercised, so these guards could regress while the positive tests remain green. Restore the subprocess-based negative cases for every changed entry point (including the fused-gate redispatch route).
AITER_CHECK(topk_indices.dtype() == AITER_DTYPE_i32,
"topk_indices must be int32");
AITER_CHECK(token_expert_indices.dtype() == AITER_DTYPE_i32,
"token_expert_indices must be int32");
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
@valarLip @zufayu @junhaha666 any chance you could also look at #5256 ? |
Problem
topk_softmax,grouped_topkandbiased_grouped_topkwrite their outputs throughreinterpret_cast<int*>(...)without checking the tensor's dtype:A caller that allocates a 64-bit index buffer gets only the low half of every slot written; the rest keeps whatever the allocation happened to contain. Nothing fails — the call returns normally with a plausible-looking tensor.
Measurement
gfx950 (MI350X), one direct
topk_softmaxcall, buffer pre-filled with-999999so an untouched slot is unmistakable. 256 tokens, topk 6, 64 experts:[0, 64)torch.int32torch.int64The surviving poison is the proof those slots were never written.
repro
Why it matters
Downstream this corrupts whatever consumes the ids, far from the kernel that produced them. In vLLM's ROCm expert-parallel path it reached DeepEP, which indexes with them, producing two unrelated-looking crash signatures from one cause: an out-of-bounds write (
Memory access fault ... Write access to a read-only page), and — once the obviously invalid ids were clamped — duplicate expert ids per token tripping DeepEP's own device assert and aborting withHSA_STATUS_ERROR_EXCEPTION. Tracking that back to a dtype mismatch in the routing kernel took a while.The change
Add the dtype checks that
topk_gatingalready performs for exactly these arguments:So this brings its siblings in line rather than introducing a new contract.
Compatibility
No behaviour change for correct callers — every allocation in this repository already uses int32:
aiter/fused_moe.py:4261,4279aiter/fused_moe_bf16_asm.py:608op_tests/test_topk_softmax.py:22-23,op_tests/test_moe_topk_gating.pyThe caller-side fix for the vLLM case is vllm-project/vllm#55147. This change is what turns the same mistake into an immediate, attributable error for the next caller instead of silent memory corruption.