Skip to main content

rlx_runtime/
compiled.rs

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