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