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}