1use 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#[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 pub headroom_fraction: f64,
45 pub kv_elem: KvElem,
49}
50
51impl Default for ResidencyAssumptions {
52 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#[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 pub budget_bytes: u64,
81 pub usable_bytes: u64,
83 pub assumptions: ResidencyAssumptions,
84 pub weights_bytes: u64,
88 pub kv_shape: KvShape,
90}
91
92impl ResidencyReport {
93 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 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 let weights_bytes = lines.iter().map(|l| l.bytes).sum();
168
169 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 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 pub fn auto_context(&self, cap: usize) -> ContextFit {
226 self.kv_budget().max_context(cap, CTX_AUTO_GRANULARITY)
227 }
228
229 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 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 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 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 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 #[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 #[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 #[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}