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