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