use crate::vision::{self, ModelMeta, VisionModelId};
use rlx_runtime::{BrowserCompiledGraph, BrowserSession, Device};
use std::cell::RefCell;
use wasm_bindgen::prelude::*;
pub const DATASET: u32 = 60_000;
#[wasm_bindgen]
pub struct ClusterWorker {
meta: ModelMeta,
exec: RefCell<BrowserCompiledGraph>,
params: RefCell<Vec<f32>>,
batch: usize,
slug: String,
backend: String,
fallback: String,
}
#[wasm_bindgen]
impl ClusterWorker {
#[wasm_bindgen(constructor)]
pub fn new(
slug: &str,
batch: usize,
seed: u32,
backend: &str,
) -> Result<ClusterWorker, JsValue> {
let id = VisionModelId::from_slug(slug)
.ok_or_else(|| JsValue::from_str(&format!("unknown vision model {slug:?}")))?;
let batch = batch.max(1);
let meta = vision::model_meta(id);
let graph = vision::build_train(id, batch);
let candidates = backend_candidates(backend, id, &rlx_runtime::available_browser_devices())
.map_err(|e| JsValue::from_str(&e))?;
let mut device = Device::Cpu;
let mut exec = None;
let mut why = String::new();
let mut skipped: Vec<String> = Vec::new();
for &candidate in &candidates {
match BrowserSession::new(candidate).and_then(|s| s.compile(graph.clone())) {
Ok(compiled) => {
if let Some(reason) = host_execution_block(candidate, &compiled) {
skipped.push(reason.clone());
why = reason;
continue;
}
device = candidate;
exec = Some(compiled);
break;
}
Err(e) => {
skipped.push(format!("{}: {e}", device_label(candidate)));
why = e.to_string();
}
}
}
let exec = exec.ok_or_else(|| {
JsValue::from_str(&format!(
"no browser backend can train {} ({}): {why}",
slug,
candidates
.iter()
.map(|d| device_label(*d))
.collect::<Vec<_>>()
.join(", ")
))
})?;
let params = vision::init_params(&meta, seed);
Ok(Self {
meta,
exec: RefCell::new(exec),
params: RefCell::new(params),
batch,
slug: slug.to_string(),
backend: device_label(device).to_string(),
fallback: skipped.join("; "),
})
}
#[wasm_bindgen(getter)]
pub fn backend(&self) -> String {
self.backend.clone()
}
#[wasm_bindgen(getter)]
pub fn fallback(&self) -> String {
self.fallback.clone()
}
#[wasm_bindgen(getter)]
pub fn slug(&self) -> String {
self.slug.clone()
}
#[wasm_bindgen(getter)]
pub fn batch(&self) -> usize {
self.batch
}
#[wasm_bindgen(getter)]
pub fn bucket_len(&self) -> usize {
self.meta.param_flat_len()
}
pub fn params(&self) -> Vec<f32> {
self.params.borrow().clone()
}
pub fn set_params(&self, flat: &[f32]) -> Result<(), JsValue> {
let mut params = self.params.borrow_mut();
if flat.len() != params.len() {
return Err(JsValue::from_str(&format!(
"params: expected {} floats, got {}",
params.len(),
flat.len()
)));
}
params.copy_from_slice(flat);
Ok(())
}
#[allow(clippy::await_holding_refcell_ref)]
#[cfg(target_arch = "wasm32")]
pub async fn grads(&self, start: u32) -> Result<Vec<f32>, JsValue> {
let (x, labels) = self.shard(start);
let mut exec = self.borrow_exec()?;
{
let params = self.params.borrow();
vision::apply_browser_params(&mut exec, &self.meta, ¶ms);
}
let outs = exec
.run_async(&[("x", &x), ("labels", &labels), ("d_output", &[1.0])])
.await
.map_err(|e| JsValue::from_str(&e.to_string()))?;
drop(exec);
self.bucket(outs)
}
pub fn grads_sync(&self, start: u32) -> Result<Vec<f32>, JsValue> {
if self.backend == "webgpu" {
return Err(JsValue::from_str(
"webgpu cannot run synchronously — await grads() instead",
));
}
let (x, labels) = self.shard(start);
let mut exec = self.borrow_exec()?;
{
let params = self.params.borrow();
vision::apply_browser_params(&mut exec, &self.meta, ¶ms);
}
let outs = exec.run(&[("x", &x), ("labels", &labels), ("d_output", &[1.0])]);
drop(exec);
self.bucket(outs)
}
pub fn apply(&self, avg: &[f32], lr: f32) -> Result<(), JsValue> {
if avg.len() != self.meta.param_flat_len() {
return Err(JsValue::from_str(&format!(
"averaged bucket: expected {} floats, got {}",
self.meta.param_flat_len(),
avg.len()
)));
}
let mut off = 0;
let slices: Vec<&[f32]> = self
.meta
.params
.iter()
.map(|p| {
let n = p.shape.iter().product::<usize>();
let s = &avg[off..off + n];
off += n;
s
})
.collect();
let mut params = self.params.borrow_mut();
*params = vision::train_step_params(&self.meta, ¶ms, &slices, lr);
Ok(())
}
#[cfg(target_arch = "wasm32")]
pub async fn loss(&self, start: u32) -> Result<f32, JsValue> {
Ok(self.grads(start).await?[0])
}
}
fn backend_candidates(
want: &str,
model: VisionModelId,
available: &[Device],
) -> Result<Vec<Device>, String> {
let live = |d: Device| d == Device::Cpu || available.contains(&d);
let trains = |d: Device| d != Device::OpenGl || model.webgl_train();
match want.to_ascii_lowercase().as_str() {
"auto" | "" => {
let mut ds: Vec<Device> = [Device::WebGpu, Device::OpenGl, Device::Cpu]
.into_iter()
.filter(|&d| live(d) && trains(d))
.collect();
if ds.is_empty() {
ds.push(Device::Cpu);
}
Ok(ds)
}
"cpu" => Ok(vec![Device::Cpu]),
"webgpu" | "gpu" | "wgpu" => {
if live(Device::WebGpu) {
Ok(vec![Device::WebGpu])
} else {
Err("webgpu is not available — call init_webgpu() first".into())
}
}
"webgl" | "opengl" | "gl" => {
if !live(Device::OpenGl) {
Err("webgl is not available in this browser".into())
} else if !trains(Device::OpenGl) {
Err(format!(
"model {} cannot train on webgl (conv backward is unavailable there); \
use mnist-mlp, or another backend",
model.slug()
))
} else {
Ok(vec![Device::OpenGl])
}
}
other => Err(format!(
"unknown backend {other:?}; use auto, cpu, webgpu or webgl"
)),
}
}
#[cfg(target_arch = "wasm32")]
fn host_execution_block(device: Device, compiled: &BrowserCompiledGraph) -> Option<String> {
if matches!(device, Device::WebGpu | Device::Gpu) && compiled.requires_host_execution() {
let detail = compiled
.host_execution_summary()
.unwrap_or_else(|| "host-executed steps".to_string());
Some(format!(
"{} cannot run this graph: {detail}",
device_label(device)
))
} else {
None
}
}
#[cfg(not(target_arch = "wasm32"))]
fn host_execution_block(_device: Device, _compiled: &BrowserCompiledGraph) -> Option<String> {
None
}
pub(crate) fn device_label(device: Device) -> &'static str {
match device {
Device::WebGpu | Device::Gpu => "webgpu",
Device::OpenGl => "webgl",
_ => "cpu",
}
}
impl ClusterWorker {
fn borrow_exec(&self) -> Result<std::cell::RefMut<'_, BrowserCompiledGraph>, JsValue> {
self.exec
.try_borrow_mut()
.map_err(|_| JsValue::from_str("a step is already in flight on this rank"))
}
fn bucket(&self, outs: Vec<Vec<f32>>) -> Result<Vec<f32>, JsValue> {
let loss = outs
.first()
.and_then(|o| o.first().copied())
.ok_or_else(|| JsValue::from_str("train graph produced no loss"))?;
let mut flat = Vec::with_capacity(1 + self.meta.param_flat_len());
flat.push(loss);
for g in outs.iter().skip(1) {
flat.extend_from_slice(g);
}
if flat.len() != 1 + self.meta.param_flat_len() {
return Err(JsValue::from_str(&format!(
"gradient bucket: expected {} floats, got {}",
self.meta.param_flat_len(),
flat.len() - 1
)));
}
Ok(flat)
}
fn shard(&self, start: u32) -> (Vec<f32>, Vec<f32>) {
let mut x = Vec::with_capacity(self.batch * self.meta.input_len());
let mut labels = Vec::with_capacity(self.batch);
for i in 0..self.batch as u32 {
let index = start.wrapping_add(i) % DATASET;
let (sx, label) = vision::synthetic_input(&self.meta, index);
x.extend_from_slice(&sx);
labels.push(label);
}
(x, labels)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_shards_average_to_full_batch() {
for slug in ["mnist-mlp", "mnist-cnn"] {
let full = ClusterWorker::new(slug, 8, 42, "cpu").unwrap();
let a = ClusterWorker::new(slug, 4, 42, "cpu").unwrap();
let b = ClusterWorker::new(slug, 4, 42, "cpu").unwrap();
let g_full = full.grads_sync(0).unwrap();
let g_a = a.grads_sync(0).unwrap();
let g_b = b.grads_sync(4).unwrap();
assert_eq!(g_full.len(), g_a.len());
let mut worst = 0.0f32;
for i in 1..g_full.len() {
let avg = 0.5 * (g_a[i] + g_b[i]);
worst = worst.max((avg - g_full[i]).abs());
}
assert!(
worst < 1e-5,
"{slug}: sharded gradients differ from full batch by {worst}"
);
}
}
#[test]
fn ranks_stay_in_sync_without_shipping_params() {
let a = ClusterWorker::new("mnist-mlp", 4, 7, "cpu").unwrap();
let b = ClusterWorker::new("mnist-mlp", 4, 7, "cpu").unwrap();
assert_eq!(a.params(), b.params());
for step in 0..5u32 {
let ga = a.grads_sync(step * 8).unwrap();
let gb = b.grads_sync(step * 8 + 4).unwrap();
let avg: Vec<f32> = ga[1..]
.iter()
.zip(&gb[1..])
.map(|(x, y)| 0.5 * (x + y))
.collect();
a.apply(&avg, 0.05).unwrap();
b.apply(&avg, 0.05).unwrap();
assert_eq!(a.params(), b.params(), "diverged at step {step}");
}
}
#[test]
fn joining_rank_adopts_cohort_params() {
let veteran = ClusterWorker::new("mnist-mlp", 4, 7, "cpu").unwrap();
for step in 0..3u32 {
let g = veteran.grads_sync(step * 4).unwrap();
veteran.apply(&g[1..], 0.05).unwrap();
}
let joiner = ClusterWorker::new("mnist-mlp", 4, 999, "cpu").unwrap();
assert_ne!(joiner.params(), veteran.params());
joiner.set_params(&veteran.params()).unwrap();
assert_eq!(
joiner.grads_sync(12).unwrap(),
veteran.grads_sync(12).unwrap()
);
}
#[test]
fn loss_decreases_over_steps() {
let w = ClusterWorker::new("mnist-mlp", 8, 3, "cpu").unwrap();
let first = w.grads_sync(0).unwrap()[0];
for step in 0..30u32 {
let g = w.grads_sync(step * 8).unwrap();
w.apply(&g[1..], 0.1).unwrap();
}
let last = w.grads_sync(0).unwrap()[0];
assert!(last < first, "loss did not decrease: {first} → {last}");
}
#[test]
fn webgl_is_only_chosen_for_a_model_it_can_train() {
use Device::{Cpu, OpenGl, WebGpu};
let gl_only = [OpenGl, Cpu];
let gpu_and_gl = [WebGpu, OpenGl, Cpu];
let cpu_only = [Cpu];
assert_eq!(
backend_candidates("webgl", VisionModelId::MnistMlp, &gl_only),
Ok(vec![OpenGl])
);
let err = backend_candidates("webgl", VisionModelId::MnistCnn, &gl_only).unwrap_err();
assert!(err.contains("conv backward"), "unhelpful refusal: {err}");
assert_eq!(
backend_candidates("auto", VisionModelId::MnistCnn, &gl_only),
Ok(vec![Cpu])
);
assert_eq!(
backend_candidates("auto", VisionModelId::MnistMlp, &gl_only),
Ok(vec![OpenGl, Cpu])
);
assert_eq!(
backend_candidates("auto", VisionModelId::MnistCnn, &gpu_and_gl),
Ok(vec![WebGpu, Cpu])
);
assert_eq!(
backend_candidates("auto", VisionModelId::MnistMlp, &gpu_and_gl),
Ok(vec![WebGpu, OpenGl, Cpu])
);
assert!(backend_candidates("webgl", VisionModelId::MnistMlp, &cpu_only).is_err());
assert!(backend_candidates("webgpu", VisionModelId::MnistCnn, &cpu_only).is_err());
assert_eq!(
backend_candidates("cpu", VisionModelId::MnistCnn, &cpu_only),
Ok(vec![Cpu])
);
assert!(backend_candidates("vulkan", VisionModelId::MnistCnn, &gpu_and_gl).is_err());
}
#[test]
fn bucket_len_matches_param_flat_len() {
let w = ClusterWorker::new("mnist-cnn", 2, 1, "cpu").unwrap();
assert_eq!(w.bucket_len(), w.params().len());
assert_eq!(w.grads_sync(0).unwrap().len(), w.bucket_len() + 1);
}
}