metrale_model_arch/nemotron_moe/
prefill_weights.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: `NemotronMoeLayer` prefill-side weight preparation (the
4//! transposed-NVFP4 expert and shared-expert copies, and the FP8 shared-expert
5//! and latent-projection copies) and the dense-GEMM prefill dispatcher.
6//!
7//! Owner: model-arch (Nemotron-H).
8//! Invariants:
9//! - `prepare_prefill_weights` never fails: a copy that cannot be built
10//!   leaves its field `None` and prefill uses the next arm.
11
12use anyhow::Result;
13use metrale_config::ModelConfig;
14use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
15
16use super::NemotronMoeLayer;
17use super::build_ptr_table_from_weights;
18use metrale_model_layers::layers::ops;
19use metrale_model_layers::weight_map::DenseWeight;
20
21impl NemotronMoeLayer {
22    /// 2026-09-25: Dense BF16 GEMM for prefill: `dense_gemm_bf16_pipelined`
23    /// when it resolved, else `dense_gemm_bf16`. Used for the gate GEMM and,
24    /// without FP8 copies, the fc1 / fc2 latent GEMMs.
25    #[allow(clippy::too_many_arguments)]
26    pub(super) fn dense_gemm_prefill(
27        &self,
28        gpu: &dyn GpuBackend,
29        input: DevicePtr,
30        weight: &DenseWeight,
31        output: DevicePtr,
32        m: u32,
33        n: u32,
34        k: u32,
35        stream: u64,
36    ) -> Result<()> {
37        if self.dense_gemm_pipelined_k.0 != 0 {
38            ops::dense_gemm_bf16_pipelined(
39                gpu,
40                self.dense_gemm_pipelined_k,
41                input,
42                weight,
43                output,
44                m,
45                n,
46                k,
47                stream,
48            )
49        } else {
50            ops::dense_gemm(
51                gpu,
52                self.dense_gemm_k,
53                input,
54                weight,
55                output,
56                m,
57                n,
58                k,
59                stream,
60            )
61        }
62    }
63
64    /// 2026-09-29: Build the prefill-only weight copies; the loader calls it
65    /// after construction. Routed experts are transposed only when
66    /// `moe_latent_size == 0`. `shared_e4m3` (`--nemotron-shared-expert-e4m3`) builds the
67    /// shared expert's transposed copies, which the E4M3 tile GEMM reads; without it the
68    /// shared expert runs the BF16-MMA `w4a16_gemm` on the checkpoint's NVFP4 weights.
69    pub fn prepare_prefill_weights(
70        &mut self,
71        gpu: &dyn GpuBackend,
72        config: &ModelConfig,
73        shared_e4m3: bool,
74    ) {
75        let h = config.hidden_size;
76        let inter = self.moe_inter;
77        let shared_inter = config.shared_expert_intermediate_size;
78
79        // 2026-09-25: A table is installed only when every expert transposed;
80        // without it the sorted path uses the base grouped GEMM.
81        if self.moe_latent_size == 0 {
82            let expert_k = h;
83            let mut up_t = Vec::new();
84            let mut down_t = Vec::new();
85            for expert in &self.weights.experts {
86                if let Ok(ut) = expert.up_proj.transpose_for_gemm(gpu, inter, expert_k) {
87                    up_t.push(ut);
88                }
89                if let Ok(dt) = expert.down_proj.transpose_for_gemm(gpu, expert_k, inter) {
90                    down_t.push(dt);
91                }
92            }
93            if up_t.len() == self.weights.experts.len()
94                && let Ok(ptrs) = build_ptr_table_from_weights(&up_t, gpu)
95            {
96                self.up_ptrs_t = Some(ptrs);
97            }
98            if down_t.len() == self.weights.experts.len()
99                && let Ok(ptrs) = build_ptr_table_from_weights(&down_t, gpu)
100            {
101                self.down_ptrs_t = Some(ptrs);
102            }
103        }
104
105        // 2026-09-29: The shared expert's copies, in every layer shape: with
106        // `METRALE_SHARED_FP8_PREFILL` set (any value) and
107        // `fp8_gemm_t_m128_mfast` resolved, pre-dequantized FP8 copies; otherwise,
108        // or if either failed, transposed NVFP4 copies, and only under `shared_e4m3`:
109        // the tile GEMM that reads them casts activations and dequantized weights to
110        // E4M3 unscaled (saturating at 448; 1-30% local error measured on
111        // Nemotron-3-Nano), so it is opt-in. Under native FP8 the NVFP4 shared weights
112        // are `QuantizedWeight::null()` and every copy here is derived from them, so
113        // none is built.
114        let native_shared =
115            self.weights.shared_up_fp8.is_some() || self.weights.shared_down_fp8.is_some();
116        let fp8_prefill = !native_shared && std::env::var("METRALE_SHARED_FP8_PREFILL").is_ok();
117        if fp8_prefill
118            && self.fp8_gemm_m128_k.0 != 0
119            && let Ok(pdq_k) = gpu.kernel("w4a16", "predequant_nvfp4_to_fp8")
120        {
121            self.shared_up_pd_fp8 = self
122                .weights
123                .shared_up
124                .predequant_to_fp8(gpu, pdq_k, shared_inter, h, 0)
125                .ok();
126            self.shared_down_pd_fp8 = self
127                .weights
128                .shared_down
129                .predequant_to_fp8(gpu, pdq_k, h, shared_inter, 0)
130                .ok();
131        }
132        if !native_shared
133            && shared_e4m3
134            && (self.shared_up_pd_fp8.is_none() || self.shared_down_pd_fp8.is_none())
135        {
136            self.shared_up_t = self
137                .weights
138                .shared_up
139                .transpose_for_gemm(gpu, shared_inter, h)
140                .ok();
141            self.shared_down_t = self
142                .weights
143                .shared_down
144                .transpose_for_gemm(gpu, h, shared_inter)
145                .ok();
146        }
147
148        // 2026-09-25: Under the same `fp8_prefill` gate, FP8 E4M3 copies of the
149        // BF16 fc1 / fc2 latent projections for `fp8_gemm_t_m128_mfast`.
150        let lat = self.moe_latent_size;
151        if lat > 0
152            && fp8_prefill
153            && self.fp8_gemm_m128_k.0 != 0
154            && let Ok(b2f) = gpu.kernel("w4a16", "bf16_to_fp8")
155        {
156            let conv = |w: &DenseWeight, n: usize, k: usize| -> Option<DevicePtr> {
157                let dst = gpu.alloc(n * k).ok()?;
158                metrale_model_layers::layers::ops::bf16_to_fp8(
159                    gpu,
160                    b2f,
161                    w.weight,
162                    dst,
163                    (n * k) as u32,
164                    0,
165                )
166                .ok()?;
167                gpu.synchronize(0).ok()?;
168                Some(dst)
169            };
170            self.fc1_pd_fp8 = self
171                .weights
172                .fc1_latent_proj
173                .as_ref()
174                .and_then(|w| conv(w, lat, h));
175            self.fc2_pd_fp8 = self
176                .weights
177                .fc2_latent_proj
178                .as_ref()
179                .and_then(|w| conv(w, h, lat));
180        }
181    }
182}
183
184#[cfg(test)]
185#[path = "prefill_weights_tests.rs"]
186mod tests;