memra_engine/spill.rs
1//! SPILLING-PLAN: full tiered spilling (VRAM ↔ pinned-host ↔ mmap-disk).
2//!
3//! Today memra has the VRAM↔pinned-host leg (the `MoeSlotCache` GPU slot cache + the pinned
4//! `HostExps` host store). This module adds the **third tier**: a `HostBuf::Mmap` arm (model.rs)
5//! so cold experts are demand-faulted from the GGUF file on disk instead of held in RAM, plus the
6//! runtime memory probe (`MemBudget`) that decides — per expert, at load — which tier each block
7//! lives in. Never hardcode: VRAM is queried via `cuMemGetInfo`, host RAM via `/proc/meminfo`.
8//!
9//! THE GATE (SPILLING-PLAN §8): spilling is a memory-PLACEMENT change, never a numerics change. A
10//! `Mmap` expert and a `Pinned` expert feed `qmatvec_view` byte-for-byte identical GGUF bytes — the
11//! `Pinned`/`Paged` stores copied FROM exactly those on-disk bytes — so argmax is unchanged.
12//!
13//! The disk tier is gated behind `MEMRA_SPILL_DISK`. Unset (default) = the current all-host
14//! behavior, byte-identical: `HostExps::tiers` stays `None` and every expert slices the single
15//! pinned/paged backing store. The daily models (9B/27B) fit 24 GB and NEVER trigger spill.
16
17use crate::Engine;
18use crate::model::HostBuf;
19use memmap2::Mmap;
20use std::sync::Arc;
21
22const DEFAULT_PINNED_FRAC: f64 = 0.60;
23
24fn parse_pinned_frac(raw: Option<&str>) -> Result<f64, &'static str> {
25 let frac = raw
26 .unwrap_or("0.60")
27 .parse::<f64>()
28 .map_err(|_| "expected a number")?;
29 if frac.is_finite() && frac > 0.0 && frac <= 1.0 {
30 Ok(frac)
31 } else {
32 Err("expected a finite fraction greater than 0 and at most 1")
33 }
34}
35
36fn configured_pinned_frac() -> f64 {
37 static PINNED_FRAC: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
38 *PINNED_FRAC.get_or_init(|| {
39 let raw = std::env::var("MEMRA_SPILL_PINNED_FRAC").ok();
40 match parse_pinned_frac(raw.as_deref()) {
41 Ok(frac) => frac,
42 Err(reason) => {
43 crate::spill_pread::note_config_fallback();
44 eprintln!(
45 "[spill] invalid MEMRA_SPILL_PINNED_FRAC={:?} ({reason}); using {DEFAULT_PINNED_FRAC}",
46 raw.as_deref().unwrap_or("")
47 );
48 DEFAULT_PINNED_FRAC
49 }
50 }
51 })
52}
53
54/// Runtime free-memory budget (SPILLING-PLAN §2). Both numbers are QUERIED at load, never
55/// hardcoded — free host RAM "varies with other LLM servers", so the split between pinned (Tier 1)
56/// and disk (Tier 2) must be decided against the live machine state.
57#[derive(Clone, Copy, Debug)]
58pub struct MemBudget {
59 /// Free VRAM in bytes, from `cuMemGetInfo` (authoritative; accounts for other GPU processes).
60 pub free_vram: usize,
61 /// Bytes of host RAM safe to pin: `/proc/meminfo MemAvailable` × `pinned_frac` (default 0.60).
62 /// Capped so `cudaHostAlloc` can neither OOM nor evict the page cache the Tier-2 mmap depends on.
63 pub free_pinnable_ram: usize,
64}
65
66impl MemBudget {
67 pub fn probe(e: &Engine) -> Result<Self, Box<dyn std::error::Error>> {
68 let (free_vram, _total) = e.ctx().mem_get_info()?; // same call moe_cache.rs:77 uses
69 let avail = read_meminfo_kb("MemAvailable")? * 1024; // MemAvailable (NOT MemFree)
70 let frac = configured_pinned_frac();
71 Ok(MemBudget {
72 free_vram,
73 free_pinnable_ram: ((avail as f64 * frac) as usize).min(avail),
74 })
75 }
76}
77
78/// Parse one `/proc/meminfo` field (a value in kB) by key, e.g. "MemAvailable".
79fn read_meminfo_kb(key: &str) -> Result<usize, Box<dyn std::error::Error>> {
80 let s = std::fs::read_to_string("/proc/meminfo")?;
81 for line in s.lines() {
82 // line form: "MemAvailable: 12345678 kB"
83 if let Some(rest) = line.strip_prefix(key) {
84 let rest = rest.trim_start_matches(':').trim();
85 let kb: usize = rest
86 .split_whitespace()
87 .next()
88 .ok_or("malformed /proc/meminfo line")?
89 .parse()?;
90 return Ok(kb);
91 }
92 }
93 Err(format!("/proc/meminfo: key {key} not found").into())
94}
95
96/// Is the disk tier (Tier 2) enabled? Gated behind `MEMRA_SPILL_DISK`. Default (unset) = off =>
97/// the unchanged all-host path (`HostExps::tiers` stays `None`). Set to anything to force-on.
98#[inline]
99pub fn disk_tier_enabled() -> bool {
100 std::env::var("MEMRA_SPILL_DISK").is_ok()
101}
102
103/// Shared load-time spill context (SPILLING-PLAN §2 step 4). Built ONCE per model load when the
104/// disk tier is on, then handed by `&mut` to each `HostExps::load` so all layers/projections share
105/// ONE file mmap PER SHARD and draw down a single running pinned-RAM budget. Greedy in load order:
106/// pin until `pinned_remaining` is exhausted, then spill every later expert to `Mmap`.
107pub struct SpillCtx {
108 /// One `MAP_SHARED` mmap per physical GGUF shard, shared (`Arc`) across every spilled expert
109 /// block that lives in that shard. Index = `TensorInfo::shard`. Single-file models have len 1.
110 /// PER-SHARD, not one map: a split model's `tensor_file_range` offsets are relative to the
111 /// OWNING shard's file, so pairing them with shard 0's mmap would read the wrong bytes (and
112 /// would index out of bounds for any shard larger than shard 0).
113 pub file_maps: Vec<Arc<Mmap>>,
114 /// The opened inodes backing `file_maps`, same indexing, retained for positioned expert reads.
115 pub files: Vec<Arc<std::fs::File>>,
116 /// Pinned-RAM budget still available (bytes); decremented as experts are pinned.
117 pub pinned_remaining: usize,
118 /// Diagnostics: how many experts landed pinned vs. mmap'd, and total disk-tier bytes.
119 pub n_pinned: usize,
120 pub n_mmap: usize,
121 pub mmap_bytes: usize,
122}
123
124impl SpillCtx {
125 /// Clone each parsed shard's opened inode, create a `MAP_SHARED` mmap per shard, and seed the
126 /// pinned budget from a live `MemBudget` probe.
127 /// The whole-map expert advice defaults to random (the historical behavior); setting
128 /// `MEMRA_MOE_MMAP_ADVICE=normal` restores ordinary Linux readahead. SPILLING-PLAN §1.
129 pub fn open(
130 g: &memra_gguf::GgufFile,
131 budget: &MemBudget,
132 ) -> Result<Self, Box<dyn std::error::Error>> {
133 let mut files = Vec::with_capacity(g.n_shards());
134 let mut file_maps = Vec::with_capacity(g.n_shards());
135 for i in 0..g.n_shards() {
136 let file = g.shard_file(i).clone();
137 // MAP_SHARED, no MAP_POPULATE (memmap2's default Mmap::map): zero upfront copy,
138 // demand-fault. This tier maps whole GGUF SHARDS, so its map length is not expert
139 // bytes — populating one would also read trunk weights the loader has already copied
140 // to VRAM. `populate_expert_slab` is therefore applied only to the `.memra-repack`
141 // tiers, whose files hold exactly one projection's expert slab.
142 let map = unsafe { Mmap::map(file.as_ref())? };
143 let _ = memra_gguf::source::apply_expert_mmap_advice(&map);
144 files.push(file);
145 file_maps.push(Arc::new(map));
146 }
147 Ok(SpillCtx {
148 file_maps,
149 files,
150 pinned_remaining: budget.free_pinnable_ram,
151 n_pinned: 0,
152 n_mmap: 0,
153 mmap_bytes: 0,
154 })
155 }
156}
157
158/// Build one expert's `HostBuf`, choosing its tier under the running budget (SPILLING-PLAN §1.1):
159/// pin (Tier 1) while `pinned_remaining` covers the block, else `Mmap` it (Tier 2). `file_off` is
160/// this expert's byte offset within ITS OWN SHARD's file
161/// (= `shards[t.shard].data_start + tensor.offset + e*stride`), and `shard` selects the matching
162/// mmap. Returns the chosen `HostBuf`; the bytes are bit-identical whichever tier is picked.
163pub fn place_expert(
164 ctx: &mut SpillCtx,
165 e: &Engine,
166 raw: &[u8],
167 file_off: usize,
168 shard: usize,
169) -> Result<HostBuf, Box<dyn std::error::Error>> {
170 let len = raw.len();
171 if ctx.pinned_remaining >= len {
172 // Tier 1: pinned host memory — true async DMA at full PCIe (matches the no-spill path).
173 ctx.pinned_remaining -= len;
174 ctx.n_pinned += 1;
175 let mut p = unsafe { e.ctx().alloc_pinned::<u8>(len)? };
176 {
177 let dst = p.as_mut_slice()?;
178 dst.copy_from_slice(raw);
179 }
180 let base = p.as_ptr()?;
181 Ok(HostBuf::Pinned {
182 slice: std::sync::Arc::new(p),
183 base,
184 len,
185 })
186 } else {
187 // Tier 2: mmap the GGUF region — demand-faulted from NVMe on first H2D. Zero RAM cost.
188 ctx.n_mmap += 1;
189 ctx.mmap_bytes += len;
190 Ok(HostBuf::Mmap {
191 map: ctx.file_maps[shard].clone(),
192 file: ctx.files[shard].clone(),
193 off: file_off,
194 len,
195 })
196 }
197}
198
199/// SPILLING-PLAN §3/§5: a single spillable weight block over the same `{Pinned, Mmap}` substrate.
200/// Lifted from the `HostExps` fields so dense weights (dense-70B case) can reuse the disk tier
201/// without the 256-expert stacking. Carried for the requested generalization; the MoE path uses
202/// `HostExps` directly (which now embeds the same tier machinery via `HostBuf`).
203pub struct SpillBlock {
204 pub host: HostBuf,
205 pub qtype: i32,
206 pub in_f: usize,
207 pub out_f: usize,
208 pub row_bytes: usize,
209}
210
211impl SpillBlock {
212 /// The H2D DMA source for this block — resolves the tier (`Pinned` fast / `Mmap` demand-fault).
213 #[inline]
214 pub fn bytes(&self) -> &[u8] {
215 self.host.as_bytes()
216 }
217}
218
219/// SPILLING-PLAN §3: the requested `Tiered` generalization. Structurally it is the existing
220/// `HostExps` (Tier 1/2 host backing, per-block) composed with the existing `MoeSlotCache`
221/// (Tier 0 GPU residency). Both seams are already present and unchanged; this names the composition.
222/// The MoE hot loop drives the two seams directly (`expert_bytes()` + `with_moe_cache`), so this is
223/// a documentation/structural alias, not a new hot path.
224pub struct Tiered {
225 pub host: crate::model::HostExps, // Tier 1/2 (Pinned hot / Mmap cold), per-expert
226 pub slots: crate::moe_cache::MoeSlotCache, // Tier 0 GPU residency (existing slot cache)
227}
228
229#[cfg(all(test, unix))]
230mod tests {
231 use super::{
232 DEFAULT_PINNED_FRAC, MemBudget, SpillCtx, configured_pinned_frac, parse_pinned_frac,
233 };
234 use crate::spill_pread::config_fallbacks;
235 use memra_gguf::{GGUF_MAGIC, GgufFile};
236
237 #[test]
238 fn pinned_frac_accepts_only_finite_values_in_range() {
239 assert_eq!(parse_pinned_frac(None), Ok(DEFAULT_PINNED_FRAC));
240 assert_eq!(parse_pinned_frac(Some("0.25")), Ok(0.25));
241 assert_eq!(parse_pinned_frac(Some("1")), Ok(1.0));
242 for invalid in ["0", "-0.1", "1.6", "NaN", "inf", "not-a-number"] {
243 assert!(
244 parse_pinned_frac(Some(invalid)).is_err(),
245 "accepted {invalid:?}"
246 );
247 }
248 }
249
250 #[test]
251 fn invalid_pinned_frac_is_counted_and_uses_safe_default() {
252 const CHILD: &str = "MEMRA_INVALID_PINNED_FRAC_TEST_CHILD";
253 const TEST: &str = "spill::tests::invalid_pinned_frac_is_counted_and_uses_safe_default";
254 if std::env::var_os(CHILD).is_some() {
255 assert_eq!(config_fallbacks(), 0);
256 assert_eq!(configured_pinned_frac(), DEFAULT_PINNED_FRAC);
257 assert_eq!(config_fallbacks(), 1);
258 return;
259 }
260
261 for raw in ["1.6", "not-a-number"] {
262 let output = std::process::Command::new(std::env::current_exe().unwrap())
263 .arg(TEST)
264 .arg("--exact")
265 .arg("--nocapture")
266 .env(CHILD, "1")
267 .env("MEMRA_SPILL_PINNED_FRAC", raw)
268 .output()
269 .unwrap();
270 let stdout = String::from_utf8_lossy(&output.stdout);
271 let stderr = String::from_utf8_lossy(&output.stderr);
272 assert!(
273 output.status.success(),
274 "invalid-config child failed for {raw:?}\nstdout:\n{stdout}\nstderr:\n{stderr}"
275 );
276 assert!(
277 stderr.contains(&format!("invalid MEMRA_SPILL_PINNED_FRAC={raw:?}"))
278 && stderr.contains("using 0.6"),
279 "invalid-config warning missing from child stderr:\n{stderr}"
280 );
281 }
282 }
283
284 #[test]
285 fn spill_ctx_keeps_parsed_gguf_inode_after_path_replacement() {
286 let path =
287 std::env::temp_dir().join(format!("memra-spill-inode-{}.gguf", std::process::id()));
288 let mut original = Vec::new();
289 original.extend_from_slice(&GGUF_MAGIC.to_le_bytes());
290 original.extend_from_slice(&3u32.to_le_bytes());
291 original.extend_from_slice(&0i64.to_le_bytes());
292 original.extend_from_slice(&0i64.to_le_bytes());
293 original.resize(32, 0);
294 std::fs::write(&path, &original).unwrap();
295
296 let gguf = GgufFile::open(&path).unwrap();
297 std::fs::remove_file(&path).unwrap();
298 std::fs::write(&path, vec![0xA5u8; original.len()]).unwrap();
299
300 let budget = MemBudget {
301 free_vram: 0,
302 free_pinnable_ram: 0,
303 };
304 let spill = SpillCtx::open(&gguf, &budget).unwrap();
305 assert_eq!(
306 spill.files.len(),
307 1,
308 "single-file GGUF must yield exactly one shard map"
309 );
310 assert!(std::sync::Arc::ptr_eq(&spill.files[0], gguf.opened_file()));
311 assert_eq!(&spill.file_maps[0][..], original.as_slice());
312 assert_eq!(std::fs::read(&path).unwrap(), vec![0xA5u8; original.len()]);
313
314 std::fs::remove_file(path).ok();
315 }
316}