spark_model/layers/glm5next_mlp/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3-Flash **MLP production surface** — dense FFN and routed MoE.
4//!
5//! Scoped to `LibertAIDAI/GLM-5.3-Flash-NVFP4@9e0d74e3`.
6//!
7//! Same shape as [`crate::layers::glm5next_dsa`]: the CUDA kernels already exist and are
8//! numerically proven against HF 5.16.1 on real weights — `kernels/gb10/common/glm5next_ffn.cu`
9//! (clamped SwiGLU, sigmoid router top-k, routed/shared combine), gated by
10//! `examples/glm5next_{ffn,moe}_microtest.rs`. What was missing, and what this module adds, is
11//! the production surface: config, kernel resolution, a weight contract and a forward a real
12//! layer can call, rather than an example wiring pointers by hand.
13//!
14//! # The stack this layer runs
15//!
16//! ```text
17//! DENSE (layers 0..first_k_dense_replace)   x -> gate_proj/up_proj -> clamped SwiGLU -> down_proj
18//!
19//! ROUTED (every later layer)
20//!   x ─┬─ gate.weight ──── logits(f32) ─ router_topk ─ ids[K], weights[K]
21//!      ├─ experts[id]  ─── NVFP4 gate/up -> clamped SwiGLU -> NVFP4 down  (LOCAL ids only)
22//!      └─ shared_experts ─ BF16 gate/up  -> clamped SwiGLU -> BF16 down
23//!                                       └─> moe_combine -> partial -> all_reduce
24//! ```
25//!
26//! # 🔴 Why one all-reduce covers BOTH EP and TP
27//!
28//! The campaign's topology is `world = 2, TP = 2, EP = 2` on the *same* two ranks. The routed
29//! experts are EP-sharded (144/rank, remote ids contribute zero) and the dense/shared FFN is
30//! TP-sharded (column-parallel gate/up, row-parallel down). Both leave a **partial sum** of the
31//! same `[T, hidden]` output, and `all_reduce(SUM)` is linear, so the two partials are summed by
32//! one collective at the end of the site. Reducing them separately would be two collectives for
33//! the same answer.
34//!
35//! 🪤 That is only true because the combine happens BEFORE the reduce. Adding the shared expert
36//! after an all-reduce of the routed partial — which is what `layers::moe::forward` does, for a
37//! model whose shared expert is replicated rather than TP-sharded — would add a TP-partial
38//! shared output exactly once and lose the other rank's half.
39//!
40//! # 🪤 Traps this module exists to hold
41//!
42//! * **The SwiGLU clamp is ASYMMETRIC**: `gate` is upper-bounded only, `up` is bounded both
43//!   ways. It is also invisible on well-scaled activations — it fires on the tails. The limit
44//!   comes from `ModelConfig::swiglu_limit`, which the `glm5_next` parser refuses to default.
45//! * **The router's correction bias steers SELECTION ONLY.** The emitted weight is the
46//!   *unbiased* sigmoid score of the chosen expert. Gathering the biased score still produces a
47//!   plausible mixture.
48//! * **`routed_scaling_factor` rides on the top-k weights and the shared expert is NOT scaled by
49//!   it** (`apply_routed_scale_to_output = false`). Scaling the shared output is the same defect
50//!   with the opposite sign.
51//! * **The router is REPLICATED and must stay bit-identical across ranks.** Masked-local EP is
52//!   only equivalent to dispatch when every rank agrees on the same `ids`. Sharding the gate
53//!   would give each rank partial logits and a different top-k — no crash, different experts.
54//! * **`num_experts` here is the FULL count (288).** The rank's local range is a separate field.
55//!   Passing the local count to `glm5next_router_topk` would rank 144 experts and renormalise
56//!   over the wrong denominator.
57
58use anyhow::{Result, bail};
59use avarok_core::config::{Glm5NextRouterMode, ModelConfig};
60use spark_runtime::gpu::{GpuBackend, KernelHandle};
61
62pub mod build;
63pub mod forward;
64pub mod forward_prefill_gemm;
65pub mod weights;
66
67pub use weights::{Glm5NextDenseMlpWeights, Glm5NextExpertWeights, Glm5NextMoeWeights};
68
69/// Module name the GLM FFN kernels resolve from. `kernels/gb10/common/glm5next_ffn.cu` is not
70/// listed in `common/KERNEL.toml`'s `[modules]`, so it takes its **file stem**.
71///
72/// 🪤 The other three modules below are the opposite case and are listed: `dense_gemm_bf16` maps
73/// to `"gemm"` and `w4a16_gemm` maps to `"w4a16"`. Guessing the stem for those resolves to
74/// nothing — the bug caught at `0846fad3`. Grep `[modules]` before writing any `resolve()`.
75pub const FFN_MODULE: &str = "glm5next_ffn";
76/// `[modules]`: `dense_gemm_bf16 = "gemm"`.
77pub const GEMM_MODULE: &str = "gemm";
78/// `[modules]`: `w4a16_gemm = "w4a16"`.
79pub const W4A16_MODULE: &str = "w4a16";
80/// 🔴 The DECODE weight kernel. `w4a16_gemm` is a tile GEMM: at M=1 it measured
81/// **9.7 GB/s** on the routed experts — 26x off the 254 GB/s roofline and 45 % of the
82/// whole decode step (2026-08-28 profile). Every routed-expert projection here is M=1.
83pub const W4A16_GEMV_MODULE: &str = "w4a16_gemv";
84/// `[modules]`: `moe_permute = "moe"` — the token sort / permute / unpermute family.
85pub const MOE_MODULE: &str = "moe";
86/// `[modules]`: `moe_w4a16_grouped_gemm = "moe_w4a16"` — the tensor-core grouped W4A16 GEMM.
87pub const MOE_GROUPED_MODULE: &str = "moe_w4a16";
88
89/// `float best_w[16]` in `glm5next_router_topk` — the most experts it can select per token.
90pub const KERNEL_MAX_TOP_K: usize = 16;
91
92/// Every kernel a GLM MLP site launches.
93///
94/// Resolved with `kernel()` (not `try_kernel`): a missing entry point is a hard error, never a
95/// silent fallback onto `moe_silu_mul`, whose SwiGLU does **not** clamp.
96#[derive(Clone, Copy)]
97pub struct Glm5NextMlpKernels {
98    /// `C = A @ B^T`, BF16 out — dense FFN, shared expert.
99    pub gemm: KernelHandle,
100    /// Same, FP32 out. 🪤 The router GEMM MUST take this one: `glm5next_router_topk` reads
101    /// `const float* logits`, while `gate.weight` is BF16 on disk.
102    pub gemm_f32: KernelHandle,
103    /// M=1 twins of `gemm` / `gemm_f32`. `gemv_f32` may be a 0 handle on a target that
104    /// predates `dense_gemv_bf16_fp32out`; `gemm()` falls back to the tile arm then.
105    pub gemv: KernelHandle,
106    pub gemv_f32: KernelHandle,
107    /// 🔴 `dense_gemv_bf16_batchm` — `2 ..= 8` rows in ONE weight sweep. The shared-expert and
108    /// dense-FFN projections are pure weight streaming, so this is what stops a K-token verify
109    /// from re-reading them K times. `0` = unavailable, falls back to the tile GEMM.
110    pub gemv_batchm: KernelHandle,
111    /// NVFP4 `C = A @ B^T`, tile GEMM. Kept for any M > 1 caller; the decode path
112    /// must not use it — see [`W4A16_GEMV_MODULE`].
113    pub w4a16: KernelHandle,
114    /// NVFP4 `C[1, N] = A[1, K] @ B[N, K]^T` — the M=1 decode kernel.
115    ///
116    /// 🪤 Its grid is COUPLED to the kernel's `N_PER_BLOCK`; use
117    /// `ops::gemv_sw::w4a16_gemv_grid_x`, never a hand-written `div_ceil`.
118    pub w4a16_gemv: KernelHandle,
119    /// Single-warp-per-output sibling of `w4a16_gemv`, **bit-identical** to it
120    /// (`examples/w4a16_gemv_sw_microtest.rs`): same `w4a16_gemv_partial` per orig-lane,
121    /// 8 outputs per 256-thread block instead of 4, and no cross-warp `__syncthreads()` +
122    /// shared-memory round trip. `try_kernel` — a target without it falls back to the base
123    /// kernel rather than failing to load.
124    ///
125    /// 🪤 Its grid is `ceil(N/8)`, NOT `ceil(N/4)`. Dispatch through
126    /// `ops::w4a16_decode_gemv`, which couples the two; swapping the kernel without
127    /// swapping the grid writes half the outputs.
128    pub w4a16_gemv_sw: KernelHandle,
129    /// Grouped MoE sibling of `w4a16_gemv_sw`: all `top_k` slots in ONE launch, expert
130    /// weights reached through a device pointer table indexed by the router's on-device ids.
131    /// **Bit-identical** per slot — same `w4a16_gemv_partial`, same shuffle tree.
132    ///
133    /// 🪤 Grid is `(ceil(N/8), top_k, 1)`. `try_kernel` — a target without it falls back to
134    /// the host-dispatch loop.
135    pub w4a16_gemv_sw_moe: KernelHandle,
136    /// Row-batched sibling of `w4a16_gemv_sw_moe`, indexed `[rows - 2]` for rows **2..=8**: the
137    /// UNION of the selected experts over a call's rows, each swept ONCE. An expert two
138    /// rows both picked costs one weight read here and two in the per-row path — 14% of the
139    /// routed traffic at K=2, 22% at K=3 (measured union 8.00/13.74/18.76/23.35 at K=1..4).
140    ///
141    /// 🔴 Widened from 2..=4 to 2..=8 on 2026-08-31. The old stop at 4 was **the compiled tier
142    /// family, not a limit of the union**: [`Self::moe_row_union`] resolves `rows * top_k` ids
143    /// in ONE 64-thread block, and GLM-5.3 is `8 * 8 == 64` exactly. It matters because the
144    /// batched prefill sub-chunk is 8 rows wide (ANOMALIES A65) and the routed experts were
145    /// the only stage of it still paying per row.
146    ///
147    /// 🪤 grid.y is the UNION entry, not the slot, and its extent is `rows * top_k`; the
148    /// entries the routing did not fill retire on `u_eid < 0`. Needs [`Self::moe_row_union`].
149    ///
150    /// 🪤 Register cost rises with the tier — measured `ptxas -v`, sm_121a, no spills at any
151    /// width: 43/40/48/70/72/80/80 registers at R = 2..8. R = 8 at 80 regs / 256 threads is
152    /// 3 CTAs/SM against R = 4's 5, so a WIDER tier is not free; it wins only when the union
153    /// actually shrinks the expert sweeps.
154    pub w4a16_gemv_sw_moe_batchm: [KernelHandle; 7],
155    /// Builds the union table the batched kernel indexes. One block, `rows * top_k` threads.
156    ///
157    /// 🪤 `rows * top_k` MUST be <= 64: it is a single block and threads past it never run, so
158    /// an over-wide call silently drops union entries. The caller gates on it.
159    pub moe_row_union: KernelHandle,
160    /// 🪤 **Clamped** SwiGLU, asymmetric. Not `moe_silu_mul`.
161    pub swiglu: KernelHandle,
162    pub router: KernelHandle,
163    pub combine: KernelHandle,
164    /// Counting sort of the router's `[rows, top_k]` ids into expert-contiguous order —
165    /// `sorted_token_ids` + `expert_offsets` + the `token_to_perm` reverse map. The shared
166    /// `moe_permute.cu` kernel every other MoE model's prefill already uses.
167    ///
168    /// 🪤 `try_kernel`: a target without it leaves the grouped prefill path unreachable and
169    /// the row-batched GEMV arm stands.
170    pub moe_sort_by_expert: KernelHandle,
171    /// 🔴 The TENSOR-CORE grouped W4A16 GEMM (`mma.sync.aligned.m16n8k16`), one launch for
172    /// ALL experts. GLM's routed experts were the only MoE in the tree still doing prefill on
173    /// the software-dequant GEMV; its NVFP4 convention is byte-for-byte what this kernel
174    /// expects (N-major `[N, K/2]` packed, N-major `[N, K/16]` E4M3 scales, per-expert
175    /// `scale2`, `GROUP_SIZE = 16`, even k = low nibble) — see `forward_prefill_gemm`.
176    pub moe_grouped_gemm: KernelHandle,
177    /// [`Self::combine`] reading routed rows in EXPERT-SORTED order through `token_to_perm`.
178    /// Same accumulation, same single rounding — only the slot's address changes.
179    pub combine_indexed: KernelHandle,
180}
181
182impl Glm5NextMlpKernels {
183    pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
184        Ok(Self {
185            gemm: gpu.kernel(GEMM_MODULE, "dense_gemm_bf16")?,
186            gemm_f32: gpu.kernel(GEMM_MODULE, "dense_gemm_bf16_f32out")?,
187            gemv: gpu.kernel("gemv", "dense_gemv_bf16")?,
188            gemv_batchm: crate::layers::try_kernel(
189                gpu,
190                "dense_gemv_bf16_batchm",
191                "dense_gemv_bf16_batchm",
192            ),
193            gemv_f32: crate::layers::try_kernel(gpu, "gemv", "dense_gemv_bf16_fp32out"),
194            w4a16: gpu.kernel(W4A16_MODULE, "w4a16_gemm")?,
195            w4a16_gemv: gpu.kernel(W4A16_GEMV_MODULE, "w4a16_gemv")?,
196            w4a16_gemv_sw: crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw"),
197            w4a16_gemv_sw_moe: crate::layers::try_kernel(
198                gpu,
199                W4A16_GEMV_MODULE,
200                "w4a16_gemv_sw_moe",
201            ),
202            w4a16_gemv_sw_moe_batchm: [
203                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m2"),
204                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m3"),
205                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m4"),
206                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m5"),
207                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m6"),
208                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m7"),
209                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m8"),
210            ],
211            moe_row_union: crate::layers::try_kernel(
212                gpu,
213                W4A16_GEMV_MODULE,
214                "glm5next_moe_row_union",
215            ),
216            swiglu: gpu.kernel(FFN_MODULE, "glm5next_swiglu_clamp")?,
217            router: gpu.kernel(FFN_MODULE, "glm5next_router_topk")?,
218            combine: gpu.kernel(FFN_MODULE, "glm5next_moe_combine")?,
219            // 🪤 `[modules]` in `common/KERNEL.toml`: `moe_permute = "moe"` and
220            // `moe_w4a16_grouped_gemm = "moe_w4a16"`. Neither takes its file stem.
221            moe_sort_by_expert: crate::layers::try_kernel(gpu, MOE_MODULE, "moe_sort_by_expert"),
222            // 🪤 The tile geometry is chosen by env BEFORE the handle is resolved, because
223            // the tile IS a different kernel entry point (and a different grid). A target
224            // whose PTX predates the tile variants resolves 0 here; rather than leave the
225            // grouped path unreachable, fall back to the base entry point and say so — the
226            // base is what every other model still launches.
227            moe_grouped_gemm: {
228                let tile = forward_prefill_gemm::gemm_tile();
229                let h = crate::layers::try_kernel(gpu, MOE_GROUPED_MODULE, tile.name);
230                if h.0 == 0 && tile.name != forward_prefill_gemm::GEMM_TILES[0].name {
231                    tracing::warn!(
232                        "GLM routed-MoE grouped GEMM tile `{}` is not in this target's PTX — \
233                         falling back to `{}`",
234                        tile.name,
235                        forward_prefill_gemm::GEMM_TILES[0].name
236                    );
237                    crate::layers::try_kernel(
238                        gpu,
239                        MOE_GROUPED_MODULE,
240                        forward_prefill_gemm::GEMM_TILES[0].name,
241                    )
242                } else {
243                    h
244                }
245            },
246            combine_indexed: crate::layers::try_kernel(
247                gpu,
248                FFN_MODULE,
249                "glm5next_moe_combine_indexed",
250            ),
251        })
252    }
253}
254
255/// Which MLP a layer runs. Mirrors [`crate::layers::glm5next_skeleton::Mlp`]; kept separate so
256/// the runtime does not depend on the skeleton's design-artifact types.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum Glm5NextMlpKind {
259    Dense,
260    RoutedMoe,
261}
262
263/// GLM MLP geometry for one rank, read from the checkpoint config — never defaulted.
264#[derive(Debug, Clone, Copy, PartialEq)]
265pub struct Glm5NextMlpConfig {
266    pub hidden: usize,
267    /// `intermediate_size` — the dense layers' FFN width, **this rank's share**.
268    pub local_dense_intermediate: usize,
269    /// `moe_intermediate_size` — one routed expert's width. **Never TP-sharded**: an expert is
270    /// owned whole by one EP rank.
271    pub moe_intermediate: usize,
272    /// `n_shared_experts * moe_intermediate_size`, **this rank's share**.
273    pub local_shared_intermediate: usize,
274    /// FULL routed-expert count (288). Not the local count — see the module header.
275    pub num_experts: usize,
276    /// Experts this rank owns, as a contiguous range `[ep_rank * local, +local)`.
277    pub local_experts: usize,
278    pub ep_rank: usize,
279    pub top_k: usize,
280    /// `routed_scaling_factor`. 🪤 Applied to the top-k WEIGHTS, never to the shared expert.
281    pub routed_scale: f32,
282    /// `norm_topk_prob` — renormalise the top-k weights (with a `1e-20` epsilon, not `1e-6`).
283    pub renormalize: bool,
284    /// 🪤 Asymmetric. See the module header.
285    pub swiglu_limit: f32,
286    /// True reproduces vLLM's bf16 router ladder; false is HF 5.16.1's fp32 semantics and the
287    /// production default. **Semantic, not precision** — the two select different experts.
288    pub router_bf16_ladder: bool,
289    /// TP ranks the dense/shared FFN is split over. `> 1` ⇒ the site output is a partial sum.
290    pub tp_world_size: usize,
291    /// EP ranks the routed experts are split over. `> 1` ⇒ the routed sum is a partial sum.
292    pub ep_world_size: usize,
293}
294
295impl Glm5NextMlpConfig {
296    /// `config` carries GLOBAL MoE counts; the TP division of the dense/shared widths and the EP
297    /// division of the expert set are applied here, in one place.
298    pub fn from_config(config: &ModelConfig) -> Result<Self> {
299        let tp = config.tp_world_size.max(1);
300        let ep = config.ep_world_size.max(1);
301        if !config.intermediate_size.is_multiple_of(tp) {
302            bail!(
303                "GLM MLP: intermediate_size {} does not divide over tp_world_size {tp}",
304                config.intermediate_size
305            );
306        }
307        if !config.shared_expert_intermediate_size.is_multiple_of(tp) {
308            bail!(
309                "GLM MLP: shared_expert_intermediate_size {} does not divide over \
310                 tp_world_size {tp}",
311                config.shared_expert_intermediate_size
312            );
313        }
314        if !config.num_experts.is_multiple_of(ep) {
315            bail!(
316                "GLM MLP: num_experts {} does not divide over ep_world_size {ep}; a ragged \
317                 expert split would leave some ids owned by nobody",
318                config.num_experts
319            );
320        }
321        let c = Self {
322            hidden: config.hidden_size,
323            local_dense_intermediate: config.intermediate_size / tp,
324            moe_intermediate: config.moe_intermediate_size,
325            local_shared_intermediate: config.shared_expert_intermediate_size / tp,
326            num_experts: config.num_experts,
327            local_experts: config.num_experts / ep,
328            ep_rank: config.ep_rank,
329            top_k: config.num_experts_per_tok,
330            routed_scale: config.routed_scaling_factor as f32,
331            renormalize: config.norm_topk_prob,
332            swiglu_limit: config.swiglu_limit,
333            router_bf16_ladder: matches!(config.glm5next_router_mode, Glm5NextRouterMode::VllmBf16),
334            tp_world_size: tp,
335            ep_world_size: ep,
336        };
337        c.validate()?;
338        Ok(c)
339    }
340
341    /// The half-open global expert-id range this rank owns.
342    pub fn local_expert_range(&self) -> std::ops::Range<usize> {
343        let start = self.ep_rank * self.local_experts;
344        start..start + self.local_experts
345    }
346
347    /// Global expert id → local slot, or `None` when another rank owns it.
348    ///
349    /// 🪤 The whole EP scheme rests on this: a remote id must contribute **zero**, not be
350    /// clamped into a local slot. Indexing a local array with a global id is the silent version
351    /// of that mistake and yields a real expert's weights for the wrong token.
352    pub fn local_slot(&self, global_id: usize) -> Option<usize> {
353        let r = self.local_expert_range();
354        r.contains(&global_id).then(|| global_id - r.start)
355    }
356
357    /// Whether the site output leaves this rank as a partial sum needing `all_reduce(SUM)`.
358    pub fn needs_all_reduce(&self) -> bool {
359        self.tp_world_size > 1 || self.ep_world_size > 1
360    }
361
362    pub fn validate(&self) -> Result<()> {
363        if self.hidden == 0 {
364            bail!("GLM MLP: hidden_size is 0");
365        }
366        if self.swiglu_limit <= 0.0 {
367            bail!(
368                "GLM MLP: swiglu_limit is {}. GLM-5.3 clamps its SwiGLU and the clamp is \
369                 asymmetric; a zero limit is not 'no clamp', it is a gate forced to <= 0. \
370                 The glm5_next parser reads the real value (10.0) and refuses to default it.",
371                self.swiglu_limit
372            );
373        }
374        if self.top_k == 0 || self.top_k > KERNEL_MAX_TOP_K {
375            bail!(
376                "GLM MLP: num_experts_per_tok {} is outside the {}-slot bound \
377                 glm5next_router_topk keeps in registers (`float best_w[16]`)",
378                self.top_k,
379                KERNEL_MAX_TOP_K
380            );
381        }
382        if self.top_k > self.num_experts {
383            bail!(
384                "GLM MLP: top_k {} exceeds num_experts {}",
385                self.top_k,
386                self.num_experts
387            );
388        }
389        if self.moe_intermediate == 0 {
390            bail!("GLM MLP: moe_intermediate_size is 0 — a routed layer would compute nothing");
391        }
392        if self.ep_rank >= self.ep_world_size {
393            bail!(
394                "GLM MLP: ep_rank {} is outside ep_world_size {}",
395                self.ep_rank,
396                self.ep_world_size
397            );
398        }
399        Ok(())
400    }
401}
402
403#[cfg(test)]
404mod tests;