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