spark_model/layers/glm5next_mlp/
forward.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The GLM MLP decode forward — dense FFN and routed MoE, one token.
4//!
5//! Launch geometry is lifted verbatim from the two gated microtests
6//! (`examples/glm5next_{ffn,moe}_microtest.rs`, Slice-10 gates 3/4/6/7), which measured this
7//! exact sequence against HF 5.16.1 on real layer-0 and layer-3 weights. Nothing here
8//! re-derives the equations.
9
10use anyhow::{Result, bail};
11use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
12use spark_runtime::kernel_args::KernelLaunch;
13
14use super::forward_prefill_gemm;
15use super::weights::{Glm5NextDenseMlpWeights, Glm5NextMoeWeights, Nvfp4Proj};
16use super::{Glm5NextMlpConfig, Glm5NextMlpKernels};
17
18const W4_TILE: u32 = 64;
19const ACT_BLOCK: u32 = 256;
20
21/// `C[M, N] = A[M, K] @ B[N, K]^T`, BF16 in and out.
22#[allow(clippy::too_many_arguments)]
23fn gemm(
24    gpu: &dyn GpuBackend,
25    k: KernelHandle,
26    gemv: KernelHandle,
27    batchm: KernelHandle,
28    a: DevicePtr,
29    b: DevicePtr,
30    c: DevicePtr,
31    m: usize,
32    n: usize,
33    kk: usize,
34    stream: u64,
35) -> Result<()> {
36    // ðŸ”ī Wide (prefill) shapes go to cuBLASLt. `dense_gemm_bf16` says of itself "Phase 1:
37    // Correct scalar implementation ... Phase 2: Will add mma.sync" — one output element
38    // per thread, no tensor cores. It measured 1.45 TFLOP/s on `mlp_dense` and 1.38 on
39    // `moe_shared`; the two agreeing at different shapes is what proves it is the KERNEL
40    // and not the shape. Together they were ~275 ms/step, ~20% of prefill.
41    //
42    // ðŸŠĪ This file kept the scalar path after the DSA and KDA blocks moved off it, so the
43    // same defect survived a commit that was supposed to have killed it. The dense MLP and
44    // the shared expert are BF16 and unquantized in the EXL3 K2 checkpoint (its scope is
45    // `glm53_routed_experts_only` — only the routed experts are trellis), so they were
46    // always eligible; nothing about the quantization stopped this.
47    //
48    // Same numerics boundary as everywhere else — see `glm5next_layer::cublas_wide_proj`.
49    // M=1 decode and M<=16 verify keep their bit-identical tiers, so the router's M=1 call
50    // below cannot reach this arm even though it shares this helper.
51    if m > crate::layers::ops::DENSE_GEMV_BATCHM_MAX_M as usize
52        && crate::layers::glm5next_layer::cublas_wide_proj()
53    {
54        return crate::layers::ops::cublas_bf16_proj_dense(
55            a, b, c, m as u32, n as u32, kk as u32, stream,
56        );
57    }
58    // M=1 decode -> GEMV; M=2..8 (a K-token verify sweep) -> ONE weight read for all rows;
59    // wider -> the tile GEMM. `ops::dense_mm_bf16` owns the policy and the grid coupling.
60    crate::layers::ops::dense_mm_bf16(
61        gpu,
62        &crate::layers::ops::DenseMmKernels {
63            gemm: k,
64            gemv,
65            batchm,
66        },
67        a,
68        b,
69        c,
70        m,
71        n,
72        kk,
73        stream,
74    )
75}
76
77/// `C[1, N] = A[1, K] @ dequant(B)[N, K]^T` — the M=1 decode kernel.
78///
79/// Same NVFP4 operand triple as [`w4a16`], one output row. Used for every routed-expert
80/// projection because they are all M=1 and the tile GEMM measured 9.7 GB/s there.
81fn w4a16_gemv(
82    gpu: &dyn GpuBackend,
83    k: KernelHandle,
84    k_sw: KernelHandle,
85    a: DevicePtr,
86    w: &Nvfp4Proj,
87    c: DevicePtr,
88    n: usize,
89    kk: usize,
90    stream: u64,
91) -> Result<()> {
92    // Prefer the single-warp sibling when the target carries it. BIT-IDENTICAL — the
93    // per-orig-lane partial is the same function, the shuffle tree is the same tree, and
94    // the final combine is the same two-term FP32 add; only the block packing differs.
95    // This site launched the base kernel directly and so had never picked up the SW win
96    // that `ops::w4a16_decode_gemv` has been handing every other decode GEMV.
97    if k_sw.0 != 0 {
98        return crate::layers::ops::w4a16_gemv_sw_raw(
99            gpu, k_sw, a, w.packed, w.scale, w.scale_2, c, n as u32, kk as u32, stream,
100        );
101    }
102    KernelLaunch::new(gpu, k)
103        .grid([crate::layers::ops::w4a16_gemv_grid_x(n as u32), 1, 1])
104        .block([256, 1, 1])
105        .arg_ptr(a)
106        .arg_ptr(w.packed)
107        .arg_ptr(w.scale)
108        // ðŸŠĪ by VALUE, as in `w4a16`.
109        .arg_f32(w.scale_2)
110        .arg_ptr(c)
111        .arg_u32(n as u32)
112        .arg_u32(kk as u32)
113        .launch(stream)?;
114    Ok(())
115}
116
117/// `C[M, N] = A[M, K] @ dequant(B)[N, K]^T` — NVFP4 weight, BF16 activation and output.
118#[allow(clippy::too_many_arguments)]
119#[allow(dead_code)]
120fn w4a16(
121    gpu: &dyn GpuBackend,
122    k: KernelHandle,
123    a: DevicePtr,
124    w: &Nvfp4Proj,
125    c: DevicePtr,
126    m: usize,
127    n: usize,
128    kk: usize,
129    stream: u64,
130) -> Result<()> {
131    KernelLaunch::new(gpu, k)
132        .grid([
133            (n as u32).div_ceil(W4_TILE),
134            (m as u32).div_ceil(W4_TILE),
135            1,
136        ])
137        .block([128, 1, 1])
138        .arg_ptr(a)
139        .arg_ptr(w.packed)
140        .arg_ptr(w.scale)
141        // ðŸŠĪ by VALUE. `weight_scale_2` is a scalar argument, not a pointer.
142        .arg_f32(w.scale_2)
143        .arg_ptr(c)
144        .arg_u32(m as u32)
145        .arg_u32(n as u32)
146        .arg_u32(kk as u32)
147        .launch(stream)?;
148    Ok(())
149}
150
151/// `out = silu(min(gate, limit)) * clamp(up, -limit, limit)` over `n` elements.
152///
153/// Re-exported to the grouped-prefill module as [`swiglu_rows`]: the clamp is asymmetric and
154/// model-specific, so that path must run THIS activation, never `moe_silu_mul`.
155pub(super) fn swiglu_rows(
156    gpu: &dyn GpuBackend,
157    k: KernelHandle,
158    gate: DevicePtr,
159    up: DevicePtr,
160    out: DevicePtr,
161    n: usize,
162    limit: f32,
163    stream: u64,
164) -> Result<()> {
165    swiglu(gpu, k, gate, up, out, n, limit, stream)
166}
167
168fn swiglu(
169    gpu: &dyn GpuBackend,
170    k: KernelHandle,
171    gate: DevicePtr,
172    up: DevicePtr,
173    out: DevicePtr,
174    n: usize,
175    limit: f32,
176    stream: u64,
177) -> Result<()> {
178    KernelLaunch::new(gpu, k)
179        .grid([(n as u32).div_ceil(ACT_BLOCK), 1, 1])
180        .block([ACT_BLOCK, 1, 1])
181        .arg_ptr(gate)
182        .arg_ptr(up)
183        .arg_ptr(out)
184        .arg_u32(n as u32)
185        .arg_f32(limit)
186        .launch(stream)?;
187    Ok(())
188}
189
190/// Scratch for one MLP site, allocated once and reused every decode step.
191///
192/// Sized for the widest thing this site can run: a dense layer's `intermediate_size`, a routed
193/// layer's `moe_intermediate_size`, and the shared expert's width.
194pub struct Glm5NextMlpWorkspace {
195    /// `[rows, max_inter]` BF16 ×3 — gate, up, activated. Shared by every projection pair.
196    a_gate: DevicePtr,
197    a_up: DevicePtr,
198    a_act: DevicePtr,
199    /// `[rows, num_experts]` F32 router logits.
200    logits: DevicePtr,
201    /// `[rows, top_k]` I32 / F32 selection.
202    ids: DevicePtr,
203    wts: DevicePtr,
204    /// `[rows, top_k, hidden]` BF16. ðŸŠĪ Fully written every step — remote and invalid slots are
205    /// memset to zero before the loop, never left stale.
206    expert_out: DevicePtr,
207    /// `[rows, hidden]` BF16 shared-expert output.
208    shared_out: DevicePtr,
209    /// `[rows * top_k]` I32 union expert ids, `-1` = entry unused. Row-batched MoE only.
210    u_eid: DevicePtr,
211    /// `[rows * top_k, rows]` I32 slot per union entry per row, `-1` = row absent.
212    u_slot: DevicePtr,
213    /// `[rows * top_k]` I32 — sorted row → original token. Grouped prefill GEMM only.
214    sorted_token_ids: DevicePtr,
215    /// `[rows * top_k]` I32 — sorted row → expert id. Grouped prefill GEMM only.
216    sorted_expert_ids: DevicePtr,
217    /// `[num_experts + 1]` I32 prefix sum over the expert-sorted rows.
218    expert_offsets: DevicePtr,
219    /// `[rows, top_k]` I32 — a slot's row in the expert-sorted output. Read by the grouped
220    /// GEMM's paired `glm5next_moe_combine_indexed`.
221    token_to_perm: DevicePtr,
222    max_inter: usize,
223    /// Widest verify this scratch can serve. `1` on the serial decode path.
224    max_rows: usize,
225    /// `max_rows * top_k` — the routed-slot extent every grouped-path buffer is sized for.
226    max_total_expanded: usize,
227}
228
229/// Per-buffer device byte sizes of one [`Glm5NextMlpWorkspace`], in allocation order.
230///
231/// ðŸ”ī Split out of `new` so the sizing can be unit-tested and LOGGED without a GPU. The load
232/// path prints the total per workspace and the stack total: the whole point of the shared
233/// workspace is a number that used to be multiplied by 45, and a number nobody can read is a
234/// number nobody checks. Every term here must stay in lockstep with `new` — the test
235/// `ws_sizing_matches_new` walks both.
236pub fn mlp_ws_bytes(cfg: &Glm5NextMlpConfig, max_rows: usize) -> [usize; 14] {
237    let rows = max_rows.max(1);
238    let max_inter = cfg
239        .local_dense_intermediate
240        .max(cfg.moe_intermediate)
241        .max(cfg.local_shared_intermediate)
242        .max(1);
243    let act_elems = (rows * max_inter)
244        .max(rows * cfg.top_k * cfg.moe_intermediate)
245        .max(1);
246    [
247        act_elems * 2,                     // a_gate
248        act_elems * 2,                     // a_up
249        act_elems * 2,                     // a_act
250        rows * cfg.num_experts * 4,        // logits
251        rows * cfg.top_k * 4,              // ids
252        rows * cfg.top_k * 4,              // wts
253        rows * cfg.top_k * cfg.hidden * 2, // expert_out
254        rows * cfg.hidden * 2,             // shared_out
255        rows * cfg.top_k * 4,              // u_eid
256        rows * cfg.top_k * rows * 4,       // u_slot — ðŸ”ī QUADRATIC in rows
257        rows * cfg.top_k * 4,              // sorted_token_ids
258        rows * cfg.top_k * 4,              // sorted_expert_ids
259        (cfg.num_experts + 1) * 4,         // expert_offsets
260        rows * cfg.top_k * 4,              // token_to_perm
261    ]
262}
263
264/// Total device bytes one MLP workspace of `max_rows` costs.
265pub fn mlp_ws_total_bytes(cfg: &Glm5NextMlpConfig, max_rows: usize) -> usize {
266    mlp_ws_bytes(cfg, max_rows).iter().sum()
267}
268
269/// ONE MLP scratch for the whole layer stack; `AVAROK_GLM_MLP_WS_SHARED=0` restores one per layer.
270///
271/// ðŸ”ī Default ON. The scratch is per-call — every buffer is fully written before it is read
272/// inside a single `mlp_forward`, and the whole stack runs on ONE stream (there is no side
273/// stream on this path), so a later layer can never observe an earlier layer's bytes. What 45
274/// private copies DID buy was ≈2.1 GB of unified memory at `AVAROK_GLM_PREFILL_ROWS=256` and
275/// ≈9.5 GB at 1024, allocated during weight LOAD — the measured cause of the host-memory-guard
276/// kills in ANOMALIES A124/A127. The `=0` arm exists so the saving can be measured in ONE image.
277pub fn mlp_ws_shared() -> bool {
278    static S: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
279    *S.get_or_init(|| {
280        let shared = std::env::var("AVAROK_GLM_MLP_WS_SHARED").as_deref() != Ok("0");
281        if !shared {
282            tracing::warn!(
283                "GLM MLP workspace: PER-LAYER (AVAROK_GLM_MLP_WS_SHARED=0) — the pre-P2 \
284                 allocation, one scratch per layer"
285            );
286        }
287        shared
288    })
289}
290
291impl Glm5NextMlpWorkspace {
292    pub fn new(gpu: &dyn GpuBackend, cfg: &Glm5NextMlpConfig, max_rows: usize) -> Result<Self> {
293        let rows = max_rows.max(1);
294        let max_inter = cfg
295            .local_dense_intermediate
296            .max(cfg.moe_intermediate)
297            .max(cfg.local_shared_intermediate)
298            .max(1);
299        // The grouped MoE path activates all `top_k` slots in one launch, so the three
300        // activation buffers are slot-major and `top_k` times a routed expert's width. The
301        // dense path still writes only the first `inter` elements — `max_inter` stays the
302        // guard for it.
303        // The dense/shared arm is now `[rows, inter]`; the grouped MoE arm is still one row's
304        // `top_k` slots at a time. Both share these buffers, so take the wider.
305        // The row-batched MoE arm computes every (row, slot) pair in ONE launch, so its
306        // activations are `[rows, top_k, moe_intermediate]` — wider than either of the above.
307        let act_elems = (rows * max_inter)
308            .max(rows * cfg.top_k * cfg.moe_intermediate)
309            .max(1);
310        Ok(Self {
311            a_gate: gpu.alloc(act_elems * 2)?,
312            a_up: gpu.alloc(act_elems * 2)?,
313            a_act: gpu.alloc(act_elems * 2)?,
314            logits: gpu.alloc(rows * cfg.num_experts * 4)?,
315            ids: gpu.alloc(rows * cfg.top_k * 4)?,
316            wts: gpu.alloc(rows * cfg.top_k * 4)?,
317            expert_out: gpu.alloc(rows * cfg.top_k * cfg.hidden * 2)?,
318            shared_out: gpu.alloc(rows * cfg.hidden * 2)?,
319            u_eid: gpu.alloc(rows * cfg.top_k * 4)?,
320            u_slot: gpu.alloc(rows * cfg.top_k * rows * 4)?,
321            // ðŸŠĪ A59: the grouped-prefill routing tables are allocated HERE, at load, with
322            // every other pool — never on the first prefill. They are tiny (a 256-row
323            // sub-chunk at top_k = 8 is 8 KB each, plus 1.2 KB of offsets), so they are
324            // allocated unconditionally rather than behind the env lever: a conditional
325            // pool is a pool that is missing exactly when a fallback needs it.
326            sorted_token_ids: gpu.alloc(rows * cfg.top_k * 4)?,
327            sorted_expert_ids: gpu.alloc(rows * cfg.top_k * 4)?,
328            expert_offsets: gpu.alloc((cfg.num_experts + 1) * 4)?,
329            token_to_perm: gpu.alloc(rows * cfg.top_k * 4)?,
330            max_inter,
331            max_rows: rows,
332            max_total_expanded: rows * cfg.top_k,
333        })
334    }
335
336    /// Widest row group this scratch serves.
337    pub(super) fn max_rows(&self) -> usize {
338        self.max_rows
339    }
340    /// `max_rows * top_k` — the routed-slot extent the grouped buffers were sized for.
341    pub(super) fn max_total_expanded(&self) -> usize {
342        self.max_total_expanded
343    }
344    pub(super) fn ids(&self) -> DevicePtr {
345        self.ids
346    }
347    pub(super) fn a_gate(&self) -> DevicePtr {
348        self.a_gate
349    }
350    pub(super) fn a_up(&self) -> DevicePtr {
351        self.a_up
352    }
353    pub(super) fn a_act(&self) -> DevicePtr {
354        self.a_act
355    }
356    pub(super) fn expert_out(&self) -> DevicePtr {
357        self.expert_out
358    }
359    pub(super) fn sorted_token_ids(&self) -> DevicePtr {
360        self.sorted_token_ids
361    }
362    pub(super) fn sorted_expert_ids(&self) -> DevicePtr {
363        self.sorted_expert_ids
364    }
365    pub(super) fn expert_offsets(&self) -> DevicePtr {
366        self.expert_offsets
367    }
368    pub(super) fn token_to_perm(&self) -> DevicePtr {
369        self.token_to_perm
370    }
371}
372
373/// A BF16 SwiGLU MLP of width `inter`: `down(clamped_swiglu(gate(x), up(x)))`.
374///
375/// Used for both the dense layers and the shared expert — identical math, different widths.
376/// With `tp_world_size > 1` the result is a **partial sum**; the caller reduces.
377#[allow(clippy::too_many_arguments)]
378pub fn forward_dense(
379    gpu: &dyn GpuBackend,
380    k: &Glm5NextMlpKernels,
381    cfg: &Glm5NextMlpConfig,
382    w: &Glm5NextDenseMlpWeights,
383    inter: usize,
384    x: DevicePtr,
385    out: DevicePtr,
386    m: usize,
387    ws: &Glm5NextMlpWorkspace,
388    stream: u64,
389) -> Result<()> {
390    if inter == 0 || inter > ws.max_inter {
391        bail!(
392            "GLM dense MLP: width {inter} does not fit a workspace built for {}",
393            ws.max_inter
394        );
395    }
396    if m == 0 || m > ws.max_rows {
397        bail!(
398            "GLM dense MLP: {m} rows do not fit a workspace built for {}",
399            ws.max_rows
400        );
401    }
402    gemm(
403        gpu,
404        k.gemm,
405        k.gemv,
406        k.gemv_batchm,
407        x,
408        w.gate_proj,
409        ws.a_gate,
410        m,
411        inter,
412        cfg.hidden,
413        stream,
414    )?;
415    gemm(
416        gpu,
417        k.gemm,
418        k.gemv,
419        k.gemv_batchm,
420        x,
421        w.up_proj,
422        ws.a_up,
423        m,
424        inter,
425        cfg.hidden,
426        stream,
427    )?;
428    swiglu(
429        gpu,
430        k.swiglu,
431        ws.a_gate,
432        ws.a_up,
433        ws.a_act,
434        // Elementwise over the whole `[m, inter]` block — bit-identical to m launches of
435        // `inter`, because every output element depends only on its own gate/up pair.
436        m * inter,
437        cfg.swiglu_limit,
438        stream,
439    )?;
440    gemm(
441        gpu,
442        k.gemm,
443        k.gemv,
444        k.gemv_batchm,
445        ws.a_act,
446        w.down_proj,
447        out,
448        m,
449        cfg.hidden,
450        inter,
451        stream,
452    )
453}
454
455/// `C[top_k, N] = A @ dequant(expert[ids[slot]])^T` — every routed slot in ONE launch.
456///
457/// Bit-identical per slot to the [`w4a16_gemv`] loop it replaces; see the kernel's header.
458/// Slots whose expert this rank does not own are skipped, so `c` must already hold whatever
459/// those rows should contribute (zero, for the routed sum).
460#[allow(clippy::too_many_arguments)]
461fn w4a16_gemv_moe(
462    gpu: &dyn GpuBackend,
463    k: KernelHandle,
464    a: DevicePtr,
465    t: &super::weights::Glm5NextExpertPtrTable,
466    c: DevicePtr,
467    ids: DevicePtr,
468    n: usize,
469    kk: usize,
470    top_k: usize,
471    num_experts: usize,
472    input_stride: usize,
473    stream: u64,
474) -> Result<()> {
475    KernelLaunch::new(gpu, k)
476        // ðŸŠĪ COUPLED to the kernel's `N_PER_BLOCK_SW` = 8, and grid.y IS the slot.
477        .grid([
478            crate::layers::ops::w4a16_gemv_sw_grid_x(n as u32),
479            top_k as u32,
480            1,
481        ])
482        .block([256, 1, 1])
483        .arg_ptr(a)
484        .arg_ptr(t.packed_ptrs)
485        .arg_ptr(t.scale_ptrs)
486        .arg_ptr(t.scale2_vals)
487        .arg_ptr(c)
488        .arg_ptr(ids)
489        .arg_u32(n as u32)
490        .arg_u32(kk as u32)
491        .arg_u32(num_experts as u32)
492        .arg_u32(input_stride as u32)
493        .launch(stream)
494}
495
496/// `C[rows, top_k, N]` — the UNION of the rows' selected experts, each expert swept ONCE.
497///
498/// Bit-identical per (row, slot) to [`w4a16_gemv_moe`]; see the kernel header. `u_eid` /
499/// `u_slot` come from `glm5next_moe_row_union` and stay on device, so this is capturable.
500///
501/// ðŸŠĪ grid.y is `rows * top_k` — the UNION extent, not `top_k`. Entries the routing did not
502/// fill retire immediately on `u_eid < 0`.
503#[allow(clippy::too_many_arguments)]
504fn w4a16_gemv_moe_batchm(
505    gpu: &dyn GpuBackend,
506    k: KernelHandle,
507    a: DevicePtr,
508    t: &super::weights::Glm5NextExpertPtrTable,
509    c: DevicePtr,
510    u_eid: DevicePtr,
511    u_slot: DevicePtr,
512    n: usize,
513    kk: usize,
514    rows: usize,
515    top_k: usize,
516    num_experts: usize,
517    a_row_stride: usize,
518    a_slot_stride: usize,
519    c_row_stride: usize,
520    stream: u64,
521) -> Result<()> {
522    KernelLaunch::new(gpu, k)
523        // ðŸŠĪ COUPLED to the kernel's `N_PER_BLOCK_SW` = 8.
524        .grid([
525            crate::layers::ops::w4a16_gemv_sw_grid_x(n as u32),
526            (rows * top_k) as u32,
527            1,
528        ])
529        .block([256, 1, 1])
530        .arg_ptr(a)
531        .arg_ptr(t.packed_ptrs)
532        .arg_ptr(t.scale_ptrs)
533        .arg_ptr(t.scale2_vals)
534        .arg_ptr(c)
535        .arg_ptr(u_eid)
536        .arg_ptr(u_slot)
537        .arg_u32(n as u32)
538        .arg_u32(kk as u32)
539        .arg_u32(num_experts as u32)
540        .arg_u32(a_row_stride as u32)
541        .arg_u32(a_slot_stride as u32)
542        .arg_u32(c_row_stride as u32)
543        .launch(stream)
544}
545
546/// Kill switch for the row-batched routed path: `AVAROK_NO_GLM_MOE_ROW_BATCH=1` restores the
547/// one-launch-per-row grouped dispatch. Read once — this sits on the per-layer decode path.
548fn row_batch_disabled() -> bool {
549    static F: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
550    *F.get_or_init(|| std::env::var("AVAROK_NO_GLM_MOE_ROW_BATCH").as_deref() == Ok("1"))
551}
552
553/// Widest tier `w4a16_gemv_sw_moe_batchm` may be dispatched at, `AVAROK_GLM_MOE_ROW_BATCH_MAX`.
554///
555/// 🔎 An A/B lever, not a tuning knob: `=4` restores the pre-2026-08-31 cap exactly, so the
556/// width extension can be measured against itself in ONE image instead of one image per arm —
557/// the same lever that found A65's real defect. Clamped to the compiled tier family.
558pub(crate) fn row_batch_max() -> usize {
559    static M: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
560    *M.get_or_init(|| {
561        let m = std::env::var("AVAROK_GLM_MOE_ROW_BATCH_MAX")
562            .ok()
563            .and_then(|v| v.parse::<usize>().ok())
564            .unwrap_or(MOE_ROW_BATCH_MAX_ROWS)
565            .clamp(1, MOE_ROW_BATCH_MAX_ROWS);
566        if m != MOE_ROW_BATCH_MAX_ROWS {
567            tracing::warn!(
568                "GLM MoE row-batch width capped at {m} (default {MOE_ROW_BATCH_MAX_ROWS})"
569            );
570        }
571        m
572    })
573}
574
575/// Widest compiled `w4a16_gemv_sw_moe_batchm_mR` tier. Mirror of the
576/// `AVAROK_MOE_BATCHM_ENTRY` list in `kernels/gb10/common/w4a16_gemv.cu` and of the
577/// `[KernelHandle; 7]` in `Glm5NextMlpKernels`.
578///
579/// ðŸ”ī Since 2026-09-02 this is a **sub-group width, not a caller contract**. The prefill sub-chunk
580/// is 16 rows wide (`glm5next_layer::PREFILL_ROWS`) because the DENSE tier widened to 16; the
581/// routed experts did not follow, so [`forward_moe`] splits any wider row group into even
582/// sub-groups of at most this and sweeps each one. Callers may pass any `rows` their workspace
583/// holds.
584pub const MOE_ROW_BATCH_MAX_ROWS: usize = 8;
585
586/// Split `rows` into consecutive `(start, width)` sub-groups of at most `cap`, as evenly as the
587/// count allows.
588///
589/// ðŸŠĪ Even, not greedy. A greedy split of 9 rows at cap 8 leaves a trailing group of ONE, and
590/// there is no `w4a16_gemv_sw_moe_batchm_m1` tier — the array starts at m2. Balancing gives 5 + 4,
591/// and at the shipping cap of `MOE_ROW_BATCH_MAX_ROWS` every group is >= 2 for every `rows >= 2`.
592///
593/// ðŸŠĪ A width-1 group is still REACHABLE at a small cap, where it is arithmetically forced (3 rows
594/// at cap 2 has no all->=2 split). That is not a correctness hole — the caller's gate requires
595/// every group to have a tier, so such a call simply runs the per-row arm — but it does mean
596/// `AVAROK_GLM_MOE_ROW_BATCH_MAX=2` silently disables row batching at odd widths. Only the A/B
597/// lever can reach it.
598///
599/// ðŸ”ī Splitting is EXACT. A row's routed output is the sum over ITS OWN top-k slots, each slot a
600/// single expert's GEMV whose accumulation order (`w4a16_gemv_partial_rows`) does not depend on
601/// `R` or on which rows share the sweep; the union table only decides which experts get swept and
602/// in what order the sweeps are issued, never what any row adds. So `forward_moe(rows)` returns
603/// the same bits however it is grouped. What splitting costs is amortization, not accuracy: two
604/// 8-row sweeps visit the union of 8 rows twice instead of the (smaller) union of 16 once.
605fn moe_row_groups(rows: usize, cap: usize) -> Vec<(usize, usize)> {
606    let n = rows.div_ceil(cap.max(1)).max(1);
607    let mut out = Vec::with_capacity(n);
608    let mut start = 0usize;
609    for i in 0..n {
610        let w = (rows - start).div_ceil(n - i);
611        out.push((start, w));
612        start += w;
613    }
614    out
615}
616
617/// ðŸŠĪ `glm5next_moe_row_union` is ONE block of `rows * top_k` threads. A CUDA block is capped
618/// at 1024 threads, but this kernel's own scans are `O(T^2)`/`O(T^3)` over that extent and the
619/// tier family was sized around 64, so 64 is the contract. Threads past a block never run:
620/// exceeding it would SILENTLY drop union entries, so the dispatch refuses instead.
621pub const MOE_ROW_UNION_MAX_IDS: usize = 64;
622
623/// Say once PER ROW COUNT whether a verify shares its expert sweeps across its rows.
624///
625/// ðŸŠĪ A plain `Once` here is a trap: the first MoE forward of a run is the prefill/K=1 step at
626/// `rows = 1`, which can never batch, so a single announcement reports "per-row" for the whole
627/// process and the batched path looks like it never engaged. Latch one bit per row count.
628fn announce_row_batch(batched: bool, rows: usize) {
629    use std::sync::atomic::{AtomicU16, Ordering};
630    static SEEN: AtomicU16 = AtomicU16::new(0);
631    let bit = 1u16 << rows.min(15);
632    if SEEN.fetch_or(bit, Ordering::Relaxed) & bit != 0 {
633        return;
634    }
635    if batched {
636        tracing::info!("GLM MoE: row-batched expert union ({rows} rows, one sweep each)");
637    } else {
638        tracing::info!("GLM MoE: per-row expert sweeps ({rows} rows)");
639    }
640}
641
642/// Say once PER ROW COUNT whether the routed experts took the grouped GEMM.
643///
644/// ðŸŠĪ A silent fallback here is the expensive kind: a missing `moe_sort_by_expert` or
645/// `moe_w4a16_grouped_gemm_ptrtable` handle sends prefill straight back to the 8-row GEMV
646/// and the only symptom is the TTFT. Latch one bit per row count, like `announce_row_batch`.
647fn announce_grouped_prefill(on: bool, rows: usize) {
648    use std::sync::atomic::{AtomicU32, Ordering};
649    static SEEN: AtomicU32 = AtomicU32::new(0);
650    if rows <= MOE_ROW_BATCH_MAX_ROWS {
651        return; // the narrow paths never consider it; saying so every step is noise
652    }
653    let bit = 1u32 << (rows.min(31));
654    if SEEN.fetch_or(bit, Ordering::Relaxed) & bit != 0 {
655        return;
656    }
657    if on {
658        // ðŸ”ī The tile is part of the path, not a detail: two arms of a WS1/P3b A/B differ
659        // ONLY by this string, and an arm that silently fell back to the base tile would
660        // read as "no difference". Announce which kernel actually ran.
661        let t = super::forward_prefill_gemm::gemm_tile();
662        tracing::info!(
663            "GLM MoE prefill: grouped tensor-core W4A16 GEMM, {rows} rows in ONE launch \
664             per projection, tile `{}` (M_TILE {}, N_TILE {}, {} threads) \
665             (AVAROK_GLM_MOE_PREFILL_GEMM=0 to restore the GEMV path; \
666             AVAROK_GLM_MOE_GEMM_TILE=base for the prior tile)",
667            t.name,
668            t.m_tile,
669            t.n_tile,
670            t.threads
671        );
672    } else {
673        tracing::info!(
674            "GLM MoE prefill: row-batched GEMV, {rows} rows split into \
675             {MOE_ROW_BATCH_MAX_ROWS}-row sweeps"
676        );
677    }
678}
679
680/// Kill switch for the grouped path: `AVAROK_GLM_MOE_HOST_DISPATCH=1` restores the
681/// read-ids-to-host expert loop. Read once — this sits on the per-layer decode path.
682fn host_dispatch_forced() -> bool {
683    static F: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
684    *F.get_or_init(|| std::env::var("AVAROK_GLM_MOE_HOST_DISPATCH").as_deref() == Ok("1"))
685}
686
687/// Say once which expert-dispatch path this process took. A missing `w4a16_gemv_sw_moe`
688/// entry point falls back SILENTLY otherwise, and the fallback is the slow one.
689fn announce_dispatch(grouped: bool) {
690    static ONCE: std::sync::Once = std::sync::Once::new();
691    ONCE.call_once(|| {
692        if grouped {
693            tracing::info!("GLM MoE: grouped device dispatch (no per-layer D2H)");
694        } else {
695            tracing::info!("GLM MoE: host dispatch (per-layer stream sync + D2H)");
696        }
697    });
698}
699
700/// One routed MoE site, one token. Leaves a **partial sum** in `out` whenever this rank shares
701/// the experts (EP) or the shared expert (TP) with anyone else.
702///
703/// # ðŸŠĪ The device→host round trip
704///
705/// The expert loop reads the selected ids back to the host to decide which experts are local.
706/// That is a synchronising `copy_d2h` on the decode critical path, once per routed layer.
707/// It is deliberate for this slice: correctness first, and it is exactly what the gated
708/// microtest does. The upgrade path is the pointer-table grouped GEMM
709/// (`layers::moe::ptr_table_build`), which keeps the routing on device — not a change to
710/// this math.
711/// Routed MoE over `rows` rows.
712///
713/// ðŸ”ī The routed experts amortize PARTIALLY over a verify's rows. Measured on the live routing
714/// trace (42 series x 406 steps), the union of selected experts over K consecutive tokens is
715/// 8.00 / 13.74 / 18.76 / 23.35 at K = 1..4, so their weight traffic grows with K however the
716/// loop is written — but it grows along that curve, not along 8K.
717///
718/// ðŸ”ī RETRACTED (2026-08-29): this comment used to say the routed experts "stay one row at a
719/// time" and that deduplicating the union "needs a device-side sort the dispatch kernel does
720/// not have yet". Both are wrong. `top_k * rows <= 64` ids resolve in ONE block by pairwise
721/// scan — no sort — and `w4a16_gemv_sw_moe_batchm_mR` then sweeps each union expert once.
722/// Measured on t69, K=3, six probes byte-identical either way: open512 20.25 -> 22.12 tok/s
723/// (+9.2%), a 125.4 -> 114.8 ms step. At K=2 (the serving default) 18.91 -> 19.75.
724///
725/// ðŸ”ī WIDENED (2026-08-31) from `rows <= 4` to `rows <= 8`. The 4 was the compiled tier family,
726/// not the union's limit — `8 * 8 == 64` fits its single block exactly. This is what makes the
727/// 8-row batched PREFILL sub-chunk (ANOMALIES A65) amortize its routed experts too; before it,
728/// prefill batched every stage EXCEPT the experts, which by then were most of the traffic left.
729///
730/// The SHARED expert is a different animal again: it is the same weights for every row, so it
731/// runs once over all of them regardless.
732#[allow(clippy::too_many_arguments)]
733pub fn forward_moe(
734    gpu: &dyn GpuBackend,
735    k: &Glm5NextMlpKernels,
736    cfg: &Glm5NextMlpConfig,
737    w: &Glm5NextMoeWeights,
738    x: DevicePtr,
739    out: DevicePtr,
740    rows: usize,
741    ws: &Glm5NextMlpWorkspace,
742    stream: u64,
743) -> Result<()> {
744    if rows == 0 || rows > ws.max_rows {
745        bail!(
746            "GLM MoE: {rows} rows do not fit a workspace built for {}",
747            ws.max_rows
748        );
749    }
750    if w.experts.len() != cfg.local_experts {
751        bail!(
752            "GLM MoE: {} bound experts but this rank owns {} of {}",
753            w.experts.len(),
754            cfg.local_experts,
755            cfg.num_experts
756        );
757    }
758
759    use crate::layers::glm5next_layer::profile;
760
761    // ðŸ”ī The routed experts DO amortize over a verify's rows — just not fully. The union of
762    // the selected experts over K consecutive tokens is 8.00 / 13.74 / 18.76 / 23.35 at
763    // K = 1..4 (live routing trace, 42 series x 406 steps), so K rows sweep that many experts
764    // instead of 8K. The per-row path pays 8K; this one pays the union.
765    // ðŸŠĪ The route trace reads `ids` back per row, which the batched path never does — leave
766    // it on the per-row arm rather than reconstructing the trace from the union table.
767    // Sub-groups the routed sweep runs at. `rows` may exceed the widest tier — the prefill
768    // sub-chunk is 16 wide since the dense tier widened — so every gate below is PER GROUP.
769    let groups = moe_row_groups(rows, row_batch_max());
770    // ── PREFILL ONLY: the whole row group through the tensor-core grouped GEMM ──
771    //
772    // ðŸ”ī `rows > MOE_ROW_BATCH_MAX_ROWS` IS the prefill test. Decode is 1 row and a
773    // speculative verify is capped at `DENSE_GEMV_BATCHM_MAX_M` (= 8 = this constant), so the
774    // only caller that can be wider is `Glm5NextLayer::prefill`'s sub-chunk. Both of the
775    // narrow paths therefore keep the bit-identical GEMV arm, unchanged, and prefill — which
776    // has never been bit-identical to decode anyway, since the dense projections leave the
777    // batched GEMV above the same width — takes the GEMM.
778    //
779    // ðŸŠĪ The same test is why this is safe under CUDA graph capture: only decode is captured,
780    // and decode can never reach here, so the host read of `expert_offsets` inside
781    // `forward_moe_grouped_prefill` cannot land inside a capture.
782    //
783    // ðŸ”ī The width FLOOR is load-bearing, not caution. MEASURED at 5,400 tokens, matched
784    // arms on one binary — GEMV / grouped GEMM tok/s: 16 rows 63.05 / 25.14 (0.40x),
785    // 64 rows 77.79 / 57.71 (0.74x), 128 rows 80.73 / 88.17 (1.09x), 256 rows 82.14 /
786    // 128.10 (1.56x). The crossover is between 64 and 128. Without the floor, "default ON"
787    // would have regressed the shipping serve 2.5x, because PREFILL_ROWS defaults to 16.
788    let grouped_prefill = rows > MOE_ROW_BATCH_MAX_ROWS
789        && rows >= forward_prefill_gemm::prefill_gemm_min_rows()
790        && forward_prefill_gemm::prefill_gemm_enabled()
791        && !host_dispatch_forced()
792        // The route trace reads `ids` back PER ROW; the grouped path never materialises a
793        // per-row id list. Leave tracing on the arm that can serve it.
794        && !profile::trace_on()
795        && k.moe_sort_by_expert.0 != 0
796        && k.moe_grouped_gemm.0 != 0
797        && k.combine_indexed.0 != 0
798        && rows * cfg.top_k <= ws.max_total_expanded();
799    let batched = !grouped_prefill
800        && rows >= 2
801        && !host_dispatch_forced()
802        && !row_batch_disabled()
803        && !profile::trace_on()
804        && k.moe_row_union.0 != 0
805        && groups.iter().all(|&(_, w)| {
806            // ðŸŠĪ The union table is one block of `w * top_k` threads; past 64 ids it would
807            // silently drop entries. GLM-5.3 is 8 x 8 = 64 exactly, so this is a live edge.
808            w >= 2
809                && w * cfg.top_k <= MOE_ROW_UNION_MAX_IDS
810                && k.w4a16_gemv_sw_moe_batchm[w - 2].0 != 0
811        });
812    announce_row_batch(batched, rows);
813    announce_grouped_prefill(grouped_prefill, rows);
814
815    // ── router: FULL expert set, FP32 logits, replicated on every rank ──
816    let t = profile::start();
817    // ðŸ”ī ONE GEMM for every row. This was `rows` M=1 GEMVs — at 256 prefill rows x 42
818    // sparse layers, ~10,700 launches per chunk, each re-reading the ENTIRE router weight
819    // to produce one row of logits. The comment this replaces named the blocker exactly:
820    // "No FP32-out batchm twin exists". It exists now.
821    //
822    // The tile GEMM was no escape either: the router is the worst tile case in the stack,
823    // N = 288 tiling to 18 blocks at a measured 6.8 GB/s.
824    //
825    // ðŸŠĪ Router logits pick the experts, so reassociation can flip a top-k tie and route a
826    // token to a different expert. That is a real behaviour change, not just a numeric one,
827    // which is why it is gated and measured on answer quality rather than on tok/s alone.
828    // Bounded to prefill widths by the same M > 16 cut used everywhere: decode and the
829    // speculative verify stay on the bit-identical GEMV and route exactly as before.
830    if rows > crate::layers::ops::DENSE_GEMV_BATCHM_MAX_M as usize
831        && crate::layers::glm5next_layer::cublas_wide_proj()
832    {
833        crate::layers::ops::cublas_bf16_proj_dense_f32_out(
834            x,
835            w.router,
836            ws.logits,
837            rows as u32,
838            cfg.num_experts as u32,
839            cfg.hidden as u32,
840            stream,
841        )?;
842    } else {
843        for r in 0..rows {
844            gemm(
845                gpu,
846                k.gemm_f32,
847                k.gemv_f32,
848                // No FP32-out batchm twin at these widths; stays on gemv/tile.
849                KernelHandle(0),
850                x.offset(r * cfg.hidden * 2),
851                w.router,
852                ws.logits.offset(r * cfg.num_experts * 4),
853                1,
854                cfg.num_experts,
855                cfg.hidden,
856                stream,
857            )?;
858        }
859    }
860    // ONE top-k for every row. `glm5next_router_topk` already takes the row on `blockIdx.x`
861    // and strides `logits`/`ids`/`wts` by it, so this is the identical per-row work in one
862    // launch instead of K — and it was a `grid [1,1,1]` launch, 120 of them per K=3 step for
863    // 1.75 ms (nsys 2026-08-29). Bit-identical: no row's arithmetic changes.
864    KernelLaunch::new(gpu, k.router)
865        .grid([rows as u32, 1, 1])
866        .block([ACT_BLOCK, 1, 1])
867        .arg_ptr(ws.logits)
868        .arg_ptr(w.router_bias)
869        .arg_ptr(ws.ids)
870        .arg_ptr(ws.wts)
871        .arg_u32(cfg.num_experts as u32)
872        .arg_u32(cfg.top_k as u32)
873        // n_group: the parser already refuses anything but 1; the kernel refuses too.
874        .arg_u32(1)
875        .arg_f32(cfg.routed_scale)
876        .arg_u32(u32::from(cfg.renormalize))
877        .arg_u32(u32::from(cfg.router_bf16_ladder))
878        .launch(stream)?;
879    profile::end(profile::MOE_ROUTER, t, gpu, stream);
880
881    // ðŸŠĪ Zero FIRST. A slot this rank does not own must contribute exactly zero to the
882    // all-reduced sum; leaving the previous token's expert output there is a wrong answer
883    // that only appears at EP > 1 and only for tokens whose routing moved. `expert_out` is
884    // `[rows, top_k, hidden]` and contiguous, so one memset covers every row.
885    gpu.memset_async(ws.expert_out, 0, rows * cfg.top_k * cfg.hidden * 2, stream)?;
886
887    for r in 0..rows {
888        if batched || grouped_prefill {
889            break; // the experts run once for ALL rows, after this loop
890        }
891        let xr = x.offset(r * cfg.hidden * 2);
892        let ids_r = ws.ids.offset(r * cfg.top_k * 4);
893        let expert_out_r = ws.expert_out.offset(r * cfg.top_k * cfg.hidden * 2);
894
895        let grouped = !host_dispatch_forced() && k.w4a16_gemv_sw_moe.0 != 0;
896        announce_dispatch(grouped);
897        if grouped {
898            // ── grouped, device-dispatched: routing never leaves the GPU ──
899            //
900            // ðŸ”ī The host loop this replaces did `synchronize` + `copy_d2h(ids)` once per routed
901            // layer — 42 full stream drains per decode token on GLM-5.3, and the reason the
902            // decode step could not be graph-captured. It also issued one launch per LOCAL
903            // expert per projection (~16/layer); this is four, whatever the routing picks.
904            let t = profile::start();
905            let mi = cfg.moe_intermediate;
906            // gate and up: every slot reads the SAME x, so input_stride = 0.
907            w4a16_gemv_moe(
908                gpu,
909                k.w4a16_gemv_sw_moe,
910                xr,
911                &w.ptrs.gate,
912                ws.a_gate,
913                ids_r,
914                mi,
915                cfg.hidden,
916                cfg.top_k,
917                cfg.num_experts,
918                0,
919                stream,
920            )?;
921            w4a16_gemv_moe(
922                gpu,
923                k.w4a16_gemv_sw_moe,
924                xr,
925                &w.ptrs.up,
926                ws.a_up,
927                ids_r,
928                mi,
929                cfg.hidden,
930                cfg.top_k,
931                cfg.num_experts,
932                0,
933                stream,
934            )?;
935            // Elementwise over all slots at once. Remote slots activate uninitialised rows; the
936            // down projection skips them, so those rows are never read.
937            swiglu(
938                gpu,
939                k.swiglu,
940                ws.a_gate,
941                ws.a_up,
942                ws.a_act,
943                cfg.top_k * mi,
944                cfg.swiglu_limit,
945                stream,
946            )?;
947            // down: slot-major activations, so input_stride = one expert's width.
948            w4a16_gemv_moe(
949                gpu,
950                k.w4a16_gemv_sw_moe,
951                ws.a_act,
952                &w.ptrs.down,
953                expert_out_r,
954                ids_r,
955                cfg.hidden,
956                mi,
957                cfg.top_k,
958                cfg.num_experts,
959                mi,
960                stream,
961            )?;
962            profile::end(profile::MOE_EXPERTS, t, gpu, stream);
963
964            if profile::trace_on() {
965                let mut ids = vec![0u8; cfg.top_k * 4];
966                gpu.synchronize(stream)?;
967                gpu.copy_d2h(ids_r, &mut ids)?;
968                let decoded: Vec<i32> = (0..cfg.top_k)
969                    .map(|k| {
970                        i32::from_le_bytes([
971                            ids[k * 4],
972                            ids[k * 4 + 1],
973                            ids[k * 4 + 2],
974                            ids[k * 4 + 3],
975                        ])
976                    })
977                    .collect();
978                profile::stash_route(&decoded);
979            }
980        } else {
981            // ðŸšĐ A FULL STREAM SYNC + D2H IN THE MIDDLE OF EVERY MoE LAYER. The routing decision
982            // is read back to the host so the expert GEMMs can be launched by id. Timed on its own
983            // because it is the one span here that is pure latency and scales with layer count,
984            // not with weight bytes.
985            let t = profile::start();
986            let mut ids = vec![0u8; cfg.top_k * 4];
987            gpu.synchronize(stream)?;
988            gpu.copy_d2h(ids_r, &mut ids)?;
989            profile::end(profile::MOE_HOSTSYNC, t, gpu, stream);
990
991            if profile::trace_on() {
992                let decoded: Vec<i32> = (0..cfg.top_k)
993                    .map(|k| {
994                        i32::from_le_bytes([
995                            ids[k * 4],
996                            ids[k * 4 + 1],
997                            ids[k * 4 + 2],
998                            ids[k * 4 + 3],
999                        ])
1000                    })
1001                    .collect();
1002                profile::stash_route(&decoded);
1003            }
1004
1005            let t = profile::start();
1006            for slot in 0..cfg.top_k {
1007                let id = i32::from_le_bytes([
1008                    ids[slot * 4],
1009                    ids[slot * 4 + 1],
1010                    ids[slot * 4 + 2],
1011                    ids[slot * 4 + 3],
1012                ]);
1013                // -1 is the kernel's "slot unfilled" sentinel; it is reachable only if top_k exceeded
1014                // the expert count, which `validate` refuses. Treat it as zero rather than as an index.
1015                if id < 0 {
1016                    continue;
1017                }
1018                let id = id as usize;
1019                if id >= cfg.num_experts {
1020                    bail!(
1021                        "GLM MoE: router selected expert {id} of {}",
1022                        cfg.num_experts
1023                    );
1024                }
1025                let Some(local) = cfg.local_slot(id) else {
1026                    continue; // another rank owns it; its zero row is already in place.
1027                };
1028                let e = &w.experts[local];
1029                let dst = expert_out_r.offset(slot * cfg.hidden * 2);
1030                let mi = cfg.moe_intermediate;
1031                w4a16_gemv(
1032                    gpu,
1033                    k.w4a16_gemv,
1034                    k.w4a16_gemv_sw,
1035                    xr,
1036                    &e.gate_proj,
1037                    ws.a_gate,
1038                    mi,
1039                    cfg.hidden,
1040                    stream,
1041                )?;
1042                w4a16_gemv(
1043                    gpu,
1044                    k.w4a16_gemv,
1045                    k.w4a16_gemv_sw,
1046                    xr,
1047                    &e.up_proj,
1048                    ws.a_up,
1049                    mi,
1050                    cfg.hidden,
1051                    stream,
1052                )?;
1053                swiglu(
1054                    gpu,
1055                    k.swiglu,
1056                    ws.a_gate,
1057                    ws.a_up,
1058                    ws.a_act,
1059                    mi,
1060                    cfg.swiglu_limit,
1061                    stream,
1062                )?;
1063                w4a16_gemv(
1064                    gpu,
1065                    k.w4a16_gemv,
1066                    k.w4a16_gemv_sw,
1067                    ws.a_act,
1068                    &e.down_proj,
1069                    dst,
1070                    cfg.hidden,
1071                    mi,
1072                    stream,
1073                )?;
1074            }
1075
1076            profile::end(profile::MOE_EXPERTS, t, gpu, stream);
1077        }
1078    }
1079
1080    if grouped_prefill {
1081        // ONE launch per projection over the WHOLE row group — the 8-row cap does not apply.
1082        // Leaves the routed outputs in `ws.expert_out` in EXPERT-SORTED order; the combine
1083        // below reads them through `ws.token_to_perm`.
1084        let t = profile::start();
1085        forward_prefill_gemm::forward_moe_grouped_prefill(gpu, k, cfg, w, x, rows, ws, stream)?;
1086        profile::end(profile::MOE_EXPERTS, t, gpu, stream);
1087    }
1088
1089    if batched {
1090        let t = profile::start();
1091        let mi = cfg.moe_intermediate;
1092        // ONE sweep per sub-group. At `rows <= MOE_ROW_BATCH_MAX_ROWS` this is the single pass it
1093        // always was; a wider prefill sub-chunk runs it twice over disjoint row ranges, which is
1094        // byte-identical (see `moe_row_groups`) and keeps the routed experts at the tier width
1095        // that was actually measured.
1096        for &(r0, w_rows) in &groups {
1097            // The union table: one block, one thread per (row, slot) id. Stays on device.
1098            // ðŸŠĪ Rebuilt per sub-group over that group's slice of `ids` — the scratch is sized for
1099            // the widest group, and a later group overwrites the earlier one's table after its
1100            // sweeps have been issued on the same stream.
1101            KernelLaunch::new(gpu, k.moe_row_union)
1102                .grid([1, 1, 1])
1103                .block([(w_rows * cfg.top_k) as u32, 1, 1])
1104                .arg_ptr(ws.ids.offset(r0 * cfg.top_k * 4))
1105                .arg_ptr(ws.u_eid)
1106                .arg_ptr(ws.u_slot)
1107                .arg_u32(w_rows as u32)
1108                .arg_u32(cfg.top_k as u32)
1109                .launch(stream)?;
1110
1111            let kb = k.w4a16_gemv_sw_moe_batchm[w_rows - 2];
1112            // gate and up: a row's slots all read the SAME x, so the slot stride is 0.
1113            w4a16_gemv_moe_batchm(
1114                gpu,
1115                kb,
1116                x.offset(r0 * cfg.hidden * 2),
1117                &w.ptrs.gate,
1118                ws.a_gate.offset(r0 * cfg.top_k * mi * 2),
1119                ws.u_eid,
1120                ws.u_slot,
1121                mi,
1122                cfg.hidden,
1123                w_rows,
1124                cfg.top_k,
1125                cfg.num_experts,
1126                cfg.hidden,
1127                0,
1128                cfg.top_k * mi,
1129                stream,
1130            )?;
1131            w4a16_gemv_moe_batchm(
1132                gpu,
1133                kb,
1134                x.offset(r0 * cfg.hidden * 2),
1135                &w.ptrs.up,
1136                ws.a_up.offset(r0 * cfg.top_k * mi * 2),
1137                ws.u_eid,
1138                ws.u_slot,
1139                mi,
1140                cfg.hidden,
1141                w_rows,
1142                cfg.top_k,
1143                cfg.num_experts,
1144                cfg.hidden,
1145                0,
1146                cfg.top_k * mi,
1147                stream,
1148            )?;
1149            // Elementwise over every (row, slot) at once. Slots this rank does not own activate
1150            // uninitialised rows; the down projection skips them, so those rows are never read.
1151            swiglu(
1152                gpu,
1153                k.swiglu,
1154                ws.a_gate.offset(r0 * cfg.top_k * mi * 2),
1155                ws.a_up.offset(r0 * cfg.top_k * mi * 2),
1156                ws.a_act.offset(r0 * cfg.top_k * mi * 2),
1157                w_rows * cfg.top_k * mi,
1158                cfg.swiglu_limit,
1159                stream,
1160            )?;
1161            // down: slot-major activations, so the slot stride is one expert's width.
1162            w4a16_gemv_moe_batchm(
1163                gpu,
1164                kb,
1165                ws.a_act.offset(r0 * cfg.top_k * mi * 2),
1166                &w.ptrs.down,
1167                ws.expert_out.offset(r0 * cfg.top_k * cfg.hidden * 2),
1168                ws.u_eid,
1169                ws.u_slot,
1170                cfg.hidden,
1171                mi,
1172                w_rows,
1173                cfg.top_k,
1174                cfg.num_experts,
1175                cfg.top_k * mi,
1176                mi,
1177                cfg.top_k * cfg.hidden,
1178                stream,
1179            )?;
1180        }
1181        profile::end(profile::MOE_EXPERTS, t, gpu, stream);
1182    }
1183
1184    // ── shared expert: BF16, TP-sharded, NOT routed-scaled ──
1185    let t = profile::start();
1186    forward_dense(
1187        gpu,
1188        k,
1189        cfg,
1190        &w.shared,
1191        cfg.local_shared_intermediate,
1192        x,
1193        ws.shared_out,
1194        rows,
1195        ws,
1196        stream,
1197    )?;
1198
1199    // ðŸ”ī The combine runs BEFORE the all-reduce, so the TP-partial shared expert and the
1200    // EP-partial routed sum reduce together in one collective. Adding the shared output after
1201    // a reduce — the `layers::moe` pattern, written for a replicated shared expert — would
1202    // keep only this rank's half of it.
1203    profile::end(profile::MOE_SHARED, t, gpu, stream);
1204    let t = profile::start();
1205    // ONE combine for every row: `glm5next_moe_combine` takes the row on `blockIdx.x` and
1206    // strides all four buffers by it. Was K `grid [1,1,1]` launches — 1.50 ms of a K=3 step.
1207    //
1208    // ðŸŠĪ The grouped path's routed rows are EXPERT-SORTED, so it takes the `_indexed` twin —
1209    // same accumulation order, same single rounding, one extra indirection through
1210    // `token_to_perm`. Reading the sorted buffer with the plain kernel would silently combine
1211    // whichever tokens happened to land at `t * top_k + k`.
1212    if grouped_prefill {
1213        KernelLaunch::new(gpu, k.combine_indexed)
1214            .grid([rows as u32, 1, 1])
1215            .block([ACT_BLOCK, 1, 1])
1216            .arg_ptr(ws.expert_out)
1217            .arg_ptr(ws.token_to_perm)
1218            .arg_ptr(ws.wts)
1219            .arg_ptr(ws.shared_out)
1220            .arg_ptr(out)
1221            .arg_u32(cfg.hidden as u32)
1222            .arg_u32(cfg.top_k as u32)
1223            .launch(stream)?;
1224    } else {
1225        KernelLaunch::new(gpu, k.combine)
1226            .grid([rows as u32, 1, 1])
1227            .block([ACT_BLOCK, 1, 1])
1228            .arg_ptr(ws.expert_out)
1229            .arg_ptr(ws.wts)
1230            .arg_ptr(ws.shared_out)
1231            .arg_ptr(out)
1232            .arg_u32(cfg.hidden as u32)
1233            .arg_u32(cfg.top_k as u32)
1234            .launch(stream)?;
1235    }
1236    profile::end(profile::MOE_COMBINE, t, gpu, stream);
1237    Ok(())
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242    use super::{MOE_ROW_BATCH_MAX_ROWS, moe_row_groups};
1243
1244    /// The split must cover every row exactly once, in order, never exceed the tier width,
1245    /// and never emit a group of ONE — there is no `w4a16_gemv_sw_moe_batchm_m1`, so a
1246    /// trailing single row would silently drop the whole batched arm for that sub-chunk.
1247    #[test]
1248    fn row_groups_cover_and_never_orphan_a_row() {
1249        for cap in 1..=MOE_ROW_BATCH_MAX_ROWS {
1250            for rows in 1..=64 {
1251                let g = moe_row_groups(rows, cap);
1252                assert_eq!(g[0].0, 0, "rows={rows} cap={cap}: does not start at 0");
1253                let mut next = 0;
1254                for &(start, w) in &g {
1255                    assert_eq!(start, next, "rows={rows} cap={cap}: gap or overlap");
1256                    assert!(w >= 1 && w <= cap, "rows={rows} cap={cap}: width {w}");
1257                    // No orphan at the width that ships. At a small cap an all->=2 split can be
1258                    // arithmetically impossible (3 rows at cap 2), and the caller's per-group
1259                    // tier gate handles that by falling back — see the fn doc.
1260                    if rows >= 2 && cap == MOE_ROW_BATCH_MAX_ROWS {
1261                        assert!(w >= 2, "rows={rows} cap={cap}: orphaned a single row");
1262                    }
1263                    next += w;
1264                }
1265                assert_eq!(next, rows, "rows={rows} cap={cap}: {next} rows covered");
1266            }
1267        }
1268    }
1269
1270    /// The two widths this actually ships at.
1271    #[test]
1272    fn row_groups_at_the_shipping_widths() {
1273        assert_eq!(moe_row_groups(16, 8), vec![(0, 8), (8, 8)]);
1274        assert_eq!(moe_row_groups(8, 8), vec![(0, 8)]);
1275        assert_eq!(moe_row_groups(9, 8), vec![(0, 5), (5, 4)]);
1276    }
1277
1278    mod ws_sizing {
1279        use crate::layers::glm5next_mlp::Glm5NextMlpConfig;
1280        use crate::layers::glm5next_mlp::forward::{mlp_ws_bytes, mlp_ws_total_bytes};
1281
1282        /// The campaign shape: GLM-5.3-Flash at TP=2/EP=2, the topology every WS1 number is on.
1283        fn cfg() -> Glm5NextMlpConfig {
1284            Glm5NextMlpConfig {
1285                hidden: 4096,
1286                local_dense_intermediate: 12288 / 2,
1287                moe_intermediate: 2048,
1288                local_shared_intermediate: 2048 / 2,
1289                num_experts: 288,
1290                local_experts: 144,
1291                ep_rank: 0,
1292                top_k: 8,
1293                routed_scale: 2.5,
1294                renormalize: true,
1295                swiglu_limit: 10.0,
1296                router_bf16_ladder: false,
1297                tp_world_size: 2,
1298                ep_world_size: 2,
1299            }
1300        }
1301
1302        /// ðŸ”ī The number the shared workspace exists to stop multiplying by 45. Hand-checked
1303        /// term by term against `Glm5NextMlpWorkspace::new`, so a future edit to `new` that
1304        /// forgets `mlp_ws_bytes` (or vice versa) fails here instead of silently under-reporting
1305        /// the load-time footprint the host-memory guard is measured against (A124/A127).
1306        #[test]
1307        fn matches_the_hand_computed_campaign_footprint() {
1308            let c = cfg();
1309            // act_elems = max(rows*6144, rows*8*2048) = rows*16384 at every row count.
1310            for rows in [16usize, 64, 128, 256, 512, 1024] {
1311                let b = mlp_ws_bytes(&c, rows);
1312                assert_eq!(b[0], rows * 16384 * 2, "a_gate at {rows}");
1313                assert_eq!(b[1], b[0], "a_up at {rows}");
1314                assert_eq!(b[2], b[0], "a_act at {rows}");
1315                assert_eq!(b[3], rows * 288 * 4, "logits at {rows}");
1316                assert_eq!(b[6], rows * 8 * 4096 * 2, "expert_out at {rows}");
1317                assert_eq!(b[7], rows * 4096 * 2, "shared_out at {rows}");
1318                // ðŸ”ī QUADRATIC. At 1024 rows u_slot alone is 33.5 MB — a third of the growth
1319                // between 512 and 1024, and the term a linear mental model misses.
1320                assert_eq!(b[9], rows * 8 * rows * 4, "u_slot at {rows}");
1321                assert_eq!(b[12], 289 * 4, "expert_offsets is row-independent");
1322                // Linear part + quadratic part, derived once and checked at every width.
1323                assert_eq!(
1324                    mlp_ws_total_bytes(&c, rows),
1325                    rows * 173_376 + 32 * rows * rows + 1156
1326                );
1327            }
1328        }
1329
1330        /// The saving the ticket is about, stated as a number rather than an adjective.
1331        #[test]
1332        fn sharing_one_workspace_saves_44_of_45_copies() {
1333            let c = cfg();
1334            // ðŸŠĪ DECIMAL MB/GB, the same unit the load-path log prints — so a figure read off a
1335            // rank-0 log can be checked against this test without a silent MiB/MB conversion.
1336            let mb = |n: usize| n as f64 / 1e6;
1337            // 45 layers is GLM-5.3-Flash's `num_hidden_layers`.
1338            for (rows, per_layer_mb, stack_gb) in [
1339                (256usize, 46.48, 2.092),
1340                (512, 97.16, 4.372),
1341                (1024, 211.09, 9.499),
1342            ] {
1343                let one = mlp_ws_total_bytes(&c, rows);
1344                assert!(
1345                    (mb(one) - per_layer_mb).abs() < 0.05,
1346                    "rows={rows}: {:.2} MB per workspace, expected ≈{per_layer_mb}",
1347                    mb(one)
1348                );
1349                assert!(
1350                    (mb(one * 45) / 1000.0 - stack_gb).abs() < 0.01,
1351                    "rows={rows}: {:.3} GB for 45 private copies, expected ≈{stack_gb}",
1352                    mb(one * 45) / 1000.0
1353                );
1354            }
1355        }
1356
1357        /// A zero row count must not produce a zero-byte pool: `new` clamps to 1, and a pool of
1358        /// nothing is a pool that faults the first time anything touches it.
1359        #[test]
1360        fn zero_rows_clamps_to_one() {
1361            let c = cfg();
1362            assert_eq!(mlp_ws_total_bytes(&c, 0), mlp_ws_total_bytes(&c, 1));
1363            assert!(mlp_ws_bytes(&c, 0).iter().all(|&b| b > 0));
1364        }
1365    }
1366}