Skip to main content

rlx_web/
lib.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! WebAssembly entry point for RLX.
17//!
18//! Compiles to `wasm32-unknown-unknown` and exposes a small JS-callable
19//! surface via `wasm-bindgen`. Both **forward** (inference) and **backward**
20//! (gradients via [`rlx_autodiff`]) passes are available.
21//!
22//! ## Compute paths
23//!
24//! - **CPU backend (default, works today):** [`mlp_forward`], [`mlp_loss`],
25//!   [`mlp_grads`], [`mlp_train_step`] build an IR graph and run it through
26//!   [`rlx_runtime::Session`] on the CPU backend. Fully synchronous — runs on
27//!   the browser main thread (`rlx-cpu` is single-threaded on wasm).
28//!
29//! - **WebGPU (`webgpu` feature):** async forward/backward via
30//!   `rlx_wgpu::WgpuExecutable::run_async` (see [`webgpu`]); GPU→CPU readback
31//!   cannot block the browser event loop, so those entry points are `async`.
32//!
33//! The forward and backward graphs are identical across backends — only the
34//! executor differs. The backward graph comes from
35//! [`rlx_autodiff::grad_with_loss`]; its outputs are `[loss, ∂loss/∂p…]` and
36//! it is seeded with a `d_output` input (the cotangent, `[1.0]` for a scalar
37//! loss).
38
39use rlx_autodiff::grad_with_loss;
40use rlx_ir::Graph;
41use rlx_runtime::{Device, Session};
42use wasm_bindgen::prelude::*;
43
44mod api;
45mod exec;
46mod mlp;
47mod transformer;
48mod vision;
49use mlp::MlpDims;
50use transformer::TfConfig;
51
52#[cfg(all(feature = "webgpu", target_arch = "wasm32"))]
53pub mod webgpu;
54
55#[cfg(all(feature = "webgl", target_arch = "wasm32"))]
56pub mod webgl;
57
58/// Module entry point — installs a panic hook that routes Rust panics to the
59/// browser console. Runs automatically when the wasm module is instantiated.
60#[wasm_bindgen(start)]
61pub fn start() {
62    #[cfg(target_arch = "wasm32")]
63    install_panic_hook();
64}
65
66#[cfg(target_arch = "wasm32")]
67fn install_panic_hook() {
68    use std::sync::Once;
69    static HOOK: Once = Once::new();
70    HOOK.call_once(|| {
71        std::panic::set_hook(Box::new(|info| {
72            web_sys::console::error_1(&JsValue::from_str(&format!("rlx-web panic: {info}")));
73        }));
74    });
75}
76
77/// Label of the compute backend used by the synchronous entry points.
78#[wasm_bindgen]
79pub fn backend() -> String {
80    "cpu".to_string()
81}
82
83// ── Real transformer (decoder-only) ─────────────────────────────────────
84
85/// Run a real decoder-only transformer (RMSNorm + RoPE + causal multi-head
86/// attention + SwiGLU) on the CPU backend with deterministic synthesized
87/// weights, and return the **next-token logits** (last position), length
88/// `vocab`. `tokens` are token ids (as f32); `seq` = `tokens.len()`.
89/// `dim` must equal `n_heads * head_dim`.
90#[wasm_bindgen]
91#[allow(clippy::too_many_arguments)]
92pub fn transformer_next_logits(
93    tokens: &[f32],
94    vocab: usize,
95    dim: usize,
96    n_layers: usize,
97    n_heads: usize,
98    head_dim: usize,
99    ffn: usize,
100    seed: u32,
101) -> Vec<f32> {
102    let cfg = TfConfig {
103        vocab,
104        dim,
105        n_layers,
106        n_heads,
107        head_dim,
108        ffn,
109        seq: tokens.len().max(1),
110        eps: 1e-5,
111        theta: 10000.0,
112    };
113    let all = transformer::transformer_logits(&cfg, tokens, seed as u64);
114    // Last position's row = next-token distribution.
115    let start = (cfg.seq - 1) * vocab;
116    all.get(start..start + vocab)
117        .map(|s| s.to_vec())
118        .unwrap_or_default()
119}
120
121// ── Forward (inference) ─────────────────────────────────────────────────
122
123/// Run a two-layer MLP — `relu(x·W1 + b1)·W2 + b2` — on the CPU backend and
124/// return the output row.
125///
126/// Shapes (row-major, batch size 1): `x`=`in_dim`, `w1`=`in_dim×hidden`,
127/// `b1`=`hidden`, `w2`=`hidden×out_dim`, `b2`=`out_dim`.
128#[wasm_bindgen]
129pub fn mlp_forward(
130    x: &[f32],
131    in_dim: usize,
132    hidden: usize,
133    out_dim: usize,
134    w1: &[f32],
135    b1: &[f32],
136    w2: &[f32],
137    b2: &[f32],
138) -> Vec<f32> {
139    let dims = MlpDims {
140        in_dim,
141        hidden,
142        out_dim,
143    };
144    let (g, _params) = mlp::build_forward(dims);
145
146    let mut compiled = Session::new(Device::Cpu).compile(g);
147    set_params(&mut compiled, w1, b1, w2, b2);
148    compiled
149        .run(&[("x", x)])
150        .into_iter()
151        .next()
152        .unwrap_or_default()
153}
154
155/// Mean-of-squares loss `Σ (y − target)²` for the MLP, on the CPU backend.
156/// (Used for inference + as the scalar the backward pass differentiates.)
157#[wasm_bindgen]
158pub fn mlp_loss(
159    x: &[f32],
160    target: &[f32],
161    in_dim: usize,
162    hidden: usize,
163    out_dim: usize,
164    w1: &[f32],
165    b1: &[f32],
166    w2: &[f32],
167    b2: &[f32],
168) -> f32 {
169    let dims = MlpDims {
170        in_dim,
171        hidden,
172        out_dim,
173    };
174    let (g, _params) = mlp::build_loss(dims);
175
176    let mut compiled = Session::new(Device::Cpu).compile(g);
177    set_params(&mut compiled, w1, b1, w2, b2);
178    let outs = compiled.run(&[("x", x), ("target", target)]);
179    outs.first().and_then(|o| o.first().copied()).unwrap_or(0.0)
180}
181
182// ── Backward (gradients) ────────────────────────────────────────────────
183
184/// Forward + backward in one call on the CPU backend. Returns a flat vector:
185/// `[loss, ∂/∂w1 …, ∂/∂b1 …, ∂/∂w2 …, ∂/∂b2 …]` (gradients in row-major
186/// parameter order, same lengths as the inputs).
187#[wasm_bindgen]
188pub fn mlp_grads(
189    x: &[f32],
190    target: &[f32],
191    in_dim: usize,
192    hidden: usize,
193    out_dim: usize,
194    w1: &[f32],
195    b1: &[f32],
196    w2: &[f32],
197    b2: &[f32],
198) -> Vec<f32> {
199    let dims = MlpDims {
200        in_dim,
201        hidden,
202        out_dim,
203    };
204    let g = grads_graph(dims);
205
206    let mut compiled = Session::new(Device::Cpu).compile(g);
207    set_params(&mut compiled, w1, b1, w2, b2);
208    let outs = compiled.run(&[("x", x), ("target", target), ("d_output", &[1.0])]);
209
210    // outs[0] = loss; outs[1..5] = grads for [w1, b1, w2, b2].
211    let mut flat = Vec::new();
212    for o in &outs {
213        flat.extend_from_slice(o);
214    }
215    flat
216}
217
218/// One SGD step: compute gradients of the loss and return the updated
219/// parameters as `[w1 …, b1 …, w2 …, b2 …]` (same layout as the inputs).
220#[wasm_bindgen]
221#[allow(clippy::too_many_arguments)]
222pub fn mlp_train_step(
223    x: &[f32],
224    target: &[f32],
225    in_dim: usize,
226    hidden: usize,
227    out_dim: usize,
228    w1: &[f32],
229    b1: &[f32],
230    w2: &[f32],
231    b2: &[f32],
232    lr: f32,
233) -> Vec<f32> {
234    let dims = MlpDims {
235        in_dim,
236        hidden,
237        out_dim,
238    };
239    let g = grads_graph(dims);
240
241    let mut compiled = Session::new(Device::Cpu).compile(g);
242    set_params(&mut compiled, w1, b1, w2, b2);
243    let outs = compiled.run(&[("x", x), ("target", target), ("d_output", &[1.0])]);
244
245    // outs[1..5] = grads for [w1, b1, w2, b2]; apply p -= lr * grad.
246    let mut updated = Vec::new();
247    for (param, grad) in [w1, b1, w2, b2].iter().zip(&outs[1..]) {
248        updated.extend(param.iter().zip(grad.iter()).map(|(p, gd)| p - lr * gd));
249    }
250    updated
251}
252
253// ── Shared helpers ──────────────────────────────────────────────────────
254
255/// Build the autodiff backward graph for the MLP MSE loss w.r.t. all four
256/// parameters. Outputs: `[loss, ∂/∂w1, ∂/∂b1, ∂/∂w2, ∂/∂b2]`.
257fn grads_graph(dims: MlpDims) -> Graph {
258    let (fwd, params) = mlp::build_loss(dims);
259    grad_with_loss(&fwd, &params)
260}
261
262fn set_params(
263    compiled: &mut rlx_runtime::CompiledGraph,
264    w1: &[f32],
265    b1: &[f32],
266    w2: &[f32],
267    b2: &[f32],
268) {
269    compiled.set_param("w1", w1);
270    compiled.set_param("b1", b1);
271    compiled.set_param("w2", w2);
272    compiled.set_param("b2", b2);
273}
274
275/// Bring up a WebGPU device in the browser (async). Returns `true` when an
276/// adapter + device were obtained. See [`webgpu`] for the GPU forward/backward
277/// entry points.
278#[cfg(all(feature = "webgpu", target_arch = "wasm32"))]
279#[wasm_bindgen]
280pub async fn init_webgpu() -> bool {
281    rlx_runtime::init_webgpu().await
282}
283
284pub use api::{
285    VisionBench, VisionModelInfo, list_vision_models, parse_backend, preferred_backend,
286    vision_model_info,
287};
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn approx(a: f32, b: f32, tol: f32) -> bool {
294        (a - b).abs() <= tol * (1.0 + a.abs().max(b.abs()))
295    }
296
297    #[test]
298    fn mlp_forward_runs_on_cpu() {
299        // x·I = x; relu([1, -2]) = [1, 0]; [1, 0]·[1; 1] = 1.
300        let out = mlp_forward(
301            &[1.0, -2.0],
302            2,
303            2,
304            1,
305            &[1.0, 0.0, 0.0, 1.0],
306            &[0.0, 0.0],
307            &[1.0, 1.0],
308            &[0.0],
309        );
310        assert_eq!(out.len(), 1);
311        assert!((out[0] - 1.0).abs() < 1e-5, "got {out:?}");
312    }
313
314    #[test]
315    fn mlp_grads_match_finite_differences() {
316        let (in_dim, hidden, out_dim) = (2usize, 3usize, 2usize);
317        let x = vec![0.5_f32, -0.3];
318        let target = vec![0.2_f32, 0.7];
319        // Deterministic non-degenerate weights (some pre-activations > 0 so
320        // ReLU has live gradients).
321        let w1: Vec<f32> = (0..in_dim * hidden)
322            .map(|i| 0.1 * (i as f32 + 1.0) - 0.2)
323            .collect();
324        let b1 = vec![0.05_f32, -0.1, 0.2];
325        let w2: Vec<f32> = (0..hidden * out_dim)
326            .map(|i| 0.15 * (i as f32) - 0.1)
327            .collect();
328        let b2 = vec![0.0_f32, 0.1];
329
330        let g = mlp_grads(&x, &target, in_dim, hidden, out_dim, &w1, &b1, &w2, &b2);
331        // [loss, w1(6), b1(3), w2(6), b2(2)]
332        assert_eq!(g.len(), 1 + 6 + 3 + 6 + 2);
333        let analytic = &g[1..];
334
335        // Finite-difference every parameter element and compare.
336        let mut params = [w1.clone(), b1.clone(), w2.clone(), b2.clone()];
337        let eps = 1e-3_f32;
338        let mut idx = 0;
339        for p in 0..4 {
340            for e in 0..params[p].len() {
341                let orig = params[p][e];
342                params[p][e] = orig + eps;
343                let lp = mlp_loss(
344                    &x, &target, in_dim, hidden, out_dim, &params[0], &params[1], &params[2],
345                    &params[3],
346                );
347                params[p][e] = orig - eps;
348                let lm = mlp_loss(
349                    &x, &target, in_dim, hidden, out_dim, &params[0], &params[1], &params[2],
350                    &params[3],
351                );
352                params[p][e] = orig;
353
354                let fd = (lp - lm) / (2.0 * eps);
355                assert!(
356                    approx(analytic[idx], fd, 2e-2),
357                    "param {p}[{e}]: analytic={} fd={}",
358                    analytic[idx],
359                    fd
360                );
361                idx += 1;
362            }
363        }
364    }
365
366    #[test]
367    fn train_step_decreases_loss() {
368        let (in_dim, hidden, out_dim) = (3usize, 4usize, 2usize);
369        let x = vec![0.4_f32, -0.2, 0.9];
370        let target = vec![1.0_f32, -0.5];
371        let mut w1: Vec<f32> = (0..in_dim * hidden)
372            .map(|i| 0.1 * (i as f32 % 5.0) - 0.2)
373            .collect();
374        let mut b1 = vec![0.0_f32; hidden];
375        let mut w2: Vec<f32> = (0..hidden * out_dim)
376            .map(|i| 0.05 * (i as f32) - 0.1)
377            .collect();
378        let mut b2 = vec![0.0_f32; out_dim];
379
380        let l0 = mlp_loss(&x, &target, in_dim, hidden, out_dim, &w1, &b1, &w2, &b2);
381        for _ in 0..50 {
382            let up = mlp_train_step(
383                &x, &target, in_dim, hidden, out_dim, &w1, &b1, &w2, &b2, 0.05,
384            );
385            let (a, b) = up.split_at(in_dim * hidden);
386            w1 = a.to_vec();
387            let (a, b) = b.split_at(hidden);
388            b1 = a.to_vec();
389            let (a, b) = b.split_at(hidden * out_dim);
390            w2 = a.to_vec();
391            b2 = b.to_vec();
392        }
393        let l1 = mlp_loss(&x, &target, in_dim, hidden, out_dim, &w1, &b1, &w2, &b2);
394        assert!(l1 < l0 * 0.5, "loss did not drop enough: {l0} -> {l1}");
395    }
396}