Skip to main content

rlx_runtime/dist/
mod.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//! Ship-graph distributed execution — **build the worker once, run any model.**
6//!
7//! A worker is a *generic* executor: it compiles and runs a graph the
8//! coordinator ships at runtime, so no model is baked into the worker binary
9//! and there is **no per-node / per-model recompile**. Build one worker binary
10//! per architecture (`Node::from_env().connect()` + [`serve_stage`]), deploy it
11//! to every node, and drive any model from a coordinator.
12//!
13//! The coordinator — which owns the model's graph builders (e.g. a crate in
14//! `rlx-models`) — partitions the model into stages and ships each worker a
15//! [`StageSpec`]: the serialized subgraph, its I/O node names, where to fetch
16//! each weight, and a device directive. Weights are resolved **locally** by a
17//! caller-provided closure (GGUF / safetensors / HF — rlx core stays
18//! model-agnostic), so only specs (KB) and activations cross the wire — never
19//! the weights.
20//!
21//! ```no_run
22//! # use rlx_runtime::dist;
23//! # use rlx_driver::ProcessGroup; use std::sync::Arc;
24//! # fn demo(group: Arc<ProcessGroup>) -> Result<(), String> {
25//! // Generic worker binary — the SAME build runs any model:
26//! let mut stage = dist::recv_stage(&group, |uri| load_weights(uri))?;
27//! let x = dist::recv_activation(&group, group.rank() - 1)?;
28//! let y = stage.run(&x);
29//! dist::send_activation(&group, /*next*/ 0, &y)?;
30//! # Ok(()) }
31//! # fn load_weights(_uri: &str) -> Vec<f32> { vec![] }
32//! ```
33
34use crate::Device;
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37
38mod diagnostics;
39mod inference;
40mod training;
41
42pub use diagnostics::*;
43pub use inference::*;
44pub use training::*;
45
46/// Where one parameter's data comes from. The worker resolves the `uri`
47/// itself, so large weights stay node-local.
48#[derive(Serialize, Deserialize, Clone, Debug)]
49pub struct WeightRef {
50    pub name: String,
51    /// Opaque to rlx core — the caller's resolver interprets it (e.g.
52    /// `gguf:///models/x.gguf#blk.0.ffn`, `rlxp:///models/x.rlxp#w`, `file:///…`, `hf://…`).
53    pub uri: String,
54    /// If set, load the **quantized bytes as-is** (U8 param) instead of
55    /// dequantizing to f32 — the graph's `DequantMatMul` decodes them at
56    /// matmul time, so the weight stays at native quant memory (e.g. Q4_K)
57    /// with no dequant round-trip. The coordinator sets this when it built a
58    /// `DequantMatMul` graph for the stage.
59    #[serde(default)]
60    pub packed: bool,
61}
62
63fn resolve_device(spec: &str) -> Device {
64    if spec.eq_ignore_ascii_case("auto") || spec.is_empty() {
65        return crate::fastest_device();
66    }
67    match crate::parse_device(spec) {
68        Ok(d) if crate::is_available(d) => d,
69        _ => crate::fastest_device(),
70    }
71}
72
73// ── weight source URIs ─────────────────────────────────────────────────────
74//
75// A model-agnostic resolver for the common on-disk formats, so the generic
76// worker can load its own shard from a path the coordinator names — weights
77// stay node-local, only the URI crosses the wire.
78
79fn split_frag(rest: &str) -> Result<(&str, &str), String> {
80    rest.split_once('#')
81        .ok_or_else(|| format!("weight URI needs a #<tensor> fragment: {rest}"))
82}
83
84/// Resolve a weight tensor to f32 from its source URI. Supported schemes:
85///   `gguf://<path>#<tensor>`         — dequantize a GGUF tensor (any K-quant)
86///   `safetensors://<path>#<tensor>`  — read a safetensors tensor (F32/F16/BF16)
87///   `mlx://<path>#<tensor>`          — MLX dir / safetensors / npz (dequant if needed)
88///   `npz://<path>#<tensor>`          — alias for MLX `.npz` / `.npy`
89///   `dduf://<path>#<qualified>`      — DDUF ZIP tensor (`component/name`)
90///   `file://<path>`                  — raw little-endian f32 array
91///   `rlxp://<path>#<tensor>`         — f32 tensor from an `.rlxp` package
92///
93/// This is model-agnostic (formats, not models); callers can still pass their
94/// own closure to [`recv_stage`]/[`serve_stage`] for other schemes.
95pub fn resolve_weight_uri(uri: &str) -> Result<Vec<f32>, String> {
96    if let Some(rest) = uri.strip_prefix("gguf://") {
97        let (path, tensor) = split_frag(rest)?;
98        let mut f = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
99        let gguf = rlx_gguf::GgufFile::from_reader(&mut f).map_err(|e| e.to_string())?;
100        let (data, _dims) = gguf.dequant_f32(tensor).map_err(|e| e.to_string())?;
101        Ok(data)
102    } else if let Some(rest) = uri.strip_prefix("rlxp://") {
103        let (path, tensor) = split_frag(rest)?;
104        let pack = rlx_pkg::Package::open(path).map_err(|e| e.to_string())?;
105        pack.tensor_f32(tensor).map_err(|e| e.to_string())
106    } else if let Some(rest) = uri.strip_prefix("safetensors://") {
107        let (path, tensor) = split_frag(rest)?;
108        let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?;
109        read_safetensors_f32(&bytes, tensor)
110    } else if let Some(rest) = uri
111        .strip_prefix("mlx://")
112        .or_else(|| uri.strip_prefix("npz://"))
113    {
114        let (path, tensor) = split_frag(rest)?;
115        let w = rlx_mlx_io::load_path(path).map_err(|e| e.to_string())?;
116        let map = w.into_f32_map().map_err(|e| e.to_string())?;
117        map.get(tensor)
118            .cloned()
119            .ok_or_else(|| format!("tensor not found in mlx weights: {tensor}"))
120    } else if let Some(rest) = uri.strip_prefix("dduf://") {
121        let (path, tensor) = split_frag(rest)?;
122        let f = rlx_dduf::DdufFile::open(path).map_err(|e| e.to_string())?;
123        f.tensor_f32(tensor)
124            .map(|s| s.to_vec())
125            .map_err(|e| e.to_string())
126    } else if let Some(path) = uri.strip_prefix("file://") {
127        let b = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?;
128        Ok(b.chunks_exact(4)
129            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
130            .collect())
131    } else {
132        Err(format!("unsupported weight URI scheme: {uri}"))
133    }
134}
135
136/// Load the **quantized bytes** of a weight as-is (no dequant), for the
137/// on-device-quant path. `gguf://<path>#<tensor>` returns the packed block
138/// bytes; `rlxp://<path>#<tensor>` returns the package shard range;
139/// `file://<path>` returns the raw file. Feed to `set_param_typed(_, _,
140/// U8)` behind a `DequantMatMul` graph.
141pub fn resolve_weight_bytes(uri: &str) -> Result<Vec<u8>, String> {
142    if let Some(rest) = uri.strip_prefix("gguf://") {
143        let (path, tensor) = split_frag(rest)?;
144        let mut f = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
145        let gguf = rlx_gguf::GgufFile::from_reader(&mut f).map_err(|e| e.to_string())?;
146        let t = gguf
147            .get(tensor)
148            .ok_or_else(|| format!("tensor not found: {tensor}"))?;
149        Ok(gguf.tensor_bytes(t).map_err(|e| e.to_string())?.to_vec())
150    } else if let Some(rest) = uri.strip_prefix("rlxp://") {
151        let (path, tensor) = split_frag(rest)?;
152        let pack = rlx_pkg::Package::open(path).map_err(|e| e.to_string())?;
153        pack.tensor_bytes(tensor).map_err(|e| e.to_string())
154    } else if let Some(path) = uri.strip_prefix("file://") {
155        std::fs::read(path).map_err(|e| format!("read {path}: {e}"))
156    } else {
157        Err(format!("packed load unsupported for scheme: {uri}"))
158    }
159}
160
161/// Parse-once, serve-many weight cache. A GGUF / `.rlxp` file is parsed a
162/// single time and then serves any number of its tensors (f32 or packed) —
163/// the right shape for a worker whose stage pulls many tensors (q/k/v/o/ffn)
164/// from one model file. Created per stage inside [`recv_stage`]; also usable
165/// standalone.
166#[derive(Default)]
167pub struct WeightCache {
168    gguf: HashMap<String, rlx_gguf::GgufFile>,
169    rlxp: HashMap<String, rlx_pkg::Package>,
170}
171
172impl WeightCache {
173    pub fn new() -> Self {
174        Self::default()
175    }
176
177    fn gguf_file(&mut self, path: &str) -> Result<&rlx_gguf::GgufFile, String> {
178        if !self.gguf.contains_key(path) {
179            let mut f = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
180            let g = rlx_gguf::GgufFile::from_reader(&mut f).map_err(|e| e.to_string())?;
181            self.gguf.insert(path.to_string(), g);
182        }
183        Ok(&self.gguf[path])
184    }
185
186    fn rlxp_pack(&mut self, path: &str) -> Result<&rlx_pkg::Package, String> {
187        if !self.rlxp.contains_key(path) {
188            let p = rlx_pkg::Package::open(path).map_err(|e| e.to_string())?;
189            self.rlxp.insert(path.to_string(), p);
190        }
191        Ok(&self.rlxp[path])
192    }
193
194    /// Dequantized f32 for `uri`, reusing the parsed file for `gguf://` / `rlxp://`.
195    pub fn f32(&mut self, uri: &str) -> Result<Vec<f32>, String> {
196        if let Some(rest) = uri.strip_prefix("gguf://") {
197            let (path, tensor) = split_frag(rest)?;
198            let (data, _dims) = self
199                .gguf_file(path)?
200                .dequant_f32(tensor)
201                .map_err(|e| e.to_string())?;
202            Ok(data)
203        } else if let Some(rest) = uri.strip_prefix("rlxp://") {
204            let (path, tensor) = split_frag(rest)?;
205            self.rlxp_pack(path)?
206                .tensor_f32(tensor)
207                .map_err(|e| e.to_string())
208        } else if uri.starts_with("safetensors://")
209            || uri.starts_with("file://")
210            || uri.starts_with("mlx://")
211            || uri.starts_with("npz://")
212            || uri.starts_with("dduf://")
213        {
214            resolve_weight_uri(uri) // header parse is cheap / file is the tensor
215        } else {
216            Err(format!("unsupported weight URI scheme: {uri}"))
217        }
218    }
219
220    /// Packed (quantized) bytes for `uri`, reusing the parsed file for `gguf://` / `rlxp://`.
221    pub fn bytes(&mut self, uri: &str) -> Result<Vec<u8>, String> {
222        if let Some(rest) = uri.strip_prefix("gguf://") {
223            let (path, tensor) = split_frag(rest)?;
224            let g = self.gguf_file(path)?;
225            let t = g
226                .get(tensor)
227                .ok_or_else(|| format!("tensor not found: {tensor}"))?;
228            Ok(g.tensor_bytes(t).map_err(|e| e.to_string())?.to_vec())
229        } else if let Some(rest) = uri.strip_prefix("rlxp://") {
230            let (path, tensor) = split_frag(rest)?;
231            self.rlxp_pack(path)?
232                .tensor_bytes(tensor)
233                .map_err(|e| e.to_string())
234        } else {
235            resolve_weight_bytes(uri)
236        }
237    }
238}
239
240/// Built-in resolver for [`serve_stage_uri`]: [`resolve_weight_uri`], logging
241/// and returning empty on failure so a missing shard surfaces loudly rather
242/// than panicking mid-collective.
243pub fn uri_resolver(uri: &str) -> Vec<f32> {
244    match resolve_weight_uri(uri) {
245        Ok(v) => v,
246        Err(e) => {
247            eprintln!("rlx dist: weight resolve failed [{uri}]: {e}");
248            Vec::new()
249        }
250    }
251}
252
253// bf16 is the top 16 bits of an f32.
254fn bf16_to_f32(b: u16) -> f32 {
255    f32::from_bits((b as u32) << 16)
256}
257
258// IEEE-754 half → f32 (handles subnormal / inf / nan).
259fn f16_to_f32(h: u16) -> f32 {
260    let sign = (h >> 15) & 1;
261    let exp = (h >> 10) & 0x1f;
262    let mant = h & 0x3ff;
263    let v = match exp {
264        0 => (mant as f32) * 2f32.powi(-24), // 0 or subnormal
265        0x1f if mant == 0 => f32::INFINITY,
266        0x1f => f32::NAN,
267        _ => (1.0 + mant as f32 / 1024.0) * 2f32.powi(exp as i32 - 15),
268    };
269    if sign == 1 { -v } else { v }
270}
271
272/// Minimal safetensors reader: `[u64 header_len][JSON header][data]`; returns
273/// the named tensor as f32 (F32 / F16 / BF16). No external crate needed.
274fn read_safetensors_f32(buf: &[u8], name: &str) -> Result<Vec<f32>, String> {
275    if buf.len() < 8 {
276        return Err("safetensors file too small".into());
277    }
278    let hlen = u64::from_le_bytes(buf[..8].try_into().unwrap()) as usize;
279    let header = buf
280        .get(8..8 + hlen)
281        .ok_or("safetensors: truncated header")?;
282    let data = &buf[8 + hlen..];
283    let v: serde_json::Value = serde_json::from_slice(header).map_err(|e| e.to_string())?;
284    let t = v
285        .get(name)
286        .ok_or_else(|| format!("tensor not found: {name}"))?;
287    let dtype = t.get("dtype").and_then(|d| d.as_str()).ok_or("no dtype")?;
288    let off = t
289        .get("data_offsets")
290        .and_then(|o| o.as_array())
291        .ok_or("no data_offsets")?;
292    let start = off[0].as_u64().ok_or("bad data_offsets")? as usize;
293    let end = off[1].as_u64().ok_or("bad data_offsets")? as usize;
294    let raw = data
295        .get(start..end)
296        .ok_or("safetensors: data_offsets out of range")?;
297    Ok(match dtype {
298        "F32" => raw
299            .chunks_exact(4)
300            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
301            .collect(),
302        "F16" => raw
303            .chunks_exact(2)
304            .map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]])))
305            .collect(),
306        "BF16" => raw
307            .chunks_exact(2)
308            .map(|c| bf16_to_f32(u16::from_le_bytes([c[0], c[1]])))
309            .collect(),
310        other => return Err(format!("unsupported safetensors dtype: {other}")),
311    })
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn resolve_safetensors_f32() {
320        let vals = [1.0f32, 2.0, -3.5, 4.25];
321        let data: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
322        let header = format!(
323            r#"{{"w":{{"dtype":"F32","shape":[4],"data_offsets":[0,{}]}}}}"#,
324            data.len()
325        );
326        let mut buf = (header.len() as u64).to_le_bytes().to_vec();
327        buf.extend_from_slice(header.as_bytes());
328        buf.extend_from_slice(&data);
329        let path =
330            std::env::temp_dir().join(format!("rlx_dist_{}.safetensors", std::process::id()));
331        std::fs::write(&path, &buf).unwrap();
332        let got = resolve_weight_uri(&format!("safetensors://{}#w", path.display())).unwrap();
333        std::fs::remove_file(&path).ok();
334        assert_eq!(got, vals);
335    }
336
337    #[test]
338    fn resolve_gguf_f32() {
339        use rlx_gguf::{GgmlType, GgufWriter};
340        let vals = [1.0f32, 2.0, -3.5, 4.25];
341        let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
342        let mut w = GgufWriter::new();
343        w.add_tensor_bytes("w", vec![4], GgmlType::F32, bytes)
344            .unwrap();
345        let path = std::env::temp_dir().join(format!("rlx_dist_{}.gguf", std::process::id()));
346        w.write_to_path(&path).unwrap();
347        let got = resolve_weight_uri(&format!("gguf://{}#w", path.display())).unwrap();
348        std::fs::remove_file(&path).ok();
349        assert_eq!(got, vals);
350    }
351
352    #[test]
353    fn weight_cache_serves_many_tensors_from_one_file() {
354        use rlx_gguf::{GgmlType, GgufWriter};
355        let a = [1.0f32, 2.0, 3.0, 4.0];
356        let b = [10.0f32, 20.0];
357        let mut w = GgufWriter::new();
358        w.add_tensor_bytes(
359            "a",
360            vec![4],
361            GgmlType::F32,
362            a.iter().flat_map(|v| v.to_le_bytes()).collect(),
363        )
364        .unwrap();
365        w.add_tensor_bytes(
366            "b",
367            vec![2],
368            GgmlType::F32,
369            b.iter().flat_map(|v| v.to_le_bytes()).collect(),
370        )
371        .unwrap();
372        let path = std::env::temp_dir().join(format!("rlx_cache_{}.gguf", std::process::id()));
373        w.write_to_path(&path).unwrap();
374        let p = path.display();
375
376        // One cache, two tensors from the same parsed file.
377        let mut cache = WeightCache::new();
378        assert_eq!(cache.f32(&format!("gguf://{p}#a")).unwrap(), a);
379        assert_eq!(cache.f32(&format!("gguf://{p}#b")).unwrap(), b);
380        // packed bytes of the F32 tensor are exactly its little-endian f32 bytes.
381        let raw = cache.bytes(&format!("gguf://{p}#a")).unwrap();
382        let raw_f32: Vec<f32> = raw
383            .chunks_exact(4)
384            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
385            .collect();
386        assert_eq!(raw_f32, a);
387        std::fs::remove_file(&path).ok();
388    }
389
390    #[test]
391    fn resolve_rlxp_f32() {
392        use rlx_ir::op::BinaryOp;
393        use rlx_ir::{DType, Graph, Shape};
394        use rlx_pkg::{BakeWeight, WriteOptions, package_from_bake};
395
396        let s = Shape::new(&[4], DType::F32);
397        let mut g = Graph::new("rlxp_uri");
398        let x = g.input("x", s.clone());
399        let w = g.param("w", s.clone());
400        let y = g.binary(BinaryOp::Mul, x, w, s);
401        g.set_outputs(vec![y]);
402        let vals = [1.0f32, 2.0, -3.5, 4.25];
403        let weights = [BakeWeight {
404            name: "w".into(),
405            shape: vec![4],
406            encoding: "f32".into(),
407            data: vals.iter().flat_map(|v| v.to_le_bytes()).collect(),
408        }];
409        let dir = tempfile::tempdir().unwrap();
410        let path = dir.path().join("m.rlxp");
411        package_from_bake(
412            &path,
413            &g,
414            &weights,
415            WriteOptions {
416                container: rlx_pkg::ContainerKind::Flat,
417                name: "rlxp_uri".into(),
418                ..WriteOptions::default()
419            },
420        )
421        .unwrap();
422        let got = resolve_weight_uri(&format!("rlxp://{}#w", path.display())).unwrap();
423        assert_eq!(got, vals);
424        let mut cache = WeightCache::new();
425        assert_eq!(
426            cache.f32(&format!("rlxp://{}#w", path.display())).unwrap(),
427            vals
428        );
429    }
430
431    #[test]
432    fn dequant_matmul_gguf_end_to_end() {
433        // Quantize a weight to Q8_0, feed the PACKED bytes as a U8 param behind
434        // a DequantMatMul graph, and check the output matches dequant-then-
435        // matmul — the quantized-on-device path, weight at native quant memory.
436        use crate::Session;
437        use rlx_ir::quant::QuantScheme;
438        use rlx_ir::{DType, Graph, Shape};
439
440        let (m, k, n) = (2usize, 32usize, 4usize); // K multiple of 32 for Q8_0
441        let w_floats: Vec<f32> = (0..n * k).map(|i| ((i % 17) as f32 - 8.0) * 0.1).collect();
442        let packed = rlx_gguf::quantize(&w_floats, rlx_gguf::GgmlType::Q8_0).unwrap();
443        let x: Vec<f32> = (0..m * k).map(|i| ((i % 13) as f32) * 0.05).collect();
444
445        let mut g = Graph::new("dq");
446        let xin = g.input("x", Shape::new(&[m, k], DType::F32));
447        let wp = g.param("W", Shape::new(&[packed.len()], DType::U8));
448        let out = g.dequant_matmul_packed(
449            xin,
450            wp,
451            QuantScheme::GgufQ8_0,
452            Shape::new(&[m, n], DType::F32),
453        );
454        g.set_outputs(vec![out]);
455
456        let mut c = Session::new(Device::Cpu).compile(g);
457        c.set_param_typed("W", &packed, DType::U8);
458        let got = c.run(&[("x", x.as_slice())]).into_iter().next().unwrap();
459
460        // Reference: dequant the SAME bytes, then BT-matmul (W is [n, k]).
461        let w = rlx_gguf::dequant_q8_0(&packed, n * k).unwrap();
462        let mut expect = vec![0f32; m * n];
463        for i in 0..m {
464            for j in 0..n {
465                let mut acc = 0f32;
466                for c2 in 0..k {
467                    acc += x[i * k + c2] * w[j * k + c2];
468                }
469                expect[i * n + j] = acc;
470            }
471        }
472        let err = got
473            .iter()
474            .zip(&expect)
475            .map(|(a, b)| (a - b).abs())
476            .fold(0f32, f32::max);
477        assert!(err < 1e-3, "max_err={err} got={got:?} expect={expect:?}");
478    }
479}