Skip to main content

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