Skip to main content

rlx_runtime/dist/
training.rs

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