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    /// GPU-resident MLX input (no-op on non-MLX backends).
240    pub fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
241        self.inner.bind_gpu_handle(name, data)
242    }
243
244    /// ZERO-COPY in-place optimizer step over GPU-resident weights: the
245    /// optimizer reads gradients and writes params directly on the unified-memory
246    /// arena (no GPU→host→GPU roundtrip). `trainable[i] = (input_name, shape)`;
247    /// the gradient is read from output slot `1 + i` (backward outputs
248    /// `[loss, grad0, …]`). Pair with [`Self::bind_gpu_handle`] (weight resident)
249    /// and `run_read_outputs(_, Some(&[0]))` (read only the loss). Returns false
250    /// on backends without GPU-resident support — fall back to a host step.
251    ///
252    /// ```ignore
253    /// compiled.bind_gpu_handle("W", &w_init);
254    /// let mut muon = Muon::new(lr);
255    /// for _ in 0..steps {
256    ///     compiled.run_read_outputs(&[("d_output", &[1.0])], Some(&[0]));
257    ///     compiled.optimizer_step_resident(&trainable, &mut |n, s, p, g| muon.step(n, s, p, g));
258    /// }
259    /// ```
260    pub fn optimizer_step_resident(
261        &mut self,
262        trainable: &[(String, Vec<usize>)],
263        step: &mut dyn FnMut(&str, &[usize], &mut [f32], &[f32]),
264    ) -> bool {
265        self.inner.optimizer_step_resident(trainable, step)
266    }
267
268    pub fn has_gpu_handle(&self, name: &str) -> bool {
269        self.inner.has_gpu_handle(name)
270    }
271
272    pub fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
273        self.inner.set_gpu_handle_feed(handle_name, output_index)
274    }
275
276    pub fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
277        self.inner.read_gpu_handle(name)
278    }
279
280    /// Read one row from a resident GPU input handle without full-tensor D2H.
281    pub fn read_gpu_handle_row(
282        &self,
283        name: &str,
284        row: usize,
285        row_inner: usize,
286    ) -> Option<Vec<f32>> {
287        self.inner.read_gpu_handle_row(name, row, row_inner)
288    }
289
290    /// Register a targeted row feed for resident KV decode (graphs that emit the
291    /// new token at the last bucket-padded output row). No-op (false) on
292    /// backends without GPU-resident handle support. See [`Self::feed_kv_row`].
293    pub fn register_kv_row_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
294        self.inner.register_kv_row_feed(handle_name, output_index)
295    }
296
297    /// Fold each registered row feed's new-token row (`src_row` of its output)
298    /// into the resident handle slot at `dst_row` (`row_elems` = kv_dim),
299    /// in-place on device. Returns false when unsupported.
300    pub fn feed_kv_row(&mut self, src_row: usize, dst_row: usize, row_elems: usize) -> bool {
301        self.inner.feed_kv_row(src_row, dst_row, row_elems)
302    }
303
304    /// Batch-major resident KV feed (`past[b, dst_row]` ← `new[b, 0]`).
305    pub fn feed_kv_batch_major(
306        &mut self,
307        dst_row: usize,
308        batch: usize,
309        seq_cap: usize,
310        row_elems: usize,
311    ) -> bool {
312        self.inner
313            .feed_kv_batch_major(dst_row, batch, seq_cap, row_elems)
314    }
315
316    /// Strided-source batch-major resident KV feed: fold the new token from a
317    /// `[B, src_seq_cap, row_elems]` output at `src_row` into the resident input
318    /// handle `[B, dst_seq_cap, row_elems]` at `dst_row`. For decode graphs that
319    /// emit the whole appended cache (new token at output row `upper`).
320    #[allow(clippy::too_many_arguments)]
321    pub fn feed_kv_batch_major_src(
322        &mut self,
323        src_row: usize,
324        src_seq_cap: usize,
325        dst_row: usize,
326        batch: usize,
327        dst_seq_cap: usize,
328        row_elems: usize,
329    ) -> bool {
330        self.inner.feed_kv_batch_major_src(
331            src_row,
332            src_seq_cap,
333            dst_row,
334            batch,
335            dst_seq_cap,
336            row_elems,
337        )
338    }
339
340    /// Ragged resident KV feed: each batch element folds the new token from a
341    /// `[B, src_seq_cap, row_elems]` output at `src_row` into its own resident
342    /// handle row `dst_rows[b]` (the sequence's `past_len`). For fused decode of
343    /// sequences at MIXED cache lengths.
344    pub fn feed_kv_batch_major_ragged(
345        &mut self,
346        src_row: usize,
347        src_seq_cap: usize,
348        dst_rows: &[usize],
349        dst_seq_cap: usize,
350        row_elems: usize,
351    ) -> bool {
352        self.inner.feed_kv_batch_major_ragged(
353            src_row,
354            src_seq_cap,
355            dst_rows,
356            dst_seq_cap,
357            row_elems,
358        )
359    }
360
361    /// Mark a graph input as device-resident without host staging.
362    pub fn prepare_resident_gpu_handle(&mut self, name: &str) -> bool {
363        self.inner.prepare_resident_gpu_handle(name)
364    }
365
366    /// Upload bound (non-resident) GPU handle mirrors into the arena.
367    pub fn stage_bound_gpu_handles_to_arena(&mut self) -> bool {
368        self.inner.stage_bound_gpu_handles_to_arena();
369        true
370    }
371
372    /// D2D copy resident KV rows `[from_row..to_row)` from another compiled graph.
373    pub fn seed_resident_kv_prefix_from(
374        &mut self,
375        src: &CompiledGraph,
376        prefix_tokens: usize,
377        outgoing_upper: usize,
378        kv_dim: usize,
379        n_layers: usize,
380    ) -> bool {
381        if self.device != src.device {
382            return false;
383        }
384        self.inner.seed_resident_kv_prefix_from(
385            src.inner.as_ref(),
386            prefix_tokens,
387            outgoing_upper,
388            kv_dim,
389            n_layers,
390        )
391    }
392
393    /// D2D copy resident KV rows `[from_row..to_row)` from another compiled graph.
394    pub fn copy_resident_kv_rows_from(
395        &mut self,
396        src: &CompiledGraph,
397        from_row: usize,
398        to_row: usize,
399        outgoing_upper: usize,
400        kv_dim: usize,
401        n_layers: usize,
402    ) -> bool {
403        if self.device != src.device {
404            return false;
405        }
406        self.inner.copy_resident_kv_rows_from(
407            src.inner.as_ref(),
408            from_row,
409            to_row,
410            outgoing_upper,
411            kv_dim,
412            n_layers,
413        )
414    }
415
416    /// Copy parameter buffers from `src` when layouts match (same device/backend).
417    pub fn copy_params_from(&mut self, src: &CompiledGraph) -> bool {
418        if self.device != src.device {
419            return false;
420        }
421        self.inner.copy_params_from(src.inner.as_ref())
422    }
423
424    /// Share `src`'s packed weight buffer (retain, not copy) instead of uploading
425    /// our own, when the weight layout matches EXACTLY — one GPU copy backs both.
426    /// Returns false (caller must upload) on device mismatch or differing layout.
427    pub fn share_params_from(&mut self, src: &CompiledGraph) -> bool {
428        if self.device != src.device {
429            return false;
430        }
431        self.inner.share_params_from(src.inner.as_ref())
432    }
433
434    /// Run, refresh GPU handle from output, return that output vector.
435    pub fn run_feed_gpu_handle(
436        &mut self,
437        inputs: &[(&str, &[f32])],
438        handle_name: &str,
439        output_index: usize,
440    ) -> Option<Vec<f32>> {
441        self.inner
442            .run_feed_gpu_handle(inputs, handle_name, output_index)
443    }
444
445    /// Hint subsequent `run` calls to process only the first `actual`
446    /// rows along the bucket axis (out of `upper`, the compile extent).
447    /// Backends that support per-kernel active-extent dispatch honor
448    /// this; others ignore it. Pass `None` to clear.
449    ///
450    /// See `BucketedCompileCache::run_padded` for the canonical caller.
451    pub fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
452        #[cfg(feature = "cpu")]
453        if let Some((actual, _)) = extent {
454            crate::onnx_active::set_active_token_count(Some(actual))
455        }
456        self.inner.set_active_extent(extent);
457    }
458
459    /// Return this graph's idle scratch (activation) pages to the OS while it stays
460    /// cached — for multi-subgraph pipelines whose stages run sequentially. Weights
461    /// stay resident; the next run refaults + re-zeros scratch transparently. Call
462    /// only between stages, never inside a hot loop. Advisory no-op off CPU.
463    pub fn release_scratch(&mut self) {
464        self.inner.release_scratch();
465    }
466
467    /// TIDE merged MoE placement (`mask[expert]` device-resident if any layer has it).
468    pub fn set_moe_resident_experts(&mut self, mask: &[bool]) {
469        self.inner.set_moe_resident_experts(mask);
470    }
471
472    /// Per MoE layer placement (forward order). Preferred on CPU over merged mask.
473    pub fn set_moe_resident_experts_per_layer(&mut self, masks: &[&[bool]]) {
474        self.inner.set_moe_resident_experts_per_layer(masks);
475    }
476
477    /// Capture MoE router TopK on next forward (CPU). Returns false if unsupported.
478    pub fn enable_moe_topk_capture(&mut self, num_experts: usize) -> bool {
479        self.inner.enable_moe_topk_capture(num_experts)
480    }
481
482    /// Per-layer expert indices from the last forward (MoE router TopK order).
483    pub fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
484        self.inner.take_moe_topk_capture()
485    }
486
487    /// GroupedMatMul GPU/CPU token accounting from the last forward (CPU).
488    pub fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
489        self.inner.take_moe_residency_stats()
490    }
491
492    // ── Pipelined / async execution (Phase C) ─────────────────────────
493
494    /// Encode + commit a forward pass without waiting for the device.
495    ///
496    /// Outputs of intermediate calls are stomped — use `run_pipelined`
497    /// when you need each call's outputs back. Pair with `sync_pending`
498    /// to drain. CPU is synchronous, so this falls back to `run`.
499    pub fn commit_no_wait(&mut self, inputs: &[(&str, &[f32])]) {
500        self.inner.commit_no_wait(inputs);
501    }
502
503    /// Wait for every command queued by `commit_no_wait`. CPU is a no-op.
504    pub fn sync_pending(&mut self) {
505        self.inner.sync_pending();
506    }
507
508    /// Pipelined batch run. Issues one commit per input set, syncs once
509    /// at the end. On Metal, each commit gets its own output snapshot
510    /// (allocated + blit-copied), so subsequent commits stomping the
511    /// shared arena don't corrupt earlier runs' outputs.
512    /// Returns `out[run_idx][output_idx][element_idx]`.
513    pub fn run_pipelined(&mut self, input_sets: &[Vec<(&str, &[f32])>]) -> Vec<Vec<Vec<f32>>> {
514        self.inner.run_pipelined(input_sets)
515    }
516
517    /// Set a named parameter from raw bytes in the given dtype. The
518    /// backend handles the widen-to-f32 (or zero-widen, when supported
519    /// natively) on the way in. Lets callers feed F16/BF16 weights
520    /// without a host-side cast.
521    pub fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
522        if let Some(st) = self.staging.as_mut() {
523            if st.prepare_params.contains(name) {
524                st.prepare.set_param_typed(name, data, dtype);
525                st.prepared = false;
526            }
527            if st.main_params.contains(name) {
528                self.inner.set_param_typed(name, data, dtype);
529            }
530        } else {
531            self.inner.set_param_typed(name, data, dtype);
532        }
533    }
534
535    /// Incrementally write raw `data` into a named param starting `byte_offset`
536    /// bytes into its storage. Returns true if the backend did the partial write;
537    /// false → caller must fall back to a whole-buffer [`Self::set_param_typed`].
538    /// Used by paged-MoE residency to upload only the changed expert slot.
539    pub fn set_param_range(&mut self, name: &str, byte_offset: usize, data: &[u8]) -> bool {
540        if let Some(st) = self.staging.as_mut() {
541            let mut done = false;
542            if st.prepare_params.contains(name) {
543                done |= st.prepare.set_param_range(name, byte_offset, data);
544                st.prepared = false;
545            }
546            if st.main_params.contains(name) {
547                done |= self.inner.set_param_range(name, byte_offset, data);
548            }
549            done
550        } else {
551            self.inner.set_param_range(name, byte_offset, data)
552        }
553    }
554
555    /// Finish param upload — warms backend caches when supported.
556    pub fn finalize_params(&mut self) {
557        self.ensure_prepared();
558        self.inner.finalize_params();
559    }
560
561    /// Execute with typed inputs and return outputs in their declared
562    /// graph dtype, byte-encoded. Mirrors the wgpu / MLX zero-widen
563    /// semantics on f32-arena backends (CPU + Metal) by widening at
564    /// the boundary.
565    pub fn run_typed(
566        &mut self,
567        inputs: &[(&str, &[u8], rlx_ir::DType)],
568    ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
569        self.ensure_prepared();
570        self.inner.run_typed(inputs)
571    }
572
573    /// Override RNG policy for in-graph random ops without recompiling.
574    pub fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
575        self.inner.set_rng(rng);
576    }
577
578    /// Current RNG compile/execute policy.
579    pub fn rng(&self) -> rlx_ir::RngOptions {
580        self.inner.rng()
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use crate::*;
587
588    #[test]
589    #[cfg(feature = "cpu")]
590    fn end_to_end_session() {
591        let mut g = Graph::new("matmul_bias_gelu");
592        let x = g.input("x", Shape::new(&[2, 4], DType::F32));
593        let w = g.param("w", Shape::new(&[4, 3], DType::F32));
594        let b = g.param("b", Shape::new(&[3], DType::F32));
595        let mm = g.matmul(x, w, Shape::new(&[2, 3], DType::F32));
596        let add = g.binary(op::BinaryOp::Add, mm, b, Shape::new(&[2, 3], DType::F32));
597        let out = g.activation(op::Activation::Gelu, add, Shape::new(&[2, 3], DType::F32));
598        g.set_outputs(vec![out]);
599
600        // Compile
601        let session = Session::new(Device::Cpu);
602        let mut compiled = session.compile(g);
603
604        // Set weights
605        // w = identity-ish [4, 3]: first 3 rows are I, last row is 0
606        compiled.set_param(
607            "w",
608            &[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
609        );
610        compiled.set_param("b", &[0.5, -0.5, 0.0]);
611
612        // Run
613        let x_data = vec![
614            1.0, 0.0, 0.0, 0.0, // row 0: [1,0,0,0] @ w = [1,0,0] + bias = [1.5,-0.5,0]
615            0.0, 1.0, 0.0, 0.0, // row 1: [0,1,0,0] @ w = [0,1,0] + bias = [0.5, 0.5,0]
616        ];
617        let outputs = compiled.run(&[("x", &x_data)]);
618
619        assert_eq!(outputs.len(), 1);
620        let result = &outputs[0];
621        assert_eq!(result.len(), 6); // [2, 3]
622
623        // gelu(1.5) ≈ 1.399, gelu(-0.5) ≈ -0.154, gelu(0) = 0
624        assert!(
625            (result[0] - 1.399).abs() < 0.01,
626            "gelu(1.5) = {}",
627            result[0]
628        );
629        assert!(
630            (result[1] - -0.154).abs() < 0.01,
631            "gelu(-0.5) = {}",
632            result[1]
633        );
634        assert!((result[2]).abs() < 0.01, "gelu(0) = {}", result[2]);
635
636        // gelu(0.5) ≈ 0.346, gelu(0.5) ≈ 0.346, gelu(0) = 0
637        assert!(
638            (result[3] - 0.346).abs() < 0.01,
639            "gelu(0.5) = {}",
640            result[3]
641        );
642        assert!(
643            (result[4] - 0.346).abs() < 0.01,
644            "gelu(0.5) = {}",
645            result[4]
646        );
647
648        // Run again with different input — zero allocation
649        let x2 = vec![0.0f32; 8];
650        let outputs2 = compiled.run(&[("x", &x2)]);
651        // All zeros input → gelu(bias) for each output
652        let r2 = &outputs2[0];
653        assert!((r2[0] - 0.346).abs() < 0.01, "gelu(0.5) = {}", r2[0]); // gelu(0+0.5)
654    }
655
656    #[test]
657    #[cfg(feature = "cpu")]
658    fn device_display() {
659        use crate::device_ext::is_available;
660        assert!(format!("{}", Device::Cpu).starts_with("CPU"));
661        assert!(is_available(Device::Cpu));
662        // Backend availability is feature-gated; only assert
663        // unavailable when the corresponding feature is off.
664        #[cfg(not(feature = "gpu"))]
665        assert!(!is_available(Device::Gpu));
666        #[cfg(not(feature = "cuda"))]
667        assert!(!is_available(Device::Cuda));
668        #[cfg(not(feature = "rocm"))]
669        assert!(!is_available(Device::Rocm));
670        #[cfg(not(feature = "tpu"))]
671        assert!(!is_available(Device::Tpu));
672    }
673}