spark_model/weight_loader/qwen35_dense/predicted_residency.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The derived-weight residency of the native-FP8 dense route, predicted from
4//! `config.json` alone — BEFORE the checkpoint loads.
5//!
6//! **WHY (#915 second pass).** `fp8_residency::DerivedResidency` tallies the
7//! derived copies as the loader builds them, which is four minutes too late
8//! for the one decision that needs the number: preflight's SSM decode-ring
9//! auto-fit runs before the first byte of the checkpoint is read, and its
10//! first pass therefore fitted the ring against PRE-LOAD free memory (78.6 GB
11//! on an 80 GB H100) instead of against the post-load KV headroom the KV
12//! budget stage actually enforces. Measured on 1xH100 2026-09-11
13//! (`h100-round2-report.md`, stage 3/5): at `--max-batch-size 16` and 32 the
14//! fitter stayed silent — 8 slots, 18.94 / 37.88 GB of ring — and the serve
15//! was refused minutes later by
16//! `factory/build.rs`'s `No memory left for KV cache`. The fit needs the
17//! post-load figure, and the only part of it that is not already known at
18//! preflight is how many bytes the loader will derive on top of the
19//! checkpoint.
20//!
21//! It IS knowable: every derived copy on this route is shape arithmetic over
22//! the config, and which copies get built is
23//! [`fp8_residency::DenseFp8Plan::resolve`], a pure function of the
24//! environment plus one backend question (are the two W8A8 prefill kernels
25//! loaded). This module evaluates both without a `WeightStore`.
26//!
27//! **Round-6 receipt** (`h100-round6-report.md`; serve I, Qwen3.8-27B-FP8,
28//! `ATLAS_DENSE_FP8=1`, `--lm-head-dtype bf16`, tp 1): the loader's own
29//! summary line reported **derived 4.24 GB** on top of a 28.75 GB checkpoint.
30//! Reproduced here from `kernels/gb10/qwen3.8-27b/MODEL.toml`'s shapes, plus
31//! the GDN head geometry, which no MODEL.toml carries and which
32//! `ModelConfig` therefore reads from the checkpoint's own `config.json`
33//! (16x128 key heads, 48x128 value heads):
34//!
35//! | term | per layer | layers | total |
36//! |---|---|---|---|
37//! | attn FP8 K twin + V twin | 10,488,320 B | 16 | 0.168 GB |
38//! | SSM fused `[QKV\|Z]` FP8 weight | 83,886,080 B | 48 | 4.027 GB |
39//! | SSM `[QKV\|Z]` + out_proj block scales | 28,160 B | 48 | 0.001 GB |
40//! | SSM `in_proj_ba` interleaved BF16 | 983,040 B | 48 | 0.047 GB |
41//! | **total** | | | **4.243 GB** |
42//!
43//! Q and O twins are absent from that total because the round-6 target ships
44//! both W8A8 prefill kernels, so `DenseFp8Plan` declines them — which is why
45//! [`Fp8RouteInputs::w8a8_prefill_kernels`] is an input and not an assumption.
46//!
47//! **What this module deliberately refuses to predict.** Anything that
48//! re-opens an NVFP4 fallback (`ATLAS_DENSE_FP8_KEEP_NVFP4`, the
49//! `ATLAS_CUTLASS_NVFP4_*` levers, `ATLAS_ATTN_W4A4`) returns
50//! [`DerivedBytesEstimate::Unavailable`] rather than a guess: those paths
51//! resurrect 18+ GiB of copies whose byte count the loader tallies through
52//! `skip`, not `keep`, so a prediction built on `keep` would be wrong by more
53//! than the quantity being predicted. The caller falls back to its pre-load
54//! behaviour and says so in the log.
55
56use atlas_core::config::ModelConfig;
57
58use super::fp8_residency::{self, RouteEnv, TwinsBuilt};
59use crate::layers::qwen3_attention::Fp8TwinSet;
60use crate::weight_map::Nvfp4Variant;
61
62/// The derived bytes the native-FP8 dense loader will allocate and KEEP,
63/// broken out so the preflight log can name each term.
64///
65/// Mirrors `DerivedResidency::kept` term for term — see the module docs'
66/// receipt table — so `predicted.total()` and the serve log's
67/// `native FP8 dense residency: ... derived X GB` are the same arithmetic
68/// evaluated at two different times.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub struct PredictedDerived {
71 /// `Fp8Weight::transpose_for_gemm` twins for the projections
72 /// [`fp8_residency::DenseFp8Plan`] selects, summed over full-attention
73 /// layers.
74 pub attn_fp8_twins: u64,
75 /// The fused `[QKV|Z]` FP8 weight, its block-scale grid, the `out_proj`
76 /// block-scale grid and the interleaved `in_proj_ba`, summed over
77 /// linear-attention layers.
78 pub ssm_fp8_concat: u64,
79 /// The fused `[2*inter, hidden]` dense-FFN gate+up weight and its
80 /// block-scale grid (#927), summed over dense-FFN layers — 0 when the
81 /// target does not arm the arm.
82 pub ffn_gateup_fused: u64,
83 /// The checkpoint bytes `prune_after_load` gives BACK because the fusion
84 /// consumed them: `mlp.gate_proj.weight` + `mlp.up_proj.weight` over the
85 /// same layers. EQUAL to [`Self::ffn_gateup_fused`] minus the scale grids,
86 /// by construction — the fused weight IS those two tensors copied side by
87 /// side — which is why the fusion nets out of [`Self::total`] below.
88 pub ffn_gateup_pruned: u64,
89 /// Which twin families the prediction expects, for the log line.
90 pub twins: TwinsBuilt,
91 /// The twin set the attention term was priced at.
92 pub attn_twin_set: Fp8TwinSet,
93}
94
95impl PredictedDerived {
96 /// Derived bytes ABOVE the on-disk checkpoint count, which is what
97 /// `headroom.rs` adds to `weights` to build the post-load yardstick.
98 ///
99 /// The gate+up fusion appears as a DIFFERENCE and not as a term: the
100 /// caller's `weights` is the checkpoint's on-disk size, which still counts
101 /// `gate_proj.weight` and `up_proj.weight` — and the loader releases both
102 /// once the fused copy exists. Adding the fused weight without subtracting
103 /// what it replaces would over-state pre-KV by **11.4 GB** on Qwen3.8-27B
104 /// and silently shrink — or refuse — the decode-rollback ring this
105 /// yardstick exists to fit. The two terms are equal by construction, so
106 /// the difference is exactly zero and every prediction taken before #927
107 /// is unchanged; both are carried so that is legible rather than asserted
108 /// (`the_gateup_fusion_is_residency_neutral`).
109 pub fn total(&self) -> u64 {
110 self.attn_fp8_twins
111 + self.ssm_fp8_concat
112 + self.ffn_gateup_fused.saturating_sub(self.ffn_gateup_pruned)
113 }
114}
115
116/// The answer, or an honest refusal to answer.
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub enum DerivedBytesEstimate {
119 /// The native-FP8 dense route will run and its derived bytes are this.
120 NativeFp8Dense(PredictedDerived),
121 /// No prediction. The `&'static str` is the reason, written to be
122 /// readable in a serve log (`… — falling back to pre-load free memory
123 /// (<reason>)`).
124 Unavailable(&'static str),
125}
126
127impl DerivedBytesEstimate {
128 /// Predicted bytes, or `None` when unavailable.
129 pub fn bytes(&self) -> Option<u64> {
130 match self {
131 Self::NativeFp8Dense(p) => Some(p.total()),
132 Self::Unavailable(_) => None,
133 }
134 }
135
136 pub fn reason(&self) -> Option<&'static str> {
137 match self {
138 Self::NativeFp8Dense(_) => None,
139 Self::Unavailable(why) => Some(*why),
140 }
141 }
142}
143
144/// The route gates `load_layers` applies, resolved from the things that exist
145/// before the checkpoint does.
146///
147/// One field per clause of the loader's own `ffn_fp8` / `attn_fp8` /
148/// `gdn_fp8_arm_selected` conditions, so a reader can diff this struct
149/// against `qwen35_dense.rs:319`, `:578` and `:172` line by line.
150#[derive(Clone, Copy, Debug)]
151pub struct Fp8RouteInputs {
152 /// `ATLAS_DENSE_FP8=1` — `qwen35_dense::dense_fp8_enabled`.
153 pub dense_fp8: bool,
154 /// `config.tp_world_size.max(1)`. The FP8 dense route is single-GPU only.
155 pub tp_size: usize,
156 /// The variant the CONFIG declares. `None` when config.json does not say
157 /// — the loader would sniff the store, which does not exist yet.
158 pub declared_variant: Option<Nvfp4Variant>,
159 /// `ATLAS_NO_GDN_FP8` is unset — `qwen35_dense::gdn_fp8_arm_selected`.
160 pub gdn_fp8: bool,
161 /// Both `qwen3_attention::W8A8_PREFILL_KERNELS` are loaded for this
162 /// target. Decides whether the Q and O FP8 prefill twins get built.
163 pub w8a8_prefill_kernels: bool,
164 /// The environment-resolved dispatch route the loader itself reads.
165 pub route: RouteEnv,
166 /// `[defaults] ffn_gateup_fused`, resolved through the SAME function the
167 /// dispatch site and the loader call — the fused gate+up weight is built
168 /// only when the compiled target (or `ATLAS_FFN_GATEUP_FUSED`) arms the arm
169 /// that reads it (#927).
170 pub ffn_gateup_fused: bool,
171}
172
173impl Fp8RouteInputs {
174 /// Resolve every gate from the process environment and the config,
175 /// through the SAME predicates the loader uses.
176 ///
177 /// `w8a8_prefill_kernels` is the caller's, because it is a property of
178 /// the loaded kernel set rather than of the environment — ask
179 /// [`crate::layers::qwen3_attention::w8a8_prefill_kernels_loaded`].
180 pub fn from_env(config: &ModelConfig, w8a8_prefill_kernels: bool) -> Self {
181 Self {
182 // Character for character `qwen35_dense::dense_fp8_enabled`:
183 // `== Ok("1")`, NOT presence. `ATLAS_DENSE_FP8=0` is off.
184 dense_fp8: std::env::var("ATLAS_DENSE_FP8").as_deref() == Ok("1"),
185 tp_size: config.tp_world_size.max(1),
186 declared_variant: crate::weight_map::config_declared_variant(config),
187 // Presence, matching `gdn_fp8_arm_selected`'s `is_none()`.
188 gdn_fp8: std::env::var_os("ATLAS_NO_GDN_FP8").is_none(),
189 w8a8_prefill_kernels,
190 route: RouteEnv::from_env(),
191 ffn_gateup_fused: crate::layers::dense_ffn::gateup_fused::ffn_gateup_fused(),
192 }
193 }
194}
195
196/// Bytes the native-FP8 dense loader will derive on top of the checkpoint.
197///
198/// Pure: no environment reads, no allocation, no device access. The `route`
199/// argument carries everything impure (see [`Fp8RouteInputs::from_env`]), so
200/// the decision table below is testable at exact integers.
201pub fn predicted_derived_bytes(
202 config: &ModelConfig,
203 route: &Fp8RouteInputs,
204) -> DerivedBytesEstimate {
205 // ── Gate 1: is this even the Qwen3.5-dense loader? ────────────────────
206 // `factory::loader_for_config` picks `Qwen35DenseWeightLoader` on
207 // `is_qwen35_dense()` and nothing else reaches this arithmetic.
208 if !config.is_qwen35_dense() {
209 return DerivedBytesEstimate::Unavailable("not the Qwen3.5-dense loader");
210 }
211 if !route.dense_fp8 {
212 return DerivedBytesEstimate::Unavailable("ATLAS_DENSE_FP8 is not 1");
213 }
214 if route.tp_size != 1 {
215 return DerivedBytesEstimate::Unavailable("--tp-size > 1 takes the NVFP4 route");
216 }
217 if route.declared_variant != Some(Nvfp4Variant::Fp8Dequanted) {
218 // Either the config declares a non-FP8 scheme, or it declares nothing
219 // and the loader will sniff the store. Both are "ask again after the
220 // load", never "assume FP8".
221 return DerivedBytesEstimate::Unavailable(
222 "config.json does not declare a block-scaled FP8 checkpoint",
223 );
224 }
225 if route.route.keep_nvfp4 {
226 return DerivedBytesEstimate::Unavailable(
227 "ATLAS_DENSE_FP8_KEEP_NVFP4 restores the pre-#915 fallback copies",
228 );
229 }
230
231 // ── Gate 2: does the plan keep any NVFP4 copy alive? ──────────────────
232 // `DerivedResidency` tallies NVFP4 builds through `skip`, never `keep`,
233 // so a prediction of `kept` cannot price them. Rather than guess at
234 // 18+ GiB, decline. Asked at `attn_fp8 = true` because that is the route
235 // under test; `ffn_nvfp4` is `!ffn_fp8` and is false by construction here.
236 let plan = fp8_residency::DenseFp8Plan::resolve(fp8_residency::DenseFp8Inputs {
237 ffn_fp8: true,
238 attn_fp8: true,
239 keep_nvfp4: false,
240 dispatch: route.route.dispatch,
241 w8a8_kernels: route.w8a8_prefill_kernels,
242 attn_w4a4: route.route.attn_w4a4,
243 attn_prefill_q_t: route.route.attn_prefill_q_t,
244 });
245 if plan.ffn_nvfp4 || plan.attn_nvfp4 {
246 return DerivedBytesEstimate::Unavailable(
247 "an NVFP4 fallback lever (ATLAS_CUTLASS_NVFP4_* / ATLAS_ATTN_W4A4) is set",
248 );
249 }
250
251 let hidden = config.hidden_size;
252 let (nh, hd) = (config.num_attention_heads, config.head_dim);
253 let nkv = config.num_key_value_heads;
254 // Same three lines as `qwen35_dense.rs:953`-`:956`.
255 let q_n = nh * hd * if config.attn_gated { 2 } else { 1 };
256 let attn_layers = config.num_attention_layers() as u64;
257 let attn_fp8_twins = attn_layers
258 * fp8_residency::attn_fp8_twin_bytes(plan.attn_fp8_twins, q_n, nkv * hd, nh * hd, hidden)
259 as u64;
260
261 let ssm_layers = config.num_ssm_layers() as u64;
262 let ssm_fp8_concat = if route.gdn_fp8 && ssm_layers > 0 {
263 ssm_layers * ssm_concat_bytes(config) as u64
264 } else {
265 0
266 };
267
268 // The fused dense-FFN gate+up weight (#927). Every clause of
269 // `qwen35_dense::ffn_gateup_fused_selected` that is knowable before the
270 // checkpoint loads: the arm is armed, the model is dense, and both extents
271 // are whole 128-blocks. The store-dependent clause (`proj_is_native_fp8`)
272 // is already carried by the `declared_variant` gate above.
273 // Same fallback order as `qwen35_dense::ffn_inter`: `moe_intermediate_size`
274 // is the per-EXPERT width and is unset on dense Qwen3.6/3.8-*-FP8, while
275 // `intermediate_size` is unset on the older MoE-style configs. Reading only
276 // one of them would make the prediction and the loader disagree on which
277 // models fuse.
278 let inter = if config.intermediate_size > 0 {
279 config.intermediate_size
280 } else {
281 config.moe_intermediate_size
282 };
283 let ffn_layers = config.num_hidden_layers as u64;
284 let fused_here = route.ffn_gateup_fused
285 && config.num_experts == 0
286 && inter > 0
287 && inter.is_multiple_of(128)
288 && hidden.is_multiple_of(128);
289 let (ffn_gateup_fused, ffn_gateup_pruned) = if fused_here {
290 // The WEIGHT term on both sides: the fused buffer is the two
291 // `[inter, hidden]` store tensors copied side by side, and
292 // `prune_after_load` releases exactly those two. The scale grid is
293 // deliberately absent from both — see the field docs.
294 let (w, _scales) = fp8_residency::ffn_gateup_fused_parts(hidden, inter);
295 (ffn_layers * w as u64, ffn_layers * w as u64)
296 } else {
297 (0, 0)
298 };
299
300 DerivedBytesEstimate::NativeFp8Dense(PredictedDerived {
301 attn_fp8_twins,
302 ssm_fp8_concat,
303 ffn_gateup_fused,
304 ffn_gateup_pruned,
305 twins: TwinsBuilt {
306 ffn_nvfp4: false,
307 attn_nvfp4: false,
308 attn_fp8: plan.attn_fp8_twins.any() && attn_layers > 0,
309 ssm_fp8_concat: ssm_fp8_concat > 0,
310 ffn_gateup_fused: ffn_gateup_fused > 0,
311 },
312 attn_twin_set: plan.attn_fp8_twins,
313 })
314}
315
316/// What ONE native-FP8 GDN layer keeps beyond the checkpoint bytes.
317///
318/// Mirrors `qwen35_dense.rs:1163`-`:1181` term for term:
319///
320/// * the fused `[QKV|Z]` E4M3 weight `concat_fp8_block_scaled` builds —
321/// `ssm_qkvz_size() x hidden` bytes, and there is no un-fused dispatch to
322/// fall back to, so it stays resident;
323/// * the concatenated `[N/128, K/128]` FP32 block-scale grid, which is the
324/// two source grids copied side by side (hence the sum, not one grid over
325/// the fused N — `ceil` of a sum is not the sum of the `ceil`s);
326/// * the `out_proj` block-scale grid, adopted for the same reason;
327/// * `in_proj_ba`, the `[2*nv, hidden]` BF16 interleave of `in_proj_a` and
328/// `in_proj_b`.
329///
330/// The per-projection source scale allocations are FREED right after the
331/// concat (`residency.free`), so they are transient and correctly absent.
332fn ssm_concat_bytes(config: &ModelConfig) -> usize {
333 let hidden = config.hidden_size;
334 let block_grid = |n: usize, k: usize| n.div_ceil(128) * k.div_ceil(128) * 4;
335 let qkv_n = config.ssm_qkv_size();
336 let z_n = config.ssm_z_size();
337 let qkvz_bytes = config.ssm_qkvz_size() * hidden;
338 let qkvz_scale_bytes = block_grid(qkv_n, hidden) + block_grid(z_n, hidden);
339 // `out_proj` is `[hidden, value_dim]`.
340 let out_scale_bytes = block_grid(hidden, config.ssm_z_size());
341 let ba_bytes = config.linear_num_value_heads * 2 * hidden * 2;
342 qkvz_bytes + qkvz_scale_bytes + out_scale_bytes + ba_bytes
343}
344
345#[cfg(test)]
346#[path = "predicted_residency_tests.rs"]
347mod tests;