Skip to main content

rlx_runtime/dist/
mod.rs

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