use rlx_ir::op::{Activation, BinaryOp};
use rlx_ir::{DType, Graph, Shape};
use rlx_runtime::dist::node::{NodeConfig, collect_caps, mean_reduce, serve_trainer_here};
use rlx_runtime::dist::{
self, DataRef, StageSpec, TrainSpec, WeightRef, recv_activation, send_activation, ship_stage,
ship_train,
};
const N: usize = 64;
fn stage_graph() -> Graph {
let mut g = Graph::new("mobile_demo_stage");
let shape = Shape::new(&[1, N], DType::F32);
let x = g.input("x", shape.clone());
let three = rlx_ir::rf::const_f32(&mut g, 3.0, rlx_ir::rf::scalar_f32());
let one = rlx_ir::rf::const_f32(&mut g, 1.0, rlx_ir::rf::scalar_f32());
let scaled = g.binary(BinaryOp::Mul, x, three, shape.clone());
let shifted = g.binary(BinaryOp::Add, scaled, one, shape.clone());
let y = g.activation(Activation::Gelu, shifted, shape);
g.set_outputs(vec![y]);
g
}
fn reference_hop(x: &[f32]) -> Vec<f32> {
x.iter()
.map(|v| {
let t = f64::from(*v) * 3.0 + 1.0;
(0.5 * t * (1.0 + erf(t / std::f64::consts::SQRT_2))) as f32
})
.collect()
}
fn erf(x: f64) -> f64 {
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let t = 1.0 / (1.0 + 0.3275911 * x);
let y = 1.0
- (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t
+ 0.254829592)
* t
* (-x * x).exp();
sign * y
}
fn free_ports(n: usize) -> Result<Vec<u16>, String> {
use std::net::{Ipv4Addr, TcpListener};
let ls: Vec<TcpListener> = (0..n)
.map(|_| TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| e.to_string()))
.collect::<Result<_, _>>()?;
ls.iter()
.map(|l| l.local_addr().map(|a| a.port()).map_err(|e| e.to_string()))
.collect()
}
const TRAIN_D: usize = 3;
const TRAIN_PER_RANK: usize = 16;
const TRAIN_BATCH: usize = 4;
fn seeded(n: usize, seed: u64) -> Vec<f32> {
let mut s = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1);
(0..n)
.map(|_| {
s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = s;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z ^= z >> 31;
((z >> 40) as f32 / (1u32 << 24) as f32) * 2.0 - 1.0
})
.collect()
}
fn write_f32(dir: &std::path::Path, name: &str, vals: &[f32]) -> String {
let path = dir.join(name);
let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
std::fs::write(&path, &bytes).expect("write demo data");
format!("file://{}", path.display())
}
fn train_spec(dir: &std::path::Path, shard: usize, world: usize, push_data: bool) -> TrainSpec {
use rlx_ir::op::ReduceOp;
let m = TRAIN_PER_RANK * world;
let f = DType::F32;
let mut g = Graph::new("demo_lr");
let x = g.input("X", Shape::new(&[TRAIN_BATCH, TRAIN_D], f));
let w = g.param("w", Shape::new(&[TRAIN_D, 1], f));
let y = g.input("y", Shape::new(&[TRAIN_BATCH, 1], f));
let pred = g.matmul(x, w, Shape::new(&[TRAIN_BATCH, 1], f));
let diff = g.binary(BinaryOp::Sub, pred, y, Shape::new(&[TRAIN_BATCH, 1], f));
let sq = g.binary(BinaryOp::Mul, diff, diff, Shape::new(&[TRAIN_BATCH, 1], f));
let loss = g.add_node(
rlx_ir::op::Op::Reduce {
op: ReduceOp::Mean,
axes: vec![0, 1],
keep_dim: false,
},
vec![sq],
Shape::from_dims(&[], f),
);
g.set_outputs(vec![loss]);
let bwd = rlx_autodiff::grad_with_loss(&g, &[w]);
let w_true = seeded(TRAIN_D, 7);
let x_data = seeded(m * TRAIN_D, 3);
let y_data: Vec<f32> = (0..m)
.map(|i| {
(0..TRAIN_D)
.map(|k| x_data[i * TRAIN_D + k] * w_true[k])
.sum()
})
.collect();
TrainSpec {
graph: bwd,
params: vec![WeightRef {
name: "w".into(),
uri: write_f32(dir, "w0.bin", &[0.0f32; TRAIN_D]),
packed: false,
}],
grad_start: 1,
loss_index: 0,
data: vec![
DataRef {
input: "X".into(),
uri: write_f32(dir, "X.bin", &x_data),
elem: TRAIN_D,
shard_start: shard * TRAIN_PER_RANK,
shard_len: TRAIN_PER_RANK,
},
DataRef {
input: "y".into(),
uri: write_f32(dir, "y.bin", &y_data),
elem: 1,
shard_start: shard * TRAIN_PER_RANK,
shard_len: TRAIN_PER_RANK,
},
],
seed_input: Some("d_output".into()),
momentum: 0.0,
lr_per_epoch: vec![0.3; 60],
batch: TRAIN_BATCH,
device: "auto".into(),
grad_group: 0,
push_data,
}
}
struct Args {
world: u32,
peers: Vec<String>,
steps: usize,
train: bool,
self_test: bool,
}
fn parse_args() -> Result<Args, String> {
let mut world = 2u32;
let mut peers = Vec::new();
let mut steps = 4usize;
let mut self_test = false;
let mut train = false;
let argv: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < argv.len() {
let need = |i: usize| -> Result<String, String> {
argv.get(i + 1)
.cloned()
.ok_or_else(|| format!("{} needs a value", argv[i]))
};
match argv[i].as_str() {
"--world" => {
world = need(i)?.parse().map_err(|_| "--world must be an integer")?;
i += 2;
}
"--peers" => {
peers = need(i)?.split(',').map(|s| s.trim().to_string()).collect();
i += 2;
}
"--steps" => {
steps = need(i)?.parse().map_err(|_| "--steps must be an integer")?;
i += 2;
}
"--train" => {
train = true;
i += 1;
}
"--self-test" => {
self_test = true;
i += 1;
}
"-h" | "--help" => {
println!(
"node_coordinator [--world N] [--peers a:p,b:p,…] [--steps K]\n\
\n\
--world ranks including this one (default 2 = one phone)\n\
--peers explicit host:port per rank; omit for UDP discovery\n\
--steps activations to stream (default 4)\n\
--self-test run the workers here on loopback (no phone)\n\
--train data-parallel training instead of inference"
);
std::process::exit(0);
}
other => return Err(format!("unknown flag: {other}")),
}
}
if world < 2 {
return Err("--world must be at least 2 (this process plus one node)".into());
}
if !peers.is_empty() && peers.len() != world as usize && peers.len() != 1 {
return Err(format!(
"--peers has {} entries; expected {world} (one per rank) or 1 (this \
coordinator's listen address, for a star)",
peers.len()
));
}
Ok(Args {
world,
peers,
steps,
train,
self_test,
})
}
fn run_training(
group: &rlx_runtime::dist::node::ProcessGroup,
caps: &[rlx_runtime::dist::node::NodeCaps],
args: &Args,
workers: Vec<std::thread::JoinHandle<Option<Vec<f32>>>>,
) {
let dir = std::env::temp_dir().join(format!("rlx_coord_train_{}", std::process::id()));
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("error: temp dir: {e}");
std::process::exit(1);
}
let world = args.world as usize;
let push = !args.self_test;
for c in caps {
if let Err(e) = c.can_train() {
eprintln!("error: {e}");
std::process::exit(1);
}
let spec = train_spec(&dir, c.rank as usize, world, push);
if let Err(e) = ship_train(group, c.rank, &spec) {
eprintln!("error: ship_train(rank {}): {e}", c.rank);
std::process::exit(1);
}
if push && let Err(e) = dist::push_shards(group, c.rank, &spec, dist::uri_resolver) {
eprintln!("error: push_shards(rank {}): {e}", c.rank);
std::process::exit(1);
}
}
println!(
"training job shipped to {} worker(s){}\n",
caps.len(),
if push {
" (data pushed over the wire)"
} else {
""
}
);
let spec = train_spec(&dir, 0, world, false);
let out = dist::run_train(
&spec,
args.world,
dist::uri_resolver,
|flat| mean_reduce(group, flat),
false,
);
let (metrics, params) = match out {
Ok(v) => v,
Err(e) => {
eprintln!("error: run_train: {e}");
std::process::exit(1);
}
};
println!(
"rank 0 trained on {} — {} samples, loss {:.5} -> {:.5} (compute {:.2}s, comm {:.2}s)",
metrics.device.name(),
metrics.samples,
metrics.first_loss,
metrics.last_loss,
metrics.compute_s,
metrics.comm_s
);
let mine = params
.into_iter()
.next()
.map(|(_, v)| v)
.unwrap_or_default();
println!("rank 0 weights: {:?}", round4(&mine));
let mut disagreed = false;
for (i, w) in workers.into_iter().enumerate() {
match w.join() {
Ok(Some(theirs)) => {
let err = mine
.iter()
.zip(&theirs)
.map(|(a, b)| (a - b).abs())
.fold(0.0f32, f32::max);
println!(
"rank {} weights: {:?} max|Δ| = {err:.3e}",
i + 1,
round4(&theirs)
);
if err != 0.0 {
disagreed = true;
}
}
Ok(None) => {}
Err(_) => {
eprintln!("error: a worker thread panicked");
std::process::exit(1);
}
}
}
std::fs::remove_dir_all(&dir).ok();
if metrics.last_loss >= metrics.first_loss {
eprintln!("\nFAIL: loss did not fall");
std::process::exit(1);
}
if disagreed {
eprintln!("\nFAIL: replicas disagree — the gradient all-reduce did not happen");
std::process::exit(1);
}
println!("\nPASS — the mesh trained together and the replicas agree.");
}
fn round4(v: &[f32]) -> Vec<f32> {
v.iter().map(|x| (x * 1e4).round() / 1e4).collect()
}
fn main() {
let args = match parse_args() {
Ok(a) => a,
Err(e) => {
eprintln!("error: {e}");
std::process::exit(2);
}
};
let mut args = args;
let mut workers = Vec::new();
if args.self_test {
let ports = match free_ports(args.world as usize) {
Ok(p) => p,
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
};
args.peers = ports.iter().map(|p| format!("127.0.0.1:{p}")).collect();
println!("self-test: {} loopback rank(s)", args.world - 1);
assert_eq!(args.peers.len(), args.world as usize);
for rank in 1..args.world {
let peers = args.peers.clone();
let training = args.train;
workers.push(std::thread::spawn(move || {
let cfg = NodeConfig::new(rank, peers.len() as u32)
.mesh()
.device("cpu")
.peers(peers)
.expect("peers");
let group = cfg.connect().expect("worker connect");
if training {
let r = serve_trainer_here(&group, dist::uri_resolver, false)
.expect("worker training");
return r.params.into_iter().next().map(|(_, v)| v);
}
let _ = rlx_runtime::dist::node::serve_worker(&group, |_| Vec::new());
None
}));
}
}
let base = NodeConfig::new(0, args.world).device("auto");
let base = if args.self_test {
base.mesh()
} else {
base.star()
};
let cfg = if args.peers.is_empty() {
println!("waiting for {} node(s) to discover us…", args.world - 1);
base.discover(29600, 29500)
} else {
println!(
"waiting for {} node(s) at {:?}…",
args.world - 1,
args.peers
);
match base.peers(args.peers.clone()) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {e}");
std::process::exit(2);
}
}
};
let group = match cfg.connect() {
Ok(g) => g,
Err(e) => {
eprintln!("error: connect failed: {e}");
std::process::exit(1);
}
};
println!("mesh up: world {}", args.world);
let caps = match collect_caps(&group) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
};
for c in &caps {
println!(
" rank {} — {} [{}] devices: {}",
c.rank,
c.platform,
match &c.role {
rlx_runtime::dist::node::NodeRole::Worker => "worker".to_string(),
rlx_runtime::dist::node::NodeRole::FixedFunction(f) =>
format!("fixed-function: {}", f.stage_id),
},
c.devices.join(", ")
);
}
if args.train {
run_training(&group, &caps, &args, workers);
return;
}
let spec = StageSpec::new(stage_graph(), "x", "y", "auto");
for c in &caps {
if let Err(e) = c.accepts(&spec) {
eprintln!("error: rank {} cannot run this stage: {e}", c.rank);
std::process::exit(1);
}
if let Err(e) = ship_stage(&group, c.rank, &spec) {
eprintln!("error: ship_stage(rank {}): {e}", c.rank);
std::process::exit(1);
}
}
println!("stage shipped to {} worker(s)\n", caps.len());
let hops = args.world as usize - 1;
let last = args.world - 1;
let mut worst: f32 = 0.0;
for step in 0..args.steps {
let x: Vec<f32> = (0..N)
.map(|i| ((i + step * N) as f32 * 0.07).sin())
.collect();
if let Err(e) = send_activation(&group, 1, &x) {
eprintln!("error: send: {e}");
std::process::exit(1);
}
let got = match recv_activation(&group, last) {
Ok(v) => v,
Err(e) => {
eprintln!("error: recv: {e}");
std::process::exit(1);
}
};
let mut want = x.clone();
for _ in 0..hops {
want = reference_hop(&want);
}
if got.len() != want.len() {
eprintln!(
"error: step {step}: got {} elems, want {}",
got.len(),
want.len()
);
std::process::exit(1);
}
let err = got
.iter()
.zip(&want)
.map(|(a, b)| (a - b).abs())
.fold(0.0f32, f32::max);
worst = worst.max(err);
println!(
"step {step}: max|got-ref| = {err:.3e} first 4: {:?}",
got.iter()
.take(4)
.map(|v| (v * 1e4).round() / 1e4)
.collect::<Vec<_>>()
);
}
let tol = 2e-5 * hops as f32;
println!("\nworst error across {} step(s): {worst:.3e}", args.steps);
if worst > tol {
eprintln!("FAIL: exceeds tolerance {tol:.1e}");
std::process::exit(1);
}
drop(group);
drop(workers);
println!("PASS — the phone(s) computed the stage correctly.");
}