Skip to main content

ferrox_models/
residency_report.rs

1//! Dry-run residency planning: what a checkpoint would cost to run,
2//! computed from its GGUF header alone -- no weights loaded, no
3//! allocation. This is the "plan before you allocate" half of the
4//! residency workstream; the enforcement half at decode time is the
5//! bounded expert store (`ferrox_core::expert_store`) plus the global
6//! device plan (`ferrox_moe::ResidencyPlan`).
7//!
8//! Accounting model (deliberately explicit about what it does NOT yet
9//! cover): dense/always-resident weight bytes, routed-expert bytes
10//! (resident, or capped by the streaming budget), per-request KV-cache
11//! bytes at a stated context length, request concurrency, and a safety
12//! headroom fraction. Not yet accounted: activation/scratch buffers,
13//! tokenizer/runtime overhead, GPU placement (VRAM budgets are planned
14//! separately by `ResidencyPlan`), and Kimi's recurrent state (this
15//! report reads GGUF headers; the Kimi safetensors path has no
16//! header-only reporter yet).
17//!
18//! The KV line and the fits/does-not-fit verdict are the same
19//! arithmetic [`crate::kv_budget`] runs -- this module is the
20//! whole-checkpoint view of it (it is the one that knows what the
21//! weights cost), and [`ResidencyReport::kv_budget`] hands back the
22//! priced inequality so `--ctx auto` and the server's admission check
23//! cannot drift away from what `inspect-plan` prints.
24
25use ferrox_gguf::ShardedGguf;
26
27use crate::config::ModelConfig;
28use crate::device_budget::human;
29use crate::kv_budget::{ContextFit, KvBudget, KvElem, KvShape, CTX_AUTO_GRANULARITY};
30use crate::loader::LoadError;
31
32/// The inputs a plan is computed against. `expert_cache_bytes: None`
33/// means routed experts load resident (mmap); `Some(budget)` means
34/// they stream through the bounded store and cost at most `budget`
35/// resident bytes.
36#[derive(Debug, Clone, Copy)]
37pub struct ResidencyAssumptions {
38    pub context_tokens: usize,
39    pub concurrent_requests: usize,
40    pub expert_cache_bytes: Option<u64>,
41    /// Fraction of the budget held back for everything this report does
42    /// not model (activations, allocator slack, OS). 0.2 is the
43    /// default the CLI uses.
44    pub headroom_fraction: f64,
45    /// Width of the KV store the run will actually keep -- f32 for the
46    /// host cache, f16 for Metal's device KV, and so on. Only this
47    /// module's caller knows which backend is selected.
48    pub kv_elem: KvElem,
49}
50
51impl Default for ResidencyAssumptions {
52    /// Single request, 4096 tokens, resident experts, host f32 KV --
53    /// the CLI's own defaults.
54    fn default() -> Self {
55        ResidencyAssumptions {
56            context_tokens: 4096,
57            concurrent_requests: 1,
58            expert_cache_bytes: None,
59            headroom_fraction: 0.2,
60            kv_elem: KvElem::F32,
61        }
62    }
63}
64
65/// One line of the plan, with the reason it costs what it costs.
66#[derive(Debug, Clone)]
67pub struct ResidencyLine {
68    pub label: String,
69    pub bytes: u64,
70    pub reason: String,
71}
72
73#[derive(Debug, Clone)]
74pub struct ResidencyReport {
75    pub lines: Vec<ResidencyLine>,
76    pub required_bytes: u64,
77    /// The ceiling this plan was priced against: a probed
78    /// [`crate::device_budget::DeviceBudget::usable_bytes`], or a
79    /// hypothetical machine's size for what-if planning.
80    pub budget_bytes: u64,
81    /// `budget_bytes` minus the headroom fraction.
82    pub usable_bytes: u64,
83    pub assumptions: ResidencyAssumptions,
84    /// Weight bytes the plan charged (dense plus whatever the expert
85    /// line resolved to), kept separately from the KV term so
86    /// [`Self::kv_budget`] can rebuild the inequality exactly.
87    pub weights_bytes: u64,
88    /// The KV geometry this checkpoint runs on.
89    pub kv_shape: KvShape,
90}
91
92impl ResidencyReport {
93    /// Computes the plan for the checkpoint at `path` (any shard of a
94    /// split set, or a single file) against `budget_bytes` (pass a
95    /// probed [`crate::device_budget::DeviceBudget::usable_bytes`], or
96    /// a hypothetical machine's size for what-if planning).
97    pub fn from_gguf(
98        path: impl AsRef<std::path::Path>,
99        assumptions: ResidencyAssumptions,
100        budget_bytes: u64,
101    ) -> Result<Self, LoadError> {
102        let file = ShardedGguf::open(path)?;
103        let config = ModelConfig::from_gguf(&file)?;
104
105        let mut dense_bytes: u64 = 0;
106        let mut routed_bytes: u64 = 0;
107        let mut routed_tensors = 0usize;
108        // A tensor whose dtype this build cannot size contributes
109        // nothing here, which is what the old `byte_len() -> 0` did
110        // implicitly. It is explicit now, and counted, so a footprint
111        // that silently omits tensors says so rather than reading as a
112        // smaller model.
113        let mut unsized_tensors = 0usize;
114        for (_, t) in file.tensors() {
115            let Some(bytes) = t.byte_len() else {
116                unsized_tensors += 1;
117                continue;
118            };
119            if t.name.contains("_exps.weight") {
120                routed_bytes += bytes as u64;
121                routed_tensors += 1;
122            } else {
123                dense_bytes += bytes as u64;
124            }
125        }
126        if unsized_tensors > 0 {
127            eprintln!(
128                "ferrox: {unsized_tensors} tensor(s) have a dtype this build cannot size; \
129                 the footprint below EXCLUDES them and is therefore a lower bound"
130            );
131        }
132
133        let mut lines = Vec::new();
134        lines.push(ResidencyLine {
135            label: "dense weights".to_string(),
136            bytes: dense_bytes,
137            reason: "attention/norms/router/shared-expert/embedding/output tensors, \
138                     always resident (quantized in place, mmap or owned)"
139                .to_string(),
140        });
141
142        let expert_line = match assumptions.expert_cache_bytes {
143            Some(budget) => {
144                let capped = budget.min(routed_bytes);
145                ResidencyLine {
146                    label: "routed experts (streamed)".to_string(),
147                    bytes: capped,
148                    reason: format!(
149                        "{routed_tensors} packed expert tensors totalling {routed_bytes} \
150                         bytes on disk, streamed through a bounded cache of {budget} bytes \
151                         (resident cost = min(budget, total))"
152                    ),
153                }
154            }
155            None => ResidencyLine {
156                label: "routed experts (resident)".to_string(),
157                bytes: routed_bytes,
158                reason: format!(
159                    "{routed_tensors} packed expert tensors, loaded as zero-copy mmap views \
160                     -- resident under memory pressure only via OS page cache eviction; \
161                     enable expert streaming to bound this explicitly"
162                ),
163            },
164        };
165        lines.push(expert_line);
166        // Everything charged so far is weights; the KV line follows.
167        let weights_bytes = lines.iter().map(|l| l.bytes).sum();
168
169        // KV cache at the stated context, per concurrent request --
170        // `kv_budget`'s arithmetic, so an MLA checkpoint is priced the
171        // way it will really run rather than as a GQA one, and a
172        // windowed checkpoint is priced at what the store keeps rather
173        // than at what attention reads.
174        let kv_shape = KvShape::from_config(&config, assumptions.kv_elem);
175        let kv_per_request = kv_shape.kv_bytes_for_tokens(assumptions.context_tokens);
176        lines.push(ResidencyLine {
177            label: "KV caches".to_string(),
178            bytes: kv_per_request * assumptions.concurrent_requests as u64,
179            reason: format!(
180                "{} at {} context tokens = {kv_per_request} bytes/request, x {} concurrent \
181                 requests",
182                kv_shape.describe(),
183                assumptions.context_tokens,
184                assumptions.concurrent_requests
185            ),
186        });
187
188        let required_bytes = lines.iter().map(|l| l.bytes).sum();
189        let usable_bytes = (budget_bytes as f64 * (1.0 - assumptions.headroom_fraction)) as u64;
190        Ok(ResidencyReport {
191            lines,
192            required_bytes,
193            budget_bytes,
194            usable_bytes,
195            assumptions,
196            weights_bytes,
197            kv_shape,
198        })
199    }
200
201    pub fn fits(&self) -> bool {
202        self.required_bytes <= self.usable_bytes
203    }
204
205    /// The same plan expressed as the priced inequality, so
206    /// `--ctx auto` and the server's admission check reuse this
207    /// report's terms instead of recomputing their own.
208    ///
209    /// The headroom fraction becomes the `activation_headroom_bytes`
210    /// term rather than shrinking the budget, which is what makes
211    /// `kv_budget().check(context_tokens).is_ok()` agree with
212    /// [`Self::fits`] exactly.
213    pub fn kv_budget(&self) -> KvBudget {
214        KvBudget {
215            weights_bytes: self.weights_bytes,
216            activation_headroom_bytes: self.budget_bytes - self.usable_bytes,
217            device_budget_bytes: self.budget_bytes,
218            shape: self.kv_shape,
219            concurrent_requests: self.assumptions.concurrent_requests,
220        }
221    }
222
223    /// Largest context that fits this plan, capped at `cap` (the
224    /// model's own trained context length).
225    pub fn auto_context(&self, cap: usize) -> ContextFit {
226        self.kv_budget().max_context(cap, CTX_AUTO_GRANULARITY)
227    }
228
229    /// Strict mode: an `Err` with the full plan text when the plan
230    /// overcommits the usable budget. Callers refuse to load on `Err`.
231    pub fn check_strict(&self) -> Result<(), String> {
232        if self.fits() {
233            Ok(())
234        } else {
235            Err(format!(
236                "residency plan overcommits: requires {} bytes but only {} usable \
237                 ({} budget minus {:.0}% headroom)\n{self}",
238                self.required_bytes,
239                self.usable_bytes,
240                self.budget_bytes,
241                self.assumptions.headroom_fraction * 100.0
242            ))
243        }
244    }
245}
246
247impl std::fmt::Display for ResidencyReport {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        writeln!(
250            f,
251            "residency plan (context={}, concurrency={}, kv={}, headroom={:.0}%):",
252            self.assumptions.context_tokens,
253            self.assumptions.concurrent_requests,
254            self.assumptions.kv_elem.as_str(),
255            self.assumptions.headroom_fraction * 100.0
256        )?;
257        for line in &self.lines {
258            writeln!(
259                f,
260                "  {:<28} {:>12}  {}",
261                line.label,
262                human(line.bytes),
263                line.reason
264            )?;
265        }
266        writeln!(
267            f,
268            "  {:<28} {:>12}",
269            "TOTAL required",
270            human(self.required_bytes)
271        )?;
272        writeln!(
273            f,
274            "  {:<28} {:>12}  ({} device budget minus headroom)",
275            "usable budget",
276            human(self.usable_bytes),
277            human(self.budget_bytes)
278        )?;
279        write!(
280            f,
281            "  verdict: {}",
282            if self.fits() {
283                "FITS"
284            } else {
285                "DOES NOT FIT (strict mode refuses to load)"
286            }
287        )
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn fixture(name: &str) -> String {
296        format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
297    }
298
299    fn assumptions(cache: Option<u64>) -> ResidencyAssumptions {
300        ResidencyAssumptions {
301            context_tokens: 128,
302            concurrent_requests: 2,
303            expert_cache_bytes: cache,
304            ..ResidencyAssumptions::default()
305        }
306    }
307
308    #[test]
309    fn moe_fixture_plan_accounts_experts_kv_and_streaming_cap() {
310        let path = fixture("ferrox_real_moe_test.gguf");
311
312        let resident = ResidencyReport::from_gguf(&path, assumptions(None), 1 << 30).expect("plan");
313        let experts_resident = resident.lines[1].bytes;
314        assert!(experts_resident > 0, "MoE fixture has routed expert bytes");
315
316        // Streaming with a tiny budget caps the expert line at the
317        // budget; everything else is identical.
318        let streamed =
319            ResidencyReport::from_gguf(&path, assumptions(Some(100)), 1 << 30).expect("plan");
320        assert_eq!(streamed.lines[1].bytes, 100);
321        assert_eq!(streamed.lines[0].bytes, resident.lines[0].bytes);
322        assert_eq!(streamed.lines[2].bytes, resident.lines[2].bytes);
323        assert_eq!(
324            resident.required_bytes - streamed.required_bytes,
325            experts_resident - 100
326        );
327
328        // A budget larger than the experts costs only the experts.
329        let big =
330            ResidencyReport::from_gguf(&path, assumptions(Some(u64::MAX)), 1 << 30).expect("plan");
331        assert_eq!(big.lines[1].bytes, experts_resident);
332
333        // KV arithmetic is exactly the documented formula.
334        let file = ShardedGguf::open(&path).unwrap();
335        let cfg = ModelConfig::from_gguf(&file).unwrap();
336        let expected_kv = (cfg.n_layers * 2 * cfg.n_kv_heads * cfg.head_dim * 4 * 128 * 2) as u64;
337        assert_eq!(resident.lines[2].bytes, expected_kv);
338    }
339
340    #[test]
341    fn strict_mode_refuses_overcommit_and_accepts_a_fitting_plan() {
342        let path = fixture("ferrox_real_moe_test.gguf");
343        let fits = ResidencyReport::from_gguf(&path, assumptions(None), 1 << 30).unwrap();
344        assert!(fits.check_strict().is_ok());
345
346        // A "machine" with 1 byte of RAM cannot fit anything.
347        let no_fit = ResidencyReport::from_gguf(&path, assumptions(None), 1).unwrap();
348        let err = no_fit.check_strict().expect_err("must refuse");
349        assert!(err.contains("overcommits"), "{err}");
350        assert!(err.contains("DOES NOT FIT"), "{err}");
351    }
352
353    /// The report's verdict and the priced inequality must be the same
354    /// answer, or `inspect-plan` would print one thing and admission
355    /// would enforce another.
356    #[test]
357    fn kv_budget_view_agrees_with_the_reports_own_verdict() {
358        let path = fixture("ferrox_real_moe_test.gguf");
359        for budget in [1u64, 1 << 20, 1 << 30, u64::MAX / 4] {
360            let report = ResidencyReport::from_gguf(&path, assumptions(None), budget).unwrap();
361            let priced = report.kv_budget();
362            assert_eq!(
363                priced.check(report.assumptions.context_tokens).is_ok(),
364                report.fits(),
365                "budget {budget}: verdict and priced inequality disagree"
366            );
367            assert_eq!(
368                priced.estimated_bytes(report.assumptions.context_tokens),
369                report.required_bytes + (report.budget_bytes - report.usable_bytes),
370                "budget {budget}: the priced total must be the plan's total plus headroom"
371            );
372        }
373    }
374
375    /// `--ctx auto` on a real header: the chosen context must actually
376    /// pass the same check, and one granularity step further must not.
377    #[test]
378    fn auto_context_picks_a_context_that_really_fits() {
379        let path = fixture("ferrox_real_moe_test.gguf");
380        let report =
381            ResidencyReport::from_gguf(&path, assumptions(None), 64 * 1024 * 1024).unwrap();
382        let fit = report.auto_context(131_072);
383        let priced = report.kv_budget();
384        assert!(fit.tokens > 0, "64 MiB must fit some context: {fit}");
385        assert!(priced.check(fit.tokens).is_ok(), "{fit}");
386        if fit.capped_by == crate::kv_budget::ContextCap::DeviceBudget {
387            assert!(
388                priced.check(fit.tokens + fit.granularity).is_err(),
389                "auto context left a whole granularity step on the table: {fit}"
390            );
391        }
392    }
393
394    /// A checkpoint whose header declares a sliding window is priced
395    /// exactly like one that does not, because no KV store this engine
396    /// allocates ever evicts a position (#33). This report used to make
397    /// a windowed model look ~60x cheaper at 32k, which is a plan an
398    /// operator can act on and a machine cannot honour.
399    #[test]
400    fn a_sliding_window_config_is_priced_like_a_full_attention_one() {
401        let mut cfg = crate::config::test_dense_fixture();
402        cfg.n_layers = 8;
403        cfg.n_kv_heads = 4;
404        cfg.head_dim = 64;
405        cfg.sliding_window = None;
406        cfg.swa_pattern = None;
407        let full = KvShape::from_config(&cfg, KvElem::F32);
408
409        cfg.sliding_window = Some(512);
410        cfg.swa_pattern = Some(6);
411        let windowed = KvShape::from_config(&cfg, KvElem::F32);
412
413        assert_eq!(
414            full.kv_bytes_for_tokens(512),
415            windowed.kv_bytes_for_tokens(512)
416        );
417        assert_eq!(
418            full.kv_bytes_for_tokens(32_768),
419            windowed.kv_bytes_for_tokens(32_768)
420        );
421    }
422}