Skip to main content

rlx_runtime/
lora_scheduler.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//! LoRA-aware request scheduling (plan #33).
6//!
7//! Borrowed from MAX's `serve/scheduler/lora_scheduler_utils.py`.
8//! When multiple LoRA adapters are loaded, each request specifies
9//! which adapter it wants. Naïvely interleaving requests forces an
10//! adapter swap per request — wasted bandwidth + latency. The
11//! scheduler here groups consecutive same-adapter requests into
12//! one "batch", so a swap happens once per batch boundary instead
13//! of once per request.
14//!
15//! Pure data-layer scheduling — no executor, no compiled graphs.
16//! Plug into a future serving loop by:
17//!   1. Push incoming requests into [`LoraScheduler::push`].
18//!   2. Drain runnable batches with [`LoraScheduler::drain_batch`].
19//!   3. For each batch, swap to that adapter once and run all
20//!      requests in the batch back-to-back.
21//!
22//! Pairs with #24 (named weights registry) which owns the adapter
23//! bytes, and #9 (LoRA kernel) which is the actual compute.
24
25use crate::weight_registry::WeightRegistry;
26use std::collections::VecDeque;
27
28/// One serving request with its target LoRA adapter (or `None`
29/// for "use the base model"). The opaque `id: u64` is whatever
30/// the caller wants — typically a request UUID hash.
31#[derive(Debug, Clone)]
32pub struct LoraRequest {
33    pub id: u64,
34    pub adapter: Option<String>,
35    /// Caller-provided payload. The scheduler doesn't interpret
36    /// it; downstream code reads it after `drain_batch`.
37    pub payload: LoraPayload,
38}
39
40/// Generic request payload. The scheduler is generic over
41/// payload type via `Box<dyn Any>`; we keep this concrete here
42/// for simplicity and the common case (text generation).
43#[derive(Debug, Clone)]
44pub struct LoraPayload {
45    pub prompt_tokens: Vec<u32>,
46    pub max_new_tokens: usize,
47}
48
49/// One batch handed to the executor. All requests in a batch
50/// target the same adapter (or all None).
51#[derive(Debug)]
52pub struct LoraBatch {
53    pub adapter: Option<String>,
54    pub requests: Vec<LoraRequest>,
55}
56
57impl LoraBatch {
58    pub fn len(&self) -> usize {
59        self.requests.len()
60    }
61    pub fn is_empty(&self) -> bool {
62        self.requests.is_empty()
63    }
64}
65
66/// FIFO-ish scheduler with same-adapter coalescing.
67pub struct LoraScheduler {
68    /// Pending requests; insertion order preserved.
69    pending: VecDeque<LoraRequest>,
70    /// Maximum requests per drained batch.
71    pub max_batch: usize,
72    /// Optional reference to a registry — `validate` checks that
73    /// adapter names are registered before push. Storing as a raw
74    /// pointer avoids lifetime entanglement; callers ensure the
75    /// registry outlives the scheduler.
76    registry: Option<*const WeightRegistry>,
77}
78
79// `*const WeightRegistry` is not Send/Sync by default; we mark
80// the scheduler Send because the pointer is only used for read
81// queries on a registry that's itself Send + Sync once we wrap
82// it in Arc<RwLock>. The pointer should never be mutated.
83unsafe impl Send for LoraScheduler {}
84
85impl LoraScheduler {
86    pub fn new(max_batch: usize) -> Self {
87        Self {
88            pending: VecDeque::new(),
89            max_batch,
90            registry: None,
91        }
92    }
93
94    /// Bind a registry for adapter-name validation. Caller must
95    /// ensure `registry` outlives the scheduler.
96    pub fn bind_registry(&mut self, registry: &WeightRegistry) {
97        self.registry = Some(registry as *const _);
98    }
99
100    /// Push a request. Returns `Err` only if a registry is bound
101    /// and the adapter name isn't registered.
102    pub fn push(&mut self, req: LoraRequest) -> Result<(), UnknownAdapter> {
103        if let (Some(reg_ptr), Some(adapter)) = (self.registry, &req.adapter) {
104            // Safety: caller guaranteed the registry outlives us.
105            let reg = unsafe { &*reg_ptr };
106            if reg.lora_adapter_handles(adapter).is_empty() {
107                return Err(UnknownAdapter {
108                    name: adapter.clone(),
109                });
110            }
111        }
112        self.pending.push_back(req);
113        Ok(())
114    }
115
116    /// Look at the next batch's adapter without draining.
117    pub fn peek_adapter(&self) -> Option<Option<String>> {
118        self.pending.front().map(|r| r.adapter.clone())
119    }
120
121    /// Drain the next runnable batch — up to `max_batch` requests
122    /// that all share the same `adapter`. Returns `None` if empty.
123    pub fn drain_batch(&mut self) -> Option<LoraBatch> {
124        let head = self.pending.pop_front()?;
125        let target = head.adapter.clone();
126        let mut requests = vec![head];
127        while requests.len() < self.max_batch {
128            match self.pending.front() {
129                Some(next) if next.adapter == target => {
130                    requests.push(self.pending.pop_front().unwrap());
131                }
132                _ => break,
133            }
134        }
135        Some(LoraBatch {
136            adapter: target,
137            requests,
138        })
139    }
140
141    pub fn pending(&self) -> usize {
142        self.pending.len()
143    }
144    pub fn is_empty(&self) -> bool {
145        self.pending.is_empty()
146    }
147}
148
149#[derive(Debug, Clone)]
150pub struct UnknownAdapter {
151    pub name: String,
152}
153
154impl std::fmt::Display for UnknownAdapter {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        write!(f, "adapter `{}` is not registered", self.name)
157    }
158}
159impl std::error::Error for UnknownAdapter {}
160
161/// Count adapter swaps that would occur if a sequence of
162/// requests were processed in declared order without coalescing.
163/// Useful for observability / unit tests.
164pub fn naive_swap_count(reqs: &[LoraRequest]) -> usize {
165    let mut swaps: usize = 0;
166    let mut last: Option<&Option<String>> = None;
167    for r in reqs {
168        if last.map(|l| l != &r.adapter).unwrap_or(true) {
169            swaps += 1;
170        }
171        last = Some(&r.adapter);
172    }
173    swaps.saturating_sub(1) // first "swap" is the initial setup, not a swap
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::weight_registry::{WeightKind, WeightRegistry};
180    use rlx_ir::{DType, Shape};
181    use std::sync::Arc;
182
183    fn req(id: u64, adapter: Option<&str>) -> LoraRequest {
184        LoraRequest {
185            id,
186            adapter: adapter.map(|s| s.to_string()),
187            payload: LoraPayload {
188                prompt_tokens: vec![],
189                max_new_tokens: 4,
190            },
191        }
192    }
193
194    #[test]
195    fn coalesces_same_adapter_runs() {
196        let mut s = LoraScheduler::new(8);
197        // Mixed-adapter input order:
198        //   code, code, math, math, code, base, base
199        for r in [
200            req(1, Some("code")),
201            req(2, Some("code")),
202            req(3, Some("math")),
203            req(4, Some("math")),
204            req(5, Some("code")),
205            req(6, None),
206            req(7, None),
207        ] {
208            s.push(r).unwrap();
209        }
210
211        let b1 = s.drain_batch().unwrap();
212        assert_eq!(b1.adapter.as_deref(), Some("code"));
213        assert_eq!(b1.len(), 2); // 1, 2 — stops at the math at front
214
215        let b2 = s.drain_batch().unwrap();
216        assert_eq!(b2.adapter.as_deref(), Some("math"));
217        assert_eq!(b2.len(), 2);
218
219        let b3 = s.drain_batch().unwrap();
220        assert_eq!(b3.adapter.as_deref(), Some("code"));
221        assert_eq!(b3.len(), 1);
222
223        let b4 = s.drain_batch().unwrap();
224        assert!(b4.adapter.is_none());
225        assert_eq!(b4.len(), 2);
226
227        assert!(s.drain_batch().is_none());
228    }
229
230    #[test]
231    fn respects_max_batch_cap() {
232        let mut s = LoraScheduler::new(3);
233        for i in 0..10 {
234            s.push(req(i, Some("code"))).unwrap();
235        }
236        let b = s.drain_batch().unwrap();
237        assert_eq!(b.len(), 3, "max_batch=3 should split a long run");
238        assert_eq!(s.pending(), 7);
239    }
240
241    #[test]
242    fn registry_validation_rejects_unknown_adapter() {
243        let mut reg = WeightRegistry::new();
244        reg.register(
245            "ffn",
246            Shape::new(&[8, 8], DType::F32),
247            Arc::from(vec![0u8; 256]),
248            WeightKind::Base,
249        );
250        reg.register(
251            "ffn.lora.a",
252            Shape::new(&[8, 4], DType::F32),
253            Arc::from(vec![0u8; 128]),
254            WeightKind::LoraAdapter {
255                adapter: "code".into(),
256            },
257        );
258
259        let mut s = LoraScheduler::new(4);
260        s.bind_registry(&reg);
261
262        // Known adapter passes.
263        assert!(s.push(req(1, Some("code"))).is_ok());
264        // None (base model) always passes.
265        assert!(s.push(req(2, None)).is_ok());
266        // Unknown adapter rejected.
267        let err = s.push(req(3, Some("nonexistent"))).unwrap_err();
268        assert_eq!(err.name, "nonexistent");
269    }
270
271    #[test]
272    fn swap_count_metric() {
273        let reqs = [
274            req(1, Some("a")),
275            req(2, Some("a")),
276            req(3, Some("b")),
277            req(4, Some("a")),
278        ];
279        // Sequence transitions: a, a→a (no swap), a→b (swap), b→a
280        // (swap) = 2 swaps after initial.
281        assert_eq!(naive_swap_count(&reqs), 2);
282    }
283}