Skip to main content

rlx_runtime/
compiled.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Compiled graph — the hot-path execution object.
6
7use crate::backend::ExecutableGraph;
8use rlx_driver::Device;
9use std::collections::HashSet;
10
11/// Param-invariant "prepare" stage attached to a compiled graph.
12///
13/// Holds a separately-compiled weight-only graph (`prepare`) whose outputs are
14/// the boundary tensors of the main graph. Run once (lazily, on the first
15/// forward / `finalize_params`), its outputs are bound into the main graph via
16/// [`CompiledGraph::bind_handle`] so weight-derived compute happens once instead
17/// of every forward. See [`rlx_compile::split_param_invariant`].
18struct Staging {
19    prepare: Box<CompiledGraph>,
20    prepared: bool,
21    /// Boundary input names on the main graph, in `prepare`-output order.
22    boundary: Vec<String>,
23    prepare_params: HashSet<String>,
24    main_params: HashSet<String>,
25    /// True once the prepared outputs were injected via persistent `bind_handle`
26    /// (CPU). When false (backends without persistent handles, e.g. CUDA), the
27    /// prepared values are fed as ordinary inputs each forward from `values`.
28    bound: bool,
29    /// Prepared boundary values, kept only for the feed-each-forward fallback.
30    values: Vec<Vec<f32>>,
31}
32
33/// A compiled graph ready for execution.
34///
35/// Created by [`crate::Session::compile`]. Holds the fused + memory-planned
36/// graph and all pre-allocated execution state. Call
37/// [`CompiledGraph::run`] repeatedly with different inputs — zero
38/// allocation per call.
39pub struct CompiledGraph {
40    inner: Box<dyn ExecutableGraph>,
41    device: Device,
42    /// Optional param-invariant prepare stage (None = ordinary graph).
43    staging: Option<Box<Staging>>,
44}
45
46impl Clone for CompiledGraph {
47    /// Deep-clones the underlying executable via `ExecutableGraph::clone_box`.
48    /// Backends that don't support cloning will panic at this point.
49    fn clone(&self) -> Self {
50        Self {
51            inner: self.inner.clone_box(),
52            device: self.device,
53            staging: self.staging.as_ref().map(|s| {
54                Box::new(Staging {
55                    prepare: s.prepare.clone(),
56                    prepared: s.prepared,
57                    boundary: s.boundary.clone(),
58                    prepare_params: s.prepare_params.clone(),
59                    main_params: s.main_params.clone(),
60                    bound: s.bound,
61                    values: s.values.clone(),
62                })
63            }),
64        }
65    }
66}
67
68impl CompiledGraph {
69    pub(crate) fn new(inner: Box<dyn ExecutableGraph>, device: Device) -> Self {
70        Self {
71            inner,
72            device,
73            staging: None,
74        }
75    }
76
77    /// Attach a param-invariant `prepare` stage to a compiled MAIN graph.
78    /// `prepare` and `boundary` come from [`rlx_compile::split_param_invariant`].
79    pub(crate) fn with_staging(
80        mut self,
81        prepare: CompiledGraph,
82        boundary: Vec<String>,
83        prepare_params: HashSet<String>,
84        main_params: HashSet<String>,
85    ) -> Self {
86        self.staging = Some(Box::new(Staging {
87            prepare: Box::new(prepare),
88            prepared: false,
89            boundary,
90            prepare_params,
91            main_params,
92            bound: false,
93            values: Vec::new(),
94        }));
95        self
96    }
97
98    /// Run the `prepare` stage once (lazily) and make its outputs available to
99    /// the main graph — via persistent `bind_handle` when supported (CPU), else
100    /// kept for feed-each-forward (CUDA). No-op after the first call.
101    fn ensure_prepared(&mut self) {
102        let Some(st) = self.staging.as_mut() else {
103            return;
104        };
105        if st.prepared {
106            return;
107        }
108        st.prepare.finalize_params();
109        let outs = st.prepare.run(&[]);
110        // Try persistent binding; it succeeds only when every boundary binds.
111        let mut all_bound = !outs.is_empty();
112        for (name, out) in st.boundary.iter().zip(outs.iter()) {
113            if !self.inner.bind_handle(name, out) {
114                all_bound = false;
115            }
116        }
117        st.bound = all_bound;
118        st.values = if all_bound { Vec::new() } else { outs };
119        st.prepared = true;
120    }
121
122    /// Which device this graph runs on.
123    pub fn device(&self) -> Device {
124        self.device
125    }
126
127    /// Set a named parameter (model weight).
128    /// Call once per parameter after compilation.
129    pub fn set_param(&mut self, name: &str, data: &[f32]) {
130        if let Some(st) = self.staging.as_mut() {
131            if st.prepare_params.contains(name) {
132                st.prepare.set_param(name, data);
133                st.prepared = false; // weight changed → re-run prepare next forward
134            }
135            if st.main_params.contains(name) {
136                self.inner.set_param(name, data);
137            }
138        } else {
139            self.inner.set_param(name, data);
140        }
141    }
142
143    /// Execute the graph with named inputs.
144    /// Returns one `Vec<f32>` per graph output (copies from arena).
145    pub fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
146        self.ensure_prepared();
147        match self.staging.as_ref() {
148            Some(st) if !st.bound => {
149                let mut merged: Vec<(&str, &[f32])> =
150                    Vec::with_capacity(inputs.len() + st.boundary.len());
151                merged.extend_from_slice(inputs);
152                for (name, vals) in st.boundary.iter().zip(st.values.iter()) {
153                    merged.push((name.as_str(), vals.as_slice()));
154                }
155                self.inner.run(&merged)
156            }
157            _ => self.inner.run(inputs),
158        }
159    }
160
161    /// Async WebGPU execution on wasm (non-blocking GPU→CPU readback).
162    /// WebGL and CPU backends fall back to synchronous [`Self::run`].
163    #[cfg(target_arch = "wasm32")]
164    pub async fn run_async(&mut self, inputs: &[(&str, &[f32])]) -> Result<Vec<Vec<f32>>, String> {
165        match self.device {
166            Device::WebGpu | Device::Gpu => self.inner.wgpu_run_async(inputs).await,
167            _ => Ok(self.run(inputs)),
168        }
169    }
170
171    /// Run and read back only selected outputs (logits-only decode on MLX).
172    pub fn run_read_outputs(
173        &mut self,
174        inputs: &[(&str, &[f32])],
175        read_indices: Option<&[usize]>,
176    ) -> Vec<Vec<f32>> {
177        self.ensure_prepared();
178        match self.staging.as_ref() {
179            Some(st) if !st.bound => {
180                let mut merged: Vec<(&str, &[f32])> =
181                    Vec::with_capacity(inputs.len() + st.boundary.len());
182                merged.extend_from_slice(inputs);
183                for (name, vals) in st.boundary.iter().zip(st.values.iter()) {
184                    merged.push((name.as_str(), vals.as_slice()));
185                }
186                self.inner.run_read_outputs(&merged, read_indices)
187            }
188            _ => self.inner.run_read_outputs(inputs, read_indices),
189        }
190    }
191
192    /// Read one row from a row-major output tensor after a forward pass.
193    pub fn read_output_row(
194        &self,
195        out_idx: usize,
196        row: usize,
197        row_inner: usize,
198    ) -> Option<Vec<f32>> {
199        self.inner.read_output_row(out_idx, row, row_inner)
200    }
201
202    /// Execute and return raw pointers to output data (zero-copy).
203    /// Data is valid until the next `run`/`run_raw` call.
204    ///
205    /// # Safety
206    /// The returned pointers point into the arena. Do not use after
207    /// the next call to run/run_raw (arena data will be overwritten).
208    pub fn run_raw(&mut self, inputs: &[(&str, &[f32])]) -> Vec<(*const f32, usize)> {
209        self.ensure_prepared();
210        self.inner.run_raw(inputs)
211    }
212
213    /// Fastest execution: inputs by slot index (order matches graph input declaration).
214    /// Returns output (offset, len) pairs. Read data via `arena_ptr().add(offset)`.
215    /// Zero HashMap lookup, zero Vec allocation, zero name matching.
216    pub fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
217        self.ensure_prepared();
218        self.inner.run_slots(inputs)
219    }
220
221    /// Arena pointer for reading output data after `run_slots`.
222    pub fn arena_ptr(&self) -> *const u8 {
223        self.inner.arena_ptr()
224    }
225
226    /// Bind a persistent buffer (KV-cache, optimizer state, etc.).
227    /// Stays alive across `run()` calls; the backend uses it as the
228    /// graph input with the matching name.
229    /// Returns true if the backend supports persistent handles.
230    pub fn bind_handle(&mut self, name: &str, data: &[f32]) -> bool {
231        self.inner.bind_handle(name, data)
232    }
233
234    /// Read the current contents of a persistent buffer.
235    pub fn read_handle(&self, name: &str) -> Option<Vec<f32>> {
236        self.inner.read_handle(name)
237    }
238
239    /// What this executable can actually do.
240    ///
241    /// The ground truth, as opposed to [`crate::advisory_capabilities`], which
242    /// answers the same question from a device enum *before* anything is
243    /// compiled — useful for planning, but hand-maintained and therefore able to
244    /// drift. `rlx-runtime/tests/capability_table_matches_backends.rs` pins the
245    /// two together.
246    pub fn capabilities(&self) -> crate::ExecutableCapabilities {
247        self.inner.capabilities()
248    }
249
250    /// GPU-resident MLX input (no-op on non-MLX backends).
251    pub fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
252        self.inner.bind_gpu_handle(name, data)
253    }
254
255    /// ZERO-COPY in-place optimizer step over GPU-resident weights: the
256    /// optimizer reads gradients and writes params directly on the unified-memory
257    /// arena (no GPU→host→GPU roundtrip). `trainable[i] = (input_name, shape)`;
258    /// the gradient is read from output slot `1 + i` (backward outputs
259    /// `[loss, grad0, …]`). Pair with [`Self::bind_gpu_handle`] (weight resident)
260    /// and `run_read_outputs(_, Some(&[0]))` (read only the loss). Returns false
261    /// on backends without GPU-resident support — fall back to a host step.
262    ///
263    /// ```ignore
264    /// compiled.bind_gpu_handle("W", &w_init);
265    /// let mut muon = Muon::new(lr);
266    /// for _ in 0..steps {
267    ///     compiled.run_read_outputs(&[("d_output", &[1.0])], Some(&[0]));
268    ///     compiled.optimizer_step_resident(&trainable, &mut |n, s, p, g| muon.step(n, s, p, g));
269    /// }
270    /// ```
271    pub fn optimizer_step_resident(
272        &mut self,
273        trainable: &[(String, Vec<usize>)],
274        step: &mut dyn FnMut(&str, &[usize], &mut [f32], &[f32]),
275    ) -> bool {
276        self.inner.optimizer_step_resident(trainable, step)
277    }
278
279    pub fn has_gpu_handle(&self, name: &str) -> bool {
280        self.inner.has_gpu_handle(name)
281    }
282
283    pub fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
284        self.inner.set_gpu_handle_feed(handle_name, output_index)
285    }
286
287    pub fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
288        self.inner.read_gpu_handle(name)
289    }
290
291    /// Read one row from a resident GPU input handle without full-tensor D2H.
292    pub fn read_gpu_handle_row(
293        &self,
294        name: &str,
295        row: usize,
296        row_inner: usize,
297    ) -> Option<Vec<f32>> {
298        self.inner.read_gpu_handle_row(name, row, row_inner)
299    }
300
301    /// Register a targeted row feed for resident KV decode (graphs that emit the
302    /// new token at the last bucket-padded output row). No-op (false) on
303    /// backends without GPU-resident handle support. See [`Self::feed_kv_row`].
304    pub fn register_kv_row_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
305        self.inner.register_kv_row_feed(handle_name, output_index)
306    }
307
308    /// Fold each registered row feed's new-token row (`src_row` of its output)
309    /// into the resident handle slot at `dst_row` (`row_elems` = kv_dim),
310    /// in-place on device. Returns false when unsupported.
311    pub fn feed_kv_row(&mut self, src_row: usize, dst_row: usize, row_elems: usize) -> bool {
312        self.inner.feed_kv_row(src_row, dst_row, row_elems)
313    }
314
315    /// Batch-major resident KV feed (`past[b, dst_row]` ← `new[b, 0]`).
316    pub fn feed_kv_batch_major(
317        &mut self,
318        dst_row: usize,
319        batch: usize,
320        seq_cap: usize,
321        row_elems: usize,
322    ) -> bool {
323        self.inner
324            .feed_kv_batch_major(dst_row, batch, seq_cap, row_elems)
325    }
326
327    /// Strided-source batch-major resident KV feed: fold the new token from a
328    /// `[B, src_seq_cap, row_elems]` output at `src_row` into the resident input
329    /// handle `[B, dst_seq_cap, row_elems]` at `dst_row`. For decode graphs that
330    /// emit the whole appended cache (new token at output row `upper`).
331    #[allow(clippy::too_many_arguments)]
332    pub fn feed_kv_batch_major_src(
333        &mut self,
334        src_row: usize,
335        src_seq_cap: usize,
336        dst_row: usize,
337        batch: usize,
338        dst_seq_cap: usize,
339        row_elems: usize,
340    ) -> bool {
341        self.inner.feed_kv_batch_major_src(
342            src_row,
343            src_seq_cap,
344            dst_row,
345            batch,
346            dst_seq_cap,
347            row_elems,
348        )
349    }
350
351    /// Ragged resident KV feed: each batch element folds the new token from a
352    /// `[B, src_seq_cap, row_elems]` output at `src_row` into its own resident
353    /// handle row `dst_rows[b]` (the sequence's `past_len`). For fused decode of
354    /// sequences at MIXED cache lengths.
355    pub fn feed_kv_batch_major_ragged(
356        &mut self,
357        src_row: usize,
358        src_seq_cap: usize,
359        dst_rows: &[usize],
360        dst_seq_cap: usize,
361        row_elems: usize,
362    ) -> bool {
363        self.inner.feed_kv_batch_major_ragged(
364            src_row,
365            src_seq_cap,
366            dst_rows,
367            dst_seq_cap,
368            row_elems,
369        )
370    }
371
372    /// Mark a graph input as device-resident without host staging.
373    pub fn prepare_resident_gpu_handle(&mut self, name: &str) -> bool {
374        self.inner.prepare_resident_gpu_handle(name)
375    }
376
377    /// Upload bound (non-resident) GPU handle mirrors into the arena.
378    pub fn stage_bound_gpu_handles_to_arena(&mut self) -> bool {
379        self.inner.stage_bound_gpu_handles_to_arena();
380        true
381    }
382
383    /// D2D copy resident KV rows `[from_row..to_row)` from another compiled graph.
384    pub fn seed_resident_kv_prefix_from(
385        &mut self,
386        src: &CompiledGraph,
387        prefix_tokens: usize,
388        outgoing_upper: usize,
389        kv_dim: usize,
390        n_layers: usize,
391    ) -> bool {
392        if self.device != src.device {
393            return false;
394        }
395        self.inner.seed_resident_kv_prefix_from(
396            src.inner.as_ref(),
397            prefix_tokens,
398            outgoing_upper,
399            kv_dim,
400            n_layers,
401        )
402    }
403
404    /// D2D copy resident KV rows `[from_row..to_row)` from another compiled graph.
405    pub fn copy_resident_kv_rows_from(
406        &mut self,
407        src: &CompiledGraph,
408        from_row: usize,
409        to_row: usize,
410        outgoing_upper: usize,
411        kv_dim: usize,
412        n_layers: usize,
413    ) -> bool {
414        if self.device != src.device {
415            return false;
416        }
417        self.inner.copy_resident_kv_rows_from(
418            src.inner.as_ref(),
419            from_row,
420            to_row,
421            outgoing_upper,
422            kv_dim,
423            n_layers,
424        )
425    }
426
427    /// Copy parameter buffers from `src` when layouts match (same device/backend).
428    pub fn copy_params_from(&mut self, src: &CompiledGraph) -> bool {
429        if self.device != src.device {
430            return false;
431        }
432        self.inner.copy_params_from(src.inner.as_ref())
433    }
434
435    /// Share `src`'s packed weight buffer (retain, not copy) instead of uploading
436    /// our own, when the weight layout matches EXACTLY — one GPU copy backs both.
437    /// Returns false (caller must upload) on device mismatch or differing layout.
438    pub fn share_params_from(&mut self, src: &CompiledGraph) -> bool {
439        if self.device != src.device {
440            return false;
441        }
442        self.inner.share_params_from(src.inner.as_ref())
443    }
444
445    /// Run, refresh GPU handle from output, return that output vector.
446    pub fn run_feed_gpu_handle(
447        &mut self,
448        inputs: &[(&str, &[f32])],
449        handle_name: &str,
450        output_index: usize,
451    ) -> Option<Vec<f32>> {
452        self.inner
453            .run_feed_gpu_handle(inputs, handle_name, output_index)
454    }
455
456    /// Hint subsequent `run` calls to process only the first `actual`
457    /// rows along the bucket axis (out of `upper`, the compile extent).
458    /// Backends that support per-kernel active-extent dispatch honor
459    /// this; others ignore it. Pass `None` to clear.
460    ///
461    /// See `BucketedCompileCache::run_padded` for the canonical caller.
462    pub fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
463        #[cfg(feature = "cpu")]
464        if let Some((actual, _)) = extent {
465            crate::onnx_active::set_active_token_count(Some(actual))
466        }
467        self.inner.set_active_extent(extent);
468    }
469
470    /// Return this graph's idle scratch (activation) pages to the OS while it stays
471    /// cached — for multi-subgraph pipelines whose stages run sequentially. Weights
472    /// stay resident; the next run refaults + re-zeros scratch transparently. Call
473    /// only between stages, never inside a hot loop. Advisory no-op off CPU.
474    pub fn release_scratch(&mut self) {
475        self.inner.release_scratch();
476    }
477
478    /// TIDE merged MoE placement (`mask[expert]` device-resident if any layer has it).
479    pub fn set_moe_resident_experts(&mut self, mask: &[bool]) {
480        self.inner.set_moe_resident_experts(mask);
481    }
482
483    /// Per MoE layer placement (forward order). Preferred on CPU over merged mask.
484    pub fn set_moe_resident_experts_per_layer(&mut self, masks: &[&[bool]]) {
485        self.inner.set_moe_resident_experts_per_layer(masks);
486    }
487
488    /// Capture MoE router TopK on next forward (CPU). Returns false if unsupported.
489    pub fn enable_moe_topk_capture(&mut self, num_experts: usize) -> bool {
490        self.inner.enable_moe_topk_capture(num_experts)
491    }
492
493    /// Per-layer expert indices from the last forward (MoE router TopK order).
494    pub fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
495        self.inner.take_moe_topk_capture()
496    }
497
498    /// GroupedMatMul GPU/CPU token accounting from the last forward (CPU).
499    pub fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
500        self.inner.take_moe_residency_stats()
501    }
502
503    // ── Pipelined / async execution (Phase C) ─────────────────────────
504
505    /// Encode + commit a forward pass without waiting for the device.
506    ///
507    /// Outputs of intermediate calls are stomped — use `run_pipelined`
508    /// when you need each call's outputs back. Pair with `sync_pending`
509    /// to drain. CPU is synchronous, so this falls back to `run`.
510    pub fn commit_no_wait(&mut self, inputs: &[(&str, &[f32])]) {
511        self.inner.commit_no_wait(inputs);
512    }
513
514    /// Wait for every command queued by `commit_no_wait`. CPU is a no-op.
515    pub fn sync_pending(&mut self) {
516        self.inner.sync_pending();
517    }
518
519    /// Pipelined batch run. Issues one commit per input set, syncs once
520    /// at the end. On Metal, each commit gets its own output snapshot
521    /// (allocated + blit-copied), so subsequent commits stomping the
522    /// shared arena don't corrupt earlier runs' outputs.
523    /// Returns `out[run_idx][output_idx][element_idx]`.
524    pub fn run_pipelined(&mut self, input_sets: &[Vec<(&str, &[f32])>]) -> Vec<Vec<Vec<f32>>> {
525        self.inner.run_pipelined(input_sets)
526    }
527
528    /// Set a named parameter from raw bytes in the given dtype. The
529    /// backend handles the widen-to-f32 (or zero-widen, when supported
530    /// natively) on the way in. Lets callers feed F16/BF16 weights
531    /// without a host-side cast.
532    pub fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
533        if let Some(st) = self.staging.as_mut() {
534            if st.prepare_params.contains(name) {
535                st.prepare.set_param_typed(name, data, dtype);
536                st.prepared = false;
537            }
538            if st.main_params.contains(name) {
539                self.inner.set_param_typed(name, data, dtype);
540            }
541        } else {
542            self.inner.set_param_typed(name, data, dtype);
543        }
544    }
545
546    /// Incrementally write raw `data` into a named param starting `byte_offset`
547    /// bytes into its storage. Returns true if the backend did the partial write;
548    /// false → caller must fall back to a whole-buffer [`Self::set_param_typed`].
549    /// Used by paged-MoE residency to upload only the changed expert slot.
550    pub fn set_param_range(&mut self, name: &str, byte_offset: usize, data: &[u8]) -> bool {
551        if let Some(st) = self.staging.as_mut() {
552            let mut done = false;
553            if st.prepare_params.contains(name) {
554                done |= st.prepare.set_param_range(name, byte_offset, data);
555                st.prepared = false;
556            }
557            if st.main_params.contains(name) {
558                done |= self.inner.set_param_range(name, byte_offset, data);
559            }
560            done
561        } else {
562            self.inner.set_param_range(name, byte_offset, data)
563        }
564    }
565
566    /// Finish param upload — warms backend caches when supported.
567    pub fn finalize_params(&mut self) {
568        self.ensure_prepared();
569        self.inner.finalize_params();
570    }
571
572    /// Execute with typed inputs and return outputs in their declared
573    /// graph dtype, byte-encoded. Mirrors the wgpu / MLX zero-widen
574    /// semantics on f32-arena backends (CPU + Metal) by widening at
575    /// the boundary.
576    pub fn run_typed(
577        &mut self,
578        inputs: &[(&str, &[u8], rlx_ir::DType)],
579    ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
580        self.ensure_prepared();
581        self.inner.run_typed(inputs)
582    }
583
584    /// Override RNG policy for in-graph random ops without recompiling.
585    pub fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
586        self.inner.set_rng(rng);
587    }
588
589    /// Current RNG compile/execute policy.
590    pub fn rng(&self) -> rlx_ir::RngOptions {
591        self.inner.rng()
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use crate::*;
598
599    #[test]
600    #[cfg(feature = "cpu")]
601    fn end_to_end_session() {
602        let mut g = Graph::new("matmul_bias_gelu");
603        let x = g.input("x", Shape::new(&[2, 4], DType::F32));
604        let w = g.param("w", Shape::new(&[4, 3], DType::F32));
605        let b = g.param("b", Shape::new(&[3], DType::F32));
606        let mm = g.matmul(x, w, Shape::new(&[2, 3], DType::F32));
607        let add = g.binary(op::BinaryOp::Add, mm, b, Shape::new(&[2, 3], DType::F32));
608        let out = g.activation(op::Activation::Gelu, add, Shape::new(&[2, 3], DType::F32));
609        g.set_outputs(vec![out]);
610
611        // Compile
612        let session = Session::new(Device::Cpu);
613        let mut compiled = session.compile(g);
614
615        // Set weights
616        // w = identity-ish [4, 3]: first 3 rows are I, last row is 0
617        compiled.set_param(
618            "w",
619            &[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
620        );
621        compiled.set_param("b", &[0.5, -0.5, 0.0]);
622
623        // Run
624        let x_data = vec![
625            1.0, 0.0, 0.0, 0.0, // row 0: [1,0,0,0] @ w = [1,0,0] + bias = [1.5,-0.5,0]
626            0.0, 1.0, 0.0, 0.0, // row 1: [0,1,0,0] @ w = [0,1,0] + bias = [0.5, 0.5,0]
627        ];
628        let outputs = compiled.run(&[("x", &x_data)]);
629
630        assert_eq!(outputs.len(), 1);
631        let result = &outputs[0];
632        assert_eq!(result.len(), 6); // [2, 3]
633
634        // gelu(1.5) ≈ 1.399, gelu(-0.5) ≈ -0.154, gelu(0) = 0
635        assert!(
636            (result[0] - 1.399).abs() < 0.01,
637            "gelu(1.5) = {}",
638            result[0]
639        );
640        assert!(
641            (result[1] - -0.154).abs() < 0.01,
642            "gelu(-0.5) = {}",
643            result[1]
644        );
645        assert!((result[2]).abs() < 0.01, "gelu(0) = {}", result[2]);
646
647        // gelu(0.5) ≈ 0.346, gelu(0.5) ≈ 0.346, gelu(0) = 0
648        assert!(
649            (result[3] - 0.346).abs() < 0.01,
650            "gelu(0.5) = {}",
651            result[3]
652        );
653        assert!(
654            (result[4] - 0.346).abs() < 0.01,
655            "gelu(0.5) = {}",
656            result[4]
657        );
658
659        // Run again with different input — zero allocation
660        let x2 = vec![0.0f32; 8];
661        let outputs2 = compiled.run(&[("x", &x2)]);
662        // All zeros input → gelu(bias) for each output
663        let r2 = &outputs2[0];
664        assert!((r2[0] - 0.346).abs() < 0.01, "gelu(0.5) = {}", r2[0]); // gelu(0+0.5)
665    }
666
667    #[test]
668    #[cfg(feature = "cpu")]
669    fn device_display() {
670        use crate::device_ext::is_available;
671        assert!(format!("{}", Device::Cpu).starts_with("CPU"));
672        assert!(is_available(Device::Cpu));
673        // Backend availability is feature-gated; only assert
674        // unavailable when the corresponding feature is off.
675        #[cfg(not(feature = "gpu"))]
676        assert!(!is_available(Device::Gpu));
677        #[cfg(not(feature = "cuda"))]
678        assert!(!is_available(Device::Cuda));
679        #[cfg(not(feature = "rocm"))]
680        assert!(!is_available(Device::Rocm));
681        #[cfg(not(feature = "tpu"))]
682        assert!(!is_available(Device::Tpu));
683    }
684}