Skip to main content

hanzo_ml/quantized/
expert_stream.rs

1//! Disk-streaming LRU expert cache: the low-memory mode for large MoE GGUFs.
2//!
3//! Each GGUF MoE layer stores its routed experts as three stacked `[n_experts, n, k]` banks
4//! (`ffn_{gate,up,down}_exps`). Instead of loading them resident, keep them on NVMe and stream one
5//! expert's `[n, k]` slice on demand: pin -> per-bank LRU -> pread (+ fadvise DONTNEED). Resident
6//! RAM is then `pinned + LRU` slabs -- bounded, not the whole model. One bank per layer x projection.
7//!
8//! Bit-exact: a streamed expert is the same file bytes the resident slice holds, so
9//! `indexed_moe_forward` yields identical output; only the fetch path changes.
10
11use crate::Result;
12use std::collections::HashMap;
13use std::fs::File;
14use std::path::PathBuf;
15use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16use std::sync::mpsc::SyncSender;
17use std::sync::{Arc, Mutex, OnceLock, Weak};
18
19use super::GgmlDType;
20
21/// Runtime gate, default OFF so resident behaviour is unchanged. The loader flips it on.
22static ENABLED: AtomicBool = AtomicBool::new(false);
23
24pub fn set_enabled(on: bool) {
25    ENABLED.store(on, Ordering::Relaxed);
26}
27
28pub fn enabled() -> bool {
29    ENABLED.load(Ordering::Relaxed)
30}
31
32/// Reserved out of the RAM budget for page cache and activations.
33const PAGE_CACHE_RESERVE: u64 = 2_500_000_000;
34const ACTIVATION_RESERVE: u64 = 1_200_000_000;
35/// Fraction of MemAvailable the cache may claim.
36const BUDGET_FRACTION: f64 = 0.88;
37/// Fraction of a bank's cap auto-pinned from the learned usage sidecar.
38const PIN_FRACTION: f64 = 0.25;
39/// Max hot-set swaps per [`repin`] pass. Four-swap cap: bounds work and
40/// stops a single turn from churning the whole tier.
41const REPIN_MAX_SWAPS: usize = 4;
42
43/// One stacked `[n_experts, n, k]` GGUF expert bank kept on disk.
44pub struct ExpertStreamBank {
45    /// Tensor name; the sidecar key for learned pinning.
46    name: String,
47    file: File,
48    /// Byte offset of expert 0 in the file.
49    base_offset: u64,
50    /// Bytes per expert (`n*k / block_size * type_size`).
51    expert_bytes: usize,
52    n_experts: usize,
53    dtype: GgmlDType,
54    n: usize,
55    k: usize,
56    inner: Mutex<BankState>,
57}
58
59struct BankState {
60    /// Max experts in the LRU; the pinned set is separate.
61    cap: usize,
62    clock: u64,
63    lru: HashMap<u32, LruEntry>,
64    pinned: HashMap<u32, Arc<[u8]>>,
65    /// Per-expert routing frequency this run, persisted for pinning next run. The long-term
66    /// signal: never decayed, so `.coli_usage`-style pinning stays stable across runs.
67    usage: Vec<u64>,
68    /// Per-expert session heat: the short-term signal `repin` adapts on. Halved each pass
69    /// (`tier_decay`) so recent routing dominates and a warm expert cools once it stops
70    /// being picked. Distinct from `usage` precisely because it is decayed.
71    heat: Vec<u32>,
72    hits: u64,
73    misses: u64,
74}
75
76struct LruEntry {
77    bytes: Arc<[u8]>,
78    used: u64,
79}
80
81impl ExpertStreamBank {
82    #[allow(clippy::too_many_arguments)]
83    pub fn open(
84        name: String,
85        path: &std::path::Path,
86        base_offset: u64,
87        expert_bytes: usize,
88        n_experts: usize,
89        dtype: GgmlDType,
90        n: usize,
91        k: usize,
92    ) -> Result<Arc<Self>> {
93        let file = File::open(path)?;
94        let bank = Arc::new(Self {
95            name,
96            file,
97            base_offset,
98            expert_bytes,
99            n_experts,
100            dtype,
101            n,
102            k,
103            // cap starts at 1 (never 0: a miss must be admittable); `finalize` sizes it from RAM.
104            inner: Mutex::new(BankState {
105                cap: 1,
106                clock: 0,
107                lru: HashMap::new(),
108                pinned: HashMap::new(),
109                usage: vec![0; n_experts],
110                heat: vec![0; n_experts],
111                hits: 0,
112                misses: 0,
113            }),
114        });
115        registry().lock().expect("registry lock").register(&bank);
116        Ok(bank)
117    }
118
119    pub fn dtype(&self) -> GgmlDType {
120        self.dtype
121    }
122
123    pub fn n_experts(&self) -> usize {
124        self.n_experts
125    }
126
127    pub fn expert_dims(&self) -> (usize, usize) {
128        (self.n, self.k)
129    }
130
131    pub fn expert_bytes(&self) -> usize {
132        self.expert_bytes
133    }
134
135    /// Full size of the bank as if resident (shape/size accounting only).
136    pub fn logical_bytes(&self) -> usize {
137        self.expert_bytes * self.n_experts
138    }
139
140    /// pinned -> LRU -> disk.
141    pub fn fetch(&self, eid: u32) -> Result<Arc<[u8]>> {
142        let mut guard = self.inner.lock().expect("expert bank lock poisoned");
143        // Reborrow so disjoint fields (hits/clock vs pinned/lru) can be borrowed independently.
144        let st = &mut *guard;
145        if (eid as usize) < st.usage.len() {
146            st.usage[eid as usize] = st.usage[eid as usize].saturating_add(1);
147            st.heat[eid as usize] = st.heat[eid as usize].saturating_add(1);
148        }
149        if let Some(bytes) = st.pinned.get(&eid) {
150            st.hits += 1;
151            return Ok(bytes.clone());
152        }
153        if let Some(entry) = st.lru.get_mut(&eid) {
154            st.hits += 1;
155            st.clock += 1;
156            entry.used = st.clock;
157            return Ok(entry.bytes.clone());
158        }
159        st.misses += 1;
160        let bytes = self.read_expert(eid)?;
161        self.admit(st, eid, bytes.clone());
162        Ok(bytes)
163    }
164
165    fn admit(&self, st: &mut BankState, eid: u32, bytes: Arc<[u8]>) {
166        if st.lru.len() >= st.cap {
167            if let Some(&victim) = st.lru.iter().min_by_key(|(_, e)| e.used).map(|(k, _)| k) {
168                st.lru.remove(&victim);
169            }
170        }
171        st.clock += 1;
172        let used = st.clock;
173        st.lru.insert(eid, LruEntry { bytes, used });
174    }
175
176    fn read_expert(&self, eid: u32) -> Result<Arc<[u8]>> {
177        let off = self.base_offset + eid as u64 * self.expert_bytes as u64;
178        let mut buf = vec![0u8; self.expert_bytes];
179        read_exact_at(&self.file, &mut buf, off)?;
180        fadvise_dontneed(&self.file, off, self.expert_bytes as u64);
181        Ok(Arc::from(buf.into_boxed_slice()))
182    }
183
184    /// Load `eid` into the pinned hot-set (idempotent).
185    pub fn pin(&self, eid: u32) -> Result<()> {
186        let mut st = self.inner.lock().expect("expert bank lock poisoned");
187        if st.pinned.contains_key(&eid) {
188            return Ok(());
189        }
190        let bytes = if let Some(entry) = st.lru.remove(&eid) {
191            entry.bytes
192        } else {
193            drop(st);
194            let b = self.read_expert(eid)?;
195            st = self.inner.lock().expect("expert bank lock poisoned");
196            b
197        };
198        st.pinned.insert(eid, bytes);
199        Ok(())
200    }
201
202    /// Live tier adaptation (`--repin`). Decay the session heat, then swap up to
203    /// [`REPIN_MAX_SWAPS`] of the coldest pinned experts for the hottest streamed ones, each
204    /// only when the heat gap clears the hysteresis margin ([`tier_pick_swap`]). Correctness
205    /// neutral: the pinned *set* changes, never the bytes -- a promoted expert is the same
206    /// file slice it would stream, so `indexed_moe_forward` output is bit-identical. Cold
207    /// experts fall back to the LRU (still warm), hot experts come from the LRU or disk.
208    /// Call at a turn boundary. Returns the number of swaps performed.
209    pub fn repin(&self, max_swaps: usize) -> Result<usize> {
210        let mut guard = self.inner.lock().expect("expert bank lock poisoned");
211        let st = &mut *guard;
212        tier_decay(&mut st.heat);
213        let mut swaps = 0usize;
214        while swaps < max_swaps {
215            let pinned: Vec<u32> = st.pinned.keys().copied().collect();
216            let Some((slot, hot, _gain)) = tier_pick_swap(&st.heat, &pinned) else {
217                break;
218            };
219            let cold = pinned[slot];
220            // Demote the cold expert to the LRU: still resident, still a bit-identical hit.
221            if let Some(bytes) = st.pinned.remove(&cold) {
222                self.admit(st, cold, bytes);
223            }
224            // Promote the hot expert: reuse an LRU copy if present, else read it from disk.
225            let bytes = match st.lru.remove(&hot) {
226                Some(entry) => entry.bytes,
227                None => self.read_expert(hot)?,
228            };
229            st.pinned.insert(hot, bytes);
230            swaps += 1;
231        }
232        Ok(swaps)
233    }
234
235    /// Background-thread readahead (router-lookahead): warm `eid` into the LRU ahead of the
236    /// `fetch` that needs it, overlapping disk I/O with compute. The read owns its bytes in an
237    /// `Arc` (unlike a bare `WILLNEED` page-cache hint, which memory pressure can re-evict), so
238    /// a later `fetch` is a guaranteed hit on identical bytes -- correctness neutral. Skips
239    /// experts already resident. Runs on the shared prefetch worker; never on the caller.
240    fn prefetch_resident(&self, eid: u32) -> Result<()> {
241        {
242            let st = self.inner.lock().expect("expert bank lock poisoned");
243            if st.pinned.contains_key(&eid) || st.lru.contains_key(&eid) {
244                return Ok(());
245            }
246        }
247        // Read without the lock so concurrent fetches on this bank are not stalled by disk.
248        let bytes = self.read_expert(eid)?;
249        let mut guard = self.inner.lock().expect("expert bank lock poisoned");
250        let st = &mut *guard;
251        // Re-check: a fetch may have admitted it while we were reading.
252        if st.pinned.contains_key(&eid) || st.lru.contains_key(&eid) {
253            return Ok(());
254        }
255        self.admit(st, eid, bytes);
256        Ok(())
257    }
258
259    /// Enqueue a router-lookahead prefetch of `eid`. Best effort and
260    /// non-blocking: dropped when disabled, out of range, or the worker queue is full -- a
261    /// missed prefetch only costs a later on-demand `fetch`, never correctness.
262    pub fn prefetch(self: &Arc<Self>, eid: u32) {
263        if !prefetch_enabled() || eid as usize >= self.n_experts {
264            return;
265        }
266        let _ = prefetcher().try_send(PrefetchJob {
267            bank: Arc::downgrade(self),
268            eid,
269        });
270    }
271
272    /// Sized by [`finalize`] from RAM; also settable directly.
273    pub fn set_cap(&self, cap: usize) {
274        let mut st = self.inner.lock().expect("expert bank lock poisoned");
275        st.cap = cap.max(1);
276    }
277
278    /// `(pinned, cached, cap, hits, misses)`.
279    pub fn stats(&self) -> (usize, usize, usize, u64, u64) {
280        let st = self.inner.lock().expect("expert bank lock poisoned");
281        (st.pinned.len(), st.lru.len(), st.cap, st.hits, st.misses)
282    }
283
284    pub fn resident_bytes(&self) -> usize {
285        let st = self.inner.lock().expect("expert bank lock poisoned");
286        (st.pinned.len() + st.lru.len()) * self.expert_bytes
287    }
288
289    fn usage_snapshot(&self) -> Vec<(u32, u64)> {
290        let st = self.inner.lock().expect("expert bank lock poisoned");
291        st.usage
292            .iter()
293            .enumerate()
294            .filter(|(_, &c)| c > 0)
295            .map(|(e, &c)| (e as u32, c))
296            .collect()
297    }
298}
299
300/// Every live bank + the usage sidecar path, so one budget sizes all banks and one pass pins.
301struct Registry {
302    banks: Vec<Weak<ExpertStreamBank>>,
303    sidecar: Option<PathBuf>,
304}
305
306fn registry() -> &'static Mutex<Registry> {
307    static REG: OnceLock<Mutex<Registry>> = OnceLock::new();
308    REG.get_or_init(|| {
309        Mutex::new(Registry {
310            banks: Vec::new(),
311            sidecar: None,
312        })
313    })
314}
315
316impl Registry {
317    fn register(&mut self, bank: &Arc<ExpertStreamBank>) {
318        self.banks.push(Arc::downgrade(bank));
319    }
320
321    fn live(&self) -> Vec<Arc<ExpertStreamBank>> {
322        self.banks.iter().filter_map(Weak::upgrade).collect()
323    }
324}
325
326pub fn set_usage_sidecar(path: PathBuf) {
327    registry().lock().expect("registry lock").sidecar = Some(path);
328}
329
330pub fn total_resident_bytes() -> usize {
331    registry()
332        .lock()
333        .expect("registry lock")
334        .live()
335        .iter()
336        .map(|b| b.resident_bytes())
337        .sum()
338}
339
340// ---------------------------------------------------------------------------
341// Live tier adaptation: a pure heat-swap decision + decay, so
342// the swap policy is testable in isolation from the I/O and locking around it.
343// ---------------------------------------------------------------------------
344
345/// Pick one pinned slot to replace with the hottest streamed expert, or `None` if no swap
346/// clears the hysteresis margin. Pure and total: the caller owns all I/O and locking.
347///
348/// `heat[e]` is expert `e`'s session heat; `pinned` lists the currently pinned expert ids.
349/// Heat-swap rule: coldest pinned vs hottest non-resident, admitted only
350/// when `hot > cold + cold/4 + 4`. The `cold/4` (25%) margin stops ping-pong between two
351/// near-equal experts; the `+4` covers tiny samples where the ratio alone is noisy. Returns
352/// `(slot, hot_eid, gain)` where `slot` indexes `pinned` and `gain = hot_heat - cold_heat`.
353fn tier_pick_swap(heat: &[u32], pinned: &[u32]) -> Option<(usize, u32, i64)> {
354    if heat.is_empty() || pinned.is_empty() {
355        return None;
356    }
357    // Coldest pinned slot (first minimum wins on ties, strict `<`).
358    let cold = pinned
359        .iter()
360        .enumerate()
361        .min_by_key(|&(_, &p)| heat[p as usize])
362        .map(|(z, _)| z)
363        .expect("pinned is non-empty");
364    // Hottest non-resident expert (first maximum wins on ties, strict `>`).
365    let mut hot: Option<usize> = None;
366    let mut hot_heat = 0u32;
367    for (e, &h) in heat.iter().enumerate() {
368        let resident = pinned.iter().any(|&p| p as usize == e);
369        if !resident && h > hot_heat {
370            hot_heat = h;
371            hot = Some(e);
372        }
373    }
374    let hot = hot?;
375    let cold_heat = heat[pinned[cold] as usize];
376    if hot_heat <= cold_heat + (cold_heat >> 2) + 4 {
377        return None;
378    }
379    Some((cold, hot as u32, hot_heat as i64 - cold_heat as i64))
380}
381
382/// Halve every expert's session heat (heat decay): recent routing keeps its lead,
383/// stale heat fades toward zero so a once-hot expert eventually loses its pin.
384fn tier_decay(heat: &mut [u32]) {
385    for h in heat.iter_mut() {
386        *h >>= 1;
387    }
388}
389
390/// Tokens between [`repin_all`] passes (`--repin N`); `0`/unset disables live tier
391/// adaptation, so the hot-set stays exactly as [`finalize`] pinned it. Read once.
392pub fn repin_interval() -> usize {
393    static N: OnceLock<usize> = OnceLock::new();
394    *N.get_or_init(|| {
395        std::env::var("STREAM_EXPERTS_REPIN")
396            .ok()
397            .and_then(|v| v.parse::<usize>().ok())
398            .unwrap_or(0)
399    })
400}
401
402/// Adapt every live bank's hot-set to recent routing heat. No-op unless `STREAM_EXPERTS_REPIN`
403/// is set. The engine calls this at turn boundaries every [`repin_interval`] tokens. Returns
404/// the total number of swaps performed across all banks.
405pub fn repin_all() -> usize {
406    if repin_interval() == 0 {
407        return 0;
408    }
409    // Snapshot the live banks and DROP the registry guard before iterating: repin() takes each
410    // bank's own Mutex across up to REPIN_MAX_SWAPS blocking preads, so holding the global
411    // registry lock across all of them would serialize every bank behind one. Same idiom as
412    // finalize()/save_usage() below.
413    let banks = registry().lock().expect("registry lock").live();
414    banks
415        .iter()
416        .map(|b| b.repin(REPIN_MAX_SWAPS).unwrap_or(0))
417        .sum()
418}
419
420// ---------------------------------------------------------------------------
421// Async prefetch (router-lookahead): one shared I/O worker warms experts into the
422// LRU ahead of the fetch that needs them, overlapping disk with compute.
423// ---------------------------------------------------------------------------
424
425/// Whether router-lookahead prefetch is on (`STREAM_EXPERTS_PREFETCH`, default OFF). Read once.
426pub fn prefetch_enabled() -> bool {
427    static ON: OnceLock<bool> = OnceLock::new();
428    *ON.get_or_init(|| {
429        std::env::var("STREAM_EXPERTS_PREFETCH")
430            .map(|v| !v.is_empty() && v != "0")
431            .unwrap_or(false)
432    })
433}
434
435struct PrefetchJob {
436    bank: Weak<ExpertStreamBank>,
437    eid: u32,
438}
439
440/// The single background readahead worker, started on first use. Bounded queue: `try_send`
441/// drops a job when full (ring buffer: full = drop), so a saturated disk can
442/// never back-pressure or block the compute thread. `Weak` keeps a queued job from pinning a
443/// bank whose model has been dropped.
444fn prefetcher() -> &'static SyncSender<PrefetchJob> {
445    static P: OnceLock<SyncSender<PrefetchJob>> = OnceLock::new();
446    P.get_or_init(|| {
447        let (tx, rx) = std::sync::mpsc::sync_channel::<PrefetchJob>(256);
448        std::thread::Builder::new()
449            .name("stream-experts-prefetch".into())
450            .spawn(move || {
451                while let Ok(job) = rx.recv() {
452                    if let Some(bank) = job.bank.upgrade() {
453                        let _ = bank.prefetch_resident(job.eid);
454                    }
455                }
456            })
457            .expect("spawn stream-experts prefetch worker");
458        tx
459    })
460}
461
462/// Size every bank's LRU cap from available RAM and pin the learned-hot experts. Call once after
463/// the dense weights are resident and all banks are open.
464pub fn finalize() {
465    let banks = registry().lock().expect("registry lock").live();
466    if banks.is_empty() {
467        return;
468    }
469    let expert_bytes = banks
470        .iter()
471        .map(|b| b.expert_bytes)
472        .max()
473        .unwrap_or(1)
474        .max(1);
475    let n_banks = banks.len() as u64;
476
477    // STREAM_EXPERTS_RAM_GB forces the cache budget; otherwise size from live MemAvailable.
478    let forced_gb = std::env::var("STREAM_EXPERTS_RAM_GB")
479        .ok()
480        .and_then(|v| v.parse::<f64>().ok())
481        .filter(|g| *g > 0.0);
482    let avail = match forced_gb {
483        Some(gb) => (gb * 1e9) as u64,
484        None => mem_available_bytes(),
485    };
486    let budget = (avail as f64 * BUDGET_FRACTION) as u64;
487    let slack = PAGE_CACHE_RESERVE + ACTIVATION_RESERVE;
488    let for_cache = budget.saturating_sub(slack);
489    let mut cap = (for_cache / (n_banks * expert_bytes as u64)) as usize;
490
491    let max_experts = banks.iter().map(|b| b.n_experts).max().unwrap_or(1);
492    cap = cap.clamp(1, max_experts);
493    for b in &banks {
494        b.set_cap(cap);
495    }
496
497    let pinned = load_and_pin(&banks, cap);
498    register_atexit_save();
499    spawn_usage_saver();
500
501    eprintln!(
502        "[stream-experts] {} banks x {:.1} MB/expert; budget {:.1} GB -> cap {}/bank \
503         (cache {:.1} GB), pinned {}",
504        banks.len(),
505        expert_bytes as f64 / 1e6,
506        avail as f64 / 1e9,
507        cap,
508        (cap as u64 * n_banks * expert_bytes as u64) as f64 / 1e9,
509        pinned,
510    );
511}
512
513/// Pin each bank's hottest experts (up to `PIN_FRACTION * cap`) from the usage sidecar.
514fn load_and_pin(banks: &[Arc<ExpertStreamBank>], cap: usize) -> usize {
515    let sidecar = match registry().lock().expect("registry lock").sidecar.clone() {
516        Some(p) if p.exists() => p,
517        _ => return 0,
518    };
519    let text = match std::fs::read_to_string(&sidecar) {
520        Ok(t) => t,
521        Err(_) => return 0,
522    };
523    let mut by_name: HashMap<&str, Vec<(u32, u64)>> = HashMap::new();
524    for line in text.lines() {
525        let mut it = line.split_whitespace();
526        let (Some(name), Some(eid), Some(cnt)) = (it.next(), it.next(), it.next()) else {
527            continue;
528        };
529        if let (Ok(eid), Ok(cnt)) = (eid.parse::<u32>(), cnt.parse::<u64>()) {
530            by_name.entry(name).or_default().push((eid, cnt));
531        }
532    }
533    let pin_budget = ((cap as f64 * PIN_FRACTION) as usize).max(1);
534    let mut pinned = 0usize;
535    for b in banks {
536        let Some(rows) = by_name.get(b.name.as_str()) else {
537            continue;
538        };
539        let mut rows = rows.clone();
540        // Descending by count. `Reverse` rather than swapping the operands, which is
541        // what clippy's sort_by_key lint asks for and reads as the intent.
542        rows.sort_by_key(|r| std::cmp::Reverse(r.1));
543        for (eid, _) in rows.into_iter().take(pin_budget) {
544            if eid as usize >= b.n_experts {
545                continue;
546            }
547            if b.pin(eid).is_ok() {
548                pinned += 1;
549            }
550        }
551    }
552    pinned
553}
554
555/// Persist this run's routing frequencies (merged with prior counts) for next-run pinning.
556pub fn save_usage() -> Result<()> {
557    let (banks, sidecar) = {
558        let reg = registry().lock().expect("registry lock");
559        (reg.live(), reg.sidecar.clone())
560    };
561    let Some(path) = sidecar else {
562        return Ok(());
563    };
564    let mut merged: HashMap<(String, u32), u64> = HashMap::new();
565    if let Ok(text) = std::fs::read_to_string(&path) {
566        for line in text.lines() {
567            let mut it = line.split_whitespace();
568            if let (Some(name), Some(eid), Some(cnt)) = (it.next(), it.next(), it.next()) {
569                if let (Ok(eid), Ok(cnt)) = (eid.parse::<u32>(), cnt.parse::<u64>()) {
570                    *merged.entry((name.to_string(), eid)).or_default() += cnt;
571                }
572            }
573        }
574    }
575    for b in &banks {
576        for (eid, cnt) in b.usage_snapshot() {
577            *merged.entry((b.name.clone(), eid)).or_default() += cnt;
578        }
579    }
580    let mut out = String::new();
581    for ((name, eid), cnt) in merged {
582        out.push_str(&format!("{name} {eid} {cnt}\n"));
583    }
584    std::fs::write(&path, out)?;
585    Ok(())
586}
587
588/// Persist usage on clean exit so the next run can pin. A SIGKILL won't fire it; a normal exit will.
589/// This is the best-case path only -- see [`spawn_usage_saver`] for the one that actually runs.
590fn register_atexit_save() {
591    #[cfg(unix)]
592    {
593        static ONCE: std::sync::Once = std::sync::Once::new();
594        ONCE.call_once(|| {
595            extern "C" fn on_exit() {
596                let _ = save_usage();
597            }
598            unsafe {
599                libc::atexit(on_exit);
600            }
601        });
602    }
603}
604
605/// How often the learned routing histogram is checkpointed to the sidecar.
606const USAGE_SAVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
607
608/// Checkpoint usage periodically, so the learned hot set survives however the process ends.
609///
610/// The cache only gets faster than cold if a *previous* run left a routing histogram to pin from, so
611/// the histogram's durability is a property of the cache, not of the shutdown path. Registering an
612/// `atexit` hook silently made it the latter: `atexit` fires on a clean return, and a served replica
613/// is ended by a signal (SIGTERM/SIGKILL from an operator, a supervisor, or the OOM killer) --- so the
614/// sidecar was never written, every run started cold, and the learning cache never learned. The
615/// mechanism was present, correct, and unreachable.
616///
617/// A low-frequency checkpoint makes the signal durable against any ending, losing at most one
618/// interval. It runs off the fetch path (no bank lock is held across the write, so it cannot deadlock
619/// with `fetch`, and it adds nothing to the decode critical path): one ~MB merge-and-write per minute
620/// against the GB/s of expert traffic that same minute is free.
621fn spawn_usage_saver() {
622    static ONCE: std::sync::Once = std::sync::Once::new();
623    ONCE.call_once(|| {
624        let _ = std::thread::Builder::new()
625            .name("expert-usage-saver".into())
626            .spawn(|| loop {
627                std::thread::sleep(USAGE_SAVE_INTERVAL);
628                // No live banks (or no sidecar configured) makes this a no-op; keep polling so a
629                // later model load is still covered by the same saver.
630                let _ = save_usage();
631            });
632    });
633}
634
635#[cfg(unix)]
636fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> Result<()> {
637    use std::os::unix::fs::FileExt;
638    file.read_exact_at(buf, offset)?;
639    Ok(())
640}
641
642#[cfg(not(unix))]
643fn read_exact_at(_file: &File, _buf: &mut [u8], _offset: u64) -> Result<()> {
644    crate::bail!("streaming experts require a unix positional-read (pread) platform")
645}
646
647/// Drop the just-read file pages so the page cache stays bounded. Best-effort; no alignment needed.
648/// Linux-only: Darwin has no `posix_fadvise` (its nearest hint, `F_NOCACHE`, changes fd semantics
649/// rather than advising a range), so everywhere else this is a no-op and the page cache self-evicts.
650#[cfg(target_os = "linux")]
651fn fadvise_dontneed(file: &File, offset: u64, len: u64) {
652    use std::os::unix::io::AsRawFd;
653    unsafe {
654        libc::posix_fadvise(
655            file.as_raw_fd(),
656            offset as libc::off_t,
657            len as libc::off_t,
658            libc::POSIX_FADV_DONTNEED,
659        );
660    }
661}
662
663#[cfg(not(target_os = "linux"))]
664fn fadvise_dontneed(_file: &File, _offset: u64, _len: u64) {}
665
666/// MemAvailable in bytes: `/proc/meminfo` on Linux, a conservative fallback elsewhere.
667fn mem_available_bytes() -> u64 {
668    #[cfg(target_os = "linux")]
669    {
670        if let Ok(text) = std::fs::read_to_string("/proc/meminfo") {
671            for line in text.lines() {
672                if let Some(rest) = line.strip_prefix("MemAvailable:") {
673                    if let Some(kb) = rest
674                        .split_whitespace()
675                        .next()
676                        .and_then(|v| v.parse::<u64>().ok())
677                    {
678                        return kb.saturating_mul(1024);
679                    }
680                }
681            }
682        }
683    }
684    static WARNED: AtomicU64 = AtomicU64::new(0);
685    if WARNED.swap(1, Ordering::Relaxed) == 0 {
686        eprintln!("[stream-experts] MemAvailable unreadable; assuming 8 GB free");
687    }
688    8_000_000_000
689}
690
691#[cfg(test)]
692mod tests {
693    use super::{tier_decay, tier_pick_swap, REPIN_MAX_SWAPS};
694
695    /// Apply the swap policy to a pinned set exactly as `repin` does (cold slot -> hot expert),
696    /// minus the disk I/O, so we can assert on convergence and the swap cap in isolation.
697    // `&mut [u32]`, not `&mut Vec<u32>`: this only writes through an index and
698    // hands the buffer to tier_pick_swap, which already takes `&[u32]`. It never
699    // grows or shrinks, so requiring a Vec asked callers for a capability the
700    // body does not use (clippy::ptr_arg). Call sites are unchanged — `&mut vec`
701    // derefs to `&mut [_]`.
702    fn simulate(heat: &[u32], pinned: &mut [u32], max_swaps: usize) -> usize {
703        let mut swaps = 0;
704        while swaps < max_swaps {
705            match tier_pick_swap(heat, pinned) {
706                Some((slot, hot, _gain)) => {
707                    pinned[slot] = hot;
708                    swaps += 1;
709                }
710                None => break,
711            }
712        }
713        swaps
714    }
715
716    #[test]
717    fn swaps_coldest_pinned_for_hottest_streamed() {
718        // heat: e0=100, e1=5 (pinned, cold), e2=200 (streamed, hot), e3=50.
719        let heat = [100, 5, 200, 50];
720        let pinned = [0, 1];
721        let (slot, hot, gain) = tier_pick_swap(&heat, &pinned).expect("beneficial swap");
722        assert_eq!(slot, 1, "coldest pinned is at slot 1 (eid 1)");
723        assert_eq!(hot, 2, "hottest streamed is eid 2");
724        assert_eq!(gain, 195, "gain is hot_heat - cold_heat = 200 - 5");
725    }
726
727    #[test]
728    fn hysteresis_blocks_near_equal_experts() {
729        // Cold pinned heat 100 -> threshold 100 + 25 + 4 = 129; a streamed expert at 110 is
730        // hotter but inside the margin, so no swap (no ping-pong on noise).
731        let heat = [100, 100, 110];
732        let pinned = [0, 1];
733        assert!(tier_pick_swap(&heat, &pinned).is_none());
734    }
735
736    #[test]
737    fn hysteresis_margin_is_exclusive_at_the_boundary() {
738        // cold_heat 100 -> threshold = 100 + (100>>2=25) + 4 = 129.
739        assert!(
740            tier_pick_swap(&[100, 100, 129], &[0, 1]).is_none(),
741            "equal to the threshold must not swap"
742        );
743        let (slot, hot, gain) =
744            tier_pick_swap(&[100, 100, 130], &[0, 1]).expect("one over the threshold swaps");
745        assert_eq!((slot, hot, gain), (0, 2, 30));
746    }
747
748    #[test]
749    fn no_swap_when_every_expert_is_already_pinned() {
750        assert!(tier_pick_swap(&[10, 20], &[0, 1]).is_none());
751    }
752
753    #[test]
754    fn empty_inputs_are_safe() {
755        assert!(tier_pick_swap(&[], &[]).is_none());
756        assert!(tier_pick_swap(&[1, 2, 3], &[]).is_none());
757        assert!(tier_pick_swap(&[], &[0, 1]).is_none());
758    }
759
760    #[test]
761    fn picks_the_coldest_slot_and_hottest_candidate_among_many() {
762        // pinned e0=50, e2=30 (cold), e4=80; streamed e1=10, e3=200 (hot), e5=5.
763        let heat = [50, 10, 30, 200, 80, 5];
764        let pinned = [0, 2, 4];
765        let (slot, hot, gain) = tier_pick_swap(&heat, &pinned).expect("swap");
766        assert_eq!(slot, 1, "eid 2 (heat 30) is the coldest pinned, at slot 1");
767        assert_eq!(hot, 3, "eid 3 (heat 200) is the hottest streamed");
768        assert_eq!(gain, 170);
769    }
770
771    #[test]
772    fn decay_halves_every_expert() {
773        let mut heat = [10, 3, 0, 255, 1];
774        tier_decay(&mut heat);
775        assert_eq!(heat, [5, 1, 0, 127, 0]);
776    }
777
778    #[test]
779    fn repeated_swaps_converge_then_stop_under_hysteresis() {
780        // Two genuinely hot streamed experts (100, 90) displace two cold pins (5, 4); once both
781        // hot experts are pinned the margin blocks further churn well under the swap cap.
782        let heat = [100, 90, 5, 4];
783        let mut pinned = vec![2, 3];
784        let swaps = simulate(&heat, &mut pinned, REPIN_MAX_SWAPS);
785        assert_eq!(swaps, 2, "exactly the two hot experts get pinned");
786        pinned.sort_unstable();
787        assert_eq!(
788            pinned,
789            vec![0, 1],
790            "hot-set converged to the two hottest experts"
791        );
792        // A second pass over the same (now settled) heat is a no-op: stable, no ping-pong.
793        assert_eq!(simulate(&heat, &mut pinned, REPIN_MAX_SWAPS), 0);
794    }
795
796    #[test]
797    fn one_dominant_expert_settles_after_a_single_swap() {
798        // A lone hot streamed expert takes one cold slot, then hysteresis stops the pass -- the
799        // other pinned experts are far enough ahead of the remaining stream to stay put.
800        let heat = [50, 10, 30, 200, 80, 5];
801        let mut pinned = vec![0, 2, 4];
802        assert_eq!(simulate(&heat, &mut pinned, REPIN_MAX_SWAPS), 1);
803        pinned.sort_unstable();
804        assert_eq!(pinned, vec![0, 3, 4]);
805    }
806}