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