Skip to main content

rlx_web/
api.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna. GPL-3.0-only.
3
4//! Developer-facing wasm API for vision benchmarks and browser runtime.
5
6use wasm_bindgen::prelude::*;
7
8use crate::exec;
9use crate::vision::{self, ModelMeta, VisionModelId};
10
11fn grad_slices(outs: &[Vec<f32>]) -> Vec<&[f32]> {
12    outs.iter().skip(1).map(|v| v.as_slice()).collect()
13}
14
15/// List vision model slugs available in the browser bundle.
16#[wasm_bindgen]
17pub fn list_vision_models() -> Vec<String> {
18    VisionModelId::all()
19        .iter()
20        .map(|id| id.slug().to_string())
21        .collect()
22}
23
24/// Metadata for a vision model (shapes, param layout, WebGL train support).
25#[wasm_bindgen]
26pub struct VisionModelInfo {
27    slug: String,
28    title: String,
29    input_dims: Vec<usize>,
30    num_classes: usize,
31    param_names: Vec<String>,
32    param_sizes: Vec<usize>,
33    webgl_train: bool,
34}
35
36#[wasm_bindgen]
37impl VisionModelInfo {
38    #[wasm_bindgen(getter)]
39    pub fn slug(&self) -> String {
40        self.slug.clone()
41    }
42
43    #[wasm_bindgen(getter)]
44    pub fn title(&self) -> String {
45        self.title.clone()
46    }
47
48    #[wasm_bindgen(getter)]
49    pub fn input_dims(&self) -> Vec<usize> {
50        self.input_dims.clone()
51    }
52
53    #[wasm_bindgen(getter)]
54    pub fn num_classes(&self) -> usize {
55        self.num_classes
56    }
57
58    #[wasm_bindgen(getter)]
59    pub fn param_names(&self) -> Vec<String> {
60        self.param_names.clone()
61    }
62
63    #[wasm_bindgen(getter)]
64    pub fn param_sizes(&self) -> Vec<usize> {
65        self.param_sizes.clone()
66    }
67
68    #[wasm_bindgen(getter)]
69    pub fn webgl_train(&self) -> bool {
70        self.webgl_train
71    }
72
73    #[wasm_bindgen(getter)]
74    pub fn input_len(&self) -> usize {
75        self.input_dims.iter().product()
76    }
77
78    #[wasm_bindgen(getter)]
79    pub fn param_flat_len(&self) -> usize {
80        self.param_sizes.iter().sum()
81    }
82}
83
84fn info_from_meta(meta: &ModelMeta) -> VisionModelInfo {
85    VisionModelInfo {
86        slug: meta.id.slug().to_string(),
87        title: meta.id.title().to_string(),
88        input_dims: meta.input_shape.to_vec(),
89        num_classes: meta.num_classes,
90        param_names: meta.param_names().into_iter().map(str::to_string).collect(),
91        param_sizes: meta.param_sizes(),
92        webgl_train: meta.id.webgl_train(),
93    }
94}
95
96/// Look up model metadata by slug (`mnist-cnn`, `mnist-mlp`, `cifar-cnn`, `resnet`).
97#[wasm_bindgen]
98pub fn vision_model_info(slug: &str) -> Result<VisionModelInfo, JsValue> {
99    let id = VisionModelId::from_slug(slug).ok_or_else(|| {
100        JsValue::from_str(&format!(
101            "unknown vision model {slug:?}; use list_vision_models()"
102        ))
103    })?;
104    Ok(info_from_meta(&vision::model_meta(id)))
105}
106
107/// Vision benchmark handle — forward, backward, and SGD on CPU / WebGPU / WebGL.
108#[wasm_bindgen]
109pub struct VisionBench {
110    id: VisionModelId,
111    meta: ModelMeta,
112}
113
114#[wasm_bindgen]
115impl VisionBench {
116    #[wasm_bindgen(constructor)]
117    pub fn new(slug: &str) -> Result<VisionBench, JsValue> {
118        let id = VisionModelId::from_slug(slug)
119            .ok_or_else(|| JsValue::from_str(&format!("unknown vision model {slug:?}")))?;
120        let meta = vision::model_meta(id);
121        Ok(Self { id, meta })
122    }
123
124    pub fn info(&self) -> VisionModelInfo {
125        info_from_meta(&self.meta)
126    }
127
128    /// Deterministic Kaiming-style init; biases are zero.
129    pub fn init_params(&self, seed: u32) -> Vec<f32> {
130        vision::init_params(&self.meta, seed)
131    }
132
133    /// Synthetic normalized input + integer class label.
134    pub fn synthetic_batch(&self, seed: u32) -> Vec<f32> {
135        let (x, label) = vision::synthetic_input(&self.meta, seed);
136        let mut out = x;
137        out.push(label);
138        out
139    }
140
141    /// Forward pass on CPU — returns logits (`num_classes` floats).
142    pub fn forward_cpu(&self, x: &[f32], params: &[f32]) -> Result<Vec<f32>, JsValue> {
143        exec::run_forward_cpu(self.id, &self.meta, x, params).map_err(|e| JsValue::from_str(&e))
144    }
145
146    /// One training step on CPU. Returns `[loss, updated_params_flat…]`.
147    pub fn train_step_cpu(
148        &self,
149        x: &[f32],
150        label: f32,
151        params: &[f32],
152        lr: f32,
153    ) -> Result<Vec<f32>, JsValue> {
154        let (loss, outs) = exec::run_train_cpu(self.id, &self.meta, x, label, params)
155            .map_err(|e| JsValue::from_str(&e))?;
156        let grads = grad_slices(&outs);
157        let updated = vision::train_step_params(&self.meta, params, &grads, lr);
158        let mut flat = vec![loss];
159        flat.extend(updated);
160        Ok(flat)
161    }
162
163    /// Run `steps` SGD iterations on CPU with synthetic data. Returns
164    /// `[initial_loss, final_loss, steps_per_sec]`.
165    pub fn bench_cpu(&self, steps: u32, seed: u32, lr: f32) -> Result<Vec<f32>, JsValue> {
166        let mut params = vision::init_params(&self.meta, seed);
167        let (x, label) = vision::synthetic_input(&self.meta, seed);
168        let l0 = exec::run_train_cpu(self.id, &self.meta, &x, label, &params)
169            .map_err(|e| JsValue::from_str(&e))?
170            .0;
171
172        let t0 = bench_now();
173        for step in 0..steps {
174            let (loss, outs) = exec::run_train_cpu(self.id, &self.meta, &x, label, &params)
175                .map_err(|e| JsValue::from_str(&e))?;
176            let grads = grad_slices(&outs);
177            params = vision::train_step_params(&self.meta, &params, &grads, lr);
178            let _ = (step, loss);
179        }
180        let elapsed = bench_now() - t0;
181        let l1 = exec::run_train_cpu(self.id, &self.meta, &x, label, &params)
182            .map_err(|e| JsValue::from_str(&e))?
183            .0;
184        let sps = if elapsed > 0.0 {
185            steps as f32 / elapsed
186        } else {
187            0.0
188        };
189        Ok(vec![l0, l1, sps])
190    }
191
192    #[cfg(all(feature = "webgpu", target_arch = "wasm32"))]
193    pub async fn forward_webgpu(&self, x: Vec<f32>, params: Vec<f32>) -> Result<Vec<f32>, JsValue> {
194        exec::run_forward_webgpu(self.id, &self.meta, &x, &params)
195            .await
196            .map_err(|e| JsValue::from_str(&e))
197    }
198
199    #[cfg(all(feature = "webgpu", target_arch = "wasm32"))]
200    pub async fn train_step_webgpu(
201        &self,
202        x: Vec<f32>,
203        label: f32,
204        params: Vec<f32>,
205        lr: f32,
206    ) -> Result<Vec<f32>, JsValue> {
207        let (loss, outs) = exec::run_train_webgpu(self.id, &self.meta, &x, label, &params)
208            .await
209            .map_err(|e| JsValue::from_str(&e))?;
210        let grads = grad_slices(&outs);
211        let updated = vision::train_step_params(&self.meta, &params, &grads, lr);
212        let mut flat = vec![loss];
213        flat.extend(updated);
214        Ok(flat)
215    }
216
217    #[cfg(all(feature = "webgl", target_arch = "wasm32"))]
218    pub fn forward_webgl(&self, x: &[f32], params: &[f32]) -> Result<Vec<f32>, JsValue> {
219        exec::run_forward_webgl(self.id, &self.meta, x, params).map_err(|e| JsValue::from_str(&e))
220    }
221
222    #[cfg(all(feature = "webgl", target_arch = "wasm32"))]
223    pub fn train_step_webgl(
224        &self,
225        x: &[f32],
226        label: f32,
227        params: &[f32],
228        lr: f32,
229    ) -> Result<Vec<f32>, JsValue> {
230        let (loss, outs) = exec::run_train_webgl(self.id, &self.meta, x, label, params)
231            .map_err(|e| JsValue::from_str(&e))?;
232        let grads = grad_slices(&outs);
233        let updated = vision::train_step_params(&self.meta, params, &grads, lr);
234        let mut flat = vec![loss];
235        flat.extend(updated);
236        Ok(flat)
237    }
238}
239
240/// Parse backend name: `auto`, `cpu`, `webgpu`, `webgl`.
241#[wasm_bindgen]
242pub fn parse_backend(name: &str) -> String {
243    exec::ExecBackend::parse(name).label().to_string()
244}
245
246/// Highest-priority available browser backend label (`cpu`, `webgpu`, `webgl`, or `none`).
247#[wasm_bindgen]
248pub fn preferred_backend() -> String {
249    rlx_runtime::preferred_browser_device()
250        .map(|d| match d {
251            rlx_runtime::Device::WebGpu => "webgpu".to_string(),
252            rlx_runtime::Device::OpenGl => "webgl".to_string(),
253            rlx_runtime::Device::Cpu => "cpu".to_string(),
254            other => format!("{other:?}"),
255        })
256        .unwrap_or_else(|| "none".to_string())
257}
258
259fn bench_now() -> f32 {
260    #[cfg(target_arch = "wasm32")]
261    {
262        web_sys::window()
263            .and_then(|w| w.performance())
264            .map(|p| p.now() as f32 / 1000.0)
265            .unwrap_or(0.0)
266    }
267    #[cfg(not(target_arch = "wasm32"))]
268    {
269        use std::time::Instant;
270        static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
271        let start = START.get_or_init(Instant::now);
272        start.elapsed().as_secs_f32()
273    }
274}
275
276#[cfg(test)]
277mod api_tests {
278    use crate::{VisionBench, list_vision_models};
279
280    #[test]
281    fn lists_four_models() {
282        assert_eq!(list_vision_models().len(), 4);
283    }
284
285    #[test]
286    fn bench_cpu_runs() {
287        let bench = VisionBench::new("mnist-mlp").unwrap();
288        let out = bench.bench_cpu(5, 7, 0.01).unwrap();
289        assert_eq!(out.len(), 3);
290    }
291
292    #[test]
293    fn all_slugs_bench_decreases_loss() {
294        for slug in list_vision_models() {
295            let bench = VisionBench::new(&slug).unwrap();
296            let out = bench.bench_cpu(15, 11, 0.05).unwrap();
297            let (l0, l1, _sps) = (out[0], out[1], out[2]);
298            assert!(
299                l0.is_finite() && l1.is_finite(),
300                "{slug}: non-finite loss {l0} -> {l1}"
301            );
302            assert!(
303                l1 <= l0 + 1e-3,
304                "{slug}: expected loss not to rise: {l0} -> {l1}"
305            );
306        }
307    }
308}