spark_model/layers/nemotron_moe/prefill_weights.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `NemotronMoeLayer` prefill-side weight preparation: the transposed-NVFP4 /
4//! pre-dequantized-FP8 / FP4 expert-weight copies and the dense-GEMM prefill
5//! dispatcher. Split from `nemotron_moe.rs` (500-LoC cap).
6
7use anyhow::Result;
8use atlas_core::config::ModelConfig;
9use spark_runtime::gpu::{DevicePtr, GpuBackend};
10
11use super::NemotronMoeLayer;
12use super::build_ptr_table_from_weights;
13use crate::layers::ops;
14use crate::weight_map::DenseWeight;
15
16impl NemotronMoeLayer {
17 /// Dense BF16 GEMM for the prefill path.
18 ///
19 /// Prefers the pipelined tensor-core kernel and falls back to the scalar
20 /// `dense_gemm_bf16` only if it is not compiled for this target. Single
21 /// source of truth for the three dense GEMMs of a LatentMoE layer (gate,
22 /// fc1_latent, fc2_latent).
23 #[allow(clippy::too_many_arguments)]
24 pub(super) fn dense_gemm_prefill(
25 &self,
26 gpu: &dyn GpuBackend,
27 input: DevicePtr,
28 weight: &DenseWeight,
29 output: DevicePtr,
30 m: u32,
31 n: u32,
32 k: u32,
33 stream: u64,
34 ) -> Result<()> {
35 if self.dense_gemm_pipelined_k.0 != 0 {
36 ops::dense_gemm_bf16_pipelined(
37 gpu,
38 self.dense_gemm_pipelined_k,
39 input,
40 weight,
41 output,
42 m,
43 n,
44 k,
45 stream,
46 )
47 } else {
48 ops::dense_gemm(
49 gpu,
50 self.dense_gemm_k,
51 input,
52 weight,
53 output,
54 m,
55 n,
56 k,
57 stream,
58 )
59 }
60 }
61
62 /// Transpose expert weights for fast grouped GEMM prefill.
63 /// Called from weight loader after construction. Skips expert transposition
64 /// when memory is tight (Super 120B: 128 experts × 40 layers would OOM).
65 pub fn prepare_prefill_weights(&mut self, gpu: &dyn GpuBackend, config: &ModelConfig) {
66 let h = config.hidden_size;
67 let inter = self.moe_inter;
68 let shared_inter = config.shared_expert_intermediate_size;
69
70 // Only transpose routed experts for small models (Nano 30B: 23 MoE layers × 128 experts).
71 // Super 120B has 40 MoE layers × 128 experts = 5120 matrices — too much memory.
72 // The sorted grouped GEMM still works with non-transposed weights via the base kernel.
73 if self.moe_latent_size == 0 {
74 let expert_k = h;
75 let mut up_t = Vec::new();
76 let mut down_t = Vec::new();
77 for expert in &self.weights.experts {
78 if let Ok(ut) = expert.up_proj.transpose_for_gemm(gpu, inter, expert_k) {
79 up_t.push(ut);
80 }
81 if let Ok(dt) = expert.down_proj.transpose_for_gemm(gpu, expert_k, inter) {
82 down_t.push(dt);
83 }
84 }
85 if up_t.len() == self.weights.experts.len()
86 && let Ok(ptrs) = build_ptr_table_from_weights(&up_t, gpu)
87 {
88 self.up_ptrs_t = Some(ptrs);
89 }
90 if down_t.len() == self.weights.experts.len()
91 && let Ok(ptrs) = build_ptr_table_from_weights(&down_t, gpu)
92 {
93 self.down_ptrs_t = Some(ptrs);
94 }
95 }
96
97 // Transpose the shared expert weights unconditionally.
98 //
99 // This is only TWO matrices per layer (shared_up, shared_down), unlike the
100 // routed experts above (512 per layer). It was previously gated behind the
101 // same `moe_latent_size == 0` memory guard, which lumped a ~cheap transpose
102 // in with the expensive one and left every LatentMoE layer on the base
103 // `w4a16_gemm`. On Puzzle the shared-expert GEMMs were a large slice of
104 // prefill; the transposed copies unlock `w4a16_gemm_t` (FP8 MMA, N128/K32,
105 // cp.async) for them. `.ok()` keeps the base GEMM as the fallback.
106 //
107 // Same idea as the SSM projections: pre-dequantize to FP8 E4M3 once at load so
108 // prefill runs `fp8_gemm_t_m128_mfast` (no dequant phase, M on the fast grid
109 // axis). But unlike the SSM ones this is OPT-IN, because it is a real trade:
110 //
111 // off (default) : 1k TTFT 490 ms, decode 33.3 tok/s <- decode at baseline
112 // on : 1k TTFT 450 ms, decode 32.6 tok/s <- -2.4% decode
113 //
114 // The ~2.1 GB of extra resident weights (shared_up/down + fc1/fc2) costs ~2%
115 // of decode, which is memory-bandwidth-bound on this box. Same-binary A/B,
116 // 10 runs each, verified on a cold server (not thermal). The SSM copies
117 // (4.3 GB, allocated during the load itself) cost nothing measurable, so the
118 // two are gated separately. Set ATLAS_SHARED_FP8_PREFILL=1 to take the trade.
119 // Under native FP8 the NVFP4 shared weights are `QuantizedWeight::null()`
120 // and every derived copy below is built FROM them, so build nothing.
121 let native_shared =
122 self.weights.shared_up_fp8.is_some() || self.weights.shared_down_fp8.is_some();
123 let fp8_prefill = !native_shared && std::env::var("ATLAS_SHARED_FP8_PREFILL").is_ok();
124 if fp8_prefill
125 && self.fp8_gemm_m128_k.0 != 0
126 && let Ok(pdq_k) = gpu.kernel("w4a16", "predequant_nvfp4_to_fp8")
127 {
128 self.shared_up_pd_fp8 = self
129 .weights
130 .shared_up
131 .predequant_to_fp8(gpu, pdq_k, shared_inter, h, 0)
132 .ok();
133 self.shared_down_pd_fp8 = self
134 .weights
135 .shared_down
136 .predequant_to_fp8(gpu, pdq_k, h, shared_inter, 0)
137 .ok();
138 }
139 if !native_shared && (self.shared_up_pd_fp8.is_none() || self.shared_down_pd_fp8.is_none())
140 {
141 self.shared_up_t = self
142 .weights
143 .shared_up
144 .transpose_for_gemm(gpu, shared_inter, h)
145 .ok();
146 self.shared_down_t = self
147 .weights
148 .shared_down
149 .transpose_for_gemm(gpu, h, shared_inter)
150 .ok();
151 }
152
153 // fc1/fc2 latent are BF16 dense and were the only prefill GEMMs still on
154 // dense_gemm_bf16_pipelined. Converting them to FP8 E4M3 both halves their
155 // bytes and moves them onto fp8_gemm_t_m128_mfast.
156 let lat = self.moe_latent_size;
157 if lat > 0
158 && fp8_prefill
159 && self.fp8_gemm_m128_k.0 != 0
160 && let Ok(b2f) = gpu.kernel("w4a16", "bf16_to_fp8")
161 {
162 let conv = |w: &DenseWeight, n: usize, k: usize| -> Option<DevicePtr> {
163 let dst = gpu.alloc(n * k).ok()?;
164 crate::layers::ops::bf16_to_fp8(gpu, b2f, w.weight, dst, (n * k) as u32, 0).ok()?;
165 gpu.synchronize(0).ok()?;
166 Some(dst)
167 };
168 self.fc1_pd_fp8 = self
169 .weights
170 .fc1_latent_proj
171 .as_ref()
172 .and_then(|w| conv(w, lat, h));
173 self.fc2_pd_fp8 = self
174 .weights
175 .fc2_latent_proj
176 .as_ref()
177 .and_then(|w| conv(w, h, lat));
178 }
179 }
180}