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;