spark_model/layers/moe/helpers_c.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Shared-expert precision setup, predequantization, and router input.
4
5use super::*;
6
7impl MoeLayer {
8 /// Pre-dequant dense (non-expert) NVFP4 weights to FP8 for zero-overhead prefill.
9 ///
10 /// Only affects gate GEMM and shared expert GEMMs. Expert weights stay NVFP4
11 /// (they're bandwidth-bound so FP8 wouldn't help).
12 pub fn predequant_for_prefill(
13 &mut self,
14 gpu: &dyn GpuBackend,
15 config: &atlas_core::config::ModelConfig,
16 stream: u64,
17 ) -> Result<()> {
18 let h = config.hidden_size;
19 let shared_inter = config.shared_expert_intermediate_size;
20 let num_experts = config.num_experts;
21 let predequant_k = gpu.kernel("w4a16", "predequant_nvfp4_to_fp8")?;
22
23 // Pre-dequant gate weight: [num_experts, H] → FP8 [num_experts, H]
24 if let Some(ref nvfp4) = self.gate_nvfp4 {
25 self.gate_fp8 =
26 Some(nvfp4.predequant_to_fp8(gpu, predequant_k, num_experts, h, stream)?);
27 }
28
29 // A checkpoint-native BF16 shared expert is the authoritative copy.
30 // Do not manufacture an FP8 prefill variant with different numerics.
31 if self.bf16_shared_expert.is_none()
32 && !self.weights.shared_expert.gate_proj.is_null()
33 && shared_inter > 0
34 {
35 self.shared_gate_fp8 = Some(self.weights.shared_expert.gate_proj.predequant_to_fp8(
36 gpu,
37 predequant_k,
38 shared_inter,
39 h,
40 stream,
41 )?);
42 self.shared_up_fp8 = Some(self.weights.shared_expert.up_proj.predequant_to_fp8(
43 gpu,
44 predequant_k,
45 shared_inter,
46 h,
47 stream,
48 )?);
49 self.shared_down_fp8 = Some(self.weights.shared_expert.down_proj.predequant_to_fp8(
50 gpu,
51 predequant_k,
52 h,
53 shared_inter,
54 stream,
55 )?);
56 }
57
58 Ok(())
59 }
60
61 /// Set FP8 expert weights for native FP8 dispatch.
62 ///
63 /// Builds device-side pointer tables from FP8 expert weights so the
64 /// fused FP8 MoE kernel can index by expert_id at dispatch time.
65 /// Also stores the shared expert FP8 weights for direct pointer passing.
66 pub fn set_fp8_experts(
67 &mut self,
68 experts: &[Fp8ExpertWeight],
69 shared_expert: Fp8ExpertWeight,
70 gpu: &dyn GpuBackend,
71 ) -> Result<()> {
72 self.fp8_gate_weight_ptrs = Some(build_fp8_ptr_table(experts, |e| &e.gate_proj, gpu)?);
73 self.fp8_up_weight_ptrs = Some(build_fp8_ptr_table(experts, |e| &e.up_proj, gpu)?);
74 self.fp8_down_weight_ptrs = Some(build_fp8_ptr_table(experts, |e| &e.down_proj, gpu)?);
75 self.fp8_shared_expert = Some(shared_expert);
76 Ok(())
77 }
78
79 /// Set BF16 expert weights for the FP8-dequant-on-load MoE path.
80 ///
81 /// Activated by `ATLAS_FP8_DEQUANT_MOE_TO_BF16=1`. Eliminates the per-layer
82 /// 0.989 FP8 cosine ceiling (measured in bench/fp8_dgx2_drift/cosine_run.py)
83 /// by serving experts as BF16 throughout, matching vLLM-BF16 reference
84 /// numerics. Memory cost: 2× expert weights vs native FP8.
85 ///
86 /// `shared_*` are the shared expert's BF16 gate/up/down DevicePtrs (or
87 /// `DevicePtr::NULL` when the model has no shared expert).
88 pub fn set_bf16_experts(
89 &mut self,
90 gate_experts: &[crate::weight_map::DenseWeight],
91 up_experts: &[crate::weight_map::DenseWeight],
92 down_experts: &[crate::weight_map::DenseWeight],
93 shared_gate: DevicePtr,
94 shared_up: DevicePtr,
95 shared_down: DevicePtr,
96 gpu: &dyn GpuBackend,
97 ) -> Result<()> {
98 use super::build_bf16_ptr_table;
99 self.bf16_gate_weight_ptrs = Some(build_bf16_ptr_table(gate_experts, gpu)?);
100 self.bf16_up_weight_ptrs = Some(build_bf16_ptr_table(up_experts, gpu)?);
101 self.bf16_down_weight_ptrs = Some(build_bf16_ptr_table(down_experts, gpu)?);
102 if shared_gate.is_null() && shared_up.is_null() && shared_down.is_null() {
103 self.bf16_shared_expert = None;
104 } else {
105 self.set_bf16_shared_expert(
106 DenseWeight {
107 weight: shared_gate,
108 },
109 DenseWeight { weight: shared_up },
110 DenseWeight {
111 weight: shared_down,
112 },
113 )?;
114 }
115 Ok(())
116 }
117
118 /// Install checkpoint-native BF16 shared-expert weights independently of
119 /// routed-expert precision.
120 pub fn set_bf16_shared_expert(
121 &mut self,
122 gate_proj: DenseWeight,
123 up_proj: DenseWeight,
124 down_proj: DenseWeight,
125 ) -> Result<()> {
126 self.bf16_shared_expert = Some(Bf16SharedExpert::new(gate_proj, up_proj, down_proj)?);
127 Ok(())
128 }
129
130 /// Whether a BF16 shared expert must overwrite the contribution produced
131 /// by a quantized fused routed-expert kernel.
132 pub(super) fn has_mixed_bf16_shared_expert(&self) -> bool {
133 self.bf16_shared_expert.is_some() && self.bf16_gate_weight_ptrs.is_none()
134 }
135
136 /// Evaluate a checkpoint-native BF16 shared expert into `down_out`.
137 ///
138 /// Callers supply scratch buffers because the safe aliases differ between
139 /// decode and prefill. Returns `true` when BF16 weights were installed.
140 #[allow(clippy::too_many_arguments)]
141 pub(super) fn run_bf16_shared_expert(
142 &self,
143 input: DevicePtr,
144 num_tokens: u32,
145 hidden_size: u32,
146 shared_intermediate: u32,
147 gate_out: DevicePtr,
148 up_out: DevicePtr,
149 down_out: DevicePtr,
150 ctx: &ForwardContext,
151 stream: u64,
152 ) -> Result<bool> {
153 let Some(shared) = self.bf16_shared_expert else {
154 return Ok(false);
155 };
156 anyhow::ensure!(
157 num_tokens > 0 && shared_intermediate > 0,
158 "BF16 shared expert requires non-zero token and intermediate dimensions"
159 );
160
161 let project = |activation: DevicePtr,
162 weight: &DenseWeight,
163 output: DevicePtr,
164 n: u32,
165 k: u32|
166 -> Result<()> {
167 if num_tokens == 1 {
168 ops::dense_gemv(
169 ctx.gpu,
170 self.dense_gemv,
171 activation,
172 weight,
173 output,
174 n,
175 k,
176 stream,
177 )
178 } else if ctx.dispatch.cublas_gemm {
179 // Multi-token shared expert: cuBLASLt BF16 beats the hand-written
180 // mma.sync GEMM. The single-token arm above stays on the GEMV —
181 // decode-sized shapes do not repay cuBLAS heuristic overhead.
182 ops::cublas_bf16_proj_dense(
183 activation,
184 weight.weight,
185 output,
186 num_tokens,
187 n,
188 k,
189 stream,
190 )
191 } else {
192 ops::dense_gemm_prefill(
193 ctx.gpu,
194 self.dense_gemm,
195 self.dense_gemm_pipelined,
196 activation,
197 weight,
198 output,
199 num_tokens,
200 n,
201 k,
202 stream,
203 )
204 }
205 };
206
207 project(
208 input,
209 &shared.gate_proj,
210 gate_out,
211 shared_intermediate,
212 hidden_size,
213 )?;
214 project(
215 input,
216 &shared.up_proj,
217 up_out,
218 shared_intermediate,
219 hidden_size,
220 )?;
221 ops::silu_mul(
222 ctx.gpu,
223 self.moe_act_mul,
224 gate_out,
225 up_out,
226 gate_out,
227 num_tokens * shared_intermediate,
228 stream,
229 )?;
230 project(
231 gate_out,
232 &shared.down_proj,
233 down_out,
234 hidden_size,
235 shared_intermediate,
236 )?;
237 Ok(true)
238 }
239
240 /// Router gate GEMM for a BF16 (dense) gate weight at prefill:
241 /// `gate_logits[n, num_experts] = router_in @ gate^T`.
242 ///
243 /// PINNED to scalar-ORDER numerics, never a reassociating fast GEMM.
244 /// Router logits are selection inputs, not data: top-k reads them after a
245 /// BF16 store, where near-tied experts sit 1 ulp apart, so ANY change in
246 /// accumulation order flips selections on borderline tokens and the flip
247 /// compounds through every downstream layer. Rerouting this one GEMM to
248 /// `dense_gemm_bf16_pipelined` (mma.sync accumulation, "cosine=1.0" but
249 /// not bit-identical) moved BFCL on the FP8 MoE flagship from 86.55 to
250 /// 84.76 overall — deterministic, reproduced to the hundredth on two
251 /// trees (2026-08-12, 03a74eac19 / cb9f8ecab4) — while every dense-model
252 /// gate stayed inside noise. The routed-expert GEMMs tolerate order
253 /// changes; the router does not.
254 ///
255 /// `dense_gemm_bf16_router` satisfies the pin: it keeps the scalar
256 /// kernel's exact per-output FP32 fma chain in strict k = 0..K-1 order
257 /// (only blocking/vectorization differ) and is verified BIT-IDENTICAL to
258 /// `dense_gemm_bf16` under the production `--fmad=false` build (0
259 /// differing elements over [4510,2048]x[2048,256] and
260 /// [2255,2048]x[2048,256]) at ~2x the speed — the router GEMM is 40
261 /// layers x ~3.2 ms of cold prefill (the real payload of the mprof
262 /// "sort_by_expert" bucket), so the 2x is ~65 ms per 4.5k-token prefill.
263 /// Falls back to the scalar kernel when the variant is absent from a
264 /// model's gemm module.
265 #[allow(clippy::too_many_arguments)]
266 pub(super) fn router_gate_gemm_dense(
267 &self,
268 router_in: DevicePtr,
269 gate_logits: DevicePtr,
270 num_tokens: u32,
271 num_experts: u32,
272 hidden_size: u32,
273 ctx: &ForwardContext,
274 stream: u64,
275 ) -> Result<()> {
276 if self.dense_gemm_router.0 != 0 {
277 return ops::dense_gemm_router(
278 ctx.gpu,
279 self.dense_gemm_router,
280 router_in,
281 &self.weights.gate,
282 gate_logits,
283 num_tokens,
284 num_experts,
285 hidden_size,
286 stream,
287 );
288 }
289 ops::dense_gemm(
290 ctx.gpu,
291 self.dense_gemm,
292 router_in,
293 &self.weights.gate,
294 gate_logits,
295 num_tokens,
296 num_experts,
297 hidden_size,
298 stream,
299 )
300 }
301
302 /// Apply the router pre-normalization (Gemma-4 only) and return the
303 /// pointer that should be fed into the gate GEMV. If the MoE has no
304 /// router_pre_norm weight, this is a no-op and returns `input` unchanged.
305 ///
306 /// HF Gemma4TextRouter computes:
307 /// router_input = rms_norm(x) * scale * hidden_size^(-0.5)
308 /// We fused `scale * root_size` into a single BF16 weight at load time
309 /// so the existing rms_norm kernel applies both steps in one pass.
310 ///
311 /// The normed output is written to `ctx.buffers.qkv_output()` which is
312 /// free at MoE time (the attention block already consumed qkv_output).
313 pub(super) fn router_input(
314 &self,
315 input: DevicePtr,
316 num_tokens: u32,
317 h: u32,
318 ctx: &ForwardContext,
319 stream: u64,
320 ) -> Result<DevicePtr> {
321 let Some(ref weight) = self.weights.router_pre_norm else {
322 return Ok(input);
323 };
324 let eps = ctx.config.rms_norm_eps as f32;
325 let normed = ctx.buffers.qkv_output();
326 ops::rms_norm(
327 ctx.gpu,
328 self.pre_expert_norm_k,
329 input,
330 weight,
331 normed,
332 num_tokens,
333 h,
334 eps,
335 stream,
336 )?;
337 Ok(normed)
338 }
339}