metrale_model_arch/weight_loader/qwen35_dense/predicted_residency.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The derived-weight bytes of the native-FP8 dense route,
4//! predicted from the config and the environment before the checkpoint loads.
5//!
6//! Owner: model-arch weight loader (Qwen3.5 dense).
7//! Invariants:
8//! - `predicted_derived_bytes` reads no environment and allocates nothing;
9//! `Fp8RouteInputs::from_env` does the reads.
10//! - A route whose plan keeps an NVFP4 copy (`METRALE_DENSE_FP8_KEEP_NVFP4`,
11//! a CUTLASS NVFP4 attention lever, `METRALE_ATTN_W4A4`) gets
12//! [`DerivedBytesEstimate::Unavailable`].
13//!
14//! The server's preflight (`post_load_yardstick` in
15//! `serve_phases/preflight/headroom.rs`) adds the prediction to the
16//! checkpoint's on-disk size to estimate the memory in use before the KV
17//! cache. When the prediction is unavailable, it uses pre-load free memory.
18//! Which copies are built is [`fp8_residency::DenseFp8Plan::resolve`]; their
19//! sizes are shape arithmetic over the config. The loader tallies NVFP4
20//! copies through `DerivedResidency::skip`, never `keep`, so a route that
21//! builds them is not predicted.
22
23use metrale_config::ModelConfig;
24
25use super::fp8_residency::{self, RouteEnv, TwinsBuilt};
26use metrale_model_layers::layers::qwen3_attention::Fp8TwinSet;
27use metrale_model_layers::weight_map::Nvfp4Variant;
28
29/// 2026-09-25: The derived bytes the native-FP8 dense loader will keep, by
30/// term.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub struct PredictedDerived {
33 /// 2026-09-25: `Fp8Weight::transpose_for_gemm` twins for the projections
34 /// [`fp8_residency::DenseFp8Plan`] selects, summed over full-attention
35 /// layers.
36 pub attn_fp8_twins: u64,
37 /// 2026-09-25: The fused `[QKV|Z]` FP8 weight, its block-scale grids, the
38 /// `out_proj` block-scale grid and the interleaved `in_proj_ba`, summed
39 /// over linear-attention layers (`ssm_concat_bytes`).
40 pub ssm_fp8_concat: u64,
41 /// 2026-09-25: The E4M3 bytes of the fused `[2*inter, hidden]` gate+up
42 /// weight, summed over layers; its scale grid is not counted. 0 when the
43 /// fusion is not selected.
44 pub ffn_gateup_fused: u64,
45 /// 2026-09-25: The `mlp.gate_proj.weight` and `mlp.up_proj.weight` bytes
46 /// that `prune_after_load` releases on the same layers. Equal to
47 /// [`Self::ffn_gateup_fused`]: the fused weight is those two tensors side
48 /// by side.
49 pub ffn_gateup_pruned: u64,
50 /// 2026-09-25: Which derived families the prediction expects.
51 pub twins: TwinsBuilt,
52 /// 2026-09-25: The twin set the attention term was priced at.
53 pub attn_twin_set: Fp8TwinSet,
54}
55
56impl PredictedDerived {
57 /// 2026-09-25: Derived bytes above the on-disk checkpoint size, which
58 /// `headroom.rs` adds to that size. The fusion enters as a difference,
59 /// which is zero: the on-disk size still counts the gate and up tensors
60 /// that `prune_after_load` releases once the fused copy exists
61 /// (`the_gateup_fusion_is_residency_neutral`).
62 pub fn total(&self) -> u64 {
63 self.attn_fp8_twins
64 + self.ssm_fp8_concat
65 + self.ffn_gateup_fused.saturating_sub(self.ffn_gateup_pruned)
66 }
67}
68
69/// 2026-09-25: A prediction, or the reason there is none.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum DerivedBytesEstimate {
72 /// 2026-09-25: The native-FP8 dense route will run, with these bytes.
73 NativeFp8Dense(PredictedDerived),
74 /// 2026-09-25: No prediction; the string says why, and the preflight
75 /// carries it in `Yardstick::PreLoadFree`.
76 Unavailable(&'static str),
77}
78
79impl DerivedBytesEstimate {
80 /// 2026-09-25: [`PredictedDerived::total`], or `None` when unavailable.
81 pub fn bytes(&self) -> Option<u64> {
82 match self {
83 Self::NativeFp8Dense(p) => Some(p.total()),
84 Self::Unavailable(_) => None,
85 }
86 }
87
88 pub fn reason(&self) -> Option<&'static str> {
89 match self {
90 Self::NativeFp8Dense(_) => None,
91 Self::Unavailable(why) => Some(*why),
92 }
93 }
94}
95
96/// 2026-09-25: The route conditions of `load_layers` that are known before the
97/// checkpoint loads.
98#[derive(Clone, Copy, Debug)]
99pub struct Fp8RouteInputs {
100 /// 2026-09-25: `METRALE_DENSE_FP8=1` (`qwen35_dense::dense_fp8_enabled`).
101 pub dense_fp8: bool,
102 /// 2026-09-25: `config.tp_world_size.max(1)`; the FP8 dense route needs 1.
103 pub tp_size: usize,
104 /// 2026-09-25: The variant the config declares (`config_declared_variant`),
105 /// or `None` when it declares none.
106 pub declared_variant: Option<Nvfp4Variant>,
107 /// 2026-09-25: `METRALE_NO_GDN_FP8` is unset, a clause of
108 /// `qwen35_dense::gdn_fp8_arm_selected`.
109 pub gdn_fp8: bool,
110 /// 2026-09-25: Both `qwen3_attention::W8A8_PREFILL_KERNELS` are loaded for
111 /// this target; it decides whether the Q and O FP8 twins are planned.
112 pub w8a8_prefill_kernels: bool,
113 /// 2026-09-25: The environment half of the loader's plan.
114 pub route: RouteEnv,
115 /// 2026-09-25: `gateup_fused::ffn_gateup_fused()`, which
116 /// `qwen35_dense::ffn_gateup_fused_selected` also requires.
117 pub ffn_gateup_fused: bool,
118}
119
120impl Fp8RouteInputs {
121 /// 2026-09-25: Resolve each condition from the environment and the config.
122 /// `w8a8_prefill_kernels` comes from the caller, which asks
123 /// `qwen3_attention::w8a8_prefill_kernels_loaded` of the loaded kernel set.
124 pub fn from_env(config: &ModelConfig, w8a8_prefill_kernels: bool) -> Self {
125 Self {
126 // 2026-09-25: The predicate of `qwen35_dense::dense_fp8_enabled`.
127 dense_fp8: std::env::var("METRALE_DENSE_FP8").as_deref() == Ok("1"),
128 tp_size: config.tp_world_size.max(1),
129 declared_variant: metrale_model_layers::weight_map::config_declared_variant(config),
130 // 2026-09-25: The predicate of `gdn_fp8_arm_selected`.
131 gdn_fp8: std::env::var_os("METRALE_NO_GDN_FP8").is_none(),
132 w8a8_prefill_kernels,
133 route: RouteEnv::from_env(),
134 ffn_gateup_fused:
135 metrale_model_layers::layers::dense_ffn::gateup_fused::ffn_gateup_fused(),
136 }
137 }
138}
139
140/// 2026-09-25: Bytes the native-FP8 dense loader will derive on top of the
141/// checkpoint, or why that is not predicted. Reads only its arguments.
142pub fn predicted_derived_bytes(
143 config: &ModelConfig,
144 route: &Fp8RouteInputs,
145) -> DerivedBytesEstimate {
146 // 2026-09-25: `factory::loader_for_config` picks `Qwen35DenseWeightLoader`
147 // when `is_qwen35_dense()` holds.
148 if !config.is_qwen35_dense() {
149 return DerivedBytesEstimate::Unavailable("not the Qwen3.5-dense loader");
150 }
151 if !route.dense_fp8 {
152 return DerivedBytesEstimate::Unavailable("METRALE_DENSE_FP8 is not 1");
153 }
154 if route.tp_size != 1 {
155 return DerivedBytesEstimate::Unavailable("--tp-size > 1 takes the NVFP4 route");
156 }
157 if route.declared_variant != Some(Nvfp4Variant::Fp8Dequanted) {
158 return DerivedBytesEstimate::Unavailable(
159 "config.json does not declare a block-scaled FP8 checkpoint",
160 );
161 }
162 if route.route.keep_nvfp4 {
163 return DerivedBytesEstimate::Unavailable(
164 "METRALE_DENSE_FP8_KEEP_NVFP4 restores the pre-#915 fallback copies",
165 );
166 }
167
168 // 2026-09-25: Decline when the plan keeps an NVFP4 copy (module docs).
169 // With `ffn_fp8` true, `ffn_nvfp4` is false, so only `attn_nvfp4` can hold.
170 let plan = fp8_residency::DenseFp8Plan::resolve(fp8_residency::DenseFp8Inputs {
171 ffn_fp8: true,
172 attn_fp8: true,
173 keep_nvfp4: false,
174 dispatch: route.route.dispatch,
175 w8a8_kernels: route.w8a8_prefill_kernels,
176 attn_w4a4: route.route.attn_w4a4,
177 attn_prefill_q_t: route.route.attn_prefill_q_t,
178 });
179 if plan.ffn_nvfp4 || plan.attn_nvfp4 {
180 return DerivedBytesEstimate::Unavailable(
181 "an NVFP4 fallback lever (METRALE_CUTLASS_NVFP4_* / METRALE_ATTN_W4A4) is set",
182 );
183 }
184
185 let hidden = config.hidden_size;
186 let (nh, hd) = (config.num_attention_heads, config.head_dim);
187 let nkv = config.num_key_value_heads;
188 let q_n = nh * hd * if config.attn_gated { 2 } else { 1 };
189 let attn_layers = config.num_attention_layers() as u64;
190 let attn_fp8_twins = attn_layers
191 * fp8_residency::attn_fp8_twin_bytes(plan.attn_fp8_twins, q_n, nkv * hd, nh * hd, hidden)
192 as u64;
193
194 let ssm_layers = config.num_ssm_layers() as u64;
195 let ssm_fp8_concat = if route.gdn_fp8 && ssm_layers > 0 {
196 ssm_layers * ssm_concat_bytes(config) as u64
197 } else {
198 0
199 };
200
201 // 2026-09-25: The clauses of `qwen35_dense::ffn_gateup_fused_selected` that
202 // need no checkpoint: the switch, a dense model, and both widths whole
203 // 128-blocks. `inter` falls back as `qwen35_dense::ffn_inter` does.
204 let inter = if config.intermediate_size > 0 {
205 config.intermediate_size
206 } else {
207 config.moe_intermediate_size
208 };
209 let ffn_layers = config.num_hidden_layers as u64;
210 let fused_here = route.ffn_gateup_fused
211 && config.num_experts == 0
212 && inter > 0
213 && inter.is_multiple_of(128)
214 && hidden.is_multiple_of(128);
215 let (ffn_gateup_fused, ffn_gateup_pruned) = if fused_here {
216 let (w, _scales) = fp8_residency::ffn_gateup_fused_parts(hidden, inter);
217 (ffn_layers * w as u64, ffn_layers * w as u64)
218 } else {
219 (0, 0)
220 };
221
222 DerivedBytesEstimate::NativeFp8Dense(PredictedDerived {
223 attn_fp8_twins,
224 ssm_fp8_concat,
225 ffn_gateup_fused,
226 ffn_gateup_pruned,
227 twins: TwinsBuilt {
228 ffn_nvfp4: false,
229 attn_nvfp4: false,
230 attn_fp8: plan.attn_fp8_twins.any() && attn_layers > 0,
231 ssm_fp8_concat: ssm_fp8_concat > 0,
232 ffn_gateup_fused: ffn_gateup_fused > 0,
233 },
234 attn_twin_set: plan.attn_fp8_twins,
235 })
236}
237
238/// 2026-09-25: What one native-FP8 GDN layer keeps beyond the checkpoint
239/// bytes: the four buffers the loader's native FP8 GDN arm adopts and counts
240/// with `residency.keep`.
241///
242/// - The fused `[QKV|Z]` E4M3 weight from `concat_fp8_block_scaled`.
243/// - Its FP32 block-scale grid: the two source grids side by side, so the sum
244/// of two grids, not one grid over the fused N.
245/// - The `out_proj` block-scale grid.
246/// - `in_proj_ba`, the `[2*nv, hidden]` BF16 interleave of `in_proj_a` and
247/// `in_proj_b`.
248///
249/// The per-projection source grids are freed after the concat, so they are
250/// not counted.
251fn ssm_concat_bytes(config: &ModelConfig) -> usize {
252 let hidden = config.hidden_size;
253 let block_grid = |n: usize, k: usize| n.div_ceil(128) * k.div_ceil(128) * 4;
254 let qkv_n = config.ssm_qkv_size();
255 let z_n = config.ssm_z_size();
256 let qkvz_bytes = config.ssm_qkvz_size() * hidden;
257 let qkvz_scale_bytes = block_grid(qkv_n, hidden) + block_grid(z_n, hidden);
258 let out_scale_bytes = block_grid(hidden, config.ssm_z_size());
259 let ba_bytes = config.linear_num_value_heads * 2 * hidden * 2;
260 qkvz_bytes + qkvz_scale_bytes + out_scale_bytes + ba_bytes
261}
262
263#[cfg(test)]
264#[path = "predicted_residency_tests.rs"]
265mod tests;