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;