Skip to main content

rlx_runtime/
expert_pool.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//! MoE expert residency pool (TIDE-style predictive offload).
6//!
7//! Mirrors the policy in [ims-kdks/TIDE](https://github.com/ims-kdks/TIDE)
8//! `LLaDA2MoeSparseMoeBlock`: rank experts by token hits, refresh placement
9//! every τ steps, paired promote/demote to limit PCIe churn.
10//!
11//! Router logits and expert indices are unchanged — placement only.
12
13use std::collections::{HashMap, HashSet};
14
15/// When to re-run hit counting and expert placement.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ExpertRefreshPolicy {
18    /// Refresh on every forward (τ = 1; Mixtral-Offload-style).
19    EveryForward,
20    /// Autoregressive decode: refresh every N generated tokens / steps.
21    EveryDecodeSteps(usize),
22    /// Diffusion block decode: refresh every N denoise steps within a block
23    /// (`jump_steps` in the TIDE reference repo).
24    EveryDenoiseSteps(usize),
25}
26
27impl Default for ExpertRefreshPolicy {
28    fn default() -> Self {
29        Self::EveryDenoiseSteps(1)
30    }
31}
32
33/// Per-forward hint from the runner (maps to TIDE `refresh_experts`).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum MoEExecMode {
36    /// Reuse current GPU/CPU placement (`moe_infer`).
37    Reuse,
38    /// Recompute placement from this step's routing (`moe_infer_with_expert_refresh`).
39    Refresh,
40}
41
42/// Anti-thrash policy for slot swaps (mirrors the llama.cpp hot-expert cache
43/// hysteresis gate + dwell). Both knobs default to *off* so existing callers keep
44/// the plain top-S paired-swap behavior.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct HysteresisConfig {
47    /// A challenger expert must exceed an incumbent's hit count by this *fraction*
48    /// before it may evict it (e.g. `0.25` = "must be ≥25% hotter"). `0.0` disables
49    /// the margin so any strictly-hotter expert wins.
50    pub margin: f32,
51    /// An incumbent may not be evicted until it has been resident for at least this
52    /// many refreshes (dwell / minimum-hold). `0` disables the dwell gate.
53    pub min_dwell: u64,
54}
55
56impl Default for HysteresisConfig {
57    fn default() -> Self {
58        Self {
59            margin: 0.0,
60            min_dwell: 0,
61        }
62    }
63}
64
65impl HysteresisConfig {
66    /// Whether either gate is active (otherwise placement uses the plain paired swap).
67    pub fn is_active(&self) -> bool {
68        self.margin > 0.0 || self.min_dwell > 0
69    }
70}
71
72/// Configuration for [`ExpertPool`].
73#[derive(Debug, Clone)]
74pub struct ExpertPoolConfig {
75    pub num_experts: usize,
76    /// Max experts resident on the accelerator per MoE layer.
77    pub gpu_budget: usize,
78    pub refresh: ExpertRefreshPolicy,
79    /// Slot-swap anti-thrash gates (default: disabled).
80    pub hysteresis: HysteresisConfig,
81}
82
83impl ExpertPoolConfig {
84    pub fn new(num_experts: usize, gpu_budget: usize, refresh: ExpertRefreshPolicy) -> Self {
85        Self {
86            num_experts,
87            gpu_budget: gpu_budget.min(num_experts),
88            refresh,
89            hysteresis: HysteresisConfig::default(),
90        }
91    }
92
93    /// Attach a hysteresis / dwell policy (builder).
94    pub fn with_hysteresis(mut self, hysteresis: HysteresisConfig) -> Self {
95        self.hysteresis = hysteresis;
96        self
97    }
98
99    /// All experts pinned on device (offload disabled).
100    pub fn all_resident(num_experts: usize) -> Self {
101        Self::new(num_experts, num_experts, ExpertRefreshPolicy::EveryForward)
102    }
103}
104
105/// Result of one placement refresh.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExpertRefreshResult {
108    pub target_gpu: Vec<usize>,
109    pub promotions: usize,
110    pub demotions: usize,
111}
112
113/// Cumulative counters (TIDE `offload_stats`).
114#[derive(Debug, Clone, Default, PartialEq, Eq)]
115pub struct ExpertPoolStats {
116    pub refreshes: u64,
117    pub promotions: u64,
118    pub demotions: u64,
119}
120
121/// Tracks which logical experts are GPU-resident and applies TIDE placement updates.
122#[derive(Debug, Clone)]
123pub struct ExpertPool {
124    num_experts: usize,
125    gpu_budget: usize,
126    refresh: ExpertRefreshPolicy,
127    resident: HashSet<usize>,
128    /// Steps since last refresh (decode / denoise counter).
129    steps_since_refresh: usize,
130    stats: ExpertPoolStats,
131    /// Slot-swap anti-thrash gates.
132    hysteresis: HysteresisConfig,
133    /// Monotonic refresh clock (never reset — dwell is measured against it, unlike
134    /// [`ExpertPoolStats::refreshes`] which [`reset_step_stats`] zeroes).
135    refresh_clock: u64,
136    /// Refresh clock at which each currently-resident expert last became resident.
137    resident_since: HashMap<usize, u64>,
138}
139
140impl ExpertPool {
141    pub fn new(config: ExpertPoolConfig) -> Self {
142        let gpu_budget = config.gpu_budget.min(config.num_experts);
143        let mut resident = HashSet::new();
144        let mut resident_since = HashMap::new();
145        for e in 0..gpu_budget {
146            resident.insert(e);
147            resident_since.insert(e, 0);
148        }
149        Self {
150            num_experts: config.num_experts,
151            gpu_budget,
152            refresh: config.refresh,
153            resident,
154            steps_since_refresh: 0,
155            stats: ExpertPoolStats::default(),
156            hysteresis: config.hysteresis,
157            refresh_clock: 0,
158            resident_since,
159        }
160    }
161
162    pub fn num_experts(&self) -> usize {
163        self.num_experts
164    }
165
166    pub fn gpu_budget(&self) -> usize {
167        self.gpu_budget
168    }
169
170    pub fn refresh_policy(&self) -> ExpertRefreshPolicy {
171        self.refresh
172    }
173
174    pub fn stats(&self) -> &ExpertPoolStats {
175        &self.stats
176    }
177
178    /// TIDE `LLaDA2MoeSparseMoeBlock.reset_stats()` — clear per-step counters before next forward.
179    pub fn reset_step_stats(&mut self) {
180        self.stats = ExpertPoolStats::default();
181    }
182
183    pub fn resident_gpu_experts(&self) -> impl Iterator<Item = usize> + '_ {
184        self.resident.iter().copied()
185    }
186
187    /// Bitmask for [`crate::CompiledGraph::set_moe_resident_experts`].
188    pub fn resident_mask(&self) -> Vec<bool> {
189        (0..self.num_experts)
190            .map(|e| self.resident.contains(&e))
191            .collect()
192    }
193
194    pub fn is_gpu_resident(&self, expert: usize) -> bool {
195        self.resident.contains(&expert)
196    }
197
198    /// Whether offload is active (budget < total experts).
199    pub fn offload_enabled(&self) -> bool {
200        self.gpu_budget < self.num_experts
201    }
202
203    /// TIDE `generate`: `refresh_experts = prefill_block || (offload && step % τ == 0)`.
204    pub fn should_refresh(
205        &self,
206        mode: MoEExecMode,
207        denoise_step: usize,
208        is_prefill_block: bool,
209    ) -> bool {
210        if !self.offload_enabled() {
211            return false;
212        }
213        match mode {
214            MoEExecMode::Refresh => true,
215            MoEExecMode::Reuse => {
216                if is_prefill_block {
217                    return true;
218                }
219                match self.refresh {
220                    ExpertRefreshPolicy::EveryForward => true,
221                    ExpertRefreshPolicy::EveryDecodeSteps(n)
222                    | ExpertRefreshPolicy::EveryDenoiseSteps(n) => {
223                        let interval = n.max(1);
224                        denoise_step.is_multiple_of(interval)
225                    }
226                }
227            }
228        }
229    }
230
231    /// Advance the step counter; returns whether this forward should refresh.
232    pub fn on_forward_step(
233        &mut self,
234        mode: MoEExecMode,
235        denoise_step: usize,
236        is_prefill_block: bool,
237    ) -> bool {
238        let refresh = self.should_refresh(mode, denoise_step, is_prefill_block);
239        if refresh {
240            self.steps_since_refresh = 0;
241        } else {
242            self.steps_since_refresh = self.steps_since_refresh.saturating_add(1);
243        }
244        refresh
245    }
246
247    /// Count token hits per expert from flat or per-token indices (TIDE `bincount`).
248    pub fn count_hits(expert_idx: &[u32], num_experts: usize) -> Vec<u64> {
249        let mut counts = vec![0u64; num_experts];
250        for &e in expert_idx {
251            let e = e as usize;
252            if e < num_experts {
253                counts[e] += 1;
254            }
255        }
256        counts
257    }
258
259    /// Top-`gpu_budget` experts by hit count (TIDE `torch.topk` on bincount).
260    pub fn target_gpu_from_counts(counts: &[u64], gpu_budget: usize) -> Vec<usize> {
261        let mut ranked: Vec<(u64, usize)> = counts
262            .iter()
263            .enumerate()
264            .filter(|&(_, c)| *c > 0)
265            .map(|(e, &c)| (c, e))
266            .collect();
267        ranked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
268        ranked
269            .into_iter()
270            .take(gpu_budget)
271            .map(|(_, e)| e)
272            .collect()
273    }
274
275    /// TIDE `update_expert_placement` + hit-based target selection.
276    ///
277    /// When a [`HysteresisConfig`] gate is active this routes through
278    /// [`Self::refresh_from_counts`] (margin + dwell); otherwise it is the plain
279    /// top-S paired swap.
280    pub fn refresh_from_indices(&mut self, expert_idx: &[u32]) -> ExpertRefreshResult {
281        let counts = Self::count_hits(expert_idx, self.num_experts);
282        self.refresh_from_counts(&counts)
283    }
284
285    /// Refresh placement from per-expert hit counts, honoring the hysteresis / dwell
286    /// gates when active.
287    pub fn refresh_from_counts(&mut self, counts: &[u64]) -> ExpertRefreshResult {
288        if self.hysteresis.is_active() {
289            self.apply_hysteresis_placement(counts)
290        } else {
291            let target_order = Self::target_gpu_from_counts(counts, self.gpu_budget);
292            self.apply_target_placement(&target_order)
293        }
294    }
295
296    /// Refresh clock at which `expert` last became resident (0 if not resident).
297    pub fn resident_since(&self, expert: usize) -> u64 {
298        self.resident_since.get(&expert).copied().unwrap_or(0)
299    }
300
301    pub fn hysteresis(&self) -> HysteresisConfig {
302        self.hysteresis
303    }
304
305    /// Mark `experts` as freshly (re)loaded at the current clock — record-keeping only
306    /// (does not change residency); used to reset dwell after a manual slot fill.
307    fn touch_resident_since(&mut self, e: usize) {
308        self.resident_since.insert(e, self.refresh_clock);
309    }
310
311    /// Apply a precomputed target GPU set (paired promote/demote, no hysteresis).
312    pub fn apply_target_placement(&mut self, target_order: &[usize]) -> ExpertRefreshResult {
313        self.refresh_clock += 1;
314        let clock = self.refresh_clock;
315        let target_set: HashSet<usize> = target_order.iter().copied().collect();
316
317        let to_promote: Vec<usize> = target_order
318            .iter()
319            .copied()
320            .filter(|e| !self.resident.contains(e))
321            .collect();
322        let can_demote: Vec<usize> = self
323            .resident
324            .iter()
325            .copied()
326            .filter(|e| !target_set.contains(e))
327            .collect();
328        let to_demote: Vec<usize> = can_demote.iter().copied().take(to_promote.len()).collect();
329
330        let mut new_resident = target_set;
331        for e in can_demote.iter().skip(to_promote.len()) {
332            new_resident.insert(*e);
333        }
334
335        // Maintain dwell bookkeeping: newly-resident experts start their dwell now;
336        // drop timestamps for anything evicted.
337        for &e in &to_promote {
338            self.resident_since.insert(e, clock);
339        }
340        self.resident_since.retain(|e, _| new_resident.contains(e));
341
342        let promotions = to_promote.len();
343        let demotions = to_demote.len();
344        self.resident = new_resident;
345        self.stats.refreshes += 1;
346        self.stats.promotions += promotions as u64;
347        self.stats.demotions += demotions as u64;
348
349        ExpertRefreshResult {
350            target_gpu: target_order.to_vec(),
351            promotions,
352            demotions,
353        }
354    }
355
356    /// Hysteresis-gated placement: promote the hottest non-resident experts, but only
357    /// evict an incumbent when the challenger is `margin`-hotter AND the incumbent has
358    /// been resident at least `min_dwell` refreshes. Challengers are considered
359    /// hottest-first and eviction candidates weakest-first, so once the hottest
360    /// challenger fails to beat the weakest evictable incumbent we stop — bounding
361    /// churn without a hard paired-swap cap.
362    fn apply_hysteresis_placement(&mut self, counts: &[u64]) -> ExpertRefreshResult {
363        self.refresh_clock += 1;
364        let clock = self.refresh_clock;
365        let budget = self.gpu_budget;
366        let margin = self.hysteresis.margin.max(0.0) as f64;
367        let dwell = self.hysteresis.min_dwell;
368        let hits = |e: usize| counts.get(e).copied().unwrap_or(0);
369
370        let desired = Self::target_gpu_from_counts(counts, budget);
371        let desired_set: HashSet<usize> = desired.iter().copied().collect();
372
373        // Challengers: desired experts not yet resident (already hottest-first).
374        let to_add: Vec<usize> = desired
375            .iter()
376            .copied()
377            .filter(|e| !self.resident.contains(e))
378            .collect();
379
380        // Eviction candidates: resident experts not in the desired set, weakest-first
381        // (fewest hits, then longest-resident, then lowest id for determinism).
382        let mut evictable: Vec<usize> = self
383            .resident
384            .iter()
385            .copied()
386            .filter(|e| !desired_set.contains(e))
387            .collect();
388        evictable.sort_by(|&a, &b| {
389            hits(a)
390                .cmp(&hits(b))
391                .then_with(|| self.resident_since(a).cmp(&self.resident_since(b)))
392                .then_with(|| a.cmp(&b))
393        });
394
395        let mut promotions = 0usize;
396        let mut demotions = 0usize;
397        let mut ev_idx = 0usize;
398
399        for c in to_add {
400            let c_hits = hits(c) as f64;
401            if self.resident.len() < budget {
402                // Free slot — promote with no eviction.
403                self.resident.insert(c);
404                self.touch_resident_since(c);
405                promotions += 1;
406                continue;
407            }
408            // Locate the weakest unprotected incumbent this challenger can displace.
409            let mut victim: Option<usize> = None;
410            while ev_idx < evictable.len() {
411                let i = evictable[ev_idx];
412                let held = clock.saturating_sub(self.resident_since(i));
413                if held < dwell {
414                    // Dwell-protected: keeps its slot, skip permanently this round.
415                    ev_idx += 1;
416                    continue;
417                }
418                if c_hits >= hits(i) as f64 * (1.0 + margin) {
419                    victim = Some(i);
420                    ev_idx += 1;
421                }
422                // Weakest unprotected incumbent decided this challenger's fate.
423                break;
424            }
425            match victim {
426                Some(i) => {
427                    self.resident.remove(&i);
428                    self.resident_since.remove(&i);
429                    self.resident.insert(c);
430                    self.touch_resident_since(c);
431                    promotions += 1;
432                    demotions += 1;
433                }
434                None => break, // colder challengers can't do better — stop.
435            }
436        }
437
438        self.stats.refreshes += 1;
439        self.stats.promotions += promotions as u64;
440        self.stats.demotions += demotions as u64;
441
442        ExpertRefreshResult {
443            target_gpu: desired,
444            promotions,
445            demotions,
446        }
447    }
448}
449
450/// Per-layer resident bitmasks (TIDE placement; one row per MoE FFN in forward order).
451pub fn per_layer_resident_masks(pools: &[ExpertPool]) -> Vec<Vec<bool>> {
452    pools.iter().map(|p| p.resident_mask()).collect()
453}
454
455/// Union of GPU-resident experts across per-layer pools (legacy single graph mask).
456pub fn merged_resident_mask(pools: &[ExpertPool]) -> Vec<bool> {
457    let Some(first) = pools.first() else {
458        return Vec::new();
459    };
460    let n = first.num_experts();
461    (0..n)
462        .map(|e| pools.iter().any(|p| p.is_gpu_resident(e)))
463        .collect()
464}
465
466pub fn gpu_expert_budget_from_vram(
467    free_bytes: usize,
468    reserve_bytes: usize,
469    expert_param_bytes: usize,
470    num_moe_layers: usize,
471    max_gpu_experts_per_layer: usize,
472    num_experts: usize,
473) -> usize {
474    if expert_param_bytes == 0 || num_moe_layers == 0 {
475        return max_gpu_experts_per_layer.min(num_experts);
476    }
477    let usable = free_bytes.saturating_sub(reserve_bytes);
478    let per_layer = usable / (expert_param_bytes.saturating_mul(num_moe_layers));
479    per_layer.min(max_gpu_experts_per_layer).min(num_experts)
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn per_layer_masks_differ_from_merged_union() {
488        let mut p0 = ExpertPool::new(ExpertPoolConfig::new(
489            4,
490            2,
491            ExpertRefreshPolicy::EveryForward,
492        ));
493        let mut p1 = ExpertPool::new(ExpertPoolConfig::new(
494            4,
495            2,
496            ExpertRefreshPolicy::EveryForward,
497        ));
498        p0.refresh_from_indices(&[0, 1]);
499        p1.refresh_from_indices(&[2, 3]);
500        let pools = [p0, p1];
501        let merged = merged_resident_mask(&pools);
502        assert_eq!(merged, vec![true, true, true, true]);
503        let per = per_layer_resident_masks(&pools);
504        assert_eq!(per[0], vec![true, true, false, false]);
505        assert_eq!(per[1], vec![false, false, true, true]);
506    }
507
508    #[test]
509    fn count_hits_matches_bincount() {
510        let idx = [1u32, 0, 1, 2, 1];
511        let c = ExpertPool::count_hits(&idx, 4);
512        assert_eq!(c, [1, 3, 1, 0]);
513    }
514
515    #[test]
516    fn target_gpu_picks_top_by_count() {
517        let counts = [10, 50, 30, 0, 50];
518        let t = ExpertPool::target_gpu_from_counts(&counts, 3);
519        assert_eq!(t, vec![1, 4, 2]); // tie-break: lower expert id first
520    }
521
522    #[test]
523    fn paired_swap_limits_demotions() {
524        let mut pool = ExpertPool::new(ExpertPoolConfig::new(
525            8,
526            2,
527            ExpertRefreshPolicy::EveryForward,
528        ));
529        pool.resident = [0, 1].into_iter().collect();
530        let r = pool.apply_target_placement(&[6, 7]);
531        assert_eq!(r.promotions, 2);
532        assert_eq!(r.demotions, 2);
533        assert_eq!(pool.resident, [6, 7].into_iter().collect::<HashSet<_>>());
534    }
535
536    #[test]
537    fn paired_swap_keeps_extra_residents() {
538        let mut pool = ExpertPool::new(ExpertPoolConfig::new(
539            8,
540            4,
541            ExpertRefreshPolicy::EveryForward,
542        ));
543        pool.resident = [0, 1, 2, 3].into_iter().collect();
544        // Target overlaps heavily — paired demotion leaves one former GPU expert
545        // on device (matches TIDE `can_demote[len(to_promote):]`).
546        let r = pool.apply_target_placement(&[2, 3, 4, 5]);
547        assert_eq!(r.promotions, 2);
548        assert_eq!(r.demotions, 2);
549        assert_eq!(pool.resident.len(), 4);
550        for e in [2, 3, 4, 5] {
551            assert!(pool.is_gpu_resident(e));
552        }
553        assert!(!pool.is_gpu_resident(0));
554    }
555
556    #[test]
557    fn jump_steps_refresh_schedule() {
558        let pool = ExpertPool::new(ExpertPoolConfig::new(
559            256,
560            64,
561            ExpertRefreshPolicy::EveryDenoiseSteps(3),
562        ));
563        assert!(pool.should_refresh(MoEExecMode::Reuse, 0, false));
564        assert!(!pool.should_refresh(MoEExecMode::Reuse, 1, false));
565        assert!(!pool.should_refresh(MoEExecMode::Reuse, 2, false));
566        assert!(pool.should_refresh(MoEExecMode::Reuse, 3, false));
567        assert!(pool.should_refresh(MoEExecMode::Reuse, 0, true)); // prefill block
568    }
569
570    #[test]
571    fn hysteresis_margin_protects_incumbent() {
572        let cfg = ExpertPoolConfig::new(4, 2, ExpertRefreshPolicy::EveryForward).with_hysteresis(
573            HysteresisConfig {
574                margin: 0.5,
575                min_dwell: 0,
576            },
577        );
578        let mut pool = ExpertPool::new(cfg); // resident {0,1}
579
580        // Expert 2 is hotter than incumbent 1 but not by the 50% margin → no swap.
581        let r = pool.refresh_from_counts(&[10, 10, 12, 0]);
582        assert_eq!(r.promotions, 0);
583        assert_eq!(r.demotions, 0);
584        assert!(!pool.is_gpu_resident(2));
585        assert!(pool.is_gpu_resident(1));
586
587        // Now expert 2 clears the margin (20 ≥ 10 * 1.5) → it displaces expert 1.
588        let r = pool.refresh_from_counts(&[10, 10, 20, 0]);
589        assert_eq!(r.promotions, 1);
590        assert_eq!(r.demotions, 1);
591        assert!(pool.is_gpu_resident(2));
592        assert!(!pool.is_gpu_resident(1));
593    }
594
595    #[test]
596    fn dwell_delays_eviction() {
597        let cfg = ExpertPoolConfig::new(4, 2, ExpertRefreshPolicy::EveryForward).with_hysteresis(
598            HysteresisConfig {
599                margin: 0.0,
600                min_dwell: 3,
601            },
602        );
603        let mut pool = ExpertPool::new(cfg); // resident {0,1}, resident_since = clock 0
604
605        // Expert 2 is far hotter, but the incumbents haven't served their dwell yet.
606        for _ in 0..2 {
607            let r = pool.refresh_from_counts(&[0, 0, 100, 0]);
608            assert_eq!(r.promotions, 0, "dwell must protect fresh incumbents");
609            assert!(!pool.is_gpu_resident(2));
610        }
611        // Third refresh: clock reaches 3, dwell satisfied → expert 2 gets a slot.
612        let r = pool.refresh_from_counts(&[0, 0, 100, 0]);
613        assert_eq!(r.promotions, 1);
614        assert!(pool.is_gpu_resident(2));
615    }
616
617    #[test]
618    fn hysteresis_disabled_matches_plain_topk() {
619        let counts = [3u64, 9, 1, 7, 0, 5];
620        // Plain path.
621        let mut plain = ExpertPool::new(ExpertPoolConfig::new(
622            6,
623            3,
624            ExpertRefreshPolicy::EveryForward,
625        ));
626        plain.refresh_from_counts(&counts);
627        // Default (disabled) hysteresis path.
628        let mut hyst = ExpertPool::new(
629            ExpertPoolConfig::new(6, 3, ExpertRefreshPolicy::EveryForward)
630                .with_hysteresis(HysteresisConfig::default()),
631        );
632        hyst.refresh_from_counts(&counts);
633        assert_eq!(plain.resident_mask(), hyst.resident_mask());
634        // Top-3 by count = experts 1(9), 3(7), 5(5).
635        assert_eq!(
636            plain.resident_mask(),
637            vec![false, true, false, true, false, true]
638        );
639    }
640
641    #[test]
642    fn vram_budget_formula() {
643        let b = gpu_expert_budget_from_vram(
644            40 * 1024 * 1024 * 1024,
645            2 * 1024 * 1024 * 1024,
646            50 * 1024 * 1024,
647            20,
648            128,
649            256,
650        );
651        assert!(b > 0 && b <= 128);
652    }
653}