Skip to main content

ferrox_core/
expert_slots.rs

1//! The executor for [`crate::expert_cache`]'s plans: a bounded slot
2//! pool, the copies a plan asks for, and the counter that says a warm
3//! step moved nothing.
4//!
5//! # Why this module exists
6//!
7//! [`ExpertCache`](crate::expert_cache::ExpertCache) decides which
8//! experts are resident and returns a
9//! [`CopyPlan`](crate::expert_cache::CopyPlan) -- and that is where it
10//! stops, deliberately: "nothing here moves bytes; the plan is the
11//! whole output". Until something executes those plans the whole `q*`
12//! split is inert, because a policy that decides how much to fetch
13//! decides nothing while no fetch happens. This is the other half: it
14//! takes a plan, validates it against a pool it owns the geometry of,
15//! performs exactly the copies the plan names, and records what
16//! crossed the link.
17//!
18//! # Still no device memory here
19//!
20//! The bytes live behind [`SlotDevice`], so this crate keeps holding
21//! no tensors and no device allocations, and every rule below stays
22//! testable on a host with no GPU at all.
23//! [`HostSlotMemory`] is a real implementation of that trait rather
24//! than a test mock -- it is the pool a CPU-only build uses, where the
25//! "link" is a memcpy.
26//!
27//! # A plan is applied whole or not at all
28//!
29//! Every check happens in a pre-pass, before a single byte is written.
30//! A plan is a unit: the cache has *already* recorded the residency the
31//! plan describes by the time the caller gets it, so a half-applied
32//! plan leaves the cache claiming an expert lives in a slot that holds
33//! someone else's bytes. That does not fail -- it multiplies, and
34//! returns a confident wrong answer. Refusing up front keeps the
35//! device exactly as it was, which is a state the cache can be told to
36//! resync to.
37//!
38//! A device fault is the one thing that can still land mid-plan, since
39//! only the device knows it failed. Whatever it cost is marked unknown
40//! here and named in the error, so the caller can hand the same slots
41//! to
42//! [`ExpertCache::forget_slot`](crate::expert_cache::ExpertCache::forget_slot)
43//! and have the next step re-fetch them instead of reading them. How
44//! much is suspect depends on when it failed: a refused copy costs its
45//! own slot ([`SlotFault::Device`]), while a failed *flush* costs every
46//! slot the plan wrote ([`SlotFault::DeviceFlush`]) -- a backend that
47//! batches its transfers cannot say which of them landed.
48//!
49//! # Row size is checked exactly, not as a minimum
50//!
51//! A host row shorter than the slot would leave the slot's tail
52//! holding the *previous* occupant's bytes: a coherent-looking expert
53//! spliced from two, which produces plausible tokens rather than an
54//! error. So a length mismatch in either direction is refused.
55
56use crate::expert_cache::{CopyPlan, ExpertId, GatherPlan};
57use crate::residency::{BankResidency, CopyRoute, ResidencyError};
58
59/// The shape of one pool: how many slots, how many banks, and how wide
60/// a row is in each bank.
61///
62/// `row_bytes` is per bank because the banks are not interchangeable:
63/// gate and up are `[ffn_dim, hidden]` while down is `[hidden,
64/// ffn_dim]`, and a checkpoint may quantize them differently, so one
65/// row width for all three would be wrong for at least one of them.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct SlotGeometry {
68    pub num_layers: usize,
69    /// Slots in the pool. This is the `cache_size` the
70    /// [`ExpertCache`](crate::expert_cache::ExpertCache) was built
71    /// with; the two must agree or a slot the planner names will not
72    /// exist here.
73    pub slots: usize,
74    /// Bytes per expert row, one entry per bank, in the bank order the
75    /// caller uses everywhere else (conventionally gate, up, down).
76    pub row_bytes: Vec<usize>,
77}
78
79impl SlotGeometry {
80    pub fn banks(&self) -> usize {
81        self.row_bytes.len()
82    }
83
84    /// Device bytes the whole pool occupies. What a VRAM budget is
85    /// checked against before anything is allocated.
86    pub fn bytes(&self) -> u64 {
87        self.row_bytes
88            .iter()
89            .map(|b| *b as u64 * self.slots as u64)
90            .sum()
91    }
92}
93
94/// Where a slot's bytes actually live.
95///
96/// One implementation per backend. Both methods take a bank index and
97/// absolute slot numbers; translating those into an address is the
98/// implementation's business, which is what keeps this crate free of
99/// device memory.
100///
101/// Implementations may defer the work and do it in [`flush`](Self::flush)
102/// -- an async copy engine is the normal case -- but must not report
103/// success for a copy they later discover failed without failing the
104/// flush.
105pub trait SlotDevice {
106    /// Announces how the copies about to be issued must be carried
107    /// out. Called once per plan, before the first copy.
108    ///
109    /// The default ignores it, which is right for any backend whose
110    /// copies are already synchronous. A backend that captures work
111    /// into a graph must not capture a
112    /// [`CopyRoute::WholeLayerPageable`] plan: that route exists
113    /// precisely because the layer's host rows have no device address,
114    /// so its copy is a synchronous pageable one and a captured graph
115    /// would replay a transfer whose source is not addressable.
116    fn begin_plan(&mut self, route: CopyRoute) -> Result<(), String> {
117        let _ = route;
118        Ok(())
119    }
120
121    /// Writes one expert row from host memory into `dst_slot`. `src`
122    /// is exactly `row_bytes[bank]` long; the implementation may rely
123    /// on that.
124    fn write_slot(&mut self, bank: usize, dst_slot: u32, src: &[u8]) -> Result<(), String>;
125
126    /// Copies one slot onto another *within* the pool, without
127    /// touching the host. This is what makes a prefill gather cheaper
128    /// than a re-fetch: the bytes are already on the far side of the
129    /// link.
130    fn copy_slot(&mut self, bank: usize, dst_slot: u32, src_slot: u32) -> Result<(), String>;
131
132    /// Completes any deferred copies. Called once per plan, after the
133    /// last copy is issued.
134    fn flush(&mut self) -> Result<(), String> {
135        Ok(())
136    }
137}
138
139/// The host-side expert rows a [`CopyPlan`] reads from.
140///
141/// Layer-local by construction, matching
142/// [`CopyPlan::src_rows`](crate::expert_cache::CopyPlan::src_rows):
143/// row `r` of layer `L`, not a flat index. That is what lets each
144/// layer's bank live in its own allocation, which in turn is what lets
145/// [`crate::residency`] give different layers different host residency
146/// classes.
147pub trait ExpertRows {
148    /// The bytes of one expert row, or `None` if the bank has no such
149    /// row. Returning a slice of the wrong length is refused by the
150    /// caller rather than trusted.
151    fn row(&self, bank: usize, layer: u32, row: u32) -> Option<&[u8]>;
152}
153
154/// What one applied plan moved.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
156pub struct Applied {
157    /// Expert rows written, summed over banks.
158    pub rows: u64,
159    pub bytes: u64,
160    /// True when the plan asked for nothing: every routed expert was
161    /// already resident. The property real expert offload is judged
162    /// on, per plan rather than per step.
163    pub warm: bool,
164}
165
166/// Running totals for `/metrics` and for the A/B that says whether
167/// residency is paying for itself.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
169pub struct SlotStats {
170    /// Plans applied, warm ones included.
171    pub plans: u64,
172    /// Plans that copied nothing at all.
173    pub warm_plans: u64,
174    /// Rows and bytes that crossed the link from host memory.
175    pub host_rows: u64,
176    pub host_bytes: u64,
177    /// Rows and bytes moved slot-to-slot without touching the host.
178    pub device_rows: u64,
179    pub device_bytes: u64,
180}
181
182impl SlotStats {
183    /// Fraction of plans that moved nothing. The headline number for
184    /// expert residency: at `1.0` the pool holds the whole working set
185    /// and decode issues no weight traffic at all.
186    pub fn warm_plan_rate(&self) -> f64 {
187        if self.plans == 0 {
188            return 0.0;
189        }
190        self.warm_plans as f64 / self.plans as f64
191    }
192
193    /// Bytes that crossed the link per plan. What a link budget is
194    /// spent against, and the figure a `q*` split is supposed to move.
195    pub fn host_bytes_per_plan(&self) -> f64 {
196        if self.plans == 0 {
197            return 0.0;
198        }
199        self.host_bytes as f64 / self.plans as f64
200    }
201}
202
203/// Why a plan was refused, or how applying it failed.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum SlotFault {
206    /// The two halves of a [`CopyPlan`] have different lengths, so
207    /// pair `i` is not a pair. Applying it anyway would write row `i`
208    /// into some *other* expert's slot -- silently wrong weights, the
209    /// worst outcome available here.
210    PlanHalvesDisagree { dst_slots: usize, src_rows: usize },
211    /// A plan named a slot the pool does not have. The planner and
212    /// this pool were built with different `cache_size` values.
213    SlotOutOfRange { slot: u32, slots: usize },
214    /// One plan writes the same slot twice. The second write wins and
215    /// the first expert is simply absent from the slot the plan
216    /// promised it in, while the cache records both as resident.
217    SlotWrittenTwice { slot: u32 },
218    /// A plan named a layer the pool was not built for.
219    LayerOutOfRange { layer: u32, num_layers: usize },
220    /// The host bank has no such row.
221    RowMissing { bank: usize, layer: u32, row: u32 },
222    /// A host row is not exactly one slot wide -- see the module docs
223    /// on why a short row is worse than a missing one.
224    RowSizeMismatch {
225        bank: usize,
226        layer: u32,
227        row: u32,
228        expected: usize,
229        got: usize,
230    },
231    /// This layer's host residency does not permit the copy the plan
232    /// describes; see [`crate::residency::CopyRoute`].
233    Residency(ResidencyError),
234    /// The device refused a copy. `slot` has been marked unknown here
235    /// and must be forgotten by the planner too.
236    Device {
237        bank: usize,
238        slot: u32,
239        detail: String,
240    },
241    /// The device failed to complete a plan's deferred copies.
242    ///
243    /// Distinct from [`Device`](Self::Device) because no single slot is
244    /// at fault: a backend that batches its copies cannot say which of
245    /// them landed, so **every** slot the plan wrote is suspect. They
246    /// are all marked unknown here and all named, so the planner can
247    /// be told to forget the same set -- forgetting only the last one
248    /// would leave the rest reading back as resident.
249    DeviceFlush { slots: Vec<u32>, detail: String },
250}
251
252impl std::fmt::Display for SlotFault {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        match self {
255            SlotFault::PlanHalvesDisagree {
256                dst_slots,
257                src_rows,
258            } => write!(
259                f,
260                "copy plan has {dst_slots} destination slots and {src_rows} source rows: the \
261                 pairs do not line up, so applying it would load experts into each other's slots"
262            ),
263            SlotFault::SlotOutOfRange { slot, slots } => write!(
264                f,
265                "plan names slot {slot} but the pool has {slots}: the planner and the pool were \
266                 built with different cache sizes"
267            ),
268            SlotFault::SlotWrittenTwice { slot } => write!(
269                f,
270                "plan writes slot {slot} twice: one of the two experts would be absent from the \
271                 slot the plan promised it in"
272            ),
273            SlotFault::LayerOutOfRange { layer, num_layers } => write!(
274                f,
275                "plan names layer {layer} but the pool was built for {num_layers}"
276            ),
277            SlotFault::RowMissing { bank, layer, row } => {
278                write!(f, "bank {bank} has no row {row} for layer {layer}")
279            }
280            SlotFault::RowSizeMismatch {
281                bank,
282                layer,
283                row,
284                expected,
285                got,
286            } => write!(
287                f,
288                "bank {bank} row {row} of layer {layer} is {got} bytes, not {expected}: a \
289                 short row would leave the slot's tail holding the previous occupant"
290            ),
291            SlotFault::Residency(e) => write!(f, "{e}"),
292            SlotFault::Device { bank, slot, detail } => write!(
293                f,
294                "device refused the copy into bank {bank} slot {slot}: {detail}"
295            ),
296            SlotFault::DeviceFlush { slots, detail } => write!(
297                f,
298                "device failed to complete {} deferred cop{}: {detail}; slots {slots:?} are all \
299                 suspect, since the backend cannot say which landed",
300                slots.len(),
301                if slots.len() == 1 { "y" } else { "ies" },
302            ),
303        }
304    }
305}
306
307impl std::error::Error for SlotFault {}
308
309impl From<ResidencyError> for SlotFault {
310    fn from(e: ResidencyError) -> Self {
311        SlotFault::Residency(e)
312    }
313}
314
315/// A bounded pool of expert slots, and the executor for the plans that
316/// fill it.
317///
318/// Holds no bytes: `occupant` is the pool's own record of what the
319/// *device* contains, kept separately from the planner's residency map
320/// on purpose. The two agreeing is the invariant; a single map could
321/// not disagree, and so could never reveal that a copy failed.
322pub struct ExpertSlots {
323    geometry: SlotGeometry,
324    residency: BankResidency,
325    occupant: Vec<Option<ExpertId>>,
326    stats: SlotStats,
327}
328
329impl ExpertSlots {
330    /// Builds a pool, refusing a geometry no plan could be valid
331    /// against.
332    ///
333    /// A zero-width bank is refused rather than tolerated: it would
334    /// make every row size check pass trivially and every copy a
335    /// no-op, so the pool would report warm plans forever while
336    /// holding nothing.
337    pub fn new(geometry: SlotGeometry) -> Result<Self, SlotFault> {
338        if geometry.slots == 0 {
339            return Err(SlotFault::SlotOutOfRange { slot: 0, slots: 0 });
340        }
341        if geometry.num_layers == 0 {
342            return Err(SlotFault::LayerOutOfRange {
343                layer: 0,
344                num_layers: 0,
345            });
346        }
347        for (bank, bytes) in geometry.row_bytes.iter().enumerate() {
348            if *bytes == 0 {
349                return Err(SlotFault::RowSizeMismatch {
350                    bank,
351                    layer: 0,
352                    row: 0,
353                    expected: 0,
354                    got: 0,
355                });
356            }
357        }
358        let residency = BankResidency::all_pinned(geometry.num_layers);
359        Ok(ExpertSlots {
360            occupant: vec![None; geometry.slots],
361            geometry,
362            residency,
363            stats: SlotStats::default(),
364        })
365    }
366
367    /// Attaches the per-layer host residency this pool copies from.
368    ///
369    /// Without it every layer is assumed device-addressable, which is
370    /// what an all-pinned host actually is. With it, a plan for an
371    /// unpinned layer is refused here rather than issued to a device
372    /// that has no address for those host rows.
373    pub fn with_residency(mut self, residency: BankResidency) -> Self {
374        self.residency = residency;
375        self
376    }
377
378    pub fn geometry(&self) -> &SlotGeometry {
379        &self.geometry
380    }
381
382    pub fn stats(&self) -> SlotStats {
383        self.stats
384    }
385
386    pub fn reset_stats(&mut self) {
387        self.stats = SlotStats::default();
388    }
389
390    /// Which expert the *device* holds in `slot`, as far as this pool
391    /// knows. `None` for an empty slot and for one whose last copy
392    /// failed.
393    pub fn occupant(&self, slot: u32) -> Option<ExpertId> {
394        self.occupant.get(slot as usize).copied().flatten()
395    }
396
397    /// Slots currently holding a known expert.
398    pub fn occupied(&self) -> usize {
399        self.occupant.iter().filter(|o| o.is_some()).count()
400    }
401
402    /// Marks a slot as holding nothing known. Idempotent.
403    pub fn invalidate_slot(&mut self, slot: u32) {
404        if let Some(o) = self.occupant.get_mut(slot as usize) {
405            *o = None;
406        }
407    }
408
409    /// Forgets the whole pool. The counters survive: they describe
410    /// traffic that really happened, and a resize does not unmake it.
411    pub fn invalidate_all(&mut self) {
412        self.occupant.iter_mut().for_each(|o| *o = None);
413    }
414
415    /// Resizes the pool, dropping everything resident.
416    ///
417    /// Slot ids are positions in an allocation that no longer exists,
418    /// so keeping the occupancy map would point at other experts'
419    /// bytes -- the same reasoning as
420    /// [`ExpertCache::rebuild`](crate::expert_cache::ExpertCache::rebuild),
421    /// and the two must be resized together or the planner will name
422    /// slots this pool does not have.
423    pub fn resize(&mut self, slots: usize) -> Result<(), SlotFault> {
424        if slots == 0 {
425            return Err(SlotFault::SlotOutOfRange { slot: 0, slots: 0 });
426        }
427        self.geometry.slots = slots;
428        self.occupant = vec![None; slots];
429        Ok(())
430    }
431
432    /// Applies an [`ensure`](crate::expert_cache::ExpertCache::ensure)
433    /// plan: the LRU picked the slots, so this is the indexed device
434    /// path and an unpinned layer cannot take it.
435    pub fn apply_copy_plan(
436        &mut self,
437        layer: u32,
438        plan: &CopyPlan,
439        rows: &dyn ExpertRows,
440        device: &mut dyn SlotDevice,
441    ) -> Result<Applied, SlotFault> {
442        self.apply_plan(layer, plan, false, rows, device)
443    }
444
445    /// Applies a
446    /// [`materialize_layer`](crate::expert_cache::ExpertCache::materialize_layer)
447    /// plan: the whole layer, slot `e` for expert `e`. This is the one
448    /// shape an unpinned layer accepts, because it needs no device
449    /// alias for the host rows.
450    pub fn apply_materialize(
451        &mut self,
452        layer: u32,
453        plan: &CopyPlan,
454        rows: &dyn ExpertRows,
455        device: &mut dyn SlotDevice,
456    ) -> Result<Applied, SlotFault> {
457        self.apply_plan(layer, plan, true, rows, device)
458    }
459
460    fn apply_plan(
461        &mut self,
462        layer: u32,
463        plan: &CopyPlan,
464        whole_layer: bool,
465        rows: &dyn ExpertRows,
466        device: &mut dyn SlotDevice,
467    ) -> Result<Applied, SlotFault> {
468        if plan.dst_slots.len() != plan.src_rows.len() {
469            return Err(SlotFault::PlanHalvesDisagree {
470                dst_slots: plan.dst_slots.len(),
471                src_rows: plan.src_rows.len(),
472            });
473        }
474        if layer as usize >= self.geometry.num_layers {
475            return Err(SlotFault::LayerOutOfRange {
476                layer,
477                num_layers: self.geometry.num_layers,
478            });
479        }
480        // Resolved even for an empty plan: a layer whose residency
481        // forbids this route has a configuration problem, and a step
482        // that happened to hit every expert is no evidence against it.
483        let route = self.residency.copy_route(layer, whole_layer)?;
484
485        self.validate_slots(&plan.dst_slots)?;
486        self.validate_rows(layer, &plan.src_rows, rows)?;
487
488        self.stats.plans += 1;
489        if plan.is_empty() {
490            self.stats.warm_plans += 1;
491            return Ok(Applied {
492                rows: 0,
493                bytes: 0,
494                warm: true,
495            });
496        }
497
498        device
499            .begin_plan(route)
500            .map_err(|detail| SlotFault::DeviceFlush {
501                slots: plan.dst_slots.clone(),
502                detail,
503            })?;
504
505        let mut applied = Applied::default();
506        for (&dst, &src) in plan.dst_slots.iter().zip(plan.src_rows.iter()) {
507            // The occupant is cleared *before* the write, so a device
508            // fault anywhere in this expert's banks leaves the slot
509            // unknown rather than half-labelled.
510            self.occupant[dst as usize] = None;
511            for bank in 0..self.geometry.banks() {
512                let bytes = rows
513                    .row(bank, layer, src)
514                    .expect("validated by validate_rows");
515                device
516                    .write_slot(bank, dst, bytes)
517                    .map_err(|detail| SlotFault::Device {
518                        bank,
519                        slot: dst,
520                        detail,
521                    })?;
522                applied.rows += 1;
523                applied.bytes += bytes.len() as u64;
524            }
525            self.occupant[dst as usize] = Some(ExpertId { layer, expert: src });
526        }
527        self.flush(device, &plan.dst_slots)?;
528
529        self.stats.host_rows += applied.rows;
530        self.stats.host_bytes += applied.bytes;
531        Ok(applied)
532    }
533
534    /// Completes the plan's copies, forgetting every slot it wrote if
535    /// the device cannot confirm they landed.
536    fn flush(&mut self, device: &mut dyn SlotDevice, written: &[u32]) -> Result<(), SlotFault> {
537        let Err(detail) = device.flush() else {
538            return Ok(());
539        };
540        for &slot in written {
541            self.invalidate_slot(slot);
542        }
543        Err(SlotFault::DeviceFlush {
544            slots: written.to_vec(),
545            detail,
546        })
547    }
548
549    /// Applies a [`GatherPlan`]: rows the prefill buffer can take from
550    /// slots that already hold them, rather than from the host.
551    ///
552    /// A source slot whose occupant is unknown is refused. The planner
553    /// believes it is resident; this pool knows a copy into it failed,
554    /// and gathering from it would propagate garbage into a second
555    /// slot while both are recorded as valid.
556    pub fn apply_gather_plan(
557        &mut self,
558        plan: &GatherPlan,
559        device: &mut dyn SlotDevice,
560    ) -> Result<Applied, SlotFault> {
561        if plan.dst_slots.len() != plan.src_slots.len() {
562            return Err(SlotFault::PlanHalvesDisagree {
563                dst_slots: plan.dst_slots.len(),
564                src_rows: plan.src_slots.len(),
565            });
566        }
567        self.validate_slots(&plan.dst_slots)?;
568        for &src in &plan.src_slots {
569            if src as usize >= self.geometry.slots {
570                return Err(SlotFault::SlotOutOfRange {
571                    slot: src,
572                    slots: self.geometry.slots,
573                });
574            }
575            if self.occupant(src).is_none() {
576                return Err(SlotFault::Device {
577                    bank: 0,
578                    slot: src,
579                    detail: "gather source holds no known expert; a failed copy would be \
580                             propagated into a second slot"
581                        .to_string(),
582                });
583            }
584        }
585
586        self.stats.plans += 1;
587        if plan.is_empty() {
588            self.stats.warm_plans += 1;
589            return Ok(Applied {
590                rows: 0,
591                bytes: 0,
592                warm: true,
593            });
594        }
595
596        let mut applied = Applied::default();
597        for (&dst, &src) in plan.dst_slots.iter().zip(plan.src_slots.iter()) {
598            let carried = self.occupant(src);
599            self.occupant[dst as usize] = None;
600            for bank in 0..self.geometry.banks() {
601                device
602                    .copy_slot(bank, dst, src)
603                    .map_err(|detail| SlotFault::Device {
604                        bank,
605                        slot: dst,
606                        detail,
607                    })?;
608                applied.rows += 1;
609                applied.bytes += self.geometry.row_bytes[bank] as u64;
610            }
611            self.occupant[dst as usize] = carried;
612        }
613        self.flush(device, &plan.dst_slots)?;
614
615        self.stats.device_rows += applied.rows;
616        self.stats.device_bytes += applied.bytes;
617        Ok(applied)
618    }
619
620    fn validate_slots(&self, slots: &[u32]) -> Result<(), SlotFault> {
621        for (i, &slot) in slots.iter().enumerate() {
622            if slot as usize >= self.geometry.slots {
623                return Err(SlotFault::SlotOutOfRange {
624                    slot,
625                    slots: self.geometry.slots,
626                });
627            }
628            // Quadratic, and deliberately so: a plan is one decode
629            // step's misses -- a handful of entries -- so a scan beats
630            // allocating a set, and this runs on the hot path.
631            if slots[..i].contains(&slot) {
632                return Err(SlotFault::SlotWrittenTwice { slot });
633            }
634        }
635        Ok(())
636    }
637
638    fn validate_rows(
639        &self,
640        layer: u32,
641        src_rows: &[u32],
642        rows: &dyn ExpertRows,
643    ) -> Result<(), SlotFault> {
644        for &row in src_rows {
645            for (bank, &expected) in self.geometry.row_bytes.iter().enumerate() {
646                let Some(bytes) = rows.row(bank, layer, row) else {
647                    return Err(SlotFault::RowMissing { bank, layer, row });
648                };
649                if bytes.len() != expected {
650                    return Err(SlotFault::RowSizeMismatch {
651                        bank,
652                        layer,
653                        row,
654                        expected,
655                        got: bytes.len(),
656                    });
657                }
658            }
659        }
660        Ok(())
661    }
662}
663
664/// A pool whose "device" is host memory.
665///
666/// A real implementation, not a stand-in: on a CPU-only build the
667/// expert pool *is* host memory and the link *is* a memcpy, so this is
668/// the backend that build uses. It is also what makes every rule above
669/// testable on a machine with no GPU, which is the whole reason the
670/// device lives behind a trait.
671pub struct HostSlotMemory {
672    banks: Vec<Vec<u8>>,
673    row_bytes: Vec<usize>,
674}
675
676impl HostSlotMemory {
677    pub fn new(geometry: &SlotGeometry) -> Self {
678        HostSlotMemory {
679            banks: geometry
680                .row_bytes
681                .iter()
682                .map(|b| vec![0u8; b * geometry.slots])
683                .collect(),
684            row_bytes: geometry.row_bytes.clone(),
685        }
686    }
687
688    /// The bytes one slot holds in one bank. This is what makes the
689    /// pool checkable: a test can assert the slot contains the expert
690    /// the plan promised, not merely that a copy was counted.
691    pub fn slot(&self, bank: usize, slot: u32) -> &[u8] {
692        let w = self.row_bytes[bank];
693        let at = w * slot as usize;
694        &self.banks[bank][at..at + w]
695    }
696}
697
698impl SlotDevice for HostSlotMemory {
699    fn write_slot(&mut self, bank: usize, dst_slot: u32, src: &[u8]) -> Result<(), String> {
700        let w = self.row_bytes[bank];
701        let at = w * dst_slot as usize;
702        self.banks[bank][at..at + w].copy_from_slice(src);
703        Ok(())
704    }
705
706    fn copy_slot(&mut self, bank: usize, dst_slot: u32, src_slot: u32) -> Result<(), String> {
707        let w = self.row_bytes[bank];
708        let (dst, src) = (w * dst_slot as usize, w * src_slot as usize);
709        self.banks[bank].copy_within(src..src + w, dst);
710        Ok(())
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use crate::expert_cache::ExpertCache;
718    use crate::residency::HostResidency;
719
720    const LAYERS: usize = 3;
721    const EXPERTS: usize = 8;
722    const GATE: usize = 6;
723    const UP: usize = 6;
724    const DOWN: usize = 4;
725
726    fn geometry(slots: usize) -> SlotGeometry {
727        SlotGeometry {
728            num_layers: LAYERS,
729            slots,
730            row_bytes: vec![GATE, UP, DOWN],
731        }
732    }
733
734    /// Host banks whose bytes name the expert they belong to, so a slot
735    /// can be checked for *which* expert it holds rather than only for
736    /// having been written.
737    struct NamedRows {
738        /// `[bank][layer * EXPERTS + row]`, materialized once so the
739        /// trait can hand out borrows.
740        banks: Vec<Vec<Vec<u8>>>,
741    }
742
743    impl NamedRows {
744        fn new() -> Self {
745            let banks = [GATE, UP, DOWN]
746                .iter()
747                .enumerate()
748                .map(|(bank, &width)| {
749                    (0..LAYERS as u32)
750                        .flat_map(|layer| {
751                            (0..EXPERTS as u32).map(move |row| named_row(bank, width, layer, row))
752                        })
753                        .collect()
754                })
755                .collect();
756            NamedRows { banks }
757        }
758
759        fn expected(&self, bank: usize, layer: u32, row: u32) -> &[u8] {
760            &self.banks[bank][layer as usize * EXPERTS + row as usize]
761        }
762    }
763
764    /// Bytes that name the expert they belong to, so a slot can be
765    /// checked for *which* expert it holds rather than only for having
766    /// been written.
767    fn named_row(bank: usize, width: usize, layer: u32, row: u32) -> Vec<u8> {
768        (0..width)
769            .map(|i| {
770                (bank as u8 + 1)
771                    .wrapping_mul(37)
772                    .wrapping_add(layer as u8)
773                    .wrapping_add((row as u8) << 3)
774                    ^ i as u8
775            })
776            .collect()
777    }
778
779    impl ExpertRows for NamedRows {
780        fn row(&self, bank: usize, layer: u32, row: u32) -> Option<&[u8]> {
781            if bank >= self.banks.len() || layer as usize >= LAYERS || row as usize >= EXPERTS {
782                return None;
783            }
784            Some(self.expected(bank, layer, row))
785        }
786    }
787
788    /// The property real expert offload is judged on, and the one that
789    /// was unverifiable while nothing executed a plan: once the working
790    /// set is resident, a decode step copies **zero** bytes, and the
791    /// counter says so.
792    #[test]
793    fn a_step_that_hits_the_cache_copies_nothing() {
794        let mut cache = ExpertCache::new(LAYERS, EXPERTS, 32);
795        let mut slots = ExpertSlots::new(geometry(32)).unwrap();
796        let rows = NamedRows::new();
797        let mut device = HostSlotMemory::new(slots.geometry());
798
799        let routed = [1u32, 4, 6];
800        let cold = cache.ensure(0, &routed);
801        let first = slots
802            .apply_copy_plan(0, &cold.copy, &rows, &mut device)
803            .unwrap();
804        assert!(!first.warm);
805        assert_eq!(first.rows, 3 * 3, "three experts across three banks");
806        assert_eq!(first.bytes as usize, 3 * (GATE + UP + DOWN));
807
808        let before = slots.stats();
809        for _ in 0..10 {
810            let warm = cache.ensure(0, &routed);
811            assert!(warm.copy.is_empty(), "the cache should report every hit");
812            let applied = slots
813                .apply_copy_plan(0, &warm.copy, &rows, &mut device)
814                .unwrap();
815            assert!(applied.warm);
816            assert_eq!(applied.bytes, 0);
817        }
818        let after = slots.stats();
819        assert_eq!(
820            after.host_bytes, before.host_bytes,
821            "ten warm steps moved bytes"
822        );
823        assert_eq!(after.warm_plans, before.warm_plans + 10);
824        assert_eq!(after.warm_plan_rate(), 10.0 / 11.0);
825    }
826
827    /// The plan is not merely counted: the slot the planner named holds
828    /// the expert it named, in every bank. Counting a copy proves a
829    /// copy happened, not that it went to the right place.
830    #[test]
831    fn every_slot_holds_the_expert_the_plan_promised() {
832        let mut cache = ExpertCache::new(LAYERS, EXPERTS, 16);
833        let mut slots = ExpertSlots::new(geometry(16)).unwrap();
834        let rows = NamedRows::new();
835        let mut device = HostSlotMemory::new(slots.geometry());
836
837        for layer in 0..LAYERS as u32 {
838            let routed: Vec<u32> = (0..4).map(|e| (e + layer) % EXPERTS as u32).collect();
839            let plan = cache.ensure(layer, &routed);
840            slots
841                .apply_copy_plan(layer, &plan.copy, &rows, &mut device)
842                .unwrap();
843
844            for (&expert, slot) in routed.iter().zip(plan.slots.iter()) {
845                let slot = slot.expect("pure offload places every route");
846                assert_eq!(
847                    slots.occupant(slot),
848                    Some(ExpertId { layer, expert }),
849                    "layer {layer} expert {expert}"
850                );
851                for bank in 0..3 {
852                    assert_eq!(
853                        device.slot(bank, slot),
854                        rows.expected(bank, layer, expert),
855                        "layer {layer} expert {expert} bank {bank}"
856                    );
857                }
858            }
859        }
860        assert_eq!(slots.occupied(), 12, "three layers of four experts");
861    }
862
863    /// An eviction must overwrite the slot's bytes, not merely its
864    /// label. A pool that relabels without copying reports a hit for
865    /// the new expert and multiplies the old one's weights.
866    #[test]
867    fn an_evicted_slot_is_overwritten_and_not_merely_relabelled() {
868        // Two slots, two experts per layer, two layers: layer 1's
869        // routes can only land on slots layer 0 already holds. A cache
870        // must be able to hold one whole layer, so forcing eviction
871        // takes a second layer rather than a smaller cache.
872        let mut cache = ExpertCache::new(2, 2, 2);
873        let mut slots = ExpertSlots::new(SlotGeometry {
874            num_layers: 2,
875            slots: 2,
876            row_bytes: vec![GATE, UP, DOWN],
877        })
878        .unwrap();
879        let rows = NamedRows::new();
880        let mut device = HostSlotMemory::new(slots.geometry());
881
882        let first = cache.ensure(0, &[0, 1]);
883        slots
884            .apply_copy_plan(0, &first.copy, &rows, &mut device)
885            .unwrap();
886        let second = cache.ensure(1, &[0, 1]);
887        assert_eq!(second.missing, 2, "layer 1 must evict layer 0");
888        slots
889            .apply_copy_plan(1, &second.copy, &rows, &mut device)
890            .unwrap();
891
892        for (&expert, slot) in [0u32, 1].iter().zip(second.slots.iter()) {
893            let slot = slot.unwrap();
894            assert_eq!(slots.occupant(slot), Some(ExpertId { layer: 1, expert }));
895            assert_eq!(
896                device.slot(0, slot),
897                rows.expected(0, 1, expert),
898                "the slot must hold layer 1's bytes, not layer 0's"
899            );
900        }
901        assert_eq!(cache.slot_of(0, 0), None, "layer 0's expert 0 was evicted");
902    }
903
904    /// A plan whose halves disagree is refused before anything is
905    /// written. Pair `i` would not be a pair, so applying it loads
906    /// experts into each other's slots -- which does not fail, it
907    /// returns a confident wrong answer.
908    #[test]
909    fn a_plan_whose_halves_disagree_is_refused_untouched() {
910        let mut slots = ExpertSlots::new(geometry(8)).unwrap();
911        let rows = NamedRows::new();
912        let mut device = HostSlotMemory::new(slots.geometry());
913        let plan = CopyPlan {
914            dst_slots: vec![0, 1, 2],
915            src_rows: vec![0, 1],
916        };
917        assert_eq!(
918            slots.apply_copy_plan(0, &plan, &rows, &mut device),
919            Err(SlotFault::PlanHalvesDisagree {
920                dst_slots: 3,
921                src_rows: 2,
922            })
923        );
924        assert_eq!(slots.occupied(), 0);
925        assert_eq!(
926            slots.stats().plans,
927            0,
928            "a refused plan is not an applied one"
929        );
930    }
931
932    /// Two writes to one slot in one plan: the second wins and the
933    /// first expert is absent from the slot the plan promised it in,
934    /// while the planner records both as resident.
935    #[test]
936    fn a_plan_that_writes_one_slot_twice_is_refused() {
937        let mut slots = ExpertSlots::new(geometry(8)).unwrap();
938        let rows = NamedRows::new();
939        let mut device = HostSlotMemory::new(slots.geometry());
940        let plan = CopyPlan {
941            dst_slots: vec![3, 1, 3],
942            src_rows: vec![0, 1, 2],
943        };
944        assert_eq!(
945            slots.apply_copy_plan(0, &plan, &rows, &mut device),
946            Err(SlotFault::SlotWrittenTwice { slot: 3 })
947        );
948        assert_eq!(slots.occupied(), 0);
949    }
950
951    /// A planner built with a larger cache than the pool names slots
952    /// that do not exist. Refused rather than clamped: clamping would
953    /// silently place two experts in one slot.
954    #[test]
955    fn a_slot_the_pool_does_not_have_is_refused() {
956        let mut slots = ExpertSlots::new(geometry(4)).unwrap();
957        let rows = NamedRows::new();
958        let mut device = HostSlotMemory::new(slots.geometry());
959        let plan = CopyPlan {
960            dst_slots: vec![0, 9],
961            src_rows: vec![0, 1],
962        };
963        assert_eq!(
964            slots.apply_copy_plan(0, &plan, &rows, &mut device),
965            Err(SlotFault::SlotOutOfRange { slot: 9, slots: 4 })
966        );
967        assert_eq!(slots.occupied(), 0);
968    }
969
970    /// A row that is not exactly one slot wide would leave the tail of
971    /// the slot holding the previous occupant: an expert spliced from
972    /// two, which produces plausible tokens rather than an error.
973    #[test]
974    fn a_row_that_is_not_exactly_one_slot_wide_is_refused() {
975        struct ShortDownBank;
976        impl ExpertRows for ShortDownBank {
977            fn row(&self, bank: usize, _layer: u32, _row: u32) -> Option<&[u8]> {
978                match bank {
979                    0 => Some(&[0u8; GATE]),
980                    1 => Some(&[0u8; UP]),
981                    _ => Some(&[0u8; DOWN - 1]),
982                }
983            }
984        }
985        let mut slots = ExpertSlots::new(geometry(8)).unwrap();
986        let mut device = HostSlotMemory::new(slots.geometry());
987        let plan = CopyPlan {
988            dst_slots: vec![0],
989            src_rows: vec![5],
990        };
991        assert_eq!(
992            slots.apply_copy_plan(0, &plan, &ShortDownBank, &mut device),
993            Err(SlotFault::RowSizeMismatch {
994                bank: 2,
995                layer: 0,
996                row: 5,
997                expected: DOWN,
998                got: DOWN - 1,
999            })
1000        );
1001        assert_eq!(slots.occupied(), 0, "nothing was written");
1002    }
1003
1004    /// A missing row is refused with the bank that lacks it, so the
1005    /// caller learns which bank is short rather than that "a copy
1006    /// failed".
1007    #[test]
1008    fn a_missing_host_row_names_its_bank() {
1009        let mut slots = ExpertSlots::new(geometry(8)).unwrap();
1010        let rows = NamedRows::new();
1011        let mut device = HostSlotMemory::new(slots.geometry());
1012        let plan = CopyPlan {
1013            dst_slots: vec![0],
1014            src_rows: vec![EXPERTS as u32],
1015        };
1016        assert_eq!(
1017            slots.apply_copy_plan(0, &plan, &rows, &mut device),
1018            Err(SlotFault::RowMissing {
1019                bank: 0,
1020                layer: 0,
1021                row: EXPERTS as u32,
1022            })
1023        );
1024    }
1025
1026    /// The failure the executor makes reachable. `copy_route` and
1027    /// `SlotRemapOnUnpinnedLayer` were written for exactly this and had
1028    /// no caller: an LRU slot remap needs a device alias for the host
1029    /// rows, which an unpinned layer does not have. The whole-layer
1030    /// materialize is the one shape it does accept.
1031    #[test]
1032    fn an_unpinned_layer_refuses_an_lru_remap_but_takes_a_materialize() {
1033        // Layer 1 is pageable, and so is on the CPU executor -- the
1034        // pairing `BankResidency::new` insists on, since an unpinned
1035        // layer the GPU cannot see must be computed somewhere.
1036        let residency = BankResidency::new(
1037            &[
1038                HostResidency::Pinned,
1039                HostResidency::Pageable,
1040                HostResidency::Pinned,
1041            ],
1042            LAYERS,
1043            &[1u32].into_iter().collect(),
1044            false,
1045        )
1046        .unwrap();
1047        let mut slots = ExpertSlots::new(geometry(EXPERTS * LAYERS))
1048            .unwrap()
1049            .with_residency(residency);
1050        let rows = NamedRows::new();
1051        let mut device = HostSlotMemory::new(slots.geometry());
1052
1053        let remap = CopyPlan {
1054            dst_slots: vec![0],
1055            src_rows: vec![3],
1056        };
1057        assert!(matches!(
1058            slots.apply_copy_plan(1, &remap, &rows, &mut device),
1059            Err(SlotFault::Residency(
1060                ResidencyError::SlotRemapOnUnpinnedLayer { layer: 1 }
1061            ))
1062        ));
1063
1064        let whole = CopyPlan {
1065            dst_slots: (0..EXPERTS as u32).collect(),
1066            src_rows: (0..EXPERTS as u32).collect(),
1067        };
1068        let applied = slots
1069            .apply_materialize(1, &whole, &rows, &mut device)
1070            .unwrap();
1071        assert_eq!(applied.rows, EXPERTS as u64 * 3);
1072
1073        // A pinned layer takes either route.
1074        assert!(slots.apply_copy_plan(0, &remap, &rows, &mut device).is_ok());
1075    }
1076
1077    /// A device fault leaves the slot marked unknown rather than
1078    /// labelled with the expert whose copy failed. Reading it back as
1079    /// residency is the failure this prevents; the error names the slot
1080    /// so the planner can be told to forget it too.
1081    #[test]
1082    fn a_device_fault_leaves_its_slot_unknown_and_names_it() {
1083        struct FailsOnDownBank;
1084        impl SlotDevice for FailsOnDownBank {
1085            fn write_slot(&mut self, bank: usize, _d: u32, _s: &[u8]) -> Result<(), String> {
1086                if bank == 2 {
1087                    return Err("out of device memory".to_string());
1088                }
1089                Ok(())
1090            }
1091            fn copy_slot(&mut self, _b: usize, _d: u32, _s: u32) -> Result<(), String> {
1092                Ok(())
1093            }
1094        }
1095        let mut slots = ExpertSlots::new(geometry(8)).unwrap();
1096        let rows = NamedRows::new();
1097        let plan = CopyPlan {
1098            dst_slots: vec![5],
1099            src_rows: vec![2],
1100        };
1101        let err = slots
1102            .apply_copy_plan(0, &plan, &rows, &mut FailsOnDownBank)
1103            .unwrap_err();
1104        assert_eq!(
1105            err,
1106            SlotFault::Device {
1107                bank: 2,
1108                slot: 5,
1109                detail: "out of device memory".to_string(),
1110            }
1111        );
1112        assert_eq!(
1113            slots.occupant(5),
1114            None,
1115            "a slot whose copy failed must not read back as resident"
1116        );
1117    }
1118
1119    /// A flush that fails must fail the plan, and must forget **every**
1120    /// slot the plan wrote -- not just the last one.
1121    ///
1122    /// A backend that batches its copies reports each one as issued and
1123    /// only discovers at flush that they did not land, and it cannot
1124    /// say which. Blaming one slot would leave the others reading back
1125    /// as resident while holding whatever the failed transfer left.
1126    #[test]
1127    fn a_failing_flush_forgets_every_slot_the_plan_wrote() {
1128        struct FlushFails;
1129        impl SlotDevice for FlushFails {
1130            fn write_slot(&mut self, _b: usize, _d: u32, _s: &[u8]) -> Result<(), String> {
1131                Ok(())
1132            }
1133            fn copy_slot(&mut self, _b: usize, _d: u32, _s: u32) -> Result<(), String> {
1134                Ok(())
1135            }
1136            fn flush(&mut self) -> Result<(), String> {
1137                Err("copy engine reported an error".to_string())
1138            }
1139        }
1140        let mut slots = ExpertSlots::new(geometry(8)).unwrap();
1141        let rows = NamedRows::new();
1142        let plan = CopyPlan {
1143            dst_slots: vec![1, 4, 6],
1144            src_rows: vec![0, 2, 3],
1145        };
1146        assert_eq!(
1147            slots.apply_copy_plan(0, &plan, &rows, &mut FlushFails),
1148            Err(SlotFault::DeviceFlush {
1149                slots: vec![1, 4, 6],
1150                detail: "copy engine reported an error".to_string(),
1151            })
1152        );
1153        assert_eq!(
1154            slots.occupied(),
1155            0,
1156            "no slot may read back as resident after an unconfirmed flush"
1157        );
1158    }
1159
1160    /// A gather moves bytes that are already across the link, and the
1161    /// counters keep it separate from host traffic: a link budget is
1162    /// spent by the host side alone, so folding the two together would
1163    /// report a device-to-device copy as bandwidth consumed.
1164    #[test]
1165    fn a_gather_is_counted_separately_from_host_traffic() {
1166        let mut slots = ExpertSlots::new(geometry(8)).unwrap();
1167        let rows = NamedRows::new();
1168        let mut device = HostSlotMemory::new(slots.geometry());
1169        slots
1170            .apply_copy_plan(
1171                0,
1172                &CopyPlan {
1173                    dst_slots: vec![4],
1174                    src_rows: vec![6],
1175                },
1176                &rows,
1177                &mut device,
1178            )
1179            .unwrap();
1180
1181        let gather = GatherPlan {
1182            dst_slots: vec![1],
1183            src_slots: vec![4],
1184        };
1185        let applied = slots.apply_gather_plan(&gather, &mut device).unwrap();
1186        assert_eq!(applied.rows, 3);
1187        assert_eq!(applied.bytes as usize, GATE + UP + DOWN);
1188
1189        let stats = slots.stats();
1190        assert_eq!(stats.device_bytes as usize, GATE + UP + DOWN);
1191        assert_eq!(
1192            stats.host_bytes as usize,
1193            GATE + UP + DOWN,
1194            "the gather must not be billed to the link"
1195        );
1196        assert_eq!(
1197            slots.occupant(1),
1198            Some(ExpertId {
1199                layer: 0,
1200                expert: 6
1201            }),
1202            "the gathered slot carries the source's identity"
1203        );
1204        assert_eq!(device.slot(1, 1), rows.expected(1, 0, 6));
1205    }
1206
1207    /// Gathering from a slot this pool knows nothing about is refused.
1208    /// The planner believes it is resident; the pool knows a copy into
1209    /// it failed, and a gather would launder that garbage into a second
1210    /// slot that both then record as valid.
1211    #[test]
1212    fn a_gather_from_an_unknown_slot_is_refused() {
1213        let mut slots = ExpertSlots::new(geometry(8)).unwrap();
1214        let mut device = HostSlotMemory::new(slots.geometry());
1215        let gather = GatherPlan {
1216            dst_slots: vec![0],
1217            src_slots: vec![7],
1218        };
1219        assert!(matches!(
1220            slots.apply_gather_plan(&gather, &mut device),
1221            Err(SlotFault::Device { slot: 7, .. })
1222        ));
1223    }
1224
1225    /// A resize drops residency, because a slot id is a position in an
1226    /// allocation that no longer exists. The counters survive: they
1227    /// describe traffic that really happened.
1228    #[test]
1229    fn a_resize_drops_residency_but_keeps_the_counters() {
1230        let mut slots = ExpertSlots::new(geometry(8)).unwrap();
1231        let rows = NamedRows::new();
1232        let mut device = HostSlotMemory::new(slots.geometry());
1233        slots
1234            .apply_copy_plan(
1235                0,
1236                &CopyPlan {
1237                    dst_slots: vec![0, 1],
1238                    src_rows: vec![0, 1],
1239                },
1240                &rows,
1241                &mut device,
1242            )
1243            .unwrap();
1244        let before = slots.stats();
1245        assert_eq!(slots.occupied(), 2);
1246
1247        slots.resize(64).unwrap();
1248        assert_eq!(slots.occupied(), 0);
1249        assert_eq!(slots.geometry().slots, 64);
1250        assert_eq!(slots.stats(), before);
1251        assert_eq!(
1252            slots.resize(0),
1253            Err(SlotFault::SlotOutOfRange { slot: 0, slots: 0 })
1254        );
1255    }
1256
1257    /// A zero-width bank would make every size check pass and every
1258    /// copy a no-op, so the pool would report warm plans forever while
1259    /// holding nothing. Refused at construction.
1260    #[test]
1261    fn a_degenerate_geometry_is_refused_at_construction() {
1262        assert!(ExpertSlots::new(geometry(0)).is_err());
1263        assert!(ExpertSlots::new(SlotGeometry {
1264            num_layers: 0,
1265            slots: 4,
1266            row_bytes: vec![GATE],
1267        })
1268        .is_err());
1269        assert!(ExpertSlots::new(SlotGeometry {
1270            num_layers: 1,
1271            slots: 4,
1272            row_bytes: vec![GATE, 0],
1273        })
1274        .is_err());
1275    }
1276
1277    /// The pool's device footprint, which a VRAM budget is checked
1278    /// against before anything is allocated.
1279    #[test]
1280    fn the_geometry_reports_the_device_bytes_it_needs() {
1281        let g = geometry(1024);
1282        assert_eq!(g.bytes(), 1024 * (GATE + UP + DOWN) as u64);
1283        assert_eq!(g.banks(), 3);
1284    }
1285
1286    /// Forgetting a slot in the planner and in the pool must leave the
1287    /// two agreeing: the next step treats it as a miss and re-fetches,
1288    /// rather than reading a slot whose copy failed.
1289    #[test]
1290    fn a_forgotten_slot_is_refetched_by_the_next_step() {
1291        let mut cache = ExpertCache::new(1, EXPERTS, 8);
1292        let mut slots = ExpertSlots::new(SlotGeometry {
1293            num_layers: 1,
1294            slots: 8,
1295            row_bytes: vec![GATE, UP, DOWN],
1296        })
1297        .unwrap();
1298        let rows = NamedRows::new();
1299        let mut device = HostSlotMemory::new(slots.geometry());
1300
1301        let plan = cache.ensure(0, &[2]);
1302        slots
1303            .apply_copy_plan(0, &plan.copy, &rows, &mut device)
1304            .unwrap();
1305        let slot = plan.slots[0].unwrap();
1306
1307        assert_eq!(
1308            cache.forget_slot(slot),
1309            Some(ExpertId {
1310                layer: 0,
1311                expert: 2
1312            })
1313        );
1314        slots.invalidate_slot(slot);
1315
1316        let again = cache.ensure(0, &[2]);
1317        assert_eq!(again.missing, 1, "a forgotten expert must miss");
1318        assert!(!again.copy.is_empty(), "and must be re-fetched");
1319        slots
1320            .apply_copy_plan(0, &again.copy, &rows, &mut device)
1321            .unwrap();
1322        assert_eq!(
1323            slots.occupant(again.slots[0].unwrap()),
1324            Some(ExpertId {
1325                layer: 0,
1326                expert: 2
1327            })
1328        );
1329        assert_eq!(
1330            again.slots[0],
1331            Some(slot),
1332            "a forgotten slot is the first candidate, so the re-fetch reclaims \
1333             it rather than spending a slot that really holds an expert"
1334        );
1335
1336        // Forgetting a slot that holds nothing is a no-op, so a caller
1337        // recovering from a fault need not first work out whether the
1338        // copy got far enough to be recorded.
1339        let empty = (0..8).find(|&s| cache.resident_in(s).is_none()).unwrap();
1340        assert_eq!(cache.forget_slot(empty), None);
1341        assert_eq!(cache.forget_slot(9_999), None, "and an unknown slot too");
1342    }
1343}