Skip to main content

rlx_runtime/dist/
training.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4//! Ship-graph **training**: ship a `TrainSpec` (backward graph + data plan +
5//! optimizer) to a generic worker that trains any model on its local hardware,
6//! with a sample-weighted, optionally-deterministic gradient reduce.
7
8use super::{WeightCache, WeightRef, resolve_device};
9use crate::{Device, Session};
10use rlx_driver::ProcessGroup;
11use rlx_ir::Graph;
12use serde::{Deserialize, Serialize};
13
14// ════════════════════════════════════════════════════════════════════════════
15// Ship-graph *training* — build the worker once, train any model.
16//
17// The inference half above ships a `StageSpec`; this half ships a `TrainSpec`:
18// a serialized **backward** graph + trainable-param init URIs + a **data plan**
19// + an optimizer directive. A generic worker ([`run_train`]) resolves its params
20// and data node-locally (weights/data never cross the wire — only the KB-sized
21// spec does), compiles on the node's chosen device, and runs the data-parallel
22// training loop. Gradients are averaged across workers by a caller-supplied
23// `reduce` closure, so this stays free of any collective dependency (the worker
24// binary plugs `rlx-collectives` in) and the crate layering is preserved.
25// ════════════════════════════════════════════════════════════════════════════
26
27/// Wire tag for a shipped [`TrainSpec`] (clear of the collective range and the
28/// inference `TAG_SPEC`/`TAG_ACT`).
29const TAG_TRAIN: u32 = 42;
30
31/// One data tensor the worker feeds to a graph input every step. Resolved
32/// node-locally from `uri` (a `file://` raw-f32 blob, or gguf/safetensors),
33/// then row-sharded: this rank owns samples `[shard_start, shard_start+len)`,
34/// each `elem` floats wide.
35#[derive(Serialize, Deserialize, Clone, Debug)]
36pub struct DataRef {
37    /// Graph input node name this tensor feeds (e.g. `"x"`, `"labels"`).
38    pub input: String,
39    /// Where the full (unsharded) tensor lives — resolved on the worker.
40    pub uri: String,
41    /// Per-sample element count (row stride into the resolved flat array).
42    pub elem: usize,
43    /// First sample index of this rank's shard.
44    pub shard_start: usize,
45    /// Number of samples in this rank's shard.
46    pub shard_len: usize,
47}
48
49/// A self-describing **training** job for a generic worker: everything needed to
50/// train a model the worker has no code for.
51#[derive(Serialize, Deserialize, Clone, Debug)]
52pub struct TrainSpec {
53    /// Backward graph. Outputs are `[.. , grad_0, grad_1, …]`, with the
54    /// gradients starting at `grad_start` and matching `params` 1:1.
55    pub graph: Graph,
56    /// Trainable params + where to fetch their initial values (node-local URI).
57    pub params: Vec<WeightRef>,
58    /// Index in `graph.outputs` where the gradients begin (`params[i]` ↔
59    /// `outputs[grad_start + i]`).
60    pub grad_start: usize,
61    /// Index in `graph.outputs` of the scalar loss (for logging).
62    pub loss_index: usize,
63    /// Inputs fed each step from node-local data shards.
64    pub data: Vec<DataRef>,
65    /// Name of the backward seed input to feed `1.0` (e.g. `"d_output"`), if any.
66    pub seed_input: Option<String>,
67    /// SGD momentum.
68    pub momentum: f32,
69    /// Learning rate per epoch (`len()` = number of epochs). The master owns the
70    /// schedule; the worker just applies `lr_per_epoch[epoch]`.
71    pub lr_per_epoch: Vec<f32>,
72    /// Mini-batch size (matches the graph's input batch dim).
73    pub batch: usize,
74    /// Device directive: `auto` (node's fastest), or a name (`metal`, `cuda`, …).
75    pub device: String,
76    /// The collective group id the worker's `reduce` closure averages over
77    /// (informational — the closure owns the actual all-reduce).
78    pub grad_group: u64,
79    /// If set, the master **pushes** each worker its data shard over the wire
80    /// (for nodes with no shared filesystem); the worker rewrites its `DataRef`s
81    /// to the received local files. When false, workers resolve the URIs
82    /// node-locally (shared FS / pre-staged data).
83    #[serde(default)]
84    pub push_data: bool,
85}
86
87/// Per-worker training result.
88#[derive(Clone, Debug)]
89pub struct TrainMetrics {
90    /// Device the worker resolved and trained on (the primary lane).
91    pub device: Device,
92    /// The backends actually used this run — one for a single device, several
93    /// for `device: "all"` (intra-node data-parallel across the node's hardware).
94    pub lanes: Vec<Device>,
95    /// Every live backend on this node (what `device: "all"` could use).
96    pub inventory: Vec<Device>,
97    pub samples: usize,
98    pub wall_s: f64,
99    /// Time in backward compute (`run()`).
100    pub compute_s: f64,
101    /// Time in the cross-worker gradient reduce (the `reduce` closure).
102    pub comm_s: f64,
103    pub first_loss: f32,
104    pub last_loss: f32,
105}
106
107/// Coordinator: ship a training job to worker `rank`.
108pub fn ship_train(group: &ProcessGroup, rank: u32, spec: &TrainSpec) -> Result<(), String> {
109    let bytes = serde_json::to_vec(spec).map_err(|e| e.to_string())?;
110    group
111        .transport()
112        .send_bytes(rank, TAG_TRAIN, &bytes)
113        .map_err(|e| format!("ship_train: {e}"))
114}
115
116/// Worker: receive a [`TrainSpec`] from the coordinator (rank 0).
117pub fn recv_train(group: &ProcessGroup) -> Result<TrainSpec, String> {
118    let bytes = group
119        .transport()
120        .recv_bytes(0, TAG_TRAIN)
121        .map_err(|e| format!("recv_train: {e}"))?;
122    serde_json::from_slice(&bytes).map_err(|e| format!("TrainSpec: {e}"))
123}
124
125/// Base wire tag for pushed data shards (`TAG_DATA_BASE + input_index`).
126const TAG_DATA_BASE: u32 = 45;
127/// Base wire tag for pushed **init params** (`TAG_PARAM_BASE + param_index`).
128const TAG_PARAM_BASE: u32 = 60;
129
130/// Master: **push** worker `rank` everything it needs but can't resolve locally
131/// — the **init params** (training-from-scratch has no node-local weights, so
132/// the `WeightRef` file:// URIs only exist on the master) and each **data
133/// shard** (only this rank's samples). Use when the worker has no shared
134/// filesystem. Pair with [`pull_shards`] on the worker.
135pub fn push_shards<W: FnMut(&str) -> Vec<f32>>(
136    group: &ProcessGroup,
137    rank: u32,
138    spec: &TrainSpec,
139    mut resolve: W,
140) -> Result<(), String> {
141    let mut cache = WeightCache::new();
142    // Init params — small, but the whole reason cross-machine training used to
143    // silently not learn: without them the worker starts from zero weights, so
144    // its gradients vanish (h = relu(x·0) = 0) and it never updates.
145    for (i, w) in spec.params.iter().enumerate() {
146        let vals = cache.f32(&w.uri).unwrap_or_else(|_| resolve(&w.uri));
147        group
148            .send_f32(rank, TAG_PARAM_BASE + i as u32, &vals)
149            .map_err(|e| format!("push param {} → {rank}: {e}", w.name))?;
150    }
151    // Data shards — only this rank's samples.
152    for (i, d) in spec.data.iter().enumerate() {
153        let shard = resolve_shard(
154            &d.uri,
155            d.elem,
156            d.shard_start,
157            d.shard_len,
158            &mut cache,
159            &mut resolve,
160        );
161        group
162            .send_f32(rank, TAG_DATA_BASE + i as u32, &shard)
163            .map_err(|e| format!("push_shards[{i}] → {rank}: {e}"))?;
164    }
165    Ok(())
166}
167
168/// Worker: **receive** the pushed init params + data shards from the master
169/// (rank 0), write them to node-local files under `dir`, and rewrite
170/// `spec.params`/`spec.data` to point at those files (data offset reset to 0).
171/// After this the generic [`run_train`] resolves everything locally exactly as
172/// in the shared-FS case.
173pub fn pull_shards(
174    group: &ProcessGroup,
175    spec: &mut TrainSpec,
176    dir: &std::path::Path,
177) -> Result<(), String> {
178    let write_bin = |name: &str, vals: &[f32]| -> Result<String, String> {
179        let path = dir.join(name);
180        let mut bytes = Vec::with_capacity(vals.len() * 4);
181        for v in vals {
182            bytes.extend_from_slice(&v.to_le_bytes());
183        }
184        std::fs::write(&path, &bytes).map_err(|e| format!("write {name}: {e}"))?;
185        Ok(format!("file://{}", path.display()))
186    };
187    // Init params.
188    for i in 0..spec.params.len() {
189        let vals = group
190            .recv_f32(0, TAG_PARAM_BASE + i as u32)
191            .map_err(|e| format!("pull param[{i}]: {e}"))?;
192        spec.params[i].uri = write_bin(&format!("rlx_pushed_param_{i}.bin"), &vals)?;
193    }
194    // Data shards.
195    for i in 0..spec.data.len() {
196        let vals = group
197            .recv_f32(0, TAG_DATA_BASE + i as u32)
198            .map_err(|e| format!("pull_shards[{i}]: {e}"))?;
199        spec.data[i].uri = write_bin(&format!("rlx_pushed_shard_{i}.bin"), &vals)?;
200        spec.data[i].shard_start = 0; // the pushed file is exactly this rank's shard
201    }
202    Ok(())
203}
204
205/// Pick this node's training lanes. `device:"all"` fans the backward out over
206/// **every live local backend at once** — intra-node data parallelism, so the
207/// CPU trains alongside the GPU on each connected node instead of sitting idle;
208/// any other directive is a single lane. ANE is excluded (its training path is a
209/// separate gated route).
210///
211/// This is safe across a **heterogeneous** cluster because [`run_train`] combines
212/// gradients with a **sample-count-weighted** reduce on a **uniform sync cadence**
213/// (see there): a node casts one weighted vote per local lane, so a 3-lane Mac
214/// and a 1-lane CUDA box average correctly and never desync. The two failure
215/// modes of naïve multi-lane DP — deadlock from unequal per-node sync counts, and
216/// gradient bias from equal-weighting unequal lane counts — are handled in the
217/// reduce, not by forcing every node down to a single lane.
218fn training_lanes(spec_device: &str, available: &[Device], graph: &Graph) -> Vec<Device> {
219    if spec_device.eq_ignore_ascii_case("all") {
220        let native = crate::devices_for(graph);
221        let mut ds: Vec<Device> = available
222            .iter()
223            .copied()
224            .filter(|d| !matches!(d, Device::Ane))
225            .collect();
226        // Native-supporting backends first (they run without host fallback).
227        ds.sort_by_key(|d| !native.contains(d));
228        if ds.is_empty() {
229            vec![crate::fastest_device()]
230        } else {
231            ds
232        }
233    } else {
234        vec![resolve_device(spec_device)]
235    }
236}
237
238/// Run one worker's full data-parallel training from a shipped [`TrainSpec`] —
239/// **the generic worker's whole job, with no model code.**
240///
241/// * `resolve` — node-local weight/data URI → f32 (built-in schemes handled by
242///   the internal [`WeightCache`]; this is the fallback for custom schemes).
243/// * `reduce` — average a flat gradient buffer across all workers (the worker
244///   binary implements this with `rlx-collectives`; keeps this crate
245///   collective-free). Called once per step with all gradients flattened into
246///   one bucket (DDP-style), and must return the same-length averaged buffer.
247///   **Every rank must call `reduce` the same number of times, in lockstep** —
248///   the collective is a barrier, so a rank that syncs fewer times deadlocks the
249///   rest. `run_train` guarantees this by keeping each rank's per-epoch sync
250///   count driven only by cluster-uniform quantities (`shard_len / batch`), not
251///   by node-local hardware (see `training_lanes`).
252///
253/// `world` is the number of ranks in the cross-worker group. It gates the
254/// intra-node multi-lane (`device:"all"`) path, which is single-node-only:
255/// mixing it with cross-node DP on heterogeneous hardware desyncs the reduce.
256///
257/// Returns the metrics and the final (synced) parameters as `(name, values)`.
258pub fn run_train<W, R>(
259    spec: &TrainSpec,
260    world: u32,
261    mut resolve: W,
262    mut reduce: R,
263    log: bool,
264) -> Result<(TrainMetrics, Vec<(String, Vec<f32>)>), String>
265where
266    W: FnMut(&str) -> Vec<f32>,
267    R: FnMut(&[f32]) -> Vec<f32>,
268{
269    use std::time::Instant;
270
271    let inventory = crate::available_devices();
272
273    // Resolve initial params node-locally (never off the wire).
274    let mut cache = WeightCache::new();
275    let mut params: Vec<Vec<f32>> = spec
276        .params
277        .iter()
278        .map(|w| cache.f32(&w.uri).unwrap_or_else(|_| resolve(&w.uri)))
279        .collect();
280    let names: Vec<String> = spec.params.iter().map(|w| w.name.clone()).collect();
281    let mut vel: Vec<Vec<f32>> = params.iter().map(|p| vec![0.0; p.len()]).collect();
282
283    // Resolve ONLY this rank's data shard (IO opt: `file://` is read with a
284    // seek to the shard offset, so the worker never loads the whole dataset).
285    // `data` here is already sharded — batch `b` is `data[b*batch*elem ..]`.
286    let feeds: Vec<Feed> = spec
287        .data
288        .iter()
289        .map(|d| Feed {
290            input: d.input.clone(),
291            elem: d.elem,
292            data: resolve_shard(
293                &d.uri,
294                d.elem,
295                d.shard_start,
296                d.shard_len,
297                &mut cache,
298                &mut resolve,
299            ),
300        })
301        .collect();
302    let shard_len = spec.data.first().map(|d| d.shard_len).unwrap_or(0);
303    let batches = shard_len / spec.batch.max(1);
304
305    // Device set (see [`training_lanes`]): `all` → every live backend on this
306    // node at once (CPU trains alongside the GPU), else the single resolved
307    // device. Multi-lane is cluster-safe here thanks to the weighted reduce below.
308    let devices: Vec<Device> =
309        training_lanes(&spec.device, &crate::available_devices(), &spec.graph);
310
311    // Fail fast (identically on every rank) if the master sharded unevenly.
312    if world > 1 {
313        assert_uniform_shards(&mut reduce, batches)?;
314    }
315
316    let np = params.len();
317    let (mut compute_s, mut comm_s) = (0.0f64, 0.0f64);
318    let (mut first_loss, mut last_loss) = (f32::NAN, f32::NAN);
319    let mut samples = 0usize;
320    let wall = Instant::now();
321
322    if devices.len() > 1 {
323        // ── intra-node multi-lane DP: one lane per local backend (CPU + GPU),
324        // each running a full independent mini-batch concurrently. Sync count is
325        // fixed to `batches` (cluster-uniform), NOT `batches / lanes`, so a node
326        // with more lanes never desyncs the collective — it just covers its shard
327        // more times per epoch (`lanes` passes) and contributes proportionally
328        // more, correctly weighted by `weighted_sync`. ──
329        if log {
330            let names: Vec<&str> = devices.iter().map(|d| crate::device_label(*d)).collect();
331            eprintln!("  intra-node lanes engaged: {names:?} (sample-weighted DP)");
332        }
333        let mut lanes = spawn_lanes(spec, &devices);
334        let ndev = lanes.len();
335        for (epoch, &lr) in spec.lr_per_epoch.iter().enumerate() {
336            let mut epoch_loss = 0.0f64;
337            for s in 0..batches {
338                let t = Instant::now();
339                for (i, lane) in lanes.iter().enumerate() {
340                    // Rotating window so the lanes cover distinct batches; over
341                    // `batches` syncs each of the `ndev` lanes makes one pass, so
342                    // the node makes `ndev` passes total (weighted accordingly).
343                    let b = (s * ndev + i) % batches;
344                    let fdata: Vec<Vec<f32>> = feeds
345                        .iter()
346                        .map(|f| {
347                            let off = b * spec.batch * f.elem;
348                            f.data[off..off + spec.batch * f.elem].to_vec()
349                        })
350                        .collect();
351                    lane.job.send(LaneJob::Step(params.clone(), fdata)).ok();
352                }
353                // SUM (not mean) grads across lanes — the per-lane sample counts
354                // are equal (`batch` each), so `weighted_sync` divides by the
355                // cluster-wide lane total to get the true global mean gradient.
356                let mut sum_grads: Vec<Vec<f32>> =
357                    params.iter().map(|p| vec![0.0f32; p.len()]).collect();
358                let mut loss_sum = 0.0f64;
359                for lane in &lanes {
360                    let (loss, grads) = lane
361                        .res
362                        .recv()
363                        .map_err(|e| format!("lane {}: {e}", crate::device_label(lane.dev)))?;
364                    loss_sum += loss as f64;
365                    for i in 0..np {
366                        for (a, g) in sum_grads[i].iter_mut().zip(&grads[i]) {
367                            *a += g;
368                        }
369                    }
370                }
371                compute_s += t.elapsed().as_secs_f64();
372                epoch_loss += loss_sum / ndev as f64;
373                samples += spec.batch * ndev;
374                weighted_sync(
375                    &mut reduce,
376                    &mut params,
377                    &mut vel,
378                    &sum_grads,
379                    ndev,
380                    spec.momentum,
381                    lr,
382                    &mut comm_s,
383                );
384            }
385            record_epoch(
386                epoch,
387                (epoch_loss / batches.max(1) as f64) as f32,
388                &mut first_loss,
389                &mut last_loss,
390                log,
391                spec.lr_per_epoch.len(),
392            );
393        }
394        for lane in &mut lanes {
395            lane.job.send(LaneJob::Stop).ok();
396        }
397        for lane in lanes {
398            lane.handle.join().ok();
399        }
400    } else {
401        // ── single lane (optimized: zero-copy batch slices, reused compiled
402        // graph). Contributes one weighted vote (lane_count = 1), so it combines
403        // correctly with multi-lane peers on the same cluster. ──
404        let mut compiled = Session::new(devices[0]).compile(spec.graph.clone());
405        for (n, p) in names.iter().zip(&params) {
406            compiled.set_param(n, p);
407        }
408        let one = [1.0f32];
409        let mut sum_grads: Vec<Vec<f32>> = params.iter().map(|p| vec![0.0f32; p.len()]).collect();
410        for (epoch, &lr) in spec.lr_per_epoch.iter().enumerate() {
411            let mut epoch_loss = 0.0f64;
412            for b in 0..batches {
413                let mut feed: Vec<(&str, &[f32])> = feeds
414                    .iter()
415                    .map(|f| {
416                        let off = b * spec.batch * f.elem;
417                        (f.input.as_str(), &f.data[off..off + spec.batch * f.elem])
418                    })
419                    .collect();
420                if let Some(seed) = &spec.seed_input {
421                    feed.push((seed.as_str(), &one));
422                }
423                let t = Instant::now();
424                let outs = compiled.run(&feed);
425                compute_s += t.elapsed().as_secs_f64();
426                epoch_loss += outs[spec.loss_index][0] as f64;
427                samples += spec.batch;
428
429                for i in 0..np {
430                    sum_grads[i].copy_from_slice(&outs[spec.grad_start + i]);
431                }
432                weighted_sync(
433                    &mut reduce,
434                    &mut params,
435                    &mut vel,
436                    &sum_grads,
437                    1,
438                    spec.momentum,
439                    lr,
440                    &mut comm_s,
441                );
442                for (n, p) in names.iter().zip(&params) {
443                    compiled.set_param(n, p);
444                }
445            }
446            record_epoch(
447                epoch,
448                (epoch_loss / batches.max(1) as f64) as f32,
449                &mut first_loss,
450                &mut last_loss,
451                log,
452                spec.lr_per_epoch.len(),
453            );
454        }
455    }
456
457    let metrics = TrainMetrics {
458        device: crate::device_ext::fastest_among(&devices),
459        lanes: devices,
460        inventory,
461        samples,
462        wall_s: wall.elapsed().as_secs_f64(),
463        compute_s,
464        comm_s,
465        first_loss,
466        last_loss,
467    };
468    let final_params = names.into_iter().zip(params).collect();
469    Ok((metrics, final_params))
470}
471
472/// One fed input's node-local **shard** (already sliced to this rank) + its row
473/// stride, so batch `b` is `data[b*batch*elem .. (b+1)*batch*elem]`.
474struct Feed {
475    input: String,
476    elem: usize,
477    data: Vec<f32>,
478}
479
480/// Read only samples `[start, start+len)` of a tensor. For `file://` this is a
481/// `seek` + bounded read (the worker never loads the whole dataset — the IO
482/// win); other schemes fall back to a full resolve then slice.
483fn resolve_shard<W: FnMut(&str) -> Vec<f32>>(
484    uri: &str,
485    elem: usize,
486    start: usize,
487    len: usize,
488    cache: &mut WeightCache,
489    resolve: &mut W,
490) -> Vec<f32> {
491    if let Some(path) = uri.strip_prefix("file://") {
492        use std::io::{Read, Seek, SeekFrom};
493        if let Ok(mut f) = std::fs::File::open(path) {
494            let mut buf = vec![0u8; len * elem * 4];
495            if f.seek(SeekFrom::Start((start * elem * 4) as u64)).is_ok()
496                && f.read_exact(&mut buf).is_ok()
497            {
498                return buf
499                    .chunks_exact(4)
500                    .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
501                    .collect();
502            }
503        }
504    }
505    let full = cache.f32(uri).unwrap_or_else(|_| resolve(uri));
506    full.get(start * elem..(start + len) * elem)
507        .map(<[f32]>::to_vec)
508        .unwrap_or(full)
509}
510
511/// Guard that every rank runs the same number of gradient syncs (`= batches`),
512/// which the barrier-style all-reduce requires. `batches` is uniform iff the
513/// master sharded evenly; we verify it cluster-wide through the `reduce` closure
514/// (mean + mean-of-squares → variance) so **all** ranks return the SAME error
515/// rather than some erroring while the rest deadlock waiting on them. Lane counts
516/// MAY differ across nodes (the weighted reduce handles that); shard sizes may not.
517fn assert_uniform_shards<R: FnMut(&[f32]) -> Vec<f32>>(
518    reduce: &mut R,
519    batches: usize,
520) -> Result<(), String> {
521    let b = batches as f32;
522    let stats = reduce(&[b, b * b]);
523    let mean_b = stats.first().copied().unwrap_or(b);
524    let mean_b2 = stats.get(1).copied().unwrap_or(b * b);
525    let var = (mean_b2 - mean_b * mean_b).max(0.0);
526    if var > 0.25 {
527        return Err(format!(
528            "run_train: uneven shards across the cluster — this rank has {batches} batches, \
529             cluster mean {mean_b:.2} (var {var:.2}). The master must ship an equal shard_len \
530             (÷ batch) to every rank, else the gradient all-reduce deadlocks."
531        ));
532    }
533    Ok(())
534}
535
536/// One data-parallel gradient sync + SGD step, with a **sample-count-weighted**
537/// cross-worker reduce that makes heterogeneous lane counts safe.
538///
539/// `sum_grads` is the element-wise SUM of this rank's per-lane gradients (each
540/// lane ran one equal-size `batch`); `lane_count` is how many lanes it ran. The
541/// bucket sent to `reduce` is `[sum_grads…, lane_count]`. A Mean- (or Sum-)reduce
542/// then gives `[Σ_w Σ_lane grad , Σ_w lane_count_w]` up to a shared 1/W factor,
543/// and dividing the gradient part by the count part yields
544/// `Σ_all grad_lane / Σ_all lane_count` — the true global mean gradient over
545/// every equal-size batch in the cluster. The 1/W cancels, so a 1-lane node
546/// (`lane_count = 1`) and a 3-lane node (`lane_count = 3`) combine **without
547/// bias**, and because the bucket length and call count are identical on every
548/// rank the collective never deadlocks.
549fn weighted_sync<R: FnMut(&[f32]) -> Vec<f32>>(
550    reduce: &mut R,
551    params: &mut [Vec<f32>],
552    vel: &mut [Vec<f32>],
553    sum_grads: &[Vec<f32>],
554    lane_count: usize,
555    momentum: f32,
556    lr: f32,
557    comm_s: &mut f64,
558) {
559    use std::time::Instant;
560    let total: usize = sum_grads.iter().map(|g| g.len()).sum();
561    let mut flat = Vec::with_capacity(total + 1);
562    for g in sum_grads {
563        flat.extend_from_slice(g);
564    }
565    flat.push(lane_count as f32);
566    let t = Instant::now();
567    let reduced = reduce(&flat);
568    *comm_s += t.elapsed().as_secs_f64();
569    let denom = reduced.get(total).copied().unwrap_or(lane_count as f32);
570    let inv = if denom != 0.0 { 1.0 / denom } else { 0.0 };
571    apply_sgd(params, vel, &reduced, inv, momentum, lr);
572}
573
574/// Fold one epoch's mean loss into the run metrics (first/last loss) and log it —
575/// the bookkeeping shared by the single-lane and multi-lane training loops.
576fn record_epoch(
577    epoch: usize,
578    mean: f32,
579    first_loss: &mut f32,
580    last_loss: &mut f32,
581    log: bool,
582    total_epochs: usize,
583) {
584    if epoch == 0 {
585        *first_loss = mean;
586    }
587    *last_loss = mean;
588    if log {
589        eprintln!("  epoch {}/{total_epochs}: mean loss {mean:.4}", epoch + 1);
590    }
591}
592
593/// In-place momentum-SGD update, scattering a flat reduced-gradient bucket back
594/// across the per-parameter tensors and applying `scale` (the weighted reduce's
595/// 1/lane_total) to each gradient on the fly — no intermediate scaled copy.
596fn apply_sgd(
597    params: &mut [Vec<f32>],
598    vel: &mut [Vec<f32>],
599    reduced: &[f32],
600    scale: f32,
601    momentum: f32,
602    lr: f32,
603) {
604    let mut off = 0usize;
605    for (p, v) in params.iter_mut().zip(vel.iter_mut()) {
606        for j in 0..p.len() {
607            let g = reduced[off + j] * scale;
608            v[j] = momentum * v[j] + g;
609            p[j] -= lr * v[j];
610        }
611        off += p.len();
612    }
613}
614
615/// A device lane: a thread that owns a `CompiledGraph` on one backend and runs
616/// mini-batches fed over a channel (keeps the non-`Send` compiled graph
617/// thread-local; only f32 buffers cross the boundary).
618struct DevLane {
619    dev: Device,
620    job: std::sync::mpsc::Sender<LaneJob>,
621    res: std::sync::mpsc::Receiver<(f32, Vec<Vec<f32>>)>,
622    handle: std::thread::JoinHandle<()>,
623}
624
625enum LaneJob {
626    /// (params snapshot, per-input batch data) → run one step, return grads.
627    Step(Vec<Vec<f32>>, Vec<Vec<f32>>),
628    Stop,
629}
630
631/// Spawn one [`DevLane`] per device, each compiling `spec.graph` on its backend.
632fn spawn_lanes(spec: &TrainSpec, devices: &[Device]) -> Vec<DevLane> {
633    devices
634        .iter()
635        .map(|&dev| {
636            let (jtx, jrx) = std::sync::mpsc::channel::<LaneJob>();
637            let (rtx, rrx) = std::sync::mpsc::channel();
638            let graph = spec.graph.clone();
639            let names: Vec<String> = spec.params.iter().map(|w| w.name.clone()).collect();
640            let inputs: Vec<String> = spec.data.iter().map(|d| d.input.clone()).collect();
641            let seed = spec.seed_input.clone();
642            let (grad_start, loss_index, np) =
643                (spec.grad_start, spec.loss_index, spec.params.len());
644            let handle = std::thread::spawn(move || {
645                let mut compiled = Session::new(dev).compile(graph);
646                let one = [1.0f32];
647                while let Ok(job) = jrx.recv() {
648                    match job {
649                        LaneJob::Step(params, fdata) => {
650                            for (n, p) in names.iter().zip(&params) {
651                                compiled.set_param(n, p);
652                            }
653                            let mut feed: Vec<(&str, &[f32])> = inputs
654                                .iter()
655                                .zip(&fdata)
656                                .map(|(n, d)| (n.as_str(), d.as_slice()))
657                                .collect();
658                            if let Some(s) = &seed {
659                                feed.push((s.as_str(), &one));
660                            }
661                            let outs = compiled.run(&feed);
662                            let loss = outs[loss_index][0];
663                            let grads: Vec<Vec<f32>> =
664                                (0..np).map(|i| outs[grad_start + i].clone()).collect();
665                            let _ = rtx.send((loss, grads));
666                        }
667                        LaneJob::Stop => break,
668                    }
669                }
670            });
671            DevLane {
672                dev,
673                job: jtx,
674                res: rrx,
675                handle,
676            }
677        })
678        .collect()
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn training_lanes_all_fans_out_cpu_and_gpu() {
687        use rlx_ir::{DType, Graph, Shape};
688        // A tiny graph so `devices_for` has an op set to inspect.
689        let mut g = Graph::new("t");
690        let x = g.input("x", Shape::new(&[2, 2], DType::F32));
691        let w = g.param("w", Shape::new(&[2, 2], DType::F32));
692        let mm = g.matmul(x, w, Shape::new(&[2, 2], DType::F32));
693        g.set_outputs(vec![mm]);
694
695        let avail = [Device::Cpu, Device::Metal];
696        // `all` engages EVERY local backend (ANE excluded) — CPU trains alongside
697        // the GPU. This holds on a cluster too: the sample-weighted reduce in
698        // `run_train` keeps heterogeneous lane counts from desyncing.
699        assert_eq!(training_lanes("all", &avail, &g).len(), 2);
700        assert_eq!(training_lanes("all", &[Device::Cpu], &g).len(), 1);
701        // An explicit device directive is always a single lane.
702        assert_eq!(training_lanes("cpu", &avail, &g).len(), 1);
703    }
704
705    #[test]
706    fn weighted_sync_unbiased_across_uneven_lane_counts() {
707        // Two "ranks": rank A ran 3 lanes (grads summed to gA_sum), rank B ran 1
708        // lane (gB). The weighted reduce must yield the global MEAN over all 4
709        // equal-size batches: (gA_sum + gB) / 4 — NOT the naïve node-mean
710        // (gA_sum/3 + gB) / 2, which over-weights the 1-lane node.
711        let p = 2usize; // params per bucket
712        let ga_sum = vec![6.0f32, 12.0]; // 3 lanes summed (e.g. lanes 1+2+3, 3+4+5)
713        let gb = [10.0f32, 20.0]; // 1 lane
714        // Emulate an all-reduce Mean over the two ranks' [grads…, lane_count]
715        // buckets (this is exactly what ProcessGroup::all_reduce(Mean) does).
716        let bucket_a = [ga_sum[0], ga_sum[1], 3.0];
717        let bucket_b = [gb[0], gb[1], 1.0];
718        let mean: Vec<f32> = (0..p + 1)
719            .map(|i| (bucket_a[i] + bucket_b[i]) / 2.0)
720            .collect();
721        let mut mean_iter = mean.clone();
722        let mut reduce = move |buf: &[f32]| -> Vec<f32> {
723            // The buffer each rank passes is identical after a real all-reduce;
724            // return the pre-computed cross-rank mean, ignoring the local buf
725            // contents beyond asserting the length/protocol.
726            assert_eq!(buf.len(), p + 1);
727            std::mem::take(&mut mean_iter)
728        };
729        let mut params = vec![vec![0.0f32; p]];
730        let mut vel = vec![vec![0.0f32; p]];
731        let sum_grads = vec![ga_sum.clone()]; // this rank = rank A (3 lanes)
732        let mut comm = 0.0;
733        weighted_sync(
734            &mut reduce,
735            &mut params,
736            &mut vel,
737            &sum_grads,
738            3,
739            0.0,
740            1.0,
741            &mut comm,
742        );
743        // global mean grad = (gA_sum + gB) / (3 + 1)
744        let expect = [(6.0 + 10.0) / 4.0, (12.0 + 20.0) / 4.0];
745        // params -= lr(1.0) * grad, starting from 0 → params == -grad
746        assert!(
747            (params[0][0] + expect[0]).abs() < 1e-6,
748            "got {:?}",
749            params[0]
750        );
751        assert!(
752            (params[0][1] + expect[1]).abs() < 1e-6,
753            "got {:?}",
754            params[0]
755        );
756    }
757}