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.
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}