1use 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#[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 pub headroom_fraction: f64,
46 pub kv_elem: KvElem,
50 pub kv_window: KvWindowPolicy,
59}
60
61impl Default for ResidencyAssumptions {
62 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#[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 pub budget_bytes: u64,
92 pub usable_bytes: u64,
94 pub assumptions: ResidencyAssumptions,
95 pub weights_bytes: u64,
99 pub kv_shape: KvShape,
101 pub kv_residency: KvResidency,
107}
108
109impl ResidencyReport {
110 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 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 let weights_bytes = lines.iter().map(|l| l.bytes).sum();
185
186 let kv_shape = KvShape::from_config(&config, assumptions.kv_elem);
192 let kv_residency = KvResidency::from_config(&config, assumptions.kv_window);
193 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 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 pub fn auto_context(&self, cap: usize) -> ContextFit {
261 self.kv_budget().max_context(cap, CTX_AUTO_GRANULARITY)
262 }
263
264 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 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 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 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 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 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 #[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 #[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 #[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}