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}