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 pub prefill_chunk: usize,
53}
54
55impl Default for ResidencyAssumptions {
56 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#[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 pub budget_bytes: u64,
86 pub usable_bytes: u64,
88 pub assumptions: ResidencyAssumptions,
89 pub weights_bytes: u64,
93 pub kv_shape: KvShape,
95}
96
97impl ResidencyReport {
98 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 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 let weights_bytes = lines.iter().map(|l| l.bytes).sum();
173
174 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 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 pub fn auto_context(&self, cap: usize) -> ContextFit {
231 self.kv_budget().max_context(cap, CTX_AUTO_GRANULARITY)
232 }
233
234 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 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 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 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 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 #[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 #[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 #[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}