Skip to main content

rlx_runtime/backend/
mod.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//! Backend trait — abstraction over CPU/GPU/CUDA execution.
17//!
18//! Each backend implements `Backend::compile(graph, &CompileOptions)` and
19//! returns an `ExecutableGraph`. New compile knobs go in `CompileOptions`
20//! rather than as new trait methods.
21
22use crate::CompileOptions;
23use rlx_ir::Graph;
24use rlx_ir::hir::HirModule;
25use rlx_ir::lir::LirModule;
26use std::collections::HashMap;
27use std::sync::Arc;
28
29use crate::cpu_low_precision;
30
31// ── Typed I/O helpers (shared across f32-arena backends) ────────────────
32
33/// Widen (or, for F64, narrow) a typed byte buffer to `Vec<f32>`. Used by
34/// `set_param_typed` / `run_typed` overrides on backends whose internal
35/// arena is f32-uniform (Vulkan / wgpu / CUDA / ROCm / oneAPI / TPU) so
36/// callers can hand in F16/BF16/F64 without doing the host-side cast
37/// themselves. F64 is narrowed to f32 to match the f32-uniform arena slot
38/// (SPD-manifold ops re-widen to f64 inside their CPU host-fallback, so
39/// the eigendecomposition itself stays f64). Panics on dtypes the f32
40/// arena can't carry.
41#[allow(dead_code)]
42pub(crate) fn widen_bytes_to_f32(data: &[u8], dtype: rlx_ir::DType) -> Vec<f32> {
43    use rlx_ir::DType;
44    // An empty typed input (e.g. an unused KV-cache slot passed as `Vec::new()`)
45    // has a dangling, 1-byte-aligned pointer. `slice::from_raw_parts` requires an
46    // aligned pointer even for length 0, so reinterpreting it as `*const f32`
47    // trips the UB precondition check. Nothing to widen — return empty.
48    if data.is_empty() {
49        return Vec::new();
50    }
51    match dtype {
52        DType::F32 => {
53            let n = data.len() / 4;
54            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
55            s.to_vec()
56        }
57        DType::F64 => {
58            let n = data.len() / 8;
59            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f64, n) };
60            s.iter().map(|&x| x as f32).collect()
61        }
62        DType::F16 => {
63            let n = data.len() / 2;
64            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const half::f16, n) };
65            s.iter().map(|h| h.to_f32()).collect()
66        }
67        DType::BF16 => {
68            let n = data.len() / 2;
69            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const half::bf16, n) };
70            s.iter().map(|h| h.to_f32()).collect()
71        }
72        // Integer I/O (token ids, durations, masks) widened to f32 for the
73        // f32-uniform arena — same convention wgpu already uses. Values are
74        // small integers (<2^24), so f32 is exact. This lets TTS/LM graphs
75        // that feed I64/I32 inputs run on CUDA/Vulkan (VITS token ids etc.).
76        DType::I64 => {
77            let n = data.len() / 8;
78            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const i64, n) };
79            s.iter().map(|&x| x as f32).collect()
80        }
81        DType::I32 => {
82            let n = data.len() / 4;
83            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const i32, n) };
84            s.iter().map(|&x| x as f32).collect()
85        }
86        DType::I8 => data.iter().map(|&b| b as i8 as f32).collect(),
87        DType::U8 | DType::Bool => data.iter().map(|&b| b as f32).collect(),
88        other => panic!(
89            "widen_bytes_to_f32: dtype {other:?} unsupported on f32-arena backends \
90             (only F32/F64/F16/BF16/I64/I32/I8/U8/Bool are accepted on the host I/O surface)"
91        ),
92    }
93}
94
95/// Narrow a `&[f32]` buffer down to the declared output dtype, returning
96/// the corresponding little-endian byte stream. Mirrors the bytes a
97/// backend that stores the native dtype would emit. Used by `run_typed`
98/// to keep the byte-level output contract identical across backends.
99#[allow(dead_code)]
100pub(crate) fn narrow_f32_to_bytes(v: &[f32], dt: rlx_ir::DType) -> Vec<u8> {
101    use rlx_ir::DType;
102    match dt {
103        DType::F32 => {
104            let mut bytes = Vec::with_capacity(v.len() * 4);
105            for &x in v {
106                bytes.extend_from_slice(&x.to_le_bytes());
107            }
108            bytes
109        }
110        DType::F16 => {
111            let mut bytes = Vec::with_capacity(v.len() * 2);
112            for &x in v {
113                bytes.extend_from_slice(&half::f16::from_f32(x).to_le_bytes());
114            }
115            bytes
116        }
117        DType::BF16 => {
118            let mut bytes = Vec::with_capacity(v.len() * 2);
119            for &x in v {
120                bytes.extend_from_slice(&half::bf16::from_f32(x).to_le_bytes());
121            }
122            bytes
123        }
124        DType::F64 => {
125            let mut bytes = Vec::with_capacity(v.len() * 8);
126            for &x in v {
127                bytes.extend_from_slice(&(x as f64).to_le_bytes());
128            }
129            bytes
130        }
131        DType::I8 => v.iter().map(|&x| x as i8 as u8).collect(),
132        DType::U8 => v.iter().map(|&x| x as u8).collect(),
133        DType::I16 => {
134            let mut bytes = Vec::with_capacity(v.len() * 2);
135            for &x in v {
136                bytes.extend_from_slice(&(x as i16).to_le_bytes());
137            }
138            bytes
139        }
140        DType::I32 => {
141            let mut bytes = Vec::with_capacity(v.len() * 4);
142            for &x in v {
143                bytes.extend_from_slice(&(x as i32).to_le_bytes());
144            }
145            bytes
146        }
147        DType::U32 => {
148            let mut bytes = Vec::with_capacity(v.len() * 4);
149            for &x in v {
150                bytes.extend_from_slice(&(x as u32).to_le_bytes());
151            }
152            bytes
153        }
154        DType::I64 => {
155            let mut bytes = Vec::with_capacity(v.len() * 8);
156            for &x in v {
157                bytes.extend_from_slice(&(x as i64).to_le_bytes());
158            }
159            bytes
160        }
161        DType::Bool => v
162            .iter()
163            .map(|&x| if x != 0.0 { 1u8 } else { 0u8 })
164            .collect(),
165        DType::C64 => {
166            // Complex narrow path: real part = the f32 value, imaginary
167            // part = 0. Mirrors how the backend stores narrowed f32
168            // operands when promoted to a complex op input.
169            let mut bytes = Vec::with_capacity(v.len() * 8);
170            for &x in v {
171                bytes.extend_from_slice(&x.to_le_bytes());
172                bytes.extend_from_slice(&0.0_f32.to_le_bytes());
173            }
174            bytes
175        }
176    }
177}
178
179#[cfg(test)]
180mod f64_boundary_tests {
181    use super::{narrow_f32_to_bytes, widen_bytes_to_f32};
182    use rlx_ir::DType;
183
184    /// Regression: `widen_bytes_to_f32` used to `panic!` on F64, so uploading
185    /// an F64 param/input (e.g. SPDNet's learnable `G`) on an f32-uniform
186    /// backend (Vulkan/wgpu/CUDA/ROCm/oneAPI/TPU) crashed. It must now narrow
187    /// f64→f32 for the f32 arena slot.
188    #[test]
189    fn widen_f64_narrows_without_panic() {
190        let vals = [1.5f64, -2.25, 3.0, 4.5];
191        let bytes: Vec<u8> = vals.iter().flat_map(|x| x.to_le_bytes()).collect();
192        let out = widen_bytes_to_f32(&bytes, DType::F64);
193        assert_eq!(out, vec![1.5f32, -2.25, 3.0, 4.5]);
194    }
195
196    /// F64 output round-trips: narrow f32→f64-bytes then widen back.
197    #[test]
198    fn f64_boundary_round_trips() {
199        let v = [0.5f32, -1.0, 2.0];
200        let bytes = narrow_f32_to_bytes(&v, DType::F64);
201        assert_eq!(bytes.len(), v.len() * 8);
202        assert_eq!(widen_bytes_to_f32(&bytes, DType::F64), v.to_vec());
203    }
204}
205
206#[cfg(all(test, feature = "cpu"))]
207mod capabilities_tests {
208    use super::*;
209    use rlx_ir::{DType, Graph, Shape};
210
211    #[test]
212    fn cpu_executable_declares_core_capabilities() {
213        let mut g = Graph::new("caps");
214        let x = g.input("x", Shape::new(&[2], DType::F32));
215        g.set_outputs(vec![x]);
216        let be = cpu_backend::CpuBackend;
217        let exec = be.compile(g, &CompileOptions::default());
218        let caps = exec.capabilities();
219        assert!(caps.clone);
220        assert!(caps.moe);
221        assert!(caps.typed_io);
222        assert!(caps.active_extent);
223        assert!(!caps.gpu_handles);
224        assert_eq!(
225            caps.enabled_names(),
226            vec!["clone", "moe", "typed_io", "active_extent"]
227        );
228    }
229}
230
231/// A compiled, ready-to-execute graph on a specific backend.
232///
233/// # Method groups
234///
235/// See [`crate::ExecutableCapabilities`] for the optional feature bits and a
236/// table of which trait methods belong to which group. Core execution is
237/// `set_param` / `run` (and friends); everything else defaults to unsupported.
238pub trait ExecutableGraph: Send {
239    /// Advisory optional-feature bitmask. Default: all clear. Override when
240    /// you implement MoE / GPU handles / typed I/O / etc. Callers must still
241    /// treat individual method return values as authoritative.
242    fn capabilities(&self) -> crate::ExecutableCapabilities {
243        crate::ExecutableCapabilities::NONE
244    }
245
246    /// Set a named parameter (weight) buffer.
247    fn set_param(&mut self, name: &str, data: &[f32]);
248
249    /// Called after all params are uploaded (`set_param` / `set_param_typed`).
250    /// Backends may warm caches (e.g. Metal QMatMul weight dequant).
251    fn finalize_params(&mut self) {}
252
253    /// Deep-clone this executable into a fresh `Box`. Lets
254    /// `CompiledGraph` implement `Clone` so callers (e.g. eda-mna's
255    /// `SensitivityContext`) can spin up N independent executor
256    /// copies for thread-parallel dispatch without paying the full
257    /// graph-compile cost N times. Default implementation panics;
258    /// backends that support cloning override.
259    fn clone_box(&self) -> Box<dyn ExecutableGraph> {
260        panic!("clone_box not implemented for this backend");
261    }
262
263    /// Execute the graph with named inputs. Returns output data (copies from arena).
264    fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>>;
265
266    /// Like [`Self::run`] but only read back outputs at `read_indices`.
267    /// GPU handle feeds still update for every output. Default: all outputs.
268    fn run_read_outputs(
269        &mut self,
270        inputs: &[(&str, &[f32])],
271        read_indices: Option<&[usize]>,
272    ) -> Vec<Vec<f32>> {
273        match read_indices {
274            None => self.run(inputs),
275            Some(ix) => {
276                // Backends without a native partial-read path still run the full
277                // graph; only clone the requested outputs on the host.
278                let all = self.run(inputs);
279                ix.iter().filter_map(|&i| all.get(i).cloned()).collect()
280            }
281        }
282    }
283
284    /// Execute and return raw pointers to output data in arena (zero-copy).
285    fn run_raw(&mut self, inputs: &[(&str, &[f32])]) -> Vec<(*const f32, usize)> {
286        let vecs = self.run(inputs);
287        vecs.iter().map(|v| (v.as_ptr(), v.len())).collect()
288    }
289
290    /// Fastest: inputs by slot index, returns output (offset, len) pairs.
291    /// Read output from arena via `arena_ptr().add(offset)`.
292    fn run_slots(&mut self, _inputs: &[&[f32]]) -> &[(usize, usize)] {
293        &[] // default: not supported
294    }
295
296    /// Get the raw arena buffer pointer for reading outputs after run_slots.
297    fn arena_ptr(&self) -> *const u8 {
298        std::ptr::null()
299    }
300
301    /// Hint the executor that subsequent `run` calls should process
302    /// only the first `actual` rows along the bucket axis (out of
303    /// `upper`, the extent the graph was compiled at). Backends that
304    /// support per-kernel active-extent dispatch honor this; others
305    /// ignore it and process the full compiled extent.
306    ///
307    /// Pass `None` to clear the hint. The hint is sticky — set it
308    /// before each `run` and clear it after, or maintain it across
309    /// runs at your discretion.
310    ///
311    /// Even when honored, callers must not rely on the contents of the
312    /// output past `actual` rows — that region may contain stale data
313    /// from earlier runs (kernels skip it).
314    ///
315    /// Default: no-op. See `BucketedCompileCache::run_padded` for the
316    /// canonical caller; backends opt in by overriding this method.
317    fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
318        let _ = extent;
319    }
320
321    /// Override RNG policy for in-graph random ops without recompiling.
322    fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
323        let _ = rng;
324    }
325
326    /// Current RNG policy (default when the backend does not override).
327    fn rng(&self) -> rlx_ir::RngOptions {
328        rlx_ir::RngOptions::default()
329    }
330
331    /// TIDE merged placement mask (union across MoE layers). CPU: stats + host path.
332    fn set_moe_resident_experts(&mut self, _mask: &[bool]) {}
333
334    /// Per MoE layer placement (`masks[layer][expert]`). Preferred over merged on CPU.
335    fn set_moe_resident_experts_per_layer(&mut self, _masks: &[&[bool]]) {}
336
337    /// Capture MoE router TopK indices on the next CPU forward (TIDE refresh).
338    fn enable_moe_topk_capture(&mut self, _num_experts: usize) -> bool {
339        false
340    }
341
342    /// Take captured per-layer expert indices (one vec per MoE TopK in order).
343    fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
344        None
345    }
346
347    /// MoE GroupedMatMul residency accounting from the last forward (CPU).
348    fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
349        None
350    }
351
352    /// Bind a persistent buffer handle (KV-cache, training state, etc.).
353    /// The buffer lives across run() calls and is not in the arena.
354    /// Returns true if the backend supports persistent handles.
355    fn bind_handle(&mut self, _name: &str, _data: &[f32]) -> bool {
356        false
357    }
358
359    /// Read a persistent buffer's current contents.
360    fn read_handle(&self, _name: &str) -> Option<Vec<f32>> {
361        None
362    }
363
364    /// GPU-resident input (MLX): upload once, reuse across runs.
365    fn bind_gpu_handle(&mut self, _name: &str, _data: &[f32]) -> bool {
366        false
367    }
368
369    fn has_gpu_handle(&self, _name: &str) -> bool {
370        false
371    }
372
373    fn set_gpu_handle_feed(&mut self, _handle_name: &str, _output_index: usize) -> bool {
374        false
375    }
376
377    fn read_gpu_handle(&self, _name: &str) -> Option<Vec<f32>> {
378        None
379    }
380
381    /// Read one row from a resident GPU input handle without full-tensor D2H.
382    fn read_gpu_handle_row(&self, _name: &str, _row: usize, _row_inner: usize) -> Option<Vec<f32>> {
383        None
384    }
385
386    /// Register a targeted *row* feed for resident KV decode (graphs that emit
387    /// the new token at the last bucket-padded output row). Returns false when
388    /// the backend has no GPU-resident handle support. See [`feed_kv_row`].
389    fn register_kv_row_feed(&mut self, _handle_name: &str, _output_index: usize) -> bool {
390        false
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. Call after a logits-only run. Returns false when
396    /// unsupported (caller keeps the host KV path).
397    fn feed_kv_row(&mut self, _src_row: usize, _dst_row: usize, _row_elems: usize) -> bool {
398        false
399    }
400
401    /// Batch-major resident KV: `new` is `[B,1,row_elems]`, past is
402    /// `[B,seq_cap,row_elems]`. Writes each batch's new row into
403    /// `past[b, dst_row, :]`. Default falls back to contiguous `feed_kv_row`
404    /// when `batch == 1`.
405    fn feed_kv_batch_major(
406        &mut self,
407        dst_row: usize,
408        batch: usize,
409        seq_cap: usize,
410        row_elems: usize,
411    ) -> bool {
412        if batch == 1 {
413            return self.feed_kv_row(0, dst_row, row_elems);
414        }
415        let _ = (dst_row, seq_cap, row_elems);
416        false
417    }
418
419    /// Mark a graph input as a device-resident handle with no host mirror.
420    fn prepare_resident_gpu_handle(&mut self, _name: &str) -> bool {
421        false
422    }
423
424    /// Upload bound (non-resident) GPU handle mirrors into the arena.
425    fn stage_bound_gpu_handles_to_arena(&mut self) {}
426
427    /// D2D seed of resident `past_k_*` / `past_v_*` from another executable's
428    /// resident prefix (bucket rollover without host DRAM round-trip).
429    fn seed_resident_kv_prefix_from(
430        &mut self,
431        _src: &dyn ExecutableGraph,
432        _prefix_tokens: usize,
433        _outgoing_upper: usize,
434        _kv_dim: usize,
435        _n_layers: usize,
436    ) -> bool {
437        false
438    }
439
440    /// D2D copy resident KV rows `[from_row..to_row)` from another executable.
441    fn copy_resident_kv_rows_from(
442        &mut self,
443        _src: &dyn ExecutableGraph,
444        _from_row: usize,
445        _to_row: usize,
446        _outgoing_upper: usize,
447        _kv_dim: usize,
448        _n_layers: usize,
449    ) -> bool {
450        false
451    }
452
453    /// Copy named parameter storage from another executable on the same backend.
454    /// Used to avoid re-uploading packed U8 weights when compiling decode buckets.
455    fn copy_params_from(&mut self, src: &dyn ExecutableGraph) -> bool {
456        let _ = src;
457        false
458    }
459
460    /// Share (retain, don't copy) the packed weight storage of another executable
461    /// on the same backend when the weight layout matches EXACTLY — one GPU copy
462    /// of the (large, read-only) weights backs both executables, instead of a
463    /// per-executable duplicate. Returns false (caller must fall back to a full
464    /// upload/copy) when unsupported or the layouts differ. `src` must already be
465    /// fully populated. See `rlx_metal::MetalExecutable::share_weights_from`.
466    fn share_params_from(&mut self, src: &dyn ExecutableGraph) -> bool {
467        let _ = src;
468        false
469    }
470
471    /// Downcast hook for [`Self::copy_params_from`]. Backends override when supported.
472    fn executable_as_any(&self) -> Option<&dyn std::any::Any> {
473        None
474    }
475
476    /// Mutable downcast hook for [`Self::copy_params_from`].
477    fn executable_as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
478        None
479    }
480
481    /// CUDA-only: mutable access for device KV seeding. Default `None`.
482    #[cfg(feature = "cuda")]
483    fn cuda_executable_for_kv_seed(&mut self) -> Option<&mut rlx_cuda::backend::CudaExecutable> {
484        let _ = self;
485        None
486    }
487
488    /// CUDA-only: immutable access for device KV seeding. Default `None`.
489    #[cfg(feature = "cuda")]
490    fn cuda_executable_for_kv_seed_ref(&self) -> Option<&rlx_cuda::backend::CudaExecutable> {
491        None
492    }
493
494    /// Read one row from a row-major graph output after `run` / `run_read_outputs`.
495    /// Metal reads a single row from the arena; default returns `None` (caller falls back).
496    fn read_output_row(&self, _out_idx: usize, _row: usize, _row_inner: usize) -> Option<Vec<f32>> {
497        None
498    }
499
500    /// Run and refresh a GPU handle from `output_index`; returns that output on host.
501    fn run_feed_gpu_handle(
502        &mut self,
503        inputs: &[(&str, &[f32])],
504        _handle_name: &str,
505        _output_index: usize,
506    ) -> Option<Vec<f32>> {
507        let _ = inputs;
508        None
509    }
510
511    // ── Pipelined / async execution (Phase C) ─────────────────────────
512    //
513    // These allow callers to amortize per-run sync latency on backends
514    // where it matters (Metal: ~150 µs `wait_until_completed` per commit).
515    // CPU has no such cost, so the default impls just call `run` serially.
516
517    /// Encode + commit a forward pass without waiting for completion.
518    ///
519    /// Outputs of intermediate calls are stomped — use `run_pipelined` if
520    /// you need outputs from each individual commit. Pair with
521    /// `sync_pending` to drain.
522    ///
523    /// Default: synchronous fallback (calls `run`, discards output). CPU
524    /// uses this default since BLAS is synchronous anyway.
525    fn commit_no_wait(&mut self, inputs: &[(&str, &[f32])]) {
526        let _ = self.run(inputs);
527    }
528
529    /// Wait for every command queued by `commit_no_wait`.
530    /// Default: no-op (synchronous backends have nothing pending).
531    fn sync_pending(&mut self) {}
532
533    /// Issue a batch of forward passes pipelined, returning per-run outputs.
534    ///
535    /// The Metal impl encodes a per-commit blit so each in-flight run's
536    /// outputs survive subsequent commits stomping the shared arena. The
537    /// CPU default is just sequential `run`s — equally correct, no perf
538    /// penalty (CPU has no GPU sync cost to amortize).
539    ///
540    /// Returns `out[run_idx][output_idx][element_idx]`.
541    fn run_pipelined(&mut self, input_sets: &[Vec<(&str, &[f32])>]) -> Vec<Vec<Vec<f32>>> {
542        input_sets.iter().map(|inputs| self.run(inputs)).collect()
543    }
544
545    // ── Typed (non-F32) host I/O ──────────────────────────────────
546    //
547    // `set_param` and `run` are F32 by contract. The typed entry
548    // points let callers pass and receive raw bytes in any rlx-ir
549    // dtype, avoiding the f32 widen/narrow round-trip that's
550    // wasteful for F16/BF16 weights and activations.
551    //
552    // The default impls only handle F32 — any other dtype panics.
553    // Backends that support typed I/O natively (e.g. MLX via
554    // Array::from_bytes/to_bytes) override these.
555
556    /// Set a named parameter from raw bytes in the given dtype.
557    fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
558        if dtype != rlx_ir::DType::F32 {
559            panic!(
560                "backend's default set_param_typed only handles F32; \
561                    got {dtype:?}. Override on the backend for typed support."
562            );
563        }
564        if !data.len().is_multiple_of(4) {
565            panic!(
566                "set_param_typed F32: data length {} not a multiple of 4",
567                data.len()
568            );
569        }
570        // SAFETY: F32 bytes are 4-aligned by source convention; we
571        // only widen access (read &[f32] from owned &[u8]). Failure
572        // mode if a caller hands us mis-aligned bytes is undefined,
573        // hence the % 4 length check.
574        let n = data.len() / 4;
575        let f32_slice = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
576        self.set_param(name, f32_slice);
577    }
578
579    /// Run with typed inputs and typed outputs. Returns
580    /// `(bytes, dtype)` per output; the dtype is whatever the
581    /// graph's output node was declared as.
582    fn run_typed(
583        &mut self,
584        inputs: &[(&str, &[u8], rlx_ir::DType)],
585    ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
586        // Default impl: convert each typed input to f32 (F32-only),
587        // run, then re-emit outputs as F32 bytes.
588        let mut owned: Vec<(String, Vec<f32>)> = Vec::with_capacity(inputs.len());
589        for (name, data, dt) in inputs {
590            if *dt != rlx_ir::DType::F32 {
591                panic!(
592                    "backend's default run_typed only handles F32 inputs; \
593                        got {dt:?} for input '{name}'"
594                );
595            }
596            if data.len() % 4 != 0 {
597                panic!(
598                    "run_typed F32 input '{name}': len {} not multiple of 4",
599                    data.len()
600                );
601            }
602            let n = data.len() / 4;
603            let v: Vec<f32> =
604                unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) }.to_vec();
605            owned.push((name.to_string(), v));
606        }
607        let refs: Vec<(&str, &[f32])> = owned
608            .iter()
609            .map(|(n, d)| (n.as_str(), d.as_slice()))
610            .collect();
611        let outs = self.run(&refs);
612        outs.into_iter()
613            .map(|v| {
614                let bytes =
615                    unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * 4) }
616                        .to_vec();
617                (bytes, rlx_ir::DType::F32)
618            })
619            .collect()
620    }
621
622    /// True when the lowered wgpu schedule contains host-executed steps
623    /// (unsupported on browser WebGPU). Default: false.
624    #[cfg(target_arch = "wasm32")]
625    fn wgpu_requires_host(&self) -> bool {
626        false
627    }
628
629    /// Async WebGPU execution for wasm (non-blocking readback). Default: error.
630    ///
631    /// Futures are **not** `Send` on wasm — WebGPU buffer mapping uses
632    /// `Rc`/`RefCell` and browser callbacks that are single-threaded.
633    #[cfg(target_arch = "wasm32")]
634    fn wgpu_run_async<'a>(
635        &'a mut self,
636        _inputs: &'a [(&'a str, &'a [f32])],
637    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<Vec<f32>>, String>> + 'a>>
638    {
639        Box::pin(async move { Err("wgpu_run_async not implemented for this backend".into()) })
640    }
641}
642
643/// Backend implementation trait.
644///
645/// Single compile entry point. New compile-time knobs are added to
646/// `CompileOptions`, not as new trait methods.
647///
648/// `Send + Sync` because backends are stateless factories — multiple
649/// threads can call `compile` concurrently. The returned
650/// `Box<dyn ExecutableGraph>` is `Send` (moveable to a worker thread)
651/// but **not** `Sync` (`run`/`run_slots` take `&mut self`).
652pub trait Backend: Send + Sync {
653    /// Compile a graph for this backend with the given options.
654    fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph>;
655
656    /// Compile pre-optimized LIR (HIR → MIR → LIR pipeline output).
657    /// Default re-enters [`Self::compile`] — backends should override
658    /// when they can reuse the embedded buffer plan.
659    fn compile_lir(&self, lir: LirModule, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
660        self.compile(lir.into_graph(), options)
661    }
662
663    /// HIR-first compile: lower blocks, run fusion pipeline, emit executable.
664    fn compile_hir(
665        &self,
666        hir: HirModule,
667        device: rlx_driver::Device,
668        options: &CompileOptions,
669    ) -> Result<Box<dyn ExecutableGraph>, rlx_ir::hir::LowerError> {
670        let result = crate::stages::compile_hir_stages(device, hir, options)?;
671        crate::stages::maybe_log_fusion(&result.fusion);
672        Ok(self.compile_lir(result.lir, options))
673    }
674
675    /// [`GraphModule`] compile — unified HIR/MIR/LIR entry.
676    fn compile_module(
677        &self,
678        module: rlx_ir::GraphModule,
679        device: rlx_driver::Device,
680        options: &CompileOptions,
681    ) -> Result<Box<dyn ExecutableGraph>, rlx_ir::hir::LowerError> {
682        let result = crate::stages::compile_module_stages(device, module, options)?;
683        crate::stages::maybe_log_fusion(&result.fusion);
684        Ok(self.compile_lir(result.lir, options))
685    }
686
687    /// PLAN L4: declare which `OpKind`s this backend can lower.
688    /// Default: empty slice = "no claim made — accept everything"
689    /// (preserves existing behavior; backends opt in by overriding).
690    /// When non-empty, the `LegalizeForBackend` pass will refuse to
691    /// compile a graph that contains an op outside this set, instead
692    /// of silently falling through to slower / wrong dispatch.
693    fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
694        &[]
695    }
696}
697
698/// Prepare a fused MIR graph from LIR for backend executable construction.
699/// Skips the fusion pipeline — LIR must come from `compile_*_stages`.
700#[allow(dead_code)]
701fn prepare_fused_graph(
702    graph: Graph,
703    options: &CompileOptions,
704    supported_ops: &[rlx_ir::OpKind],
705    backend_name: &str,
706) -> Graph {
707    let (mut graph, report) = rlx_opt::prepare_graph_for_backend_with_report(
708        graph,
709        backend_name,
710        supported_ops,
711        options.kernel_dispatch,
712    );
713    rlx_opt::maybe_log_dispatch_report(&report);
714    if !report.compile_ready {
715        panic!(
716            "{}\n{}",
717            rlx_opt::format_legalize_error(backend_name, &report.still_unsupported),
718            rlx_opt::format_dispatch_report(&report)
719        );
720    }
721    // Backends that claim `OpKind::Scan` keep long Scans for host-fallback;
722    // short ones IR-unroll so the body runs as ordinary device ops.
723    if supported_ops.contains(&rlx_ir::OpKind::Scan) {
724        graph = apply_scan_device_preference(graph, options);
725    }
726    graph = crate::precompile::post_fusion_cleanup(graph, options);
727    if let Some(p) = options.policy.clone() {
728        use rlx_opt::pass::Pass as _;
729        graph = rlx_opt::AutoMixedPrecision::new(p).run(graph);
730    }
731    graph
732}
733
734/// Short-length + small-budget Scan unroll so GPU backends run the body on-device.
735fn apply_scan_device_preference(graph: Graph, options: &CompileOptions) -> Graph {
736    let g = rlx_cpu::rlx_maybe_unroll_scans!(graph, options.scan_unroll_max_length);
737    rlx_opt::maybe_unroll_scans_budget(g, 4096)
738}
739
740#[allow(dead_code)]
741fn declared_output_dtypes(
742    manifest: &cpu_low_precision::IoDtypeManifest,
743    exec_dtypes: Vec<rlx_ir::DType>,
744) -> Vec<rlx_ir::DType> {
745    exec_dtypes
746        .into_iter()
747        .enumerate()
748        .map(|(i, exec)| manifest.output_dtype(i, exec))
749        .collect()
750}
751
752// ── Convenience helpers preserved from older API ──────────────────────
753//
754// These let existing call sites keep working unchanged while the new
755// trait is the canonical one. We provide free functions rather than
756// trait methods so adding them doesn't grow the trait surface.
757
758/// Compile at default options (F32, no policy).
759pub fn compile(backend: &dyn Backend, graph: Graph) -> Box<dyn ExecutableGraph> {
760    backend.compile(graph, &CompileOptions::default())
761}
762
763/// Compile HIR through the fusion-first pipeline.
764pub fn compile_hir(
765    backend: &dyn Backend,
766    hir: HirModule,
767    device: rlx_driver::Device,
768    options: &CompileOptions,
769) -> Result<Box<dyn ExecutableGraph>, rlx_ir::hir::LowerError> {
770    backend.compile_hir(hir, device, options)
771}
772
773/// Compile a [`GraphModule`] through the fusion-first pipeline.
774pub fn compile_module(
775    backend: &dyn Backend,
776    module: rlx_ir::GraphModule,
777    device: rlx_driver::Device,
778    options: &CompileOptions,
779) -> Result<Box<dyn ExecutableGraph>, rlx_ir::hir::LowerError> {
780    backend.compile_module(module, device, options)
781}
782
783/// Compile at a specific precision (default policy = none).
784pub fn compile_with_precision(
785    backend: &dyn Backend,
786    graph: Graph,
787    precision: crate::Precision,
788) -> Box<dyn ExecutableGraph> {
789    backend.compile(graph, &CompileOptions::new().precision(precision))
790}
791
792/// Helper retained for backward compatibility — applies the precision
793/// rewrite at the runtime layer if backends don't override their
794/// pipeline placement. Modern code: pass the policy via CompileOptions
795/// and let the backend handle ordering.
796fn _legacy_apply_policy(graph: Graph, policy: Option<rlx_opt::PrecisionPolicy>) -> Graph {
797    use rlx_opt::pass::Pass as _;
798    match policy {
799        Some(p) => rlx_opt::AutoMixedPrecision::new(p).run(graph),
800        None => graph,
801    }
802}
803
804// ── CPU Backend ─────────────────────────────────────────────────────────
805
806// Per-device Backend / ExecutableGraph wiring (feature-gated).
807
808#[cfg(feature = "cpu")]
809pub mod cpu_backend;
810
811#[cfg(feature = "gpu")]
812pub mod wgpu_backend;
813
814#[cfg(feature = "opengl")]
815pub mod webgl_backend;
816
817#[cfg(feature = "vulkan")]
818pub mod vulkan_backend;
819
820#[cfg(feature = "oneapi")]
821pub mod oneapi_backend;
822
823#[cfg(all(feature = "mlx", rlx_mlx_host))]
824pub mod mlx_backend;
825
826/// Apple CoreML / Neural Engine (ANE) backend wiring.
827///
828/// Unlike the GPU backends, CoreML compiles a *static* model with weights
829/// baked in, so we hand the raw graph (Param nodes intact) straight to
830/// `rlx_coreml` and skip the fusion/LIR arena pipeline.
831#[cfg(all(
832    feature = "coreml",
833    target_vendor = "apple",
834    not(target_os = "watchos")
835))]
836pub mod coreml_backend;
837
838#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
839pub mod metal_backend;
840
841#[cfg(feature = "cuda")]
842pub mod cuda_backend;
843
844#[cfg(feature = "rocm")]
845pub mod rocm_backend;
846
847#[cfg(feature = "tpu")]
848pub mod tpu_backend;
849
850/// QNN (Hexagon NPU) backend adapter — wraps the `rlx-qnn` FFI runtime
851/// (`Device::Hexagon`). Lowers a general supported subgraph in-process via
852/// `libQnnCpu.so` / `libQnnHtp.so`. Claims `FusedAttentionBlock` and
853/// decomposes it before FFI lower (same pattern as CoreML / Vulkan).
854#[cfg(feature = "qnn")]
855pub mod qnn_backend;