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.
209///
210/// The Apple arms come first and are listed individually: every one of them
211/// also satisfies `target_vendor = "apple"`, and a tvOS rank reported as
212/// `macos` is worse than useless in a mesh log. `visionos` is spelled the way
213/// Rust spells the target (Apple's SDK and `xcodebuild` call the same platform
214/// `xros`).
215pub const fn platform_tag() -> &'static str {
216 if cfg!(target_os = "ios") {
217 "ios"
218 } else if cfg!(target_os = "tvos") {
219 "tvos"
220 } else if cfg!(target_os = "watchos") {
221 "watchos"
222 } else if cfg!(target_os = "visionos") {
223 "visionos"
224 } else if cfg!(target_os = "android") {
225 "android"
226 } else if cfg!(target_os = "macos") {
227 "macos"
228 } else if cfg!(target_os = "linux") {
229 "linux"
230 } else if cfg!(target_os = "windows") {
231 "windows"
232 } else if cfg!(target_arch = "wasm32") {
233 "wasm"
234 } else {
235 "unknown"
236 }
237}
238
239// ── peer configuration ────────────────────────────────────────────────────
240
241/// How this node finds its peers.
242#[derive(Clone, Debug)]
243pub enum PeerSource {
244 /// Explicit `host:port` list, index = rank.
245 Static(Vec<String>),
246 /// UDP rendezvous. `via` unicasts the query to a known host instead of
247 /// broadcasting — the path that works from a NAT'd or broadcast-restricted
248 /// network (mobile hotspots, Docker, QEMU).
249 Discover {
250 disc_port: u16,
251 data_base: u16,
252 via: Option<String>,
253 },
254}
255
256impl Default for PeerSource {
257 fn default() -> Self {
258 Self::Static(Vec::new())
259 }
260}
261
262// ── node configuration ────────────────────────────────────────────────────
263
264/// Programmatic node configuration.
265///
266/// Every field is settable in code. [`NodeConfig::from_env`] exists for parity
267/// with the CLI workflow, but nothing here *requires* an environment — an iOS
268/// app or an Android service has no meaningful `RANK`/`PEERS` to read.
269#[derive(Clone, Debug)]
270pub struct NodeConfig {
271 pub rank: u32,
272 pub world: u32,
273 pub peers: PeerSource,
274 pub topology: Topology,
275 /// Device directive: `auto` (node's fastest) or a name (`cpu`, `metal`, …).
276 pub device: String,
277 pub heap_bytes: Option<usize>,
278 pub role: NodeRole,
279}
280
281impl NodeConfig {
282 pub fn new(rank: u32, world: u32) -> Self {
283 Self {
284 rank,
285 world,
286 peers: PeerSource::default(),
287 topology: Topology::Mesh,
288 device: "auto".into(),
289 heap_bytes: None,
290 role: NodeRole::Worker,
291 }
292 }
293
294 /// Static peer list.
295 ///
296 /// Accepts either form:
297 /// * `world` addresses, indexed by rank — a full mesh, where every rank
298 /// must be able to reach every other.
299 /// * exactly one address — the **coordinator**, for a star. This is what a
300 /// phone can actually supply: a handset does not know its own reachable
301 /// address, and on most Wi-Fi it cannot accept inbound connections at
302 /// all, so it dials out and nothing dials it.
303 pub fn peers<I, S>(mut self, addrs: I) -> Result<Self, String>
304 where
305 I: IntoIterator<Item = S>,
306 S: Into<String>,
307 {
308 let v: Vec<String> = addrs.into_iter().map(Into::into).collect();
309 if v.len() != self.world as usize && v.len() != 1 {
310 return Err(format!(
311 "peers: got {} address(es); expected {} (one per rank, for a \
312 mesh) or 1 (the coordinator, for a star)",
313 v.len(),
314 self.world
315 ));
316 }
317 self.peers = PeerSource::Static(v);
318 Ok(self)
319 }
320
321 /// Zero-config UDP peer discovery on a shared LAN.
322 pub fn discover(mut self, disc_port: u16, data_base: u16) -> Self {
323 self.peers = PeerSource::Discover {
324 disc_port,
325 data_base,
326 via: None,
327 };
328 self
329 }
330
331 /// Unicast the discovery query to a known rendezvous host. Use this on
332 /// networks where UDP broadcast does not reach every peer — which includes
333 /// most carrier and guest Wi-Fi, and iOS without the multicast entitlement.
334 pub fn discover_via(mut self, host: impl Into<String>) -> Self {
335 if let PeerSource::Discover { via, .. } = &mut self.peers {
336 *via = Some(host.into());
337 } else {
338 self.peers = PeerSource::Discover {
339 disc_port: 29600,
340 data_base: 29500,
341 via: Some(host.into()),
342 };
343 }
344 self
345 }
346
347 pub fn topology(mut self, t: Topology) -> Self {
348 self.topology = t;
349 self
350 }
351
352 /// Star topology: workers dial the coordinator, which never waits to hear
353 /// them. This is the shape a phone needs — a handset on Wi-Fi often cannot
354 /// accept inbound connections, and in a mesh every rank must reach every
355 /// other.
356 pub fn star(self) -> Self {
357 self.topology(Topology::Star)
358 }
359
360 /// Full mesh: every rank connects to every other. The default.
361 pub fn mesh(self) -> Self {
362 self.topology(Topology::Mesh)
363 }
364
365 pub fn device(mut self, d: impl Into<String>) -> Self {
366 self.device = d.into();
367 self
368 }
369
370 pub fn heap_bytes(mut self, n: usize) -> Self {
371 self.heap_bytes = Some(n);
372 self
373 }
374
375 pub fn role(mut self, r: NodeRole) -> Self {
376 self.role = r;
377 self
378 }
379
380 /// Declare this node a pre-synthesized fixed-function rank.
381 pub fn fixed_function(self, ff: FixedFunction) -> Self {
382 self.role(NodeRole::FixedFunction(ff))
383 }
384
385 /// Read the same variables the CLI node driver uses (`RANK`, `WORLD`,
386 /// `PEERS` / `DISCOVER`, `TOPOLOGY`, `HEAP_MB`, `DEVICE`). Provided for
387 /// parity; prefer the builder on platforms without a shell environment.
388 #[cfg(not(target_arch = "wasm32"))]
389 pub fn from_env() -> Result<Self, String> {
390 let var = |k: &str| std::env::var(k).ok();
391 let rank: u32 = var("RANK")
392 .as_deref()
393 .unwrap_or("0")
394 .parse()
395 .map_err(|_| "RANK must be an integer".to_string())?;
396 let world: u32 = var("WORLD")
397 .as_deref()
398 .unwrap_or("1")
399 .parse()
400 .map_err(|_| "WORLD must be an integer".to_string())?;
401 let mut cfg = NodeConfig::new(rank, world).device(var("DEVICE").unwrap_or("auto".into()));
402
403 let star =
404 var("TOPOLOGY").as_deref() == Some("star") || var("DIAL_OUT").is_some_and(|v| v != "0");
405 cfg = cfg.topology(if star { Topology::Star } else { Topology::Mesh });
406
407 if let Some(mb) = var("HEAP_MB").and_then(|v| v.parse::<usize>().ok()) {
408 cfg = cfg.heap_bytes(mb << 20);
409 }
410
411 if var("DISCOVER").is_some_and(|v| v != "0") {
412 let dp = var("DISC_PORT")
413 .and_then(|v| v.parse().ok())
414 .unwrap_or(29600);
415 let db = var("DATA_PORT")
416 .and_then(|v| v.parse().ok())
417 .unwrap_or(29500);
418 cfg = cfg.discover(dp, db);
419 if let Some(h) = var("DISCOVER_HOST") {
420 cfg = cfg.discover_via(h);
421 }
422 } else if let Some(peers) = var("PEERS") {
423 let addrs: Vec<String> = peers.split(',').map(|s| s.trim().to_string()).collect();
424 cfg = cfg.peers(addrs)?;
425 }
426 Ok(cfg)
427 }
428
429 /// Build the transport and join the mesh.
430 pub fn connect(&self) -> std::io::Result<Arc<ProcessGroup>> {
431 let mut node = Node::new(self.rank, self.world).topology(self.topology);
432 if let Some(n) = self.heap_bytes {
433 node = node.heap_bytes(n);
434 }
435 node = match &self.peers {
436 PeerSource::Static(v) => node.peers(v.iter().map(String::as_str))?,
437 PeerSource::Discover {
438 disc_port,
439 data_base,
440 via,
441 } => {
442 let n = node.discover(*disc_port, *data_base);
443 match via {
444 Some(h) => n.discover_via(h.clone()),
445 None => n,
446 }
447 }
448 };
449 node.connect()
450 }
451
452 /// This node's advertised capabilities.
453 pub fn caps(&self) -> NodeCaps {
454 NodeCaps {
455 rank: self.rank,
456 role: self.role.clone(),
457 devices: crate::available_devices()
458 .into_iter()
459 .map(|d| d.as_arg().to_string())
460 .collect(),
461 platform: platform_tag().into(),
462 }
463 }
464}
465
466// ── capability handshake ──────────────────────────────────────────────────
467
468/// Worker: announce capabilities to the coordinator (rank 0).
469pub fn send_caps(group: &ProcessGroup, caps: &NodeCaps) -> Result<(), String> {
470 let bytes = serde_json::to_vec(caps).map_err(|e| e.to_string())?;
471 group
472 .transport()
473 .send_bytes(0, TAG_CAPS, &bytes)
474 .map_err(|e| format!("send_caps: {e}"))
475}
476
477/// Coordinator: collect every worker's capabilities (ranks `1..world`).
478///
479/// Call this before placement so a stage is only ever assigned to hardware that
480/// can run it — the difference between a clear error and a silent hang when a
481/// fixed-function rank is in the mesh.
482pub fn collect_caps(group: &ProcessGroup) -> Result<Vec<NodeCaps>, String> {
483 let mut out = Vec::new();
484 for r in 1..group.world_size() {
485 let bytes = group
486 .transport()
487 .recv_bytes(r, TAG_CAPS)
488 .map_err(|e| format!("collect_caps(rank {r}): {e}"))?;
489 out.push(serde_json::from_slice(&bytes).map_err(|e| format!("NodeCaps(rank {r}): {e}"))?);
490 }
491 Ok(out)
492}
493
494// ── run control ───────────────────────────────────────────────────────────
495
496/// Bounds a serving loop: a step budget, plus a stop flag another thread can
497/// raise.
498///
499/// A mobile node needs both. An Android service or an iOS app gets backgrounded,
500/// thermally throttled, or unplugged, and must leave the mesh without waiting
501/// for the coordinator to finish the job.
502///
503/// **The flag is observed between activations, not during one.** A node parked
504/// in `recv` stays parked until its peer sends or the link drops — stopping is
505/// cooperative, not a cancellation.
506#[derive(Clone)]
507pub struct NodeControl {
508 steps: Option<usize>,
509 stop: Arc<AtomicBool>,
510}
511
512impl NodeControl {
513 /// Serve until the coordinator finishes or [`NodeStopHandle::stop`] fires.
514 pub fn unbounded() -> Self {
515 Self {
516 steps: None,
517 stop: Arc::new(AtomicBool::new(false)),
518 }
519 }
520
521 /// Serve at most `n` activations.
522 pub fn steps(n: usize) -> Self {
523 Self {
524 steps: Some(n),
525 stop: Arc::new(AtomicBool::new(false)),
526 }
527 }
528
529 /// A handle another thread can use to wind this node down.
530 pub fn stop_handle(&self) -> NodeStopHandle {
531 NodeStopHandle(self.stop.clone())
532 }
533
534 /// Whether the loop should run another activation.
535 pub fn should_continue(&self, done: usize) -> bool {
536 if self.stop.load(Ordering::Relaxed) {
537 return false;
538 }
539 self.steps.is_none_or(|limit| done < limit)
540 }
541}
542
543impl Default for NodeControl {
544 fn default() -> Self {
545 Self::unbounded()
546 }
547}
548
549/// Raises the stop flag on a [`NodeControl`] from another thread.
550#[derive(Clone)]
551pub struct NodeStopHandle(Arc<AtomicBool>);
552
553impl NodeStopHandle {
554 /// Ask the node to leave the mesh after its current activation.
555 pub fn stop(&self) {
556 self.0.store(true, Ordering::Relaxed);
557 }
558 pub fn is_stopped(&self) -> bool {
559 self.0.load(Ordering::Relaxed)
560 }
561}
562
563// ── serving ───────────────────────────────────────────────────────────────
564
565/// What a node did, returned instead of printed — a mobile shell has no stdout
566/// to scrape.
567#[derive(Clone, Debug)]
568pub struct NodeReport {
569 pub rank: u32,
570 pub device: Device,
571 pub platform: &'static str,
572 /// Activations processed.
573 pub activations: usize,
574}
575
576/// Serve as a general worker: announce caps, receive a stage, then run
577/// activations until the peer closes the link.
578///
579/// `ctl` bounds the serving loop — a step budget and/or a cooperative stop
580/// flag. The default ([`NodeControl::unbounded`]) serves until the transport
581/// errors, which is how a node exits when the coordinator finishes.
582pub fn serve_worker_n<F>(
583 group: &ProcessGroup,
584 caps: &NodeCaps,
585 resolve: F,
586 ctl: &NodeControl,
587) -> Result<NodeReport, String>
588where
589 F: FnMut(&str) -> Vec<f32>,
590{
591 send_caps(group, caps)?;
592 let rank = group.rank();
593 let world = group.world_size();
594 let mut stage = super::recv_stage(group, resolve)?;
595 let next = if rank + 1 < world { rank + 1 } else { 0 };
596
597 let mut n = 0usize;
598 while ctl.should_continue(n) {
599 let Ok(x) = recv_activation(group, rank - 1) else {
600 break; // coordinator finished / link closed — a clean exit
601 };
602 let y = stage.run(&x);
603 send_activation(group, next, &y)?;
604 n += 1;
605 }
606 Ok(NodeReport {
607 rank,
608 device: stage.device,
609 platform: platform_tag(),
610 activations: n,
611 })
612}
613
614/// [`serve_worker_n`] with default capabilities and no step bound.
615pub fn serve_worker<F>(group: &ProcessGroup, resolve: F) -> Result<NodeReport, String>
616where
617 F: FnMut(&str) -> Vec<f32>,
618{
619 let caps = NodeCaps {
620 rank: group.rank(),
621 role: NodeRole::Worker,
622 devices: crate::available_devices()
623 .into_iter()
624 .map(|d| d.as_arg().to_string())
625 .collect(),
626 platform: platform_tag().into(),
627 };
628 serve_worker_n(group, &caps, resolve, &NodeControl::unbounded())
629}
630
631/// Serve as a **fixed-function** rank: announce the one stage this hardware
632/// implements, then stream activations through the board.
633///
634/// No graph is compiled and no `StageSpec` is consumed — the datapath is
635/// already synthesized. The activation length is checked against the descriptor
636/// on every step, so a mismatched feed is refused instead of being clocked into
637/// the fabric.
638pub fn serve_fixed_function_n(
639 group: &ProcessGroup,
640 ff: &FixedFunction,
641 board: &mut dyn FixedFunctionDevice,
642 ctl: &NodeControl,
643) -> Result<NodeReport, String> {
644 if board.stage_id() != ff.stage_id {
645 return Err(format!(
646 "board implements [{}] but rank advertises [{}]",
647 board.stage_id(),
648 ff.stage_id
649 ));
650 }
651 let caps = NodeCaps {
652 rank: group.rank(),
653 role: NodeRole::FixedFunction(ff.clone()),
654 devices: vec!["fpga".into()],
655 platform: platform_tag().into(),
656 };
657 send_caps(group, &caps)?;
658
659 let rank = group.rank();
660 let world = group.world_size();
661 let next = if rank + 1 < world { rank + 1 } else { 0 };
662
663 let mut n = 0usize;
664 while ctl.should_continue(n) {
665 let Ok(x) = recv_activation(group, rank - 1) else {
666 break;
667 };
668 if x.len() != ff.input_elems {
669 return Err(format!(
670 "fixed-function stage [{}] takes {} elems, got {}: the datapath \
671 is synthesized for one shape and cannot adapt",
672 ff.stage_id,
673 ff.input_elems,
674 x.len()
675 ));
676 }
677 let y = board.execute(&x)?;
678 if y.len() != ff.output_elems {
679 return Err(format!(
680 "fixed-function stage [{}] declares {} output elems, board \
681 returned {}",
682 ff.stage_id,
683 ff.output_elems,
684 y.len()
685 ));
686 }
687 send_activation(group, next, &y)?;
688 n += 1;
689 }
690 Ok(NodeReport {
691 rank,
692 device: Device::Cpu, // host lane; the datapath itself is off-CPU
693 platform: platform_tag(),
694 activations: n,
695 })
696}
697
698// ── training ──────────────────────────────────────────────────────────────
699
700/// What a node did in a training run.
701#[derive(Clone, Debug)]
702pub struct TrainReport {
703 pub rank: u32,
704 pub platform: &'static str,
705 /// Metrics from the local training loop, including which backends ran.
706 pub metrics: super::TrainMetrics,
707 /// Final synced parameters, as `(name, values)`.
708 pub params: Vec<(String, Vec<f32>)>,
709}
710
711/// Serve as a **training** worker: announce capabilities, receive a
712/// [`TrainSpec`](super::TrainSpec) the coordinator ships, and run the
713/// data-parallel loop against this node's own hardware.
714///
715/// No model code is baked in — the coordinator owns the graph, the worker just
716/// executes it. Weights and data stay node-local; only the spec crosses the
717/// wire (or the data shard, when the spec sets `push_data` because there is no
718/// shared filesystem — a phone never has one).
719///
720/// The cross-rank gradient reduce runs on this group's
721/// [`all_reduce`](ProcessGroup::all_reduce), so the node driver needs no
722/// dependency on the in-graph collectives crate.
723///
724/// **Every rank must call this in lockstep.** The reduce is a barrier, so a
725/// node that leaves early — a phone going to background — stalls the rest.
726/// That is why there is no step budget here: a training run is not something a
727/// node can drop out of halfway.
728pub fn serve_trainer<F>(
729 group: &ProcessGroup,
730 caps: &NodeCaps,
731 mut resolve: F,
732 log: bool,
733) -> Result<TrainReport, String>
734where
735 F: FnMut(&str) -> Vec<f32>,
736{
737 send_caps(group, caps)?;
738 let mut spec = super::recv_train(group)?;
739
740 // No shared filesystem: pull this rank's shard and rewrite the spec to the
741 // files we just received.
742 if spec.push_data {
743 super::pull_shards(group, &mut spec, &std::env::temp_dir())?;
744 }
745
746 let world = group.world_size();
747 let (metrics, params) = super::run_train(
748 &spec,
749 world,
750 |uri| resolve(uri),
751 |flat| mean_reduce(group, flat),
752 log,
753 )?;
754
755 Ok(TrainReport {
756 rank: group.rank(),
757 platform: platform_tag(),
758 metrics,
759 params,
760 })
761}
762
763/// Average a flat gradient buffer across every rank in `group`.
764///
765/// The reduce a data-parallel step needs, exposed because **every rank must use
766/// the same one**: the coordinator trains alongside the workers, and a group
767/// where one side sums while another averages scales the step by the world size
768/// on some replicas and not others — which shows up as slow divergence, not as
769/// an error.
770///
771/// Mean rather than Sum because [`run_train`](super::run_train) applies the
772/// result directly as the gradient.
773pub fn mean_reduce(group: &ProcessGroup, flat: &[f32]) -> Vec<f32> {
774 let mut buf = flat.to_vec();
775 match group.all_reduce(&mut buf, ReduceKind::Mean) {
776 Ok(()) => buf,
777 // `run_train`'s reduce closure has no error channel. Returning the
778 // unreduced buffer would silently train on local gradients only, which
779 // looks like convergence and is not; zeros stall the step visibly.
780 Err(_) => vec![0.0; flat.len()],
781 }
782}
783
784/// [`serve_trainer`] with capabilities derived from this node.
785pub fn serve_trainer_here<F>(
786 group: &ProcessGroup,
787 resolve: F,
788 log: bool,
789) -> Result<TrainReport, String>
790where
791 F: FnMut(&str) -> Vec<f32>,
792{
793 let caps = NodeCaps {
794 rank: group.rank(),
795 role: NodeRole::Worker,
796 devices: crate::available_devices()
797 .into_iter()
798 .map(|d| d.as_arg().to_string())
799 .collect(),
800 platform: platform_tag().into(),
801 };
802 serve_trainer(group, &caps, resolve, log)
803}
804
805/// Resolve this node's device directive to a concrete backend.
806pub fn node_device(spec: &str) -> Device {
807 resolve_device(spec)
808}
809
810/// Weight cache constructor re-exported for shells that resolve their own URIs.
811pub fn weight_cache() -> WeightCache {
812 WeightCache::new()
813}