Skip to main content

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
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.
139            let map = unsafe { Mmap::map(file.as_ref())? };
140            let _ = memra_gguf::source::apply_expert_mmap_advice(&map);
141            files.push(file);
142            file_maps.push(Arc::new(map));
143        }
144        Ok(SpillCtx {
145            file_maps,
146            files,
147            pinned_remaining: budget.free_pinnable_ram,
148            n_pinned: 0,
149            n_mmap: 0,
150            mmap_bytes: 0,
151        })
152    }
153}
154
155/// Build one expert's `HostBuf`, choosing its tier under the running budget (SPILLING-PLAN §1.1):
156/// pin (Tier 1) while `pinned_remaining` covers the block, else `Mmap` it (Tier 2). `file_off` is
157/// this expert's byte offset within ITS OWN SHARD's file
158/// (= `shards[t.shard].data_start + tensor.offset + e*stride`), and `shard` selects the matching
159/// mmap. Returns the chosen `HostBuf`; the bytes are bit-identical whichever tier is picked.
160pub fn place_expert(
161    ctx: &mut SpillCtx,
162    e: &Engine,
163    raw: &[u8],
164    file_off: usize,
165    shard: usize,
166) -> Result<HostBuf, Box<dyn std::error::Error>> {
167    let len = raw.len();
168    if ctx.pinned_remaining >= len {
169        // Tier 1: pinned host memory — true async DMA at full PCIe (matches the no-spill path).
170        ctx.pinned_remaining -= len;
171        ctx.n_pinned += 1;
172        let mut p = unsafe { e.ctx().alloc_pinned::<u8>(len)? };
173        {
174            let dst = p.as_mut_slice()?;
175            dst.copy_from_slice(raw);
176        }
177        let base = p.as_ptr()? as *const u8;
178        Ok(HostBuf::Pinned {
179            slice: std::sync::Arc::new(p),
180            base,
181            len,
182        })
183    } else {
184        // Tier 2: mmap the GGUF region — demand-faulted from NVMe on first H2D. Zero RAM cost.
185        ctx.n_mmap += 1;
186        ctx.mmap_bytes += len;
187        Ok(HostBuf::Mmap {
188            map: ctx.file_maps[shard].clone(),
189            file: ctx.files[shard].clone(),
190            off: file_off,
191            len,
192        })
193    }
194}
195
196/// SPILLING-PLAN §3/§5: a single spillable weight block over the same `{Pinned, Mmap}` substrate.
197/// Lifted from the `HostExps` fields so dense weights (dense-70B case) can reuse the disk tier
198/// without the 256-expert stacking. Carried for the requested generalization; the MoE path uses
199/// `HostExps` directly (which now embeds the same tier machinery via `HostBuf`).
200pub struct SpillBlock {
201    pub host: HostBuf,
202    pub qtype: i32,
203    pub in_f: usize,
204    pub out_f: usize,
205    pub row_bytes: usize,
206}
207
208impl SpillBlock {
209    /// The H2D DMA source for this block — resolves the tier (`Pinned` fast / `Mmap` demand-fault).
210    #[inline]
211    pub fn bytes(&self) -> &[u8] {
212        self.host.as_bytes()
213    }
214}
215
216/// SPILLING-PLAN §3: the requested `Tiered` generalization. Structurally it is the existing
217/// `HostExps` (Tier 1/2 host backing, per-block) composed with the existing `MoeSlotCache`
218/// (Tier 0 GPU residency). Both seams are already present and unchanged; this names the composition.
219/// The MoE hot loop drives the two seams directly (`expert_bytes()` + `with_moe_cache`), so this is
220/// a documentation/structural alias, not a new hot path.
221pub struct Tiered {
222    pub host: crate::model::HostExps, // Tier 1/2 (Pinned hot / Mmap cold), per-expert
223    pub slots: crate::moe_cache::MoeSlotCache, // Tier 0 GPU residency (existing slot cache)
224}
225
226#[cfg(all(test, unix))]
227mod tests {
228    use super::{
229        configured_pinned_frac, parse_pinned_frac, MemBudget, SpillCtx, DEFAULT_PINNED_FRAC,
230    };
231    use crate::spill_pread::config_fallbacks;
232    use memra_gguf::{GgufFile, GGUF_MAGIC};
233
234    #[test]
235    fn pinned_frac_accepts_only_finite_values_in_range() {
236        assert_eq!(parse_pinned_frac(None), Ok(DEFAULT_PINNED_FRAC));
237        assert_eq!(parse_pinned_frac(Some("0.25")), Ok(0.25));
238        assert_eq!(parse_pinned_frac(Some("1")), Ok(1.0));
239        for invalid in ["0", "-0.1", "1.6", "NaN", "inf", "not-a-number"] {
240            assert!(parse_pinned_frac(Some(invalid)).is_err(), "accepted {invalid:?}");
241        }
242    }
243
244    #[test]
245    fn invalid_pinned_frac_is_counted_and_uses_safe_default() {
246        const CHILD: &str = "MEMRA_INVALID_PINNED_FRAC_TEST_CHILD";
247        const TEST: &str =
248            "spill::tests::invalid_pinned_frac_is_counted_and_uses_safe_default";
249        if std::env::var_os(CHILD).is_some() {
250            assert_eq!(config_fallbacks(), 0);
251            assert_eq!(configured_pinned_frac(), DEFAULT_PINNED_FRAC);
252            assert_eq!(config_fallbacks(), 1);
253            return;
254        }
255
256        for raw in ["1.6", "not-a-number"] {
257            let output = std::process::Command::new(std::env::current_exe().unwrap())
258                .arg(TEST)
259                .arg("--exact")
260                .arg("--nocapture")
261                .env(CHILD, "1")
262                .env("MEMRA_SPILL_PINNED_FRAC", raw)
263                .output()
264                .unwrap();
265            let stdout = String::from_utf8_lossy(&output.stdout);
266            let stderr = String::from_utf8_lossy(&output.stderr);
267            assert!(
268                output.status.success(),
269                "invalid-config child failed for {raw:?}\nstdout:\n{stdout}\nstderr:\n{stderr}"
270            );
271            assert!(
272                stderr.contains(&format!(
273                    "invalid MEMRA_SPILL_PINNED_FRAC={raw:?}"
274                )) && stderr.contains("using 0.6"),
275                "invalid-config warning missing from child stderr:\n{stderr}"
276            );
277        }
278    }
279
280    #[test]
281    fn spill_ctx_keeps_parsed_gguf_inode_after_path_replacement() {
282        let path =
283            std::env::temp_dir().join(format!("memra-spill-inode-{}.gguf", std::process::id()));
284        let mut original = Vec::new();
285        original.extend_from_slice(&GGUF_MAGIC.to_le_bytes());
286        original.extend_from_slice(&3u32.to_le_bytes());
287        original.extend_from_slice(&0i64.to_le_bytes());
288        original.extend_from_slice(&0i64.to_le_bytes());
289        original.resize(32, 0);
290        std::fs::write(&path, &original).unwrap();
291
292        let gguf = GgufFile::open(&path).unwrap();
293        std::fs::remove_file(&path).unwrap();
294        std::fs::write(&path, vec![0xA5u8; original.len()]).unwrap();
295
296        let budget = MemBudget {
297            free_vram: 0,
298            free_pinnable_ram: 0,
299        };
300        let spill = SpillCtx::open(&gguf, &budget).unwrap();
301        assert_eq!(spill.files.len(), 1, "single-file GGUF must yield exactly one shard map");
302        assert!(std::sync::Arc::ptr_eq(&spill.files[0], gguf.opened_file()));
303        assert_eq!(&spill.file_maps[0][..], original.as_slice());
304        assert_eq!(std::fs::read(&path).unwrap(), vec![0xA5u8; original.len()]);
305
306        std::fs::remove_file(path).ok();
307    }
308}