Skip to main content

rlx_distributed/
paging.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! **Disk-backed MoE expert paging.**
6//!
7//! The other half of [`crate::experts`]. That module spreads experts across
8//! *machines* and keeps them resident; this one keeps them on *local storage*
9//! and reads only what a token fires. Which is right depends on what you have:
10//! expert-parallel needs enough aggregate RAM in the cluster, paging needs
11//! enough disk bandwidth on one box.
12//!
13//! Paging is what the planner assumes whenever it reports a stage as `paged`
14//! ([`crate::cluster::Assignment::experts_paged`]), and its cost model — the
15//! `per_layer_expert_active_bytes / io_mbps` term in `stage_secs` — is exactly
16//! the arithmetic here: per token, per layer, read `top_k` of `n_routed`
17//! experts.
18//!
19//! ```text
20//!   router fires ids [3, 41, 190, ...]  (top_k of n_routed)
21//!            │
22//!   ExpertPager::gather(layer, bank, ids)
23//!            │   cache hit  → memcpy from the LRU
24//!            │   cache miss → pread the expert's slice of the bank
25//!            ▼
26//!   [top_k * bytes_per_expert] contiguous, in FIRED order
27//!            │
28//!   GroupedMatMul over a top_k-expert bank, not an n_routed-expert one
29//! ```
30//!
31//! The gathered buffer is laid out exactly like the full bank it came from, so
32//! it is a drop-in for a `GroupedMatMul` / `DequantGroupedMatMul` operand with
33//! `num_experts = ids.len()`. Bytes stay **packed**: paging exists to avoid
34//! materializing an f32 bank, so dequantizing on the way through would defeat
35//! it (at 2 bits/weight, 16x).
36//!
37//! ## Why a byte budget rather than an entry count
38//!
39//! Experts are not all the same size — a `ffn_gate_exps` slice and a
40//! `ffn_down_exps` slice differ, and quantization differs between banks. A
41//! cache bounded by entries would hold wildly varying amounts of memory on
42//! different models, which is not something a placement plan can reason about.
43//! The budget here is the number the planner hands over.
44
45use anyhow::{Context, Result, bail};
46use std::collections::{BTreeMap, HashMap};
47use std::fs::File;
48use std::path::{Path, PathBuf};
49use std::sync::{Arc, Mutex};
50
51/// A registered bank, resolved to a dense handle.
52///
53/// Lets a caller hoist the `(layer, name)` lookup out of a per-expert loop, and
54/// makes the cache key `Copy` so neither a hit nor an LRU move allocates.
55///
56/// Measured, not assumed: at GLM-5.3-Flash decode shape (42 layers x 3 banks x
57/// 8 experts = ~1000 lookups per token) resolving by handle instead of by name
58/// is **0.95x** — no faster, within noise. The map-key allocation is nothing
59/// beside the bytes the gather moves, which run at memory bandwidth. Use the
60/// handle because it is the clearer API and because it removes an allocation
61/// from a hot path, not for throughput.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
63pub struct BankIx(usize);
64
65/// Where an expert bank's bytes live, and how they are cut into experts.
66///
67/// `bytes_per_expert` is the bank's byte length divided by its expert count.
68/// That is exact for the block-quantized formats this targets *provided* each
69/// expert's rows are a whole number of quant blocks — true for GGUF K-quants,
70/// whose block is 256 elements and whose `in_dim` is a multiple of it.
71/// [`ExpertPager::register`] checks it rather than trusting it, because getting
72/// it wrong reads a misaligned window and silently produces garbage weights.
73#[derive(Debug, Clone)]
74pub struct BankLocation {
75    /// Checkpoint file holding the bank.
76    pub path: PathBuf,
77    /// Absolute byte offset of expert 0 within that file.
78    pub offset: u64,
79    pub num_experts: usize,
80    pub bytes_per_expert: usize,
81}
82
83impl BankLocation {
84    /// Absolute file offset of one expert's slice.
85    fn expert_offset(&self, id: usize) -> u64 {
86        self.offset + (id as u64) * (self.bytes_per_expert as u64)
87    }
88    /// Total bytes of the whole bank.
89    pub fn total_bytes(&self) -> u64 {
90        (self.num_experts as u64) * (self.bytes_per_expert as u64)
91    }
92}
93
94/// What paging cost, in the terms the planner priced it in.
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
96pub struct PagerStats {
97    /// Expert reads served from RAM.
98    pub hits: u64,
99    /// Expert reads that went to disk.
100    pub misses: u64,
101    /// Bytes actually read from storage.
102    pub bytes_read: u64,
103    /// Bytes served from the cache (what the disk did *not* have to move).
104    pub bytes_served: u64,
105    /// Experts dropped to stay inside the budget.
106    pub evictions: u64,
107}
108
109impl PagerStats {
110    /// Share of expert reads served without touching disk, in `0..=1`.
111    ///
112    /// The number that decides whether paging is viable for a given model: a
113    /// router that spreads uniformly over 288 experts has almost no reuse to
114    /// exploit and this stays near zero however large the budget, whereas a
115    /// peaked router turns most of the IO term into memcpy.
116    pub fn hit_rate(&self) -> f64 {
117        let n = self.hits + self.misses;
118        if n == 0 {
119            return 0.0;
120        }
121        self.hits as f64 / n as f64
122    }
123}
124
125/// One cached expert.
126struct Entry {
127    bytes: Arc<Vec<u8>>,
128    /// Logical clock of the last touch; the key of `lru` below.
129    used: u64,
130}
131
132struct Inner {
133    /// Cached experts by `(bank, expert)`. The key is `Copy`, so neither a
134    /// lookup nor an LRU move allocates.
135    resident: HashMap<(BankIx, usize), Entry>,
136    /// Touch order, oldest first. A `BTreeMap` rather than a queue so a touch
137    /// is O(log n) instead of an O(n) scan for the key to move.
138    lru: BTreeMap<u64, (BankIx, usize)>,
139    /// Monotonic touch counter.
140    clock: u64,
141    /// Bytes currently held.
142    bytes: u64,
143    /// Open file handles, so a per-token gather does not re-`open` per expert.
144    files: HashMap<PathBuf, Arc<File>>,
145    stats: PagerStats,
146}
147
148/// A byte-budgeted, disk-backed cache of MoE experts.
149///
150/// Model-agnostic: it knows byte ranges, not architectures. A model registers
151/// where its banks live and asks for the experts a token fired.
152///
153/// Cheap to share — `gather` takes `&self`, so several decode threads can page
154/// against one cache.
155pub struct ExpertPager {
156    /// Registered banks, indexed by [`BankIx`].
157    locs: Vec<BankLocation>,
158    /// `layer -> bank name -> handle`. Nested rather than keyed by a
159    /// `(usize, String)` tuple so a lookup can borrow the name instead of
160    /// allocating one.
161    by_name: HashMap<usize, HashMap<String, BankIx>>,
162    budget_bytes: u64,
163    inner: Mutex<Inner>,
164}
165
166impl ExpertPager {
167    /// A pager holding at most `budget_bytes` of expert data.
168    ///
169    /// A budget of 0 disables caching: every read goes to disk. That is a
170    /// legitimate configuration (it is what the planner's cost model assumes,
171    /// being the pessimistic case) rather than an error.
172    pub fn new(budget_bytes: u64) -> Self {
173        Self {
174            locs: Vec::new(),
175            by_name: HashMap::new(),
176            budget_bytes,
177            inner: Mutex::new(Inner {
178                resident: HashMap::new(),
179                lru: BTreeMap::new(),
180                clock: 0,
181                bytes: 0,
182                files: HashMap::new(),
183                stats: PagerStats::default(),
184            }),
185        }
186    }
187
188    /// Tell the pager where one bank lives.
189    ///
190    /// Rejects a location whose bytes do not divide evenly into its experts:
191    /// that means `bytes_per_expert` was computed from a wrong expert count or
192    /// a wrong tensor, and every read after the first would be misaligned —
193    /// which does not fail, it returns plausible-looking garbage.
194    pub fn register(
195        &mut self,
196        layer: usize,
197        bank: impl Into<String>,
198        loc: BankLocation,
199    ) -> Result<BankIx> {
200        if loc.num_experts == 0 {
201            bail!("expert bank has no experts");
202        }
203        if loc.bytes_per_expert == 0 {
204            bail!("expert bank has zero-length experts");
205        }
206        let bank = bank.into();
207        if let Ok(meta) = std::fs::metadata(&loc.path) {
208            let end = loc.offset + loc.total_bytes();
209            if end > meta.len() {
210                bail!(
211                    "bank {bank} of layer {layer} runs to byte {end} but {} is only {} bytes",
212                    loc.path.display(),
213                    meta.len()
214                );
215            }
216        }
217        // Re-registering replaces the location. The handle stays valid so any
218        // caller holding one keeps working, but the experts cached under it now
219        // describe the old bytes and have to go.
220        if let Some(&ix) = self.by_name.get(&layer).and_then(|m| m.get(&bank)) {
221            self.locs[ix.0] = loc;
222            self.forget(ix);
223            return Ok(ix);
224        }
225        let ix = BankIx(self.locs.len());
226        self.locs.push(loc);
227        self.by_name.entry(layer).or_default().insert(bank, ix);
228        Ok(ix)
229    }
230
231    /// Handle for a registered bank, or `None` if it was never registered.
232    ///
233    /// Hoist this out of a per-token loop: the handle is `Copy`, and every
234    /// lookup taking one is allocation-free.
235    pub fn bank_ix(&self, layer: usize, bank: &str) -> Option<BankIx> {
236        self.by_name.get(&layer)?.get(bank).copied()
237    }
238
239    /// Where a bank lives.
240    pub fn location(&self, ix: BankIx) -> &BankLocation {
241        &self.locs[ix.0]
242    }
243
244    /// Drop every cached expert of one bank.
245    fn forget(&mut self, ix: BankIx) {
246        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
247        let doomed: Vec<(BankIx, usize)> = inner
248            .resident
249            .keys()
250            .filter(|(b, _)| *b == ix)
251            .copied()
252            .collect();
253        for k in doomed {
254            if let Some(e) = inner.resident.remove(&k) {
255                inner.bytes -= e.bytes.len() as u64;
256                inner.lru.remove(&e.used);
257            }
258        }
259    }
260
261    /// Banks registered so far.
262    pub fn bank_count(&self) -> usize {
263        self.locs.len()
264    }
265
266    /// Bytes the whole registered checkpoint would occupy if held resident —
267    /// what paging is avoiding.
268    pub fn total_bank_bytes(&self) -> u64 {
269        self.locs.iter().map(|l| l.total_bytes()).sum()
270    }
271
272    pub fn stats(&self) -> PagerStats {
273        self.inner.lock().unwrap_or_else(|e| e.into_inner()).stats
274    }
275
276    /// Bytes currently cached.
277    pub fn resident_bytes(&self) -> u64 {
278        self.inner.lock().unwrap_or_else(|e| e.into_inner()).bytes
279    }
280
281    /// One expert's packed bytes, from cache or disk.
282    ///
283    /// Resolves the bank by name each call; [`Self::expert_at`] skips that.
284    pub fn expert(&self, layer: usize, bank: &str, id: usize) -> Result<Arc<Vec<u8>>> {
285        let ix = self
286            .bank_ix(layer, bank)
287            .with_context(|| format!("no expert bank registered for layer {layer} `{bank}`"))?;
288        self.expert_at(ix, id)
289    }
290
291    /// One expert's packed bytes, by handle.
292    pub fn expert_at(&self, ix: BankIx, id: usize) -> Result<Arc<Vec<u8>>> {
293        let loc = self
294            .locs
295            .get(ix.0)
296            .with_context(|| format!("bank handle {ix:?} is not registered"))?;
297        if id >= loc.num_experts {
298            bail!(
299                "expert {id} out of range for bank {ix:?} ({} experts)",
300                loc.num_experts
301            );
302        }
303        let ck = (ix, id);
304
305        // Phase 1: under the lock, look for a hit and take the file handle.
306        let file = {
307            let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
308            if let Some(bytes) = inner.touch(&ck) {
309                return Ok(bytes);
310            }
311            inner.open(&loc.path)?
312        };
313
314        // Phase 2: read WITHOUT the lock.
315        //
316        // Holding it across the `pread` would serialize every concurrent gather
317        // on one disk read, in the one module whose whole purpose is IO
318        // throughput — a pager that can only ever have one request in flight
319        // gets none of the queue depth an NVMe needs to reach its rated speed,
320        // and `stage_secs` prices paging at that rated speed.
321        let mut buf = vec![0u8; loc.bytes_per_expert];
322        read_exact_at(&file, &mut buf, loc.expert_offset(id)).with_context(|| {
323            format!(
324                "reading expert {id} of bank {ix:?} from {}",
325                loc.path.display()
326            )
327        })?;
328
329        // Phase 3: re-acquire and publish.
330        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
331        inner.stats.misses += 1;
332        inner.stats.bytes_read += buf.len() as u64;
333        // Another thread may have admitted the same expert while this one read
334        // it. Both copies are identical, so prefer the resident one and drop
335        // this read rather than holding the expert twice.
336        if let Some(bytes) = inner.touch(&ck) {
337            return Ok(bytes);
338        }
339        let bytes = Arc::new(buf);
340        inner.admit(ck, bytes.clone(), self.budget_bytes);
341        Ok(bytes)
342    }
343
344    /// The fired experts' bytes, concatenated in the order given.
345    ///
346    /// Laid out exactly like the full bank, so the result is a `GroupedMatMul`
347    /// operand with `num_experts = ids.len()`. Order is the caller's: it has to
348    /// match the routing weights the caller will apply, so this must not sort
349    /// or deduplicate. Repeating an id is legal and copies it twice.
350    pub fn gather(&self, layer: usize, bank: &str, ids: &[usize]) -> Result<Vec<u8>> {
351        let ix = self
352            .bank_ix(layer, bank)
353            .with_context(|| format!("no expert bank registered for layer {layer} `{bank}`"))?;
354        self.gather_at(ix, ids)
355    }
356
357    /// [`Self::gather`] by handle — the name is resolved once, not per expert.
358    pub fn gather_at(&self, ix: BankIx, ids: &[usize]) -> Result<Vec<u8>> {
359        let per = self
360            .locs
361            .get(ix.0)
362            .with_context(|| format!("bank handle {ix:?} is not registered"))?
363            .bytes_per_expert;
364        let mut out = Vec::with_capacity(ids.len() * per);
365        for &id in ids {
366            out.extend_from_slice(&self.expert_at(ix, id)?);
367        }
368        Ok(out)
369    }
370
371    /// Pull experts into the cache ahead of needing them.
372    ///
373    /// Useful when the router for step `t+1` is known while step `t` is still
374    /// computing. Errors are swallowed: a prefetch that fails costs a later
375    /// cache miss, and turning a speculative read into a hard failure would be
376    /// worse than the miss.
377    pub fn prefetch(&self, layer: usize, bank: &str, ids: &[usize]) {
378        for &id in ids {
379            let _ = self.expert(layer, bank, id);
380        }
381    }
382
383    /// Drop everything cached, keeping the registrations and the statistics.
384    pub fn clear(&self) {
385        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
386        inner.resident.clear();
387        inner.lru.clear();
388        inner.bytes = 0;
389    }
390}
391
392impl Inner {
393    /// Serve `key` from the cache, moving it to the most-recently-used end.
394    ///
395    /// Returns `None` on a miss, having changed nothing.
396    fn touch(&mut self, key: &(BankIx, usize)) -> Option<Arc<Vec<u8>>> {
397        let old = self.resident.get(key)?.used;
398        self.lru.remove(&old);
399        self.clock += 1;
400        let now = self.clock;
401        self.lru.insert(now, *key);
402        let e = self.resident.get_mut(key)?;
403        e.used = now;
404        let bytes = e.bytes.clone();
405        self.stats.hits += 1;
406        self.stats.bytes_served += bytes.len() as u64;
407        Some(bytes)
408    }
409
410    fn open(&mut self, path: &Path) -> Result<Arc<File>> {
411        if let Some(f) = self.files.get(path) {
412            return Ok(f.clone());
413        }
414        let f = Arc::new(
415            File::open(path).with_context(|| format!("opening checkpoint {}", path.display()))?,
416        );
417        self.files.insert(path.to_path_buf(), f.clone());
418        Ok(f)
419    }
420
421    /// Insert an expert, evicting least-recently-used ones to stay in budget.
422    fn admit(&mut self, key: (BankIx, usize), bytes: Arc<Vec<u8>>, budget: u64) {
423        let len = bytes.len() as u64;
424        // An expert larger than the whole budget is not cacheable; serving it
425        // straight through beats evicting everything to hold one item.
426        if len > budget {
427            return;
428        }
429        while self.bytes + len > budget {
430            let Some((&oldest, _)) = self.lru.iter().next() else {
431                break;
432            };
433            let Some(victim) = self.lru.remove(&oldest) else {
434                break;
435            };
436            if let Some(e) = self.resident.remove(&victim) {
437                self.bytes -= e.bytes.len() as u64;
438                self.stats.evictions += 1;
439            }
440        }
441        self.clock += 1;
442        let now = self.clock;
443        self.lru.insert(now, key);
444        self.bytes += len;
445        self.resident.insert(key, Entry { bytes, used: now });
446    }
447}
448
449/// Positional read that does not disturb a shared file cursor.
450fn read_exact_at(f: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
451    #[cfg(unix)]
452    {
453        use std::os::unix::fs::FileExt;
454        f.read_exact_at(buf, offset)
455    }
456    #[cfg(windows)]
457    {
458        use std::os::windows::fs::FileExt;
459        let mut done = 0usize;
460        while done < buf.len() {
461            let n = f.seek_read(&mut buf[done..], offset + done as u64)?;
462            if n == 0 {
463                return Err(std::io::Error::new(
464                    std::io::ErrorKind::UnexpectedEof,
465                    "short read paging an expert",
466                ));
467            }
468            done += n;
469        }
470        Ok(())
471    }
472    #[cfg(not(any(unix, windows)))]
473    {
474        use std::io::{Read, Seek, SeekFrom};
475        let mut f = f.try_clone()?;
476        f.seek(SeekFrom::Start(offset))?;
477        f.read_exact(buf)
478    }
479}
480
481/// Register every routed-expert bank in a GGUF checkpoint.
482///
483/// Reads only the header — the point of paging is never to load the tensor
484/// data, and a 93 GB checkpoint's index is a few hundred KB.
485///
486/// A bank is any tensor under a `blk.{i}.` prefix whose name ends in one of
487/// `bank_suffixes` (for `glm5next`: `ffn_gate_exps.weight`, `ffn_up_exps.weight`,
488/// `ffn_down_exps.weight`). GGUF stores dimensions innermost-first, so the
489/// expert count is the LAST entry of `shape` — `[4096, 2048, 8]` is 8 experts of
490/// a 4096x2048 projection, not 4096 of anything.
491///
492/// Returns the number of banks registered, and the key each was registered
493/// under is `(block_index, suffix_without_the_dot_weight)`.
494#[cfg(feature = "gguf")]
495pub fn register_gguf_expert_banks(
496    pager: &mut ExpertPager,
497    path: impl AsRef<Path>,
498    bank_suffixes: &[&str],
499) -> Result<usize> {
500    let path = path.as_ref();
501    let g = rlx_gguf::GgufFile::header_from_path(path)
502        .with_context(|| format!("reading GGUF header of {}", path.display()))?;
503    let base = g.data_offset();
504    let mut n = 0usize;
505
506    for t in g.tensors.values() {
507        let Some(rest) = t.name.strip_prefix("blk.") else {
508            continue;
509        };
510        let Some((idx, tail)) = rest.split_once('.') else {
511            continue;
512        };
513        let Ok(layer) = idx.parse::<usize>() else {
514            continue;
515        };
516        let Some(&suffix) = bank_suffixes.iter().find(|s| tail == **s) else {
517            continue;
518        };
519
520        let num_experts = *t.shape.last().unwrap_or(&0);
521        if num_experts == 0 {
522            bail!("{}: expert bank with an empty shape", t.name);
523        }
524        let total = rlx_gguf::bytes_for_public(t.dtype, t.n_elements()).ok_or_else(|| {
525            anyhow::anyhow!("{}: no byte size known for dtype {:?}", t.name, t.dtype)
526        })?;
527        if !total.is_multiple_of(num_experts) {
528            // Block-quantized rows that do not divide evenly by the expert
529            // count mean an expert's slice is not block-aligned, so every read
530            // past the first would start mid-block and dequantize to noise.
531            bail!(
532                "{}: {total} bytes do not divide into {num_experts} experts;                  the expert slices are not block-aligned and cannot be paged                  independently",
533                t.name
534            );
535        }
536        let bank = suffix.strip_suffix(".weight").unwrap_or(suffix);
537        pager.register(
538            layer,
539            bank,
540            BankLocation {
541                path: path.to_path_buf(),
542                offset: base + t.offset,
543                num_experts,
544                bytes_per_expert: total / num_experts,
545            },
546        )?;
547        n += 1;
548    }
549    Ok(n)
550}
551
552/// The routed-expert bank names a `glm5next` / DeepSeek-style GGUF uses.
553pub const GGUF_MOE_BANKS: [&str; 3] = [
554    "ffn_gate_exps.weight",
555    "ffn_up_exps.weight",
556    "ffn_down_exps.weight",
557];
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use std::io::Write;
563
564    /// A checkpoint whose expert `i` of bank `b` is the byte `(b*100 + i)`
565    /// repeated — so a misread window is visible in the content, not just in a
566    /// length.
567    fn checkpoint(dir: &str, banks: usize, experts: usize, per: usize) -> PathBuf {
568        let d = std::env::temp_dir().join(format!("rlx-pager-{dir}"));
569        let _ = std::fs::create_dir_all(&d);
570        let p = d.join("ckpt.bin");
571        let mut f = File::create(&p).unwrap();
572        for b in 0..banks {
573            for i in 0..experts {
574                f.write_all(&vec![(b * 100 + i) as u8; per]).unwrap();
575            }
576        }
577        f.flush().unwrap();
578        p
579    }
580
581    fn loc(path: &Path, bank: usize, experts: usize, per: usize) -> BankLocation {
582        BankLocation {
583            path: path.to_path_buf(),
584            offset: (bank * experts * per) as u64,
585            num_experts: experts,
586            bytes_per_expert: per,
587        }
588    }
589
590    #[test]
591    fn reads_the_right_expert_window() {
592        let p = checkpoint("windows", 3, 8, 64);
593        let mut pager = ExpertPager::new(1 << 20);
594        for b in 0..3 {
595            pager
596                .register(b, format!("bank{b}"), loc(&p, b, 8, 64))
597                .unwrap();
598        }
599        for b in 0..3 {
600            for i in 0..8 {
601                let got = pager.expert(b, &format!("bank{b}"), i).unwrap();
602                assert_eq!(got.len(), 64);
603                assert!(
604                    got.iter().all(|&x| x == (b * 100 + i) as u8),
605                    "layer {b} expert {i} read the wrong window: {:?}",
606                    &got[..8]
607                );
608            }
609        }
610    }
611
612    /// A gather is the fired experts back to back, in the caller's order — the
613    /// layout a GroupedMatMul operand needs. Order is not the pager's to
614    /// choose: it has to line up with the routing weights, so sorting or
615    /// deduplicating would silently pair each token with the wrong gate.
616    #[test]
617    fn gather_concatenates_in_fired_order() {
618        let p = checkpoint("gather", 1, 16, 32);
619        let mut pager = ExpertPager::new(1 << 20);
620        pager.register(0, "exps", loc(&p, 0, 16, 32)).unwrap();
621
622        let ids = [7usize, 2, 15, 2];
623        let g = pager.gather(0, "exps", &ids).unwrap();
624        assert_eq!(g.len(), ids.len() * 32);
625        for (slot, &id) in ids.iter().enumerate() {
626            let w = &g[slot * 32..(slot + 1) * 32];
627            assert!(
628                w.iter().all(|&x| x == id as u8),
629                "slot {slot} should hold expert {id}, holds {:?}",
630                &w[..4]
631            );
632        }
633    }
634
635    /// The budget is a hard ceiling. Exceeding it is how a paged stage OOMs the
636    /// node it was planned onto.
637    #[test]
638    fn the_byte_budget_is_never_exceeded() {
639        let p = checkpoint("budget", 1, 64, 1024);
640        let budget = 8 * 1024u64;
641        let mut pager = ExpertPager::new(budget);
642        pager.register(0, "exps", loc(&p, 0, 64, 1024)).unwrap();
643
644        for i in 0..64 {
645            pager.expert(0, "exps", i).unwrap();
646            assert!(
647                pager.resident_bytes() <= budget,
648                "after {i} experts the cache holds {} bytes against a {budget} budget",
649                pager.resident_bytes()
650            );
651        }
652        assert!(pager.stats().evictions > 0, "nothing was ever evicted");
653    }
654
655    /// Eviction must drop the LEAST-RECENTLY-USED expert. Evicting anything
656    /// else still respects the budget while destroying the hit rate, which is
657    /// the only reason the cache exists.
658    #[test]
659    fn eviction_is_least_recently_used() {
660        let p = checkpoint("lru", 1, 8, 100);
661        // Exactly three experts fit.
662        let mut pager = ExpertPager::new(300);
663        pager.register(0, "exps", loc(&p, 0, 8, 100)).unwrap();
664
665        for i in 0..3 {
666            pager.expert(0, "exps", i).unwrap();
667        }
668        // Touch 0 and 2, leaving 1 as the oldest.
669        pager.expert(0, "exps", 0).unwrap();
670        pager.expert(0, "exps", 2).unwrap();
671
672        let before = pager.stats();
673        pager.expert(0, "exps", 3).unwrap(); // forces one eviction
674        assert_eq!(pager.stats().evictions, before.evictions + 1);
675
676        // 1 should be gone; 0 and 2 should still be hits.
677        let mid = pager.stats();
678        pager.expert(0, "exps", 0).unwrap();
679        pager.expert(0, "exps", 2).unwrap();
680        assert_eq!(
681            pager.stats().hits,
682            mid.hits + 2,
683            "the recently-used experts were evicted instead of the stale one"
684        );
685        let mid = pager.stats();
686        pager.expert(0, "exps", 1).unwrap();
687        assert_eq!(
688            pager.stats().misses,
689            mid.misses + 1,
690            "expert 1 was the least-recently-used and should have been evicted"
691        );
692    }
693
694    /// Re-firing an expert must not re-read it. This is the entire economic
695    /// case for the cache, and it is what `hit_rate` reports to the planner.
696    #[test]
697    fn a_refired_expert_is_served_from_ram() {
698        let p = checkpoint("reuse", 1, 8, 256);
699        let mut pager = ExpertPager::new(1 << 20);
700        pager.register(0, "exps", loc(&p, 0, 8, 256)).unwrap();
701
702        pager.gather(0, "exps", &[1, 3, 5]).unwrap();
703        let after_first = pager.stats();
704        assert_eq!(after_first.misses, 3);
705        assert_eq!(after_first.bytes_read, 3 * 256);
706
707        pager.gather(0, "exps", &[1, 3, 5]).unwrap();
708        let s = pager.stats();
709        assert_eq!(s.misses, 3, "the second token re-read from disk");
710        assert_eq!(s.bytes_read, 3 * 256, "no new bytes should have been read");
711        assert_eq!(s.hits, 3);
712        assert!((s.hit_rate() - 0.5).abs() < 1e-9, "{}", s.hit_rate());
713    }
714
715    /// A zero budget is a valid configuration — no cache, every read from disk.
716    /// It is also exactly what the planner's `stage_secs` assumes, so it must
717    /// work rather than divide by zero or hold one entry anyway.
718    #[test]
719    fn a_zero_budget_pages_everything() {
720        let p = checkpoint("nocache", 1, 4, 128);
721        let mut pager = ExpertPager::new(0);
722        pager.register(0, "exps", loc(&p, 0, 4, 128)).unwrap();
723
724        for _ in 0..3 {
725            pager.gather(0, "exps", &[0, 1]).unwrap();
726        }
727        let s = pager.stats();
728        assert_eq!(s.hits, 0);
729        assert_eq!(s.misses, 6);
730        assert_eq!(pager.resident_bytes(), 0);
731    }
732
733    /// A bank whose declared extent runs past the file is a wrong expert count
734    /// or a wrong tensor. Caught at registration, because the reads it produces
735    /// do not fail — they return whatever bytes happen to be there.
736    #[test]
737    fn a_bank_that_overruns_the_file_is_rejected() {
738        let p = checkpoint("overrun", 1, 4, 64);
739        let mut pager = ExpertPager::new(1 << 20);
740        let err = pager
741            .register(
742                0,
743                "exps",
744                BankLocation {
745                    path: p.clone(),
746                    offset: 0,
747                    num_experts: 4,
748                    bytes_per_expert: 1024, // 4 KB claimed, 256 B present
749                },
750            )
751            .expect_err("an overrunning bank must be rejected");
752        assert!(err.to_string().contains("bytes"), "{err}");
753    }
754
755    /// The cache lock must be free while a read is in flight.
756    ///
757    /// The read used to happen with the mutex held, which let exactly one
758    /// request be in flight at a time — in the module whose only job is IO
759    /// throughput, and against a cost model (`stage_secs`) that prices paging
760    /// at the disk's rated speed, which an NVMe reaches only with queue depth.
761    /// Nothing else here would notice: every correctness test passes perfectly
762    /// well single-threaded.
763    ///
764    /// Testing it by comparing concurrent against serial throughput does not
765    /// work, and the reason is worth recording: once the checkpoint is in the
766    /// page cache a "read" is a memcpy, so there is no IO to overlap and eight
767    /// threads simply contend for memory bandwidth — concurrent came out
768    /// *slower* than serial with the lock already fixed.
769    ///
770    /// What is actually claimed is narrower and directly observable: while one
771    /// thread is inside a read, another can still take the lock. A reader takes
772    /// a large expert while a second thread spins on [`ExpertPager::stats`],
773    /// which needs the lock; if the read held it, the spinner makes no progress
774    /// for the read's whole duration. The separation between the two outcomes
775    /// is several orders of magnitude, not a percentage.
776    #[test]
777    fn the_cache_lock_is_free_while_a_read_is_in_flight() {
778        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
779
780        const BIG: usize = 64 << 20;
781        let p = checkpoint("lockfree", 1, 1, BIG);
782        // Budget 0: the read path runs every time, nothing is served from cache.
783        let mut pager = ExpertPager::new(0);
784        pager.register(0, "exps", loc(&p, 0, 1, BIG)).unwrap();
785        let pager = Arc::new(pager);
786        pager.expert(0, "exps", 0).unwrap(); // warm the page cache
787
788        let done = Arc::new(AtomicBool::new(false));
789        let spins = Arc::new(AtomicUsize::new(0));
790        let spinner = {
791            let (pager, done, spins) = (pager.clone(), done.clone(), spins.clone());
792            std::thread::spawn(move || {
793                while !done.load(Ordering::Relaxed) {
794                    // Takes the same mutex the read path uses.
795                    let _ = pager.stats();
796                    spins.fetch_add(1, Ordering::Relaxed);
797                }
798            })
799        };
800
801        // Long enough that the spinner would be starved for a clearly
802        // measurable stretch if the lock were held.
803        for _ in 0..4 {
804            pager.expert(0, "exps", 0).unwrap();
805        }
806        done.store(true, Ordering::Relaxed);
807        spinner.join().unwrap();
808
809        let n = spins.load(Ordering::Relaxed);
810        assert!(
811            n > 1_000,
812            "the spinner took the lock only {n} times across four 64 MB reads — \
813             it is being blocked for the duration of each read, so the cache \
814             mutex is held across the IO"
815        );
816        let _ = std::fs::remove_dir_all(p.parent().unwrap());
817    }
818
819    /// Two threads racing on the SAME expert must not leave two copies in the
820    /// cache. Both reads return identical bytes, so the bug is invisible except
821    /// as a budget the pager quietly exceeds.
822    #[test]
823    fn a_racing_double_read_admits_one_copy() {
824        let per = 1 << 20;
825        let p = checkpoint("race", 1, 2, per);
826        let mut pager = ExpertPager::new(8 * per as u64);
827        pager.register(0, "exps", loc(&p, 0, 2, per)).unwrap();
828        let pager = Arc::new(pager);
829
830        let barrier = Arc::new(std::sync::Barrier::new(4));
831        let hs: Vec<_> = (0..4)
832            .map(|_| {
833                let pager = pager.clone();
834                let barrier = barrier.clone();
835                std::thread::spawn(move || {
836                    barrier.wait();
837                    pager.expert(0, "exps", 1).unwrap()
838                })
839            })
840            .collect();
841        let got: Vec<_> = hs.into_iter().map(|h| h.join().unwrap()).collect();
842
843        for g in &got {
844            assert!(g.iter().all(|&x| x == 1u8), "a racing read got wrong bytes");
845        }
846        assert_eq!(
847            pager.resident_bytes(),
848            per as u64,
849            "the same expert is cached more than once"
850        );
851        let _ = std::fs::remove_dir_all(p.parent().unwrap());
852    }
853
854    /// The handle path and the name path must be the same path.
855    #[test]
856    fn a_handle_resolves_to_the_same_bytes_as_a_name() {
857        let p = checkpoint("handles", 2, 6, 48);
858        let mut pager = ExpertPager::new(1 << 20);
859        let ix0 = pager.register(0, "bank0", loc(&p, 0, 6, 48)).unwrap();
860        let ix1 = pager.register(1, "bank1", loc(&p, 1, 6, 48)).unwrap();
861        assert_ne!(ix0, ix1, "distinct banks must get distinct handles");
862        assert_eq!(pager.bank_ix(0, "bank0"), Some(ix0));
863        assert_eq!(pager.bank_ix(1, "bank1"), Some(ix1));
864        assert_eq!(pager.bank_ix(0, "bank1"), None, "handles are per layer");
865
866        for id in 0..6 {
867            assert_eq!(
868                pager.expert(0, "bank0", id).unwrap(),
869                pager.expert_at(ix0, id).unwrap()
870            );
871        }
872        assert_eq!(
873            pager.gather(1, "bank1", &[3, 0, 5]).unwrap(),
874            pager.gather_at(ix1, &[3, 0, 5]).unwrap()
875        );
876    }
877
878    /// Re-registering a bank must drop what was cached under it.
879    ///
880    /// Handles stay valid across a re-registration so a caller holding one
881    /// keeps working — which is exactly what makes the stale-cache hazard real:
882    /// the experts cached under that handle describe the OLD file, and serving
883    /// them after the location changed returns the wrong weights without any
884    /// error. Reloading a checkpoint in place is the obvious way to hit it.
885    #[test]
886    fn re_registering_a_bank_invalidates_its_cached_experts() {
887        // Two files whose expert 0 differs: bank 0 is filled with 0, bank 1
888        // with 100.
889        let p = checkpoint("reregister", 2, 4, 64);
890        let mut pager = ExpertPager::new(1 << 20);
891        let ix = pager.register(0, "exps", loc(&p, 0, 4, 64)).unwrap();
892
893        let before = pager.expert_at(ix, 0).unwrap();
894        assert!(before.iter().all(|&x| x == 0u8));
895        assert!(pager.resident_bytes() > 0);
896
897        // Point the same bank at the second region.
898        let again = pager.register(0, "exps", loc(&p, 1, 4, 64)).unwrap();
899        assert_eq!(again, ix, "the handle should survive re-registration");
900        assert_eq!(
901            pager.resident_bytes(),
902            0,
903            "experts cached from the old location are still held"
904        );
905
906        let after = pager.expert_at(ix, 0).unwrap();
907        assert!(
908            after.iter().all(|&x| x == 100u8),
909            "re-registered bank still serves the old file's bytes: {:?}",
910            &after[..4]
911        );
912    }
913
914    #[test]
915    fn an_out_of_range_expert_is_an_error_not_a_bad_read() {
916        let p = checkpoint("range", 1, 4, 64);
917        let mut pager = ExpertPager::new(1 << 20);
918        pager.register(0, "exps", loc(&p, 0, 4, 64)).unwrap();
919        let err = pager
920            .expert(0, "exps", 4)
921            .expect_err("expert 4 of 4 does not exist");
922        assert!(err.to_string().contains("out of range"), "{err}");
923    }
924}