rlx_runtime/dist/node.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
5//! Platform-neutral **node driver** — the piece a non-desktop shell links.
6//!
7//! Everything a node needs to join a mesh and serve work, with **no
8//! environment variables, no process spawning, and no stdout contract**. That
9//! matters because the existing node driver lives in an *example*
10//! (`rlx-collectives/examples/dist_node.rs`), and an example cannot be linked
11//! into an Android `.so`, an iOS framework, or an embedded host shim.
12//!
13//! ```no_run
14//! # use rlx_runtime::dist::node::{NodeConfig, serve_worker};
15//! # fn demo() -> Result<(), String> {
16//! let cfg = NodeConfig::new(1, 2)
17//! .peers(["192.168.1.10:29500", "192.168.1.11:29501"])?
18//! .device("auto");
19//! let group = cfg.connect().map_err(|e| e.to_string())?;
20//! let report = serve_worker(&group, |_uri| Vec::new())?;
21//! # let _ = report; Ok(()) }
22//! ```
23//!
24//! # Roles
25//!
26//! * [`NodeRole::Worker`] — the general case: compile and run whatever stage
27//! the coordinator ships. Any host with a CPU qualifies (phone, SBC, laptop).
28//! * [`NodeRole::FixedFunction`] — a **pre-synthesized** accelerator whose
29//! datapath is baked into a bitstream (an FPGA). It cannot compile a shipped
30//! graph, so it advertises the one stage it implements and refuses anything
31//! else. See [`FixedFunction`].
32
33use super::{StageSpec, WeightCache, recv_activation, resolve_device, send_activation};
34use crate::Device;
35use rlx_driver::{Node, ReduceKind};
36
37/// Re-exported so a shell can name what [`NodeConfig::connect`] hands back
38/// without adding a dependency on the transport crate. The node API is meant
39/// to be the whole surface a non-Rust shell needs — `.star()` / `.mesh()`
40/// exist for the same reason.
41pub use rlx_driver::{ProcessGroup, Topology};
42use rlx_ir::DType;
43use serde::{Deserialize, Serialize};
44use std::sync::Arc;
45use std::sync::atomic::{AtomicBool, Ordering};
46
47/// Wire tag for the capability handshake. Kept clear of the ship-graph tags
48/// (`40`/`41` in [`super::inference`]) and the reserved collective range.
49const TAG_CAPS: u32 = 42;
50
51// ── fixed-function (pre-synthesized) ranks ────────────────────────────────
52
53/// A stage baked into hardware that cannot be reprogrammed at run time.
54///
55/// An FPGA bitstream implements exactly one datapath. Synthesis takes minutes,
56/// so the mesh cannot ship it a graph the way it ships one to a CPU/GPU worker
57/// — which is why `rlx-fpga` is a code generator and deliberately does not
58/// implement the runtime `Backend` trait. Instead the board joins as a rank
59/// that advertises *this* descriptor, and the coordinator only ever assigns it
60/// the matching stage.
61#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
62pub struct FixedFunction {
63 /// Identifier of the synthesized stage. The coordinator sets the same id on
64 /// the [`StageSpec`] it ships; a mismatch is refused before any work moves.
65 pub stage_id: String,
66 /// Element count of the activation this datapath consumes.
67 pub input_elems: usize,
68 /// Element count it produces.
69 pub output_elems: usize,
70 /// Element type the datapath was synthesized for (INT8 datapaths are common
71 /// on FPGA; the host still exchanges f32 on the wire and the board shim
72 /// does the conversion).
73 pub dtype: DType,
74}
75
76impl FixedFunction {
77 pub fn new(
78 stage_id: impl Into<String>,
79 input_elems: usize,
80 output_elems: usize,
81 dtype: DType,
82 ) -> Self {
83 Self {
84 stage_id: stage_id.into(),
85 input_elems,
86 output_elems,
87 dtype,
88 }
89 }
90
91 /// Whether this hardware can run `spec`. Checked by the **coordinator**
92 /// before shipping, so a mismatch is a clear error at placement time rather
93 /// than a hang or a wrong number at execution time.
94 pub fn accepts(&self, spec: &StageSpec) -> Result<(), String> {
95 match spec.stage_id.as_deref() {
96 Some(id) if id == self.stage_id => Ok(()),
97 Some(id) => Err(format!(
98 "fixed-function rank implements stage [{}], cannot run [{id}]: \
99 an FPGA datapath is synthesized, not compiled",
100 self.stage_id
101 )),
102 None => Err(format!(
103 "fixed-function rank implements stage [{}], but the shipped \
104 StageSpec carries no stage_id — set StageSpec::stage_id to \
105 target pre-synthesized hardware",
106 self.stage_id
107 )),
108 }
109 }
110}
111
112/// The host-side driver for a piece of fixed-function hardware.
113///
114/// Implemented per board/link (PCIe, AXI on a Zynq PS, USB, UART). Keeping it a
115/// trait means the mesh side is testable with [`LoopbackFixedFunction`] and no
116/// hardware attached.
117pub trait FixedFunctionDevice: Send {
118 /// The stage this board implements — must match the advertised descriptor.
119 fn stage_id(&self) -> &str;
120 /// Push one activation to the board and read the result back.
121 fn execute(&mut self, input: &[f32]) -> Result<Vec<f32>, String>;
122}
123
124/// A software stand-in for a synthesized board, so the fixed-function mesh path
125/// can be exercised in tests and on a dev machine with no FPGA attached.
126pub struct LoopbackFixedFunction {
127 stage_id: String,
128 f: Box<dyn FnMut(&[f32]) -> Vec<f32> + Send>,
129}
130
131impl LoopbackFixedFunction {
132 pub fn new(
133 stage_id: impl Into<String>,
134 f: impl FnMut(&[f32]) -> Vec<f32> + Send + 'static,
135 ) -> Self {
136 Self {
137 stage_id: stage_id.into(),
138 f: Box::new(f),
139 }
140 }
141}
142
143impl FixedFunctionDevice for LoopbackFixedFunction {
144 fn stage_id(&self) -> &str {
145 &self.stage_id
146 }
147 fn execute(&mut self, input: &[f32]) -> Result<Vec<f32>, String> {
148 Ok((self.f)(input))
149 }
150}
151
152// ── node identity ─────────────────────────────────────────────────────────
153
154/// What kind of work a node accepts.
155#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
156pub enum NodeRole {
157 /// Compiles and runs any stage shipped to it.
158 #[default]
159 Worker,
160 /// Runs exactly one pre-synthesized stage.
161 FixedFunction(FixedFunction),
162}
163
164/// What a node tells the coordinator about itself, so placement is driven by
165/// what the hardware can actually do rather than by assumption.
166#[derive(Serialize, Deserialize, Clone, Debug)]
167pub struct NodeCaps {
168 pub rank: u32,
169 pub role: NodeRole,
170 /// Backends live on this node, as `Device::as_arg` tokens (the wire stays
171 /// stable even if the `Device` enum gains variants).
172 pub devices: Vec<String>,
173 /// Free-form platform tag (`"ios"`, `"android"`, `"linux"`, …) for logs and
174 /// placement heuristics.
175 pub platform: String,
176}
177
178impl NodeCaps {
179 /// Whether this node can run `spec` at all.
180 pub fn accepts(&self, spec: &StageSpec) -> Result<(), String> {
181 match &self.role {
182 NodeRole::Worker => Ok(()),
183 NodeRole::FixedFunction(ff) => ff.accepts(spec),
184 }
185 }
186
187 /// Whether this node can join a **training** run.
188 ///
189 /// A coordinator must ask before shipping a
190 /// [`TrainSpec`](super::TrainSpec). Shipping one to a rank that cannot
191 /// train does not fail loudly: the spec lands on a tag that rank never
192 /// reads, and then the coordinator blocks forever on the first gradient
193 /// all-reduce, because a barrier needs every rank to arrive.
194 pub fn can_train(&self) -> Result<(), String> {
195 match &self.role {
196 NodeRole::Worker => Ok(()),
197 NodeRole::FixedFunction(ff) => Err(format!(
198 "rank {} implements the pre-synthesized stage [{}] and cannot \
199 train: a bitstream has no backward pass, and a rank that never \
200 reduces stalls every rank that does",
201 self.rank, ff.stage_id
202 )),
203 }
204 }
205}
206
207/// Compile-time platform tag. Not a capability — just an identifier, so a
208/// coordinator log says *which* kind of node answered.
209pub const fn platform_tag() -> &'static str {
210 if cfg!(target_os = "ios") {
211 "ios"
212 } else if cfg!(target_os = "android") {
213 "android"
214 } else if cfg!(target_os = "macos") {
215 "macos"
216 } else if cfg!(target_os = "linux") {
217 "linux"
218 } else if cfg!(target_os = "windows") {
219 "windows"
220 } else if cfg!(target_arch = "wasm32") {
221 "wasm"
222 } else {
223 "unknown"
224 }
225}
226
227// ── peer configuration ────────────────────────────────────────────────────
228
229/// How this node finds its peers.
230#[derive(Clone, Debug)]
231pub enum PeerSource {
232 /// Explicit `host:port` list, index = rank.
233 Static(Vec<String>),
234 /// UDP rendezvous. `via` unicasts the query to a known host instead of
235 /// broadcasting — the path that works from a NAT'd or broadcast-restricted
236 /// network (mobile hotspots, Docker, QEMU).
237 Discover {
238 disc_port: u16,
239 data_base: u16,
240 via: Option<String>,
241 },
242}
243
244impl Default for PeerSource {
245 fn default() -> Self {
246 Self::Static(Vec::new())
247 }
248}
249
250// ── node configuration ────────────────────────────────────────────────────
251
252/// Programmatic node configuration.
253///
254/// Every field is settable in code. [`NodeConfig::from_env`] exists for parity
255/// with the CLI workflow, but nothing here *requires* an environment — an iOS
256/// app or an Android service has no meaningful `RANK`/`PEERS` to read.
257#[derive(Clone, Debug)]
258pub struct NodeConfig {
259 pub rank: u32,
260 pub world: u32,
261 pub peers: PeerSource,
262 pub topology: Topology,
263 /// Device directive: `auto` (node's fastest) or a name (`cpu`, `metal`, …).
264 pub device: String,
265 pub heap_bytes: Option<usize>,
266 pub role: NodeRole,
267}
268
269impl NodeConfig {
270 pub fn new(rank: u32, world: u32) -> Self {
271 Self {
272 rank,
273 world,
274 peers: PeerSource::default(),
275 topology: Topology::Mesh,
276 device: "auto".into(),
277 heap_bytes: None,
278 role: NodeRole::Worker,
279 }
280 }
281
282 /// Static peer list.
283 ///
284 /// Accepts either form:
285 /// * `world` addresses, indexed by rank — a full mesh, where every rank
286 /// must be able to reach every other.
287 /// * exactly one address — the **coordinator**, for a star. This is what a
288 /// phone can actually supply: a handset does not know its own reachable
289 /// address, and on most Wi-Fi it cannot accept inbound connections at
290 /// all, so it dials out and nothing dials it.
291 pub fn peers<I, S>(mut self, addrs: I) -> Result<Self, String>
292 where
293 I: IntoIterator<Item = S>,
294 S: Into<String>,
295 {
296 let v: Vec<String> = addrs.into_iter().map(Into::into).collect();
297 if v.len() != self.world as usize && v.len() != 1 {
298 return Err(format!(
299 "peers: got {} address(es); expected {} (one per rank, for a \
300 mesh) or 1 (the coordinator, for a star)",
301 v.len(),
302 self.world
303 ));
304 }
305 self.peers = PeerSource::Static(v);
306 Ok(self)
307 }
308
309 /// Zero-config UDP peer discovery on a shared LAN.
310 pub fn discover(mut self, disc_port: u16, data_base: u16) -> Self {
311 self.peers = PeerSource::Discover {
312 disc_port,
313 data_base,
314 via: None,
315 };
316 self
317 }
318
319 /// Unicast the discovery query to a known rendezvous host. Use this on
320 /// networks where UDP broadcast does not reach every peer — which includes
321 /// most carrier and guest Wi-Fi, and iOS without the multicast entitlement.
322 pub fn discover_via(mut self, host: impl Into<String>) -> Self {
323 if let PeerSource::Discover { via, .. } = &mut self.peers {
324 *via = Some(host.into());
325 } else {
326 self.peers = PeerSource::Discover {
327 disc_port: 29600,
328 data_base: 29500,
329 via: Some(host.into()),
330 };
331 }
332 self
333 }
334
335 pub fn topology(mut self, t: Topology) -> Self {
336 self.topology = t;
337 self
338 }
339
340 /// Star topology: workers dial the coordinator, which never waits to hear
341 /// them. This is the shape a phone needs — a handset on Wi-Fi often cannot
342 /// accept inbound connections, and in a mesh every rank must reach every
343 /// other.
344 pub fn star(self) -> Self {
345 self.topology(Topology::Star)
346 }
347
348 /// Full mesh: every rank connects to every other. The default.
349 pub fn mesh(self) -> Self {
350 self.topology(Topology::Mesh)
351 }
352
353 pub fn device(mut self, d: impl Into<String>) -> Self {
354 self.device = d.into();
355 self
356 }
357
358 pub fn heap_bytes(mut self, n: usize) -> Self {
359 self.heap_bytes = Some(n);
360 self
361 }
362
363 pub fn role(mut self, r: NodeRole) -> Self {
364 self.role = r;
365 self
366 }
367
368 /// Declare this node a pre-synthesized fixed-function rank.
369 pub fn fixed_function(self, ff: FixedFunction) -> Self {
370 self.role(NodeRole::FixedFunction(ff))
371 }
372
373 /// Read the same variables the CLI node driver uses (`RANK`, `WORLD`,
374 /// `PEERS` / `DISCOVER`, `TOPOLOGY`, `HEAP_MB`, `DEVICE`). Provided for
375 /// parity; prefer the builder on platforms without a shell environment.
376 #[cfg(not(target_arch = "wasm32"))]
377 pub fn from_env() -> Result<Self, String> {
378 let var = |k: &str| std::env::var(k).ok();
379 let rank: u32 = var("RANK")
380 .as_deref()
381 .unwrap_or("0")
382 .parse()
383 .map_err(|_| "RANK must be an integer".to_string())?;
384 let world: u32 = var("WORLD")
385 .as_deref()
386 .unwrap_or("1")
387 .parse()
388 .map_err(|_| "WORLD must be an integer".to_string())?;
389 let mut cfg = NodeConfig::new(rank, world).device(var("DEVICE").unwrap_or("auto".into()));
390
391 let star =
392 var("TOPOLOGY").as_deref() == Some("star") || var("DIAL_OUT").is_some_and(|v| v != "0");
393 cfg = cfg.topology(if star { Topology::Star } else { Topology::Mesh });
394
395 if let Some(mb) = var("HEAP_MB").and_then(|v| v.parse::<usize>().ok()) {
396 cfg = cfg.heap_bytes(mb << 20);
397 }
398
399 if var("DISCOVER").is_some_and(|v| v != "0") {
400 let dp = var("DISC_PORT")
401 .and_then(|v| v.parse().ok())
402 .unwrap_or(29600);
403 let db = var("DATA_PORT")
404 .and_then(|v| v.parse().ok())
405 .unwrap_or(29500);
406 cfg = cfg.discover(dp, db);
407 if let Some(h) = var("DISCOVER_HOST") {
408 cfg = cfg.discover_via(h);
409 }
410 } else if let Some(peers) = var("PEERS") {
411 let addrs: Vec<String> = peers.split(',').map(|s| s.trim().to_string()).collect();
412 cfg = cfg.peers(addrs)?;
413 }
414 Ok(cfg)
415 }
416
417 /// Build the transport and join the mesh.
418 pub fn connect(&self) -> std::io::Result<Arc<ProcessGroup>> {
419 let mut node = Node::new(self.rank, self.world).topology(self.topology);
420 if let Some(n) = self.heap_bytes {
421 node = node.heap_bytes(n);
422 }
423 node = match &self.peers {
424 PeerSource::Static(v) => node.peers(v.iter().map(String::as_str))?,
425 PeerSource::Discover {
426 disc_port,
427 data_base,
428 via,
429 } => {
430 let n = node.discover(*disc_port, *data_base);
431 match via {
432 Some(h) => n.discover_via(h.clone()),
433 None => n,
434 }
435 }
436 };
437 node.connect()
438 }
439
440 /// This node's advertised capabilities.
441 pub fn caps(&self) -> NodeCaps {
442 NodeCaps {
443 rank: self.rank,
444 role: self.role.clone(),
445 devices: crate::available_devices()
446 .into_iter()
447 .map(|d| d.as_arg().to_string())
448 .collect(),
449 platform: platform_tag().into(),
450 }
451 }
452}
453
454// ── capability handshake ──────────────────────────────────────────────────
455
456/// Worker: announce capabilities to the coordinator (rank 0).
457pub fn send_caps(group: &ProcessGroup, caps: &NodeCaps) -> Result<(), String> {
458 let bytes = serde_json::to_vec(caps).map_err(|e| e.to_string())?;
459 group
460 .transport()
461 .send_bytes(0, TAG_CAPS, &bytes)
462 .map_err(|e| format!("send_caps: {e}"))
463}
464
465/// Coordinator: collect every worker's capabilities (ranks `1..world`).
466///
467/// Call this before placement so a stage is only ever assigned to hardware that
468/// can run it — the difference between a clear error and a silent hang when a
469/// fixed-function rank is in the mesh.
470pub fn collect_caps(group: &ProcessGroup) -> Result<Vec<NodeCaps>, String> {
471 let mut out = Vec::new();
472 for r in 1..group.world_size() {
473 let bytes = group
474 .transport()
475 .recv_bytes(r, TAG_CAPS)
476 .map_err(|e| format!("collect_caps(rank {r}): {e}"))?;
477 out.push(serde_json::from_slice(&bytes).map_err(|e| format!("NodeCaps(rank {r}): {e}"))?);
478 }
479 Ok(out)
480}
481
482// ── run control ───────────────────────────────────────────────────────────
483
484/// Bounds a serving loop: a step budget, plus a stop flag another thread can
485/// raise.
486///
487/// A mobile node needs both. An Android service or an iOS app gets backgrounded,
488/// thermally throttled, or unplugged, and must leave the mesh without waiting
489/// for the coordinator to finish the job.
490///
491/// **The flag is observed between activations, not during one.** A node parked
492/// in `recv` stays parked until its peer sends or the link drops — stopping is
493/// cooperative, not a cancellation.
494#[derive(Clone)]
495pub struct NodeControl {
496 steps: Option<usize>,
497 stop: Arc<AtomicBool>,
498}
499
500impl NodeControl {
501 /// Serve until the coordinator finishes or [`NodeStopHandle::stop`] fires.
502 pub fn unbounded() -> Self {
503 Self {
504 steps: None,
505 stop: Arc::new(AtomicBool::new(false)),
506 }
507 }
508
509 /// Serve at most `n` activations.
510 pub fn steps(n: usize) -> Self {
511 Self {
512 steps: Some(n),
513 stop: Arc::new(AtomicBool::new(false)),
514 }
515 }
516
517 /// A handle another thread can use to wind this node down.
518 pub fn stop_handle(&self) -> NodeStopHandle {
519 NodeStopHandle(self.stop.clone())
520 }
521
522 /// Whether the loop should run another activation.
523 pub fn should_continue(&self, done: usize) -> bool {
524 if self.stop.load(Ordering::Relaxed) {
525 return false;
526 }
527 self.steps.is_none_or(|limit| done < limit)
528 }
529}
530
531impl Default for NodeControl {
532 fn default() -> Self {
533 Self::unbounded()
534 }
535}
536
537/// Raises the stop flag on a [`NodeControl`] from another thread.
538#[derive(Clone)]
539pub struct NodeStopHandle(Arc<AtomicBool>);
540
541impl NodeStopHandle {
542 /// Ask the node to leave the mesh after its current activation.
543 pub fn stop(&self) {
544 self.0.store(true, Ordering::Relaxed);
545 }
546 pub fn is_stopped(&self) -> bool {
547 self.0.load(Ordering::Relaxed)
548 }
549}
550
551// ── serving ───────────────────────────────────────────────────────────────
552
553/// What a node did, returned instead of printed — a mobile shell has no stdout
554/// to scrape.
555#[derive(Clone, Debug)]
556pub struct NodeReport {
557 pub rank: u32,
558 pub device: Device,
559 pub platform: &'static str,
560 /// Activations processed.
561 pub activations: usize,
562}
563
564/// Serve as a general worker: announce caps, receive a stage, then run
565/// activations until the peer closes the link.
566///
567/// `ctl` bounds the serving loop — a step budget and/or a cooperative stop
568/// flag. The default ([`NodeControl::unbounded`]) serves until the transport
569/// errors, which is how a node exits when the coordinator finishes.
570pub fn serve_worker_n<F>(
571 group: &ProcessGroup,
572 caps: &NodeCaps,
573 resolve: F,
574 ctl: &NodeControl,
575) -> Result<NodeReport, String>
576where
577 F: FnMut(&str) -> Vec<f32>,
578{
579 send_caps(group, caps)?;
580 let rank = group.rank();
581 let world = group.world_size();
582 let mut stage = super::recv_stage(group, resolve)?;
583 let next = if rank + 1 < world { rank + 1 } else { 0 };
584
585 let mut n = 0usize;
586 while ctl.should_continue(n) {
587 let Ok(x) = recv_activation(group, rank - 1) else {
588 break; // coordinator finished / link closed — a clean exit
589 };
590 let y = stage.run(&x);
591 send_activation(group, next, &y)?;
592 n += 1;
593 }
594 Ok(NodeReport {
595 rank,
596 device: stage.device,
597 platform: platform_tag(),
598 activations: n,
599 })
600}
601
602/// [`serve_worker_n`] with default capabilities and no step bound.
603pub fn serve_worker<F>(group: &ProcessGroup, resolve: F) -> Result<NodeReport, String>
604where
605 F: FnMut(&str) -> Vec<f32>,
606{
607 let caps = NodeCaps {
608 rank: group.rank(),
609 role: NodeRole::Worker,
610 devices: crate::available_devices()
611 .into_iter()
612 .map(|d| d.as_arg().to_string())
613 .collect(),
614 platform: platform_tag().into(),
615 };
616 serve_worker_n(group, &caps, resolve, &NodeControl::unbounded())
617}
618
619/// Serve as a **fixed-function** rank: announce the one stage this hardware
620/// implements, then stream activations through the board.
621///
622/// No graph is compiled and no `StageSpec` is consumed — the datapath is
623/// already synthesized. The activation length is checked against the descriptor
624/// on every step, so a mismatched feed is refused instead of being clocked into
625/// the fabric.
626pub fn serve_fixed_function_n(
627 group: &ProcessGroup,
628 ff: &FixedFunction,
629 board: &mut dyn FixedFunctionDevice,
630 ctl: &NodeControl,
631) -> Result<NodeReport, String> {
632 if board.stage_id() != ff.stage_id {
633 return Err(format!(
634 "board implements [{}] but rank advertises [{}]",
635 board.stage_id(),
636 ff.stage_id
637 ));
638 }
639 let caps = NodeCaps {
640 rank: group.rank(),
641 role: NodeRole::FixedFunction(ff.clone()),
642 devices: vec!["fpga".into()],
643 platform: platform_tag().into(),
644 };
645 send_caps(group, &caps)?;
646
647 let rank = group.rank();
648 let world = group.world_size();
649 let next = if rank + 1 < world { rank + 1 } else { 0 };
650
651 let mut n = 0usize;
652 while ctl.should_continue(n) {
653 let Ok(x) = recv_activation(group, rank - 1) else {
654 break;
655 };
656 if x.len() != ff.input_elems {
657 return Err(format!(
658 "fixed-function stage [{}] takes {} elems, got {}: the datapath \
659 is synthesized for one shape and cannot adapt",
660 ff.stage_id,
661 ff.input_elems,
662 x.len()
663 ));
664 }
665 let y = board.execute(&x)?;
666 if y.len() != ff.output_elems {
667 return Err(format!(
668 "fixed-function stage [{}] declares {} output elems, board \
669 returned {}",
670 ff.stage_id,
671 ff.output_elems,
672 y.len()
673 ));
674 }
675 send_activation(group, next, &y)?;
676 n += 1;
677 }
678 Ok(NodeReport {
679 rank,
680 device: Device::Cpu, // host lane; the datapath itself is off-CPU
681 platform: platform_tag(),
682 activations: n,
683 })
684}
685
686// ── training ──────────────────────────────────────────────────────────────
687
688/// What a node did in a training run.
689#[derive(Clone, Debug)]
690pub struct TrainReport {
691 pub rank: u32,
692 pub platform: &'static str,
693 /// Metrics from the local training loop, including which backends ran.
694 pub metrics: super::TrainMetrics,
695 /// Final synced parameters, as `(name, values)`.
696 pub params: Vec<(String, Vec<f32>)>,
697}
698
699/// Serve as a **training** worker: announce capabilities, receive a
700/// [`TrainSpec`](super::TrainSpec) the coordinator ships, and run the
701/// data-parallel loop against this node's own hardware.
702///
703/// No model code is baked in — the coordinator owns the graph, the worker just
704/// executes it. Weights and data stay node-local; only the spec crosses the
705/// wire (or the data shard, when the spec sets `push_data` because there is no
706/// shared filesystem — a phone never has one).
707///
708/// The cross-rank gradient reduce runs on this group's
709/// [`all_reduce`](ProcessGroup::all_reduce), so the node driver needs no
710/// dependency on the in-graph collectives crate.
711///
712/// **Every rank must call this in lockstep.** The reduce is a barrier, so a
713/// node that leaves early — a phone going to background — stalls the rest.
714/// That is why there is no step budget here: a training run is not something a
715/// node can drop out of halfway.
716pub fn serve_trainer<F>(
717 group: &ProcessGroup,
718 caps: &NodeCaps,
719 mut resolve: F,
720 log: bool,
721) -> Result<TrainReport, String>
722where
723 F: FnMut(&str) -> Vec<f32>,
724{
725 send_caps(group, caps)?;
726 let mut spec = super::recv_train(group)?;
727
728 // No shared filesystem: pull this rank's shard and rewrite the spec to the
729 // files we just received.
730 if spec.push_data {
731 super::pull_shards(group, &mut spec, &std::env::temp_dir())?;
732 }
733
734 let world = group.world_size();
735 let (metrics, params) = super::run_train(
736 &spec,
737 world,
738 |uri| resolve(uri),
739 |flat| mean_reduce(group, flat),
740 log,
741 )?;
742
743 Ok(TrainReport {
744 rank: group.rank(),
745 platform: platform_tag(),
746 metrics,
747 params,
748 })
749}
750
751/// Average a flat gradient buffer across every rank in `group`.
752///
753/// The reduce a data-parallel step needs, exposed because **every rank must use
754/// the same one**: the coordinator trains alongside the workers, and a group
755/// where one side sums while another averages scales the step by the world size
756/// on some replicas and not others — which shows up as slow divergence, not as
757/// an error.
758///
759/// Mean rather than Sum because [`run_train`](super::run_train) applies the
760/// result directly as the gradient.
761pub fn mean_reduce(group: &ProcessGroup, flat: &[f32]) -> Vec<f32> {
762 let mut buf = flat.to_vec();
763 match group.all_reduce(&mut buf, ReduceKind::Mean) {
764 Ok(()) => buf,
765 // `run_train`'s reduce closure has no error channel. Returning the
766 // unreduced buffer would silently train on local gradients only, which
767 // looks like convergence and is not; zeros stall the step visibly.
768 Err(_) => vec![0.0; flat.len()],
769 }
770}
771
772/// [`serve_trainer`] with capabilities derived from this node.
773pub fn serve_trainer_here<F>(
774 group: &ProcessGroup,
775 resolve: F,
776 log: bool,
777) -> Result<TrainReport, String>
778where
779 F: FnMut(&str) -> Vec<f32>,
780{
781 let caps = NodeCaps {
782 rank: group.rank(),
783 role: NodeRole::Worker,
784 devices: crate::available_devices()
785 .into_iter()
786 .map(|d| d.as_arg().to_string())
787 .collect(),
788 platform: platform_tag().into(),
789 };
790 serve_trainer(group, &caps, resolve, log)
791}
792
793/// Resolve this node's device directive to a concrete backend.
794pub fn node_device(spec: &str) -> Device {
795 resolve_device(spec)
796}
797
798/// Weight cache constructor re-exported for shells that resolve their own URIs.
799pub fn weight_cache() -> WeightCache {
800 WeightCache::new()
801}