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    /// Identifies which synthesized datapath this stage corresponds to, for
34    /// pre-synthesized fixed-function ranks (FPGA bitstreams). `None` for
35    /// ordinary stages, which any worker can compile. See
36    /// [`node::FixedFunction`](crate::dist::node::FixedFunction).
37    #[serde(default)]
38    pub stage_id: Option<String>,
39}
40
41impl StageSpec {
42    /// A stage whose weights all come from `uri_for(param_name)`, on `device`.
43    pub fn new(
44        graph: Graph,
45        input: impl Into<String>,
46        output: impl Into<String>,
47        device: impl Into<String>,
48    ) -> Self {
49        Self {
50            graph,
51            input: input.into(),
52            output: output.into(),
53            weights: Vec::new(),
54            device: device.into(),
55            stage_id: None,
56        }
57    }
58
59    /// Tag this stage with the synthesized datapath it targets, so a
60    /// fixed-function rank can accept (or refuse) it at placement time.
61    pub fn stage_id(mut self, id: impl Into<String>) -> Self {
62        self.stage_id = Some(id.into());
63        self
64    }
65    /// Add an f32 weight (dequantized on load).
66    pub fn weight(mut self, name: impl Into<String>, uri: impl Into<String>) -> Self {
67        self.weights.push(WeightRef {
68            name: name.into(),
69            uri: uri.into(),
70            packed: false,
71        });
72        self
73    }
74    /// Add a **quantized** weight loaded as packed bytes (for a `DequantMatMul`
75    /// graph) — native quant memory, no dequant round-trip.
76    pub fn weight_packed(mut self, name: impl Into<String>, uri: impl Into<String>) -> Self {
77        self.weights.push(WeightRef {
78            name: name.into(),
79            uri: uri.into(),
80            packed: true,
81        });
82        self
83    }
84}
85
86/// Coordinator: ship a stage spec to worker `rank`.
87pub fn ship_stage(group: &ProcessGroup, rank: u32, spec: &StageSpec) -> Result<(), String> {
88    let bytes = serde_json::to_vec(spec).map_err(|e| e.to_string())?;
89    group
90        .transport()
91        .send_bytes(rank, TAG_SPEC, &bytes)
92        .map_err(|e| format!("ship_stage: {e}"))
93}
94
95/// Send an activation tensor to `to` (a worker, or the next pipeline stage).
96pub fn send_activation(group: &ProcessGroup, to: u32, data: &[f32]) -> Result<(), String> {
97    group.send_f32(to, TAG_ACT, data).map_err(|e| e.to_string())
98}
99
100/// Receive an activation tensor from `from`.
101pub fn recv_activation(group: &ProcessGroup, from: u32) -> Result<Vec<f32>, String> {
102    group.recv_f32(from, TAG_ACT).map_err(|e| e.to_string())
103}
104
105/// A compiled stage sitting on this worker, ready to run activations.
106pub struct WorkerStage {
107    /// The backend this worker resolved the stage onto.
108    pub device: Device,
109    /// Node count of the received graph (handy for logging).
110    pub nodes: usize,
111    input: String,
112    compiled: CompiledGraph,
113}
114
115impl WorkerStage {
116    /// Run one activation through the stage; returns the (single) output.
117    pub fn run(&mut self, x: &[f32]) -> Vec<f32> {
118        self.compiled
119            .run(&[(self.input.as_str(), x)])
120            .into_iter()
121            .next()
122            .unwrap_or_default()
123    }
124    pub fn input_name(&self) -> &str {
125        &self.input
126    }
127}
128
129/// Worker: receive a [`StageSpec`] from the coordinator (rank 0), resolve its
130/// weights via `resolve` (`uri -> f32`), pick the directed device, and compile.
131/// This is the generic worker's setup — the same code runs any model's stage.
132///
133/// `resolve` is where the caller plugs in weight loading (GGUF / safetensors /
134/// HF), keeping rlx core model-agnostic. It runs on the worker, so the weights
135/// never cross the wire.
136pub fn recv_stage<F>(group: &ProcessGroup, mut fallback: F) -> Result<WorkerStage, String>
137where
138    F: FnMut(&str) -> Vec<f32>,
139{
140    let bytes = group
141        .transport()
142        .recv_bytes(0, TAG_SPEC)
143        .map_err(|e| format!("recv_stage: {e}"))?;
144    let spec: StageSpec = serde_json::from_slice(&bytes).map_err(|e| format!("StageSpec: {e}"))?;
145    let device = resolve_device(&spec.device);
146    let nodes = spec.graph.len();
147    let mut compiled = Session::new(device).compile(spec.graph);
148    // Parse-once, serve-many: one `WeightCache` across the stage's weights, so
149    // many tensors from the same GGUF are read after a single parse.
150    let mut cache = WeightCache::new();
151    for w in &spec.weights {
152        if w.packed {
153            // Quantized-on-device: raw packed bytes → U8 param. The graph's
154            // DequantMatMul decodes at matmul time (native quant memory).
155            let bytes = cache
156                .bytes(&w.uri)
157                .map_err(|e| format!("weight {}: {e}", w.name))?;
158            compiled.set_param_typed(&w.name, &bytes, rlx_ir::DType::U8);
159        } else {
160            // f32: built-in gguf:// / safetensors:// / file:// (cached), else
161            // the caller's `fallback` resolver (custom schemes like seed://).
162            let vals = cache.f32(&w.uri).unwrap_or_else(|_| fallback(&w.uri));
163            compiled.set_param(&w.name, &vals);
164        }
165    }
166    Ok(WorkerStage {
167        device,
168        nodes,
169        input: spec.input,
170        compiled,
171    })
172}
173
174/// Worker convenience: set up the stage, then run one activation cycle —
175/// receive from the previous rank (or an external feed for rank 1), run, and
176/// forward to the next rank (or back to the coordinator if last). Returns the
177/// device it ran on. For a serving loop, call [`recv_stage`] once and drive
178/// [`WorkerStage::run`] + [`recv_activation`]/[`send_activation`] yourself.
179pub fn serve_stage<F>(group: &ProcessGroup, resolve: F) -> Result<Device, String>
180where
181    F: FnMut(&str) -> Vec<f32>,
182{
183    let n = group.world_size();
184    let rank = group.rank();
185    let mut stage = recv_stage(group, resolve)?;
186    let input = recv_activation(group, rank - 1)?;
187    let out = stage.run(&input);
188    let next = if rank + 1 < n { rank + 1 } else { 0 };
189    send_activation(group, next, &out)?;
190    Ok(stage.device)
191}
192
193/// Same as [`serve_stage`] but with no custom resolver — a worker binary that
194/// serves GGUF / safetensors / raw-f32 models out of the box (`recv_stage`'s
195/// built-in [`WeightCache`] handles those schemes; anything else warns).
196pub fn serve_stage_uri(group: &ProcessGroup) -> Result<Device, String> {
197    serve_stage(group, |uri| {
198        eprintln!("rlx dist: no resolver for weight URI [{uri}]");
199        Vec::new()
200    })
201}