MIT-RLX
MIT-RLX/rlx · rlx-models
RLX: A Unified Multi-Backend Tensor Compiler and Distributed Runtime in Rust — arXiv:2609.37916
Eugene Hauptmann, Nataliya Kosmyna
repo
models
paper

RLX tab cluster — one rank per tab

What makes RLX different

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.

This demo

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.

Cluster—
Throughput—

This rank

Rank id—
Role—
Backend—
Statestarting…
Model—
Transport—
Room— (this browser only)
Join URL—
ICE preflight—
ICE per peer—
Peer ranks—
Notes—

Training

Step—
Loss (cohort mean)—
Step rate—
Gradient traffic—
Adaptation—

Controls

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.

Calling this model from Rust, JavaScript or Python

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.

JavaScript — in the browser, via wasm
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();
Rust — native, any backend
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, &params);
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]);
}
Python — via pyrlx
import 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))