Skip to main content

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}