Skip to main content

rlx_runtime/dist/
inference.rs

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