metrale_model_arch/weight_loader/qwen35_dense/
fp8_residency.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Which derived weight copies the native-FP8 dense route builds,
4//! and a tally of the derived bytes the loader kept, skipped and freed.
5//!
6//! Owner: model-arch weight loader (Qwen3.5 dense).
7//! Invariants:
8//! - `DenseFp8Plan::resolve` reads no environment and allocates nothing.
9//! - With `keep_nvfp4` set, the plan builds every NVFP4 copy, and every FP8
10//!   twin on a layer with an FP8 attention overlay.
11//!
12//! With the native FP8 overlay installed, the dense FFN reads no NVFP4
13//! weight: `DenseFfnLayer::forward` and `forward_prefill_inner` return inside
14//! their `fp8_weights` arm, `forward_k2`, `forward_k3` and `forward_km` send an
15//! FP8 layer to `forward_prefill` (`native_small_batch_uses_prefill`), and the
16//! prefill `w8_gemm!` is given `None` for every transposed operand, so each of
17//! its arms reads the untransposed E4M3 weight. `METRALE_FFN_W8A16_ONLY` only
18//! turns off the W8A8 arm of that macro, so it is not an input here.
19//!
20//! The loader runs before a `ForwardContext` exists, so the route comes from
21//! `GemmDispatch::from_env`, the constructor the model uses for the context's
22//! dispatch. A lever that can send a native-FP8 layer to an NVFP4 kernel must
23//! be an input of [`DenseFp8Plan::resolve`] as well as of that dispatch site,
24//! or the loader skips a copy the site reads. `METRALE_DENSE_FP8_KEEP_NVFP4`
25//! builds every copy while such a gap is diagnosed.
26
27use metrale_model_layers::layers::ops::GemmDispatch;
28use metrale_model_layers::layers::qwen3_attention::Fp8TwinSet;
29
30/// 2026-09-25: Whether `METRALE_DENSE_FP8_KEEP_NVFP4` is set, which makes the
31/// plan build every NVFP4 copy even where the dispatch cannot reach it.
32/// Presence, like `METRALE_FFN_W8A16_ONLY`: any value, including empty and
33/// `0`, turns it on. Read once per process.
34pub fn keep_nvfp4_fallback() -> bool {
35    static KEEP: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
36    *KEEP.get_or_init(|| std::env::var_os("METRALE_DENSE_FP8_KEEP_NVFP4").is_some())
37}
38
39/// 2026-09-25: Which derived copies one dense layer builds beyond the
40/// checkpoint bytes. Each field means "build this copy".
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct DenseFp8Plan {
43    /// 2026-09-25: The NVFP4 gate/up/down weights and their transposed twins
44    /// (`fp8_lut::load_dense_ffn`).
45    pub ffn_nvfp4: bool,
46    /// 2026-09-25: The NVFP4 q/k/v/o weights, their transposed twins, and the
47    /// fused `[q|k|v]` transposed twin.
48    pub attn_nvfp4: bool,
49    /// 2026-09-25: Which FP8 prefill twins `transpose_fp8_for_prefill_selected`
50    /// builds.
51    pub attn_fp8_twins: Fp8TwinSet,
52}
53
54/// 2026-09-25: The inputs of [`DenseFp8Plan::resolve`]. A plain struct, so the
55/// decision-table tests set every clause without the process environment,
56/// which `keep_nvfp4_fallback` caches in a `OnceLock`.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct DenseFp8Inputs {
59    /// 2026-09-25: The native FP8 dense-FFN overlay is installed on this layer
60    /// (`qwen35_dense::ffn_fp8_arm_selected`: `METRALE_DENSE_FP8=1`, tp size 1,
61    /// `Fp8Dequanted`, a native FP8 `gate_proj`).
62    pub ffn_fp8: bool,
63    /// 2026-09-25: The native FP8 attention overlay is installed on this layer.
64    pub attn_fp8: bool,
65    /// 2026-09-25: `METRALE_DENSE_FP8_KEEP_NVFP4` (`keep_nvfp4_fallback`).
66    pub keep_nvfp4: bool,
67    /// 2026-09-25: `GemmDispatch::from_env()`, the value the model stores as its
68    /// dispatch.
69    pub dispatch: GemmDispatch,
70    /// 2026-09-25: Both W8A8 prefill kernels are loaded
71    /// (`per_token_group_quant_fp8` and `fp8_gemm_t_blockscaled`). The W8A8 arms
72    /// of `prefill/paged_qkv.rs` and `prefill/paged_oproj.rs` need both; without
73    /// them those projections fall through to the transposed W8A16 kernels,
74    /// which read the FP8 twins.
75    pub w8a8_kernels: bool,
76    /// 2026-09-25: `METRALE_ATTN_W4A4` is set. The W4A4 arm of
77    /// `prefill/paged_oproj.rs` checks no weight type and reads
78    /// `self.attn.o_proj`, the NVFP4 o_proj, so this lever keeps the NVFP4
79    /// attention weights under an FP8 overlay. The Q/K/V W4A4 arm in
80    /// `prefill/paged_qkv.rs` requires an NVFP4 `q_weight`, so it never runs on
81    /// an FP8 layer.
82    pub attn_w4a4: bool,
83    /// 2026-09-25: `METRALE_ATTN_PREFILL_Q_T=1`. `prefill/cache_skip_qkv.rs` reads
84    /// it on every call and, when set, lets Q use `q_fp8w_t`.
85    pub attn_prefill_q_t: bool,
86}
87
88impl DenseFp8Plan {
89    /// 2026-09-25: The decision table.
90    ///
91    /// - FFN NVFP4: built only when the layer has no FFN overlay, or under
92    ///   `keep_nvfp4` (the module docs say why the FP8 FFN never reads it).
93    /// - Attention NVFP4: `set_fp8_weights` replaces `q_weight`, `k_weight`,
94    ///   `v_weight` and `o_weight` with `QuantWeight::Fp8`. The NVFP4 copies
95    ///   are kept when a `METRALE_CUTLASS_NVFP4_*` attention arm
96    ///   (`cutlass_nvfp4_*` in the dispatch) or the W4A4 o_proj arm
97    ///   (`attn_w4a4`) can read them.
98    /// - FP8 twins K and V: built on every FP8 attention layer unless the
99    ///   umbrella flag is set (last item). A single
100    ///   stream's first prefill chunk (`seq_len_start == 0`) runs
101    ///   `prefill/cache_skip_qkv.rs`, whose only W8A8 arm is the cuBLASLt one
102    ///   (`cache_skip_qkv_cublas_selected`, which needs `METRALE_CUBLAS_GEMM`
103    ///   to cover `attn`). When it declines, K and V read `k_fp8w_t` and
104    ///   `v_fp8w_t` if present.
105    /// - FP8 twins Q and O: Q is read on that chain only with
106    ///   `attn_prefill_q_t`, and on the paged chain after its W8A8 arm declines;
107    ///   both chains run O through `prefill/paged_oproj.rs`, which reads the O
108    ///   twin after its W8A8 arm declines. The W8A8 arms decline when
109    ///   block-scaled prefill is off or a W8A8 kernel is missing.
110    /// - Under the umbrella `cutlass_nvfp4_gemm` no FP8 twin is planned, because
111    ///   `transpose_fp8_for_prefill_selected` builds none under that flag.
112    pub fn resolve(i: DenseFp8Inputs) -> Self {
113        if i.keep_nvfp4 {
114            return Self {
115                ffn_nvfp4: true,
116                attn_nvfp4: true,
117                attn_fp8_twins: if i.attn_fp8 {
118                    Fp8TwinSet::ALL
119                } else {
120                    Fp8TwinSet::NONE
121                },
122            };
123        }
124        let cutlass_nvfp4_attn = i.dispatch.cutlass_nvfp4_gemm
125            || i.dispatch.cutlass_nvfp4_attn_q
126            || i.dispatch.cutlass_nvfp4_attn_kv
127            || i.dispatch.cutlass_nvfp4_attn_o;
128        let w8a8_covers_prefill = i.dispatch.fp8_blockscaled_prefill && i.w8a8_kernels;
129        let fp8_twins = if !i.attn_fp8 || i.dispatch.cutlass_nvfp4_gemm {
130            Fp8TwinSet::NONE
131        } else {
132            Fp8TwinSet {
133                q: i.attn_prefill_q_t || !w8a8_covers_prefill,
134                k: true,
135                v: true,
136                o: !w8a8_covers_prefill,
137            }
138        };
139        Self {
140            ffn_nvfp4: !i.ffn_fp8,
141            attn_nvfp4: !i.attn_fp8 || cutlass_nvfp4_attn || i.attn_w4a4,
142            attn_fp8_twins: fp8_twins,
143        }
144    }
145}
146
147/// 2026-09-25: The environment half of [`DenseFp8Inputs`]. `load_layers`
148/// resolves it once, so every layer of one load is planned from the same
149/// values.
150#[derive(Clone, Copy, Debug)]
151pub struct RouteEnv {
152    pub keep_nvfp4: bool,
153    pub dispatch: GemmDispatch,
154    /// 2026-09-25: The `METRALE_ATTN_W4A4` check of `prefill/paged_oproj.rs`
155    /// and `prefill/paged_qkv.rs`, which read the variable on every call.
156    pub attn_w4a4: bool,
157    /// 2026-09-25: The `METRALE_ATTN_PREFILL_Q_T` check of
158    /// `prefill/cache_skip_qkv.rs`, also read on every call.
159    pub attn_prefill_q_t: bool,
160}
161
162impl RouteEnv {
163    pub fn from_env() -> Self {
164        Self {
165            keep_nvfp4: keep_nvfp4_fallback(),
166            dispatch: GemmDispatch::from_env(),
167            // 2026-09-25: The dispatch sites' predicates: `is_ok()` for W4A4,
168            // `== "1"` for the Q transpose.
169            attn_w4a4: std::env::var("METRALE_ATTN_W4A4").is_ok(),
170            attn_prefill_q_t: std::env::var("METRALE_ATTN_PREFILL_Q_T").ok().as_deref()
171                == Some("1"),
172        }
173    }
174
175    /// 2026-09-25: One layer's plan. `w8a8_kernels` comes from the layer's
176    /// resolved kernel handles, so it is an argument, not a field.
177    pub fn plan(&self, ffn_fp8: bool, attn_fp8: bool, w8a8_kernels: bool) -> DenseFp8Plan {
178        DenseFp8Plan::resolve(DenseFp8Inputs {
179            ffn_fp8,
180            attn_fp8,
181            keep_nvfp4: self.keep_nvfp4,
182            dispatch: self.dispatch,
183            w8a8_kernels,
184            attn_w4a4: self.attn_w4a4,
185            attn_prefill_q_t: self.attn_prefill_q_t,
186        })
187    }
188
189    /// 2026-09-25: The attention NVFP4 decision. The loader asks it before the
190    /// layer exists and passes `w8a8_kernels = true`, which is sound because
191    /// `attn_nvfp4` does not depend on it
192    /// (`attn_nvfp4_does_not_depend_on_the_w8a8_kernels`).
193    pub fn attn_nvfp4(&self, attn_fp8: bool) -> bool {
194        self.plan(false, attn_fp8, true).attn_nvfp4
195    }
196
197    /// 2026-09-25: Which FP8 prefill twins this layer needs. The loader passes
198    /// the constructed layer's `has_w8a8_prefill_kernels()`.
199    pub fn attn_fp8_twins(&self, attn_fp8: bool, w8a8_kernels: bool) -> Fp8TwinSet {
200        self.plan(false, attn_fp8, w8a8_kernels).attn_fp8_twins
201    }
202}
203
204/// 2026-09-25: Bytes of one NVFP4 `QuantizedWeight`: `n * k / 2` packed E2M1
205/// bytes plus `n * k / 16` group-scale bytes, the two allocations of
206/// `quantize_to_nvfp4` and of `QuantizedWeight::transpose_for_gemm_gs` with 16-wide groups.
207pub fn nvfp4_bytes(n: usize, k: usize) -> usize {
208    n * k / 2 + n * k / 16
209}
210
211/// 2026-09-25: NVFP4 bytes of one dense-FFN layer: gate, up and down, each
212/// with a transposed twin of the same size.
213pub fn dense_ffn_nvfp4_bytes(hidden: usize, inter: usize) -> usize {
214    // 2026-09-25: gate/up are `[inter, hidden]` and down is `[hidden, inter]`:
215    // the same element count.
216    3 * 2 * nvfp4_bytes(inter, hidden)
217}
218
219/// 2026-09-25: NVFP4 bytes of one full-attention layer: q/k/v/o, each with a
220/// transposed twin, plus the fused `[q|k|v]` transposed twin
221/// (`transpose_concat_for_gemm`).
222///
223/// `q_n` is `num_attention_heads * head_dim`, doubled when `attn_gated`;
224/// `kv_n` is `num_key_value_heads * head_dim`; `o_k` is the o_proj contraction
225/// width `num_attention_heads * head_dim`. These are the loader's arguments.
226pub fn attn_nvfp4_bytes(q_n: usize, kv_n: usize, o_k: usize, hidden: usize) -> usize {
227    let qkv = nvfp4_bytes(q_n, hidden) + 2 * nvfp4_bytes(kv_n, hidden);
228    let o = nvfp4_bytes(hidden, o_k);
229    2 * (qkv + o) + nvfp4_bytes(q_n + 2 * kv_n, hidden)
230}
231
232/// 2026-09-25: Bytes of one FP8 transposed twin: `n * k` E4M3 bytes plus the
233/// FP32 block-scale grid over 128x128 blocks, the two allocations of
234/// `Fp8Weight::transpose_for_gemm`.
235pub fn fp8_twin_bytes(n: usize, k: usize) -> usize {
236    n * k + n.div_ceil(128) * k.div_ceil(128) * 4
237}
238
239/// 2026-09-25: Bytes of the FP8 prefill twins `want` selects, for one
240/// full-attention layer.
241pub fn attn_fp8_twin_bytes(
242    want: Fp8TwinSet,
243    q_n: usize,
244    kv_n: usize,
245    o_k: usize,
246    hidden: usize,
247) -> usize {
248    let mut b = 0;
249    if want.q {
250        b += fp8_twin_bytes(q_n, hidden);
251    }
252    if want.k {
253        b += fp8_twin_bytes(kv_n, hidden);
254    }
255    if want.v {
256        b += fp8_twin_bytes(kv_n, hidden);
257    }
258    if want.o {
259        b += fp8_twin_bytes(hidden, o_k);
260    }
261    b
262}
263
264/// 2026-09-25: Bytes of one fused dense-FFN gate+up weight: the two
265/// `[inter, hidden]` E4M3 blocks side by side, plus their two FP32 block-scale
266/// grids side by side. Two grids, not one grid over `2 * inter`, because the
267/// concat copies the two source grids.
268pub fn ffn_gateup_fused_bytes(hidden: usize, inter: usize) -> usize {
269    let (w, s) = ffn_gateup_fused_parts(hidden, inter);
270    w + s
271}
272
273/// 2026-09-25: [`ffn_gateup_fused_bytes`] as `(weight bytes, scale-grid
274/// bytes)`. The loader adopts the two buffers separately.
275pub fn ffn_gateup_fused_parts(hidden: usize, inter: usize) -> (usize, usize) {
276    (
277        2 * inter * hidden,
278        2 * (inter.div_ceil(128) * hidden.div_ceil(128) * 4),
279    )
280}
281
282/// 2026-09-25: Running tally, kept by the loader as it goes, of the derived
283/// (non-checkpoint) device bytes it built, freed and declined to build.
284#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
285pub struct DerivedResidency {
286    /// 2026-09-25: Bytes the loader recorded with `keep`: derived copies it
287    /// keeps after the load.
288    pub kept: u64,
289    /// 2026-09-25: Derived bytes the plan declined to build.
290    pub skipped: u64,
291    /// 2026-09-25: Derived bytes allocated and freed during the load.
292    pub freed: u64,
293    /// 2026-09-25: Which derived families were built, for the summary line.
294    pub twins: TwinsBuilt,
295}
296
297/// 2026-09-25: Which derived families the loader built, named in the summary
298/// line.
299#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
300pub struct TwinsBuilt {
301    pub ffn_nvfp4: bool,
302    pub attn_nvfp4: bool,
303    pub attn_fp8: bool,
304    pub ssm_fp8_concat: bool,
305    /// 2026-09-25: The dense-FFN `[2*inter, hidden]` gate+up concat. Its bytes
306    /// count in `kept`, while `prune_after_load` releases the two store
307    /// tensors it copied.
308    pub ffn_gateup_fused: bool,
309}
310
311impl TwinsBuilt {
312    /// 2026-09-25: `none`, or a comma-separated list, for the summary line.
313    pub fn describe(self) -> String {
314        let mut parts: Vec<&str> = Vec::new();
315        if self.ffn_nvfp4 {
316            parts.push("ffn-nvfp4+t");
317        }
318        if self.attn_nvfp4 {
319            parts.push("attn-nvfp4+t");
320        }
321        if self.attn_fp8 {
322            parts.push("attn-fp8-t");
323        }
324        if self.ssm_fp8_concat {
325            parts.push("ssm-qkvz-fp8");
326        }
327        if self.ffn_gateup_fused {
328            parts.push("ffn-gateup-fp8");
329        }
330        if parts.is_empty() {
331            "none".to_owned()
332        } else {
333            parts.join(", ")
334        }
335    }
336}
337
338impl DerivedResidency {
339    pub fn keep(&mut self, bytes: usize) {
340        self.kept += bytes as u64;
341    }
342
343    pub fn skip(&mut self, bytes: usize) {
344        self.skipped += bytes as u64;
345    }
346
347    pub fn free(&mut self, bytes: usize) {
348        self.freed += bytes as u64;
349    }
350
351    /// 2026-09-25: The summary line `load_layers` logs at its end. `weights`
352    /// is the argument (the store's resident bytes), `derived` is `kept`, and
353    /// `not built` is `skipped`.
354    pub fn summary(&self, weight_bytes: usize) -> String {
355        let gb = |b: u64| b as f64 / 1e9;
356        format!(
357            "native FP8 dense residency: weights {:.2} GB, derived {:.2} GB \
358             (twins: {}), freed {:.2} GB, not built {:.2} GB",
359            weight_bytes as f64 / 1e9,
360            gb(self.kept),
361            self.twins.describe(),
362            gb(self.freed),
363            gb(self.skipped),
364        )
365    }
366}
367
368#[cfg(test)]
369#[path = "fp8_residency_tests.rs"]
370mod tests;