spark_model/layers/glm5next_mlp/
forward_prefill_gemm.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM routed-MoE **prefill** through the shared tensor-core grouped W4A16 GEMM.
4//!
5//! # What this replaces, and why it is not a new kernel
6//!
7//! `forward_moe`'s routed arm runs `w4a16_gemv_sw_moe_batchm_mR` β€” a software-dequant
8//! GEMV with **no `mma.sync`** β€” and `MOE_ROW_BATCH_MAX_ROWS` caps it at 8 rows per
9//! launch *regardless of `AVAROK_GLM_PREFILL_ROWS`*. A 256-row prefill sub-chunk is
10//! therefore 32 separate 8-row sweeps, and each sweep re-reads the weights of every
11//! expert its 8 rows selected. Measured share of prefill: **36.9 % (2026-09-02) to
12//! 49.3 % (2026-09-06) of the GPU-busy window** β€” the single largest bucket in both
13//! profiles, and at 94-100 % of its own DRAM roofline, so the win cannot come from
14//! more FLOP/s. It has to come from reading each expert's weights FEWER TIMES.
15//!
16//! `kernels/gb10/common/moe_w4a16_grouped_gemm.cu::moe_w4a16_grouped_gemm_ptrtable`
17//! does exactly that: **one launch for all experts**, `mma.sync.aligned.m16n8k16`,
18//! `expert_offsets` prefix sum, in-kernel A-gather through `sorted_token_ids`. Thirteen
19//! other MoE models already run their prefill on it through
20//! `layers::moe::forward_prefill_routed`. GLM never did, because `glm5next_mlp` is a
21//! separate bespoke implementation.
22//!
23//! # πŸ”΄ Layout compatibility β€” VERIFIED, not assumed
24//!
25//! The two kernels read the SAME bytes with the same convention. Checked term by term
26//! against `w4a16_gemv.cu::w4a16_gemv_partial_rows` (the GEMV this replaces) and
27//! `moe_w4a16_grouped_gemm.cu::moe_w4a16_grouped_gemm_ptrtable`:
28//!
29//! | term | GEMV (`w4a16_gemv.cu`) | grouped GEMM | same? |
30//! |---|---|---|---|
31//! | packed byte for logical `k` | `B_packed[n * (K/2) + k/2]` (`kk*8 + b`, `k = kk*16 + 2b(+1)`) | `B_expert[gn * (K/2) + gk/2]` | βœ… N-major `[N, K/2]` |
32//! | nibble | even `k` β†’ `byte & 0xF`, odd β†’ `byte >> 4` | `(gk & 1) ? (byte >> 4) : (byte & 0xF)` | βœ… |
33//! | codebook | `E2M1_LUT` | `E2M1_LUT_MOE` (same 16 values) | βœ… |
34//! | block scale | `B_scale[n * (K/16) + k/16]`, E4M3 | `S_expert[gn * (K/GROUP_SIZE) + k_base/GROUP_SIZE]`, E4M3, `GROUP_SIZE 16` | βœ… |
35//! | per-tensor scale | `scale2_vals[eid]`, multiplied into the block scale | `scale2_vals[expert_id]`, multiplied into the dequant | βœ… |
36//! | remote expert (EP) | `packed_ptrs[eid] == 0` β†’ return, caller's zeros stand | `B_expert == 0` β†’ return, caller's zeros stand | βœ… |
37//! | table index | GLOBAL expert id, `[num_experts]` | GLOBAL expert id, `[num_experts]` | βœ… |
38//!
39//! `weight_loader/glm5_next_load/nvfp4_dequant.rs` states the same three conventions
40//! (ModelOpt direct-multiplier order, `GROUP_SIZE = 16` not DeepSeek-V4's 32, even flat
41//! index = LOW nibble). **No permutation, no repack, no extra bytes at load** β€” the
42//! already-uploaded `Glm5NextMoePtrTables` are passed straight through.
43//!
44//! # πŸ”΄ What is NOT bit-identical β€” and it is NOT only re-association
45//!
46//! Two things move, and the second one is the bigger one:
47//!
48//! 1. **Association.** The GEMM accumulates a `k`-major `mma.sync` FP32 chain over
49//!    `K_STEP = 16` tiles; the GEMV accumulates two interleaved FP32 `fmaf` chains per
50//!    orig-lane and combines them through a warp shuffle tree.
51//! 2. **Operand precision.** `mma.sync.aligned.m16n8k16.row.col.f32.bf16.bf16.f32` takes
52//!    BF16 operands, so the grouped kernel stages its dequantised weight tile as
53//!    `__float2bfloat16(E2M1_LUT[nibble] * e4m3 * scale2)` in shared memory. The GEMV keeps
54//!    that product in **FP32** all the way into its `fmaf`. The grouped path therefore
55//!    carries an extra ~2^-9 relative rounding PER WEIGHT ELEMENT that the GEMV does not.
56//!
57//! MEASURED 2026-09-22, `examples/glm5next_moe_grouped_prefill_microtest.rs` on n1 (GB10),
58//! M=64, top_k=8, 16 experts, N=256, K=512, random NVFP4, against an FP32 host reference:
59//!
60//! | arm | max_abs | max_abs / β€–refβ€–βˆž | max_rel |
61//! |---|---:|---:|---:|
62//! | grouped GEMM vs exact FP32 ref | 1.462067 | 0.003306 | 0.128986 |
63//! | GEMV (production) vs the same ref | 0.999542 | 0.002260 | 0.003889 |
64//! | **grouped GEMM vs BF16-WEIGHT ref** | **0.998566** | **0.002258** | **0.003888** |
65//! | grouped GEMM vs GEMV | 2.000000 | 0.004522 | 0.130725 |
66//!
67//! The third row is the finding: against a reference that rounds the dequantised weight to
68//! BF16 first, the grouped GEMM scores `0.003888` β€” the GEMV's own `0.003889` to five
69//! figures. **All of the residual gap is operand precision; none of it is layout.** That is
70//! what makes the layout claim above evidence rather than assertion.
71//!
72//! It is still a numerics change, so it is gated OFF for decode and for the speculative
73//! verify (`rows <= MOE_ROW_BATCH_MAX_ROWS`), which must stay bit-identical, and ON only
74//! for prefill β€” which already left the bit-identical tier when the dense projections moved
75//! to cuBLASLt above the same width.
76//!
77//! # Buffer discipline
78//!
79//! πŸͺ€ **A59**: every buffer this path needs is allocated in `Glm5NextMlpWorkspace::new`,
80//! at load, sized from `max_rows`. Nothing here allocates. The only per-call host traffic
81//! is one `[num_experts + 1]` i32 read of `expert_offsets` (see `max_m_tiles_from_offsets`
82//! in `tile.rs`).
83//!
84//! Split into `tile.rs` (tile geometry + env-lever resolution) and `dispatch.rs` (the
85//! grouped-GEMM launch + the prefill driver) to keep each file under the 500-LoC cap; both
86//! are re-exported here so callers keep using `forward_prefill_gemm::<name>` unchanged.
87
88mod dispatch;
89mod tile;
90
91pub(crate) use tile::*;
92
93pub(super) use dispatch::forward_moe_grouped_prefill;
94
95#[cfg(test)]
96mod tests;