Skip to main content

rlx_runtime/dist/
inference.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//! Ship-graph **inference**: partition a model into stages, ship each worker a
5//! self-describing `StageSpec`, and drive activations rank→rank. Shared weight
6//! loading + device resolution live in the parent `dist` module.
7
8use super::{WeightCache, WeightRef, resolve_device};
9use crate::compiled::CompiledGraph;
10use crate::{Device, Session};
11use rlx_driver::ProcessGroup;
12use rlx_ir::Graph;
13use serde::{Deserialize, Serialize};
14
15/// Wire tags for the ship-graph protocol (kept clear of the reserved
16/// collective range in `rlx-driver`).
17const TAG_SPEC: u32 = 40;
18const TAG_ACT: u32 = 41;
19
20/// A self-describing unit of work for a worker: everything needed to run one
21/// stage of a model, with nothing implied by convention.
22#[derive(Serialize, Deserialize, Clone, Debug)]
23pub struct StageSpec {
24    pub graph: Graph,
25    /// Input node name the activation feeds.
26    pub input: String,
27    /// Output node name (informational; `run` returns the single output).
28    pub output: String,
29    pub weights: Vec<WeightRef>,
30    /// Device directive: `auto` (fastest available) or a device name
31    /// (`cuda`, `metal`, `cpu`, …). Honored against local availability.
32    pub device: String,
33}
34
35impl StageSpec {
36    /// A stage whose weights all come from `uri_for(param_name)`, on `device`.
37    pub fn new(
38        graph: Graph,
39        input: impl Into<String>,
40        output: impl Into<String>,
41        device: impl Into<String>,
42    ) -> Self {
43        Self {
44            graph,
45            input: input.into(),
46            output: output.into(),
47            weights: Vec::new(),
48            device: device.into(),
49        }
50    }
51    /// Add an f32 weight (dequantized on load).
52    pub fn weight(mut self, name: impl Into<String>, uri: impl Into<String>) -> Self {
53        self.weights.push(WeightRef {
54            name: name.into(),
55            uri: uri.into(),
56            packed: false,
57        });
58        self
59    }
60    /// Add a **quantized** weight loaded as packed bytes (for a `DequantMatMul`
61    /// graph) — native quant memory, no dequant round-trip.
62    pub fn weight_packed(mut self, name: impl Into<String>, uri: impl Into<String>) -> Self {
63        self.weights.push(WeightRef {
64            name: name.into(),
65            uri: uri.into(),
66            packed: true,
67        });
68        self
69    }
70}
71
72/// Coordinator: ship a stage spec to worker `rank`.
73pub fn ship_stage(group: &ProcessGroup, rank: u32, spec: &StageSpec) -> Result<(), String> {
74    let bytes = serde_json::to_vec(spec).map_err(|e| e.to_string())?;
75    group
76        .transport()
77        .send_bytes(rank, TAG_SPEC, &bytes)
78        .map_err(|e| format!("ship_stage: {e}"))
79}
80
81/// Send an activation tensor to `to` (a worker, or the next pipeline stage).
82pub fn send_activation(group: &ProcessGroup, to: u32, data: &[f32]) -> Result<(), String> {
83    group.send_f32(to, TAG_ACT, data).map_err(|e| e.to_string())
84}
85
86/// Receive an activation tensor from `from`.
87pub fn recv_activation(group: &ProcessGroup, from: u32) -> Result<Vec<f32>, String> {
88    group.recv_f32(from, TAG_ACT).map_err(|e| e.to_string())
89}
90
91/// A compiled stage sitting on this worker, ready to run activations.
92pub struct WorkerStage {
93    /// The backend this worker resolved the stage onto.
94    pub device: Device,
95    /// Node count of the received graph (handy for logging).
96    pub nodes: usize,
97    input: String,
98    compiled: CompiledGraph,
99}
100
101impl WorkerStage {
102    /// Run one activation through the stage; returns the (single) output.
103    pub fn run(&mut self, x: &[f32]) -> Vec<f32> {
104        self.compiled
105            .run(&[(self.input.as_str(), x)])
106            .into_iter()
107            .next()
108            .unwrap_or_default()
109    }
110    pub fn input_name(&self) -> &str {
111        &self.input
112    }
113}
114
115/// Worker: receive a [`StageSpec`] from the coordinator (rank 0), resolve its
116/// weights via `resolve` (`uri -> f32`), pick the directed device, and compile.
117/// This is the generic worker's setup — the same code runs any model's stage.
118///
119/// `resolve` is where the caller plugs in weight loading (GGUF / safetensors /
120/// HF), keeping rlx core model-agnostic. It runs on the worker, so the weights
121/// never cross the wire.
122pub fn recv_stage<F>(group: &ProcessGroup, mut fallback: F) -> Result<WorkerStage, String>
123where
124    F: FnMut(&str) -> Vec<f32>,
125{
126    let bytes = group
127        .transport()
128        .recv_bytes(0, TAG_SPEC)
129        .map_err(|e| format!("recv_stage: {e}"))?;
130    let spec: StageSpec = serde_json::from_slice(&bytes).map_err(|e| format!("StageSpec: {e}"))?;
131    let device = resolve_device(&spec.device);
132    let nodes = spec.graph.len();
133    let mut compiled = Session::new(device).compile(spec.graph);
134    // Parse-once, serve-many: one `WeightCache` across the stage's weights, so
135    // many tensors from the same GGUF are read after a single parse.
136    let mut cache = WeightCache::new();
137    for w in &spec.weights {
138        if w.packed {
139            // Quantized-on-device: raw packed bytes → U8 param. The graph's
140            // DequantMatMul decodes at matmul time (native quant memory).
141            let bytes = cache
142                .bytes(&w.uri)
143                .map_err(|e| format!("weight {}: {e}", w.name))?;
144            compiled.set_param_typed(&w.name, &bytes, rlx_ir::DType::U8);
145        } else {
146            // f32: built-in gguf:// / safetensors:// / file:// (cached), else
147            // the caller's `fallback` resolver (custom schemes like seed://).
148            let vals = cache.f32(&w.uri).unwrap_or_else(|_| fallback(&w.uri));
149            compiled.set_param(&w.name, &vals);
150        }
151    }
152    Ok(WorkerStage {
153        device,
154        nodes,
155        input: spec.input,
156        compiled,
157    })
158}
159
160/// Worker convenience: set up the stage, then run one activation cycle —
161/// receive from the previous rank (or an external feed for rank 1), run, and
162/// forward to the next rank (or back to the coordinator if last). Returns the
163/// device it ran on. For a serving loop, call [`recv_stage`] once and drive
164/// [`WorkerStage::run`] + [`recv_activation`]/[`send_activation`] yourself.
165pub fn serve_stage<F>(group: &ProcessGroup, resolve: F) -> Result<Device, String>
166where
167    F: FnMut(&str) -> Vec<f32>,
168{
169    let n = group.world_size();
170    let rank = group.rank();
171    let mut stage = recv_stage(group, resolve)?;
172    let input = recv_activation(group, rank - 1)?;
173    let out = stage.run(&input);
174    let next = if rank + 1 < n { rank + 1 } else { 0 };
175    send_activation(group, next, &out)?;
176    Ok(stage.device)
177}
178
179/// Same as [`serve_stage`] but with no custom resolver — a worker binary that
180/// serves GGUF / safetensors / raw-f32 models out of the box (`recv_stage`'s
181/// built-in [`WeightCache`] handles those schemes; anything else warns).
182pub fn serve_stage_uri(group: &ProcessGroup) -> Result<Device, String> {
183    serve_stage(group, |uri| {
184        eprintln!("rlx dist: no resolver for weight URI [{uri}]");
185        Vec::new()
186    })
187}