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}