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}