Most stacks treat the browser as an export target: you train with PyTorch or JAX in Python, then convert the model for a separate runtime — transformers.js, ONNX Runtime Web, WebLLM — which can only run it forward. TensorFlow.js does train in a tab, but it is its own JavaScript implementation rather than the compiler that serves your GPUs. RLX is one compiler with one IR: the graph on this page is the same graph, through the same fusion passes and cost models, that targets Metal, CUDA, ROCm and Vulkan natively — 21 backend crates, one description of the model. The browser is a backend, not a conversion step.
Two consequences are visible right here. Autodiff lives on that same
IR, so this page computes gradients and trains rather than
just predicting — the code examples below build a graph and let autodiff
rewrite it into a backward graph, identically in Rust, JavaScript and
Python. And the distributed runtime is in the same tree, so the
cohort you are part of is not demo scaffolding: it is the
reduce contract a native multi-GPU job uses, with a browser
transport plugged in where TCP normally goes. It is written in Rust with
no Python at runtime, which is why the whole thing fits in a wasm bundle.
What it is not is as mature as those ecosystems — fewer models, fewer
pretrained weights, a much smaller community.
Each tab runs an RLX rank in a Web Worker: it compiles the
backward graph once on its own backend — WebGPU, WebGL2 or CPU —
computes gradients over its own shard of the dataset, and the
cohort averages them. This page is a static site: tabs in one
browser find each other over a BroadcastChannel, and
other devices join a WebRTC mesh — signalling goes over
public relays, but every gradient bucket travels peer-to-peer, so no
server of ours is involved at runtime. Gradients go
worker → worker and never touch a UI thread. Open another tab
and watch samples/s rise.
The page autostarts, so changing model, backend or batch and pressing Start reloads into that configuration — a rank is built once and holds compiled-graph state. The learning rate is live, and the leader's value is the one every rank applies. Closing the leader's tab triggers a re-election — any synced rank can take over, because every rank holds identical parameters.
Wire precision trades bandwidth for accuracy. A round
ships the whole parameter vector, so the format is the
bandwidth. Measured on a 2,048-element bucket: f16 is 1.96×
smaller for 4.6e-4 relative error, and both f8 formats are
3.85× smaller — e4m3 at 5.9% error, e5m2 at 11%. e4m3 wins here because
the per-message dynamic scale already handles range, so e5m2's extra
exponent bit buys nothing and costs a mantissa bit. The format id and its
scale travel in each message, so ranks set differently still interoperate.
Batch size decides which backend wins. Measured on
this machine with mnist-mlp, samples/s: at batch 8 CPU does
10,300 and WebGPU 3,000 — per-dispatch cost and the async readback
dominate when the maths takes microseconds. At batch 64 they draw
(14,800 vs 12,700). At batch 256 WebGPU wins 3.5× (40,300 vs 11,400),
while the CPU falls off. Hence the default of 64; raise it to see the GPU
pull ahead.
Backends: only mnist-mlp trains on a GPU
in the browser. Every conv model's backward pass lowers to host
steps (conv2d_backward_input_host,
conv2d_backward_weight_host — 3 of them for
mnist-cnn, 12 for resnet), and browser WebGPU
refuses any schedule it cannot run entirely on the device; WebGL2 has no
conv backward at all. Those ranks therefore resolve to CPU, and say so
under Notes. A rank always reports the backend it actually got,
never the one it asked for.
Adaptation: the step waits for a quorum of buckets rather than all of them once the cohort grows past three, and the heartbeat period stretches with peer count (mesh beat traffic grows as n²). Both are shown live above.
Other devices: press Open a room and share
the join URL (or the room code) with the second device. Bandwidth is the
limit in a mesh — the leader sends parameters to every peer each step, so
mnist-cnn (5,258 params, 21 KB a step) crosses a phone link
comfortably while mnist-mlp (101,770 params, 398 KB) is
chunked and will throttle itself.
The same graph, the same three steps — build, compile, run —
with autodiff turning the forward graph into a backward one. These are
the real APIs, not pseudocode: the Rust shape is
src/vision/mnist_mlp.rs, the JavaScript is what this page
bundles (pkg/rlx_web.d.ts), the Python mirrors
pyrlx/examples/dsl_quickstart.py.
import init, {
WebGraph, compile_forward, compile_backward,
} from "./pkg/rlx_web.js";
await init(); // load the wasm module
// ── describe the model ──
// 784 → 128 → 10 with softmax cross-entropy. Node ids are
// plain numbers; `param` marks a tensor as trainable.
const BATCH = 64;
const g = new WebGraph("mlp");
const x = g.input("x", [BATCH, 784]);
const labels = g.input("labels", [BATCH]); // class idx/row
const w1 = g.param("w1", [784, 128]);
const b1 = g.param("b1", [128]);
const w2 = g.param("w2", [128, 10]);
const b2 = g.param("b2", [10]);
let h = g.matmul(x, w1, [BATCH, 128]);
h = g.add(h, b1, [BATCH, 128]);
h = g.relu(h, [BATCH, 128]);
let logits = g.matmul(h, w2, [BATCH, 10]);
logits = g.add(logits, b2, [BATCH, 10]);
const perSample = g.softmax_cross_entropy(logits, labels);
g.set_outputs([g.mean(perSample, 0, [])]); // scalar loss
// ── call it ──
// "cpu" → "webgpu", "webgl" or "auto"; same graph.
const fwd = compile_forward(g, "cpu");
fwd.set_param("w1", W1); fwd.set_param("b1", B1);
fwd.set_param("w2", W2); fwd.set_param("b2", B2);
fwd.feed("x", xs);
fwd.feed("labels", ys);
const [loss] = fwd.run(); // output_sizes() is [1]
// ── train it ──
// Autodiff rewrites the graph: outputs become
// [loss, dW1, db1, dW2, db2], and a `d_output` seed input
// appears for ∂loss/∂loss.
const step = compile_backward(g, [w1, b1, w2, b2], "cpu");
const sizes = step.output_sizes();
const weights = [W1, B1, W2, B2];
const NAMES = ["w1", "b1", "w2", "b2"];
step.feed("x", xs);
step.feed("labels", ys);
step.feed("d_output", new Float32Array([1]));
for (let i = 0; i < 100; i++) {
NAMES.forEach((n, k) => step.set_param(n, weights[k]));
const out = step.run(); // [loss, grads…] flat
let at = sizes[0];
weights.forEach((w, k) => {
const n = sizes[k + 1];
for (let j = 0; j < n; j++) w[j] -= 0.05 * out[at + j];
at += sizes[k + 1];
});
if (i % 20 === 0) console.log(i, out[0]);
}
// On WebGPU readback cannot block the loop — await it:
// const out = await step.run_async();
use rlx_autodiff::grad_with_loss;
use rlx_ir::{op::*, DType, Graph, NodeId, Shape};
use rlx_optim::{Optimizer, Sgd};
use rlx_runtime::{Device, Session};
const BATCH: usize = 64;
/// 784 → 10 classifier with softmax cross-entropy. Returns
/// the graph and the parameters to differentiate wrt.
fn build_loss() -> (Graph, Vec<NodeId>) {
let f = DType::F32;
let mut g = Graph::new("mlp");
let x = g.input("x", Shape::new(&[BATCH, 784], f));
let lbl = g.input("labels", Shape::new(&[BATCH], f));
let w = g.param("w", Shape::new(&[784, 10], f));
let b = g.param("b", Shape::new(&[10], f));
let mm = g.matmul(x, w, Shape::new(&[BATCH, 10], f));
let shape = Shape::new(&[BATCH, 10], f);
let logits = g.binary(BinaryOp::Add, mm, b, shape);
let per = g.softmax_cross_entropy_with_logits(logits, lbl);
let loss = g.add_node(
Op::Reduce {
op: ReduceOp::Mean,
axes: vec![0],
keep_dim: false,
},
vec![per],
Shape::from_dims(&[], f),
);
g.set_outputs(vec![loss]);
(g, vec![w, b])
}
// ── call it ──
// `Device::Cpu` → `Metal`, `Cuda`, `WebGpu`, … same graph.
let (graph, params) = build_loss();
let mut fwd = Session::new(Device::Cpu).compile(graph.clone());
fwd.set_param("w", &w_data);
fwd.set_param("b", &b_data);
let feed = [("x", &x_data[..]), ("labels", &labels[..])];
let loss = fwd.run(&feed)[0][0];
// ── train it ──
// Autodiff rewrites the graph; outputs become [loss, dW, db].
let backward = grad_with_loss(&graph, ¶ms);
let mut step = Session::new(Device::Cpu).compile(backward);
let mut sgd = Sgd::new(0.05);
for _ in 0..100 {
step.set_param("w", &w_data);
step.set_param("b", &b_data);
let outs = step.run(&[
("x", &x_data),
("labels", &labels),
("d_output", &[1.0][..]), // seed ∂loss/∂loss = 1
]);
sgd.step("w", &[784, 10], &mut w_data, &outs[1]);
sgd.step("b", &[10], &mut b_data, &outs[2]);
}
pyrlximport numpy as np
import pyrlx as rlx
device = rlx.available_devices()[0] # "cpu", "metal", …
with rlx.graph("mlp") as g:
x = g.input("x", [64, 784], "f32")
target = g.input("target", [64, 10], "f32")
w = g.param("w", [784, 10], "f32")
b = g.param("b", [10], "f32")
y = (x @ w + b).relu()
loss = ((y - target) ** 2).mean([0, 1]) # MSE
g.outputs = [loss]
forward, w_id, b_id = g.raw, w.id, b.id
W = (np.random.randn(784, 10) * 0.02).astype(np.float32)
B = np.zeros(10, dtype=np.float32)
xs = np.random.randn(64, 784).astype(np.float32)
ts = np.zeros((64, 10), dtype=np.float32)
# ── call it ──
compiled = rlx.Session(device).compile(forward)
rlx.set_param(compiled, "w", W)
rlx.set_param(compiled, "b", B)
(loss,) = rlx.run(compiled, x=xs, target=ts)
print("loss:", float(loss))
# ── train it ──
# `grad` returns a graph whose outputs are [loss, dW, db].
with rlx.graph("mlp") as g2: # `g.raw` consumed g
x = g2.input("x", [64, 784], "f32")
target = g2.input("target", [64, 10], "f32")
w = g2.param("w", [784, 10], "f32")
b = g2.param("b", [10], "f32")
y = (x @ w + b).relu()
g2.outputs = [((y - target) ** 2).mean([0, 1])]
fwd2, ids = g2.raw, [w.id, b.id]
backward = rlx.grad(fwd2, ids)
step = rlx.Session(device).compile(backward)
seed = np.ones(1, dtype=np.float32) # ∂loss/∂loss
for i in range(100):
rlx.set_param(step, "w", W)
rlx.set_param(step, "b", B)
loss, dW, dB = rlx.run(step, x=xs, target=ts, d_output=seed)
W -= 0.05 * dW.reshape(W.shape)
B -= 0.05 * dB.reshape(B.shape)
if i % 20 == 0:
print(i, float(loss))