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;