metrale_model_layers/layers/moe/forward_fp8_grouped_decode.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: `MoeLayer::forward_fp8_grouped_decode`: the FP8 MoE of `m` rows in
4//! one routed+shared expert dispatch.
5//!
6//! Rows are grouped by expert with `moe_sort_by_expert`, and the grouped
7//! kernels (`ops/fp8_moe_grouped.rs`) read each active expert's weights once
8//! for all the rows routed to it. Callers: multi-sequence decode
9//! (`qwen3_attention/trait_impl/multi_seq/ffn.rs`), multi-sequence and
10//! batched SSM decode (`qwen3_ssm/trait_decode_multi_seq.rs`,
11//! `qwen3_ssm/trait_decode_batched.rs`) and the MTP drafter
12//! (`mtp_head/forward_batch_ffn.rs`).
13//!
14//! Given the same routing, the output equals a per-row loop of `forward`'s FP8
15//! kernels bit for bit; `model-arch/examples/fp8_moe_grouped_decode_microtest.rs`
16//! checks this for M = 2, 3, 4, 8, 16 and 32 with the routing fed to both.
17//! The routing here is the batched gate GEMM and `moe_topk_*_batched`, which
18//! may choose differently from the per-token GEMV and top-k on a near-tie.
19//!
20//! Owner: model-layers (MoE).
21//! Invariants: `forward_fp8_grouped_decode` launches nothing unless
22//! `fp8_grouped_decode_ok(m, ctx)` holds.
23
24use super::*;
25
26/// 2026-09-25: Widest row count the scalar expert kernels admit. Above it `forward_prefill`
27/// takes its FP8 grouped GEMM when that kernel resolved.
28pub const FP8_GROUPED_DECODE_MAX_ROWS: usize = 64;
29
30/// 2026-09-29: Widest row count the tensor-core expert kernels admit: one verify of 128
31/// sequences at one draft (`--max-batch-size 128`, k = 1). A row's bits do not depend on the
32/// row count there (`fp8_grouped_tc.rs`), so every decode and verify width up to it keeps the
33/// arithmetic of one row, where `forward_prefill` (W8A8) and the per-row verify loop took the
34/// rows above 64 before.
35pub const FP8_GROUPED_DECODE_TC_MAX_ROWS: usize = 256;
36
37/// 2026-09-25: The grouped decode's kernels, looked up with `try_kernel`. A zero
38/// handle among the first four declines the path in
39/// [`MoeLayer::fp8_grouped_decode_arena_ok`]; a zero `topk_rows` or `router_gemv`
40/// (2026-09-26) declines only the exact routings in
41/// [`MoeLayer::fp8_grouped_routing_ok`].
42pub(super) struct GroupedKernels {
43 pub gate_up: KernelHandle,
44 pub silu_down: KernelHandle,
45 pub blend: KernelHandle,
46 pub sort: KernelHandle,
47 pub topk_rows: KernelHandle,
48 pub router_gemv: KernelHandle,
49}
50
51impl GroupedKernels {
52 /// 2026-09-25: One direct `try_kernel` call per kernel: `try_kernel` is
53 /// `#[track_caller]`, so each lookup keeps its own line in the audit.
54 pub(super) fn resolve(gpu: &dyn GpuBackend) -> Self {
55 use super::super::try_kernel;
56 const FUSED: &str = "moe_shared_expert_fused_fp8_grouped";
57 Self {
58 gate_up: try_kernel(gpu, FUSED, "moe_expert_gate_up_act_fp8_grouped"),
59 silu_down: try_kernel(gpu, FUSED, "moe_expert_down_act_fp8_grouped"),
60 blend: try_kernel(
61 gpu,
62 "moe_fp8_grouped_blend",
63 "moe_weighted_sum_blend_fp8_grouped",
64 ),
65 sort: try_kernel(gpu, "moe_fp8_grouped_sort", "moe_fp8_grouped_sort"),
66 topk_rows: try_kernel(gpu, "moe_topk", "moe_topk_softmax_rows"),
67 router_gemv: try_kernel(gpu, "dense_gemv_bf16_batchm", "dense_gemv_bf16_batchm"),
68 }
69 }
70}
71
72/// 2026-09-25: `METRALE_NO_FP8_MOE_GROUPED_DECODE`, set to any value, turns this
73/// path off. Read once per process. It covers the MTP drafter, which is on by
74/// default, and the target model's multi-row decode, which also needs the
75/// `ModelLevers::moe_fp8_grouped_decode_target` lever
76/// (`FfnComponent::fp8_grouped_decode_ok`). 2026-09-26: It also turns off the
77/// exact routings (`GroupedRouting::PerRow` / `PerToken`), which are on by default.
78fn fp8_grouped_decode_enabled() -> bool {
79 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
80 *ON.get_or_init(|| std::env::var_os("METRALE_NO_FP8_MOE_GROUPED_DECODE").is_none())
81}
82
83/// 2026-09-25: Shape admission for the scalar grouped kernels, without a GPU:
84/// `fp8_grouped_decode_rows_ok(m, false)` and `fp8_grouped_decode_dims_ok`.
85pub fn fp8_grouped_decode_shape_ok(m: usize, hidden: u32, inter: u32) -> bool {
86 fp8_grouped_decode_rows_ok(m, false) && fp8_grouped_decode_dims_ok(hidden, inter)
87}
88
89/// 2026-09-29: The row envelope of the grouped decode: `2..=FP8_GROUPED_DECODE_MAX_ROWS` for
90/// the scalar expert kernels, `1..=FP8_GROUPED_DECODE_TC_MAX_ROWS` for the tensor-core ones
91/// (`tensor_core`), whose one-row bits equal the batched bits.
92pub fn fp8_grouped_decode_rows_ok(m: usize, tensor_core: bool) -> bool {
93 if tensor_core {
94 (1..=FP8_GROUPED_DECODE_TC_MAX_ROWS).contains(&m)
95 } else {
96 (2..=FP8_GROUPED_DECODE_MAX_ROWS).contains(&m)
97 }
98}
99
100/// 2026-09-29: The width terms of the grouped kernels:
101/// * `hidden % 16 == 0`: the gate/up kernel reads weights in 16-element chunks.
102/// * `inter % 8 == 0`: the down kernel reads weights in 8-element chunks and the
103/// SiLU product rows as `float4`.
104pub fn fp8_grouped_decode_dims_ok(hidden: u32, inter: u32) -> bool {
105 hidden >= 16 && hidden.is_multiple_of(16) && inter >= 8 && inter.is_multiple_of(8)
106}
107
108/// 2026-09-25: Bytes this path needs in each arena buffer it borrows, so a batch
109/// wider than the arena is refused rather than written past a buffer's end.
110pub(crate) struct GroupedDecodeBufferNeed {
111 pub scratch: usize,
112 pub gate_logits: usize,
113 pub expert_gate_out: usize,
114 pub expert_down_out: usize,
115 pub shared_act: usize,
116 pub row_hidden: usize,
117}
118
119pub(crate) fn grouped_decode_buffer_need(
120 m: usize,
121 hidden: usize,
122 inter: usize,
123 num_experts: usize,
124 top_k: usize,
125) -> GroupedDecodeBufferNeed {
126 let te = m * top_k;
127 GroupedDecodeBufferNeed {
128 // 2026-09-25: indices `[te]` u32 + weights `[te]` f32.
129 scratch: 2 * te * 4,
130 // 2026-09-25: max(gate logits `[m, E]` BF16, sort scratch:
131 // sorted_token_ids `[te]` + sorted_expert_ids `[te]` + expert_offsets
132 // `[E+1]` + token_to_perm `[te]` + active_experts `[cap]` +
133 // active_count `[1]`), with `cap = min(te, E)`.
134 gate_logits: (m * num_experts * 2)
135 .max(te * 4 * 3 + (num_experts + 1) * 4 + (te.min(num_experts) + 1) * 4),
136 // 2026-09-26: The routed SiLU product `[te, inter]` FP32.
137 expert_gate_out: te * inter * 4,
138 expert_down_out: te * hidden * 2,
139 // 2026-09-26: The shared-expert SiLU product `[m, inter]` FP32, in `logits()`.
140 shared_act: m * inter * 4,
141 // 2026-09-25: Shared-expert down output and `moe_output`, `[m, hidden]` BF16.
142 row_hidden: m * hidden * 2,
143 }
144}
145
146impl MoeLayer {
147 /// 2026-09-25: The weight, kernel, shape and arena terms of
148 /// [`Self::fp8_grouped_decode_ok`], decidable without a `ForwardContext`,
149 /// so the MTP batch cap (`mtp_head/batch_caps.rs`) can size a batch. Not
150 /// sufficient alone: the kill switch, the FP32-routing lever and expert
151 /// parallelism are checked only by the full predicate.
152 pub fn fp8_grouped_decode_arena_ok(
153 &self,
154 m: usize,
155 cfg: &metrale_config::ModelConfig,
156 b: &metrale_gpu_runtime::buffers::BufferArena,
157 ) -> bool {
158 let h = cfg.hidden_size;
159 let inter = cfg.moe_intermediate_size;
160 let need =
161 grouped_decode_buffer_need(m, h, inter, cfg.num_experts, cfg.num_experts_per_tok);
162 // 2026-09-28: One row too under the tensor-core expert kernels, whose row bits do not
163 // depend on the row count (`fp8_grouped_tc.rs`). 2026-09-29: And up to
164 // `FP8_GROUPED_DECODE_TC_MAX_ROWS` rows.
165 fp8_grouped_decode_rows_ok(m, self.fp8_grouped_tc_on(h, inter))
166 && fp8_grouped_decode_dims_ok(h as u32, inter as u32)
167 && self.fp8_gate_weight_ptrs.is_some()
168 && self.fp8_up_weight_ptrs.is_some()
169 && self.fp8_down_weight_ptrs.is_some()
170 && self.fp8_shared_expert.is_some()
171 && self.moe_expert_gate_up_act_fp8_grouped_k.0 != 0
172 && self.moe_expert_down_act_fp8_grouped_k.0 != 0
173 && self.moe_weighted_sum_blend_fp8_grouped_k.0 != 0
174 && self.moe_fp8_grouped_sort_k.0 != 0
175 && cfg.num_experts <= ops::FP8_GROUPED_SORT_MAX_EXPERTS as usize
176 // 2026-09-25: This path has no LoRA fold hooks.
177 && self.lora.is_none()
178 && self.pre_expert_norm.is_none()
179 && self.tid2eid_dev.is_none()
180 && !self.has_mixed_bf16_shared_expert()
181 // 2026-09-25: Zero-expert routers are not served here.
182 && self.router_logits_n as usize == cfg.num_experts
183 // 2026-09-25: The bias arm below runs `moe_topk_sigmoid_batched`, so a
184 // sqrtsoftplus router is refused.
185 && !(self.correction_bias_dev.is_some() && cfg.scoring_func == "sqrtsoftplus")
186 && b.scratch_bytes() >= need.scratch
187 && b.gate_logits_bytes() >= need.gate_logits
188 && b.expert_gate_out_bytes() >= need.expert_gate_out
189 && b.expert_down_out_bytes() >= need.expert_down_out
190 && b.logits_bytes() >= need.shared_act
191 && b.attn_output_bytes() >= need.row_hidden
192 && b.moe_output_bytes() >= need.row_hidden
193 }
194
195 /// 2026-09-25: Whether `forward_fp8_grouped_decode` serves `m` rows on this
196 /// layer under `ctx`: [`Self::fp8_grouped_decode_arena_ok`], the kill
197 /// switch, FP32 routing off and no expert parallelism.
198 pub fn fp8_grouped_decode_ok(&self, m: usize, ctx: &ForwardContext) -> bool {
199 let cfg = ctx.config;
200 fp8_grouped_decode_enabled()
201 && self.fp8_grouped_decode_arena_ok(m, cfg, ctx.buffers)
202 && !self.fp32_routing_active(ctx.levers)
203 && !(ctx.comm.is_some() && cfg.ep_world_size > 1)
204 }
205
206 /// 2026-09-25: The FP8 MoE of `m` rows: `input` is `[m, H]` BF16, the output
207 /// lands in rows `0..m` of `moe_output()`. Returns an error, launching
208 /// nothing, when `fp8_grouped_decode_ok(m, ctx)` is false. Routes with
209 /// [`GroupedRouting::Batched`].
210 pub fn forward_fp8_grouped_decode(
211 &self,
212 input: DevicePtr,
213 m: usize,
214 ctx: &ForwardContext,
215 stream: u64,
216 ) -> Result<()> {
217 self.forward_fp8_grouped_decode_routed(input, m, GroupedRouting::Batched, ctx, stream)
218 }
219
220 /// 2026-09-26: [`Self::forward_fp8_grouped_decode`] with the router arithmetic
221 /// `routing` names. Returns an error, launching nothing, when
222 /// `fp8_grouped_routing_ok(m, routing, ctx)` is false.
223 pub fn forward_fp8_grouped_decode_routed(
224 &self,
225 input: DevicePtr,
226 m: usize,
227 routing: GroupedRouting,
228 ctx: &ForwardContext,
229 stream: u64,
230 ) -> Result<()> {
231 anyhow::ensure!(
232 self.fp8_grouped_routing_ok(m, routing, ctx),
233 "forward_fp8_grouped_decode: predicate false for m={m}, {routing:?} (caller must gate on it)"
234 );
235 let (Some(gp), Some(up), Some(dp), Some(sh)) = (
236 &self.fp8_gate_weight_ptrs,
237 &self.fp8_up_weight_ptrs,
238 &self.fp8_down_weight_ptrs,
239 &self.fp8_shared_expert,
240 ) else {
241 unreachable!("fp8_grouped_decode_ok checked the FP8 tables");
242 };
243 let h = ctx.config.hidden_size as u32;
244 let inter = ctx.config.moe_intermediate_size as u32;
245 let num_experts = ctx.config.num_experts as u32;
246 let top_k = ctx.config.num_experts_per_tok as u32;
247 let n = m as u32;
248 let te = m * top_k as usize;
249
250 let log_key = match routing {
251 GroupedRouting::Batched => "log:moe_fp8_grouped_decode",
252 GroupedRouting::PerRow => "log:moe_fp8_grouped_decode_per_row",
253 GroupedRouting::PerToken => "log:moe_fp8_grouped_decode_per_token",
254 };
255 if ctx.stats.once(log_key) {
256 let family = if self.fp8_grouped_tc_on(h as usize, inter as usize) {
257 "tensor-core"
258 } else {
259 "scalar"
260 };
261 tracing::info!(
262 "MoE FP8 grouped decode: cross-row batched routed+shared expert dispatch \
263 active (first use M={m}, top_k={top_k}, experts={num_experts}, \
264 routing={routing:?}, expert kernels {family}; one-time log)"
265 );
266 }
267
268 // 2026-09-25: 1-2. Router and top-k: indices `[m*top_k]` u32, weights
269 // `[m*top_k]` f32, with the arithmetic `routing` names.
270 let scratch = ctx.buffers.scratch();
271 let indices_dev = scratch;
272 let weights_dev = scratch.offset(te * 4);
273 self.grouped_route(input, m, routing, indices_dev, weights_dev, ctx, stream)?;
274 let gate_logits = ctx.buffers.gate_logits();
275
276 // 2026-09-25: 3. Group slots by expert. Top-k has read `gate_logits`
277 // earlier on the same stream, so the sort scratch reuses it.
278 let ne = num_experts as usize;
279 let sorted_token_ids = gate_logits;
280 let sorted_expert_ids = gate_logits.offset(te * 4);
281 let expert_offsets = gate_logits.offset(te * 4 * 2);
282 let token_to_perm = gate_logits.offset(te * 4 * 2 + (ne + 1) * 4);
283 // 2026-09-25: 4. The active experts go to a list of fixed capacity `cap`
284 // (a function of `m`, so the grids are the same for a captured graph),
285 // then gate+up, silu+down and blend. 2026-09-27: One launch sorts the
286 // slots and builds the list.
287 let cap = ops::fp8_grouped_active_cap(n, top_k, num_experts);
288 let active_experts = token_to_perm.offset(te * 4);
289 let active_count = active_experts.offset(cap as usize * 4);
290 ops::moe_fp8_grouped_sort(
291 ctx.gpu,
292 self.moe_fp8_grouped_sort_k,
293 ops::Fp8GroupedSortOut {
294 sorted_token_ids,
295 sorted_expert_ids,
296 expert_offsets,
297 token_to_perm,
298 active_experts,
299 active_count,
300 },
301 indices_dev,
302 te as u32,
303 num_experts,
304 top_k,
305 stream,
306 )?;
307 super::dump::dump_grouped_active(ctx.gpu, stream, active_count, m, ctx.graph_capture)?;
308 // 2026-09-26: The SiLU products are FP32: routed `[te, inter]` in
309 // `expert_gate_out()`, shared `[m, inter]` in `logits()`
310 // (`grouped_decode_buffer_need`); the W8A8 step keeps its E4M3 products there too.
311 let experts = self.fp8_grouped_expert_kernels(h as usize, inter as usize);
312 let act = ctx.buffers.expert_gate_out();
313 let expert_down_out = ctx.buffers.expert_down_out();
314 let shared_act = ctx.buffers.logits();
315 let shared_out = ctx.buffers.attn_output();
316 let output = ctx.buffers.moe_output();
317
318 // 2026-09-28: The opt-in W8A8 expert step (`fp8_grouped_tc_w8a8.rs`).
319 if self.fp8_grouped_tc_w8a8_on(h as usize, inter as usize) {
320 let io = super::fp8_grouped_tc_w8a8::GroupedExpertIo {
321 input,
322 act,
323 shared_act,
324 expert_down_out,
325 shared_out,
326 rows: ops::Fp8GroupedW8a8Rows {
327 expert_offsets,
328 sorted_token_ids,
329 active_experts,
330 active_count,
331 cap,
332 num_tokens: n,
333 },
334 };
335 self.run_fp8_grouped_w8a8(&io, ctx, stream)?;
336 } else {
337 ops::moe_expert_gate_up_act_fp8_grouped(
338 ctx.gpu,
339 experts.gate_up,
340 experts.gate_up_geometry,
341 input,
342 gp.weight_ptrs,
343 gp.scale_ptrs,
344 up.weight_ptrs,
345 up.scale_ptrs,
346 act,
347 expert_offsets,
348 sorted_token_ids,
349 active_experts,
350 active_count,
351 &sh.gate_proj,
352 &sh.up_proj,
353 shared_act,
354 inter,
355 h,
356 cap,
357 n,
358 stream,
359 )?;
360 ops::moe_expert_down_act_fp8_grouped(
361 ctx.gpu,
362 experts.down,
363 experts.down_geometry,
364 act,
365 dp.weight_ptrs,
366 dp.scale_ptrs,
367 expert_down_out,
368 expert_offsets,
369 active_experts,
370 active_count,
371 shared_act,
372 &sh.down_proj,
373 shared_out,
374 h,
375 inter,
376 cap,
377 n,
378 stream,
379 )?;
380 }
381 ops::moe_weighted_sum_blend_fp8_grouped(
382 ctx.gpu,
383 self.moe_weighted_sum_blend_fp8_grouped_k,
384 output,
385 expert_down_out,
386 weights_dev,
387 token_to_perm,
388 shared_out,
389 input,
390 self.weights.shared_expert_gate.weight,
391 h,
392 top_k,
393 h,
394 n,
395 stream,
396 )
397 }
398}
399
400#[cfg(test)]
401#[path = "forward_fp8_grouped_decode_tests.rs"]
402mod tests;