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}