spark_model/weight_loader/qwen35_dense/
fp8_residency.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Which derived weight copies the native-FP8 dense route actually needs, and
4//! a tally of the ones it still builds.
5//!
6//! **WHY (#915 root cause; O9 of the 2026-09-05 rental; refs #916, #917, #736).**
7//! Measured on 1xH100, 2026-09-11, `Qwen/Qwen3.8-27B-FP8` at `5f78270dc`,
8//! native-FP8 profile (`ATLAS_DENSE_FP8=1`, `--lm-head-dtype bf16`):
9//!
10//! * the checkpoint itself is **28.75 GB** — `WeightStore after prune: 1606
11//!   tensors, 28.747 GiB still resident`, one ledger site
12//!   (`fast_weights/mod.rs:434`, 29,436.7 MB x1606);
13//! * the ledger nevertheless reported **58.1 GB live before the KV cache was
14//!   sized**, and `ATLAS_MEM_PROFILE` recorded GPU-free falling **437 MB per
15//!   layer over all 64 layers (28.0 GB)** *after* the checkpoint was resident;
16//! * the teardown sweep reclaimed **28.01 GB across 1,980 allocations that no
17//!   `ModelResource` owned**.
18//!
19//! The six sites the ledger named, and what each holds per layer:
20//!
21//! | site | bytes x count | tensor |
22//! |---|---|---|
23//! | `weight_map/loaders_fp8.rs:229` | 8,960 MB x256 | `quantize_to_nvfp4` packed `[N,K/2]` |
24//! | `weight_map/quantized.rs:261` | 8,960 MB x256 | the transposed twin of the same |
25//! | `weight_loader/qwen35_dense.rs:135` | 3,840 MB x48 | SSM fused `[QKV\|Z]` FP8 concat |
26//! | `weight_map/quantized.rs:643` | 1,600 MB x64 | attention `Fp8WeightTransposed::weight_t` |
27//! | `weight_map/loaders_fp8.rs:230` | 1,120 MB x256 | the NVFP4 per-16 group scales |
28//! | `weight_map/quantized.rs:262` | 1,120 MB x256 | the transposed twin of those scales |
29//!
30//! The 256 counts are `64 layers x 3` dense-FFN projections (gate/up/down,
31//! 42.5 MiB packed each) plus `16 attention layers x 4` (q/k/v/o, 25/6.25/
32//! 6.25/12.5 MiB) — 8,160 + 800 MB, which is exactly the 8,960 reported.
33//!
34//! **None of the NVFP4 copies is reachable once the native FP8 overlay is
35//! installed.** `DenseFfnLayer::forward` (`dense_ffn.rs:1044`) and
36//! `forward_prefill_inner` (`dense_ffn.rs:1985`) both return from inside their
37//! `if let Some(ref fp8w) = self.fp8_weights` arm, `forward_k2`/`forward_k3`/
38//! `forward_km` redirect to `forward_prefill` via
39//! `native_small_batch_uses_prefill`, and the `w8_gemm!` macro binds
40//! `gate_t`/`up_t`/`down_t` to a literal `None`, so even the W8A16 fallback
41//! rungs read the original `[N,K]` E4M3 bytes. The NVFP4 gate/up/down and
42//! their transposed twins are pure load-time waste: 18.4 GiB of the 28.
43//!
44//! **The loader runs before dispatch exists**, so the route has to be derived
45//! from the same resolvers the forward pass uses rather than from a
46//! `ForwardContext`:
47//!
48//! * [`crate::layers::ops::GemmDispatch::from_env`] — the exact constructor
49//!   `model/impl_a1.rs:836` calls to build the context. It is a pure function
50//!   of the environment and a serve never rewrites its own environment, so the
51//!   loader's answer and the context's answer cannot disagree.
52//!
53//! `ATLAS_FFN_W8A16_ONLY` is deliberately NOT an input: it steers the dense FFN
54//! from the W8A8 arm onto rung 5 of the same `w8_gemm!` match, whose transposed
55//! operand is that literal `None` — so it selects between two kernels that both
56//! read the original FP8 bytes, and cannot resurrect an NVFP4 reader.
57//!
58//! That is the contract: **any new lever that can route a native-FP8 layer back
59//! onto an NVFP4 kernel must be added to [`DenseFp8Plan::resolve`] as well as
60//! to the dispatch site**, or the loader will have freed the weight that site
61//! wants. `ATLAS_DENSE_FP8_KEEP_NVFP4` is the escape hatch that restores the
62//! pre-#915 behaviour wholesale while such a gap is diagnosed.
63
64use crate::layers::ops::GemmDispatch;
65use crate::layers::qwen3_attention::Fp8TwinSet;
66
67/// The per-layer kill switch that restores the pre-#915 behaviour: build every
68/// NVFP4 fallback copy even where the dispatch cannot reach it.
69///
70/// PRESENCE (any value, including empty), matching `ATLAS_FFN_W8A16_ONLY` —
71/// this is an escape hatch an operator reaches for while a serve is
72/// misbehaving, and `ATLAS_DENSE_FP8_KEEP_NVFP4=0` meaning "on" is a trap.
73pub fn keep_nvfp4_fallback() -> bool {
74    static KEEP: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
75    *KEEP.get_or_init(|| std::env::var_os("ATLAS_DENSE_FP8_KEEP_NVFP4").is_some())
76}
77
78/// What a native-FP8 dense layer must materialise beyond the checkpoint bytes.
79///
80/// Every field is "build this derived copy": `false` means the selected
81/// kernels provably never read it.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub struct DenseFp8Plan {
84    /// NVFP4 gate/up/down **and** their `w4a16_gemm_t_m128` transposed twins.
85    pub ffn_nvfp4: bool,
86    /// NVFP4 q/k/v/o, their transposed twins, and the fused `[q|k|v]` twin.
87    pub attn_nvfp4: bool,
88    /// Which `Fp8WeightTransposed` twins `transpose_fp8_for_prefill` builds.
89    pub attn_fp8_twins: Fp8TwinSet,
90}
91
92/// The inputs [`DenseFp8Plan::resolve`] decides from. Taken as a struct so the
93/// CPU decision-table test can pin every clause without touching the process
94/// environment (the `OnceLock` resolvers cannot be toggled per test).
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub struct DenseFp8Inputs {
97    /// The native FP8 dense-FFN overlay will be installed on this layer
98    /// (`ATLAS_DENSE_FP8=1`, tp_size 1, `Fp8Dequanted`, gate_proj native FP8).
99    pub ffn_fp8: bool,
100    /// The native FP8 attention overlay will be installed on this layer.
101    pub attn_fp8: bool,
102    /// `ATLAS_DENSE_FP8_KEEP_NVFP4` — restore the pre-#915 behaviour.
103    pub keep_nvfp4: bool,
104    /// Resolved exactly as `model/impl_a1.rs:836` resolves it.
105    pub dispatch: GemmDispatch,
106    /// Whether the target's `per_token_group_quant_fp8` + `fp8_gemm_t_blockscaled`
107    /// kernels are both loaded. Both are required by the W8A8 prefill arm in
108    /// `qwen3_attention/prefill/paged_qkv.rs:220` and
109    /// `prefill/paged_oproj.rs:94`; without them those two chains fall through
110    /// to the transposed W8A16 kernels, which read the FP8 twins.
111    pub w8a8_kernels: bool,
112    /// `ATLAS_ATTN_W4A4` is set. `prefill/paged_oproj.rs:38-42` builds its W4A4
113    /// arm with NO weight-type predicate and then feeds it
114    /// `&self.attn.o_proj` — the NVFP4 o_proj — so this one lever keeps the
115    /// NVFP4 attention weights alive even under a full FP8 overlay. (The QKV
116    /// side at `paged_qkv.rs:51` does check `as_nvfp4()`, so it is already
117    /// closed; the o_proj asymmetry is not.)
118    pub attn_w4a4: bool,
119    /// `ATLAS_ATTN_PREFILL_Q_T=1`. `prefill/cache_skip_qkv.rs:142` reads it per
120    /// projection per prefill and, when set, dispatches Q through `q_fp8w_t`.
121    pub attn_prefill_q_t: bool,
122}
123
124impl DenseFp8Plan {
125    /// The decision table. Pure — no environment reads, no allocation.
126    ///
127    /// * **FFN NVFP4** — unreachable the moment `set_fp8_weights` runs (see the
128    ///   module docs: every entry point returns from inside the FP8 arm and the
129    ///   `w8_gemm!` transposed operands are a literal `None`). Built only under
130    ///   the escape hatch.
131    /// * **Attention NVFP4** — `set_fp8_weights` *overwrites*
132    ///   `q_weight`/`k_weight`/`v_weight`/`o_weight` with `QuantWeight::Fp8`,
133    ///   so the base NVFP4 weights are orphaned at load. The transposed and
134    ///   fused twins survive only behind the `ATLAS_CUTLASS_NVFP4_*` levers,
135    ///   which default off.
136    /// * **Attention FP8 twins, K and V** — KEPT UNCONDITIONALLY. The
137    ///   first prefill chunk (`seq_len_start == 0`, the default for every
138    ///   request) does not go through `paged_qkv.rs` at all: it goes through
139    ///   `prefill/cache_skip_qkv.rs`, whose dispatch chain has **no W8A8 arm**,
140    ///   so `k_fp8w_t`/`v_fp8w_t` are dereferenced at `cache_skip_qkv.rs:218`
141    ///   / `:235` on every request regardless of `fp8_blockscaled_prefill`.
142    ///   Freeing them is a NULL-pointer kernel launch on the first token.
143    /// * **Attention FP8 twins, Q and O** — Q on that same chain is behind
144    ///   `ATLAS_ATTN_PREFILL_Q_T=1` (`cache_skip_qkv.rs:142`) and O is routed
145    ///   to `paged_oproj.rs` from both chains, so both are reachable only after
146    ///   the W8A8 arm declines — block-scaled prefill off, or a target missing
147    ///   one of the two kernels.
148    pub fn resolve(i: DenseFp8Inputs) -> Self {
149        if i.keep_nvfp4 {
150            return Self {
151                ffn_nvfp4: true,
152                attn_nvfp4: true,
153                attn_fp8_twins: if i.attn_fp8 {
154                    Fp8TwinSet::ALL
155                } else {
156                    Fp8TwinSet::NONE
157                },
158            };
159        }
160        let cutlass_nvfp4_attn = i.dispatch.cutlass_nvfp4_gemm
161            || i.dispatch.cutlass_nvfp4_attn_q
162            || i.dispatch.cutlass_nvfp4_attn_kv
163            || i.dispatch.cutlass_nvfp4_attn_o;
164        // `transpose_fp8_for_prefill` already refuses to build anything under
165        // the umbrella NVFP4 flag (`prefill_weights.rs:357`); mirror that here
166        // so the plan and the builder cannot drift.
167        let w8a8_covers_prefill = i.dispatch.fp8_blockscaled_prefill && i.w8a8_kernels;
168        let fp8_twins = if !i.attn_fp8 || i.dispatch.cutlass_nvfp4_gemm {
169            Fp8TwinSet::NONE
170        } else {
171            Fp8TwinSet {
172                q: i.attn_prefill_q_t || !w8a8_covers_prefill,
173                k: true,
174                v: true,
175                o: !w8a8_covers_prefill,
176            }
177        };
178        Self {
179            ffn_nvfp4: !i.ffn_fp8,
180            attn_nvfp4: !i.attn_fp8 || cutlass_nvfp4_attn || i.attn_w4a4,
181            attn_fp8_twins: fp8_twins,
182        }
183    }
184}
185
186/// The environment-resolved half of [`DenseFp8Inputs`], read ONCE per load.
187///
188/// Hoisted out of the per-layer decision because `GemmDispatch::from_env`
189/// walks the environment block for a dozen variables and a 64-layer model
190/// would otherwise do it 64 times — and because resolving once is what makes
191/// "every layer of this model took the same route" a property of the type
192/// rather than of the environment holding still.
193#[derive(Clone, Copy, Debug)]
194pub struct RouteEnv {
195    pub keep_nvfp4: bool,
196    pub dispatch: GemmDispatch,
197    /// Mirrors `prefill/paged_oproj.rs:42` and `prefill/paged_qkv.rs:53`,
198    /// which read this per projection per prefill and are NOT memoised.
199    pub attn_w4a4: bool,
200    /// Mirrors `prefill/cache_skip_qkv.rs:142`, same caveat.
201    pub attn_prefill_q_t: bool,
202}
203
204impl RouteEnv {
205    pub fn from_env() -> Self {
206        Self {
207            keep_nvfp4: keep_nvfp4_fallback(),
208            dispatch: GemmDispatch::from_env(),
209            // Same predicates as the dispatch sites, character for character:
210            // `is_ok()` (presence) for W4A4, `== "1"` for the Q-transpose.
211            attn_w4a4: std::env::var("ATLAS_ATTN_W4A4").is_ok(),
212            attn_prefill_q_t: std::env::var("ATLAS_ATTN_PREFILL_Q_T").ok().as_deref() == Some("1"),
213        }
214    }
215
216    /// This layer's plan. `w8a8_kernels` is a property of the *layer* (its
217    /// resolved kernel handles), which is why it is not part of `RouteEnv`.
218    pub fn plan(&self, ffn_fp8: bool, attn_fp8: bool, w8a8_kernels: bool) -> DenseFp8Plan {
219        DenseFp8Plan::resolve(DenseFp8Inputs {
220            ffn_fp8,
221            attn_fp8,
222            keep_nvfp4: self.keep_nvfp4,
223            dispatch: self.dispatch,
224            w8a8_kernels,
225            attn_w4a4: self.attn_w4a4,
226            attn_prefill_q_t: self.attn_prefill_q_t,
227        })
228    }
229
230    /// The NVFP4 half of the plan. Asked BEFORE the layer exists, so it may
231    /// not depend on any layer-local kernel handle — pinned by
232    /// `attn_nvfp4_does_not_depend_on_the_w8a8_kernels`.
233    pub fn attn_nvfp4(&self, attn_fp8: bool) -> bool {
234        self.plan(false, attn_fp8, true).attn_nvfp4
235    }
236
237    /// Which FP8 prefill twins this layer needs. `w8a8_kernels` is read off
238    /// the constructed layer (`Qwen3AttentionLayer::has_w8a8_prefill_kernels`).
239    pub fn attn_fp8_twins(&self, attn_fp8: bool, w8a8_kernels: bool) -> Fp8TwinSet {
240        self.plan(false, attn_fp8, w8a8_kernels).attn_fp8_twins
241    }
242}
243
244/// Bytes one NVFP4 `QuantizedWeight` costs: packed `[N, K/2]` E2M1 nibbles
245/// plus the `[N, K/16]` per-group scale byte. Mirrors `quantize_to_nvfp4`
246/// (`weight_map/loaders_fp8.rs:229`-`230`) and `transpose_for_gemm_gs`
247/// (`weight_map/quantized.rs:261`-`262`), which allocate the same two sizes.
248pub fn nvfp4_bytes(n: usize, k: usize) -> usize {
249    n * k / 2 + n * k / 16
250}
251
252/// What the pre-#915 loader spent per dense-FFN layer on NVFP4: gate, up and
253/// down, each with a transposed twin of identical size.
254pub fn dense_ffn_nvfp4_bytes(hidden: usize, inter: usize) -> usize {
255    // gate/up are [inter, hidden] and down is [hidden, inter] — the same
256    // element count, so all three cost the same.
257    3 * 2 * nvfp4_bytes(inter, hidden)
258}
259
260/// What the pre-#915 loader spent per full-attention layer on NVFP4: q/k/v/o,
261/// each with a transposed twin, plus the fused `[q|k|v]` transposed twin
262/// (`transpose_concat_for_gemm`).
263///
264/// `q_n` is `num_attention_heads * head_dim`, doubled when `attn_gated`;
265/// `kv_n` is `num_key_value_heads * head_dim`; `o_k` is the o_proj contraction
266/// width `num_attention_heads * head_dim`.
267pub fn attn_nvfp4_bytes(q_n: usize, kv_n: usize, o_k: usize, hidden: usize) -> usize {
268    let qkv = nvfp4_bytes(q_n, hidden) + 2 * nvfp4_bytes(kv_n, hidden);
269    let o = nvfp4_bytes(hidden, o_k);
270    // base + per-projection twin + the fused q|k|v twin
271    2 * (qkv + o) + nvfp4_bytes(q_n + 2 * kv_n, hidden)
272}
273
274/// Bytes one FP8 `[K, N]` transposed twin costs: the E4M3 bytes plus the
275/// transposed `[K/128, N/128]` FP32 block-scale grid. Mirrors
276/// `Fp8Weight::transpose_for_gemm` (`weight_map/quantized.rs:643`/`:660`).
277pub fn fp8_twin_bytes(n: usize, k: usize) -> usize {
278    n * k + n.div_ceil(128) * k.div_ceil(128) * 4
279}
280
281/// The FP8 prefill twins `want` selects, for one full-attention layer.
282pub fn attn_fp8_twin_bytes(
283    want: Fp8TwinSet,
284    q_n: usize,
285    kv_n: usize,
286    o_k: usize,
287    hidden: usize,
288) -> usize {
289    let mut b = 0;
290    if want.q {
291        b += fp8_twin_bytes(q_n, hidden);
292    }
293    if want.k {
294        b += fp8_twin_bytes(kv_n, hidden);
295    }
296    if want.v {
297        b += fp8_twin_bytes(kv_n, hidden);
298    }
299    if want.o {
300        b += fp8_twin_bytes(hidden, o_k);
301    }
302    b
303}
304
305/// What ONE fused dense-FFN gate+up weight costs: the two `[inter, hidden]`
306/// E4M3 blocks appended along N, plus their two `[inter/128, hidden/128]` FP32
307/// block-scale grids appended the same way (#927).
308///
309/// The sum of the two grids and NOT one grid over the fused N, for the reason
310/// `predicted_residency::ssm_concat_bytes` gives: `ceil` of a sum is not the
311/// sum of the `ceil`s, and the concat copies the two grids side by side.
312///
313/// RESIDENCY-NEUTRAL: `prune_after_load` releases the two `[inter, hidden]`
314/// store tensors this copied, so the same number is also what the checkpoint
315/// gives back. Both sides are priced from this one function.
316pub fn ffn_gateup_fused_bytes(hidden: usize, inter: usize) -> usize {
317    let (w, s) = ffn_gateup_fused_parts(hidden, inter);
318    w + s
319}
320
321/// [`ffn_gateup_fused_bytes`] split into `(weight bytes, scale-grid bytes)` —
322/// the loader adopts the two buffers separately, so it needs the terms rather
323/// than the sum, and taking them from here is what keeps the prediction and
324/// the tally one arithmetic.
325pub fn ffn_gateup_fused_parts(hidden: usize, inter: usize) -> (usize, usize) {
326    (
327        2 * inter * hidden,
328        2 * (inter.div_ceil(128) * hidden.div_ceil(128) * 4),
329    )
330}
331
332/// Running tally of the derived (non-checkpoint) device bytes this loader
333/// allocated, and of the ones it decided not to build.
334///
335/// Counted in the loader rather than read back from the ledger because the
336/// ledger cannot tell a derived copy from a checkpoint tensor — both are plain
337/// `gpu.alloc` — and because the "not built" number is the one the fix is
338/// judged on and has no allocation to read.
339#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
340pub struct DerivedResidency {
341    /// Derived bytes still resident at the end of load.
342    pub kept: u64,
343    /// Derived bytes the plan declined to build, versus the pre-#915 loader.
344    pub skipped: u64,
345    /// Transient derived bytes allocated and freed during load.
346    pub freed: u64,
347    /// Which twin families were built, for the one-line summary.
348    pub twins: TwinsBuilt,
349}
350
351/// Which derived twin families the plan built. Reported by name so the serve
352/// log says *which* copies are resident rather than only how many bytes.
353#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
354pub struct TwinsBuilt {
355    pub ffn_nvfp4: bool,
356    pub attn_nvfp4: bool,
357    pub attn_fp8: bool,
358    pub ssm_fp8_concat: bool,
359    /// The dense-FFN `[2*inter, hidden]` gate+up concat (#927). Named in the
360    /// summary like the others, and worth naming even though it is residency-
361    /// NEUTRAL: its bytes appear in `kept` while the two store tensors they
362    /// replace disappear from `WeightStore::resident_bytes` at the prune, so a
363    /// reader comparing two serve logs needs to know which of the two numbers
364    /// moved and why.
365    pub ffn_gateup_fused: bool,
366}
367
368impl TwinsBuilt {
369    /// `none`, or a comma-separated list, for the summary line.
370    pub fn describe(self) -> String {
371        let mut parts: Vec<&str> = Vec::new();
372        if self.ffn_nvfp4 {
373            parts.push("ffn-nvfp4+t");
374        }
375        if self.attn_nvfp4 {
376            parts.push("attn-nvfp4+t");
377        }
378        if self.attn_fp8 {
379            parts.push("attn-fp8-t");
380        }
381        if self.ssm_fp8_concat {
382            parts.push("ssm-qkvz-fp8");
383        }
384        if self.ffn_gateup_fused {
385            parts.push("ffn-gateup-fp8");
386        }
387        if parts.is_empty() {
388            "none".to_owned()
389        } else {
390            parts.join(", ")
391        }
392    }
393}
394
395impl DerivedResidency {
396    pub fn keep(&mut self, bytes: usize) {
397        self.kept += bytes as u64;
398    }
399
400    pub fn skip(&mut self, bytes: usize) {
401        self.skipped += bytes as u64;
402    }
403
404    pub fn free(&mut self, bytes: usize) {
405        self.freed += bytes as u64;
406    }
407
408    /// The line the next H100 run is read against.
409    ///
410    /// Emitted at the end of `load_layers` so a serve log proves the residency
411    /// without an `ATLAS_MEM_PROFILE` rerun: `weights` is the checkpoint,
412    /// `derived` is everything this loader built on top of it, and `skipped`
413    /// is what the pre-#915 loader would have built and this one did not.
414    pub fn summary(&self, weight_bytes: usize) -> String {
415        let gb = |b: u64| b as f64 / 1e9;
416        format!(
417            "native FP8 dense residency: weights {:.2} GB, derived {:.2} GB \
418             (twins: {}), freed {:.2} GB, not built {:.2} GB",
419            weight_bytes as f64 / 1e9,
420            gb(self.kept),
421            self.twins.describe(),
422            gb(self.freed),
423            gb(self.skipped),
424        )
425    }
426}
427
428#[cfg(test)]
429#[path = "fp8_residency_tests.rs"]
430mod tests;