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}