spark_model/layers/moe/
forward_prefill.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! MoeLayer::forward_prefill.
4
5use super::*;
6
7impl MoeLayer {
8    /// N-token prefill via grouped GEMM: sort-by-expert → tensor-core GEMM per expert.
9    ///
10    /// Each expert's weight matrix is loaded once (not per-token), cutting LPDDR5X
11    /// reads from ~6 GB (GEMV) to ~150 MB (grouped GEMM) at N=1024.
12    ///
13    /// Pipeline: gate → topK → sort → grouped gate/up GEMM → SiLU → grouped down GEMM
14    ///           → unpermute + weighted reduce → shared expert blend.
15    /// Shared expert uses checkpoint-native BF16 when installed, otherwise W4A16.
16    #[allow(unused_assignments)]
17    pub fn forward_prefill(
18        &self,
19        input: DevicePtr, // [num_tokens, H] BF16 — normed MoE input
20        num_tokens: usize,
21        ctx: &ForwardContext,
22        stream: u64,
23    ) -> Result<()> {
24        // Native-HIP (gfx1151) has NO ported grouped-GEMM MoE path:
25        // moe_fp8_grouped_gemm is a compile stub (kernels/strix-hip/.../
26        // moe_fp8_grouped_gemm.cu writes nothing) and the grouped prefill
27        // pipeline launches additional kernels that are null on the HIP module
28        // set → cuLaunchKernel hipErrorInvalidHandle at layer 0 for any prefill
29        // chunk >64 tokens. forward_batched is the correct, complete per-token
30        // path (its kernels are all bit-exact-verified on HIP) — route there for
31        // ALL token counts on atlas_hip. SCALE keeps grouped (its symlinked
32        // grouped GEMM is real via PTX-recompile); NVIDIA byte-unchanged.
33        //
34        // EXCEPTION: the FP8 routed grouped GEMM (moe_fp8_grouped_gemm) has now
35        // been ported to HIP WMMA (kernels/strix-hip/common/moe_fp8_grouped_gemm.cu
36        // — weight-stationary per-expert, register-prefetch double-buffered, two-
37        // level FP32 block-scale accumulation matching the GB10/oracle numerics),
38        // so long FP8 prefills (>64 tokens) take the grouped path on atlas_hip too
39        // — amortizing the ~50 GB/layer per-token weight re-streaming of
40        // forward_batched. The BF16-dequant grouped GEMM is NOT ported (its
41        // strix-hip kernel is still absent), so its branch stays HIP-batched.
42        let hip_force_batched = cfg!(atlas_hip);
43        // FP8 grouped path is HIP-ready (kernel ported); do not force-batch it.
44        let hip_force_batched_fp8 = false;
45
46        // Feature-1 MoE LoRA: the router/expert fold is wired into ALL THREE
47        // grouped prefill bodies (nvfp4 below, bf16 in forward_prefill_bf16, fp8 in
48        // forward_prefill_fp8) — they write the same sorted BF16 `expert_down_out`,
49        // so one device kernel (`moe_lora_grouped_down`) folds every base. The
50        // SHORT-prefill / missing-grouped-kernel / HIP-forced fallback to
51        // `forward_batched` is NO LONGER uncovered (SOLID Incr-4): it folds the
52        // routed-expert gate/up + down delta PER TOKEN via
53        // `apply_expert_lora_decode_{gateup,down}`. A prefill `ForwardContext`
54        // always carries `moe_row_adapter == NULL` (only batched DECODE uploads a
55        // per-row map), so those hooks take the single-active fallback — the
56        // request-granularity `moe_route_gate` folds all `top_k` slots of every
57        // token, which is exactly `num_tokens` independent replays of the C=1
58        // decode fold (GPU-validated bit-clean). Hence NO refuse here: a
59        // single-active short prefill folds correctly, a base/non-active request
60        // (`Skip`) folds nothing and stays byte-identical, and the requests that
61        // still cannot be served refuse downstream in `forward_batched` at the
62        // correct granularity — the router (`mlp.gate`) delta now folds on the
63        // batched path via `apply_router_lora_batched` (SOLID Incr-4), and
64        // mixed/packed or non-active adapters refuse via `moe_route_gate` `Refuse`.
65        // (The device per-row prefill map for a MIXED short-prefill batch is the
66        // Incr-3 follow-up: `build_moe_row_adapter_host`, still refused for now.)
67
68        // BF16 experts (FP8-dequant-on-load path): same dispatch shape as
69        // FP8 — grouped GEMM for long prefills, fused per-token for short.
70        if self.bf16_gate_weight_ptrs.is_some() {
71            if self.moe_bf16_grouped_gemm_k.0 != 0 && num_tokens > 64 && !hip_force_batched {
72                return self.forward_prefill_bf16(input, num_tokens, ctx, stream);
73            }
74            return self.forward_batched(input, num_tokens, ctx, stream);
75        }
76
77        // FP8 experts: use grouped GEMM for long prefills (>64 tokens),
78        // fall back to per-token fused GEMV for short prefills where
79        // the GEMM launch overhead exceeds the bandwidth savings.
80        if self.fp8_gate_weight_ptrs.is_some() {
81            if self.moe_fp8_grouped_gemm_k.0 != 0 && num_tokens > 64 && !hip_force_batched_fp8 {
82                return self.forward_prefill_fp8(input, num_tokens, ctx, stream);
83            }
84            return self.forward_batched(input, num_tokens, ctx, stream);
85        }
86
87        // Lazy down_proj transpose: synchronous on the compute stream.
88        // (See `kick_off_lazy_transpose` for an attempted overlap path
89        // that regressed by 30 % on GB10 — SM contention dominated the
90        // overlap savings, so the synchronous path is the shipped one.)
91        let _t_xpose = if ctx.profile && self.down_t_scratch_packed.is_some() {
92            ctx.gpu.synchronize(stream)?;
93            Some(std::time::Instant::now())
94        } else {
95            None
96        };
97        self.transpose_down_into_scratch(ctx, stream)?;
98        if let Some(t0) = _t_xpose {
99            ctx.gpu.synchronize(stream)?;
100            tracing::info!(
101                "  MoE prefill [lazy_transpose_down] N={}: {}µs",
102                num_tokens,
103                t0.elapsed().as_micros(),
104            );
105        }
106
107        let h = ctx.config.hidden_size as u32;
108        let inter = ctx.config.moe_intermediate_size as u32;
109        let shared_inter = ctx.config.shared_expert_intermediate_size as u32;
110        let num_experts = ctx.config.num_experts as u32;
111        let top_k = ctx.config.num_experts_per_tok as u32;
112        let n = num_tokens as u32;
113        let total_expanded = n * top_k;
114
115        // Profile helper macro
116        #[allow(unused_macros)]
117        macro_rules! prof {
118            ($label:expr) => {
119                if ctx.profile {
120                    ctx.gpu.synchronize(stream)?;
121                    let _t = std::time::Instant::now();
122                    tracing::info!("  MoE prefill [{}] N={}", $label, num_tokens);
123                }
124            };
125        }
126        #[allow(unused_assignments)]
127        let mut t0 = if ctx.profile {
128            ctx.gpu.synchronize(stream)?;
129            Some(std::time::Instant::now())
130        } else {
131            None
132        };
133        macro_rules! prof_step {
134            ($label:expr) => {
135                if let Some(t) = t0.take() {
136                    ctx.gpu.synchronize(stream)?;
137                    let elapsed = t.elapsed().as_micros();
138                    tracing::info!("  MoE prefill [{}] N={}: {}µs", $label, num_tokens, elapsed);
139                    t0 = Some(std::time::Instant::now());
140                }
141            };
142        }
143
144        // ── Shared expert on secondary stream (overlaps with routed path) ──
145        // Shared expert only reads `input` and writes to separate buffers
146        // (ssm_deinterleaved, ssm_qkvz, attn_output) — no data conflict
147        // with the routed expert path.  In profile mode, run sequentially
148        // on the default stream for accurate per-step timing.
149        //
150        // Skip entirely when shared_inter == 0 (models without a shared expert,
151        // e.g. Qwen3-VL-30B which has no shared_expert_intermediate_size).
152        // Launching kernels with N=0 produces CUDA_ERROR_INVALID_VALUE (grid.x=0).
153        let has_shared = shared_inter > 0;
154        let use_overlap = false; // disabled: dual-stream contention worsens LPDDR5X bandwidth
155        let aux = if use_overlap {
156            self.prefill_stream
157        } else {
158            stream
159        };
160
161        if has_shared {
162            self.run_shared_expert_prefill(
163                input,
164                n,
165                h,
166                shared_inter,
167                aux,
168                stream,
169                use_overlap,
170                ctx,
171            )?;
172        }
173        prof_step!("shared_expert");
174
175        // ── Routed expert path on default stream ──
176
177        // Gemma-4 router pre-norm (no-op for other models).
178        let router_in = self.router_input(input, n, h, ctx, stream)?;
179        super::dump::dump_gate_input(ctx.gpu, stream, router_in, n, h)?;
180        // 1. Gate GEMM: [N, H] × [H, num_experts] → [N, num_experts]
181        let gate_logits = ctx.buffers.gate_logits();
182        if let Some(fp8) = self.gate_fp8 {
183            ops::fp8_gemm_n128(
184                ctx.gpu,
185                self.fp8_gemm_k,
186                router_in,
187                fp8,
188                gate_logits,
189                n,
190                // = num_experts everywhere except LongCat (zero-expert logits).
191                self.router_logits_n,
192                h,
193                stream,
194            )?;
195        } else if let Some(ref nvfp4) = self.gate_nvfp4 {
196            ops::w4a16_gemm(
197                ctx.gpu,
198                self.w4a16_gemm,
199                router_in,
200                nvfp4,
201                gate_logits,
202                n,
203                self.router_logits_n,
204                h,
205                stream,
206            )?;
207        } else {
208            // Selection numerics — see router_gate_gemm_dense for why this
209            // must stay on the scalar kernel and why ATLAS_CUBLAS_GEMM must
210            // not reroute it either (2026-08-12 BFCL regression: a rerouted
211            // router GEMM flips top-k on borderline tokens deterministically).
212            self.router_gate_gemm_dense(
213                router_in,
214                gate_logits,
215                n,
216                self.router_logits_n,
217                h,
218                ctx,
219                stream,
220            )?;
221        }
222        super::dump::dump_gate_logits(ctx.gpu, stream, gate_logits, n, num_experts)?;
223        prof_step!("gate_gemm");
224
225        // Feature-1: fold the router (`mlp.gate`) LoRA delta onto the routing
226        // logits BEFORE top-k (reproduces PEFT `mlp.gate`). No-op unless a router
227        // delta is installed (ATLAS_LORA_EXPERTS=1).
228        self.apply_router_lora_prefill(router_in, gate_logits, n, ctx, stream)?;
229
230        // 2. Batched topK dispatch. DeepSeek-V3 / MiniMax-M2 use sigmoid
231        //    + correction bias (detected via `correction_bias_dev`);
232        //    every other model takes the softmax path (no behavior
233        //    change — this is additive).
234        let scratch = ctx.buffers.scratch();
235        let indices_dev = scratch;
236        let weights_dev = scratch.offset(total_expanded as usize * 4);
237        if let Some(tid2eid) = self.tid2eid_dev {
238            // DeepSeek-V4 hash routing (hash_moe layer): static
239            // `tid2eid[token_id]` selection, sqrtsoftplus-weighted.
240            let token_ids = ctx.token_ids.ok_or_else(|| {
241                anyhow::anyhow!(
242                    "DeepSeek-V4 hash-MoE layer requires ForwardContext.token_ids (prefill grouped)"
243                )
244            })?;
245            ops::moe_hash_route_batched(
246                ctx.gpu,
247                self.moe_hash_route_batched_k,
248                gate_logits,
249                tid2eid,
250                token_ids,
251                indices_dev,
252                weights_dev,
253                num_experts,
254                top_k,
255                ctx.config.norm_topk_prob,
256                ctx.config.routed_scaling_factor as f32,
257                n,
258                stream,
259            )?;
260        } else if let Some(bias) = self.correction_bias_dev {
261            // DeepSeek-V4 scores experts with sqrtsoftplus (NOT sigmoid); the
262            // bias selects experts, weights gather pre-bias scores. Other
263            // sigmoid+bias models (DeepSeek-V3 / MiniMax-M2) keep sigmoid.
264            if ctx.config.scoring_func == "sqrtsoftplus" {
265                ops::moe_topk_sqrtsoftplus_batched(
266                    ctx.gpu,
267                    self.moe_topk_sqrtsoftplus_batched_k,
268                    gate_logits,
269                    bias,
270                    indices_dev,
271                    weights_dev,
272                    num_experts,
273                    top_k,
274                    ctx.config.norm_topk_prob,
275                    ctx.config.routed_scaling_factor as f32,
276                    n,
277                    stream,
278                )?;
279            } else if ctx.config.scoring_func == "softmax" {
280                self.router_softmax_bias_batched(
281                    gate_logits,
282                    bias,
283                    indices_dev,
284                    weights_dev,
285                    num_experts,
286                    top_k,
287                    n,
288                    ctx,
289                    stream,
290                )?;
291            } else {
292                ops::moe_topk_sigmoid_batched(
293                    ctx.gpu,
294                    self.moe_topk_sigmoid_batched_k,
295                    gate_logits,
296                    bias,
297                    indices_dev,
298                    weights_dev,
299                    num_experts,
300                    top_k,
301                    ctx.config.norm_topk_prob,
302                    ctx.config.routed_scaling_factor as f32,
303                    n,
304                    stream,
305                )?;
306            }
307        } else {
308            ops::moe_topk_softmax_batched(
309                ctx.gpu,
310                self.moe_topk_batched,
311                gate_logits,
312                indices_dev,
313                weights_dev,
314                num_experts,
315                top_k,
316                ctx.config.norm_topk_prob,
317                n,
318                stream,
319            )?;
320        }
321        super::dump::dump_expert_ids(ctx.gpu, stream, indices_dev, weights_dev, n, top_k)?;
322        prof_step!("topk");
323
324        // 3. Sort tokens by expert → L2-optimized ordering.
325        let te = total_expanded as usize;
326        let ne = num_experts as usize;
327        let sorted_token_ids = gate_logits;
328        let sorted_expert_ids = gate_logits.offset(te * 4);
329        let expert_offsets = gate_logits.offset(te * 4 * 2);
330        let token_to_perm = gate_logits.offset(te * 4 * 2 + (ne + 1) * 4);
331        ops::moe_sort_by_expert(
332            ctx.gpu,
333            self.moe_sort_by_expert,
334            indices_dev,
335            sorted_token_ids,
336            sorted_expert_ids,
337            expert_offsets,
338            token_to_perm,
339            total_expanded,
340            num_experts,
341            top_k,
342            stream,
343        )?;
344        prof_step!("sort");
345
346        // 3.5. Pre-expert norm: norm the input for expert dispatch (Gemma-4 26B).
347        // Router already used the raw input for routing; now norm for experts.
348        // IMPORTANT: write to scratch (ssm_deinterleaved), NOT in-place — `input` is
349        // the residual and must be preserved for the subsequent residual add.
350        let expert_input = if let Some(ref norm_w) = self.pre_expert_norm {
351            let normed_buf = ctx.buffers.ssm_deinterleaved();
352            let n_tokens = num_tokens as u32;
353            let eps = ctx.config.rms_norm_eps as f32;
354            ops::rms_norm(
355                ctx.gpu,
356                self.pre_expert_norm_k,
357                input,
358                norm_w,
359                normed_buf,
360                n_tokens,
361                h,
362                eps,
363                stream,
364            )?;
365            normed_buf
366        } else {
367            input
368        };
369        prof_step!("pre_expert_norm");
370
371        // 4-6. Routed grouped-GEMM phase (grid sizing → grouped gate+up
372        // GEMM → SiLU → grouped down GEMM). Hoisted to forward_prefill_routed.rs
373        // to keep this file under the 500 LoC cap; behavior identical.
374        self.run_routed_grouped_gemm(
375            expert_input,
376            expert_offsets,
377            sorted_token_ids,
378            n,
379            h,
380            inter,
381            num_experts,
382            top_k,
383            num_tokens,
384            ne,
385            &mut t0,
386            ctx,
387            stream,
388        )?;
389        let expert_down_out = ctx.buffers.expert_down_out();
390
391        // Feature-1: fold the routed-expert down_proj LoRA deltas onto the sorted
392        // `expert_down_out` BEFORE the unpermute + weighted reduce, so the router
393        // weight multiplies base+delta (PEFT semantics). x = the post-SiLU sorted
394        // activations. No-op unless routed-expert deltas are installed.
395        self.apply_expert_lora_prefill_down(
396            ctx.buffers.expert_gate_out(),
397            expert_down_out,
398            expert_offsets,
399            sorted_token_ids,
400            total_expanded,
401            ctx,
402            stream,
403        )?;
404
405        // 7. Unpermute + weighted reduce: scatter sorted outputs to token order
406        let output = ctx.buffers.moe_output();
407        ops::moe_unpermute_reduce_indexed(
408            ctx.gpu,
409            self.moe_unpermute_reduce,
410            expert_down_out,
411            output,
412            token_to_perm,
413            weights_dev,
414            h,
415            n,
416            top_k,
417            stream,
418        )?;
419
420        // 8. Blend shared expert: output += sigmoid(dot(input, gate)) * shared
421        // Skip when has_shared == false (no shared expert in this model config).
422        // EP fix: defer shared expert blend until AFTER all-reduce to avoid doubling.
423        let is_ep_prefill = ctx.comm.is_some() && ctx.config.ep_world_size > 1;
424        if has_shared && !is_ep_prefill {
425            let shared_down_out = ctx.buffers.attn_output();
426            if use_overlap {
427                ctx.gpu.stream_wait_event(stream, self.event_b)?;
428            }
429            super::dump::dump_routed_only(ctx.gpu, stream, output, n, h)?;
430            super::dump::dump_shared_out(ctx.gpu, stream, shared_down_out, n, h)?;
431            super::dump::dump_shared_gate(
432                ctx.gpu,
433                stream,
434                input,
435                self.weights.shared_expert_gate.weight,
436                n,
437                h,
438            )?;
439            ops::moe_batched_blend(
440                ctx.gpu,
441                self.moe_batched_blend,
442                output,
443                shared_down_out,
444                input,
445                self.weights.shared_expert_gate.weight,
446                h,
447                n,
448                stream,
449            )?;
450        }
451        super::dump::dump_moe_out(ctx.gpu, stream, output, n, h)?;
452        prof_step!("unpermute_blend");
453
454        // EP all-reduce
455        if let Some(comm) = ctx.comm
456            && ctx.config.ep_world_size > 1
457        {
458            let _t0 = if ctx.profile {
459                ctx.gpu.synchronize(stream)?;
460                Some(std::time::Instant::now())
461            } else {
462                None
463            };
464            if ctx.graph_capture {
465                comm.all_reduce(output.0, num_tokens * h as usize * 2)?;
466            } else {
467                comm.all_reduce_async(output.0, num_tokens * h as usize * 2, stream)?;
468            }
469            if let Some(t0) = _t0 {
470                ctx.gpu.synchronize(stream)?;
471                tracing::info!(
472                    "  EP allreduce (moe out) N={}: {}µs",
473                    num_tokens,
474                    t0.elapsed().as_micros(),
475                );
476            }
477            // Add shared expert ONCE after all-reduce (prevents EP doubling)
478            if has_shared {
479                let shared_down_out = ctx.buffers.attn_output();
480                if use_overlap {
481                    ctx.gpu.stream_wait_event(stream, self.event_b)?;
482                }
483                ops::moe_batched_blend(
484                    ctx.gpu,
485                    self.moe_batched_blend,
486                    output,
487                    shared_down_out,
488                    input,
489                    self.weights.shared_expert_gate.weight,
490                    h,
491                    n,
492                    stream,
493                )?;
494            }
495        }
496
497        Ok(())
498    }
499}