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 atlas_core::config::{Glm5NextRouterMode, ModelConfig};
60use spark_runtime::gpu::{GpuBackend, KernelHandle};
61
62pub mod build;
63pub mod forward;
64pub mod weights;
65
66pub use weights::{Glm5NextDenseMlpWeights, Glm5NextExpertWeights, Glm5NextMoeWeights};
67
68/// Module name the GLM FFN kernels resolve from. `kernels/gb10/common/glm5next_ffn.cu` is not
69/// listed in `common/KERNEL.toml`'s `[modules]`, so it takes its **file stem**.
70///
71/// 🪤 The other three modules below are the opposite case and are listed: `dense_gemm_bf16` maps
72/// to `"gemm"` and `w4a16_gemm` maps to `"w4a16"`. Guessing the stem for those resolves to
73/// nothing — the bug caught at `0846fad3`. Grep `[modules]` before writing any `resolve()`.
74pub const FFN_MODULE: &str = "glm5next_ffn";
75/// `[modules]`: `dense_gemm_bf16 = "gemm"`.
76pub const GEMM_MODULE: &str = "gemm";
77/// `[modules]`: `w4a16_gemm = "w4a16"`.
78pub const W4A16_MODULE: &str = "w4a16";
79/// 🔴 The DECODE weight kernel. `w4a16_gemm` is a tile GEMM: at M=1 it measured
80/// **9.7 GB/s** on the routed experts — 26x off the 254 GB/s roofline and 45 % of the
81/// whole decode step (2026-08-28 profile). Every routed-expert projection here is M=1.
82pub const W4A16_GEMV_MODULE: &str = "w4a16_gemv";
83
84/// `float best_w[16]` in `glm5next_router_topk` — the most experts it can select per token.
85pub const KERNEL_MAX_TOP_K: usize = 16;
86
87/// Every kernel a GLM MLP site launches.
88///
89/// Resolved with `kernel()` (not `try_kernel`): a missing entry point is a hard error, never a
90/// silent fallback onto `moe_silu_mul`, whose SwiGLU does **not** clamp.
91#[derive(Clone, Copy)]
92pub struct Glm5NextMlpKernels {
93    /// `C = A @ B^T`, BF16 out — dense FFN, shared expert.
94    pub gemm: KernelHandle,
95    /// Same, FP32 out. 🪤 The router GEMM MUST take this one: `glm5next_router_topk` reads
96    /// `const float* logits`, while `gate.weight` is BF16 on disk.
97    pub gemm_f32: KernelHandle,
98    /// M=1 twins of `gemm` / `gemm_f32`. `gemv_f32` may be a 0 handle on a target that
99    /// predates `dense_gemv_bf16_fp32out`; `gemm()` falls back to the tile arm then.
100    pub gemv: KernelHandle,
101    pub gemv_f32: KernelHandle,
102    /// 🔴 `dense_gemv_bf16_batchm` — `2 ..= 8` rows in ONE weight sweep. The shared-expert and
103    /// dense-FFN projections are pure weight streaming, so this is what stops a K-token verify
104    /// from re-reading them K times. `0` = unavailable, falls back to the tile GEMM.
105    pub gemv_batchm: KernelHandle,
106    /// NVFP4 `C = A @ B^T`, tile GEMM. Kept for any M > 1 caller; the decode path
107    /// must not use it — see [`W4A16_GEMV_MODULE`].
108    pub w4a16: KernelHandle,
109    /// NVFP4 `C[1, N] = A[1, K] @ B[N, K]^T` — the M=1 decode kernel.
110    ///
111    /// 🪤 Its grid is COUPLED to the kernel's `N_PER_BLOCK`; use
112    /// `ops::gemv_sw::w4a16_gemv_grid_x`, never a hand-written `div_ceil`.
113    pub w4a16_gemv: KernelHandle,
114    /// Single-warp-per-output sibling of `w4a16_gemv`, **bit-identical** to it
115    /// (`examples/w4a16_gemv_sw_microtest.rs`): same `w4a16_gemv_partial` per orig-lane,
116    /// 8 outputs per 256-thread block instead of 4, and no cross-warp `__syncthreads()` +
117    /// shared-memory round trip. `try_kernel` — a target without it falls back to the base
118    /// kernel rather than failing to load.
119    ///
120    /// 🪤 Its grid is `ceil(N/8)`, NOT `ceil(N/4)`. Dispatch through
121    /// `ops::w4a16_decode_gemv`, which couples the two; swapping the kernel without
122    /// swapping the grid writes half the outputs.
123    pub w4a16_gemv_sw: KernelHandle,
124    /// Grouped MoE sibling of `w4a16_gemv_sw`: all `top_k` slots in ONE launch, expert
125    /// weights reached through a device pointer table indexed by the router's on-device ids.
126    /// **Bit-identical** per slot — same `w4a16_gemv_partial`, same shuffle tree.
127    ///
128    /// 🪤 Grid is `(ceil(N/8), top_k, 1)`. `try_kernel` — a target without it falls back to
129    /// the host-dispatch loop.
130    pub w4a16_gemv_sw_moe: KernelHandle,
131    /// Row-batched sibling of `w4a16_gemv_sw_moe`, indexed `[rows - 2]` for rows **2..=8**: the
132    /// UNION of the selected experts over a call's rows, each swept ONCE. An expert two
133    /// rows both picked costs one weight read here and two in the per-row path — 14% of the
134    /// routed traffic at K=2, 22% at K=3 (measured union 8.00/13.74/18.76/23.35 at K=1..4).
135    ///
136    /// 🔴 Widened from 2..=4 to 2..=8 on 2026-08-31. The old stop at 4 was **the compiled tier
137    /// family, not a limit of the union**: [`Self::moe_row_union`] resolves `rows * top_k` ids
138    /// in ONE 64-thread block, and GLM-5.3 is `8 * 8 == 64` exactly. It matters because the
139    /// batched prefill sub-chunk is 8 rows wide (ANOMALIES A65) and the routed experts were
140    /// the only stage of it still paying per row.
141    ///
142    /// 🪤 grid.y is the UNION entry, not the slot, and its extent is `rows * top_k`; the
143    /// entries the routing did not fill retire on `u_eid < 0`. Needs [`Self::moe_row_union`].
144    ///
145    /// 🪤 Register cost rises with the tier — measured `ptxas -v`, sm_121a, no spills at any
146    /// width: 43/40/48/70/72/80/80 registers at R = 2..8. R = 8 at 80 regs / 256 threads is
147    /// 3 CTAs/SM against R = 4's 5, so a WIDER tier is not free; it wins only when the union
148    /// actually shrinks the expert sweeps.
149    pub w4a16_gemv_sw_moe_batchm: [KernelHandle; 7],
150    /// Builds the union table the batched kernel indexes. One block, `rows * top_k` threads.
151    ///
152    /// 🪤 `rows * top_k` MUST be <= 64: it is a single block and threads past it never run, so
153    /// an over-wide call silently drops union entries. The caller gates on it.
154    pub moe_row_union: KernelHandle,
155    /// 🪤 **Clamped** SwiGLU, asymmetric. Not `moe_silu_mul`.
156    pub swiglu: KernelHandle,
157    pub router: KernelHandle,
158    pub combine: KernelHandle,
159}
160
161impl Glm5NextMlpKernels {
162    pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
163        Ok(Self {
164            gemm: gpu.kernel(GEMM_MODULE, "dense_gemm_bf16")?,
165            gemm_f32: gpu.kernel(GEMM_MODULE, "dense_gemm_bf16_f32out")?,
166            gemv: gpu.kernel("gemv", "dense_gemv_bf16")?,
167            gemv_batchm: crate::layers::try_kernel(
168                gpu,
169                "dense_gemv_bf16_batchm",
170                "dense_gemv_bf16_batchm",
171            ),
172            gemv_f32: crate::layers::try_kernel(gpu, "gemv", "dense_gemv_bf16_fp32out"),
173            w4a16: gpu.kernel(W4A16_MODULE, "w4a16_gemm")?,
174            w4a16_gemv: gpu.kernel(W4A16_GEMV_MODULE, "w4a16_gemv")?,
175            w4a16_gemv_sw: crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw"),
176            w4a16_gemv_sw_moe: crate::layers::try_kernel(
177                gpu,
178                W4A16_GEMV_MODULE,
179                "w4a16_gemv_sw_moe",
180            ),
181            w4a16_gemv_sw_moe_batchm: [
182                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m2"),
183                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m3"),
184                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m4"),
185                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m5"),
186                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m6"),
187                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m7"),
188                crate::layers::try_kernel(gpu, W4A16_GEMV_MODULE, "w4a16_gemv_sw_moe_batchm_m8"),
189            ],
190            moe_row_union: crate::layers::try_kernel(
191                gpu,
192                W4A16_GEMV_MODULE,
193                "glm5next_moe_row_union",
194            ),
195            swiglu: gpu.kernel(FFN_MODULE, "glm5next_swiglu_clamp")?,
196            router: gpu.kernel(FFN_MODULE, "glm5next_router_topk")?,
197            combine: gpu.kernel(FFN_MODULE, "glm5next_moe_combine")?,
198        })
199    }
200}
201
202/// Which MLP a layer runs. Mirrors [`crate::layers::glm5next_skeleton::Mlp`]; kept separate so
203/// the runtime does not depend on the skeleton's design-artifact types.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum Glm5NextMlpKind {
206    Dense,
207    RoutedMoe,
208}
209
210/// GLM MLP geometry for one rank, read from the checkpoint config — never defaulted.
211#[derive(Debug, Clone, Copy, PartialEq)]
212pub struct Glm5NextMlpConfig {
213    pub hidden: usize,
214    /// `intermediate_size` — the dense layers' FFN width, **this rank's share**.
215    pub local_dense_intermediate: usize,
216    /// `moe_intermediate_size` — one routed expert's width. **Never TP-sharded**: an expert is
217    /// owned whole by one EP rank.
218    pub moe_intermediate: usize,
219    /// `n_shared_experts * moe_intermediate_size`, **this rank's share**.
220    pub local_shared_intermediate: usize,
221    /// FULL routed-expert count (288). Not the local count — see the module header.
222    pub num_experts: usize,
223    /// Experts this rank owns, as a contiguous range `[ep_rank * local, +local)`.
224    pub local_experts: usize,
225    pub ep_rank: usize,
226    pub top_k: usize,
227    /// `routed_scaling_factor`. 🪤 Applied to the top-k WEIGHTS, never to the shared expert.
228    pub routed_scale: f32,
229    /// `norm_topk_prob` — renormalise the top-k weights (with a `1e-20` epsilon, not `1e-6`).
230    pub renormalize: bool,
231    /// 🪤 Asymmetric. See the module header.
232    pub swiglu_limit: f32,
233    /// True reproduces vLLM's bf16 router ladder; false is HF 5.16.1's fp32 semantics and the
234    /// production default. **Semantic, not precision** — the two select different experts.
235    pub router_bf16_ladder: bool,
236    /// TP ranks the dense/shared FFN is split over. `> 1` ⇒ the site output is a partial sum.
237    pub tp_world_size: usize,
238    /// EP ranks the routed experts are split over. `> 1` ⇒ the routed sum is a partial sum.
239    pub ep_world_size: usize,
240}
241
242impl Glm5NextMlpConfig {
243    /// `config` carries GLOBAL MoE counts; the TP division of the dense/shared widths and the EP
244    /// division of the expert set are applied here, in one place.
245    pub fn from_config(config: &ModelConfig) -> Result<Self> {
246        let tp = config.tp_world_size.max(1);
247        let ep = config.ep_world_size.max(1);
248        if !config.intermediate_size.is_multiple_of(tp) {
249            bail!(
250                "GLM MLP: intermediate_size {} does not divide over tp_world_size {tp}",
251                config.intermediate_size
252            );
253        }
254        if !config.shared_expert_intermediate_size.is_multiple_of(tp) {
255            bail!(
256                "GLM MLP: shared_expert_intermediate_size {} does not divide over \
257                 tp_world_size {tp}",
258                config.shared_expert_intermediate_size
259            );
260        }
261        if !config.num_experts.is_multiple_of(ep) {
262            bail!(
263                "GLM MLP: num_experts {} does not divide over ep_world_size {ep}; a ragged \
264                 expert split would leave some ids owned by nobody",
265                config.num_experts
266            );
267        }
268        let c = Self {
269            hidden: config.hidden_size,
270            local_dense_intermediate: config.intermediate_size / tp,
271            moe_intermediate: config.moe_intermediate_size,
272            local_shared_intermediate: config.shared_expert_intermediate_size / tp,
273            num_experts: config.num_experts,
274            local_experts: config.num_experts / ep,
275            ep_rank: config.ep_rank,
276            top_k: config.num_experts_per_tok,
277            routed_scale: config.routed_scaling_factor as f32,
278            renormalize: config.norm_topk_prob,
279            swiglu_limit: config.swiglu_limit,
280            router_bf16_ladder: matches!(config.glm5next_router_mode, Glm5NextRouterMode::VllmBf16),
281            tp_world_size: tp,
282            ep_world_size: ep,
283        };
284        c.validate()?;
285        Ok(c)
286    }
287
288    /// The half-open global expert-id range this rank owns.
289    pub fn local_expert_range(&self) -> std::ops::Range<usize> {
290        let start = self.ep_rank * self.local_experts;
291        start..start + self.local_experts
292    }
293
294    /// Global expert id → local slot, or `None` when another rank owns it.
295    ///
296    /// 🪤 The whole EP scheme rests on this: a remote id must contribute **zero**, not be
297    /// clamped into a local slot. Indexing a local array with a global id is the silent version
298    /// of that mistake and yields a real expert's weights for the wrong token.
299    pub fn local_slot(&self, global_id: usize) -> Option<usize> {
300        let r = self.local_expert_range();
301        r.contains(&global_id).then(|| global_id - r.start)
302    }
303
304    /// Whether the site output leaves this rank as a partial sum needing `all_reduce(SUM)`.
305    pub fn needs_all_reduce(&self) -> bool {
306        self.tp_world_size > 1 || self.ep_world_size > 1
307    }
308
309    pub fn validate(&self) -> Result<()> {
310        if self.hidden == 0 {
311            bail!("GLM MLP: hidden_size is 0");
312        }
313        if self.swiglu_limit <= 0.0 {
314            bail!(
315                "GLM MLP: swiglu_limit is {}. GLM-5.3 clamps its SwiGLU and the clamp is \
316                 asymmetric; a zero limit is not 'no clamp', it is a gate forced to <= 0. \
317                 The glm5_next parser reads the real value (10.0) and refuses to default it.",
318                self.swiglu_limit
319            );
320        }
321        if self.top_k == 0 || self.top_k > KERNEL_MAX_TOP_K {
322            bail!(
323                "GLM MLP: num_experts_per_tok {} is outside the {}-slot bound \
324                 glm5next_router_topk keeps in registers (`float best_w[16]`)",
325                self.top_k,
326                KERNEL_MAX_TOP_K
327            );
328        }
329        if self.top_k > self.num_experts {
330            bail!(
331                "GLM MLP: top_k {} exceeds num_experts {}",
332                self.top_k,
333                self.num_experts
334            );
335        }
336        if self.moe_intermediate == 0 {
337            bail!("GLM MLP: moe_intermediate_size is 0 — a routed layer would compute nothing");
338        }
339        if self.ep_rank >= self.ep_world_size {
340            bail!(
341                "GLM MLP: ep_rank {} is outside ep_world_size {}",
342                self.ep_rank,
343                self.ep_world_size
344            );
345        }
346        Ok(())
347    }
348}
349
350#[cfg(test)]
351mod tests;