ferrox_core/residency.rs
1//! What a layer's expert bank actually settled at in host memory, and
2//! how much of host memory this machine will let us page-lock at all.
3//!
4//! [`crate::placement`] decides *which* layers give up their GPU expert
5//! path when the banks do not fit the page-locking budget. It does not
6//! say what happens to their bytes, and it takes the budget as a
7//! parameter nothing produces. This module is both halves: the label a
8//! bank carries once it has been filled, and the budget the label
9//! selection runs against.
10//!
11//! # Three classes, one that can feed the GPU
12//!
13//! - [`HostResidency::Pinned`] -- page-locked *and* device-addressable
14//! (`cudaHostRegister`). Only this class can be DMA'd from, so only
15//! this class can serve the GPU expert cache.
16//! - [`HostResidency::Locked`] -- resident and unswappable (`mlock`)
17//! but with no device address. The CPU executor reads it at full
18//! speed; the GPU cannot see it at all.
19//! - [`HostResidency::Pageable`] -- an ordinary mapping. Correct, and
20//! free of any quota, but the kernel may swap it out from under a
21//! decode step.
22//!
23//! The two non-pinned classes exist for hosts that cap the CUDA pin
24//! quota. Locking costs no pin quota, so a layer that has been handed
25//! to the CPU executor anyway should be locked rather than pinned:
26//! that is the whole point of the split.
27//!
28//! # Failure is recorded, not assumed
29//!
30//! A lock can fail -- `RLIMIT_MEMLOCK` is small by default and the
31//! quota is per process, so the first refusal means every later,
32//! larger, request fails too. When it does, the bank is *still there*,
33//! merely pageable, and everything downstream still works. So a failed
34//! lock is not an error: [`ResidencyPlan`] records the class the bank
35//! **achieved** and echoes the achieved labels back, and one pageable
36//! bank downgrades its whole layer (a layer is many banks; the layer is
37//! only as resident as its worst one). The alternative -- assuming the
38//! request was honored -- labels a swappable layer `Locked`, and the
39//! swap-in then lands in the middle of a decode step as an
40//! unexplainable latency spike.
41//!
42//! # The three invariants a non-pinned layer imposes
43//!
44//! [`BankResidency::new`] refuses a configuration that violates any of
45//! them, at attach time, before a byte has moved:
46//!
47//! 1. **A non-pinned layer must already be a CPU layer.** It has no
48//! device address, so the only executor that can read it is the CPU
49//! one. If the CPU-layer set were decided after the labels, the
50//! layer's first decode step would index a device pointer that was
51//! never registered.
52//! 2. **Prefill overlap is refused, not degraded.** The overlap path
53//! DMAs a layer's experts from a registered bank while the previous
54//! layer computes. A locked bank cannot serve that copy at all, so
55//! the answer is to turn the overlap off *up front* (the caller
56//! does, on the same signal) rather than to discover it per layer.
57//! 3. **An unpinned layer accepts only the whole-layer materialize.**
58//! That path writes expert `e` to slot `e` -- `position == expert
59//! id` -- which is the one mapping that needs no device alias for
60//! the source rows. The LRU's slot remapping ([`crate::expert_cache`])
61//! picks arbitrary victim slots, and honoring it would require
62//! addressing individual host rows from the device. So a slot-remap
63//! copy staged against an unpinned layer is an error, not something
64//! to fix up silently with a different slot.
65//!
66//! # The budget the labels are chosen against
67//!
68//! [`resolve_pin_budget`] answers "how many bytes may this process
69//! page-lock". On plain Linux the answer is [`None`]: nothing caps
70//! pinning, every layer stays on the GPU path, and
71//! [`crate::placement::auto_cpu_layers`] hands out no CPU layers. On
72//! WSL, CUDA runs over WDDM and pinning is capped near half of RAM
73//! **shared across every process on the machine**, so the budget is a
74//! deliberately conservative 40% of physical RAM. Without this,
75//! `auto_cpu_layers` has no budget on the one platform where the cap
76//! actually bites, and the load dies inside the page-lock call *after*
77//! the whole checkpoint has been read off disk.
78//!
79//! Every rule takes the host facts as parameters -- the kernel release
80//! string and the physical-RAM figure -- so all of it is testable on
81//! any host. [`host_pin_budget_bytes`] is the thin wrapper that reads
82//! those two facts and applies them.
83//!
84//! Ported 1:1 from FreeToken's `moe/host_banks.py` (`HostResidency`,
85//! `_ResidencyPlan`, `_settle`, `pin_banks`), the `set_bank_sources` /
86//! `copy_missing` invariants in `moe/offload_cache.py`, and
87//! `engine/engine.py`'s `_pin_budget_bytes` (Apache-2.0); see
88//! `docs/THIRD_PARTY_NOTICES.md`.
89
90use std::collections::BTreeSet;
91
92/// Residency class of one layer's expert bank.
93///
94/// The string forms are the wire values FreeToken uses
95/// (`"pinned"` / `"locked"` / `"pageable"`), so a label written by
96/// either side reads the same.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
98pub enum HostResidency {
99 /// Page-locked and device-addressable: the only class the GPU
100 /// expert path can DMA from.
101 #[default]
102 Pinned,
103 /// Resident (unswappable) but CPU-only: no device address.
104 Locked,
105 /// An ordinary mapping; may be swapped out.
106 Pageable,
107}
108
109impl HostResidency {
110 pub fn as_str(self) -> &'static str {
111 match self {
112 HostResidency::Pinned => "pinned",
113 HostResidency::Locked => "locked",
114 HostResidency::Pageable => "pageable",
115 }
116 }
117
118 /// The inverse of [`as_str`](Self::as_str). Unknown text is
119 /// [`None`] rather than a default: a label nobody wrote is not the
120 /// same as a label that says "pinned", and guessing `Pinned` would
121 /// promise a device address that does not exist.
122 pub fn from_label(label: &str) -> Option<Self> {
123 match label {
124 "pinned" => Some(HostResidency::Pinned),
125 "locked" => Some(HostResidency::Locked),
126 "pageable" => Some(HostResidency::Pageable),
127 _ => None,
128 }
129 }
130
131 /// Whether the GPU can read this bank directly. The single
132 /// question every consumer of a label actually asks.
133 pub fn is_device_addressable(self) -> bool {
134 matches!(self, HostResidency::Pinned)
135 }
136}
137
138impl std::fmt::Display for HostResidency {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.write_str(self.as_str())
141 }
142}
143
144/// The per-layer labels a split-residency load asks for: the CPU
145/// layers locked, everything else pinned.
146///
147/// This is the bridge from [`crate::placement::auto_cpu_layers`] --
148/// which says *which* layers leave the GPU path -- to the loader, which
149/// needs to know what to do with their bytes. Locked rather than
150/// pageable because a CPU layer is read on every step that routes to
151/// it: it must not be swappable, it just does not need a pin.
152pub fn requested_labels(num_layers: usize, cpu_layers: &BTreeSet<u32>) -> Vec<HostResidency> {
153 (0..num_layers)
154 .map(|layer| {
155 if cpu_layers.contains(&(layer as u32)) {
156 HostResidency::Locked
157 } else {
158 HostResidency::Pinned
159 }
160 })
161 .collect()
162}
163
164/// What the loader must do to a bank it has just finished filling.
165///
166/// Always *after* the fill: the banks are lazy anonymous mappings, so
167/// page-locking an empty one faults and zero-fills every page, and the
168/// read then overwrites all of it -- a whole redundant pass over
169/// hundreds of gigabytes.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum SettleAction {
172 /// Page-lock and register for device access (`cudaHostRegister`).
173 PageLockForDevice,
174 /// Page-lock only (`mlock`): resident, no device address, no pin
175 /// quota spent.
176 LockResident,
177 /// Nothing to do -- a pageable bank is the mapping as allocated.
178 LeavePageable,
179}
180
181/// The residency labels a bank load asked for, and the ones it got.
182///
183/// The loader walks layers, calls [`settle_action`](Self::settle_action)
184/// for each, does the syscall itself, and reports back with
185/// [`record`](Self::record) / [`record_lock`](Self::record_lock).
186/// Nothing here touches memory: this side owns the bookkeeping, the
187/// caller owns the syscall. [`achieved_labels`](Self::achieved_labels)
188/// is what then feeds [`BankResidency::new`] -- the *achieved* labels,
189/// never the requested ones.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct ResidencyPlan {
192 requested: Vec<HostResidency>,
193 achieved: Vec<Option<HostResidency>>,
194 applied: bool,
195 lock_quota_exhausted: bool,
196}
197
198impl ResidencyPlan {
199 pub fn new(requested: Vec<HostResidency>) -> Self {
200 let achieved = vec![None; requested.len()];
201 Self {
202 requested,
203 achieved,
204 applied: false,
205 lock_quota_exhausted: false,
206 }
207 }
208
209 /// The no-split plan: every layer pinned, which is what a host
210 /// with no pin cap always does.
211 pub fn all_pinned(num_layers: usize) -> Self {
212 Self::new(vec![HostResidency::Pinned; num_layers])
213 }
214
215 pub fn num_layers(&self) -> usize {
216 self.requested.len()
217 }
218
219 /// Whether any layer was asked to be something other than pinned.
220 /// A plan with none of them is indistinguishable from no plan.
221 pub fn has_unpinned(&self) -> bool {
222 self.requested.iter().any(|r| !r.is_device_addressable())
223 }
224
225 /// Whether any settle point consulted this plan.
226 ///
227 /// A loader that pins everything by construction never asks, and
228 /// its banks are all pinned no matter what was requested. That is
229 /// still a *working* load -- CPU-layer decode reads a pinned bank
230 /// perfectly well -- it just saved no pin quota, so the honest
231 /// report is "not applied", not the labels that were wished for.
232 pub fn applied(&self) -> bool {
233 self.applied
234 }
235
236 /// Whether a lock has already failed.
237 ///
238 /// The OS lock ceiling is a per-process quota, so once one request
239 /// is over it every later (larger cumulative) request is too.
240 /// Sticky, therefore, rather than retried per bank: retrying buys
241 /// nothing and turns one failure into one failed syscall per bank
242 /// for the rest of the load.
243 pub fn lock_quota_exhausted(&self) -> bool {
244 self.lock_quota_exhausted
245 }
246
247 /// The class layer `layer_id` was asked for.
248 ///
249 /// # Panics
250 /// If `layer_id` is not a layer of this plan.
251 pub fn requested(&self, layer_id: usize) -> HostResidency {
252 self.requested[layer_id]
253 }
254
255 /// What to do with layer `layer_id`'s freshly filled bank, marking
256 /// the plan applied.
257 ///
258 /// Returns [`SettleAction::LeavePageable`] for a `Locked` layer
259 /// once the lock quota is known to be exhausted -- the syscall
260 /// would fail anyway, and the bank ends up pageable either way.
261 ///
262 /// # Panics
263 /// If `layer_id` is not a layer of this plan.
264 pub fn settle_action(&mut self, layer_id: usize) -> SettleAction {
265 self.applied = true;
266 match self.requested[layer_id] {
267 HostResidency::Pinned => SettleAction::PageLockForDevice,
268 HostResidency::Locked if self.lock_quota_exhausted => SettleAction::LeavePageable,
269 HostResidency::Locked => SettleAction::LockResident,
270 HostResidency::Pageable => SettleAction::LeavePageable,
271 }
272 }
273
274 /// Record what one of layer `layer_id`'s banks actually settled at.
275 ///
276 /// A layer is several banks and is only as resident as its worst
277 /// one, so once a layer has recorded [`HostResidency::Pageable`] no
278 /// later, better, report can raise it again.
279 ///
280 /// # Panics
281 /// If `layer_id` is not a layer of this plan.
282 pub fn record(&mut self, layer_id: usize, achieved: HostResidency) {
283 if self.achieved[layer_id] != Some(HostResidency::Pageable) {
284 self.achieved[layer_id] = Some(achieved);
285 }
286 }
287
288 /// Record the outcome of one lock attempt: `locked` false means the
289 /// bank is pageable *and* the quota is spent for the rest of the
290 /// load.
291 ///
292 /// # Panics
293 /// If `layer_id` is not a layer of this plan.
294 pub fn record_lock(&mut self, layer_id: usize, locked: bool) {
295 if !locked {
296 self.lock_quota_exhausted = true;
297 }
298 self.record(
299 layer_id,
300 if locked {
301 HostResidency::Locked
302 } else {
303 HostResidency::Pageable
304 },
305 );
306 }
307
308 /// What layer `layer_id` settled at, or [`None`] if no bank of it
309 /// reported.
310 ///
311 /// # Panics
312 /// If `layer_id` is not a layer of this plan.
313 pub fn achieved(&self, layer_id: usize) -> Option<HostResidency> {
314 self.achieved[layer_id]
315 }
316
317 /// The labels to hand [`BankResidency::new`]: what was achieved
318 /// where anything reported, what was requested elsewhere.
319 ///
320 /// A pinned layer never reports, because a failed *pin* is a hard
321 /// error the loader raises rather than a downgrade -- there is no
322 /// second way to serve a GPU layer.
323 pub fn achieved_labels(&self) -> Vec<HostResidency> {
324 self.requested
325 .iter()
326 .zip(&self.achieved)
327 .map(|(requested, achieved)| achieved.unwrap_or(*requested))
328 .collect()
329 }
330
331 /// The layers that did not get what they asked for, for the one
332 /// warning a human needs to see: they still decode on the CPU
333 /// executor, but they may now swap under memory pressure.
334 pub fn downgraded(&self) -> Vec<u32> {
335 self.achieved_labels()
336 .iter()
337 .zip(&self.requested)
338 .enumerate()
339 .filter(|(_, (achieved, requested))| achieved != requested)
340 .map(|(layer, _)| layer as u32)
341 .collect()
342 }
343}
344
345/// A residency configuration that cannot be served, refused before the
346/// banks are attached.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum ResidencyError {
349 /// The label list does not describe this model.
350 LabelCountMismatch { labels: usize, num_layers: usize },
351 /// Layers with no device address that nothing routed to the CPU.
352 UnpinnedLayerNotOnCpu { layers: Vec<u32> },
353 /// Prefill overlap asked for alongside a layer that cannot feed it.
354 PrefillOverlapWithUnpinned { layers: Vec<u32> },
355 /// A slot-remapping copy staged against a layer with no device
356 /// alias for its host rows.
357 SlotRemapOnUnpinnedLayer { layer: u32 },
358}
359
360impl std::fmt::Display for ResidencyError {
361 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 match self {
363 ResidencyError::LabelCountMismatch { labels, num_layers } => write!(
364 f,
365 "{labels} residency labels for a model of {num_layers} MoE layers"
366 ),
367 ResidencyError::UnpinnedLayerNotOnCpu { layers } => write!(
368 f,
369 "layers {layers:?} are not page-locked for the device and are not CPU layers: \
370 a layer without a device address can only decode on the CPU executor, so the \
371 CPU-layer set must be decided before the banks are attached"
372 ),
373 ResidencyError::PrefillOverlapWithUnpinned { layers } => write!(
374 f,
375 "prefill overlap DMAs from registered banks; it must be disabled when any layer \
376 is locked or pageable (layers {layers:?})"
377 ),
378 ResidencyError::SlotRemapOnUnpinnedLayer { layer } => write!(
379 f,
380 "layer {layer} is not page-locked for the device: its only copy is the \
381 whole-layer materialize (position == expert id); an LRU slot remap cannot be \
382 honored without a device alias for the host rows"
383 ),
384 }
385 }
386}
387
388impl std::error::Error for ResidencyError {}
389
390/// How a staged copy for one layer may be carried out.
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum CopyRoute {
393 /// The normal path: gather the missing expert rows from the
394 /// registered bank into whichever slots the LRU picked.
395 DeviceIndexed,
396 /// A synchronous pageable copy of the whole layer into slots
397 /// `[0, num_experts)`, `position == expert id`. Never captured in
398 /// a CUDA graph: prefill is not captured, and decode never reaches
399 /// this branch because an unpinned layer routes to the CPU
400 /// executor.
401 WholeLayerPageable,
402}
403
404/// The validated per-layer residency of an attached set of expert
405/// banks.
406///
407/// Constructing one is the check; holding one is the proof the three
408/// invariants in the module docs hold for this configuration.
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct BankResidency {
411 labels: Vec<HostResidency>,
412 unpinned: BTreeSet<u32>,
413}
414
415impl BankResidency {
416 /// Attach `labels` (the **achieved** ones) to a model of
417 /// `num_layers` MoE layers.
418 ///
419 /// `cpu_layers` is the set already routed to the CPU executor and
420 /// `prefill_overlap` is whether the overlap path is still enabled.
421 /// Both are inputs, not outputs: this refuses a bad combination
422 /// rather than repairing it, because repairing it here would mean
423 /// silently moving a layer to another executor after the cache
424 /// geometry was already sized against the old answer.
425 pub fn new(
426 labels: &[HostResidency],
427 num_layers: usize,
428 cpu_layers: &BTreeSet<u32>,
429 prefill_overlap: bool,
430 ) -> Result<Self, ResidencyError> {
431 if labels.len() != num_layers {
432 return Err(ResidencyError::LabelCountMismatch {
433 labels: labels.len(),
434 num_layers,
435 });
436 }
437 let unpinned: BTreeSet<u32> = labels
438 .iter()
439 .enumerate()
440 .filter(|(_, label)| !label.is_device_addressable())
441 .map(|(layer, _)| layer as u32)
442 .collect();
443 if !unpinned.is_empty() {
444 let stranded: Vec<u32> = unpinned.difference(cpu_layers).copied().collect();
445 if !stranded.is_empty() {
446 return Err(ResidencyError::UnpinnedLayerNotOnCpu { layers: stranded });
447 }
448 if prefill_overlap {
449 return Err(ResidencyError::PrefillOverlapWithUnpinned {
450 layers: unpinned.iter().copied().collect(),
451 });
452 }
453 }
454 Ok(Self {
455 labels: labels.to_vec(),
456 unpinned,
457 })
458 }
459
460 /// The default when no plan was in force: every layer pinned, no
461 /// invariant to check, prefill overlap free to stay on.
462 pub fn all_pinned(num_layers: usize) -> Self {
463 Self {
464 labels: vec![HostResidency::Pinned; num_layers],
465 unpinned: BTreeSet::new(),
466 }
467 }
468
469 pub fn num_layers(&self) -> usize {
470 self.labels.len()
471 }
472
473 pub fn labels(&self) -> &[HostResidency] {
474 &self.labels
475 }
476
477 /// # Panics
478 /// If `layer_id` is not a layer of this model.
479 pub fn label(&self, layer_id: u32) -> HostResidency {
480 self.labels[layer_id as usize]
481 }
482
483 /// Layers with no device address. The copy plan skips their rows
484 /// entirely.
485 pub fn unpinned_layers(&self) -> &BTreeSet<u32> {
486 &self.unpinned
487 }
488
489 pub fn is_unpinned(&self, layer_id: u32) -> bool {
490 self.unpinned.contains(&layer_id)
491 }
492
493 /// Whether any layer is locked or pageable -- the same signal that
494 /// must have turned prefill overlap off.
495 pub fn has_unpinned(&self) -> bool {
496 !self.unpinned.is_empty()
497 }
498
499 /// How a copy staged for `layer_id` must be carried out.
500 ///
501 /// `whole_layer` is whether the caller staged the whole-layer
502 /// materialize (every expert, slot `e` for expert `e`) rather than
503 /// an LRU `ensure`. An unpinned layer accepts only the former; a
504 /// pinned layer takes the indexed device path either way, since a
505 /// registered bank can serve both.
506 pub fn copy_route(
507 &self,
508 layer_id: u32,
509 whole_layer: bool,
510 ) -> Result<CopyRoute, ResidencyError> {
511 if !self.is_unpinned(layer_id) {
512 return Ok(CopyRoute::DeviceIndexed);
513 }
514 if whole_layer {
515 Ok(CopyRoute::WholeLayerPageable)
516 } else {
517 Err(ResidencyError::SlotRemapOnUnpinnedLayer { layer: layer_id })
518 }
519 }
520}
521
522/// Overrides the pin budget on any host, in gibibytes. Empty means
523/// unset.
524///
525/// Only the name differs from the reference, which spells it
526/// `FREETOKEN_PIN_BUDGET_GB`; the value and every rule around it are
527/// the same.
528pub const PIN_BUDGET_ENV: &str = "FERROX_PIN_BUDGET_GB";
529
530/// The tag WSL puts in its kernel release string. Matched
531/// case-insensitively, as a substring: the surrounding version text
532/// differs per WSL build and per distribution kernel.
533pub const WSL_KERNEL_TAG: &str = "microsoft";
534
535/// Fraction of physical RAM a WSL host may page-lock.
536///
537/// WDDM-backed CUDA caps pinning near *half* of RAM, and that ceiling
538/// is shared across every process on the machine -- so 40% leaves room
539/// for whatever else on the host has pinned memory. Taking the full
540/// half would make the budget correct only on an otherwise idle
541/// machine, and wrong exactly when the machine is busy.
542pub const WSL_PIN_FRACTION: f64 = 0.4;
543
544const GIB: f64 = (1u64 << 30) as f64;
545
546/// A [`PIN_BUDGET_ENV`] value that is not a number of gibibytes.
547#[derive(Debug, Clone, PartialEq, Eq)]
548pub struct PinBudgetEnvError {
549 pub value: String,
550}
551
552impl std::fmt::Display for PinBudgetEnvError {
553 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554 write!(
555 f,
556 "{PIN_BUDGET_ENV}={:?} is not a number of GiB",
557 self.value
558 )
559 }
560}
561
562impl std::error::Error for PinBudgetEnvError {}
563
564/// Read a [`PIN_BUDGET_ENV`] value: [`None`] for unset (or empty),
565/// otherwise the budget in bytes.
566///
567/// A negative figure clamps to zero, which is a real answer -- "pin
568/// nothing" -- and is how a deployment forces every MoE layer onto the
569/// CPU executor. Text that is not a number at all is refused instead of
570/// ignored: ignoring it would silently uncap the host the variable was
571/// set to cap, and the failure then happens after the whole checkpoint
572/// has been read.
573pub fn parse_pin_budget_gb(value: &str) -> Result<Option<u64>, PinBudgetEnvError> {
574 let text = value.trim();
575 if text.is_empty() {
576 return Ok(None);
577 }
578 let gb: f64 = text.parse().map_err(|_| PinBudgetEnvError {
579 value: value.to_string(),
580 })?;
581 if !gb.is_finite() {
582 return Err(PinBudgetEnvError {
583 value: value.to_string(),
584 });
585 }
586 Ok(Some((gb * GIB).max(0.0) as u64))
587}
588
589/// Whether this host caps how much memory a process may page-lock for
590/// the device.
591///
592/// The one platform that does is WSL, told by the `microsoft` tag its
593/// kernel release carries. A release string we could not read at all
594/// is empty, which reads as "not WSL" -- the same answer the reference
595/// gives on a platform with no `uname`.
596pub fn is_pin_capped_host(kernel_release: &str) -> bool {
597 kernel_release.to_ascii_lowercase().contains(WSL_KERNEL_TAG)
598}
599
600/// Bytes this process may safely page-lock, from the host facts alone.
601///
602/// [`None`] means *uncapped*, not unknown: on plain Linux nothing caps
603/// pinning, so every layer stays on the GPU path and
604/// [`crate::placement::auto_cpu_layers`] hands out nothing.
605///
606/// On a capped host with `phys_ram_bytes == 0` -- the figure could not
607/// be read -- the answer is `Some(0)`, "pin nothing". That is the
608/// conservative direction: it costs throughput (every layer decodes on
609/// the CPU) where returning [`None`] would claim an uncapped host and
610/// die in the page-lock call after the whole checkpoint has been read.
611/// [`PIN_BUDGET_ENV`] is the way out of it.
612pub fn pin_budget_bytes(kernel_release: &str, phys_ram_bytes: u64) -> Option<u64> {
613 if !is_pin_capped_host(kernel_release) {
614 return None;
615 }
616 Some((phys_ram_bytes as f64 * WSL_PIN_FRACTION) as u64)
617}
618
619/// The pin budget with the environment override applied.
620///
621/// The override wins **anywhere**, including on a host this would
622/// otherwise call uncapped: it is how a machine with an out-of-band pin
623/// consumer (another process holding registered memory, a hypervisor)
624/// tells the engine what is actually left.
625pub fn resolve_pin_budget(
626 kernel_release: &str,
627 phys_ram_bytes: u64,
628 env_value: Option<&str>,
629) -> Result<Option<u64>, PinBudgetEnvError> {
630 if let Some(value) = env_value {
631 if let Some(bytes) = parse_pin_budget_gb(value)? {
632 return Ok(Some(bytes));
633 }
634 }
635 Ok(pin_budget_bytes(kernel_release, phys_ram_bytes))
636}
637
638/// This host's kernel release, or [`None`] where there is nothing to
639/// read.
640///
641/// `/proc/sys/kernel/osrelease` is the file form of `uname -r`; the
642/// reference calls `os.uname()`. A host without it is not WSL, which
643/// is the answer either way.
644pub fn host_kernel_release() -> Option<String> {
645 std::fs::read_to_string("/proc/sys/kernel/osrelease")
646 .ok()
647 .map(|release| release.trim().to_string())
648 .filter(|release| !release.is_empty())
649}
650
651/// This host's physical RAM, or [`None`] where it cannot be read.
652///
653/// `MemTotal` from `/proc/meminfo`, which is the file form of
654/// `sysconf(_SC_PHYS_PAGES) * sysconf(_SC_PAGE_SIZE)`; reading it keeps
655/// this crate free of a libc dependency.
656pub fn host_phys_ram_bytes() -> Option<u64> {
657 parse_mem_total_bytes(&std::fs::read_to_string("/proc/meminfo").ok()?)
658}
659
660fn parse_mem_total_bytes(meminfo: &str) -> Option<u64> {
661 let line = meminfo.lines().find(|line| line.starts_with("MemTotal:"))?;
662 let mut fields = line.split_whitespace().skip(1);
663 let value: u64 = fields.next()?.parse().ok()?;
664 let scale = match fields.next() {
665 None => 1,
666 Some("kB") | Some("KB") | Some("kb") => 1024,
667 Some(_) => return None,
668 };
669 value.checked_mul(scale)
670}
671
672/// This host's pin budget: [`PIN_BUDGET_ENV`], else the platform rule
673/// applied to what `/proc` reports.
674///
675/// The convenience wrapper around [`resolve_pin_budget`] -- everything
676/// it decides is in that function, so a caller that already knows the
677/// host facts (a test, a remote sizing pass) should call that instead.
678pub fn host_pin_budget_bytes() -> Result<Option<u64>, PinBudgetEnvError> {
679 let env_value = std::env::var(PIN_BUDGET_ENV).ok();
680 resolve_pin_budget(
681 &host_kernel_release().unwrap_or_default(),
682 host_phys_ram_bytes().unwrap_or(0),
683 env_value.as_deref(),
684 )
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use crate::placement::auto_cpu_layers;
691
692 fn set(items: &[u32]) -> BTreeSet<u32> {
693 items.iter().copied().collect()
694 }
695
696 #[test]
697 fn a_label_survives_a_round_trip_through_its_wire_form() {
698 for label in [
699 HostResidency::Pinned,
700 HostResidency::Locked,
701 HostResidency::Pageable,
702 ] {
703 assert_eq!(HostResidency::from_label(label.as_str()), Some(label));
704 }
705 assert_eq!(HostResidency::from_label("registered"), None);
706 assert!(HostResidency::Pinned.is_device_addressable());
707 assert!(!HostResidency::Locked.is_device_addressable());
708 assert!(!HostResidency::Pageable.is_device_addressable());
709 }
710
711 #[test]
712 fn the_cpu_layers_are_the_ones_asked_to_lock() {
713 assert_eq!(
714 requested_labels(4, &set(&[0, 3])),
715 vec![
716 HostResidency::Locked,
717 HostResidency::Pinned,
718 HostResidency::Pinned,
719 HostResidency::Locked,
720 ]
721 );
722 assert_eq!(
723 requested_labels(3, &BTreeSet::new()),
724 vec![HostResidency::Pinned; 3]
725 );
726 }
727
728 #[test]
729 fn a_plan_settles_each_layer_at_the_class_it_asked_for() {
730 let mut plan = ResidencyPlan::new(requested_labels(3, &set(&[1])));
731 assert!(!plan.applied());
732 assert_eq!(plan.settle_action(0), SettleAction::PageLockForDevice);
733 assert_eq!(plan.settle_action(1), SettleAction::LockResident);
734 assert!(plan.applied());
735 assert!(plan.has_unpinned());
736 assert!(!ResidencyPlan::all_pinned(3).has_unpinned());
737 }
738
739 /// The naive version assumes the lock it asked for succeeded and
740 /// labels the layer `Locked`. The bank is pageable, and the label
741 /// promises residency the kernel never granted -- so the swap-in
742 /// lands mid-decode with nothing to explain it.
743 #[test]
744 fn a_failed_lock_is_recorded_as_pageable_rather_than_assumed_locked() {
745 let mut plan = ResidencyPlan::new(requested_labels(3, &set(&[0, 2])));
746 assert_eq!(plan.settle_action(0), SettleAction::LockResident);
747 plan.record_lock(0, false);
748 assert_eq!(plan.settle_action(2), SettleAction::LeavePageable);
749 plan.record(2, HostResidency::Pageable);
750 assert_eq!(plan.achieved(0), Some(HostResidency::Pageable));
751 assert_eq!(
752 plan.achieved_labels(),
753 vec![
754 HostResidency::Pageable,
755 HostResidency::Pinned,
756 HostResidency::Pageable,
757 ],
758 "the quota is spent for good, so layer 2's lock never even ran"
759 );
760 assert_eq!(plan.downgraded(), vec![0, 2]);
761 }
762
763 /// A pinned layer never reports, because a failed *pin* is a hard
764 /// error rather than a downgrade: its requested label is what it
765 /// achieved.
766 #[test]
767 fn an_unreported_layer_echoes_back_what_it_asked_for() {
768 let mut plan = ResidencyPlan::new(requested_labels(2, &set(&[1])));
769 plan.settle_action(1);
770 plan.record_lock(1, true);
771 assert_eq!(
772 plan.achieved_labels(),
773 vec![HostResidency::Pinned, HostResidency::Locked]
774 );
775 assert!(plan.downgraded().is_empty());
776 }
777
778 /// A layer is several banks and is only as resident as its worst
779 /// one: taking the last report would let a later bank's success
780 /// erase an earlier bank's failure.
781 #[test]
782 fn one_pageable_bank_downgrades_the_whole_layer() {
783 let mut plan = ResidencyPlan::all_pinned(1);
784 plan.record(0, HostResidency::Pageable);
785 plan.record(0, HostResidency::Locked);
786 plan.record(0, HostResidency::Pinned);
787 assert_eq!(plan.achieved(0), Some(HostResidency::Pageable));
788 }
789
790 /// The lock ceiling is a per-process quota, so once one request is
791 /// over it every larger cumulative request is too: retrying per
792 /// bank buys nothing and costs a failing syscall per bank for the
793 /// rest of the load.
794 #[test]
795 fn a_spent_lock_quota_leaves_every_later_layer_pageable() {
796 let mut plan = ResidencyPlan::new(vec![HostResidency::Locked; 3]);
797 assert_eq!(plan.settle_action(0), SettleAction::LockResident);
798 plan.record_lock(0, false);
799 assert!(plan.lock_quota_exhausted());
800 assert_eq!(plan.settle_action(1), SettleAction::LeavePageable);
801 assert_eq!(plan.settle_action(2), SettleAction::LeavePageable);
802 }
803
804 #[test]
805 fn labels_that_do_not_describe_the_model_are_refused() {
806 assert_eq!(
807 BankResidency::new(&[HostResidency::Pinned; 3], 4, &BTreeSet::new(), false),
808 Err(ResidencyError::LabelCountMismatch {
809 labels: 3,
810 num_layers: 4
811 })
812 );
813 }
814
815 /// The naive version attaches the banks and lets the layer's first
816 /// decode step index a device pointer that was never registered.
817 /// The relationship is checked here, at attach time, before a byte
818 /// moves.
819 #[test]
820 fn a_non_pinned_layer_that_is_not_a_cpu_layer_is_refused() {
821 let labels = requested_labels(4, &set(&[0, 3]));
822 assert_eq!(
823 BankResidency::new(&labels, 4, &set(&[0]), false),
824 Err(ResidencyError::UnpinnedLayerNotOnCpu { layers: vec![3] })
825 );
826 // Both routed to the CPU: fine.
827 let attached = BankResidency::new(&labels, 4, &set(&[0, 3]), false).unwrap();
828 assert_eq!(attached.unpinned_layers(), &set(&[0, 3]));
829 // A CPU layer whose bank did get pinned is not a violation --
830 // it reads a registered bank perfectly well, it just saved no
831 // pin quota.
832 assert!(
833 BankResidency::new(&[HostResidency::Pinned; 4], 4, &set(&[0, 3]), true).is_ok(),
834 "an all-pinned load keeps prefill overlap even with CPU layers"
835 );
836 }
837
838 /// The naive version leaves the overlap on and lets it degrade per
839 /// layer. It cannot: the overlap path DMAs from a registered bank,
840 /// and a locked bank has no device address to DMA from, so the
841 /// configuration is refused and the caller turns the overlap off
842 /// on the same signal.
843 #[test]
844 fn prefill_overlap_with_any_unpinned_layer_is_refused() {
845 let labels = requested_labels(4, &set(&[2]));
846 assert_eq!(
847 BankResidency::new(&labels, 4, &set(&[2]), true),
848 Err(ResidencyError::PrefillOverlapWithUnpinned { layers: vec![2] })
849 );
850 assert!(BankResidency::new(&labels, 4, &set(&[2]), false).is_ok());
851 }
852
853 /// The naive version honors the LRU's chosen victim slot for an
854 /// unpinned layer. It cannot: only the whole-layer materialize's
855 /// `position == expert id` mapping works without a device alias for
856 /// the host rows, so a slot remap is an error rather than something
857 /// to quietly copy somewhere else.
858 #[test]
859 fn an_unpinned_layer_accepts_only_the_whole_layer_materialize() {
860 let labels = requested_labels(3, &set(&[1]));
861 let banks = BankResidency::new(&labels, 3, &set(&[1]), false).unwrap();
862 assert_eq!(banks.copy_route(1, true), Ok(CopyRoute::WholeLayerPageable));
863 assert_eq!(
864 banks.copy_route(1, false),
865 Err(ResidencyError::SlotRemapOnUnpinnedLayer { layer: 1 })
866 );
867 }
868
869 /// A registered bank serves both shapes of copy, so a pinned layer
870 /// takes the indexed device path even for a whole-layer stage.
871 #[test]
872 fn a_pinned_layer_takes_the_indexed_device_copy_either_way() {
873 let banks = BankResidency::all_pinned(3);
874 assert!(!banks.has_unpinned());
875 assert_eq!(banks.copy_route(0, true), Ok(CopyRoute::DeviceIndexed));
876 assert_eq!(banks.copy_route(0, false), Ok(CopyRoute::DeviceIndexed));
877 assert_eq!(banks.label(0), HostResidency::Pinned);
878 }
879
880 /// End to end: a lock fails, the achieved labels say so, and the
881 /// attach validates against those -- not against what was asked
882 /// for. The downgraded layer is still a CPU layer, so it still
883 /// attaches; it is merely pageable now.
884 #[test]
885 fn the_achieved_labels_are_what_the_banks_attach_with() {
886 let cpu_layers = set(&[0, 5]);
887 let mut plan = ResidencyPlan::new(requested_labels(6, &cpu_layers));
888 assert_eq!(plan.settle_action(0), SettleAction::LockResident);
889 plan.record_lock(0, false);
890 assert_eq!(plan.settle_action(5), SettleAction::LeavePageable);
891 plan.record(5, HostResidency::Pageable);
892 let labels = plan.achieved_labels();
893 assert_eq!(labels[0], HostResidency::Pageable);
894 assert_eq!(labels[5], HostResidency::Pageable);
895 let banks = BankResidency::new(&labels, 6, &cpu_layers, false).unwrap();
896 assert_eq!(banks.unpinned_layers(), &cpu_layers);
897 assert_eq!(banks.label(0), HostResidency::Pageable);
898 }
899
900 /// The naive version reports a WSL host as uncapped, which is the
901 /// one platform where the cap bites: `auto_cpu_layers` then hands
902 /// out no CPU layers and the load dies inside the page-lock call
903 /// after the whole checkpoint has been read.
904 #[test]
905 fn a_wsl_host_is_capped_at_forty_percent_of_ram() {
906 let release = "5.15.153.1-microsoft-standard-WSL2";
907 assert!(is_pin_capped_host(release));
908 assert_eq!(
909 pin_budget_bytes(release, 64 << 30),
910 Some((64 << 30) * 2 / 5)
911 );
912 // Case-insensitive: distribution kernels spell the tag either way.
913 assert!(is_pin_capped_host("5.10.16.3-Microsoft-standard-WSL2"));
914 }
915
916 /// And the consequence the budget exists for: with no budget every
917 /// layer stays pinned on the GPU path; with the WSL budget the
918 /// over-cap model gives layers to the CPU instead.
919 #[test]
920 fn the_wsl_budget_is_what_moves_layers_off_the_gpu_path() {
921 let banks = 48u64 << 30;
922 let budget = pin_budget_bytes("5.15.153.1-microsoft-standard-WSL2", 64 << 30);
923 assert!(
924 auto_cpu_layers(48, banks, None).is_empty(),
925 "an uncapped host keeps everything pinned"
926 );
927 assert!(!auto_cpu_layers(48, banks, budget).is_empty());
928 }
929
930 #[test]
931 fn plain_linux_reports_no_pin_cap() {
932 assert!(!is_pin_capped_host("6.8.0-45-generic"));
933 assert_eq!(pin_budget_bytes("6.8.0-45-generic", 64 << 30), None);
934 assert_eq!(pin_budget_bytes("", 64 << 30), None, "no readable release");
935 assert_eq!(
936 resolve_pin_budget("6.8.0-45-generic", 64 << 30, None),
937 Ok(None)
938 );
939 }
940
941 /// A capped host whose RAM figure could not be read must not come
942 /// back uncapped: budgeting nothing costs throughput, claiming no
943 /// cap costs the whole load.
944 #[test]
945 fn an_unreadable_ram_figure_on_a_capped_host_budgets_nothing() {
946 assert_eq!(pin_budget_bytes("microsoft-standard-WSL2", 0), Some(0));
947 assert_eq!(auto_cpu_layers(8, 1 << 30, Some(0)).len(), 8);
948 }
949
950 #[test]
951 fn the_environment_variable_overrides_on_any_host() {
952 assert_eq!(
953 resolve_pin_budget("6.8.0-45-generic", 64 << 30, Some("8")),
954 Ok(Some(8 << 30)),
955 "an uncapped host can still be told a budget"
956 );
957 assert_eq!(
958 resolve_pin_budget("microsoft-standard-WSL2", 64 << 30, Some("1.5")),
959 Ok(Some(1024 * 1024 * 1024 * 3 / 2)),
960 "and a capped host's computed budget is replaced, not clamped"
961 );
962 assert_eq!(
963 resolve_pin_budget("6.8.0-45-generic", 64 << 30, Some("-1")),
964 Ok(Some(0)),
965 "a negative budget means pin nothing"
966 );
967 }
968
969 #[test]
970 fn an_empty_pin_budget_variable_counts_as_unset() {
971 assert_eq!(parse_pin_budget_gb(""), Ok(None));
972 assert_eq!(parse_pin_budget_gb(" "), Ok(None));
973 assert_eq!(
974 resolve_pin_budget("microsoft-standard-WSL2", 64 << 30, Some("")),
975 Ok(Some((64 << 30) * 2 / 5)),
976 "an empty value in a unit file means 'use the normal rule'"
977 );
978 }
979
980 /// The naive version ignores a value it cannot read, which
981 /// silently uncaps the very host the variable was set to cap.
982 #[test]
983 fn an_unparsable_pin_budget_is_refused_rather_than_ignored() {
984 for value in ["eight", "8GiB", "inf", "NaN"] {
985 assert_eq!(
986 parse_pin_budget_gb(value),
987 Err(PinBudgetEnvError {
988 value: value.to_string()
989 }),
990 "{value:?} must not read as 'no cap'"
991 );
992 }
993 assert!(resolve_pin_budget("6.8.0-45-generic", 64 << 30, Some("eight")).is_err());
994 }
995
996 #[test]
997 fn mem_total_is_read_in_kilobytes() {
998 let meminfo = "MemTotal: 65809172 kB\nMemFree: 1234 kB\n";
999 assert_eq!(parse_mem_total_bytes(meminfo), Some(65809172 * 1024));
1000 assert_eq!(parse_mem_total_bytes("MemFree: 1234 kB\n"), None);
1001 assert_eq!(parse_mem_total_bytes("MemTotal: notanumber kB"), None);
1002 assert_eq!(parse_mem_total_bytes("MemTotal: 12 MB"), None);
1003 }
1004}