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 PER SHARD and draw down a single running pinned-RAM budget. Greedy in load order:
77/// pin until `pinned_remaining` is exhausted, then spill every later expert to `Mmap`.
78pub struct SpillCtx {
79 /// One `MAP_SHARED` mmap per physical GGUF shard, shared (`Arc`) across every spilled expert
80 /// block that lives in that shard. Index = `TensorInfo::shard`. Single-file models have len 1.
81 /// PER-SHARD, not one map: a split model's `tensor_file_range` offsets are relative to the
82 /// OWNING shard's file, so pairing them with shard 0's mmap would read the wrong bytes (and
83 /// would index out of bounds for any shard larger than shard 0).
84 pub file_maps: Vec<Arc<Mmap>>,
85 /// The opened inodes backing `file_maps`, same indexing, retained for positioned expert reads.
86 pub files: Vec<Arc<std::fs::File>>,
87 /// Pinned-RAM budget still available (bytes); decremented as experts are pinned.
88 pub pinned_remaining: usize,
89 /// Diagnostics: how many experts landed pinned vs. mmap'd, and total disk-tier bytes.
90 pub n_pinned: usize,
91 pub n_mmap: usize,
92 pub mmap_bytes: usize,
93}
94
95impl SpillCtx {
96 /// Clone each parsed shard's opened inode, create a `MAP_SHARED` mmap per shard, and seed the
97 /// pinned budget from a live `MemBudget` probe.
98 /// The whole-map expert advice defaults to random (the historical behavior); setting
99 /// `MEMRA_MOE_MMAP_ADVICE=normal` restores ordinary Linux readahead. SPILLING-PLAN §1.
100 pub fn open(
101 g: &memra_gguf::GgufFile,
102 budget: &MemBudget,
103 ) -> Result<Self, Box<dyn std::error::Error>> {
104 let mut files = Vec::with_capacity(g.n_shards());
105 let mut file_maps = Vec::with_capacity(g.n_shards());
106 for i in 0..g.n_shards() {
107 let file = g.shard_file(i).clone();
108 // MAP_SHARED, no MAP_POPULATE (memmap2's default Mmap::map): zero upfront copy,
109 // demand-fault.
110 let map = unsafe { Mmap::map(file.as_ref())? };
111 let _ = memra_gguf::source::apply_expert_mmap_advice(&map);
112 files.push(file);
113 file_maps.push(Arc::new(map));
114 }
115 Ok(SpillCtx {
116 file_maps,
117 files,
118 pinned_remaining: budget.free_pinnable_ram,
119 n_pinned: 0,
120 n_mmap: 0,
121 mmap_bytes: 0,
122 })
123 }
124}
125
126/// Build one expert's `HostBuf`, choosing its tier under the running budget (SPILLING-PLAN §1.1):
127/// pin (Tier 1) while `pinned_remaining` covers the block, else `Mmap` it (Tier 2). `file_off` is
128/// this expert's byte offset within ITS OWN SHARD's file
129/// (= `shards[t.shard].data_start + tensor.offset + e*stride`), and `shard` selects the matching
130/// mmap. Returns the chosen `HostBuf`; the bytes are bit-identical whichever tier is picked.
131pub fn place_expert(
132 ctx: &mut SpillCtx,
133 e: &Engine,
134 raw: &[u8],
135 file_off: usize,
136 shard: usize,
137) -> Result<HostBuf, Box<dyn std::error::Error>> {
138 let len = raw.len();
139 if ctx.pinned_remaining >= len {
140 // Tier 1: pinned host memory — true async DMA at full PCIe (matches the no-spill path).
141 ctx.pinned_remaining -= len;
142 ctx.n_pinned += 1;
143 let mut p = unsafe { e.ctx().alloc_pinned::<u8>(len)? };
144 {
145 let dst = p.as_mut_slice()?;
146 dst.copy_from_slice(raw);
147 }
148 let base = p.as_ptr()? as *const u8;
149 Ok(HostBuf::Pinned {
150 slice: std::sync::Arc::new(p),
151 base,
152 len,
153 })
154 } else {
155 // Tier 2: mmap the GGUF region — demand-faulted from NVMe on first H2D. Zero RAM cost.
156 ctx.n_mmap += 1;
157 ctx.mmap_bytes += len;
158 Ok(HostBuf::Mmap {
159 map: ctx.file_maps[shard].clone(),
160 file: ctx.files[shard].clone(),
161 off: file_off,
162 len,
163 })
164 }
165}
166
167/// SPILLING-PLAN §3/§5: a single spillable weight block over the same `{Pinned, Mmap}` substrate.
168/// Lifted from the `HostExps` fields so dense weights (dense-70B case) can reuse the disk tier
169/// without the 256-expert stacking. Carried for the requested generalization; the MoE path uses
170/// `HostExps` directly (which now embeds the same tier machinery via `HostBuf`).
171pub struct SpillBlock {
172 pub host: HostBuf,
173 pub qtype: i32,
174 pub in_f: usize,
175 pub out_f: usize,
176 pub row_bytes: usize,
177}
178
179impl SpillBlock {
180 /// The H2D DMA source for this block — resolves the tier (`Pinned` fast / `Mmap` demand-fault).
181 #[inline]
182 pub fn bytes(&self) -> &[u8] {
183 self.host.as_bytes()
184 }
185}
186
187/// SPILLING-PLAN §3: the requested `Tiered` generalization. Structurally it is the existing
188/// `HostExps` (Tier 1/2 host backing, per-block) composed with the existing `MoeSlotCache`
189/// (Tier 0 GPU residency). Both seams are already present and unchanged; this names the composition.
190/// The MoE hot loop drives the two seams directly (`expert_bytes()` + `with_moe_cache`), so this is
191/// a documentation/structural alias, not a new hot path.
192pub struct Tiered {
193 pub host: crate::model::HostExps, // Tier 1/2 (Pinned hot / Mmap cold), per-expert
194 pub slots: crate::moe_cache::MoeSlotCache, // Tier 0 GPU residency (existing slot cache)
195}
196
197#[cfg(all(test, unix))]
198mod tests {
199 use super::{MemBudget, SpillCtx};
200 use memra_gguf::{GgufFile, GGUF_MAGIC};
201
202 #[test]
203 fn spill_ctx_keeps_parsed_gguf_inode_after_path_replacement() {
204 let path =
205 std::env::temp_dir().join(format!("memra-spill-inode-{}.gguf", std::process::id()));
206 let mut original = Vec::new();
207 original.extend_from_slice(&GGUF_MAGIC.to_le_bytes());
208 original.extend_from_slice(&3u32.to_le_bytes());
209 original.extend_from_slice(&0i64.to_le_bytes());
210 original.extend_from_slice(&0i64.to_le_bytes());
211 original.resize(32, 0);
212 std::fs::write(&path, &original).unwrap();
213
214 let gguf = GgufFile::open(&path).unwrap();
215 std::fs::remove_file(&path).unwrap();
216 std::fs::write(&path, vec![0xA5u8; original.len()]).unwrap();
217
218 let budget = MemBudget {
219 free_vram: 0,
220 free_pinnable_ram: 0,
221 };
222 let spill = SpillCtx::open(&gguf, &budget).unwrap();
223 assert_eq!(spill.files.len(), 1, "single-file GGUF must yield exactly one shard map");
224 assert!(std::sync::Arc::ptr_eq(&spill.files[0], gguf.opened_file()));
225 assert_eq!(&spill.file_maps[0][..], original.as_slice());
226 assert_eq!(std::fs::read(&path).unwrap(), vec![0xA5u8; original.len()]);
227
228 std::fs::remove_file(path).ok();
229 }
230}