Repository navigation
fix(cuda/moe): grouped GEMM selects cutlass::arch::Sm75 on an sm_70 part #1544
Description
Part of #1536. Phase 4. Independent of #1539-#1543; can land any time after #1538's baseline. Coordinate with #1543 if that also touches gather_gemm.
Verification-first: this may be a latent defect, a dead branch, or benign. The point of the issue is to find out, because "MoE on Volta silently takes an unsupported-arch path" is not a state anyone should have to guess about.
Context
src/lib/mlx-cpp/patches/mlx/backend/cuda/gemms/grouped_gemm_unaligned.cu:338-347:
template <typename F>
void dispatch_cutlass_arch(cu::Device& device, F&& f) {
if (device.compute_capability_major() < 8) {
f(type_identity<cutlass::arch::Sm75>{});
} else if (device.compute_capability_major() == 8) {
f(type_identity<cutlass::arch::Sm80>{});
} else {
f(type_identity<cutlass::arch::Sm90>{});
}
}and the default at :349: auto* fun = grouped_gemm_v2<GemmConfiguration<float, cutlass::arch::Sm75>>;
cutlass::arch::Sm75 selects Turing MMA (m16n8k8), which does not exist on sm_70. The < 8 branch is written as though pre-Ampere means Turing; Volta is a generation below that and has only the 8x8x4 HMMA shape. CUTLASS guards these behind CUTLASS_ARCH_MMA_SM75_SUPPORTED, so compiling an Sm75 configuration for an sm_70 target does not necessarily fail the build — it can compile to a path that traps or degenerates at runtime.
Observed during the #1536 audit: gemma-4-26b-a4b-it-4bit (MoE) generates correct output on the V100. So this is not an outright crash. Either the branch is unreachable for that checkpoint (mlxcel's fused-MoE path may bypass grouped_gemm_v2 entirely), or CUTLASS is falling back to something functional. Neither has been confirmed.
Scope
Determine what actually executes on sm_70, and make the arch tag correct for whatever that is.
Out of scope: MoE performance tuning beyond fixing the arch tag. If the path turns out to be dead on Volta, saying so precisely and adding a guard is the whole deliverable.
Implementation plan
- Determine reachability first. Instrument
dispatch_cutlass_arch(or nsys the MoE run with--cuda-graph-trace=node) to record whethergrouped_gemm_v2is entered at all on sm_70 forgemma-4-26b-a4b-it-4bit, and which kernel names appear. mlxcel has a fused decode-MoE path and a sorted-MoE prefill fast path (quantized.cpp, the[mlxcel #629]block); establish which of these the checkpoint takes before touching the dispatch. - If reachable, verify correctness rather than assuming it: compare grouped-GEMM output against a reference (dense per-expert matmul, or the same run on CPU) on the shapes the model actually uses. A silently-degenerate CUTLASS path can produce plausible-looking but wrong numbers, which greedy text output will not reveal.
- Fix the tag. Replace the
< 8arm withcutlass::arch::Sm70forcompute_capability_major() == 7, keepingSm75for== 7.5if Turing is ever a target (it is not today — do not add speculative arms). Also fix theSm75default initializer at:349, which is the value used whendispatch_float_typesfinds no match. - Check the sibling.
GemmConfigurationat:219-245carriesstatic const int kStages = 3; // use SM80_CP_ASYNC. Confirm the stage count is not applied to a pre-Ampere arch tag, wherecp.asyncdoes not exist; a 3-stage pipeline without async copy is either a compile error or a silent serialization. - If unreachable on Volta, add an explicit guard that fails loudly (or routes to a documented fallback) rather than leaving an arch tag that describes hardware the device does not have, and document the actual MoE path on Volta in the test(benchmark): Volta (sm_70) baseline record and build coverage #1538 baseline doc.
Acceptance criteria
- Written determination of whether
grouped_gemm_v2is reached on sm_70, with the evidence (instrumentation output or nsys kernel names) for at leastgemma-4-26b-a4b-it-4bit. Reached. 180cutlass::Kernel<GemmGrouped>launches at 3.8% of GPU time on a 573-token prompt, absent underMLXCEL_GATHER_QMM_GROUPED=0and absent at a 46-token prompt, which is below perf(cuda/moe): fix MoE prefill collapse on CUDA (gather_qmm / grouped GEMM path) #629'sB >= 8 * num_expertsgate and is why the test(benchmark): Volta (sm_70) baseline record and build coverage #1538 profile showed none. - If reached: numerical correctness of grouped-GEMM output verified against a reference on the model's real shapes. Byte-identical 64-token greedy continuation against the legacy
qmm_naivepath, plus new unit tests against anf64dense per-expert reference atk = 2816, n = 704and its transpose, over both entry points, both alignment arms, both operand layouts and f32/bf16/f16. - Arch tag matches the hardware for every branch, including the default initializer at
:349. The pre-Ampere arm iscutlass::arch::Sm70; the:349placeholder is replaced by a named function-pointer type initialised tonullptrwith a loud guard, so no architecture is named there at all. -
kStages/cp.asyncconsistency confirmed for the pre-Ampere configuration.kStages = 3belongs to an explicit full specialization oncutlass::arch::Sm80and is unreachable from any pre-Ampere tag; the pre-Ampere arm getskStages = 2and runsMmaPipelined. Pinned bystatic_assert. - MoE decode and prefill tok/s on Volta reported before and after against test(benchmark): Volta (sm_70) baseline record and build coverage #1538. 4-bit decode 29.66 to 29.73 ms/token, 8-bit 31.68 to 32.71, 4-bit prefill 12,720 to 12,644 ms; every delta inside the before arm's own repetition spread, which byte-identical device code requires. The test(benchmark): Volta (sm_70) baseline record and build coverage #1538 row is re-measured rather than quoted, because perf(cuda/quant): use float accumulators in qmv below Ampere at bits < 8 #1539 moved MoE decode after that record was written.
- Zero change on sm_80+: the
== 8and> 8branches untouched; GB10 MoE output byte-identical and throughput unmoved. Left unticked because its GB10 half needs an sm_80+ part and this host has none. The rest is closed here rather than deferred: the== 8and> 8arms are textually untouched,only_the_pre_ampere_arm_changedenumerates the tag mapping over every compute capability and shows nothing at or above 8 moves, and compiling the translation unit before and after atcompute_80andcompute_121yields the same 51 device symbols with byte-identical SASS bodies at both. - If the path is dead on Volta, that is stated in the test(benchmark): Volta (sm_70) baseline record and build coverage #1538 baseline doc along with what runs instead. The path is not dead, so the test(benchmark): Volta (sm_70) baseline record and build coverage #1538 doc is annotated with the opposite finding: what actually runs, the prompt-length gate that decides it, and why that document's own MoE profile showed no grouped GEMM.
-
cargo test --features cudagreen on sm_70 and sm_121. sm_121 needs a GB10 host. On sm_70 the newgrouped_gemm_arch_testsmodule is green (7 of 7) and themlxcel-corelibrary suite was run in full; see the PR test plan for what was executed.
Validation
MLX_CUDA_ARCHITECTURES=70 make release-cuda
nsys profile -t cuda,nvtx --cuda-graph-trace=node -o moe_volta \
./target/release/mlxcel generate -m ./models/mlx-community/gemma-4-26b-a4b-it-4bit -p "Hi." -n 32
nsys stats --report cuda_gpu_kern_sum --format csv moe_volta.nsys-rep
References
- Dispatch and default:
src/lib/mlx-cpp/patches/mlx/backend/cuda/gemms/grouped_gemm_unaligned.cu:338-355. - Stage count comment: same file,
:219-245. - Upstream's separate
sm80flag in the gather path, which perf(cuda/quant): qmm_sm70 — Volta tensor-core MMA path for quantized GEMM #1543 also touches:mlx/backend/cuda/gemms/gather_gemm.cu:54. - Prior MoE prefill work on CUDA: perf(cuda/moe): fix MoE prefill collapse on CUDA (gather_qmm / grouped GEMM path) #629.
Activity
perf(cuda/quant): size the qmm_naive tile from the shared-memory budg…
perf(cuda): explain the Volta fixed first-token cost, and instrument …
fix(cuda/moe): tag the pre-Ampere grouped GEMM arm Sm70, not Sm75 (la…