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}