Skip to main content

ferrox_core/
host_memory.rs

1//! How much memory the host actually has free, and whether a model fits.
2//!
3//! Lives here rather than in a binary because both `ferrox-cli` and
4//! `ferrox-server` need the same answer, and two copies of a
5//! platform probe drift.
6
7/// Bytes a new allocation can reasonably expect to get.
8///
9/// On macOS this counts free plus INACTIVE pages, because inactive
10/// pages are reclaimable and counting only free ones understates what
11/// is available by many gigabytes on a machine that has been up for a
12/// while. On Linux it is `MemAvailable`, which the kernel computes for
13/// exactly this question. `None` when the platform will not say, which
14/// callers must treat as "unknown", never as zero.
15pub fn available_bytes() -> Option<u64> {
16    #[cfg(target_os = "macos")]
17    {
18        let out = std::process::Command::new("vm_stat").output().ok()?;
19        if !out.status.success() {
20            return None;
21        }
22        parse_vm_stat(&String::from_utf8_lossy(&out.stdout))
23    }
24    #[cfg(target_os = "linux")]
25    {
26        let text = std::fs::read_to_string("/proc/meminfo").ok()?;
27        text.lines()
28            .find(|l| l.starts_with("MemAvailable:"))
29            .and_then(|l| l.split_whitespace().nth(1)?.parse::<u64>().ok())
30            .map(|kb| kb * 1024)
31    }
32    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
33    {
34        None
35    }
36}
37
38/// Free plus inactive pages from `vm_stat` output, in bytes.
39pub fn parse_vm_stat(text: &str) -> Option<u64> {
40    let page = text
41        .lines()
42        .next()?
43        .split("page size of ")
44        .nth(1)?
45        .split(' ')
46        .next()?
47        .parse::<u64>()
48        .ok()?;
49    let field = |name: &str| -> Option<u64> {
50        text.lines()
51            .find(|l| l.starts_with(name))
52            .and_then(|l| l.split(':').nth(1))
53            .and_then(|v| v.trim().trim_end_matches('.').parse::<u64>().ok())
54    };
55    Some((field("Pages free")? + field("Pages inactive")?) * page)
56}
57
58/// What to do about a model whose weights may not fit.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum FitPlan {
61    /// Load everything resident. The fast path, and the default.
62    Resident,
63    /// Stream experts, keeping at most this many bytes of them cached.
64    Stream { cache_bytes: u64 },
65}
66
67/// Decide whether streaming is needed, given what the model weighs and
68/// what the host has.
69///
70/// The bar is deliberately not "does it fit exactly". A model that
71/// fills memory to the last byte will thrash the page cache and leave
72/// nothing for the KV cache, so `headroom_bytes` is subtracted first.
73///
74/// `available` of `None` means the platform would not say. That
75/// resolves to `Resident`, NOT to streaming: guessing that a machine is
76/// short on memory would silently put every user on the slow path on
77/// any platform without a probe.
78pub fn plan_for(
79    weight_bytes: u64,
80    available: Option<u64>,
81    headroom_bytes: u64,
82    min_cache_bytes: u64,
83) -> FitPlan {
84    let Some(available) = available else {
85        return FitPlan::Resident;
86    };
87    let usable = available.saturating_sub(headroom_bytes);
88    if weight_bytes <= usable {
89        return FitPlan::Resident;
90    }
91    // It does not fit. Spend what is usable on the expert cache, but
92    // never less than the floor: a cache too small to hold one decode
93    // step's experts turns every acquire into a fresh read, which is
94    // correct but pathologically slow.
95    FitPlan::Stream {
96        cache_bytes: usable.max(min_cache_bytes),
97    }
98}
99
100/// Bytes to leave for everything that is not weights: the KV cache,
101/// activations, and the pages of the checkpoint not yet touched.
102///
103/// Named here rather than at the call site because two consumers now
104/// subtract it -- [`plan_for`] and [`derived_copy_budget`] -- and two
105/// spellings of one headroom is this repo's dominant defect shape. Both
106/// still take it as a parameter so a test can vary it.
107pub const FIT_HEADROOM_BYTES: u64 = 4 * 1024 * 1024 * 1024;
108
109/// One in this many usable bytes may be spent on retained *derived*
110/// copies of weights that are already resident.
111///
112/// A share, not a byte count: the budget itself is derived from a live
113/// probe of the host, so it shrinks on a small machine instead of being
114/// a number somebody picked once and nobody revisited.
115///
116/// A quarter, because a repacked matrix is a second copy of weights the
117/// process has already mapped: spending more of what is left than the
118/// weights themselves could plausibly want is how a cache turns into
119/// the reason a model stops fitting.
120const DERIVED_COPY_SHARE: u64 = 4;
121
122/// Bytes a process-wide cache of DERIVED copies of already-resident
123/// weights may retain -- today, `weight_matrix::repack_cache`, which
124/// holds the interleaved (`repacked`) form of matrices the GEMV kernels
125/// read.
126///
127/// Three properties, and each one is why this function exists rather
128/// than a constant next to that cache:
129///
130/// 1. **It is derived, not restated.** The byte figure comes from
131///    [`available_bytes`] at the moment the cache first needs one.
132/// 2. **It subtracts the expert budget instead of competing with it.**
133///    `expert_store` is the SINGLE holder of the expert byte budget,
134///    because on unified memory two budgets are the same RAM counted
135///    twice. `committed_bytes`
136///    ([`crate::expert_store::committed_expert_bytes`]) comes out of
137///    the pool BEFORE the share is taken, so the two numbers are one
138///    subtraction rather than two opinions.
139/// 3. **It can be zero.** A host under `headroom + committed` yields
140///    zero, and a zero budget means nothing is retained -- the
141///    behaviour before the repack cache existed, which is the
142///    memory-constrained case being expressible rather than merely
143///    smaller.
144///
145/// `available` of `None` means the platform would not say, and that
146/// resolves to **zero**, the opposite of [`plan_for`]'s answer to the
147/// same unknown. The asymmetry is deliberate: guessing wrong there puts
148/// a user on a slow path, guessing wrong here retains unbounded copies
149/// of a checkpoint. Slow and correct beats unbounded.
150pub fn derived_copy_budget(
151    available: Option<u64>,
152    headroom_bytes: u64,
153    committed_bytes: u64,
154) -> u64 {
155    let Some(available) = available else {
156        return 0;
157    };
158    available
159        .saturating_sub(headroom_bytes)
160        .saturating_sub(committed_bytes)
161        / DERIVED_COPY_SHARE
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    const GB: u64 = 1024 * 1024 * 1024;
169
170    #[test]
171    fn a_model_that_fits_stays_resident() {
172        assert_eq!(
173            plan_for(8 * GB, Some(32 * GB), 4 * GB, GB),
174            FitPlan::Resident
175        );
176    }
177
178    /// Filling memory to the last byte leaves nothing for the KV cache,
179    /// so the headroom is subtracted before the comparison.
180    #[test]
181    fn headroom_is_subtracted_before_deciding() {
182        assert_eq!(
183            plan_for(30 * GB, Some(32 * GB), 4 * GB, GB),
184            FitPlan::Stream {
185                cache_bytes: 28 * GB
186            },
187            "30 GiB of weights into 32 GiB with 4 GiB reserved does not fit"
188        );
189    }
190
191    #[test]
192    fn a_model_far_larger_than_ram_streams() {
193        assert!(matches!(
194            plan_for(155 * GB, Some(32 * GB), 4 * GB, GB),
195            FitPlan::Stream { .. }
196        ));
197    }
198
199    /// An unknown probe must not be read as "no memory". Guessing that
200    /// would put every user on a platform without a probe onto the slow
201    /// path silently.
202    #[test]
203    fn an_unknown_amount_of_memory_never_forces_streaming() {
204        assert_eq!(plan_for(155 * GB, None, 4 * GB, GB), FitPlan::Resident);
205    }
206
207    /// A cache too small to hold one decode step's experts makes every
208    /// acquire a fresh read: correct, and pathologically slow.
209    #[test]
210    fn the_cache_never_falls_below_the_floor() {
211        assert_eq!(
212            plan_for(100 * GB, Some(5 * GB), 4 * GB, 2 * GB),
213            FitPlan::Stream {
214                cache_bytes: 2 * GB
215            },
216            "1 GiB usable must be raised to the 2 GiB floor"
217        );
218    }
219
220    /// The expert budget comes out of the pool BEFORE the derived-copy
221    /// share is taken. `expert_store` is the single holder of the expert
222    /// byte budget, and on unified memory a repacked byte and an expert
223    /// byte are the same RAM: if this subtraction is dropped, the two
224    /// budgets are one pool counted twice.
225    ///
226    /// Sabotage: remove the `committed_bytes` subtraction and the second
227    /// assertion goes red.
228    #[test]
229    fn the_expert_budget_is_subtracted_before_the_derived_copy_share() {
230        // 32 GiB, 4 GiB headroom, nothing committed: a quarter of 28.
231        assert_eq!(
232            derived_copy_budget(Some(32 * GB), 4 * GB, 0),
233            7 * GB,
234            "a quarter of what is usable"
235        );
236        // The same host with a 20 GiB expert cache has 8 GiB usable.
237        assert_eq!(
238            derived_copy_budget(Some(32 * GB), 4 * GB, 20 * GB),
239            2 * GB,
240            "the expert budget must come out of the pool first"
241        );
242    }
243
244    /// Zero is reachable, and it has to be: it is how the memory-
245    /// constrained case is expressed rather than merely made smaller.
246    /// A zero budget retains nothing, which is the behaviour before the
247    /// repack cache existed.
248    #[test]
249    fn a_host_with_no_room_left_gets_a_zero_budget() {
250        assert_eq!(derived_copy_budget(Some(4 * GB), 4 * GB, 0), 0, "no slack");
251        assert_eq!(
252            derived_copy_budget(Some(32 * GB), 4 * GB, 100 * GB),
253            0,
254            "committed more than exists: saturates to zero, never wraps"
255        );
256    }
257
258    /// The opposite of [`plan_for`]'s answer to the same unknown, on
259    /// purpose: guessing wrong there is slow, guessing wrong here is
260    /// unbounded.
261    #[test]
262    fn an_unknown_amount_of_memory_retains_nothing() {
263        assert_eq!(derived_copy_budget(None, 4 * GB, 0), 0);
264    }
265
266    #[test]
267    fn vm_stat_counts_reclaimable_pages_not_just_free_ones() {
268        let sample = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
269                      Pages free:                          100000.\n\
270                      Pages active:                        900000.\n\
271                      Pages inactive:                       50000.\n";
272        // (100000 + 50000) * 16384
273        assert_eq!(parse_vm_stat(sample), Some(150_000 * 16_384));
274    }
275}