1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum BudgetBackend {
40 Cpu,
41 Metal,
42 Cuda,
43}
44
45impl BudgetBackend {
46 pub fn as_str(self) -> &'static str {
47 match self {
48 BudgetBackend::Cpu => "cpu",
49 BudgetBackend::Metal => "metal",
50 BudgetBackend::Cuda => "cuda",
51 }
52 }
53}
54
55impl std::fmt::Display for BudgetBackend {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.write_str(self.as_str())
58 }
59}
60
61impl std::str::FromStr for BudgetBackend {
64 type Err = String;
65
66 fn from_str(value: &str) -> Result<Self, Self::Err> {
67 match value.trim().to_ascii_lowercase().as_str() {
68 "cpu" | "host" => Ok(BudgetBackend::Cpu),
69 "metal" => Ok(BudgetBackend::Metal),
70 "cuda" => Ok(BudgetBackend::Cuda),
71 other => Err(format!("unknown backend `{other}` (cpu, metal, cuda)")),
72 }
73 }
74}
75
76pub const CPU_RESERVE_FRACTION: f64 = 0.2;
80
81pub const DEVICE_RESERVE_FRACTION: f64 = 0.1;
85
86pub const BUDGET_ENV: &str = "FERROX_DEVICE_BUDGET_BYTES";
91
92#[derive(Debug, Clone, PartialEq)]
96pub struct DeviceBudget {
97 pub backend: BudgetBackend,
98 pub total_bytes: u64,
100 pub usable_bytes: u64,
102 pub reserve_fraction: f64,
104 pub source: String,
107 pub approximate: bool,
113}
114
115impl DeviceBudget {
116 pub fn new(backend: BudgetBackend, total_bytes: u64, reserve: f64, source: String) -> Self {
118 let reserve = reserve.clamp(0.0, 1.0);
119 DeviceBudget {
120 backend,
121 total_bytes,
122 usable_bytes: (total_bytes as f64 * (1.0 - reserve)) as u64,
123 reserve_fraction: reserve,
124 source,
125 approximate: true,
126 }
127 }
128
129 pub fn detect(backend: BudgetBackend) -> Self {
137 if let Some(bytes) = env_override() {
138 return DeviceBudget {
139 backend,
140 total_bytes: bytes,
141 usable_bytes: bytes,
142 reserve_fraction: 0.0,
143 source: format!("{BUDGET_ENV} override (no reserve applied)"),
144 approximate: true,
145 };
146 }
147 match backend {
148 BudgetBackend::Metal => metal_budget(),
149 BudgetBackend::Cuda => cuda_budget(),
150 BudgetBackend::Cpu => host_ram_budget(),
151 }
152 }
153
154 pub fn is_unknown(&self) -> bool {
158 self.total_bytes == 0
159 }
160
161 pub fn usable_provenance(&self) -> String {
169 if self.is_unknown() {
170 return self.source.clone();
171 }
172 format!(
173 "{:.0}% of {} {}, {:.0}% held back",
174 (1.0 - self.reserve_fraction) * 100.0,
175 self.total_bytes,
176 self.source,
177 self.reserve_fraction * 100.0,
178 )
179 }
180
181 pub fn caveat(&self) -> &'static str {
183 "approximate: ferrox mmaps quantized weights, so how much of them stays resident is \
184 the kernel's page cache to decide; this charges the whole checkpoint, which is an \
185 upper bound, and the budget itself is a snapshot, not a reservation"
186 }
187}
188
189impl std::fmt::Display for DeviceBudget {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 if self.is_unknown() {
192 return write!(
193 f,
194 "{} budget: unknown ({}); no ceiling enforced",
195 self.backend, self.source
196 );
197 }
198 write!(
199 f,
200 "{} budget: {} usable of {} total ({:.0}% held back) via {}",
201 self.backend,
202 human(self.usable_bytes),
203 human(self.total_bytes),
204 self.reserve_fraction * 100.0,
205 self.source
206 )
207 }
208}
209
210fn env_override() -> Option<u64> {
211 std::env::var(BUDGET_ENV)
212 .ok()
213 .and_then(|v| v.trim().parse::<u64>().ok())
214 .filter(|v| *v > 0)
215}
216
217fn metal_budget() -> DeviceBudget {
221 let profile = ferrox_metal::MetalProfile::detect();
222 if profile.available && profile.recommended_working_set_bytes > 0 {
223 return DeviceBudget::new(
224 BudgetBackend::Metal,
225 profile.recommended_working_set_bytes,
226 DEVICE_RESERVE_FRACTION,
227 format!(
228 "Metal recommendedMaxWorkingSetSize on {}",
229 profile.device_name.as_deref().unwrap_or("unnamed device")
230 ),
231 );
232 }
233 let mut fallback = host_ram_budget();
234 fallback.backend = BudgetBackend::Metal;
235 fallback.source = format!(
236 "no Metal device query available; fell back to {}",
237 fallback.source
238 );
239 fallback
240}
241
242fn cuda_budget() -> DeviceBudget {
247 let profile = ferrox_cuda::HardwareProfile::detect();
248 if profile.cuda_available && profile.cuda_vram_free_bytes > 0 {
249 return DeviceBudget::new(
250 BudgetBackend::Cuda,
251 profile.cuda_vram_free_bytes,
252 DEVICE_RESERVE_FRACTION,
253 format!(
254 "cuMemGetInfo free VRAM on {} ({} total)",
255 profile.cuda_device_name.as_deref().unwrap_or("device 0"),
256 human(profile.cuda_vram_total_bytes)
257 ),
258 );
259 }
260 let mut fallback = host_ram_budget();
261 fallback.backend = BudgetBackend::Cuda;
262 fallback.source = format!(
263 "no CUDA device query available; fell back to {}",
264 fallback.source
265 );
266 fallback
267}
268
269fn host_ram_budget() -> DeviceBudget {
274 let total = ferrox_cuda::HardwareProfile::detect().host_ram_total_bytes;
275 if total == 0 {
276 return DeviceBudget {
277 backend: BudgetBackend::Cpu,
278 total_bytes: 0,
279 usable_bytes: 0,
280 reserve_fraction: 0.0,
281 source: "host RAM could not be probed on this platform".to_string(),
282 approximate: true,
283 };
284 }
285 DeviceBudget::new(
286 BudgetBackend::Cpu,
287 total,
288 CPU_RESERVE_FRACTION,
289 "total physical host RAM".to_string(),
290 )
291}
292
293pub(crate) fn human(bytes: u64) -> String {
294 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
295 let mut v = bytes as f64;
296 let mut u = 0;
297 while v >= 1024.0 && u < UNITS.len() - 1 {
298 v /= 1024.0;
299 u += 1;
300 }
301 format!("{v:.2} {}", UNITS[u])
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn reserve_is_applied_and_reported() {
310 let b = DeviceBudget::new(BudgetBackend::Cpu, 1000, 0.2, "test".into());
311 assert_eq!(b.total_bytes, 1000);
312 assert_eq!(b.usable_bytes, 800);
313 assert_eq!(b.reserve_fraction, 0.2);
314 assert!(!b.is_unknown());
315 assert!(b.approximate);
317 }
318
319 #[test]
320 fn a_nonsense_reserve_is_clamped_rather_than_producing_a_negative_budget() {
321 let over = DeviceBudget::new(BudgetBackend::Cpu, 1000, 5.0, "test".into());
322 assert_eq!(over.usable_bytes, 0);
323 let under = DeviceBudget::new(BudgetBackend::Cpu, 1000, -1.0, "test".into());
324 assert_eq!(under.usable_bytes, 1000);
325 }
326
327 #[test]
328 fn zero_total_reads_as_unknown_not_as_a_zero_ceiling() {
329 let b = DeviceBudget::new(BudgetBackend::Cpu, 0, 0.2, "nothing to probe".into());
330 assert!(b.is_unknown());
331 assert!(b.to_string().contains("no ceiling enforced"), "{b}");
332 }
333
334 #[test]
338 fn cpu_budget_is_either_unknown_or_a_plausible_fraction_of_real_ram() {
339 let b = DeviceBudget::detect(BudgetBackend::Cpu);
340 assert_eq!(b.backend, BudgetBackend::Cpu);
341 if b.is_unknown() {
342 assert_eq!(b.usable_bytes, 0);
343 } else {
344 assert!(b.total_bytes > 128 * 1024 * 1024);
345 assert!(b.usable_bytes < b.total_bytes);
346 assert!(b.usable_bytes > b.total_bytes / 2);
347 assert!(b.to_string().contains("host RAM"), "{b}");
348 }
349 }
350
351 #[test]
355 fn accelerator_budgets_fall_back_to_host_ram_when_no_device_answers() {
356 for backend in [BudgetBackend::Metal, BudgetBackend::Cuda] {
357 let b = DeviceBudget::detect(backend);
358 assert_eq!(b.backend, backend);
359 if b.source.contains("fell back") {
360 assert!(b.source.contains("host RAM"), "{b}");
361 }
362 }
363 }
364
365 #[test]
366 fn human_bytes_are_readable_at_every_scale() {
367 assert_eq!(human(0), "0.00 B");
368 assert_eq!(human(1024), "1.00 KiB");
369 assert_eq!(human(3 * 1024 * 1024 * 1024), "3.00 GiB");
370 }
371}