Skip to main content

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}