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}