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(¶ms) {
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(¶ms) {
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(¶ms) {
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}