Skip to main content

rlx_runtime/
hot_expert_cache.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Hot-expert device slot cache for MoE offload (the "hotstore" of the
6//! hot-on-GPU / cold-on-CPU split).
7//!
8//! One [`HotExpertCache`] models a single MoE layer's expert projection stack:
9//! `num_slots` device-resident slots drawn from `num_experts` total experts. An
10//! [`crate::ExpertPool`] chooses *which* experts are hot (by usage heatmap +
11//! hysteresis); this cache tracks *where* each hot expert physically lives on the
12//! device (its slot) and emits the minimal set of host→device copies
13//! ([`SlotLoad`]) needed to bring the slots in line with the pool's resident set.
14//!
15//! Backend-agnostic on purpose: the runtime owns the slot↔expert bookkeeping and
16//! the routing split; a backend (CUDA / ROCm) owns the actual device buffers, the
17//! H2D copies named by [`SlotLoad`], and the grouped-matmul kernel that runs over
18//! the slot buffer. Cold experts (no slot) never occupy device memory — that is
19//! where the VRAM saving comes from — and are computed on the host from the full
20//! [`crate::MoeExpertStore`].
21
22/// One host→device load: copy `expert`'s weights into device `slot`, overwriting
23/// whatever `evicted` expert previously occupied it (`None` if the slot was empty).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct SlotLoad {
26    pub slot: usize,
27    pub expert: usize,
28    pub evicted: Option<usize>,
29}
30
31/// Per-token routing after the slot split: `slot_idx[t]` is the device slot for a
32/// token whose expert is resident, or `cold_sentinel` when the token's expert is
33/// cold (to be computed on the host). `cold` lists `(token_index, global_expert)`
34/// for every cold token so the host path can gather/scatter them.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SlotRoute {
37    pub slot_idx: Vec<u32>,
38    pub cold: Vec<(usize, usize)>,
39}
40
41/// Device slot cache for one MoE layer.
42#[derive(Debug, Clone)]
43pub struct HotExpertCache {
44    num_experts: usize,
45    num_slots: usize,
46    /// slot -> expert currently loaded (`None` = empty slot).
47    slot_to_expert: Vec<Option<usize>>,
48    /// expert -> slot (`None` = cold / not device-resident).
49    expert_to_slot: Vec<Option<usize>>,
50}
51
52impl HotExpertCache {
53    /// New cache with the first `min(num_slots, num_experts)` experts pre-loaded,
54    /// matching [`ExpertPool::new`](crate::ExpertPool)'s initial residency so the
55    /// two structures start consistent (no spurious first-refresh reload).
56    pub fn new(num_experts: usize, num_slots: usize) -> Self {
57        let num_slots = num_slots.min(num_experts);
58        let mut slot_to_expert = vec![None; num_slots];
59        let mut expert_to_slot = vec![None; num_experts];
60        for s in 0..num_slots {
61            slot_to_expert[s] = Some(s);
62            expert_to_slot[s] = Some(s);
63        }
64        Self {
65            num_experts,
66            num_slots,
67            slot_to_expert,
68            expert_to_slot,
69        }
70    }
71
72    pub fn num_experts(&self) -> usize {
73        self.num_experts
74    }
75
76    pub fn num_slots(&self) -> usize {
77        self.num_slots
78    }
79
80    /// Device slot holding `expert`, or `None` if the expert is cold.
81    pub fn slot_of(&self, expert: usize) -> Option<usize> {
82        self.expert_to_slot.get(expert).copied().flatten()
83    }
84
85    /// Whether `expert` currently occupies a device slot.
86    pub fn is_resident(&self, expert: usize) -> bool {
87        self.slot_of(expert).is_some()
88    }
89
90    /// slot -> expert table (`None` = empty slot).
91    pub fn slot_to_expert(&self) -> &[Option<usize>] {
92        &self.slot_to_expert
93    }
94
95    /// expert -> slot table (`None` = cold).
96    pub fn expert_to_slot(&self) -> &[Option<usize>] {
97        &self.expert_to_slot
98    }
99
100    /// Bring device slots in line with `resident_mask` (typically
101    /// [`ExpertPool::resident_mask`](crate::ExpertPool::resident_mask)), keeping
102    /// experts that stay resident in their existing slot (no reload). Returns the
103    /// minimal set of host→device loads for newly-resident experts.
104    ///
105    /// Two clean passes so leftover slots (when fewer experts are hot than there
106    /// are slots) are always left consistently empty rather than dangling on a
107    /// now-cold expert.
108    pub fn reconcile(&mut self, resident_mask: &[bool]) -> Vec<SlotLoad> {
109        debug_assert_eq!(resident_mask.len(), self.num_experts);
110        let resident = |e: usize| resident_mask.get(e).copied().unwrap_or(false);
111
112        // Pass 1: free slots holding a now-cold expert (or already empty),
113        // remembering the prior occupant for the `evicted` field.
114        let mut free_slots: Vec<(usize, Option<usize>)> = Vec::new();
115        for slot in 0..self.num_slots {
116            match self.slot_to_expert[slot] {
117                Some(e) if resident(e) => {} // stays put
118                Some(e) => {
119                    self.expert_to_slot[e] = None;
120                    self.slot_to_expert[slot] = None;
121                    free_slots.push((slot, Some(e)));
122                }
123                None => free_slots.push((slot, None)),
124            }
125        }
126
127        // Newly-resident experts needing a slot (ascending id for determinism).
128        let newly: Vec<usize> = (0..self.num_experts)
129            .filter(|&e| resident(e) && self.expert_to_slot[e].is_none())
130            .collect();
131        debug_assert!(
132            newly.len() <= free_slots.len(),
133            "resident_mask has more hot experts ({}) than free slots ({})",
134            newly.len(),
135            free_slots.len()
136        );
137
138        // Pass 2: assign each newly-hot expert to a freed slot, emitting the load.
139        let mut loads = Vec::with_capacity(newly.len());
140        for (idx, &e) in newly.iter().enumerate() {
141            let Some(&(slot, evicted)) = free_slots.get(idx) else {
142                break;
143            };
144            self.slot_to_expert[slot] = Some(e);
145            self.expert_to_slot[e] = Some(slot);
146            loads.push(SlotLoad {
147                slot,
148                expert: e,
149                evicted,
150            });
151        }
152        loads
153    }
154
155    /// Split per-token routed expert indices into slot indices + a cold-token list.
156    /// Resident tokens get their device slot; cold tokens get `cold_sentinel` in
157    /// `slot_idx` and an entry in `cold`.
158    pub fn route(&self, expert_idx: &[u32], cold_sentinel: u32) -> SlotRoute {
159        let mut slot_idx = Vec::with_capacity(expert_idx.len());
160        let mut cold = Vec::new();
161        for (t, &e) in expert_idx.iter().enumerate() {
162            let e = e as usize;
163            match self.slot_of(e) {
164                Some(slot) => slot_idx.push(slot as u32),
165                None => {
166                    slot_idx.push(cold_sentinel);
167                    cold.push((t, e));
168                }
169            }
170        }
171        SlotRoute { slot_idx, cold }
172    }
173}
174
175/// Reconcile a whole model's per-layer caches against per-layer resident masks
176/// (e.g. `pools.iter().map(|p| p.resident_mask())`). Returns per-layer load lists.
177pub fn reconcile_layers(caches: &mut [HotExpertCache], masks: &[Vec<bool>]) -> Vec<Vec<SlotLoad>> {
178    caches
179        .iter_mut()
180        .zip(masks.iter())
181        .map(|(cache, mask)| cache.reconcile(mask))
182        .collect()
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::{ExpertPool, ExpertPoolConfig, ExpertRefreshPolicy};
189
190    #[test]
191    fn starts_consistent_with_expert_pool() {
192        let cache = HotExpertCache::new(8, 3);
193        // Experts 0,1,2 pre-loaded into slots 0,1,2 (mirrors ExpertPool::new).
194        assert_eq!(cache.slot_of(0), Some(0));
195        assert_eq!(cache.slot_of(2), Some(2));
196        assert_eq!(cache.slot_of(3), None);
197        assert!(cache.is_resident(1));
198        assert!(!cache.is_resident(7));
199    }
200
201    #[test]
202    fn reconcile_keeps_stable_experts_in_place() {
203        let mut cache = HotExpertCache::new(8, 3); // slots: 0->0,1->1,2->2
204        // New hot set {1,2,5}: expert 0 leaves, expert 5 arrives; 1,2 stay put.
205        let mut mask = vec![false; 8];
206        for e in [1, 2, 5] {
207            mask[e] = true;
208        }
209        let loads = cache.reconcile(&mask);
210        // Only one load: expert 5 into the slot freed by expert 0 (slot 0).
211        assert_eq!(
212            loads,
213            vec![SlotLoad {
214                slot: 0,
215                expert: 5,
216                evicted: Some(0),
217            }]
218        );
219        assert_eq!(cache.slot_of(1), Some(1), "stable expert keeps its slot");
220        assert_eq!(cache.slot_of(2), Some(2), "stable expert keeps its slot");
221        assert_eq!(cache.slot_of(5), Some(0));
222        assert_eq!(cache.slot_of(0), None);
223    }
224
225    #[test]
226    fn reconcile_leaves_extra_slots_empty_and_consistent() {
227        let mut cache = HotExpertCache::new(8, 4); // slots 0..4 -> experts 0..4
228        // Only two experts are hot now.
229        let mut mask = vec![false; 8];
230        mask[6] = true;
231        mask[1] = true; // 1 already resident (slot 1)
232        let loads = cache.reconcile(&mask);
233        // Expert 6 loads into the first freed slot (slot 0, evicting expert 0).
234        assert_eq!(loads.len(), 1);
235        assert_eq!(loads[0].expert, 6);
236        assert!(cache.is_resident(1) && cache.is_resident(6));
237        // Every cold expert must have no slot (no dangling expert_to_slot entries).
238        for e in [0, 2, 3, 4, 5, 7] {
239            assert!(
240                !cache.is_resident(e),
241                "cold expert {e} still marked resident"
242            );
243        }
244        // Slot table has exactly two occupied slots.
245        let occupied = cache
246            .slot_to_expert()
247            .iter()
248            .filter(|s| s.is_some())
249            .count();
250        assert_eq!(occupied, 2);
251    }
252
253    #[test]
254    fn route_splits_hot_and_cold_tokens() {
255        let mut cache = HotExpertCache::new(8, 3);
256        let mask = {
257            let mut m = vec![false; 8];
258            for e in [1, 2, 5] {
259                m[e] = true;
260            }
261            m
262        };
263        cache.reconcile(&mask); // 1->1, 2->2, 5->0
264        // Tokens routed to experts [5, 3, 1, 7, 2]; 3 and 7 are cold.
265        let r = cache.route(&[5, 3, 1, 7, 2], u32::MAX);
266        assert_eq!(r.slot_idx, vec![0, u32::MAX, 1, u32::MAX, 2]);
267        assert_eq!(r.cold, vec![(1, 3), (3, 7)]);
268    }
269
270    #[test]
271    fn tracks_expert_pool_placement_end_to_end() {
272        // The cache should exactly mirror an ExpertPool's resident set after each
273        // heatmap refresh, with bounded loads.
274        let mut pool = ExpertPool::new(ExpertPoolConfig::new(
275            8,
276            3,
277            ExpertRefreshPolicy::EveryForward,
278        ));
279        let mut cache = HotExpertCache::new(8, 3);
280
281        // Route heavily to experts 4,5,6 → they should become resident.
282        pool.refresh_from_indices(&[4, 4, 5, 5, 6, 6, 0]);
283        let loads = reconcile_layers(&mut [cache.clone()], &[pool.resident_mask()]);
284        cache.reconcile(&pool.resident_mask());
285        // Cache residency must equal pool residency.
286        for e in 0..8 {
287            assert_eq!(
288                cache.is_resident(e),
289                pool.is_gpu_resident(e),
290                "expert {e} residency mismatch"
291            );
292        }
293        assert!(!loads[0].is_empty());
294    }
295}