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