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}