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;