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;