metrale_model_arch/glm5next_mlp/mod.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: GLM-5.3-Flash MLP: the dense SwiGLU FFN and the routed NVFP4 MoE, their kernels and per-rank geometry.
4//!
5//! Owner: model-arch (GLM-5.3).
6//! Invariants:
7//! - `Glm5NextMlpConfig::from_config` returns a config only when the dense and shared widths
8//! divide over `tp_world_size`, `num_experts` divides over `ep_world_size`, and `validate`
9//! passes.
10//! - For such configs, the `local_expert_range`s of ranks `0..ep_world_size` partition
11//! `0..num_experts`, and `local_slot` is `None` for every id outside this rank's range.
12//!
13//! # One all-reduce for both EP and TP
14//!
15//! With TP or EP above 1, a routed site's output is a partial sum on every rank: the shared
16//! expert is TP-sharded (`local_shared_intermediate`) and each rank runs only the experts it
17//! owns. `forward::forward_moe` adds the shared output and the routed slots in one combine
18//! kernel, and the layer then reduces that output once. The combine has to come before the
19//! reduce: adding a TP-sharded shared expert after the reduce would drop the other ranks'
20//! part of it.
21//!
22//! # Numerics the kernels rely on
23//!
24//! * The SwiGLU clamp is asymmetric: `gate` is bounded above only, `up` on both sides. The
25//! limit is `ModelConfig::swiglu_limit`, which the `glm5_next` parser refuses to default.
26//! * The router's correction bias steers selection only. The emitted weight is the chosen
27//! expert's unbiased sigmoid score.
28//! * `routed_scaling_factor` multiplies the top-k weights; the shared expert is added unscaled.
29//! * Every rank holds the whole router and ranks all `num_experts`, so for the same input every
30//! rank selects the same ids. Sharding the router would give each rank different partial
31//! logits and a different top-k.
32//! * `num_experts` is the full routed-expert count; `local_experts` is this rank's share.
33//! `glm5next_router_topk` must be given the full count.
34
35use anyhow::{Result, bail};
36use metrale_config::{Glm5NextRouterMode, ModelConfig};
37use metrale_gpu_runtime::gpu::{GpuBackend, KernelHandle};
38
39pub mod build;
40pub mod forward;
41pub mod forward_prefill_gemm;
42pub mod weights;
43
44pub use weights::{Glm5NextDenseMlpWeights, Glm5NextExpertWeights, Glm5NextMoeWeights};
45
46/// 2026-09-25: Module of `kernels/gb10/common/glm5next_ffn.cu`. A `.cu` file not listed in
47/// `common/KERNEL.toml`'s `[modules]` resolves under its file stem; the listed ones below do
48/// not, so each module name here is checked against that table.
49pub const FFN_MODULE: &str = "glm5next_ffn";
50/// 2026-09-25: `[modules]`: `dense_gemm_bf16 = "gemm"`.
51pub const GEMM_MODULE: &str = "gemm";
52/// 2026-09-25: `[modules]`: `w4a16_gemm = "w4a16"`.
53pub const W4A16_MODULE: &str = "w4a16";
54/// 2026-09-25: Module of `w4a16_gemv.cu` (not in `[modules]`, so its file stem). Its GEMV
55/// kernels run every routed-expert projection except the grouped prefill GEMM.
56pub const W4A16_GEMV_MODULE: &str = "w4a16_gemv";
57/// 2026-09-25: `[modules]`: `moe_permute = "moe"`, the token sort / permute / unpermute kernels.
58pub const MOE_MODULE: &str = "moe";
59/// 2026-09-25: `[modules]`: `moe_w4a16_grouped_gemm = "moe_w4a16"`, the tensor-core grouped W4A16 GEMM.
60pub const MOE_GROUPED_MODULE: &str = "moe_w4a16";
61
62/// 2026-09-25: The most experts `glm5next_router_topk` can select per token: its per-token
63/// selection lives in the shared arrays `sel_id[16]` and `sel_w[16]`.
64pub const KERNEL_MAX_TOP_K: usize = 16;
65
66/// 2026-09-25: The kernels a GLM MLP site launches.
67///
68/// `resolve` fails when a `gpu.kernel` entry point is missing (the GEMM/GEMV bases, `w4a16`,
69/// `w4a16_gemv` and the three `glm5next_ffn.cu` kernels). The `try_kernel` ones are
70/// `KernelHandle(0)` when absent, and the forward takes another path for each.
71#[derive(Clone, Copy)]
72pub struct Glm5NextMlpKernels {
73 /// 2026-09-25: BF16 `C = A @ B^T` tile GEMM: dense FFN and shared expert.
74 pub gemm: KernelHandle,
75 /// 2026-09-25: The same GEMM with FP32 output, used for the router, because
76 /// `glm5next_router_topk` reads FP32 logits.
77 pub gemm_f32: KernelHandle,
78 /// 2026-09-25: M=1 GEMVs for `gemm` / `gemm_f32`. `gemv_f32` is optional; when it is 0,
79 /// the router's M=1 calls run the tile GEMM (`ops::dense_mm_bf16`).
80 pub gemv: KernelHandle,
81 pub gemv_f32: KernelHandle,
82 /// 2026-09-25: `dense_gemv_bf16_batchm`: 2 to `DENSE_GEMV_BATCHM_MAX_M` (16) rows in one
83 /// weight sweep, for the dense FFN and shared expert. When 0, those rows run the tile GEMM.
84 pub gemv_batchm: KernelHandle,
85 /// 2026-09-25: NVFP4 `w4a16_gemm` tile GEMM. Resolved, but not launched by this module.
86 pub w4a16: KernelHandle,
87 /// 2026-09-25: NVFP4 `C[1, N] = A[1, K] @ B[N, K]^T`, the per-expert decode GEMV.
88 ///
89 /// Its grid is tied to the kernel's `N_PER_BLOCK`; use `ops::w4a16_gemv_grid_x`.
90 pub w4a16_gemv: KernelHandle,
91 /// 2026-09-25: Single-warp-per-output variant of `w4a16_gemv`, checked bit-identical to it
92 /// by `examples/w4a16_gemv_sw_microtest.rs`. When 0, the base kernel runs.
93 ///
94 /// Its grid is `ceil(N/8)` (`N_PER_BLOCK_SW`), not the base kernel's `ceil(N/4)`; launch it
95 /// through `ops::w4a16_gemv_sw_raw` or `ops::w4a16_decode_gemv`, which pick the grid.
96 pub w4a16_gemv_sw: KernelHandle,
97 /// 2026-09-25: All `top_k` slots of one row in one launch, grid `(ceil(N/8), top_k, 1)`.
98 /// Weights come from the global-id pointer tables indexed by the router's on-device ids.
99 /// When 0, the forward reads the ids back to the host and launches per local expert.
100 pub w4a16_gemv_sw_moe: KernelHandle,
101 /// 2026-09-25: Row-batched `w4a16_gemv_sw_moe`, indexed `[rows - 2]` for rows 2..=8
102 /// (`w4a16_gemv_sw_moe_batchm_m2` .. `_m8`). Each expert in the union of the rows'
103 /// selections is swept once for all the rows that picked it.
104 ///
105 /// grid.y is the union entry, not the slot, with extent `rows * top_k`; unfilled entries
106 /// return on `u_eid < 0`. Needs [`Self::moe_row_union`].
107 pub w4a16_gemv_sw_moe_batchm: [KernelHandle; 7],
108 /// 2026-09-25: Builds the union table the batched kernel indexes: one block of
109 /// `rows * top_k` threads. `forward_moe` uses it only when `rows * top_k` is at most
110 /// `MOE_ROW_UNION_MAX_IDS` (64).
111 pub moe_row_union: KernelHandle,
112 /// 2026-09-25: `glm5next_swiglu_clamp`, the asymmetric clamped SwiGLU. `moe_silu_mul`
113 /// does not clamp.
114 pub swiglu: KernelHandle,
115 pub router: KernelHandle,
116 pub combine: KernelHandle,
117 /// 2026-09-25: `moe_sort_by_expert` (`moe_permute.cu`): counting sort of the `[rows, top_k]`
118 /// ids into expert-contiguous order, writing `sorted_token_ids`, `expert_offsets` and
119 /// `token_to_perm`. When 0, the grouped prefill path is off.
120 pub moe_sort_by_expert: KernelHandle,
121 /// 2026-09-25: The tensor-core grouped W4A16 GEMM (`mma.sync.aligned.m16n8k16`) for
122 /// prefill, at the tile `forward_prefill_gemm::gemm_tile` picks. When 0, the grouped
123 /// prefill path is off.
124 pub moe_grouped_gemm: KernelHandle,
125 /// 2026-09-25: [`Self::combine`] reading the routed rows in expert-sorted order through
126 /// `token_to_perm`, with the same accumulation order and single rounding.
127 pub combine_indexed: KernelHandle,
128}
129
130impl Glm5NextMlpKernels {
131 pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
132 Ok(Self {
133 gemm: gpu.kernel(GEMM_MODULE, "dense_gemm_bf16")?,
134 gemm_f32: gpu.kernel(GEMM_MODULE, "dense_gemm_bf16_f32out")?,
135 gemv: gpu.kernel("gemv", "dense_gemv_bf16")?,
136 gemv_batchm: metrale_model_layers::layers::try_kernel(
137 gpu,
138 "dense_gemv_bf16_batchm",
139 "dense_gemv_bf16_batchm",
140 ),
141 gemv_f32: metrale_model_layers::layers::try_kernel(
142 gpu,
143 "gemv",
144 "dense_gemv_bf16_fp32out",
145 ),
146 w4a16: gpu.kernel(W4A16_MODULE, "w4a16_gemm")?,
147 w4a16_gemv: gpu.kernel(W4A16_GEMV_MODULE, "w4a16_gemv")?,
148 w4a16_gemv_sw: metrale_model_layers::layers::try_kernel(
149 gpu,
150 W4A16_GEMV_MODULE,
151 "w4a16_gemv_sw",
152 ),
153 w4a16_gemv_sw_moe: metrale_model_layers::layers::try_kernel(
154 gpu,
155 W4A16_GEMV_MODULE,
156 "w4a16_gemv_sw_moe",
157 ),
158 w4a16_gemv_sw_moe_batchm: [
159 metrale_model_layers::layers::try_kernel(
160 gpu,
161 W4A16_GEMV_MODULE,
162 "w4a16_gemv_sw_moe_batchm_m2",
163 ),
164 metrale_model_layers::layers::try_kernel(
165 gpu,
166 W4A16_GEMV_MODULE,
167 "w4a16_gemv_sw_moe_batchm_m3",
168 ),
169 metrale_model_layers::layers::try_kernel(
170 gpu,
171 W4A16_GEMV_MODULE,
172 "w4a16_gemv_sw_moe_batchm_m4",
173 ),
174 metrale_model_layers::layers::try_kernel(
175 gpu,
176 W4A16_GEMV_MODULE,
177 "w4a16_gemv_sw_moe_batchm_m5",
178 ),
179 metrale_model_layers::layers::try_kernel(
180 gpu,
181 W4A16_GEMV_MODULE,
182 "w4a16_gemv_sw_moe_batchm_m6",
183 ),
184 metrale_model_layers::layers::try_kernel(
185 gpu,
186 W4A16_GEMV_MODULE,
187 "w4a16_gemv_sw_moe_batchm_m7",
188 ),
189 metrale_model_layers::layers::try_kernel(
190 gpu,
191 W4A16_GEMV_MODULE,
192 "w4a16_gemv_sw_moe_batchm_m8",
193 ),
194 ],
195 moe_row_union: metrale_model_layers::layers::try_kernel(
196 gpu,
197 W4A16_GEMV_MODULE,
198 "glm5next_moe_row_union",
199 ),
200 swiglu: gpu.kernel(FFN_MODULE, "glm5next_swiglu_clamp")?,
201 router: gpu.kernel(FFN_MODULE, "glm5next_router_topk")?,
202 combine: gpu.kernel(FFN_MODULE, "glm5next_moe_combine")?,
203 moe_sort_by_expert: metrale_model_layers::layers::try_kernel(
204 gpu,
205 MOE_MODULE,
206 "moe_sort_by_expert",
207 ),
208 // 2026-09-25: Each tile is its own entry point, so the tile
209 // (`METRALE_GLM_MOE_GEMM_TILE`) is read before resolving. A missing tile
210 // other than the base falls back to `GEMM_TILES[0]`, with a warning.
211 moe_grouped_gemm: {
212 let tile = forward_prefill_gemm::gemm_tile();
213 let h =
214 metrale_model_layers::layers::try_kernel(gpu, MOE_GROUPED_MODULE, tile.name);
215 if h.0 == 0 && tile.name != forward_prefill_gemm::GEMM_TILES[0].name {
216 tracing::warn!(
217 "GLM routed-MoE grouped GEMM tile `{}` is not in this target's PTX — \
218 falling back to `{}`",
219 tile.name,
220 forward_prefill_gemm::GEMM_TILES[0].name
221 );
222 metrale_model_layers::layers::try_kernel(
223 gpu,
224 MOE_GROUPED_MODULE,
225 forward_prefill_gemm::GEMM_TILES[0].name,
226 )
227 } else {
228 h
229 }
230 },
231 combine_indexed: metrale_model_layers::layers::try_kernel(
232 gpu,
233 FFN_MODULE,
234 "glm5next_moe_combine_indexed",
235 ),
236 })
237 }
238}
239
240/// 2026-09-25: Which MLP a layer runs; the same variants as [`crate::glm5next_skeleton::Mlp`].
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum Glm5NextMlpKind {
243 Dense,
244 RoutedMoe,
245}
246
247/// 2026-09-25: GLM MLP geometry for one rank, built by [`Self::from_config`].
248#[derive(Debug, Clone, Copy, PartialEq)]
249pub struct Glm5NextMlpConfig {
250 pub hidden: usize,
251 /// 2026-09-25: `intermediate_size / tp_world_size`: this rank's share of the dense FFN width.
252 pub local_dense_intermediate: usize,
253 /// 2026-09-25: `moe_intermediate_size`, one routed expert's width. Not divided by TP: an
254 /// expert is owned whole by one EP rank.
255 pub moe_intermediate: usize,
256 /// 2026-09-25: `shared_expert_intermediate_size / tp_world_size`: this rank's share of the
257 /// shared expert.
258 pub local_shared_intermediate: usize,
259 /// 2026-09-25: The full routed-expert count, not this rank's share.
260 pub num_experts: usize,
261 /// 2026-09-25: `num_experts / ep_world_size`; this rank owns ids
262 /// `[ep_rank * local_experts, (ep_rank + 1) * local_experts)`.
263 pub local_experts: usize,
264 pub ep_rank: usize,
265 pub top_k: usize,
266 /// 2026-09-25: `routed_scaling_factor`. Applied to the top-k weights, not to the shared
267 /// expert.
268 pub routed_scale: f32,
269 /// 2026-09-25: `norm_topk_prob`: divide the top-k weights by their sum plus `1e-20`.
270 pub renormalize: bool,
271 /// 2026-09-25: The asymmetric SwiGLU clamp bound; see the module header.
272 pub swiglu_limit: f32,
273 /// 2026-09-25: True for `Glm5NextRouterMode::VllmBf16`: the router kernel then rounds the
274 /// scores, the running sum and each weight to BF16, which can change which experts are
275 /// selected. The parser yields `HfFp32` (false) when the checkpoint names no router dtype.
276 pub router_bf16_ladder: bool,
277 /// 2026-09-25: TP ranks the dense/shared FFN is split over. Above 1, the site output is a
278 /// partial sum.
279 pub tp_world_size: usize,
280 /// 2026-09-25: EP ranks the routed experts are split over. Above 1, the routed sum is a
281 /// partial sum.
282 pub ep_world_size: usize,
283}
284
285impl Glm5NextMlpConfig {
286 /// 2026-09-25: Divides the global widths by TP and the expert set by EP (a world size of 0
287 /// counts as 1), then runs [`Self::validate`]. Errors when a width or the expert count does
288 /// not divide evenly.
289 pub fn from_config(config: &ModelConfig) -> Result<Self> {
290 let tp = config.tp_world_size.max(1);
291 let ep = config.ep_world_size.max(1);
292 if !config.intermediate_size.is_multiple_of(tp) {
293 bail!(
294 "GLM MLP: intermediate_size {} does not divide over tp_world_size {tp}",
295 config.intermediate_size
296 );
297 }
298 if !config.shared_expert_intermediate_size.is_multiple_of(tp) {
299 bail!(
300 "GLM MLP: shared_expert_intermediate_size {} does not divide over \
301 tp_world_size {tp}",
302 config.shared_expert_intermediate_size
303 );
304 }
305 if !config.num_experts.is_multiple_of(ep) {
306 bail!(
307 "GLM MLP: num_experts {} does not divide over ep_world_size {ep}; a ragged \
308 expert split would leave some ids owned by nobody",
309 config.num_experts
310 );
311 }
312 let c = Self {
313 hidden: config.hidden_size,
314 local_dense_intermediate: config.intermediate_size / tp,
315 moe_intermediate: config.moe_intermediate_size,
316 local_shared_intermediate: config.shared_expert_intermediate_size / tp,
317 num_experts: config.num_experts,
318 local_experts: config.num_experts / ep,
319 ep_rank: config.ep_rank,
320 top_k: config.num_experts_per_tok,
321 routed_scale: config.routed_scaling_factor as f32,
322 renormalize: config.norm_topk_prob,
323 swiglu_limit: config.swiglu_limit,
324 router_bf16_ladder: matches!(config.glm5next_router_mode, Glm5NextRouterMode::VllmBf16),
325 tp_world_size: tp,
326 ep_world_size: ep,
327 };
328 c.validate()?;
329 Ok(c)
330 }
331
332 /// 2026-09-25: The half-open global expert-id range this rank owns.
333 pub fn local_expert_range(&self) -> std::ops::Range<usize> {
334 let start = self.ep_rank * self.local_experts;
335 start..start + self.local_experts
336 }
337
338 /// 2026-09-25: Global expert id to local slot, or `None` when another rank owns it. A
339 /// remote id contributes zero; `experts` is indexed by this slot, never by the global id.
340 pub fn local_slot(&self, global_id: usize) -> Option<usize> {
341 let r = self.local_expert_range();
342 r.contains(&global_id).then(|| global_id - r.start)
343 }
344
345 /// 2026-09-25: Whether the site output leaves this rank as a partial sum needing
346 /// `all_reduce(SUM)`.
347 pub fn needs_all_reduce(&self) -> bool {
348 self.tp_world_size > 1 || self.ep_world_size > 1
349 }
350
351 pub fn validate(&self) -> Result<()> {
352 if self.hidden == 0 {
353 bail!("GLM MLP: hidden_size is 0");
354 }
355 if self.swiglu_limit <= 0.0 {
356 bail!(
357 "GLM MLP: swiglu_limit is {}. GLM-5.3 clamps its SwiGLU and the clamp is \
358 asymmetric; a zero limit is not 'no clamp', it is a gate forced to <= 0. \
359 The glm5_next parser reads the real value (10.0) and refuses to default it.",
360 self.swiglu_limit
361 );
362 }
363 if self.top_k == 0 || self.top_k > KERNEL_MAX_TOP_K {
364 bail!(
365 "GLM MLP: num_experts_per_tok {} is outside the {}-slot bound \
366 glm5next_router_topk keeps in registers (`float best_w[16]`)",
367 self.top_k,
368 KERNEL_MAX_TOP_K
369 );
370 }
371 if self.top_k > self.num_experts {
372 bail!(
373 "GLM MLP: top_k {} exceeds num_experts {}",
374 self.top_k,
375 self.num_experts
376 );
377 }
378 if self.moe_intermediate == 0 {
379 bail!("GLM MLP: moe_intermediate_size is 0 — a routed layer would compute nothing");
380 }
381 if self.ep_rank >= self.ep_world_size {
382 bail!(
383 "GLM MLP: ep_rank {} is outside ep_world_size {}",
384 self.ep_rank,
385 self.ep_world_size
386 );
387 }
388 Ok(())
389 }
390}
391
392#[cfg(test)]
393mod tests;