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
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}