Skip to main content

ferrox_core/
expert_cache.rs

1//! The global expert slot cache: which experts are resident on the GPU
2//! right now, and what one decode step has to move.
3//!
4//! # One id space, one pool
5//!
6//! Experts are addressed by a **flat id**, `layer * num_experts +
7//! expert`, and all layers compete for one pool of `cache_size` slots.
8//! That is the "global" in global LRU, and it is the point: expert
9//! activation is not uniform across layers, so a per-layer cache of
10//! `cache_size / num_layers` slots wastes residency on layers whose
11//! routing is flat and starves the layers where a few experts take most
12//! of the traffic.
13//!
14//! # What a step produces
15//!
16//! [`ExpertCache::ensure`] takes the experts a layer routed to and
17//! returns, for each, the slot it will be read from -- plus a
18//! [`CopyPlan`]: the (slot, host row) pairs the caller must copy before
19//! the multiply. Nothing here moves bytes; the plan is the whole
20//! output.
21//!
22//! # Two rules that must not drift
23//!
24//! - **Victims are chosen by `(usage, slot)` ascending**, and a slot
25//!   touched *by this very step* is not a victim. Without the second
26//!   rule a step that misses more experts than it hits can evict an
27//!   expert it is about to read.
28//! - **Duplicate routes collapse.** A batch routing twice to the same
29//!   expert counts one miss, issues one copy, and shares one slot.
30//!
31//! The hybrid entry point adds the [`crate::qstar`] split on top: only
32//! the first `fetch` misses get slots, and the rest come back as
33//! [`None`], meaning "compute this one on the CPU". Every route is
34//! assigned exactly once, to exactly one device.
35//!
36//! # Reading the cache back
37//!
38//! [`ExpertCache::stats`] answers "is the cache big enough?" for the
39//! model as a whole. [`ExpertCache::layer_stats`] answers it per MoE
40//! layer, which is the only form that can tell *one layer thrashing*
41//! from *every layer uniformly a little over budget*: both show the
42//! same global miss rate and they have opposite fixes -- rebalance
43//! versus grow. [`ExpertCache::routing_skew`] goes one level below the
44//! cache and reports what the routing distribution itself allows; a
45//! layer whose `oracle_hit_at_slots` is already low cannot be helped by
46//! any cache size, because no policy could do better on that traffic.
47//!
48//! # The prefill double buffers
49//!
50//! A prefill chunk walks the layers in order, so it can stage layer
51//! `L + 1`'s experts while layer `L` computes. The two staging buffers
52//! **borrow** slots `[0, 2 * num_experts)` of this very cache and
53//! rotate by `layer % 2`. Borrowed is not owned: the bytes in those
54//! slots are rewritten every other layer within a chunk, so a slot at
55//! or below `2 * num_experts` can never be read as residency, and the
56//! buffers' contents are never registered in the residency map.
57//! [`ExpertCache::prefetch_prefill_layer`] is the whole portable half
58//! of that -- which expert rows are already on the device, which have
59//! to cross the link, and what the borrowed slots do to the LRU. No
60//! streams, no events and no copies live here.
61//!
62//! Ported 1:1 from FreeToken's `moe/offload_cache.py` and
63//! `moe/offload_kernels.py`, whose LRU is the `lru_ensure` kernel from
64//! `flashlib` (both Apache-2.0); see `docs/THIRD_PARTY_NOTICES.md`.
65
66use crate::qstar::QStarPolicy;
67
68/// A cached expert's address in the flat id space.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
70pub struct ExpertId {
71    pub layer: u32,
72    pub expert: u32,
73}
74
75/// The copies a step needs before it can multiply.
76///
77/// `dst_slots[i]` is a cache slot; `src_rows[i]` is a **layer-local**
78/// expert row, so it indexes this layer's own host bank rather than a
79/// flat table. Keeping it layer-local is what lets each layer's weights
80/// live in their own allocation, which in turn is what lets residency
81/// (pinned / locked / pageable) differ per layer.
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub struct CopyPlan {
84    pub dst_slots: Vec<u32>,
85    pub src_rows: Vec<u32>,
86}
87
88impl CopyPlan {
89    pub fn len(&self) -> usize {
90        self.dst_slots.len()
91    }
92
93    pub fn is_empty(&self) -> bool {
94        self.dst_slots.is_empty()
95    }
96}
97
98/// What one `ensure` decided.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct EnsurePlan {
101    /// Per routed expert, in the caller's order: the slot to read it
102    /// from, or `None` for "compute this one on the CPU" (hybrid only).
103    pub slots: Vec<Option<u32>>,
104    pub copy: CopyPlan,
105    /// Distinct experts this step routed to.
106    pub active: usize,
107    /// Distinct experts that were not resident -- *before* any fetch
108    /// cap was applied.
109    pub missing: usize,
110    /// Distinct experts actually being fetched over the link.
111    pub fetched: usize,
112}
113
114/// Which miss gets the scarce fetch slots when the split caps them.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub enum FetchOrder {
117    /// Most-recently-active expert first. An expert that keeps being
118    /// routed to but keeps losing the cap ranks higher every step until
119    /// it wins one, so a hot expert converges into residency instead of
120    /// being computed on the CPU forever.
121    #[default]
122    ByRecency,
123    /// Lowest expert id first. Deterministic and cheap; kept because it
124    /// is the reference order the two implementations were first
125    /// cross-checked against.
126    LowestId,
127}
128
129/// Running counters, for `/metrics` and for deciding whether the cache
130/// is sized right.
131#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
132pub struct ExpertCacheStats {
133    /// `ensure` calls.
134    pub calls: u64,
135    /// Distinct experts routed to, summed over calls.
136    pub active: u64,
137    /// Distinct misses, summed over calls, before any cap.
138    pub missing: u64,
139    /// Misses actually fetched, summed over calls.
140    pub fetched: u64,
141}
142
143impl ExpertCacheStats {
144    /// Fraction of routed experts that were not resident. The number
145    /// that says whether the cache is big enough.
146    pub fn miss_rate(&self) -> f64 {
147        if self.active == 0 {
148            return 0.0;
149        }
150        self.missing as f64 / self.active as f64
151    }
152
153    /// Fraction of misses served over the link rather than by the CPU.
154    /// The number that says what the `q*` split actually did.
155    pub fn fetch_rate(&self) -> f64 {
156        if self.missing == 0 {
157            return 0.0;
158        }
159        self.fetched as f64 / self.missing as f64
160    }
161
162    pub fn active_per_call(&self) -> f64 {
163        if self.calls == 0 {
164            return 0.0;
165        }
166        self.active as f64 / self.calls as f64
167    }
168
169    /// Misses per call, before the cap. Quoted beside
170    /// [`active_per_call`](Self::active_per_call) because the pair is
171    /// what sizes a link budget: `missing_per_call * bytes_per_expert`
172    /// is what one step wants to move.
173    pub fn missing_per_call(&self) -> f64 {
174        if self.calls == 0 {
175            return 0.0;
176        }
177        self.missing as f64 / self.calls as f64
178    }
179
180    /// Misses per call that actually crossed the link. Under the `q*`
181    /// split this is the one the link sees; `missing_per_call` minus
182    /// this is what the CPU absorbed.
183    pub fn fetched_per_call(&self) -> f64 {
184        if self.calls == 0 {
185            return 0.0;
186        }
187        self.fetched as f64 / self.calls as f64
188    }
189}
190
191/// One MoE layer's routing concentration over the observed decode
192/// histogram.
193///
194/// Every field is derived from that layer's histogram row alone, so a
195/// layer that was never routed to has no entry at all rather than a row
196/// of zeros -- a zero working set and a zero oracle hit rate would read
197/// as "this layer is unservable" when the truth is "no data".
198#[derive(Debug, Clone, Copy, PartialEq)]
199pub struct LayerRoutingSkew {
200    pub layer: u32,
201    /// Total routes observed for this layer, counting duplicates.
202    pub routed: u64,
203    /// Distinct experts that were routed to at least once.
204    pub working_set: usize,
205    /// How many of the hottest experts it takes to cover 90% of this
206    /// layer's routing mass.
207    pub experts_for_90pct: usize,
208    /// Routing entropy over `ln(num_experts)`. `1.0` is a perfectly
209    /// flat router, `0.0` a layer that always picks the same expert.
210    pub norm_entropy: f64,
211    /// The top-`oracle_slots` share of this layer's routing mass: the
212    /// best hit rate *any* per-layer policy could reach on the observed
213    /// distribution, with no LRU/LFU dynamics in it at all.
214    ///
215    /// This is the number that separates the two diagnoses. A high
216    /// oracle hit rate next to a low realized one
217    /// ([`ExpertCacheStats::miss_rate`]) means the policy is losing
218    /// residency it could have kept; a low oracle hit rate means the
219    /// layer's routing is flat and no cache size will help it.
220    pub oracle_hit_at_slots: f64,
221}
222
223/// The routing-skew report: per layer, plus the means over the layers
224/// that were actually routed to.
225///
226/// The means deliberately exclude un-routed layers, matching the
227/// per-layer list. Averaging zeros for layers that produced no traffic
228/// would drag every aggregate toward zero in proportion to how much of
229/// the model the window happened to touch, which makes two windows of
230/// the same model incomparable.
231#[derive(Debug, Clone, PartialEq)]
232pub struct RoutingSkewReport {
233    /// `cache_size / num_layers`, unrounded -- the fair share a
234    /// per-layer cache would get.
235    pub slots_per_layer: f64,
236    /// The fair share as a usable slot count, `max(1, round(share))`.
237    /// The oracle is quoted at this many slots.
238    pub oracle_slots: usize,
239    pub working_set_mean: f64,
240    pub working_set_max: usize,
241    pub experts_for_90pct: f64,
242    pub oracle_hit_at_slots: f64,
243    pub norm_entropy: f64,
244    /// One entry per layer that was routed to, in layer order.
245    pub per_layer: Vec<LayerRoutingSkew>,
246}
247
248/// The share of routing mass [`LayerRoutingSkew::experts_for_90pct`]
249/// covers.
250const COVERAGE_FRACTION: f64 = 0.9;
251
252/// Per-expert row size below which a host bank ships its whole layer as
253/// one transfer entry.
254///
255/// A batched host-to-device copy silently degrades to a *synchronous*
256/// transfer when one batch mixes large entries with sub-256 KiB ones.
257/// The bytes still move at full link rate, but the host stalls for the
258/// whole transfer, which un-hides the GEMM that the prefetch was
259/// supposed to overlap with. Banks whose per-expert row is smaller than
260/// this ship as a single whole-layer entry -- their entire layer is
261/// tiny -- so every per-run entry a batch sees stays above the floor.
262pub const SMALL_BANK_FEAT_BYTES: u64 = 256 * 1024;
263
264/// Marks a slot as unevictable for this step.
265const UNEVICTABLE: i64 = i64::MAX;
266
267/// A contiguous run of expert ids, `[start, start + len)`.
268///
269/// Misses are coalesced into runs because one transfer entry per run is
270/// one descriptor and one large copy, where one entry per expert is
271/// `num_experts` descriptors of one row each -- the same bytes at a
272/// fraction of the achievable rate, and enough small entries to trip
273/// the [`SMALL_BANK_FEAT_BYTES`] floor.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub struct MissRun {
276    pub start: u32,
277    pub len: u32,
278}
279
280/// Device-to-device rows: what the prefill buffer can take from the
281/// cache instead of from the host.
282///
283/// `dst_slots[i]` is an absolute cache slot inside the buffer's borrowed
284/// range; `src_slots[i]` is the absolute cache slot the expert is
285/// already resident in. Both are slots -- unlike [`CopyPlan`], nothing
286/// here indexes a host bank.
287#[derive(Debug, Clone, Default, PartialEq, Eq)]
288pub struct GatherPlan {
289    pub dst_slots: Vec<u32>,
290    pub src_slots: Vec<u32>,
291}
292
293impl GatherPlan {
294    pub fn len(&self) -> usize {
295        self.dst_slots.len()
296    }
297
298    pub fn is_empty(&self) -> bool {
299        self.dst_slots.is_empty()
300    }
301}
302
303/// One host-to-device transfer entry: `rows` consecutive expert rows of
304/// one bank.
305///
306/// `dst_slot` is the absolute cache slot the run lands on (inside the
307/// buffer's borrowed range) and `src_row` is the **layer-local** expert
308/// row it comes from, the same convention as [`CopyPlan::src_rows`], so
309/// each layer's bank can live in its own allocation.
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub struct BankEntry {
312    /// Index into the `bank_feat_bytes` the caller passed, in that
313    /// order.
314    pub bank: usize,
315    pub dst_slot: u32,
316    pub src_row: u32,
317    pub rows: u32,
318    pub bytes: u64,
319    /// Set when this entry is the whole layer because the bank is
320    /// small ([`SMALL_BANK_FEAT_BYTES`]) rather than because the whole
321    /// layer missed.
322    pub whole_layer: bool,
323}
324
325/// What staging one layer into a prefill double buffer costs.
326///
327/// The two row sets are disjoint by construction -- an expert is either
328/// gathered device-side or shipped from the host, never both -- which is
329/// what lets the gather and the host transfer run without ordering
330/// against each other.
331#[derive(Debug, Clone, PartialEq)]
332pub struct PrefillPlan {
333    pub layer: u32,
334    /// `layer % 2`.
335    pub buffer_id: u32,
336    /// The buffer already held this layer, so nothing moves. Every
337    /// other field is empty.
338    pub already_loaded: bool,
339    /// Resident rows, cache -> buffer, device-side.
340    pub gather: GatherPlan,
341    /// Non-resident rows, coalesced.
342    pub miss_runs: Vec<MissRun>,
343    /// The host transfer batch, grouped by bank in the caller's order.
344    pub entries: Vec<BankEntry>,
345    /// Banks the gather serves -- those at or above
346    /// [`SMALL_BANK_FEAT_BYTES`]. A small bank's rows are covered by its
347    /// whole-layer entry instead, so gathering them too would move the
348    /// same bytes twice.
349    pub gather_banks: Vec<usize>,
350}
351
352/// The GPU expert cache's residency map.
353#[derive(Debug)]
354pub struct ExpertCache {
355    num_layers: usize,
356    num_experts: usize,
357    cache_size: usize,
358    /// Flat id -> slot, or -1.
359    slot_for_id: Vec<i64>,
360    /// Slot -> flat id, or -1 for empty.
361    id_of_slot: Vec<i64>,
362    /// Slot -> the step that last touched it. The LRU clock.
363    usage: Vec<i64>,
364    step: i64,
365    /// Flat id -> the step this expert was last *routed to*, resident
366    /// or not. Distinct from `usage`, which only tracks residency.
367    recency: Vec<i64>,
368    fetch_order: FetchOrder,
369    stats: ExpertCacheStats,
370    /// The same counters as `stats`, attributed to the layer that
371    /// produced them. Indexed by MoE layer id.
372    layer_stats: Vec<ExpertCacheStats>,
373    collect_routing: bool,
374    /// Flat id -> how many times it was routed to, duplicates counted.
375    routing_freq: Vec<u64>,
376    /// Which layer each prefill buffer currently stages, if any.
377    prefill_layer: [Option<u32>; 2],
378    /// Whether the consumer is done reading each buffer.
379    prefill_released: [bool; 2],
380    /// `slot_for_id` as it stood when the chunk opened. See
381    /// [`ExpertCache::begin_prefill`].
382    prefill_snapshot: Option<Vec<i64>>,
383    prefill_hit_rows: u64,
384    prefill_total_rows: u64,
385}
386
387impl ExpertCache {
388    /// A cold cache of `cache_size` slots shared by every layer.
389    ///
390    /// `cache_size` must hold at least one whole layer: a prefill
391    /// materializes a layer's experts into slots `0..num_experts`, and
392    /// a cache that cannot hold one layer cannot serve a prefill at
393    /// all.
394    pub fn new(num_layers: usize, num_experts: usize, cache_size: usize) -> Self {
395        assert!(
396            num_layers > 0 && num_experts > 0,
397            "a MoE model has layers and experts"
398        );
399        assert!(
400            cache_size >= num_experts,
401            "cache_size {cache_size} cannot hold one layer of {num_experts} experts"
402        );
403        let total_ids = num_layers * num_experts;
404        ExpertCache {
405            num_layers,
406            num_experts,
407            cache_size,
408            slot_for_id: vec![-1; total_ids],
409            id_of_slot: vec![-1; cache_size],
410            usage: vec![0; cache_size],
411            step: 0,
412            recency: vec![-1; total_ids],
413            fetch_order: FetchOrder::default(),
414            stats: ExpertCacheStats::default(),
415            layer_stats: vec![ExpertCacheStats::default(); num_layers],
416            collect_routing: false,
417            routing_freq: vec![0; total_ids],
418            prefill_layer: [None, None],
419            prefill_released: [true, true],
420            prefill_snapshot: None,
421            prefill_hit_rows: 0,
422            prefill_total_rows: 0,
423        }
424    }
425
426    pub fn with_fetch_order(mut self, order: FetchOrder) -> Self {
427        self.fetch_order = order;
428        self
429    }
430
431    pub fn num_layers(&self) -> usize {
432        self.num_layers
433    }
434
435    pub fn num_experts(&self) -> usize {
436        self.num_experts
437    }
438
439    pub fn cache_size(&self) -> usize {
440        self.cache_size
441    }
442
443    pub fn stats(&self) -> ExpertCacheStats {
444        self.stats
445    }
446
447    /// The same counters as [`stats`](Self::stats), attributed to one
448    /// MoE layer over the current `reset_stats`-delimited window.
449    ///
450    /// A single global miss rate cannot distinguish one layer thrashing
451    /// from every layer being uniformly a little over budget: the two
452    /// average out to the same number and want opposite fixes -- move
453    /// slots between layers, versus give the pool more slots. This is
454    /// the breakdown that tells them apart. Sums over every layer equal
455    /// [`stats`](Self::stats) exactly.
456    pub fn layer_stats(&self, layer: u32) -> ExpertCacheStats {
457        self.layer_stats[layer as usize]
458    }
459
460    /// Every layer's counters at once, indexed by MoE layer id.
461    pub fn per_layer_stats(&self) -> &[ExpertCacheStats] {
462        &self.layer_stats
463    }
464
465    /// Close the current stats window and open a new one.
466    ///
467    /// The routing histogram is deliberately *not* cleared here: it
468    /// estimates a distribution, and its value grows with the number of
469    /// steps behind it, so a `/metrics` scrape that resets the rate
470    /// counters must not also destroy it. Use
471    /// [`reset_routing`](Self::reset_routing) when the distribution
472    /// itself is what went stale.
473    pub fn reset_stats(&mut self) {
474        self.stats = ExpertCacheStats::default();
475        for layer in &mut self.layer_stats {
476            *layer = ExpertCacheStats::default();
477        }
478        self.prefill_hit_rows = 0;
479        self.prefill_total_rows = 0;
480    }
481
482    /// Total routed experts across the model -- the denominator the
483    /// cache's residency rate is quoted against.
484    pub fn total_experts(&self) -> usize {
485        self.num_layers * self.num_experts
486    }
487
488    fn flat(&self, layer: u32, expert: u32) -> usize {
489        let layer = layer as usize;
490        let expert = expert as usize;
491        debug_assert!(layer < self.num_layers && expert < self.num_experts);
492        layer * self.num_experts + expert
493    }
494
495    /// Which slot holds `(layer, expert)`, if any. For tests and
496    /// reporting; the hot path uses [`ensure`](Self::ensure).
497    pub fn slot_of(&self, layer: u32, expert: u32) -> Option<u32> {
498        let slot = self.slot_for_id[self.flat(layer, expert)];
499        (slot >= 0).then_some(slot as u32)
500    }
501
502    /// Which expert occupies `slot`, if any.
503    pub fn resident_in(&self, slot: u32) -> Option<ExpertId> {
504        let id = self.id_of_slot[slot as usize];
505        (id >= 0).then(|| ExpertId {
506            layer: (id as usize / self.num_experts) as u32,
507            expert: (id as usize % self.num_experts) as u32,
508        })
509    }
510
511    /// Slots currently holding an expert.
512    pub fn resident_slots(&self) -> usize {
513        self.id_of_slot.iter().filter(|id| **id >= 0).count()
514    }
515
516    /// Drop one slot's residency, returning what it used to hold.
517    ///
518    /// The map here is a record of copies the caller was *asked* to
519    /// make, and a copy can fail after the map has recorded it -- see
520    /// [`crate::expert_slots::SlotFault::Device`]. Without this the
521    /// next step reads that slot as a hit and multiplies whatever the
522    /// failed copy left in it; with it, the expert simply misses again
523    /// and is re-fetched.
524    ///
525    /// The slot becomes the *first* eviction candidate rather than
526    /// merely an empty one: its LRU stamp goes to the beginning of
527    /// time, so a pool under pressure spends it before evicting an
528    /// expert that is really there. Idempotent, and `None` for a slot
529    /// that already held nothing.
530    pub fn forget_slot(&mut self, slot: u32) -> Option<ExpertId> {
531        let id = *self.id_of_slot.get(slot as usize)?;
532        if id < 0 {
533            return None;
534        }
535        self.id_of_slot[slot as usize] = -1;
536        self.slot_for_id[id as usize] = -1;
537        self.usage[slot as usize] = i64::MIN;
538        Some(ExpertId {
539            layer: (id as usize / self.num_experts) as u32,
540            expert: (id as usize % self.num_experts) as u32,
541        })
542    }
543
544    /// Forget everything. A rebuild is a cold start, so the counters go
545    /// too -- carrying them across would skew every rate that is quoted
546    /// per call.
547    pub fn reset(&mut self) {
548        self.slot_for_id.fill(-1);
549        self.id_of_slot.fill(-1);
550        self.usage.fill(0);
551        self.recency.fill(-1);
552        self.step = 0;
553        self.reset_stats();
554        self.reset_routing();
555        self.forget_prefill_buffers();
556    }
557
558    /// Resize the pool, keeping the model geometry.
559    ///
560    /// Everything resident is dropped: slot ids are positions in an
561    /// allocation that no longer exists, so keeping the map would point
562    /// at other experts' bytes. Refusing an impossible target *before*
563    /// touching anything is deliberate -- the caller can then keep
564    /// serving from the cache it already has.
565    pub fn rebuild(&mut self, cache_size: usize) -> Result<(), RebuildRejected> {
566        if cache_size < self.num_experts {
567            return Err(RebuildRejected {
568                requested: cache_size,
569                minimum: self.num_experts,
570            });
571        }
572        self.cache_size = cache_size;
573        self.id_of_slot = vec![-1; cache_size];
574        self.usage = vec![0; cache_size];
575        self.slot_for_id.fill(-1);
576        self.recency.fill(-1);
577        self.step = 0;
578        self.reset_stats();
579        // A rebuild changes the geometry the oracle is quoted against
580        // (`cache_size / num_layers`), so a histogram carried across it
581        // would be reported at slot counts it was never observed under.
582        self.reset_routing();
583        self.forget_prefill_buffers();
584        Ok(())
585    }
586
587    /// Make every expert this layer routed to resident, evicting by LRU.
588    ///
589    /// Every route gets a slot: this is the pure-offload path, where
590    /// the CPU computes nothing.
591    pub fn ensure(&mut self, layer: u32, expert_ids: &[u32]) -> EnsurePlan {
592        self.ensure_split(layer, expert_ids, None)
593    }
594
595    /// The bandwidth-adaptive path: fetch what the `q*` split says to
596    /// fetch, and hand the rest back for the CPU.
597    ///
598    /// A route that comes back `None` is the caller's to compute from
599    /// host RAM. Together with the `Some` routes it covers every
600    /// routed expert exactly once -- which is what makes it safe to
601    /// simply add the two partial results.
602    pub fn ensure_hybrid(
603        &mut self,
604        layer: u32,
605        expert_ids: &[u32],
606        policy: &QStarPolicy,
607    ) -> EnsurePlan {
608        self.ensure_split(layer, expert_ids, Some(policy))
609    }
610
611    fn ensure_split(
612        &mut self,
613        layer: u32,
614        expert_ids: &[u32],
615        policy: Option<&QStarPolicy>,
616    ) -> EnsurePlan {
617        self.step += 1;
618        let step = self.step;
619        let base = self.flat(layer, 0);
620
621        // Routing is observed here, before anything collapses or caps
622        // it: the histogram must describe what the router asked for,
623        // not what the cache decided to do about it. Duplicates count,
624        // because a batch that sends two tokens to one expert is two
625        // units of that expert's mass -- deduplicating would flatten
626        // exactly the skew the report exists to measure.
627        if self.collect_routing {
628            for expert in expert_ids {
629                self.routing_freq[base + *expert as usize] += 1;
630            }
631        }
632
633        // Distinct routes, first occurrence wins. A batch that routes
634        // twice to one expert must not fetch it twice or, worse, evict
635        // its own first copy.
636        let mut distinct: Vec<u32> = Vec::with_capacity(expert_ids.len());
637        for id in expert_ids {
638            if !distinct.contains(id) {
639                distinct.push(*id);
640            }
641        }
642
643        let mut missing: Vec<u32> = Vec::new();
644        for expert in &distinct {
645            let slot = self.slot_for_id[base + *expert as usize];
646            if slot >= 0 {
647                // A hit refreshes the slot, which also makes it
648                // unevictable for the rest of this step.
649                self.usage[slot as usize] = step;
650            } else {
651                missing.push(*expert);
652            }
653        }
654
655        match (policy, self.fetch_order) {
656            // The capped path prefers experts that were routed to most
657            // recently, so a hot expert that keeps losing the cap wins
658            // one eventually.
659            (Some(_), FetchOrder::ByRecency) => {
660                missing.sort_by_key(|e| (-self.recency[base + *e as usize], *e));
661            }
662            _ => missing.sort_unstable(),
663        }
664
665        let num_missing = missing.len();
666        let num_fetch = match policy {
667            Some(policy) => policy.split(num_missing).fetch,
668            None => num_missing,
669        };
670
671        // Slots this step already committed to -- hits above, and each
672        // victim as it is claimed -- are off the table.
673        let mut evictable: Vec<i64> = self
674            .usage
675            .iter()
676            .map(|u| if *u == step { UNEVICTABLE } else { *u })
677            .collect();
678        // Under a cap, a slot holding an expert this step routes to is
679        // also off the table even if the route is being sent to the
680        // CPU: evicting it would throw away residency the very next
681        // step wants.
682        if policy.is_some() {
683            for (evict, id) in evictable.iter_mut().zip(self.id_of_slot.iter()) {
684                if *id < 0 {
685                    continue;
686                }
687                let owner = *id - base as i64;
688                if (0..self.num_experts as i64).contains(&owner)
689                    && distinct.contains(&(owner as u32))
690                {
691                    *evict = UNEVICTABLE;
692                }
693            }
694        }
695
696        let mut copy = CopyPlan::default();
697        for expert in missing.iter().take(num_fetch) {
698            let victim = argmin_slot(&evictable);
699            let old = self.id_of_slot[victim];
700            if old >= 0 {
701                self.slot_for_id[old as usize] = -1;
702            }
703            let id = base + *expert as usize;
704            self.id_of_slot[victim] = id as i64;
705            self.slot_for_id[id] = victim as i64;
706            self.usage[victim] = step;
707            evictable[victim] = UNEVICTABLE;
708            copy.dst_slots.push(victim as u32);
709            // Layer-local, so it indexes this layer's own host bank.
710            copy.src_rows.push(*expert);
711        }
712
713        let slots: Vec<Option<u32>> = expert_ids
714            .iter()
715            .map(|expert| {
716                let slot = self.slot_for_id[base + *expert as usize];
717                (slot >= 0).then_some(slot as u32)
718            })
719            .collect();
720
721        if policy.is_some() {
722            for expert in &distinct {
723                self.recency[base + *expert as usize] = step;
724            }
725        }
726
727        self.stats.calls += 1;
728        self.stats.active += distinct.len() as u64;
729        self.stats.missing += num_missing as u64;
730        self.stats.fetched += num_fetch as u64;
731
732        // The same four counters again, keeping the layer this time.
733        let per_layer = &mut self.layer_stats[layer as usize];
734        per_layer.calls += 1;
735        per_layer.active += distinct.len() as u64;
736        per_layer.missing += num_missing as u64;
737        per_layer.fetched += num_fetch as u64;
738
739        EnsurePlan {
740            slots,
741            copy,
742            active: distinct.len(),
743            missing: num_missing,
744            fetched: num_fetch,
745        }
746    }
747
748    /// Put a whole layer's experts in slots `0..num_experts`, in expert
749    /// order, for a prefill.
750    ///
751    /// A prefill touches every expert, so streaming them one miss at a
752    /// time is pointless -- the layer is copied whole, and **position
753    /// equals expert id**, which lets routing ids index the buffer
754    /// directly with no slot lookup at all.
755    ///
756    /// The bookkeeping still has to be exact: any *other* layer's
757    /// expert that was living in one of those slots loses its
758    /// residency here, or the next decode step would hit on a slot that
759    /// now holds someone else's bytes.
760    pub fn materialize_layer(&mut self, layer: u32) -> CopyPlan {
761        let base = self.flat(layer, 0);
762        let owns = |id: i64| id >= base as i64 && id < (base + self.num_experts) as i64;
763        // Snapshot before mutating: the checks below all read the
764        // pre-existing occupant.
765        let previous: Vec<i64> = self.id_of_slot.clone();
766
767        for (slot, old) in previous.iter().enumerate() {
768            if owns(*old) {
769                // This layer's own stale placements go; they are about
770                // to be re-established at position == expert id.
771                self.id_of_slot[slot] = -1;
772                self.usage[slot] = 0;
773            }
774        }
775        for old in previous.iter().take(self.num_experts) {
776            if *old >= 0 && !owns(*old) {
777                self.slot_for_id[*old as usize] = -1;
778            }
779        }
780
781        self.step += 1;
782        let mut copy = CopyPlan::default();
783        for expert in 0..self.num_experts {
784            self.id_of_slot[expert] = (base + expert) as i64;
785            self.slot_for_id[base + expert] = expert as i64;
786            self.usage[expert] = self.step;
787            copy.dst_slots.push(expert as u32);
788            copy.src_rows.push(expert as u32);
789        }
790        copy
791    }
792
793    // ---- routing skew -------------------------------------------------
794
795    /// Start (or stop) accumulating the decode routing histogram.
796    ///
797    /// Off by default: the counters are cheap but they are only
798    /// meaningful over a long, stationary window, and a histogram that
799    /// silently spans a model swap or a rebuild is worse than none.
800    pub fn set_collect_routing(&mut self, on: bool) {
801        self.collect_routing = on;
802    }
803
804    pub fn collects_routing(&self) -> bool {
805        self.collect_routing
806    }
807
808    /// One layer's raw histogram row, indexed by expert id.
809    pub fn routing_histogram(&self, layer: u32) -> &[u64] {
810        let base = self.flat(layer, 0);
811        &self.routing_freq[base..base + self.num_experts]
812    }
813
814    /// Throw the observed routing distribution away.
815    pub fn reset_routing(&mut self) {
816        self.routing_freq.fill(0);
817    }
818
819    /// Per-layer routing concentration over the observed histogram, or
820    /// [`None`] if no routing has been observed at all.
821    ///
822    /// This describes the *traffic*, not the cache: nothing here
823    /// depends on which policy ran or on what it happened to keep. That
824    /// is the point. The realized miss rate says how a policy did;
825    /// [`LayerRoutingSkew::oracle_hit_at_slots`] says how well the best
826    /// possible policy could have done on the same traffic with the
827    /// same fair share of slots. A layer where the two are close is not
828    /// a cache-sizing problem however bad it looks, because the routing
829    /// is flat and the slots are not where the hit rate went.
830    pub fn routing_skew(&self) -> Option<RoutingSkewReport> {
831        let experts = self.num_experts;
832        let slots_per_layer = self.cache_size as f64 / self.num_layers as f64;
833        // At least one slot: a per-layer share that rounds to zero
834        // still gets to hold one expert, and an oracle over zero slots
835        // would report 0.0 for every layer regardless of its routing.
836        let oracle_slots = (slots_per_layer.round() as usize).max(1);
837        // A one-expert layer has no skew to normalize against; ln(1) is
838        // zero and the ratio would be a NaN that poisons the mean.
839        let ln_experts = (experts as f64).ln();
840
841        let mut per_layer: Vec<LayerRoutingSkew> = Vec::new();
842        for layer in 0..self.num_layers {
843            let freq = &self.routing_freq[layer * experts..(layer + 1) * experts];
844            let routed: u64 = freq.iter().sum();
845            if routed == 0 {
846                continue;
847            }
848            let total = routed as f64;
849            let working_set = freq.iter().filter(|f| **f > 0).count();
850
851            let mut descending: Vec<u64> = freq.to_vec();
852            descending.sort_unstable_by(|a, b| b.cmp(a));
853
854            let head: u64 = descending.iter().take(oracle_slots).sum();
855            let oracle_hit_at_slots = head as f64 / total;
856
857            // The cdf is non-decreasing, so "how many entries are below
858            // the coverage line" is a prefix length; +1 is the entry
859            // that crosses it.
860            let mut cumulative = 0u64;
861            let mut below = 0usize;
862            for count in &descending {
863                cumulative += *count;
864                if cumulative as f64 / total >= COVERAGE_FRACTION {
865                    break;
866                }
867                below += 1;
868            }
869
870            let mut entropy = 0.0f64;
871            for count in freq {
872                if *count == 0 {
873                    continue;
874                }
875                let p = *count as f64 / total;
876                entropy -= p * p.max(1e-12).ln();
877            }
878            let norm_entropy = if ln_experts > 0.0 {
879                entropy / ln_experts
880            } else {
881                0.0
882            };
883
884            per_layer.push(LayerRoutingSkew {
885                layer: layer as u32,
886                routed,
887                working_set,
888                experts_for_90pct: below + 1,
889                norm_entropy,
890                oracle_hit_at_slots,
891            });
892        }
893
894        if per_layer.is_empty() {
895            return None;
896        }
897        let layers = per_layer.len() as f64;
898        Some(RoutingSkewReport {
899            slots_per_layer,
900            oracle_slots,
901            working_set_mean: per_layer.iter().map(|l| l.working_set as f64).sum::<f64>() / layers,
902            working_set_max: per_layer.iter().map(|l| l.working_set).max().unwrap_or(0),
903            experts_for_90pct: per_layer
904                .iter()
905                .map(|l| l.experts_for_90pct as f64)
906                .sum::<f64>()
907                / layers,
908            oracle_hit_at_slots: per_layer.iter().map(|l| l.oracle_hit_at_slots).sum::<f64>()
909                / layers,
910            norm_entropy: per_layer.iter().map(|l| l.norm_entropy).sum::<f64>() / layers,
911            per_layer,
912        })
913    }
914
915    // ---- prefill double buffers ---------------------------------------
916
917    /// The slot range the two prefill buffers borrow: `[0, 2 *
918    /// num_experts)`.
919    pub fn prefill_buffer_slots(&self) -> usize {
920        2 * self.num_experts
921    }
922
923    /// Whether this pool is large enough to lend the buffers their
924    /// slots at all.
925    ///
926    /// A cache of exactly `2 * num_experts` fits the buffers but leaves
927    /// no hit region above them, so every prefill row is a miss by the
928    /// classification rule below. That is correct, merely slow; below
929    /// `2 * num_experts` the buffers do not fit and overlap must be off.
930    pub fn prefill_overlap_fits(&self) -> bool {
931        self.cache_size >= self.prefill_buffer_slots()
932    }
933
934    /// Which layer a buffer currently stages, if any.
935    pub fn prefill_buffer_layer(&self, buffer_id: u32) -> Option<u32> {
936        self.prefill_layer[buffer_id as usize]
937    }
938
939    /// Expert rows served from the cache since the last
940    /// [`reset_stats`](Self::reset_stats).
941    pub fn prefill_hit_rows(&self) -> u64 {
942        self.prefill_hit_rows
943    }
944
945    /// All expert rows staged into the buffers over the same window.
946    /// The ratio against [`prefill_hit_rows`](Self::prefill_hit_rows)
947    /// is how much of a prefill never touched the link.
948    pub fn prefill_rows(&self) -> u64 {
949        self.prefill_total_rows
950    }
951
952    /// Open a prefill chunk: both buffers empty, and take the residency
953    /// snapshot the chunk classifies against.
954    ///
955    /// The snapshot is what makes the classification stable for the
956    /// whole chunk. Hits are decided once, against the map as it stood
957    /// before any of this chunk's staging ran, and the only writer
958    /// inside the chunk -- buffer invalidation -- only ever rewrites
959    /// slots that are already below the buffer threshold and therefore
960    /// misses under both the live map and the snapshot. So the two can
961    /// never disagree about a row, and a caller may issue the gathers
962    /// and the host transfers in any order.
963    pub fn begin_prefill(&mut self) {
964        assert!(
965            self.prefill_overlap_fits(),
966            "cache of {} slots cannot lend {} to the prefill buffers",
967            self.cache_size,
968            self.prefill_buffer_slots()
969        );
970        self.prefill_layer = [None, None];
971        self.prefill_released = [true, true];
972        self.prefill_snapshot = Some(self.slot_for_id.clone());
973    }
974
975    /// Stage one layer into its buffer and say what that costs.
976    ///
977    /// `bank_feat_bytes` is each host bank's per-expert row size, in
978    /// the caller's bank order; [`BankEntry::bank`] indexes it.
979    ///
980    /// Three rules decide the answer, and each of them is a correctness
981    /// rule rather than a tuning choice:
982    ///
983    /// - **A row is resident only if its slot is at or above
984    ///   `2 * num_experts`.** Anything below that -- including `-1` --
985    ///   is a miss *by definition*, because the buffers own those slots
986    ///   and rewrite them every other layer within the chunk. Widening
987    ///   the test to "has a slot at all" makes a prefill gather from
988    ///   slots the other buffer is concurrently overwriting, and it
989    ///   loads some other expert's bytes without any error.
990    /// - **Invalidation clears the residency map *and* zeroes usage**
991    ///   for the buffer's slots. See
992    ///   [`prefill_buffer_slots`](Self::prefill_buffer_slots).
993    /// - **Misses coalesce into contiguous expert runs**, and a bank
994    ///   under [`SMALL_BANK_FEAT_BYTES`] ships its whole layer as one
995    ///   entry even when nothing missed.
996    ///
997    /// Nothing staged here is registered as resident: the buffer's
998    /// bytes are volatile within the chunk, so recording them would
999    /// hand the next decode step a hit on a slot that is about to be
1000    /// overwritten.
1001    pub fn prefetch_prefill_layer(&mut self, layer: u32, bank_feat_bytes: &[u64]) -> PrefillPlan {
1002        assert!(
1003            (layer as usize) < self.num_layers,
1004            "layer {layer} is outside a model of {} layers",
1005            self.num_layers
1006        );
1007        let experts = self.num_experts;
1008        let buffer_id = (layer as usize) % 2;
1009        let buffer_base = buffer_id * experts;
1010        let gather_banks: Vec<usize> = bank_feat_bytes
1011            .iter()
1012            .enumerate()
1013            .filter(|(_, feat)| **feat >= SMALL_BANK_FEAT_BYTES)
1014            .map(|(bank, _)| bank)
1015            .collect();
1016
1017        // Re-staging the layer a buffer already holds is a no-op, which
1018        // is what lets a caller prefetch ahead and then ask for the
1019        // same layer when it reaches it.
1020        if self.prefill_layer[buffer_id] == Some(layer) {
1021            return PrefillPlan {
1022                layer,
1023                buffer_id: buffer_id as u32,
1024                already_loaded: true,
1025                gather: GatherPlan::default(),
1026                miss_runs: Vec::new(),
1027                entries: Vec::new(),
1028                gather_banks,
1029            };
1030        }
1031        if let Some(held) = self.prefill_layer[buffer_id] {
1032            assert!(
1033                self.prefill_released[buffer_id],
1034                "prefill buffer {buffer_id} still holds layer {held}; staging layer \
1035                 {layer} into it would overwrite bytes a running GEMM is reading"
1036            );
1037        }
1038
1039        let snapshot = self
1040            .prefill_snapshot
1041            .as_ref()
1042            .expect("begin_prefill must open the chunk before a layer is staged");
1043        let base = layer as usize * experts;
1044        // The threshold, not `0`: the buffers own everything below it.
1045        let threshold = self.prefill_buffer_slots() as i64;
1046
1047        let mut gather = GatherPlan::default();
1048        let mut missing: Vec<u32> = Vec::new();
1049        for expert in 0..experts {
1050            let slot = snapshot[base + expert];
1051            if slot >= threshold {
1052                gather.dst_slots.push((buffer_base + expert) as u32);
1053                gather.src_slots.push(slot as u32);
1054            } else {
1055                missing.push(expert as u32);
1056            }
1057        }
1058
1059        self.prefill_hit_rows += gather.len() as u64;
1060        self.prefill_total_rows += experts as u64;
1061        self.invalidate_prefill_buffer(buffer_id);
1062
1063        let miss_runs = coalesce_runs(&missing);
1064        let mut entries: Vec<BankEntry> = Vec::new();
1065        for (bank, feat) in bank_feat_bytes.iter().enumerate() {
1066            if *feat < SMALL_BANK_FEAT_BYTES {
1067                // Whole layer as one entry, even with zero misses: it
1068                // keeps every entry in the batch above the driver's
1069                // async floor, and it covers the rows the gather skips
1070                // for this bank.
1071                entries.push(BankEntry {
1072                    bank,
1073                    dst_slot: buffer_base as u32,
1074                    src_row: 0,
1075                    rows: experts as u32,
1076                    bytes: experts as u64 * feat,
1077                    whole_layer: true,
1078                });
1079                continue;
1080            }
1081            for run in &miss_runs {
1082                entries.push(BankEntry {
1083                    bank,
1084                    dst_slot: buffer_base as u32 + run.start,
1085                    src_row: run.start,
1086                    rows: run.len,
1087                    bytes: run.len as u64 * feat,
1088                    whole_layer: false,
1089                });
1090            }
1091        }
1092
1093        self.prefill_layer[buffer_id] = Some(layer);
1094        self.prefill_released[buffer_id] = false;
1095
1096        PrefillPlan {
1097            layer,
1098            buffer_id: buffer_id as u32,
1099            already_loaded: false,
1100            gather,
1101            miss_runs,
1102            entries,
1103            gather_banks,
1104        }
1105    }
1106
1107    /// Mark a layer's buffer done, freeing it for the next layer of the
1108    /// same parity. A layer that is not the one staged is ignored.
1109    pub fn release_prefill_layer(&mut self, layer: u32) {
1110        let buffer_id = (layer as usize) % 2;
1111        if self.prefill_layer[buffer_id] == Some(layer) {
1112            self.prefill_released[buffer_id] = true;
1113        }
1114    }
1115
1116    /// Drop whatever a buffer held.
1117    ///
1118    /// Two things must happen and skipping either one is a live bug.
1119    /// Clearing `slot_for_id` for the old occupants is what stops a
1120    /// later decode step from hitting on bytes the buffer is about to
1121    /// overwrite. Zeroing `usage` is what makes these slots the *oldest*
1122    /// in the pool, so the `argmin(usage)` victim search in
1123    /// [`ensure`](Self::ensure) takes them before anything else: leave
1124    /// the old usage in place and the very next miss evicts a real
1125    /// resident from somewhere else in the cache while these free slots
1126    /// sit unused.
1127    fn invalidate_prefill_buffer(&mut self, buffer_id: usize) {
1128        let start = buffer_id * self.num_experts;
1129        for slot in start..start + self.num_experts {
1130            let old = self.id_of_slot[slot];
1131            if old >= 0 {
1132                self.slot_for_id[old as usize] = -1;
1133            }
1134            self.id_of_slot[slot] = -1;
1135            self.usage[slot] = 0;
1136        }
1137    }
1138
1139    /// Forget the buffers without touching residency, for the paths
1140    /// that just wiped residency anyway.
1141    fn forget_prefill_buffers(&mut self) {
1142        self.prefill_layer = [None, None];
1143        self.prefill_released = [true, true];
1144        self.prefill_snapshot = None;
1145    }
1146}
1147
1148/// A slot resize the cache refused, having changed nothing.
1149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1150pub struct RebuildRejected {
1151    pub requested: usize,
1152    pub minimum: usize,
1153}
1154
1155impl std::fmt::Display for RebuildRejected {
1156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1157        write!(
1158            f,
1159            "expert cache of {} slots cannot hold one layer of {} experts",
1160            self.requested, self.minimum
1161        )
1162    }
1163}
1164
1165impl std::error::Error for RebuildRejected {}
1166
1167/// Ascending expert ids collapsed into contiguous runs.
1168///
1169/// One entry per run rather than one per expert: a batched transfer
1170/// pays a fixed cost per entry, and a layer whose misses happen to be
1171/// adjacent -- the common case, since routing tends to cluster -- would
1172/// otherwise ship `num_experts` single-row entries instead of a handful
1173/// of large ones.
1174fn coalesce_runs(experts: &[u32]) -> Vec<MissRun> {
1175    let mut runs: Vec<MissRun> = Vec::new();
1176    for expert in experts {
1177        match runs.last_mut() {
1178            Some(run) if run.start + run.len == *expert => run.len += 1,
1179            _ => runs.push(MissRun {
1180                start: *expert,
1181                len: 1,
1182            }),
1183        }
1184    }
1185    runs
1186}
1187
1188/// The lowest-`usage` slot, ties to the lowest slot index.
1189///
1190/// The tie-break is not cosmetic: a GPU kernel and a CPU reference have
1191/// to pick the same victim, or the two halves of a hybrid step disagree
1192/// about which expert is where.
1193fn argmin_slot(usage: &[i64]) -> usize {
1194    let mut best = 0usize;
1195    let mut best_usage = usage[0];
1196    for (slot, value) in usage.iter().enumerate().skip(1) {
1197        if *value < best_usage {
1198            best = slot;
1199            best_usage = *value;
1200        }
1201    }
1202    best
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::*;
1208
1209    fn cache() -> ExpertCache {
1210        ExpertCache::new(4, 8, 16)
1211    }
1212
1213    #[test]
1214    fn a_cold_miss_is_fetched_and_becomes_resident() {
1215        let mut cache = cache();
1216        let plan = cache.ensure(0, &[3, 5]);
1217        assert_eq!(plan.missing, 2);
1218        assert_eq!(plan.fetched, 2);
1219        assert_eq!(plan.copy.src_rows, vec![3, 5], "layer-local rows");
1220        assert_eq!(plan.slots.len(), 2);
1221        assert!(plan.slots.iter().all(Option::is_some));
1222
1223        // The second visit is free.
1224        let again = cache.ensure(0, &[3, 5]);
1225        assert_eq!(again.missing, 0);
1226        assert!(again.copy.is_empty());
1227        assert_eq!(again.slots, plan.slots);
1228    }
1229
1230    /// Layers share one pool, so the same expert *index* in two layers
1231    /// is two different residents.
1232    #[test]
1233    fn layers_share_one_pool_through_a_flat_id_space() {
1234        let mut cache = cache();
1235        cache.ensure(0, &[3]);
1236        let plan = cache.ensure(1, &[3]);
1237        assert_eq!(plan.missing, 1, "layer 1's expert 3 is a different expert");
1238        assert_ne!(cache.slot_of(0, 3), cache.slot_of(1, 3));
1239        assert_eq!(cache.resident_slots(), 2);
1240    }
1241
1242    #[test]
1243    fn duplicate_routes_collapse_into_one_copy_and_one_slot() {
1244        let mut cache = cache();
1245        let plan = cache.ensure(0, &[7, 7, 7, 2]);
1246        assert_eq!(plan.active, 2);
1247        assert_eq!(plan.copy.len(), 2, "one copy per distinct expert");
1248        assert_eq!(plan.slots[0], plan.slots[1]);
1249        assert_eq!(plan.slots[1], plan.slots[2]);
1250        assert_ne!(plan.slots[0], plan.slots[3]);
1251    }
1252
1253    /// The rule that keeps a step from sabotaging itself: an expert
1254    /// this step just hit cannot be the victim of this step's misses.
1255    #[test]
1256    fn a_step_never_evicts_an_expert_it_is_about_to_read() {
1257        // Two layers competing for exactly one layer's worth of slots.
1258        let mut cache = ExpertCache::new(2, 4, 4);
1259        cache.ensure(0, &[0, 1, 2, 3]);
1260        cache.ensure(1, &[0]);
1261        let held = cache.slot_of(1, 0).unwrap();
1262
1263        // A route to a resident expert plus a miss: the miss must take
1264        // some *other* slot.
1265        let plan = cache.ensure(1, &[0, 1]);
1266        assert_eq!(plan.missing, 1);
1267        assert_eq!(cache.slot_of(1, 0), Some(held), "the hit kept its slot");
1268        assert_ne!(plan.copy.dst_slots[0], held);
1269        assert_eq!(plan.slots[0], Some(held));
1270    }
1271
1272    #[test]
1273    fn eviction_takes_the_least_recently_used_slot() {
1274        let mut cache = ExpertCache::new(2, 4, 4);
1275        for expert in 0..4u32 {
1276            cache.ensure(0, &[expert]); // one step each, ascending usage
1277        }
1278        cache.ensure(0, &[0]); // refresh 0, making 1 the oldest
1279        let oldest = cache.slot_of(0, 1).unwrap();
1280
1281        let plan = cache.ensure(1, &[0]);
1282        assert_eq!(plan.copy.dst_slots, vec![oldest]);
1283        assert_eq!(cache.slot_of(0, 1), None, "the oldest went");
1284        assert!(cache.slot_of(0, 0).is_some(), "the refreshed one stayed");
1285    }
1286
1287    /// A large cold batch must give every distinct expert its own slot
1288    /// -- no two routes may share one.
1289    #[test]
1290    fn a_large_cold_batch_assigns_unique_slots() {
1291        let mut cache = ExpertCache::new(4, 64, 256);
1292        let ids: Vec<u32> = (0..64).collect();
1293        let plan = cache.ensure(2, &ids);
1294        assert_eq!(plan.missing, 64);
1295        assert_eq!(plan.copy.len(), 64);
1296
1297        let mut slots: Vec<u32> = plan.slots.iter().map(|s| s.unwrap()).collect();
1298        slots.sort_unstable();
1299        slots.dedup();
1300        assert_eq!(slots.len(), 64, "no slot serves two experts");
1301        assert_eq!(
1302            plan.copy.src_rows, ids,
1303            "misses are ranked by ascending expert id"
1304        );
1305    }
1306
1307    /// Every routed expert is assigned exactly once, to exactly one
1308    /// device -- the invariant that makes adding the GPU and CPU
1309    /// partial results correct.
1310    #[test]
1311    fn the_hybrid_split_assigns_every_route_to_exactly_one_device() {
1312        let mut cache = ExpertCache::new(1, 32, 40);
1313        let policy = QStarPolicy::from_fraction(0.415);
1314        let routes: Vec<u32> = (0..8).collect();
1315
1316        let plan = cache.ensure_hybrid(0, &routes, &policy);
1317        assert_eq!(plan.missing, 8);
1318        assert_eq!(plan.fetched, 3, "0.415 * 8 = 3.32 -> 3");
1319        assert_eq!(plan.copy.len(), 3);
1320        let on_gpu = plan.slots.iter().filter(|s| s.is_some()).count();
1321        assert_eq!(on_gpu, 3);
1322        assert_eq!(plan.slots.iter().filter(|s| s.is_none()).count(), 5);
1323    }
1324
1325    /// A hot expert that keeps losing the cap must eventually win one,
1326    /// or it is computed on the CPU forever while the cache holds cold
1327    /// experts.
1328    #[test]
1329    fn a_recurring_miss_climbs_the_fetch_order() {
1330        let mut cache = ExpertCache::new(1, 16, 16);
1331        let policy = QStarPolicy::fixed_cap(1);
1332        // Expert 9 is routed every step alongside a rotating cast.
1333        for round in 0..6u32 {
1334            cache.ensure_hybrid(0, &[9, round], &policy);
1335        }
1336        assert!(
1337            cache.slot_of(0, 9).is_some(),
1338            "the expert routed every step became resident"
1339        );
1340    }
1341
1342    #[test]
1343    fn the_fixed_cap_is_the_unbenchmarked_default() {
1344        let mut cache = ExpertCache::new(1, 32, 40);
1345        let plan = cache.ensure_hybrid(0, &[0, 1, 2, 3, 4, 5, 6, 7], &QStarPolicy::fixed_cap(1));
1346        assert_eq!(plan.missing, 8);
1347        assert_eq!(plan.fetched, 1);
1348        assert_eq!(plan.copy.len(), 1);
1349    }
1350
1351    /// A prefill copies a whole layer and lays it out so that position
1352    /// equals expert id -- and must not leave another layer thinking it
1353    /// still owns one of those slots.
1354    #[test]
1355    fn materializing_a_layer_reclaims_other_layers_slots_cleanly() {
1356        let mut cache = ExpertCache::new(2, 4, 4);
1357        cache.ensure(0, &[0, 1, 2, 3]);
1358        assert_eq!(cache.resident_slots(), 4);
1359
1360        let copy = cache.materialize_layer(1);
1361        assert_eq!(copy.dst_slots, vec![0, 1, 2, 3]);
1362        assert_eq!(copy.src_rows, vec![0, 1, 2, 3], "position == expert id");
1363        for expert in 0..4u32 {
1364            assert_eq!(cache.slot_of(1, expert), Some(expert));
1365            assert_eq!(
1366                cache.slot_of(0, expert),
1367                None,
1368                "layer 0 must not hit on layer 1's bytes"
1369            );
1370        }
1371
1372        // And a later decode on layer 0 misses and reloads, rather than
1373        // reading whatever is in the slot.
1374        let plan = cache.ensure(0, &[3]);
1375        assert_eq!(plan.missing, 1);
1376    }
1377
1378    #[test]
1379    fn a_second_materialize_of_the_same_layer_is_idempotent() {
1380        let mut cache = ExpertCache::new(2, 4, 6);
1381        cache.materialize_layer(1);
1382        cache.materialize_layer(1);
1383        for expert in 0..4u32 {
1384            assert_eq!(cache.slot_of(1, expert), Some(expert));
1385        }
1386        assert_eq!(cache.resident_slots(), 4);
1387    }
1388
1389    #[test]
1390    fn stats_answer_whether_the_cache_is_big_enough() {
1391        let mut cache = ExpertCache::new(1, 2, 2);
1392        cache.ensure(0, &[0, 1]); // 2 misses
1393        cache.ensure(0, &[0, 1]); // 2 hits
1394        let stats = cache.stats();
1395        assert_eq!(stats.calls, 2);
1396        assert_eq!(stats.active, 4);
1397        assert_eq!(stats.missing, 2);
1398        assert_eq!(stats.miss_rate(), 0.5);
1399        assert_eq!(stats.fetch_rate(), 1.0);
1400        assert_eq!(stats.active_per_call(), 2.0);
1401    }
1402
1403    #[test]
1404    fn a_rebuild_that_cannot_hold_a_layer_changes_nothing() {
1405        let mut cache = ExpertCache::new(2, 8, 16);
1406        cache.ensure(0, &[1]);
1407        let err = cache.rebuild(4).unwrap_err();
1408        assert_eq!(err.minimum, 8);
1409        assert_eq!(cache.cache_size(), 16, "still serving from the old cache");
1410        assert!(cache.slot_of(0, 1).is_some());
1411
1412        cache.rebuild(8).expect("one layer fits");
1413        assert_eq!(cache.cache_size(), 8);
1414        assert_eq!(
1415            cache.resident_slots(),
1416            0,
1417            "slot ids no longer mean anything"
1418        );
1419    }
1420
1421    #[test]
1422    #[should_panic(expected = "cannot hold one layer")]
1423    fn a_cache_too_small_for_one_layer_is_rejected_at_construction() {
1424        ExpertCache::new(2, 8, 4);
1425    }
1426
1427    /// The residency map must stay coherent under sustained pressure:
1428    /// no slot claimed by two experts, no expert claiming a slot that
1429    /// does not point back.
1430    #[test]
1431    fn the_residency_map_stays_bijective_under_pressure() {
1432        let mut cache = ExpertCache::new(4, 16, 20);
1433        let policy = QStarPolicy::from_fraction(0.5);
1434        for step in 0..200u32 {
1435            let layer = step % 4;
1436            let routes: Vec<u32> = (0..6).map(|i| (step * 7 + i * 3) % 16).collect();
1437            let plan = if step % 2 == 0 {
1438                cache.ensure(layer, &routes)
1439            } else {
1440                cache.ensure_hybrid(layer, &routes, &policy)
1441            };
1442            assert_eq!(plan.slots.len(), routes.len());
1443
1444            for slot in 0..cache.cache_size() as u32 {
1445                if let Some(id) = cache.resident_in(slot) {
1446                    assert_eq!(
1447                        cache.slot_of(id.layer, id.expert),
1448                        Some(slot),
1449                        "slot {slot} and its occupant disagree"
1450                    );
1451                }
1452            }
1453            assert!(cache.resident_slots() <= cache.cache_size());
1454        }
1455    }
1456
1457    // ---- per-layer stats ----------------------------------------------
1458
1459    /// The rule: the counters keep the layer that produced them.
1460    ///
1461    /// This test FAILS if per-layer stats are derived the naive way --
1462    /// by taking the global window and apportioning it across layers.
1463    /// Both caches below end the window with an identical global
1464    /// picture (8 active, 4 missing, a 0.5 miss rate), so any
1465    /// apportioning reports the same 0.5 for every layer of both. The
1466    /// two situations are in fact opposites: the first has one layer
1467    /// thrashing at 1.0 beside one at 0.0 and wants slots moved between
1468    /// layers, the second is uniformly half-missing and wants a bigger
1469    /// pool.
1470    #[test]
1471    fn per_layer_stats_tell_one_thrashing_layer_from_a_uniformly_tight_cache() {
1472        let mut thrashing = ExpertCache::new(2, 4, 8);
1473        thrashing.ensure(1, &[0, 1]); // warm layer 1 outside the window
1474        thrashing.reset_stats();
1475        thrashing.ensure(0, &[0, 1]); // 2 active, 2 missing
1476        thrashing.ensure(0, &[2, 3]); // 2 active, 2 missing
1477        thrashing.ensure(1, &[0, 1]); // 2 active, 0 missing
1478        thrashing.ensure(1, &[0, 1]); // 2 active, 0 missing
1479
1480        let mut uniform = ExpertCache::new(2, 4, 8);
1481        uniform.ensure(0, &[0]);
1482        uniform.ensure(1, &[0]);
1483        uniform.reset_stats();
1484        uniform.ensure(0, &[0, 1]); // hit + miss
1485        uniform.ensure(0, &[0, 2]); // hit + miss
1486        uniform.ensure(1, &[0, 1]);
1487        uniform.ensure(1, &[0, 2]);
1488
1489        // Indistinguishable globally.
1490        assert_eq!(thrashing.stats().active, uniform.stats().active);
1491        assert_eq!(thrashing.stats().missing, uniform.stats().missing);
1492        assert_eq!(thrashing.stats().miss_rate(), 0.5);
1493        assert_eq!(uniform.stats().miss_rate(), 0.5);
1494
1495        // Opposite per layer.
1496        assert_eq!(thrashing.layer_stats(0).miss_rate(), 1.0);
1497        assert_eq!(thrashing.layer_stats(1).miss_rate(), 0.0);
1498        assert_eq!(uniform.layer_stats(0).miss_rate(), 0.5);
1499        assert_eq!(uniform.layer_stats(1).miss_rate(), 0.5);
1500    }
1501
1502    #[test]
1503    fn per_layer_stats_sum_to_the_global_stats() {
1504        let mut cache = ExpertCache::new(3, 8, 16);
1505        let policy = QStarPolicy::from_fraction(0.5);
1506        for step in 0..30u32 {
1507            let layer = step % 3;
1508            let routes: Vec<u32> = (0..4).map(|i| (step * 5 + i * 3) % 8).collect();
1509            if step % 2 == 0 {
1510                cache.ensure(layer, &routes);
1511            } else {
1512                cache.ensure_hybrid(layer, &routes, &policy);
1513            }
1514        }
1515        let global = cache.stats();
1516        let summed =
1517            cache
1518                .per_layer_stats()
1519                .iter()
1520                .fold(ExpertCacheStats::default(), |mut acc, layer| {
1521                    acc.calls += layer.calls;
1522                    acc.active += layer.active;
1523                    acc.missing += layer.missing;
1524                    acc.fetched += layer.fetched;
1525                    acc
1526                });
1527        assert_eq!(summed, global);
1528        assert_eq!(cache.per_layer_stats().len(), 3);
1529        assert_eq!(cache.layer_stats(1).calls, 10);
1530    }
1531
1532    #[test]
1533    fn resetting_stats_opens_a_new_per_layer_window() {
1534        let mut cache = ExpertCache::new(2, 4, 8);
1535        cache.ensure(0, &[0, 1]);
1536        assert_eq!(cache.layer_stats(0).missing, 2);
1537        cache.reset_stats();
1538        assert_eq!(cache.layer_stats(0), ExpertCacheStats::default());
1539        assert_eq!(cache.stats(), ExpertCacheStats::default());
1540
1541        // The window is about counters, not residency: the experts are
1542        // still there, so the new window sees hits.
1543        cache.ensure(0, &[0, 1]);
1544        assert_eq!(cache.layer_stats(0).missing, 0);
1545        assert_eq!(cache.layer_stats(0).active, 2);
1546        assert_eq!(cache.layer_stats(0).active_per_call(), 2.0);
1547        assert_eq!(cache.layer_stats(0).missing_per_call(), 0.0);
1548    }
1549
1550    // ---- routing skew --------------------------------------------------
1551
1552    /// The rule: the histogram records what the router asked for, so
1553    /// duplicate routes count as separate mass.
1554    ///
1555    /// This test FAILS if the histogram is fed the deduplicated route
1556    /// list `ensure` already computes for its miss accounting. Skew is
1557    /// precisely the difference between "which experts were touched"
1558    /// and "how much traffic each one took"; deduplicating erases the
1559    /// second and reports every busy layer as uniform.
1560    #[test]
1561    fn the_routing_histogram_counts_every_route_not_every_distinct_expert() {
1562        let mut cache = ExpertCache::new(2, 4, 8);
1563        cache.set_collect_routing(true);
1564        cache.ensure(0, &[3, 3, 3, 1]);
1565        assert_eq!(cache.routing_histogram(0), &[0, 1, 0, 3]);
1566        assert_eq!(cache.routing_histogram(1), &[0, 0, 0, 0]);
1567    }
1568
1569    /// The rule: `oracle_hit_at_slots` is the top-`C` share of the
1570    /// observed mass, `C = max(1, round(cache_size / num_layers))`.
1571    ///
1572    /// This test FAILS if the oracle is derived the naive way, from the
1573    /// working set alone -- `C / working_set`, "how much of what this
1574    /// layer touches fits". Both layers below touch all 8 experts, so
1575    /// that formula reports 0.5 for each. The real answer is 0.96 for
1576    /// the skewed layer and 0.52 for the flat one, and that gap is the
1577    /// whole diagnosis: four slots serve the first layer almost
1578    /// perfectly and cannot help the second at any size.
1579    #[test]
1580    fn oracle_hit_at_slots_separates_a_skewed_layer_from_a_flat_one() {
1581        let mut cache = ExpertCache::new(2, 8, 8);
1582        cache.set_collect_routing(true);
1583
1584        let routes = |counts: [usize; 8]| -> Vec<u32> {
1585            let mut out = Vec::new();
1586            for (expert, count) in counts.iter().enumerate() {
1587                for _ in 0..*count {
1588                    out.push(expert as u32);
1589                }
1590            }
1591            out
1592        };
1593        // 96 of 100 routes land on four experts.
1594        cache.ensure(0, &routes([24, 24, 24, 24, 1, 1, 1, 1]));
1595        // Near-flat: the best four experts hold 52 of 100.
1596        cache.ensure(1, &routes([13, 13, 13, 13, 12, 12, 12, 12]));
1597
1598        let report = cache.routing_skew().expect("routing was observed");
1599        assert_eq!(report.slots_per_layer, 4.0);
1600        assert_eq!(report.oracle_slots, 4);
1601        assert_eq!(report.per_layer.len(), 2);
1602
1603        let skewed = report.per_layer[0];
1604        let flat = report.per_layer[1];
1605        assert_eq!(skewed.routed, 100);
1606        assert_eq!(flat.routed, 100);
1607        assert_eq!(
1608            skewed.working_set, flat.working_set,
1609            "both layers touch every expert, so the working set cannot tell them apart"
1610        );
1611        assert!((skewed.oracle_hit_at_slots - 0.96).abs() < 1e-12);
1612        assert!((flat.oracle_hit_at_slots - 0.52).abs() < 1e-12);
1613        assert!(skewed.norm_entropy < flat.norm_entropy);
1614        assert!(skewed.experts_for_90pct < flat.experts_for_90pct);
1615    }
1616
1617    #[test]
1618    fn routing_skew_reports_the_working_set_coverage_and_entropy_per_layer() {
1619        let mut cache = ExpertCache::new(2, 8, 8);
1620        cache.set_collect_routing(true);
1621        cache.ensure(0, &[0, 1, 2, 3, 4, 5, 6, 7]); // perfectly flat
1622        cache.ensure(1, &[2, 2, 2, 2]); // one expert, always
1623
1624        let report = cache.routing_skew().expect("routing was observed");
1625        let flat = report.per_layer[0];
1626        assert_eq!(flat.working_set, 8);
1627        assert_eq!(flat.experts_for_90pct, 8, "7 experts reach 0.875, not 0.9");
1628        assert!((flat.norm_entropy - 1.0).abs() < 1e-12);
1629        assert!((flat.oracle_hit_at_slots - 0.5).abs() < 1e-12);
1630
1631        let single = report.per_layer[1];
1632        assert_eq!(single.working_set, 1);
1633        assert_eq!(single.experts_for_90pct, 1);
1634        assert_eq!(single.norm_entropy, 0.0);
1635        assert_eq!(single.oracle_hit_at_slots, 1.0);
1636
1637        assert_eq!(report.working_set_max, 8);
1638        assert_eq!(report.working_set_mean, 4.5);
1639        assert_eq!(report.experts_for_90pct, 4.5);
1640        assert!((report.oracle_hit_at_slots - 0.75).abs() < 1e-12);
1641        assert!((report.norm_entropy - 0.5).abs() < 1e-12);
1642    }
1643
1644    /// A layer nothing routed to has no row, and an unobserved model
1645    /// has no report -- zeros here would read as "flat routing, no
1646    /// cache will help", which is the opposite of "no data".
1647    #[test]
1648    fn routing_skew_is_absent_until_routing_is_observed() {
1649        let mut cache = ExpertCache::new(4, 8, 16);
1650        cache.ensure(0, &[1, 2]);
1651        assert!(
1652            cache.routing_skew().is_none(),
1653            "collection is opt-in, so nothing was recorded"
1654        );
1655
1656        cache.set_collect_routing(true);
1657        cache.ensure(2, &[1, 2]);
1658        let report = cache.routing_skew().expect("layer 2 was observed");
1659        assert_eq!(report.per_layer.len(), 1);
1660        assert_eq!(report.per_layer[0].layer, 2);
1661
1662        cache.reset_routing();
1663        assert!(cache.routing_skew().is_none());
1664    }
1665
1666    // ---- prefill double buffers ----------------------------------------
1667
1668    const BIG_BANK: u64 = 512 * 1024;
1669    const SMALL_BANK: u64 = 4 * 1024;
1670
1671    /// Four layers of four experts filling all sixteen slots in layer
1672    /// order, so layer `L` owns slots `[4L, 4L + 4)`. The buffers
1673    /// borrow slots `[0, 8)`, which puts layers 0 and 1 inside the
1674    /// buffer range and layers 2 and 3 above it.
1675    fn cache_with_one_layer_per_quarter() -> ExpertCache {
1676        let mut cache = ExpertCache::new(4, 4, 16);
1677        for layer in 0..4u32 {
1678            cache.ensure(layer, &[0, 1, 2, 3]);
1679        }
1680        for layer in 0..4u32 {
1681            for expert in 0..4u32 {
1682                assert_eq!(cache.slot_of(layer, expert), Some(layer * 4 + expert));
1683            }
1684        }
1685        cache
1686    }
1687
1688    /// The rule: a row is resident only if its slot is at or above
1689    /// `2 * num_experts`.
1690    ///
1691    /// This test FAILS if hits are classified the naive way, `slot >=
1692    /// 0` -- "the residency map has a slot for it, so it is on the
1693    /// device". Layer 1's experts live in slots 4..8, which is exactly
1694    /// the range buffer 1 is about to overwrite; the naive test calls
1695    /// all four hits and emits a gather that reads those slots while
1696    /// the staging copy writes them, so the prefill silently computes
1697    /// on whichever bytes won.
1698    #[test]
1699    fn a_slot_inside_the_prefill_buffers_is_a_miss_however_resident_it_looks() {
1700        let mut cache = cache_with_one_layer_per_quarter();
1701        cache.begin_prefill();
1702
1703        // Layer 1 -> buffer 1, whose slots are 4..8 -- where layer 1's
1704        // own experts are resident.
1705        assert_eq!(cache.slot_of(1, 0), Some(4));
1706        let plan = cache.prefetch_prefill_layer(1, &[BIG_BANK]);
1707        assert_eq!(plan.buffer_id, 1);
1708        assert!(
1709            plan.gather.is_empty(),
1710            "slots below 2 * num_experts are volatile, not resident"
1711        );
1712        assert_eq!(plan.miss_runs, vec![MissRun { start: 0, len: 4 }]);
1713        assert_eq!(cache.prefill_hit_rows(), 0);
1714        assert_eq!(cache.prefill_rows(), 4);
1715    }
1716
1717    #[test]
1718    fn a_resident_expert_above_the_buffer_slots_is_gathered_device_side() {
1719        let mut cache = cache_with_one_layer_per_quarter();
1720        cache.begin_prefill();
1721
1722        // Layer 2 lives in slots 8..12, clear of the buffers.
1723        let plan = cache.prefetch_prefill_layer(2, &[BIG_BANK]);
1724        assert_eq!(plan.buffer_id, 0);
1725        assert_eq!(plan.gather.dst_slots, vec![0, 1, 2, 3]);
1726        assert_eq!(plan.gather.src_slots, vec![8, 9, 10, 11]);
1727        assert!(plan.miss_runs.is_empty());
1728        assert!(
1729            plan.entries.is_empty(),
1730            "a big bank with no misses ships nothing"
1731        );
1732        assert_eq!(cache.prefill_hit_rows(), 4);
1733        assert_eq!(cache.prefill_rows(), 4);
1734    }
1735
1736    /// The rule: invalidation zeroes `usage` on the buffer's slots as
1737    /// well as clearing the residency map.
1738    ///
1739    /// This test FAILS if invalidation is done the naive way -- drop
1740    /// the occupants and leave the LRU clock alone. Slots 0..4 are
1741    /// freshly touched here, so their stale usage outranks layer 2's;
1742    /// the next decode miss then evicts a live resident from slot 8
1743    /// while four empty slots sit unused, and layer 2 pays a fetch it
1744    /// did not have to.
1745    #[test]
1746    fn invalidating_a_prefill_buffer_makes_its_slots_the_first_victims() {
1747        let mut cache = cache_with_one_layer_per_quarter();
1748        // Re-touch the low half so the buffer slots are the *newest*
1749        // in the pool, not the oldest.
1750        cache.ensure(0, &[0, 1, 2, 3]);
1751        cache.ensure(1, &[0, 1, 2, 3]);
1752
1753        cache.begin_prefill();
1754        cache.prefetch_prefill_layer(0, &[BIG_BANK]); // buffer 0 == slots 0..4
1755        for expert in 0..4u32 {
1756            assert_eq!(
1757                cache.slot_of(0, expert),
1758                None,
1759                "the buffer's old occupants lost their residency"
1760            );
1761        }
1762
1763        // A cold decode miss must land in the invalidated buffer.
1764        let plan = cache.ensure(0, &[0]);
1765        assert_eq!(plan.missing, 1);
1766        assert_eq!(plan.copy.dst_slots, vec![0]);
1767        assert_eq!(
1768            cache.slot_of(2, 0),
1769            Some(8),
1770            "no real resident was evicted while free slots existed"
1771        );
1772    }
1773
1774    /// The rule: misses coalesce into contiguous expert runs.
1775    ///
1776    /// This test FAILS if each missing expert ships as its own entry.
1777    /// Experts 0,1,2 and 5,6 miss here; the run form is two entries of
1778    /// three and two rows, the naive form five single-row entries --
1779    /// five times the descriptors for the same bytes, at a fraction of
1780    /// the achievable rate.
1781    #[test]
1782    fn prefill_misses_ship_as_contiguous_expert_runs() {
1783        let mut cache = ExpertCache::new(4, 8, 32);
1784        cache.ensure(0, &[0, 1, 2, 3, 4, 5, 6, 7]); // slots 0..8
1785        cache.ensure(2, &[0, 1, 2, 3, 4, 5, 6, 7]); // slots 8..16
1786        cache.ensure(1, &[3, 4, 7]); // slots 16, 17, 18 -- above the buffers
1787        cache.begin_prefill();
1788
1789        let plan = cache.prefetch_prefill_layer(1, &[BIG_BANK]);
1790        assert_eq!(plan.buffer_id, 1);
1791        assert_eq!(plan.gather.dst_slots, vec![11, 12, 15]);
1792        assert_eq!(plan.gather.src_slots, vec![16, 17, 18]);
1793        assert_eq!(
1794            plan.miss_runs,
1795            vec![MissRun { start: 0, len: 3 }, MissRun { start: 5, len: 2 }]
1796        );
1797        assert_eq!(
1798            plan.entries,
1799            vec![
1800                BankEntry {
1801                    bank: 0,
1802                    dst_slot: 8,
1803                    src_row: 0,
1804                    rows: 3,
1805                    bytes: 3 * BIG_BANK,
1806                    whole_layer: false,
1807                },
1808                BankEntry {
1809                    bank: 0,
1810                    dst_slot: 13,
1811                    src_row: 5,
1812                    rows: 2,
1813                    bytes: 2 * BIG_BANK,
1814                    whole_layer: false,
1815                },
1816            ]
1817        );
1818    }
1819
1820    /// The rule: a bank whose per-expert row is under
1821    /// `SMALL_BANK_FEAT_BYTES` ships its whole layer as one entry, even
1822    /// with zero misses.
1823    ///
1824    /// This test FAILS if entries are emitted the naive way, only for
1825    /// experts that actually missed. Every row of layer 1 is resident
1826    /// here, so the naive plan ships nothing at all -- but the gather
1827    /// deliberately skips small banks, so their rows would never arrive
1828    /// and the layer would compute on stale buffer bytes. The
1829    /// whole-layer entry is also what keeps a tiny bank from dropping a
1830    /// sub-256 KiB entry into a batch and turning the whole transfer
1831    /// synchronous.
1832    #[test]
1833    fn a_small_bank_ships_its_whole_layer_even_with_no_misses() {
1834        let mut cache = ExpertCache::new(4, 8, 32);
1835        cache.ensure(0, &[0, 1, 2, 3, 4, 5, 6, 7]); // slots 0..8
1836        cache.ensure(2, &[0, 1, 2, 3, 4, 5, 6, 7]); // slots 8..16
1837        cache.ensure(1, &[0, 1, 2, 3, 4, 5, 6, 7]); // slots 16..24
1838        cache.begin_prefill();
1839
1840        let plan = cache.prefetch_prefill_layer(1, &[BIG_BANK, SMALL_BANK]);
1841        assert_eq!(plan.gather.len(), 8, "every row is resident");
1842        assert!(plan.miss_runs.is_empty());
1843        assert_eq!(plan.gather_banks, vec![0], "the small bank is not gathered");
1844        assert_eq!(
1845            plan.entries,
1846            vec![BankEntry {
1847                bank: 1,
1848                dst_slot: 8,
1849                src_row: 0,
1850                rows: 8,
1851                bytes: 8 * SMALL_BANK,
1852                whole_layer: true,
1853            }]
1854        );
1855    }
1856
1857    #[test]
1858    fn the_two_prefill_buffers_rotate_by_layer_parity() {
1859        let mut cache = ExpertCache::new(4, 4, 16);
1860        cache.begin_prefill();
1861        assert_eq!(cache.prefill_buffer_layer(0), None);
1862
1863        assert_eq!(cache.prefetch_prefill_layer(0, &[BIG_BANK]).buffer_id, 0);
1864        assert_eq!(cache.prefetch_prefill_layer(1, &[BIG_BANK]).buffer_id, 1);
1865        assert_eq!(cache.prefill_buffer_layer(0), Some(0));
1866        assert_eq!(cache.prefill_buffer_layer(1), Some(1));
1867
1868        // Re-staging what a buffer already holds moves nothing.
1869        let again = cache.prefetch_prefill_layer(1, &[BIG_BANK]);
1870        assert!(again.already_loaded);
1871        assert!(again.entries.is_empty());
1872        assert_eq!(cache.prefill_rows(), 8, "the no-op staged no rows");
1873
1874        cache.release_prefill_layer(0);
1875        assert_eq!(cache.prefetch_prefill_layer(2, &[BIG_BANK]).buffer_id, 0);
1876        assert_eq!(cache.prefill_buffer_layer(0), Some(2));
1877    }
1878
1879    /// Reusing a buffer the consumer has not finished with would
1880    /// overwrite bytes a running multiply is reading, which is a silent
1881    /// wrong answer rather than a failure -- so it is refused.
1882    #[test]
1883    #[should_panic(expected = "still holds layer 0")]
1884    fn reusing_a_prefill_buffer_before_it_is_released_is_refused() {
1885        let mut cache = ExpertCache::new(4, 4, 16);
1886        cache.begin_prefill();
1887        cache.prefetch_prefill_layer(0, &[BIG_BANK]);
1888        cache.prefetch_prefill_layer(2, &[BIG_BANK]);
1889    }
1890
1891    #[test]
1892    #[should_panic(expected = "begin_prefill")]
1893    fn staging_a_layer_without_opening_the_chunk_is_refused() {
1894        let mut cache = ExpertCache::new(4, 4, 16);
1895        cache.prefetch_prefill_layer(0, &[BIG_BANK]);
1896    }
1897
1898    /// A chunk classifies against the snapshot taken when it opened.
1899    /// Buffer invalidation is the only writer inside a chunk and it
1900    /// only ever touches slots below the threshold, which are misses
1901    /// under both maps -- so the snapshot and the live map agree on
1902    /// every row, and the caller may order the gather and the host
1903    /// transfer however it likes.
1904    #[test]
1905    fn the_chunk_snapshot_and_the_live_map_agree_on_every_row() {
1906        let mut cache = ExpertCache::new(4, 4, 16);
1907        for layer in 0..4u32 {
1908            cache.ensure(layer, &[0, 1, 2, 3]);
1909        }
1910        cache.begin_prefill();
1911        for layer in [2u32, 3, 2] {
1912            let live: Vec<Option<u32>> = (0..4)
1913                .map(|expert| cache.slot_of(layer, expert))
1914                .map(|slot| slot.filter(|s| *s >= 8))
1915                .collect();
1916            let plan = cache.prefetch_prefill_layer(layer, &[BIG_BANK]);
1917            cache.release_prefill_layer(layer);
1918            let hits: Vec<u32> = plan.gather.src_slots.clone();
1919            let live_hits: Vec<u32> = live.into_iter().flatten().collect();
1920            if !plan.already_loaded {
1921                assert_eq!(hits, live_hits, "layer {layer} classified differently");
1922            }
1923        }
1924    }
1925
1926    #[test]
1927    fn a_pool_that_cannot_lend_the_buffers_their_slots_says_so() {
1928        let cache = ExpertCache::new(2, 8, 12);
1929        assert!(!cache.prefill_overlap_fits());
1930        assert_eq!(cache.prefill_buffer_slots(), 16);
1931
1932        let fits = ExpertCache::new(2, 8, 16);
1933        assert!(fits.prefill_overlap_fits());
1934    }
1935
1936    #[test]
1937    #[should_panic(expected = "cannot lend")]
1938    fn opening_a_chunk_on_a_pool_too_small_for_the_buffers_is_refused() {
1939        let mut cache = ExpertCache::new(2, 8, 12);
1940        cache.begin_prefill();
1941    }
1942}