Skip to main content

rlx_runtime/
weight_registry.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//! Named weights registry (plan #24).
6//!
7//! Borrowed from MAX's `weights_registry/weights_registry.mojo`.
8//! Promotes the passive `weights.rs` loader contract to an active
9//! per-process registry: named handles + reference counts + LoRA
10//! adapter accounting.
11//!
12//! Why a registry beyond the existing `WeightLoader`?
13//!   - **LoRA hot-swap.** Multiple adapters share the same base
14//!     weights. The registry owns the bytes; adapters are
15//!     handle-pointers with their own metadata.
16//!   - **Tied embeddings.** GPT-2 / LLaMA / Gemma tie input
17//!     embedding to output projection. With a registry both
18//!     positions resolve to the same `WeightEntry`.
19//!   - **Weight streaming.** Future "load layer N when running
20//!     layer N" patterns need to refcount which weights are
21//!     in-memory.
22//!   - **Memory accounting.** [`#35`] memory estimation queries
23//!     `total_bytes()` to gate model loads against unified-memory
24//!     budget.
25
26use rlx_ir::Shape;
27use std::collections::HashMap;
28use std::sync::Arc;
29use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
30
31/// Stable handle into a [`WeightRegistry`]. Cheap to copy; passing
32/// it around is the recommended way to refer to a weight without
33/// keeping the registry borrowed.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub struct WeightHandle(u64);
36
37impl WeightHandle {
38    pub fn id(self) -> u64 {
39        self.0
40    }
41}
42
43/// What role a weight plays. Drives downstream scheduling
44/// (LoRA-aware request grouping) and accounting.
45#[derive(Debug, Clone)]
46pub enum WeightKind {
47    /// A base model weight — independent storage.
48    Base,
49    /// A LoRA adapter's slice (down-proj A or up-proj B).
50    /// Multiple adapters can attach to the same base; the
51    /// scheduler groups requests by `adapter` name.
52    LoraAdapter { adapter: String },
53    /// A view that resolves to another weight's storage. Used for
54    /// tied embeddings — `embed_tokens.weight` and
55    /// `lm_head.weight` are the same buffer, two names.
56    TiedAlias { target: WeightHandle },
57}
58
59/// One entry in the registry.
60#[derive(Debug)]
61pub struct WeightEntry {
62    pub name: String,
63    pub shape: Shape,
64    pub kind: WeightKind,
65    /// `Arc` so multiple consumers (different graphs / different
66    /// LoRA combinations / hot-reload) can hold the same bytes
67    /// without copying.
68    pub bytes: Arc<[u8]>,
69    /// Ref count. Goes up on `pin`, down on `release`. Hitting
70    /// zero on `release` keeps the entry in the registry; explicit
71    /// `unregister` is required to drop. This separation matters
72    /// for "weight streaming" use cases that re-pin frequently.
73    pub refs: AtomicUsize,
74}
75
76/// The registry itself. One per process is the typical setup; a
77/// `Session` can borrow a registry to resolve weight names during
78/// graph compile.
79pub struct WeightRegistry {
80    by_name: HashMap<String, WeightHandle>,
81    by_handle: HashMap<u64, Arc<WeightEntry>>,
82    next_id: AtomicU64,
83}
84
85impl WeightRegistry {
86    pub fn new() -> Self {
87        Self {
88            by_name: HashMap::new(),
89            by_handle: HashMap::new(),
90            next_id: AtomicU64::new(0),
91        }
92    }
93
94    fn alloc_id(&self) -> u64 {
95        self.next_id.fetch_add(1, Ordering::Relaxed)
96    }
97
98    /// Register a fresh weight under `name`. If `name` already
99    /// exists, returns the existing handle (idempotent — useful for
100    /// weight loaders that may see the same tensor twice).
101    pub fn register(
102        &mut self,
103        name: impl Into<String>,
104        shape: Shape,
105        bytes: Arc<[u8]>,
106        kind: WeightKind,
107    ) -> WeightHandle {
108        let name = name.into();
109        if let Some(&h) = self.by_name.get(&name) {
110            return h;
111        }
112        let id = self.alloc_id();
113        let h = WeightHandle(id);
114        let entry = Arc::new(WeightEntry {
115            name: name.clone(),
116            shape,
117            kind,
118            bytes,
119            refs: AtomicUsize::new(0),
120        });
121        self.by_name.insert(name, h);
122        self.by_handle.insert(id, entry);
123        h
124    }
125
126    /// Resolve a name → handle.
127    pub fn lookup(&self, name: &str) -> Option<WeightHandle> {
128        self.by_name.get(name).copied()
129    }
130
131    /// Read the entry for `handle`. Resolves a TiedAlias one step.
132    pub fn get(&self, handle: WeightHandle) -> Option<&Arc<WeightEntry>> {
133        let entry = self.by_handle.get(&handle.0)?;
134        if let WeightKind::TiedAlias { target } = entry.kind {
135            return self.by_handle.get(&target.0);
136        }
137        Some(entry)
138    }
139
140    /// Increment the entry's refcount. Returns the new count.
141    pub fn pin(&self, handle: WeightHandle) -> Option<usize> {
142        let entry = self.by_handle.get(&handle.0)?;
143        Some(entry.refs.fetch_add(1, Ordering::Relaxed) + 1)
144    }
145
146    /// Decrement the entry's refcount. Returns the new count.
147    /// Hitting zero does NOT drop the entry — call `unregister` to
148    /// drop. Returns `None` if the handle is unknown.
149    pub fn release(&self, handle: WeightHandle) -> Option<usize> {
150        let entry = self.by_handle.get(&handle.0)?;
151        let prev = entry.refs.fetch_sub(1, Ordering::Relaxed);
152        debug_assert!(prev >= 1, "release on a zero-refcount entry");
153        Some(prev - 1)
154    }
155
156    /// Drop an entry. Caller must have already `release`d to zero
157    /// (debug-asserted). Returns the entry's name on success.
158    pub fn unregister(&mut self, handle: WeightHandle) -> Option<String> {
159        let entry = self.by_handle.remove(&handle.0)?;
160        debug_assert_eq!(
161            entry.refs.load(Ordering::Relaxed),
162            0,
163            "unregister on a still-referenced entry: refs={}",
164            entry.refs.load(Ordering::Relaxed)
165        );
166        self.by_name.remove(&entry.name);
167        Some(entry.name.clone())
168    }
169
170    /// Total registered weight bytes — sums each base entry's
171    /// `bytes.len()` plus each adapter; tied-alias entries don't
172    /// double-count (they share storage).
173    pub fn total_bytes(&self) -> usize {
174        self.by_handle
175            .values()
176            .filter(|e| !matches!(e.kind, WeightKind::TiedAlias { .. }))
177            .map(|e| e.bytes.len())
178            .sum()
179    }
180
181    /// All handles whose kind is `LoraAdapter { adapter: <name> }`.
182    /// Used by LoRA-aware scheduling (#33) to group requests.
183    pub fn lora_adapter_handles(&self, adapter: &str) -> Vec<WeightHandle> {
184        let mut v: Vec<WeightHandle> = self
185            .by_handle
186            .iter()
187            .filter_map(|(&id, e)| match &e.kind {
188                WeightKind::LoraAdapter { adapter: a } if a == adapter => Some(WeightHandle(id)),
189                _ => None,
190            })
191            .collect();
192        v.sort_by_key(|h| h.0);
193        v
194    }
195
196    /// All distinct LoRA adapter names currently registered.
197    pub fn lora_adapter_names(&self) -> Vec<String> {
198        let mut s: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
199        for e in self.by_handle.values() {
200            if let WeightKind::LoraAdapter { adapter } = &e.kind {
201                s.insert(adapter.clone());
202            }
203        }
204        s.into_iter().collect()
205    }
206
207    pub fn len(&self) -> usize {
208        self.by_handle.len()
209    }
210    pub fn is_empty(&self) -> bool {
211        self.by_handle.is_empty()
212    }
213}
214
215impl Default for WeightRegistry {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use rlx_ir::DType;
225
226    fn shape() -> Shape {
227        Shape::new(&[8, 8], DType::F32)
228    }
229    fn bytes(n: usize) -> Arc<[u8]> {
230        vec![0u8; n].into()
231    }
232
233    #[test]
234    fn register_and_lookup() {
235        let mut r = WeightRegistry::new();
236        let h = r.register("w", shape(), bytes(256), WeightKind::Base);
237        assert_eq!(r.lookup("w"), Some(h));
238        let entry = r.get(h).unwrap();
239        assert_eq!(entry.name, "w");
240        assert_eq!(entry.bytes.len(), 256);
241    }
242
243    #[test]
244    fn register_is_idempotent() {
245        let mut r = WeightRegistry::new();
246        let h1 = r.register("w", shape(), bytes(128), WeightKind::Base);
247        let h2 = r.register("w", shape(), bytes(999), WeightKind::Base);
248        // Same handle; the second register call doesn't overwrite.
249        assert_eq!(h1, h2);
250        assert_eq!(r.get(h1).unwrap().bytes.len(), 128);
251    }
252
253    #[test]
254    fn pin_release_balance() {
255        let mut r = WeightRegistry::new();
256        let h = r.register("w", shape(), bytes(64), WeightKind::Base);
257        assert_eq!(r.pin(h), Some(1));
258        assert_eq!(r.pin(h), Some(2));
259        assert_eq!(r.release(h), Some(1));
260        assert_eq!(r.release(h), Some(0));
261        // Unregister now ok.
262        assert_eq!(r.unregister(h), Some("w".to_string()));
263        assert!(r.lookup("w").is_none());
264    }
265
266    #[test]
267    fn tied_alias_resolves_to_target() {
268        let mut r = WeightRegistry::new();
269        let target = r.register("embed", shape(), bytes(128), WeightKind::Base);
270        let alias = r.register(
271            "lm_head",
272            shape(),
273            bytes(0), // alias has no bytes of its own
274            WeightKind::TiedAlias { target },
275        );
276        let resolved = r.get(alias).unwrap();
277        assert_eq!(resolved.name, "embed");
278        assert_eq!(resolved.bytes.len(), 128);
279    }
280
281    #[test]
282    fn total_bytes_skips_aliases() {
283        let mut r = WeightRegistry::new();
284        let _t = r.register("embed", shape(), bytes(100), WeightKind::Base);
285        let _a = r.register(
286            "lm_head",
287            shape(),
288            bytes(0),
289            WeightKind::TiedAlias {
290                target: r.lookup("embed").unwrap(),
291            },
292        );
293        let _b = r.register("ffn", shape(), bytes(200), WeightKind::Base);
294        assert_eq!(r.total_bytes(), 300, "alias must not double-count");
295    }
296
297    #[test]
298    fn lora_grouping() {
299        let mut r = WeightRegistry::new();
300        let _b = r.register("ffn", shape(), bytes(100), WeightKind::Base);
301        r.register(
302            "ffn.lora.a",
303            shape(),
304            bytes(8),
305            WeightKind::LoraAdapter {
306                adapter: "code".into(),
307            },
308        );
309        r.register(
310            "ffn.lora.b",
311            shape(),
312            bytes(8),
313            WeightKind::LoraAdapter {
314                adapter: "code".into(),
315            },
316        );
317        r.register(
318            "attn.lora.a",
319            shape(),
320            bytes(8),
321            WeightKind::LoraAdapter {
322                adapter: "math".into(),
323            },
324        );
325
326        let mut adapters = r.lora_adapter_names();
327        adapters.sort();
328        assert_eq!(adapters, vec!["code".to_string(), "math".to_string()]);
329
330        let code_handles = r.lora_adapter_handles("code");
331        assert_eq!(code_handles.len(), 2);
332    }
333}