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}