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::model::HostBuf;
18use crate::Engine;
19use memmap2::Mmap;
20use std::sync::Arc;
21
22/// Runtime free-memory budget (SPILLING-PLAN §2). Both numbers are QUERIED at load, never
23/// hardcoded — free host RAM "varies with other LLM servers", so the split between pinned (Tier 1)
24/// and disk (Tier 2) must be decided against the live machine state.
25#[derive(Clone, Copy, Debug)]
26pub struct MemBudget {
27 /// Free VRAM in bytes, from `cuMemGetInfo` (authoritative; accounts for other GPU processes).
28 pub free_vram: usize,
29 /// Bytes of host RAM safe to pin: `/proc/meminfo MemAvailable` × `pinned_frac` (default 0.60).
30 /// Capped so `cudaHostAlloc` can neither OOM nor evict the page cache the Tier-2 mmap depends on.
31 pub free_pinnable_ram: usize,
32}
33
34impl MemBudget {
35 pub fn probe(e: &Engine) -> Result<Self, Box<dyn std::error::Error>> {
36 let (free_vram, _total) = e.ctx().mem_get_info()?; // same call moe_cache.rs:77 uses
37 let avail = read_meminfo_kb("MemAvailable")? * 1024; // MemAvailable (NOT MemFree)
38 let frac = std::env::var("MEMRA_SPILL_PINNED_FRAC")
39 .ok()
40 .and_then(|s| s.parse::<f64>().ok())
41 .unwrap_or(0.60);
42 Ok(MemBudget {
43 free_vram,
44 free_pinnable_ram: (avail as f64 * frac) as usize,
45 })
46 }
47}
48
49/// Parse one `/proc/meminfo` field (a value in kB) by key, e.g. "MemAvailable".
50fn read_meminfo_kb(key: &str) -> Result<usize, Box<dyn std::error::Error>> {
51 let s = std::fs::read_to_string("/proc/meminfo")?;
52 for line in s.lines() {
53 // line form: "MemAvailable: 12345678 kB"
54 if let Some(rest) = line.strip_prefix(key) {
55 let rest = rest.trim_start_matches(':').trim();
56 let kb: usize = rest
57 .split_whitespace()
58 .next()
59 .ok_or("malformed /proc/meminfo line")?
60 .parse()?;
61 return Ok(kb);
62 }
63 }
64 Err(format!("/proc/meminfo: key {key} not found").into())
65}
66
67/// Is the disk tier (Tier 2) enabled? Gated behind `MEMRA_SPILL_DISK`. Default (unset) = off =>
68/// the unchanged all-host path (`HostExps::tiers` stays `None`). Set to anything to force-on.
69#[inline]
70pub fn disk_tier_enabled() -> bool {
71 std::env::var("MEMRA_SPILL_DISK").is_ok()
72}
73
74/// Shared load-time spill context (SPILLING-PLAN §2 step 4). Built ONCE per model load when the
75/// disk tier is on, then handed by `&mut` to each `HostExps::load` so all layers/projections share
76/// ONE file mmap and draw down a single running pinned-RAM budget. Greedy in load order: pin until
77/// `pinned_remaining` is exhausted, then spill every later expert to `Mmap`.
78pub struct SpillCtx {
79 /// One `MAP_SHARED` mmap of the whole GGUF, shared (`Arc`) across every spilled expert block.
80 pub file_map: Arc<Mmap>,
81 /// The same opened inode backing `file_map`, retained for future positioned expert reads.
82 pub file: Arc<std::fs::File>,
83 /// Pinned-RAM budget still available (bytes); decremented as experts are pinned.
84 pub pinned_remaining: usize,
85 /// Diagnostics: how many experts landed pinned vs. mmap'd, and total disk-tier bytes.
86 pub n_pinned: usize,
87 pub n_mmap: usize,
88 pub mmap_bytes: usize,
89}
90
91impl SpillCtx {
92 /// Clone the parsed GGUF's opened inode, create a `MAP_SHARED` mmap from it, and seed the pinned
93 /// budget from a live `MemBudget` probe.
94 /// The whole-map expert advice defaults to random (the historical behavior); setting
95 /// `MEMRA_MOE_MMAP_ADVICE=normal` restores ordinary Linux readahead. SPILLING-PLAN §1.
96 pub fn open(
97 g: &memra_gguf::GgufFile,
98 budget: &MemBudget,
99 ) -> Result<Self, Box<dyn std::error::Error>> {
100 let file = g.opened_file().clone();
101 // MAP_SHARED, no MAP_POPULATE (memmap2's default Mmap::map): zero upfront copy, demand-fault.
102 let map = unsafe { Mmap::map(file.as_ref())? };
103 let _ = memra_gguf::source::apply_expert_mmap_advice(&map);
104 Ok(SpillCtx {
105 file_map: Arc::new(map),
106 file,
107 pinned_remaining: budget.free_pinnable_ram,
108 n_pinned: 0,
109 n_mmap: 0,
110 mmap_bytes: 0,
111 })
112 }
113}
114
115/// Build one expert's `HostBuf`, choosing its tier under the running budget (SPILLING-PLAN §1.1):
116/// pin (Tier 1) while `pinned_remaining` covers the block, else `Mmap` it (Tier 2). `file_off` is
117/// this expert's absolute byte offset within the GGUF file (= `data_start + tensor.offset + e*stride`).
118/// Returns the chosen `HostBuf`; the bytes are bit-identical whichever tier is picked.
119pub fn place_expert(
120 ctx: &mut SpillCtx,
121 e: &Engine,
122 raw: &[u8],
123 file_off: usize,
124) -> Result<HostBuf, Box<dyn std::error::Error>> {
125 let len = raw.len();
126 if ctx.pinned_remaining >= len {
127 // Tier 1: pinned host memory — true async DMA at full PCIe (matches the no-spill path).
128 ctx.pinned_remaining -= len;
129 ctx.n_pinned += 1;
130 let mut p = unsafe { e.ctx().alloc_pinned::<u8>(len)? };
131 {
132 let dst = p.as_mut_slice()?;
133 dst.copy_from_slice(raw);
134 }
135 let base = p.as_ptr()? as *const u8;
136 Ok(HostBuf::Pinned {
137 slice: std::sync::Arc::new(p),
138 base,
139 len,
140 })
141 } else {
142 // Tier 2: mmap the GGUF region — demand-faulted from NVMe on first H2D. Zero RAM cost.
143 ctx.n_mmap += 1;
144 ctx.mmap_bytes += len;
145 Ok(HostBuf::Mmap {
146 map: ctx.file_map.clone(),
147 file: ctx.file.clone(),
148 off: file_off,
149 len,
150 })
151 }
152}
153
154/// SPILLING-PLAN §3/§5: a single spillable weight block over the same `{Pinned, Mmap}` substrate.
155/// Lifted from the `HostExps` fields so dense weights (dense-70B case) can reuse the disk tier
156/// without the 256-expert stacking. Carried for the requested generalization; the MoE path uses
157/// `HostExps` directly (which now embeds the same tier machinery via `HostBuf`).
158pub struct SpillBlock {
159 pub host: HostBuf,
160 pub qtype: i32,
161 pub in_f: usize,
162 pub out_f: usize,
163 pub row_bytes: usize,
164}
165
166impl SpillBlock {
167 /// The H2D DMA source for this block — resolves the tier (`Pinned` fast / `Mmap` demand-fault).
168 #[inline]
169 pub fn bytes(&self) -> &[u8] {
170 self.host.as_bytes()
171 }
172}
173
174/// SPILLING-PLAN §3: the requested `Tiered` generalization. Structurally it is the existing
175/// `HostExps` (Tier 1/2 host backing, per-block) composed with the existing `MoeSlotCache`
176/// (Tier 0 GPU residency). Both seams are already present and unchanged; this names the composition.
177/// The MoE hot loop drives the two seams directly (`expert_bytes()` + `with_moe_cache`), so this is
178/// a documentation/structural alias, not a new hot path.
179pub struct Tiered {
180 pub host: crate::model::HostExps, // Tier 1/2 (Pinned hot / Mmap cold), per-expert
181 pub slots: crate::moe_cache::MoeSlotCache, // Tier 0 GPU residency (existing slot cache)
182}
183
184#[cfg(all(test, unix))]
185mod tests {
186 use super::{MemBudget, SpillCtx};
187 use memra_gguf::{GgufFile, GGUF_MAGIC};
188
189 #[test]
190 fn spill_ctx_keeps_parsed_gguf_inode_after_path_replacement() {
191 let path =
192 std::env::temp_dir().join(format!("memra-spill-inode-{}.gguf", std::process::id()));
193 let mut original = Vec::new();
194 original.extend_from_slice(&GGUF_MAGIC.to_le_bytes());
195 original.extend_from_slice(&3u32.to_le_bytes());
196 original.extend_from_slice(&0i64.to_le_bytes());
197 original.extend_from_slice(&0i64.to_le_bytes());
198 original.resize(32, 0);
199 std::fs::write(&path, &original).unwrap();
200
201 let gguf = GgufFile::open(&path).unwrap();
202 std::fs::remove_file(&path).unwrap();
203 std::fs::write(&path, vec![0xA5u8; original.len()]).unwrap();
204
205 let budget = MemBudget {
206 free_vram: 0,
207 free_pinnable_ram: 0,
208 };
209 let spill = SpillCtx::open(&gguf, &budget).unwrap();
210 assert!(std::sync::Arc::ptr_eq(&spill.file, gguf.opened_file()));
211 assert_eq!(&spill.file_map[..], original.as_slice());
212 assert_eq!(std::fs::read(&path).unwrap(), vec![0xA5u8; original.len()]);
213
214 std::fs::remove_file(path).ok();
215 }
216}