Skip to main content

rlx_runtime/
backend.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 a typed byte buffer to `Vec<f32>`. Used by `set_param_typed` /
34/// `run_typed` overrides on backends whose internal arena is f32-uniform
35/// (CPU, Metal, wgpu) so callers can hand in F16/BF16 without doing the
36/// host-side cast themselves. Panics on dtypes the f32 arena can't carry.
37#[allow(dead_code)]
38pub(crate) fn widen_bytes_to_f32(data: &[u8], dtype: rlx_ir::DType) -> Vec<f32> {
39    use rlx_ir::DType;
40    match dtype {
41        DType::F32 => {
42            let n = data.len() / 4;
43            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
44            s.to_vec()
45        }
46        DType::F16 => {
47            let n = data.len() / 2;
48            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const half::f16, n) };
49            s.iter().map(|h| h.to_f32()).collect()
50        }
51        DType::BF16 => {
52            let n = data.len() / 2;
53            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const half::bf16, n) };
54            s.iter().map(|h| h.to_f32()).collect()
55        }
56        other => panic!(
57            "widen_bytes_to_f32: dtype {other:?} unsupported on f32-arena backends \
58             (only F32/F16/BF16 are accepted on the host I/O surface)"
59        ),
60    }
61}
62
63/// Narrow a `&[f32]` buffer down to the declared output dtype, returning
64/// the corresponding little-endian byte stream. Mirrors the bytes a
65/// backend that stores the native dtype would emit. Used by `run_typed`
66/// to keep the byte-level output contract identical across backends.
67#[allow(dead_code)]
68pub(crate) fn narrow_f32_to_bytes(v: &[f32], dt: rlx_ir::DType) -> Vec<u8> {
69    use rlx_ir::DType;
70    match dt {
71        DType::F32 => {
72            let mut bytes = Vec::with_capacity(v.len() * 4);
73            for &x in v {
74                bytes.extend_from_slice(&x.to_le_bytes());
75            }
76            bytes
77        }
78        DType::F16 => {
79            let mut bytes = Vec::with_capacity(v.len() * 2);
80            for &x in v {
81                bytes.extend_from_slice(&half::f16::from_f32(x).to_le_bytes());
82            }
83            bytes
84        }
85        DType::BF16 => {
86            let mut bytes = Vec::with_capacity(v.len() * 2);
87            for &x in v {
88                bytes.extend_from_slice(&half::bf16::from_f32(x).to_le_bytes());
89            }
90            bytes
91        }
92        DType::F64 => {
93            let mut bytes = Vec::with_capacity(v.len() * 8);
94            for &x in v {
95                bytes.extend_from_slice(&(x as f64).to_le_bytes());
96            }
97            bytes
98        }
99        DType::I8 => v.iter().map(|&x| x as i8 as u8).collect(),
100        DType::U8 => v.iter().map(|&x| x as u8).collect(),
101        DType::I16 => {
102            let mut bytes = Vec::with_capacity(v.len() * 2);
103            for &x in v {
104                bytes.extend_from_slice(&(x as i16).to_le_bytes());
105            }
106            bytes
107        }
108        DType::I32 => {
109            let mut bytes = Vec::with_capacity(v.len() * 4);
110            for &x in v {
111                bytes.extend_from_slice(&(x as i32).to_le_bytes());
112            }
113            bytes
114        }
115        DType::U32 => {
116            let mut bytes = Vec::with_capacity(v.len() * 4);
117            for &x in v {
118                bytes.extend_from_slice(&(x as u32).to_le_bytes());
119            }
120            bytes
121        }
122        DType::I64 => {
123            let mut bytes = Vec::with_capacity(v.len() * 8);
124            for &x in v {
125                bytes.extend_from_slice(&(x as i64).to_le_bytes());
126            }
127            bytes
128        }
129        DType::Bool => v
130            .iter()
131            .map(|&x| if x != 0.0 { 1u8 } else { 0u8 })
132            .collect(),
133        DType::C64 => {
134            // Complex narrow path: real part = the f32 value, imaginary
135            // part = 0. Mirrors how the backend stores narrowed f32
136            // operands when promoted to a complex op input.
137            let mut bytes = Vec::with_capacity(v.len() * 8);
138            for &x in v {
139                bytes.extend_from_slice(&x.to_le_bytes());
140                bytes.extend_from_slice(&0.0_f32.to_le_bytes());
141            }
142            bytes
143        }
144    }
145}
146
147/// A compiled, ready-to-execute graph on a specific backend.
148pub trait ExecutableGraph: Send {
149    /// Set a named parameter (weight) buffer.
150    fn set_param(&mut self, name: &str, data: &[f32]);
151
152    /// Called after all params are uploaded (`set_param` / `set_param_typed`).
153    /// Backends may warm caches (e.g. Metal QMatMul weight dequant).
154    fn finalize_params(&mut self) {}
155
156    /// Deep-clone this executable into a fresh `Box`. Lets
157    /// `CompiledGraph` implement `Clone` so callers (e.g. eda-mna's
158    /// `SensitivityContext`) can spin up N independent executor
159    /// copies for thread-parallel dispatch without paying the full
160    /// graph-compile cost N times. Default implementation panics;
161    /// backends that support cloning override.
162    fn clone_box(&self) -> Box<dyn ExecutableGraph> {
163        panic!("clone_box not implemented for this backend");
164    }
165
166    /// Execute the graph with named inputs. Returns output data (copies from arena).
167    fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>>;
168
169    /// Like [`Self::run`] but only read back outputs at `read_indices`.
170    /// GPU handle feeds still update for every output. Default: all outputs.
171    fn run_read_outputs(
172        &mut self,
173        inputs: &[(&str, &[f32])],
174        read_indices: Option<&[usize]>,
175    ) -> Vec<Vec<f32>> {
176        match read_indices {
177            None => self.run(inputs),
178            Some(ix) => {
179                // Backends without a native partial-read path still run the full
180                // graph; only clone the requested outputs on the host.
181                let all = self.run(inputs);
182                ix.iter().filter_map(|&i| all.get(i).cloned()).collect()
183            }
184        }
185    }
186
187    /// Execute and return raw pointers to output data in arena (zero-copy).
188    fn run_raw(&mut self, inputs: &[(&str, &[f32])]) -> Vec<(*const f32, usize)> {
189        let vecs = self.run(inputs);
190        vecs.iter().map(|v| (v.as_ptr(), v.len())).collect()
191    }
192
193    /// Fastest: inputs by slot index, returns output (offset, len) pairs.
194    /// Read output from arena via `arena_ptr().add(offset)`.
195    fn run_slots(&mut self, _inputs: &[&[f32]]) -> &[(usize, usize)] {
196        &[] // default: not supported
197    }
198
199    /// Get the raw arena buffer pointer for reading outputs after run_slots.
200    fn arena_ptr(&self) -> *const u8 {
201        std::ptr::null()
202    }
203
204    /// Hint the executor that subsequent `run` calls should process
205    /// only the first `actual` rows along the bucket axis (out of
206    /// `upper`, the extent the graph was compiled at). Backends that
207    /// support per-kernel active-extent dispatch honor this; others
208    /// ignore it and process the full compiled extent.
209    ///
210    /// Pass `None` to clear the hint. The hint is sticky — set it
211    /// before each `run` and clear it after, or maintain it across
212    /// runs at your discretion.
213    ///
214    /// Even when honored, callers must not rely on the contents of the
215    /// output past `actual` rows — that region may contain stale data
216    /// from earlier runs (kernels skip it).
217    ///
218    /// Default: no-op. See `BucketedCompileCache::run_padded` for the
219    /// canonical caller; backends opt in by overriding this method.
220    fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
221        let _ = extent;
222    }
223
224    /// Override RNG policy for in-graph random ops without recompiling.
225    fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
226        let _ = rng;
227    }
228
229    /// Current RNG policy (default when the backend does not override).
230    fn rng(&self) -> rlx_ir::RngOptions {
231        rlx_ir::RngOptions::default()
232    }
233
234    /// TIDE merged placement mask (union across MoE layers). CPU: stats + host path.
235    fn set_moe_resident_experts(&mut self, _mask: &[bool]) {}
236
237    /// Per MoE layer placement (`masks[layer][expert]`). Preferred over merged on CPU.
238    fn set_moe_resident_experts_per_layer(&mut self, _masks: &[&[bool]]) {}
239
240    /// Capture MoE router TopK indices on the next CPU forward (TIDE refresh).
241    fn enable_moe_topk_capture(&mut self, _num_experts: usize) -> bool {
242        false
243    }
244
245    /// Take captured per-layer expert indices (one vec per MoE TopK in order).
246    fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
247        None
248    }
249
250    /// MoE GroupedMatMul residency accounting from the last forward (CPU).
251    fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
252        None
253    }
254
255    /// Bind a persistent buffer handle (KV-cache, training state, etc.).
256    /// The buffer lives across run() calls and is not in the arena.
257    /// Returns true if the backend supports persistent handles.
258    fn bind_handle(&mut self, _name: &str, _data: &[f32]) -> bool {
259        false
260    }
261
262    /// Read a persistent buffer's current contents.
263    fn read_handle(&self, _name: &str) -> Option<Vec<f32>> {
264        None
265    }
266
267    /// GPU-resident input (MLX): upload once, reuse across runs.
268    fn bind_gpu_handle(&mut self, _name: &str, _data: &[f32]) -> bool {
269        false
270    }
271
272    fn has_gpu_handle(&self, _name: &str) -> bool {
273        false
274    }
275
276    fn set_gpu_handle_feed(&mut self, _handle_name: &str, _output_index: usize) -> bool {
277        false
278    }
279
280    fn read_gpu_handle(&self, _name: &str) -> Option<Vec<f32>> {
281        None
282    }
283
284    /// Register a targeted *row* feed for resident KV decode (graphs that emit
285    /// the new token at the last bucket-padded output row). Returns false when
286    /// the backend has no GPU-resident handle support. See [`feed_kv_row`].
287    fn register_kv_row_feed(&mut self, _handle_name: &str, _output_index: usize) -> bool {
288        false
289    }
290
291    /// Fold each registered row feed's new-token row (`src_row` of its output)
292    /// into the resident handle slot at `dst_row` (`row_elems` = kv_dim),
293    /// in-place on device. Call after a logits-only run. Returns false when
294    /// unsupported (caller keeps the host KV path).
295    fn feed_kv_row(&mut self, _src_row: usize, _dst_row: usize, _row_elems: usize) -> bool {
296        false
297    }
298
299    /// Read one row from a row-major graph output after `run` / `run_read_outputs`.
300    /// Metal reads a single row from the arena; default returns `None` (caller falls back).
301    fn read_output_row(&self, _out_idx: usize, _row: usize, _row_inner: usize) -> Option<Vec<f32>> {
302        None
303    }
304
305    /// Run and refresh a GPU handle from `output_index`; returns that output on host.
306    fn run_feed_gpu_handle(
307        &mut self,
308        inputs: &[(&str, &[f32])],
309        _handle_name: &str,
310        _output_index: usize,
311    ) -> Option<Vec<f32>> {
312        let _ = inputs;
313        None
314    }
315
316    // ── Pipelined / async execution (Phase C) ─────────────────────────
317    //
318    // These allow callers to amortize per-run sync latency on backends
319    // where it matters (Metal: ~150 µs `wait_until_completed` per commit).
320    // CPU has no such cost, so the default impls just call `run` serially.
321
322    /// Encode + commit a forward pass without waiting for completion.
323    ///
324    /// Outputs of intermediate calls are stomped — use `run_pipelined` if
325    /// you need outputs from each individual commit. Pair with
326    /// `sync_pending` to drain.
327    ///
328    /// Default: synchronous fallback (calls `run`, discards output). CPU
329    /// uses this default since BLAS is synchronous anyway.
330    fn commit_no_wait(&mut self, inputs: &[(&str, &[f32])]) {
331        let _ = self.run(inputs);
332    }
333
334    /// Wait for every command queued by `commit_no_wait`.
335    /// Default: no-op (synchronous backends have nothing pending).
336    fn sync_pending(&mut self) {}
337
338    /// Issue a batch of forward passes pipelined, returning per-run outputs.
339    ///
340    /// The Metal impl encodes a per-commit blit so each in-flight run's
341    /// outputs survive subsequent commits stomping the shared arena. The
342    /// CPU default is just sequential `run`s — equally correct, no perf
343    /// penalty (CPU has no GPU sync cost to amortize).
344    ///
345    /// Returns `out[run_idx][output_idx][element_idx]`.
346    fn run_pipelined(&mut self, input_sets: &[Vec<(&str, &[f32])>]) -> Vec<Vec<Vec<f32>>> {
347        input_sets.iter().map(|inputs| self.run(inputs)).collect()
348    }
349
350    // ── Typed (non-F32) host I/O ──────────────────────────────────
351    //
352    // `set_param` and `run` are F32 by contract. The typed entry
353    // points let callers pass and receive raw bytes in any rlx-ir
354    // dtype, avoiding the f32 widen/narrow round-trip that's
355    // wasteful for F16/BF16 weights and activations.
356    //
357    // The default impls only handle F32 — any other dtype panics.
358    // Backends that support typed I/O natively (e.g. MLX via
359    // Array::from_bytes/to_bytes) override these.
360
361    /// Set a named parameter from raw bytes in the given dtype.
362    fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
363        if dtype != rlx_ir::DType::F32 {
364            panic!(
365                "backend's default set_param_typed only handles F32; \
366                    got {dtype:?}. Override on the backend for typed support."
367            );
368        }
369        if !data.len().is_multiple_of(4) {
370            panic!(
371                "set_param_typed F32: data length {} not a multiple of 4",
372                data.len()
373            );
374        }
375        // SAFETY: F32 bytes are 4-aligned by source convention; we
376        // only widen access (read &[f32] from owned &[u8]). Failure
377        // mode if a caller hands us mis-aligned bytes is undefined,
378        // hence the % 4 length check.
379        let n = data.len() / 4;
380        let f32_slice = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
381        self.set_param(name, f32_slice);
382    }
383
384    /// Run with typed inputs and typed outputs. Returns
385    /// `(bytes, dtype)` per output; the dtype is whatever the
386    /// graph's output node was declared as.
387    fn run_typed(
388        &mut self,
389        inputs: &[(&str, &[u8], rlx_ir::DType)],
390    ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
391        // Default impl: convert each typed input to f32 (F32-only),
392        // run, then re-emit outputs as F32 bytes.
393        let mut owned: Vec<(String, Vec<f32>)> = Vec::with_capacity(inputs.len());
394        for (name, data, dt) in inputs {
395            if *dt != rlx_ir::DType::F32 {
396                panic!(
397                    "backend's default run_typed only handles F32 inputs; \
398                        got {dt:?} for input '{name}'"
399                );
400            }
401            if data.len() % 4 != 0 {
402                panic!(
403                    "run_typed F32 input '{name}': len {} not multiple of 4",
404                    data.len()
405                );
406            }
407            let n = data.len() / 4;
408            let v: Vec<f32> =
409                unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) }.to_vec();
410            owned.push((name.to_string(), v));
411        }
412        let refs: Vec<(&str, &[f32])> = owned
413            .iter()
414            .map(|(n, d)| (n.as_str(), d.as_slice()))
415            .collect();
416        let outs = self.run(&refs);
417        outs.into_iter()
418            .map(|v| {
419                let bytes =
420                    unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * 4) }
421                        .to_vec();
422                (bytes, rlx_ir::DType::F32)
423            })
424            .collect()
425    }
426}
427
428/// Backend implementation trait.
429///
430/// Single compile entry point. New compile-time knobs are added to
431/// `CompileOptions`, not as new trait methods.
432///
433/// `Send + Sync` because backends are stateless factories — multiple
434/// threads can call `compile` concurrently. The returned
435/// `Box<dyn ExecutableGraph>` is `Send` (moveable to a worker thread)
436/// but **not** `Sync` (`run`/`run_slots` take `&mut self`).
437pub trait Backend: Send + Sync {
438    /// Compile a graph for this backend with the given options.
439    fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph>;
440
441    /// Compile pre-optimized LIR (HIR → MIR → LIR pipeline output).
442    /// Default re-enters [`Self::compile`] — backends should override
443    /// when they can reuse the embedded buffer plan.
444    fn compile_lir(&self, lir: LirModule, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
445        self.compile(lir.into_graph(), options)
446    }
447
448    /// HIR-first compile: lower blocks, run fusion pipeline, emit executable.
449    fn compile_hir(
450        &self,
451        hir: HirModule,
452        device: rlx_driver::Device,
453        options: &CompileOptions,
454    ) -> Result<Box<dyn ExecutableGraph>, rlx_ir::hir::LowerError> {
455        let result = crate::stages::compile_hir_stages(device, hir, options)?;
456        crate::stages::maybe_log_fusion(&result.fusion);
457        Ok(self.compile_lir(result.lir, options))
458    }
459
460    /// [`GraphModule`] compile — unified HIR/MIR/LIR entry.
461    fn compile_module(
462        &self,
463        module: rlx_ir::GraphModule,
464        device: rlx_driver::Device,
465        options: &CompileOptions,
466    ) -> Result<Box<dyn ExecutableGraph>, rlx_ir::hir::LowerError> {
467        let result = crate::stages::compile_module_stages(device, module, options)?;
468        crate::stages::maybe_log_fusion(&result.fusion);
469        Ok(self.compile_lir(result.lir, options))
470    }
471
472    /// PLAN L4: declare which `OpKind`s this backend can lower.
473    /// Default: empty slice = "no claim made — accept everything"
474    /// (preserves existing behavior; backends opt in by overriding).
475    /// When non-empty, the `LegalizeForBackend` pass will refuse to
476    /// compile a graph that contains an op outside this set, instead
477    /// of silently falling through to slower / wrong dispatch.
478    fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
479        &[]
480    }
481}
482
483/// Prepare a fused MIR graph from LIR for backend executable construction.
484/// Skips the fusion pipeline — LIR must come from `compile_*_stages`.
485#[allow(dead_code)]
486fn prepare_fused_graph(
487    graph: Graph,
488    options: &CompileOptions,
489    supported_ops: &[rlx_ir::OpKind],
490    backend_name: &str,
491) -> Graph {
492    let (mut graph, report) = rlx_opt::prepare_graph_for_backend_with_report(
493        graph,
494        backend_name,
495        supported_ops,
496        options.kernel_dispatch,
497    );
498    rlx_opt::maybe_log_dispatch_report(&report);
499    if !report.compile_ready {
500        panic!(
501            "{}\n{}",
502            rlx_opt::format_legalize_error(backend_name, &report.still_unsupported),
503            rlx_opt::format_dispatch_report(&report)
504        );
505    }
506    graph = crate::precompile::post_fusion_cleanup(graph, options);
507    if let Some(p) = options.policy.clone() {
508        use rlx_opt::pass::Pass as _;
509        graph = rlx_opt::AutoMixedPrecision::new(p).run(graph);
510    }
511    graph
512}
513
514#[allow(dead_code)]
515fn declared_output_dtypes(
516    manifest: &cpu_low_precision::IoDtypeManifest,
517    exec_dtypes: Vec<rlx_ir::DType>,
518) -> Vec<rlx_ir::DType> {
519    exec_dtypes
520        .into_iter()
521        .enumerate()
522        .map(|(i, exec)| manifest.output_dtype(i, exec))
523        .collect()
524}
525
526// ── Convenience helpers preserved from older API ──────────────────────
527//
528// These let existing call sites keep working unchanged while the new
529// trait is the canonical one. We provide free functions rather than
530// trait methods so adding them doesn't grow the trait surface.
531
532/// Compile at default options (F32, no policy).
533pub fn compile(backend: &dyn Backend, graph: Graph) -> Box<dyn ExecutableGraph> {
534    backend.compile(graph, &CompileOptions::default())
535}
536
537/// Compile HIR through the fusion-first pipeline.
538pub fn compile_hir(
539    backend: &dyn Backend,
540    hir: HirModule,
541    device: rlx_driver::Device,
542    options: &CompileOptions,
543) -> Result<Box<dyn ExecutableGraph>, rlx_ir::hir::LowerError> {
544    backend.compile_hir(hir, device, options)
545}
546
547/// Compile a [`GraphModule`] through the fusion-first pipeline.
548pub fn compile_module(
549    backend: &dyn Backend,
550    module: rlx_ir::GraphModule,
551    device: rlx_driver::Device,
552    options: &CompileOptions,
553) -> Result<Box<dyn ExecutableGraph>, rlx_ir::hir::LowerError> {
554    backend.compile_module(module, device, options)
555}
556
557/// Compile at a specific precision (default policy = none).
558pub fn compile_with_precision(
559    backend: &dyn Backend,
560    graph: Graph,
561    precision: crate::Precision,
562) -> Box<dyn ExecutableGraph> {
563    backend.compile(graph, &CompileOptions::new().precision(precision))
564}
565
566/// Helper retained for backward compatibility — applies the precision
567/// rewrite at the runtime layer if backends don't override their
568/// pipeline placement. Modern code: pass the policy via CompileOptions
569/// and let the backend handle ordering.
570fn _legacy_apply_policy(graph: Graph, policy: Option<rlx_opt::PrecisionPolicy>) -> Graph {
571    use rlx_opt::pass::Pass as _;
572    match policy {
573        Some(p) => rlx_opt::AutoMixedPrecision::new(p).run(graph),
574        None => graph,
575    }
576}
577
578// ── CPU Backend ─────────────────────────────────────────────────────────
579
580#[cfg(feature = "cpu")]
581pub mod cpu_backend {
582    use super::*;
583    use rlx_cpu::{arena::Arena, thunk};
584    use rlx_ir::{DType, NodeId, Op};
585    use rlx_opt::memory::{self, MemoryPlan};
586    // Arena typed read/write helpers live in `crate::arena` so every
587    // backend (CPU, Metal, future CUDA/wgpu/WASM) shares one implementation.
588    use rlx_driver::arena::{read_typed_to_f32, write_typed_from_f32};
589
590    pub struct CpuBackend;
591
592    /// PLAN L4: ops the CPU backend can lower today. Includes
593    /// DotGeneral (lowered via `LowerDotGeneral` pass) and
594    /// ElementwiseRegion (lowered natively per L2). Excludes
595    /// FusedTransformerLayer / If / While — those have IR variants
596    /// but no CPU lowering yet (see `compile_thunks` arm absence +
597    /// `subgraph.rs` "If/While executor wiring is pending" note).
598    const CPU_SUPPORTED_OPS: &[rlx_ir::OpKind] = {
599        use rlx_ir::OpKind::*;
600        &[
601            Input,
602            Param,
603            Constant,
604            Activation,
605            Cast,
606            StopGradient,
607            Binary,
608            Compare,
609            Where,
610            Fma,
611            ElementwiseRegion,
612            MatMul,
613            DotGeneral,
614            DenseSolve,
615            BatchedDenseSolve,
616            Scan,
617            ScanBackward,
618            ScanBackwardXs,
619            LayerNorm,
620            LayerNorm2d,
621            GroupNorm,
622            BatchNormInference,
623            RmsNorm,
624            ResizeNearest2x,
625            AxialRope2d,
626            Attention,
627            Rope,
628            Reshape,
629            Transpose,
630            Narrow,
631            Concat,
632            Expand,
633            Gather,
634            Reverse,
635            Reduce,
636            Softmax,
637            Cumsum,
638            ArgMax,
639            ArgMin,
640            TopK,
641            Sample,
642            RngNormal,
643            RngUniform,
644            Conv,
645            Im2Col,
646            ConvTranspose2d,
647            Pool,
648            GroupedMatMul,
649            DequantGroupedMatMul,
650            DequantMoEWeights,
651            ScatterAdd,
652            LoraMatMul,
653            DequantMatMul,
654            ScaledMatMul,
655            ScaledQuantize,
656            ScaledQuantScale,
657            ScaledDequantize,
658            SelectiveScan,
659            GatedDeltaNet,
660            Lstm,
661            Gru,
662            Rnn,
663            Mamba2,
664            FusedSwiGLU,
665            FusedMatMulBiasAct,
666            FusedResidualLN,
667            FusedResidualRmsNorm,
668            FusedAttentionBlock,
669            // Backward ops emitted by `rlx_opt::autodiff::grad_with_loss`.
670            // Their thunks live in rlx-cpu/src/thunk.rs alongside the
671            // forward kernels; without these entries the legalize step
672            // below would reject any compiled gradient graph.
673            ReluBackward,
674            ActivationBackward,
675            FakeQuantize,
676            FakeQuantizeBackward,
677            // LSQ (learned step size) QAT — native CPU thunks in thunk.rs.
678            FakeQuantizeLSQ,
679            FakeQuantizeLSQBackwardX,
680            FakeQuantizeLSQBackwardScale,
681            MaxPool2dBackward,
682            Conv2dBackwardInput,
683            Conv2dBackwardWeight,
684            SoftmaxCrossEntropy,
685            SoftmaxCrossEntropyWithLogits,
686            SoftmaxCrossEntropyBackward,
687            AttentionBackward,
688            LayerNormBackwardInput,
689            LayerNormBackwardGamma,
690            BatchNormInferenceBackwardInput,
691            BatchNormInferenceBackwardGamma,
692            BatchNormInferenceBackwardBeta,
693            // GroupNorm backward (native thunks in rlx-cpu/training_bwd):
694            GroupNormBackwardInput,
695            GroupNormBackwardGamma,
696            GroupNormBackwardBeta,
697            RmsNormBackwardInput,
698            RmsNormBackwardGamma,
699            RmsNormBackwardBeta,
700            RopeBackward,
701            CumsumBackward,
702            GatherBackward,
703            // 3D Gaussian splat CPU reference render/backward (requires `rlx-cpu/splat`).
704            GaussianSplatRender,
705            GaussianSplatRenderBackward,
706            GaussianSplatPrepare,
707            GaussianSplatRasterize,
708            // User-registered custom ops dispatched through
709            // `rlx_cpu::op_registry`. Lowering panics with a clear
710            // message if the named CPU kernel isn't registered.
711            Custom,
712            // User-defined sub-graph with optional override AD rules
713            // (JAX-shaped custom_vjp / custom_jvp). Body is a regular
714            // Graph compiled recursively in compile_thunks.
715            CustomFn,
716            // FFT primitive (1D last-axis, 2N real-block layout, f64
717            // power-of-2 sizes). Other backends panic at lowering;
718            // pin FFT-containing graphs to Device::Cpu for now.
719            Fft,
720            FftButterflyStage,
721            LogMel,
722            LogMelBackward,
723            WelchPeaks,
724            // C64 Wirtinger AD surface. ComplexNormSq is the canonical
725            // real-valued loss for complex inputs; Conjugate is emitted
726            // by the new Wirtinger VJP rules for BinaryOp::Mul/Div on
727            // C64. Both have CPU thunks in rlx-cpu.
728            ComplexNormSq,
729            ComplexNormSqBackward,
730            Conjugate,
731        ]
732    };
733
734    impl Backend for CpuBackend {
735        fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
736            CPU_SUPPORTED_OPS
737        }
738
739        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
740            use rlx_opt::pass::Pass as _;
741            static ONNX_KERNELS: std::sync::Once = std::sync::Once::new();
742            ONNX_KERNELS.call_once(rlx_cpu::onnx_ref::register_onnx_reference_kernels);
743            // Lower Op::If / Op::While to primitives BEFORE legalize
744            // so the supported-op check doesn't reject them — the CPU
745            // backend has no native sub-graph executor; this rewrite
746            // makes If/While invisible to the rest of the pipeline.
747            // No-op when neither op is in the graph.
748            let graph = rlx_opt::LowerControlFlow.run(graph);
749            // PLAN L4: legalize against the backend's claimed op set
750            // BEFORE running fusion (so the diagnostic points at the
751            // user's IR, not at a fused-away node).
752            if let Err(errors) = rlx_opt::legalize_for_backend(&graph, CPU_SUPPORTED_OPS) {
753                panic!("{}", rlx_opt::format_legalize_error("cpu", &errors));
754            }
755            let policy = options.policy.clone();
756            let _precision = options.precision;
757            let cfg = rlx_cpu::config::RuntimeConfig::global();
758
759            let graph = crate::precompile::precompile_cleanup(graph, options);
760
761            // Run fusion pipeline (HIR/MIR/LIR ideology — fusion is first-class).
762            let mut compile_opts = options.clone();
763            compile_opts.arena_alignment = cfg.arena_alignment;
764            let compile_result = crate::stages::compile_graph_stages_for_backend(
765                rlx_driver::Device::Cpu,
766                graph,
767                &compile_opts,
768                CPU_SUPPORTED_OPS,
769            );
770            crate::stages::maybe_log_fusion(&compile_result.fusion);
771            let fused = compile_result.lir.into_graph();
772
773            // Apply precision policy AFTER fusion — Cast nodes don't disrupt
774            // the now-flattened fused ops.
775            let fused = match policy {
776                Some(p) => rlx_opt::AutoMixedPrecision::new(p).run(fused),
777                None => fused,
778            };
779
780            let io_manifest = cpu_low_precision::IoDtypeManifest::from_graph(&fused);
781            let exec_graph = if cpu_low_precision::needs_f32_exec(&fused) {
782                cpu_low_precision::promote_to_f32(fused)
783            } else {
784                fused
785            };
786
787            // Re-plan after precision rewrites (may change dtypes / sizes).
788            let plan = memory::plan_memory_aligned(&exec_graph, cfg.arena_alignment);
789            if cfg.verbose >= 1 {
790                eprintln!(
791                    "[rlx] arena: {} bytes, {} buffers, alignment: {}",
792                    plan.arena_size,
793                    plan.assignments.len(),
794                    cfg.arena_alignment
795                );
796            }
797            Box::new(build_cpu_executable(
798                exec_graph,
799                plan,
800                io_manifest,
801                options.rng,
802            ))
803        }
804
805        fn compile_lir(
806            &self,
807            lir: LirModule,
808            options: &CompileOptions,
809        ) -> Box<dyn ExecutableGraph> {
810            let alignment = lir.buffers.alignment.max(options.arena_alignment);
811            let mut graph = lir.into_graph();
812            {
813                use rlx_opt::pass::Pass as _;
814                graph = rlx_opt::LegalizeBroadcast.run(graph);
815            }
816            if let Some(p) = options.policy.clone() {
817                use rlx_opt::pass::Pass;
818                graph = rlx_opt::AutoMixedPrecision::new(p).run(graph);
819            }
820            let io_manifest = cpu_low_precision::IoDtypeManifest::from_graph(&graph);
821            let promote = cpu_low_precision::needs_f32_exec(&graph);
822            let exec_graph = if promote {
823                cpu_low_precision::promote_to_f32(graph)
824            } else {
825                graph
826            };
827            // LegalizeBroadcast may insert Expand nodes — must replan; the
828            // embedded LIR buffer map is from before legalization.
829            let plan = memory::plan_memory_aligned(&exec_graph, alignment);
830            let cfg = rlx_cpu::config::RuntimeConfig::global();
831            if cfg.verbose >= 1 {
832                eprintln!(
833                    "[rlx] compile_lir: arena {} bytes ({} buffers, alignment {})",
834                    plan.arena_size,
835                    plan.assignments.len(),
836                    alignment,
837                );
838            }
839            Box::new(build_cpu_executable(
840                exec_graph,
841                plan,
842                io_manifest,
843                options.rng,
844            ))
845        }
846    }
847
848    fn build_cpu_executable(
849        graph: Graph,
850        plan: MemoryPlan,
851        io_manifest: cpu_low_precision::IoDtypeManifest,
852        rng: rlx_ir::RngOptions,
853    ) -> CpuExecutable {
854        let mut arena = Arena::from_plan(plan);
855        let mut input_ids = HashMap::new();
856        let mut param_ids = HashMap::new();
857        let mut node_dtypes: HashMap<NodeId, DType> = HashMap::new();
858        for node in graph.nodes() {
859            node_dtypes.insert(node.id, node.shape.dtype());
860            match &node.op {
861                Op::Input { name } => {
862                    input_ids.insert(name.clone(), node.id);
863                }
864                Op::Param { name } => {
865                    param_ids.insert(name.clone(), node.id);
866                }
867                _ => {}
868            }
869        }
870
871        let schedule = thunk::compile_thunks_with_rng(&graph, &arena, rng);
872
873        let mut input_slots = Vec::new();
874        for node in graph.nodes() {
875            if let Op::Input { name } = &node.op {
876                let off = arena.byte_offset(node.id);
877                let len = node.shape.num_elements().unwrap_or(0);
878                input_slots.push((name.clone(), off, len, node.shape.dtype()));
879            }
880        }
881
882        let output_slots: Vec<(usize, usize)> = graph
883            .outputs
884            .iter()
885            .map(|&id| {
886                let off = arena.byte_offset(id);
887                let len = graph.node(id).shape.num_elements().unwrap_or(0);
888                (off, len)
889            })
890            .collect();
891
892        for node in graph.nodes() {
893            if let Op::Constant { data } = &node.op
894                && arena.has_buffer(node.id)
895                && !data.is_empty()
896            {
897                match node.shape.dtype() {
898                    // True-width dtypes (their arena slot is sized to the real
899                    // element width, not f32): copy the raw bytes. I64/I32/U32
900                    // constants were previously caught by the f32-reinterpret
901                    // branch below, which read them in 4-byte chunks as f32 —
902                    // corrupting e.g. the VITS sequence-mask `arange` constant
903                    // (i64 [0..T-1]) so the downstream i64 `Compare` read garbage.
904                    DType::F64
905                    | DType::F16
906                    | DType::BF16
907                    | DType::I64
908                    | DType::I32
909                    | DType::U32 => {
910                        let off = arena.byte_offset(node.id);
911                        let buf = arena.raw_buf_mut();
912                        let n = buf.len().saturating_sub(off).min(data.len());
913                        buf[off..off + n].copy_from_slice(&data[..n]);
914                    }
915                    _ => {
916                        let buf = arena.slice_mut(node.id);
917                        let n_floats = data.len() / 4;
918                        let n = buf.len().min(n_floats);
919                        for i in 0..n {
920                            let bytes = [
921                                data[i * 4],
922                                data[i * 4 + 1],
923                                data[i * 4 + 2],
924                                data[i * 4 + 3],
925                            ];
926                            buf[i] = f32::from_le_bytes(bytes);
927                        }
928                    }
929                }
930            }
931        }
932
933        CpuExecutable {
934            graph,
935            arena,
936            input_ids,
937            param_ids,
938            node_dtypes,
939            io_manifest,
940            schedule,
941            input_slots,
942            output_slots,
943            handles: HashMap::new(),
944            active_extent: None,
945            moe_resident: None,
946            moe_resident_layers: None,
947            moe_topk_capture: None,
948            baseline_written: false,
949        }
950    }
951
952    #[derive(Clone)]
953    struct CpuExecutable {
954        graph: Graph,
955        arena: Arena,
956        input_ids: HashMap<String, NodeId>,
957        param_ids: HashMap<String, NodeId>,
958        /// Per-node arena dtype. Lets set_param/run cast f32 ↔ F16/BF16
959        /// when AutoMixedPrecision has rewritten the graph.
960        node_dtypes: HashMap<NodeId, DType>,
961        /// User-facing boundary dtypes (before f32 promotion for CPU exec).
962        io_manifest: cpu_low_precision::IoDtypeManifest,
963        schedule: thunk::ThunkSchedule,
964        // Pre-resolved: ordered list of (input_name, arena_byte_offset, max_elems, dtype)
965        input_slots: Vec<(String, usize, usize, DType)>,
966        /// Output (byte_offset, num_elements). dtype is in node_dtypes.
967        output_slots: Vec<(usize, usize)>,
968        /// Persistent buffer handles (KV-cache, optimizer state, etc.).
969        /// Lives outside the arena and survives across run() calls.
970        /// On run(): if a handle's name matches a graph input, the
971        /// handle's data is used as the input.
972        handles: HashMap<String, Vec<f32>>,
973        /// Active-extent hint (`Some((actual, upper))`) for L1 bucketed
974        /// dispatch. When set AND every thunk in the schedule is in
975        /// `Thunk::safe_for_active_extent`, the executor processes only
976        /// `actual / upper` of each kernel's work. Otherwise (or when
977        /// `None`) runs at the full compiled extent. See PLAN L1.
978        active_extent: Option<(usize, usize)>,
979        moe_resident: Option<std::sync::Arc<[bool]>>,
980        moe_resident_layers: Option<std::sync::Arc<Vec<std::sync::Arc<[bool]>>>>,
981        moe_topk_capture: Option<std::sync::Arc<rlx_cpu::moe_topk_capture::MoeTopkCapture>>,
982        /// Whether params + constants are already resident in the arena. While
983        /// `true`, `restore_arena_baseline` zeros only the scratch buffers instead
984        /// of re-zeroing + rewriting every param each run (which is O(params) and
985        /// allocates a full params clone — catastrophic for multi-GB models).
986        /// `set_param`/`set_param_typed` reset it to `false`.
987        baseline_written: bool,
988    }
989
990    unsafe impl Send for CpuExecutable {}
991
992    impl CpuExecutable {
993        /// Write a f32 input slice into the arena, casting to the node's dtype.
994        fn write_input(&mut self, id: NodeId, data: &[f32]) {
995            let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
996            let off = self.arena.byte_offset(id);
997            let buf = self.arena.raw_buf_mut();
998            let elem_size = dtype.size_bytes();
999            let max_elems = (buf.len() - off) / elem_size;
1000            unsafe {
1001                write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
1002            }
1003        }
1004
1005        /// Read a node's arena bytes back as Vec<f32>, casting from its dtype.
1006        fn read_output(&self, id: NodeId) -> Vec<f32> {
1007            let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
1008            let off = self.arena.byte_offset(id);
1009            let buf = self.arena.raw_buf();
1010            let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
1011            unsafe { read_typed_to_f32(buf.as_ptr().add(off), dtype, n_elems) }
1012        }
1013    }
1014
1015    impl ExecutableGraph for CpuExecutable {
1016        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
1017            Box::new(self.clone())
1018        }
1019        fn set_param(&mut self, name: &str, data: &[f32]) {
1020            // Params live solely in the arena (dedicated, never-aliased slots, see
1021            // the memory planner) — no redundant CPU-side copy is kept, which would
1022            // double the weight footprint for multi-GB models.
1023            // Cast f32 → arena dtype when the param has been rewritten to F16/BF16.
1024            if let Some(&id) = self.param_ids.get(name)
1025                && self.arena.has_buffer(id)
1026            {
1027                let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
1028                let off = self.arena.byte_offset(id);
1029                let buf = self.arena.raw_buf_mut();
1030                let elem_size = dtype.size_bytes();
1031                let max_elems = (buf.len() - off) / elem_size;
1032                unsafe {
1033                    write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
1034                }
1035            }
1036        }
1037
1038        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
1039            self.restore_arena_baseline();
1040            // 1. Apply persistent handles first — they act like default inputs.
1041            //    Explicit `inputs` passed to run() override matching handle names.
1042            let handle_names: Vec<String> = self.handles.keys().cloned().collect();
1043            for name in &handle_names {
1044                if let Some(&id) = self.input_ids.get(name)
1045                    && self.arena.has_buffer(id)
1046                {
1047                    let data = self.handles.get(name).cloned().unwrap_or_default();
1048                    self.write_input(id, &data);
1049                }
1050            }
1051            // 2. Explicit per-call inputs override handles.
1052            for &(name, data) in inputs {
1053                if let Some(&id) = self.input_ids.get(name)
1054                    && self.arena.has_buffer(id)
1055                {
1056                    self.write_input(id, data);
1057                }
1058            }
1059
1060            // Active-extent fast-path (PLAN L1): if hinted AND every thunk
1061            // in the schedule supports it, run scaled. Otherwise fall back
1062            // to full-extent dispatch — preserves correctness when the
1063            // schedule contains a thunk that hasn't yet been wired in.
1064            let active_used = if let Some((actual, upper)) = self.active_extent {
1065                thunk::execute_thunks_active(
1066                    &self.schedule,
1067                    self.arena.raw_buf_mut(),
1068                    actual,
1069                    upper,
1070                )
1071            } else {
1072                false
1073            };
1074            if !active_used {
1075                // Execute via pre-compiled thunks (zero per-node dispatch overhead)
1076                thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
1077            }
1078
1079            // 3. Sync any handle whose name matches a graph OUTPUT —
1080            //    KV-cache pattern: outputs flow back into the same-named
1081            //    handle for the next iteration.
1082            for (idx, &out_id) in self.graph.outputs.iter().enumerate() {
1083                let name = format!("out{idx}");
1084                if self.handles.contains_key(&name) {
1085                    let v = self.read_output(out_id);
1086                    self.handles.insert(name, v);
1087                }
1088            }
1089
1090            self.graph
1091                .outputs
1092                .iter()
1093                .map(|&out_id| self.read_output(out_id))
1094                .collect()
1095        }
1096
1097        fn run_raw(&mut self, inputs: &[(&str, &[f32])]) -> Vec<(*const f32, usize)> {
1098            self.restore_arena_baseline();
1099            // Copy inputs by name (HashMap lookup), casting to arena dtype.
1100            for &(name, data) in inputs {
1101                if let Some(&id) = self.input_ids.get(name)
1102                    && self.arena.has_buffer(id)
1103                {
1104                    self.write_input(id, data);
1105                }
1106            }
1107            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
1108            // Note: pointers are raw arena bytes — for F16 outputs, callers
1109            // must read 2 bytes/elem, not 4. run() is the safe path for
1110            // mixed precision; run_raw() is only meaningful for F32.
1111            self.graph
1112                .outputs
1113                .iter()
1114                .map(|&out_id| {
1115                    let (ptr, len) = self.arena.raw_ptr(out_id);
1116                    (ptr as *const f32, len)
1117                })
1118                .collect()
1119        }
1120
1121        /// Fastest path: inputs by index (matching input_slots order), zero-copy output.
1122        /// No HashMap, no name matching, no Vec allocation. Casts f32 input
1123        /// to F16/BF16 if the input slot's dtype was rewritten.
1124        fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
1125            self.restore_arena_baseline();
1126            let buf = self.arena.raw_buf_mut();
1127            for (i, &data) in inputs.iter().enumerate() {
1128                if i < self.input_slots.len() {
1129                    let (_, off, max_len, dtype) = &self.input_slots[i];
1130                    unsafe {
1131                        write_typed_from_f32(buf.as_mut_ptr().add(*off), *dtype, data, *max_len);
1132                    }
1133                }
1134            }
1135            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
1136            &self.output_slots
1137        }
1138
1139        fn arena_ptr(&self) -> *const u8 {
1140            self.arena.raw_buf_mut_ptr()
1141        }
1142
1143        fn bind_handle(&mut self, name: &str, data: &[f32]) -> bool {
1144            // Persistent buffer: stored separately from arena, survives run().
1145            // If the name matches a graph input, run() will use this data
1146            // as the input. If the graph also writes back to this name (via
1147            // an output binding pattern), read_handle returns the latest.
1148            self.handles.insert(name.to_string(), data.to_vec());
1149            true
1150        }
1151
1152        fn read_handle(&self, name: &str) -> Option<Vec<f32>> {
1153            self.handles.get(name).cloned()
1154        }
1155
1156        fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
1157            self.active_extent = extent;
1158        }
1159
1160        fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
1161            *self.schedule.rng.write().unwrap() = rng;
1162        }
1163
1164        fn rng(&self) -> rlx_ir::RngOptions {
1165            *self.schedule.rng.read().unwrap()
1166        }
1167
1168        fn set_moe_resident_experts(&mut self, mask: &[bool]) {
1169            self.moe_resident_layers = None;
1170            self.schedule.moe_resident_layers = None;
1171            self.moe_resident = Some(Arc::from(mask));
1172            self.schedule.moe_resident = self.moe_resident.clone();
1173        }
1174
1175        fn set_moe_resident_experts_per_layer(&mut self, masks: &[&[bool]]) {
1176            self.moe_resident = None;
1177            self.schedule.moe_resident = None;
1178            let layers: Vec<Arc<[bool]>> = masks.iter().map(|m| Arc::from(*m)).collect();
1179            let arc = Arc::new(layers);
1180            self.moe_resident_layers = Some(arc.clone());
1181            self.schedule.moe_resident_layers = Some(arc);
1182        }
1183
1184        fn enable_moe_topk_capture(&mut self, num_experts: usize) -> bool {
1185            let cap = rlx_cpu::moe_topk_capture::MoeTopkCapture::new(num_experts);
1186            self.moe_topk_capture = Some(cap.clone());
1187            self.schedule.moe_topk_capture = Some(cap);
1188            true
1189        }
1190
1191        fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
1192            let cap = self.moe_topk_capture.as_ref()?;
1193            let layers = cap.take_layers();
1194            if layers.is_empty() {
1195                None
1196            } else {
1197                Some(layers)
1198            }
1199        }
1200
1201        fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
1202            rlx_cpu::moe_residency::take_last_forward_stats()
1203        }
1204
1205        /// Typed param upload. F32 / F16 / BF16 go through the existing
1206        /// widen-to-f32 path (the CPU arena is historically f32 with
1207        /// optional half-precision rewrite). F64 (and any future
1208        /// non-widenable dtype) lands directly in the arena as bytes —
1209        /// the f32 path would lose precision.
1210        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
1211            if matches!(dtype, DType::F64 | DType::I64 | DType::I32 | DType::U32) {
1212                self.set_param_bytes(name, data, dtype);
1213                return;
1214            }
1215            // U8 / I8 raw byte tensors: opaque storage for the GGUF
1216            // K-quant `Op::DequantMatMul` path (weights stay packed
1217            // in the arena). One arena byte = one element.
1218            if matches!(dtype, DType::U8 | DType::I8) {
1219                self.set_param_bytes(name, data, dtype);
1220                return;
1221            }
1222            if dtype == DType::F32 {
1223                let n = data.len() / 4;
1224                let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
1225                self.set_param(name, s);
1226            } else {
1227                let f32_buf = super::widen_bytes_to_f32(data, dtype);
1228                self.set_param(name, &f32_buf);
1229            }
1230        }
1231
1232        /// Typed run with mixed-dtype inputs/outputs.
1233        ///
1234        /// For each input: if its declared graph dtype matches the
1235        /// caller's bytes, we write directly into the arena (zero
1236        /// precision loss — F64 stays F64). For F32 with a half-precision
1237        /// arena rewrite, we widen as before. F16/BF16 callers go
1238        /// through the existing widen path.
1239        ///
1240        /// Outputs are read straight from the arena in the graph node's
1241        /// declared dtype — F64 outputs come back as 8 bytes/element,
1242        /// F32 as 4, etc.
1243        fn run_typed(
1244            &mut self,
1245            inputs: &[(&str, &[u8], rlx_ir::DType)],
1246        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
1247            // Decide: are *all* inputs F64? If so, use the direct-byte
1248            // path for everything and skip the f32 widening machinery
1249            // entirely. Mixed dtype graphs (F32 + F64) take the
1250            // per-input dispatch route below.
1251            let all_f64 = !inputs.is_empty() && inputs.iter().all(|(_, _, dt)| *dt == DType::F64);
1252
1253            if all_f64 {
1254                for (name, data, _) in inputs {
1255                    if let Some(&id) = self.input_ids.get(*name) {
1256                        if !self.arena.has_buffer(id) {
1257                            continue;
1258                        }
1259                        let off = self.arena.byte_offset(id);
1260                        let buf = self.arena.raw_buf_mut();
1261                        let n = data.len();
1262                        debug_assert!(
1263                            off + n <= buf.len(),
1264                            "run_typed: input '{name}' overflows arena slot"
1265                        );
1266                        buf[off..off + n].copy_from_slice(data);
1267                    }
1268                }
1269                thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
1270            } else {
1271                // Mixed-dtype path: dtypes that survive untouched
1272                // through the f32-aliased arena (F64, I32, I64, U32)
1273                // go in as bytes; F32 and the half-precision family
1274                // route through widen-to-f32 + run.
1275                let mut f32_owned: Vec<(String, Vec<f32>)> = Vec::new();
1276                for (name, data, dt) in inputs {
1277                    let direct = matches!(
1278                        *dt,
1279                        DType::F64 | DType::I32 | DType::I64 | DType::U32 | DType::C64
1280                    );
1281                    if direct {
1282                        if let Some(&id) = self.input_ids.get(*name) {
1283                            if !self.arena.has_buffer(id) {
1284                                continue;
1285                            }
1286                            let off = self.arena.byte_offset(id);
1287                            let buf = self.arena.raw_buf_mut();
1288                            buf[off..off + data.len()].copy_from_slice(data);
1289                        }
1290                    } else {
1291                        let v = super::widen_bytes_to_f32(data, *dt);
1292                        f32_owned.push((name.to_string(), v));
1293                    }
1294                }
1295                for (name, data) in &f32_owned {
1296                    if let Some(&id) = self.input_ids.get(name.as_str()) {
1297                        if self.arena.has_buffer(id) {
1298                            self.write_input(id, data);
1299                        }
1300                    }
1301                }
1302                let active_used = if let Some((actual, upper)) = self.active_extent {
1303                    thunk::execute_thunks_active(
1304                        &self.schedule,
1305                        self.arena.raw_buf_mut(),
1306                        actual,
1307                        upper,
1308                    )
1309                } else {
1310                    false
1311                };
1312                if !active_used {
1313                    thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
1314                }
1315            }
1316
1317            // Read outputs in declared boundary dtypes.
1318            self.graph
1319                .outputs
1320                .iter()
1321                .enumerate()
1322                .map(|(idx, &id)| {
1323                    let exec_dtype = self.graph.node(id).shape.dtype();
1324                    let declared = self.io_manifest.output_dtype(idx, exec_dtype);
1325                    if matches!(
1326                        exec_dtype,
1327                        DType::F64
1328                            | DType::F16
1329                            | DType::BF16
1330                            | DType::I32
1331                            | DType::I64
1332                            | DType::U32
1333                            | DType::C64
1334                    ) {
1335                        let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
1336                        let n_bytes = n_elems * exec_dtype.size_bytes();
1337                        let off = self.arena.byte_offset(id);
1338                        let bytes = self.arena.raw_buf()[off..off + n_bytes].to_vec();
1339                        return (bytes, declared);
1340                    }
1341                    let f32_vals = self.read_output(id);
1342                    if declared != exec_dtype {
1343                        return (super::narrow_f32_to_bytes(&f32_vals, declared), declared);
1344                    }
1345                    let bytes = f32_vals.iter().flat_map(|v| v.to_le_bytes()).collect();
1346                    (bytes, declared)
1347                })
1348                .collect()
1349        }
1350    }
1351
1352    impl CpuExecutable {
1353        /// Clear ephemeral (scratch) arena slots before each `run()`. Params are
1354        /// written into their dedicated, never-aliased arena slots by `set_param`
1355        /// and live for the whole execution, so they are NOT re-zeroed/rewritten
1356        /// here — only the intermediate buffers (which carry stale data from the
1357        /// previous pass) are zeroed. Compile-time constants are written once.
1358        ///
1359        /// This keeps the per-run cost O(scratch) instead of O(params): a previous
1360        /// version cloned + rewrote the entire (multi-GB) weight region every run,
1361        /// which made large models swap-thrash.
1362        fn restore_arena_baseline(&mut self) {
1363            // Persistent slots (params + constants) — never zeroed.
1364            let persistent: std::collections::HashSet<NodeId> = {
1365                let mut s: std::collections::HashSet<NodeId> =
1366                    self.param_ids.values().copied().collect();
1367                for node in self.graph.nodes() {
1368                    if matches!(node.op, Op::Constant { .. }) {
1369                        s.insert(node.id);
1370                    }
1371                }
1372                s
1373            };
1374
1375            // Write compile-time constants into the arena once (a fresh arena is
1376            // zero-initialized; params are already resident via set_param).
1377            if !self.baseline_written {
1378                let constants: Vec<(NodeId, DType, Vec<u8>)> = self
1379                    .graph
1380                    .nodes()
1381                    .iter()
1382                    .filter_map(|node| {
1383                        if let Op::Constant { data } = &node.op
1384                            && self.arena.has_buffer(node.id)
1385                            && !data.is_empty()
1386                        {
1387                            Some((node.id, node.shape.dtype(), data.clone()))
1388                        } else {
1389                            None
1390                        }
1391                    })
1392                    .collect();
1393                for (id, dtype, data) in constants {
1394                    self.write_constant_to_arena(id, dtype, &data);
1395                }
1396                self.baseline_written = true;
1397            }
1398
1399            // Zero everything EXCEPT the persistent (param + constant) byte ranges.
1400            //
1401            // We zero the *complement* of the persistent ranges rather than each
1402            // scratch node's exact byte span. That covers inter-slot padding and
1403            // arena gaps too — a kernel that over-reads its input into adjacent
1404            // alignment padding (common in SIMD reductions) would otherwise pick up
1405            // stale bytes from a previous run, since per-node zeroing only clears
1406            // `num_elements` and leaves the padding dirty. The cost stays O(arena −
1407            // params): for a 7B the params dominate the arena and are skipped, so
1408            // the swept region is tiny.
1409            let mut keep: Vec<(usize, usize)> = self
1410                .graph
1411                .nodes()
1412                .iter()
1413                .filter_map(|node| {
1414                    let id = node.id;
1415                    if !persistent.contains(&id) || !self.arena.has_buffer(id) {
1416                        return None;
1417                    }
1418                    let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
1419                    let nbytes = node.shape.num_elements().unwrap_or(0) * dtype.size_bytes();
1420                    let off = self.arena.byte_offset(id);
1421                    Some((off, off + nbytes))
1422                })
1423                .collect();
1424            keep.sort_unstable();
1425
1426            let buf = self.arena.raw_buf_mut();
1427            let len = buf.len();
1428            let mut cursor = 0usize;
1429            for (start, end) in keep {
1430                let start = start.min(len);
1431                if cursor < start {
1432                    buf[cursor..start].fill(0);
1433                }
1434                cursor = cursor.max(end.min(len));
1435            }
1436            if cursor < len {
1437                buf[cursor..len].fill(0);
1438            }
1439        }
1440
1441        fn write_constant_to_arena(&mut self, id: NodeId, dtype: DType, data: &[u8]) {
1442            match dtype {
1443                DType::F64 | DType::F16 | DType::BF16 | DType::U8 | DType::I8 => {
1444                    let off = self.arena.byte_offset(id);
1445                    let buf = self.arena.raw_buf_mut();
1446                    let n = buf.len().saturating_sub(off).min(data.len());
1447                    buf[off..off + n].copy_from_slice(&data[..n]);
1448                }
1449                _ => {
1450                    let buf = self.arena.slice_mut(id);
1451                    let n_floats = data.len() / 4;
1452                    let n = buf.len().min(n_floats);
1453                    for i in 0..n {
1454                        let bytes = [
1455                            data[i * 4],
1456                            data[i * 4 + 1],
1457                            data[i * 4 + 2],
1458                            data[i * 4 + 3],
1459                        ];
1460                        buf[i] = f32::from_le_bytes(bytes);
1461                    }
1462                }
1463            }
1464        }
1465
1466        /// Direct-byte param upload — copies caller's bytes into the
1467        /// arena slot for the named param without any dtype conversion.
1468        /// Used by `set_param_typed` for dtypes that f32-widening would
1469        /// corrupt (F64). Caller is responsible for matching the param's
1470        /// declared graph dtype.
1471        fn set_param_bytes(&mut self, name: &str, data: &[u8], _dtype: rlx_ir::DType) {
1472            // Byte-backed params also live solely in the arena (no CPU-side copy).
1473            self.write_param_bytes_to_arena(name, data);
1474        }
1475
1476        fn write_param_bytes_to_arena(&mut self, name: &str, data: &[u8]) {
1477            if let Some(&id) = self.param_ids.get(name)
1478                && self.arena.has_buffer(id)
1479            {
1480                let off = self.arena.byte_offset(id);
1481                let buf = self.arena.raw_buf_mut();
1482                debug_assert!(
1483                    off + data.len() <= buf.len(),
1484                    "set_param_bytes: '{name}' would overflow arena slot"
1485                );
1486                buf[off..off + data.len()].copy_from_slice(data);
1487            }
1488        }
1489    }
1490}
1491
1492// ── Metal Backend ───────────────────────────────────────────────────────
1493
1494// ── wgpu Backend ────────────────────────────────────────────────────────
1495
1496#[cfg(feature = "gpu")]
1497pub mod wgpu_backend {
1498    use super::*;
1499    use rlx_ir::OpKind;
1500    use rlx_wgpu::backend::WgpuExecutable;
1501
1502    pub struct WgpuBackend;
1503
1504    /// PLAN L4: ops the wgpu backend can lower today. The fused
1505    /// macro-kernels (FAB, FTL, FusedSwiGLU) get decomposed by
1506    /// `crate::unfuse::unfuse` upstream — they're listed here too so
1507    /// graphs that already contain them legalize cleanly. Conv1d/3d
1508    /// and Pool1d/3d are deferred (Conv2d only).
1509    const WGPU_SUPPORTED_OPS: &[OpKind] = &[
1510        OpKind::Input,
1511        OpKind::Param,
1512        OpKind::Constant,
1513        OpKind::Activation,
1514        OpKind::Cast,
1515        OpKind::StopGradient,
1516        OpKind::Binary,
1517        OpKind::Compare,
1518        OpKind::Where,
1519        OpKind::Fma,
1520        OpKind::ElementwiseRegion,
1521        OpKind::TransformRegion,
1522        OpKind::BatchElementwiseRegion,
1523        OpKind::MatMul,
1524        OpKind::DotGeneral,
1525        OpKind::LayerNorm,
1526        OpKind::LayerNorm2d,
1527        OpKind::GroupNorm,
1528        OpKind::ResizeNearest2x,
1529        OpKind::RmsNorm,
1530        OpKind::Attention,
1531        OpKind::AttentionBackward,
1532        OpKind::RmsNormBackwardInput,
1533        OpKind::RmsNormBackwardGamma,
1534        OpKind::RmsNormBackwardBeta,
1535        // LayerNorm backward family:
1536        //   * Input  — single workgroup-per-row fused kernel.
1537        //   * Gamma  — two-dispatch (partial + reduce) that uses a tail
1538        //              scratch zone in the arena to hold per-chunk
1539        //              partial sums; the reduce kernel sums them.
1540        // Both beat the autodiff-decomposed primitive chain.
1541        OpKind::LayerNormBackwardInput,
1542        OpKind::LayerNormBackwardGamma,
1543        OpKind::RopeBackward,
1544        OpKind::CumsumBackward,
1545        OpKind::GatherBackward,
1546        OpKind::Rope,
1547        OpKind::Reshape,
1548        OpKind::Transpose,
1549        OpKind::Narrow,
1550        OpKind::Concat,
1551        OpKind::Expand,
1552        OpKind::Gather,
1553        OpKind::Reverse,
1554        OpKind::Reduce,
1555        OpKind::Softmax,
1556        OpKind::SoftmaxCrossEntropy,
1557        OpKind::ArgMax,
1558        OpKind::ArgMin,
1559        OpKind::Cumsum,
1560        OpKind::TopK,
1561        OpKind::Sample,
1562        OpKind::Conv,
1563        OpKind::Im2Col,
1564        OpKind::Pool,
1565        OpKind::GroupedMatMul,
1566        OpKind::DequantGroupedMatMul,
1567        OpKind::DequantMoEWeights,
1568        OpKind::ScatterAdd,
1569        OpKind::SelectiveScan,
1570        OpKind::Lstm,
1571        OpKind::Gru,
1572        OpKind::Rnn,
1573        OpKind::Mamba2,
1574        // Transposed conv (vision U-Net decoder) — host fallback via the CPU kernel.
1575        OpKind::ConvTranspose2d,
1576        OpKind::DequantMatMul,
1577        OpKind::FusedMatMulBiasAct,
1578        OpKind::FusedResidualLN,
1579        OpKind::FusedResidualRmsNorm,
1580        OpKind::FusedSwiGLU,
1581        OpKind::FusedAttentionBlock,
1582        OpKind::FusedTransformerLayer,
1583        // Native FFT (WGSL radix-2): f32 only, power-of-2 N ≤ 1024.
1584        // Anything outside that envelope panics at lowering with a
1585        // "pin to Device::Cpu" hint. No host fallback — WGPU has no
1586        // unified memory, so silent CPU round-trip would be a hidden
1587        // performance cliff.
1588        OpKind::Fft,
1589        OpKind::LogMel,
1590        OpKind::LogMelBackward,
1591        OpKind::WelchPeaks,
1592        // 3D Gaussian splat: native Metal / CPU reference per backend.
1593        OpKind::GaussianSplatRender,
1594        OpKind::GaussianSplatRenderBackward,
1595        OpKind::GaussianSplatPrepare,
1596        OpKind::GaussianSplatRasterize,
1597        OpKind::Custom,
1598        OpKind::RngNormal,
1599        OpKind::RngUniform,
1600        // LoRA, If, While: not yet wired in wgpu — fail loudly.
1601    ];
1602
1603    impl Backend for WgpuBackend {
1604        fn supported_ops(&self) -> &'static [OpKind] {
1605            WGPU_SUPPORTED_OPS
1606        }
1607
1608        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
1609            use rlx_opt::pass::Pass as _;
1610            let graph = rlx_opt::LowerControlFlow.run(graph);
1611            let graph = rlx_opt::legalize_or_rewrite_for_backend(graph, WGPU_SUPPORTED_OPS)
1612                .unwrap_or_else(|errors| {
1613                    panic!("{}", rlx_opt::format_legalize_error("wgpu", &errors));
1614                });
1615            let graph = crate::precompile::precompile_cleanup(graph, options);
1616            // Materialize mid-axis broadcasts before MarkElementwiseRegions:
1617            // wgpu Binary/region kernels only handle trailing/scalar broadcast
1618            // via modulus; EEG patch embed uses [1,C,1,D] + [1,C,P,D].
1619            let graph = rlx_opt::LegalizeBroadcast.run(graph);
1620            // ORDER MATTERS: targeted-pattern fusions run BEFORE the
1621            // catch-all `MarkElementwiseRegions`. Otherwise the region
1622            // pass swallows the Add / Activation nodes into chains and
1623            // FuseMatMulBiasAct / FuseResidualLN fail to match the
1624            // narrower patterns they look for. (Metal pipeline at line
1625            // ~377 already orders these correctly; wgpu was inverted
1626            // and silently shipped 13 unfused LayerNorms per BERT
1627            // forward where 12 should have been FusedResidualLN.)
1628            let compile_result = crate::stages::compile_graph_stages_for_backend(
1629                rlx_driver::Device::Gpu,
1630                graph,
1631                options,
1632                WGPU_SUPPORTED_OPS,
1633            );
1634            crate::stages::maybe_log_fusion(&compile_result.fusion);
1635            let graph = compile_result.lir.into_graph();
1636            let graph = match options.policy.clone() {
1637                Some(p) => rlx_opt::AutoMixedPrecision::new(p).run(graph),
1638                None => graph,
1639            };
1640            let (graph, io_manifest) = cpu_low_precision::prepare_f32_exec_graph(graph);
1641            Box::new(WgpuExecutableWrapper {
1642                inner: WgpuExecutable::compile_rng(graph, options.rng),
1643                io_manifest,
1644            })
1645        }
1646
1647        fn compile_lir(
1648            &self,
1649            lir: LirModule,
1650            options: &CompileOptions,
1651        ) -> Box<dyn ExecutableGraph> {
1652            use rlx_opt::pass::Pass as _;
1653            // LIR may already contain fused ElementwiseRegions; legalize
1654            // broadcasts on the unfused graph shape before backend prep.
1655            let graph = rlx_opt::LegalizeBroadcast.run(lir.into_graph());
1656            let graph = prepare_fused_graph(graph, options, WGPU_SUPPORTED_OPS, "wgpu");
1657            let (graph, io_manifest) = cpu_low_precision::prepare_f32_exec_graph(graph);
1658            Box::new(WgpuExecutableWrapper {
1659                inner: WgpuExecutable::compile_rng(graph, options.rng),
1660                io_manifest,
1661            })
1662        }
1663    }
1664
1665    struct WgpuExecutableWrapper {
1666        inner: WgpuExecutable,
1667        io_manifest: cpu_low_precision::IoDtypeManifest,
1668    }
1669
1670    unsafe impl Send for WgpuExecutableWrapper {}
1671
1672    impl ExecutableGraph for WgpuExecutableWrapper {
1673        fn set_param(&mut self, name: &str, data: &[f32]) {
1674            self.inner.set_param(name, data);
1675        }
1676        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
1677            self.inner.run(inputs)
1678        }
1679        fn run_read_outputs(
1680            &mut self,
1681            inputs: &[(&str, &[f32])],
1682            read_indices: Option<&[usize]>,
1683        ) -> Vec<Vec<f32>> {
1684            self.inner.run_read_outputs(inputs, read_indices)
1685        }
1686        fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
1687            self.inner.bind_gpu_handle(name, data)
1688        }
1689        fn has_gpu_handle(&self, name: &str) -> bool {
1690            self.inner.has_gpu_handle(name)
1691        }
1692        fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
1693            self.inner.set_gpu_handle_feed(handle_name, output_index);
1694            true
1695        }
1696        fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
1697            self.inner.read_gpu_handle(name)
1698        }
1699        fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
1700            self.inner.set_active_extent(extent);
1701        }
1702
1703        fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
1704            self.inner.set_rng(rng);
1705        }
1706
1707        fn rng(&self) -> rlx_ir::RngOptions {
1708            self.inner.rng()
1709        }
1710
1711        /// Typed param upload: widens F16/BF16 to F32 at the host boundary,
1712        /// since the wgpu arena is f32-uniform.
1713        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
1714            match dtype {
1715                rlx_ir::DType::U8 | rlx_ir::DType::I8 => {
1716                    self.inner.set_param_bytes(name, data);
1717                }
1718                rlx_ir::DType::F32 => {
1719                    let n = data.len() / 4;
1720                    let f32_slice =
1721                        unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
1722                    self.inner.set_param(name, f32_slice);
1723                }
1724                rlx_ir::DType::F16 => {
1725                    let n = data.len() / 2;
1726                    let f16_slice =
1727                        unsafe { std::slice::from_raw_parts(data.as_ptr() as *const half::f16, n) };
1728                    let f32: Vec<f32> = f16_slice.iter().map(|h| h.to_f32()).collect();
1729                    self.inner.set_param(name, &f32);
1730                }
1731                rlx_ir::DType::BF16 => {
1732                    let n = data.len() / 2;
1733                    let bf16_slice = unsafe {
1734                        std::slice::from_raw_parts(data.as_ptr() as *const half::bf16, n)
1735                    };
1736                    let f32: Vec<f32> = bf16_slice.iter().map(|h| h.to_f32()).collect();
1737                    self.inner.set_param(name, &f32);
1738                }
1739                other => panic!(
1740                    "rlx-wgpu set_param_typed: dtype {other:?} unsupported \
1741                                 (F32, F16, BF16 only — wgpu arena is f32-uniform)"
1742                ),
1743            }
1744        }
1745
1746        /// Typed run: widen each typed input to F32, run, then narrow each
1747        /// output back to its declared dtype.
1748        fn run_typed(
1749            &mut self,
1750            inputs: &[(&str, &[u8], rlx_ir::DType)],
1751        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
1752            let mut owned: Vec<(String, Vec<f32>)> = Vec::with_capacity(inputs.len());
1753            for (name, data, dt) in inputs {
1754                let v: Vec<f32> = match *dt {
1755                    rlx_ir::DType::F32 => {
1756                        let n = data.len() / 4;
1757                        unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) }
1758                            .to_vec()
1759                    }
1760                    rlx_ir::DType::F16 => {
1761                        let n = data.len() / 2;
1762                        let s = unsafe {
1763                            std::slice::from_raw_parts(data.as_ptr() as *const half::f16, n)
1764                        };
1765                        s.iter().map(|h| h.to_f32()).collect()
1766                    }
1767                    rlx_ir::DType::BF16 => {
1768                        let n = data.len() / 2;
1769                        let s = unsafe {
1770                            std::slice::from_raw_parts(data.as_ptr() as *const half::bf16, n)
1771                        };
1772                        s.iter().map(|h| h.to_f32()).collect()
1773                    }
1774                    // Integer/bool inputs (e.g. embedding indices `phone_ids`) are
1775                    // widened to f32, matching the f32-arena convention shared with
1776                    // the CPU backend (Gather etc. operate on f32-encoded indices).
1777                    rlx_ir::DType::I64 => {
1778                        let n = data.len() / 8;
1779                        let s =
1780                            unsafe { std::slice::from_raw_parts(data.as_ptr() as *const i64, n) };
1781                        s.iter().map(|&x| x as f32).collect()
1782                    }
1783                    rlx_ir::DType::I32 => {
1784                        let n = data.len() / 4;
1785                        let s =
1786                            unsafe { std::slice::from_raw_parts(data.as_ptr() as *const i32, n) };
1787                        s.iter().map(|&x| x as f32).collect()
1788                    }
1789                    rlx_ir::DType::U8 | rlx_ir::DType::I8 | rlx_ir::DType::Bool => {
1790                        data.iter().map(|&b| b as f32).collect()
1791                    }
1792                    other => {
1793                        panic!("rlx-wgpu run_typed: input '{name}' dtype {other:?} unsupported")
1794                    }
1795                };
1796                owned.push((name.to_string(), v));
1797            }
1798            let refs: Vec<(&str, &[f32])> = owned
1799                .iter()
1800                .map(|(n, d)| (n.as_str(), d.as_slice()))
1801                .collect();
1802            let dtypes =
1803                super::declared_output_dtypes(&self.io_manifest, self.inner.output_dtypes());
1804            let outs = self.inner.run(&refs);
1805            outs.into_iter()
1806                .zip(
1807                    dtypes
1808                        .into_iter()
1809                        .chain(std::iter::repeat(rlx_ir::DType::F32)),
1810                )
1811                .map(|(v, dt)| (narrow_to_dtype(&v, dt), dt))
1812                .collect()
1813        }
1814
1815        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
1816            Box::new(WgpuExecutableWrapper {
1817                inner: self.inner.clone_for_cache(),
1818                io_manifest: self.io_manifest.clone(),
1819            })
1820        }
1821    }
1822
1823    /// Cast every element of a wgpu f32 output buffer down to the
1824    /// declared output dtype, returning the corresponding byte stream.
1825    /// The arena keeps every value as f32; declared output dtypes
1826    /// (Bool, I8, I32, F16, ...) require an exit-time narrowing to be
1827    /// byte-identical with backends that store the native dtype.
1828    fn narrow_to_dtype(v: &[f32], dt: rlx_ir::DType) -> Vec<u8> {
1829        use rlx_ir::DType;
1830        match dt {
1831            DType::F32 => {
1832                let mut bytes = Vec::with_capacity(v.len() * 4);
1833                for &x in v {
1834                    bytes.extend_from_slice(&x.to_le_bytes());
1835                }
1836                bytes
1837            }
1838            DType::F16 => {
1839                let mut bytes = Vec::with_capacity(v.len() * 2);
1840                for &x in v {
1841                    bytes.extend_from_slice(&half::f16::from_f32(x).to_le_bytes());
1842                }
1843                bytes
1844            }
1845            DType::BF16 => {
1846                let mut bytes = Vec::with_capacity(v.len() * 2);
1847                for &x in v {
1848                    bytes.extend_from_slice(&half::bf16::from_f32(x).to_le_bytes());
1849                }
1850                bytes
1851            }
1852            DType::F64 => {
1853                let mut bytes = Vec::with_capacity(v.len() * 8);
1854                for &x in v {
1855                    bytes.extend_from_slice(&(x as f64).to_le_bytes());
1856                }
1857                bytes
1858            }
1859            DType::I8 => v.iter().map(|&x| x as i8 as u8).collect(),
1860            DType::U8 => v.iter().map(|&x| x as u8).collect(),
1861            DType::I16 => {
1862                let mut bytes = Vec::with_capacity(v.len() * 2);
1863                for &x in v {
1864                    bytes.extend_from_slice(&(x as i16).to_le_bytes());
1865                }
1866                bytes
1867            }
1868            DType::I32 => {
1869                let mut bytes = Vec::with_capacity(v.len() * 4);
1870                for &x in v {
1871                    bytes.extend_from_slice(&(x as i32).to_le_bytes());
1872                }
1873                bytes
1874            }
1875            DType::U32 => {
1876                let mut bytes = Vec::with_capacity(v.len() * 4);
1877                for &x in v {
1878                    bytes.extend_from_slice(&(x as u32).to_le_bytes());
1879                }
1880                bytes
1881            }
1882            DType::I64 => {
1883                let mut bytes = Vec::with_capacity(v.len() * 8);
1884                for &x in v {
1885                    bytes.extend_from_slice(&(x as i64).to_le_bytes());
1886                }
1887                bytes
1888            }
1889            DType::Bool => v
1890                .iter()
1891                .map(|&x| if x != 0.0 { 1u8 } else { 0u8 })
1892                .collect(),
1893            // C64 (complex f32 pair) — the wgpu backend's f32 arena
1894            // doesn't synthesize complex outputs today; this branch
1895            // only fires if a graph somehow asks for a C64 output and
1896            // the backend lowered it as 2N real floats. We pass the
1897            // raw f32 stream straight through; downstream code that
1898            // wants complex semantics is responsible for re-pairing.
1899            DType::C64 => {
1900                let mut bytes = Vec::with_capacity(v.len() * 4);
1901                for &x in v {
1902                    bytes.extend_from_slice(&x.to_le_bytes());
1903                }
1904                bytes
1905            }
1906        }
1907    }
1908}
1909
1910// ── Native Vulkan Backend ───────────────────────────────────────────────
1911
1912#[cfg(feature = "vulkan")]
1913pub mod vulkan_backend {
1914    use super::*;
1915    use rlx_ir::OpKind;
1916    use rlx_vulkan::backend::VulkanExecutable;
1917
1918    pub struct VulkanBackend;
1919
1920    impl Backend for VulkanBackend {
1921        fn supported_ops(&self) -> &'static [OpKind] {
1922            rlx_vulkan::backend::SUPPORTED_OPS
1923        }
1924
1925        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
1926            // `VulkanExecutable::compile_rng` runs the legalize/rewrite pass
1927            // (decomposing DotGeneral / Fma / fused ops / non-last reduce down
1928            // to the native primitive set) itself, so we can hand it the graph
1929            // directly — no fusion pre-pass that would emit ops it can't lower.
1930            Box::new(VulkanExecutableWrapper {
1931                inner: VulkanExecutable::compile_rng(graph, options.rng),
1932            })
1933        }
1934    }
1935
1936    struct VulkanExecutableWrapper {
1937        inner: VulkanExecutable,
1938    }
1939
1940    unsafe impl Send for VulkanExecutableWrapper {}
1941
1942    impl ExecutableGraph for VulkanExecutableWrapper {
1943        fn set_param(&mut self, name: &str, data: &[f32]) {
1944            self.inner.set_param(name, data);
1945        }
1946
1947        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
1948            self.inner.run(inputs)
1949        }
1950
1951        fn run_read_outputs(
1952            &mut self,
1953            inputs: &[(&str, &[f32])],
1954            read_indices: Option<&[usize]>,
1955        ) -> Vec<Vec<f32>> {
1956            self.inner.run_read_outputs(inputs, read_indices)
1957        }
1958
1959        fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
1960            self.inner.set_active_extent(extent);
1961        }
1962
1963        fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
1964            self.inner.set_rng(rng);
1965        }
1966
1967        fn rng(&self) -> rlx_ir::RngOptions {
1968            self.inner.rng()
1969        }
1970
1971        fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
1972            self.inner.bind_gpu_handle(name, data)
1973        }
1974
1975        fn has_gpu_handle(&self, name: &str) -> bool {
1976            self.inner.has_gpu_handle(name)
1977        }
1978
1979        fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
1980            self.inner.set_gpu_handle_feed(handle_name, output_index);
1981            true
1982        }
1983
1984        fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
1985            self.inner.read_gpu_handle(name)
1986        }
1987
1988        fn register_kv_row_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
1989            self.inner.register_kv_row_feed(handle_name, output_index);
1990            true
1991        }
1992
1993        fn feed_kv_row(&mut self, src_row: usize, dst_row: usize, row_elems: usize) -> bool {
1994            self.inner.feed_kv_row(src_row, dst_row, row_elems);
1995            true
1996        }
1997
1998        fn read_output_row(
1999            &self,
2000            out_idx: usize,
2001            row: usize,
2002            row_inner: usize,
2003        ) -> Option<Vec<f32>> {
2004            self.inner.read_output_row(out_idx, row, row_inner)
2005        }
2006
2007        /// The Vulkan arena is f32-uniform: widen F16/BF16/int params to f32.
2008        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
2009            match dtype {
2010                rlx_ir::DType::U8 | rlx_ir::DType::I8 => self.inner.set_param_bytes(name, data),
2011                rlx_ir::DType::F32 => {
2012                    let n = data.len() / 4;
2013                    let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
2014                    self.inner.set_param(name, s);
2015                }
2016                other => {
2017                    let f = super::widen_bytes_to_f32(data, other);
2018                    self.inner.set_param(name, &f);
2019                }
2020            }
2021        }
2022
2023        /// Widen typed inputs to f32, run, then narrow each output back to its
2024        /// declared dtype (byte-identical with native-dtype backends).
2025        fn run_typed(
2026            &mut self,
2027            inputs: &[(&str, &[u8], rlx_ir::DType)],
2028        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
2029            let mut owned: Vec<(String, Vec<f32>)> = Vec::with_capacity(inputs.len());
2030            for (name, data, dt) in inputs {
2031                let v = if *dt == rlx_ir::DType::F32 {
2032                    let n = data.len() / 4;
2033                    unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) }.to_vec()
2034                } else {
2035                    super::widen_bytes_to_f32(data, *dt)
2036                };
2037                owned.push((name.to_string(), v));
2038            }
2039            let refs: Vec<(&str, &[f32])> = owned
2040                .iter()
2041                .map(|(n, d)| (n.as_str(), d.as_slice()))
2042                .collect();
2043            let dtypes = self.inner.output_dtypes();
2044            let outs = self.inner.run(&refs);
2045            outs.into_iter()
2046                .zip(
2047                    dtypes
2048                        .into_iter()
2049                        .chain(std::iter::repeat(rlx_ir::DType::F32)),
2050                )
2051                .map(|(v, dt)| (super::narrow_f32_to_bytes(&v, dt), dt))
2052                .collect()
2053        }
2054
2055        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
2056            Box::new(VulkanExecutableWrapper {
2057                inner: self.inner.clone_for_cache(),
2058            })
2059        }
2060    }
2061}
2062
2063// ── Intel oneAPI (Level Zero) Backend ───────────────────────────────────
2064
2065#[cfg(feature = "oneapi")]
2066pub mod oneapi_backend {
2067    use super::*;
2068    use rlx_ir::OpKind;
2069    use rlx_oneapi::backend::OneApiExecutable;
2070
2071    pub struct OneApiBackend;
2072
2073    impl Backend for OneApiBackend {
2074        fn supported_ops(&self) -> &'static [OpKind] {
2075            rlx_oneapi::backend::SUPPORTED_OPS
2076        }
2077
2078        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
2079            // `OneApiExecutable::compile_rng` runs the legalize/rewrite pass
2080            // itself (decomposing DotGeneral / Fma / fused ops down to the
2081            // native primitive set), so hand it the graph directly.
2082            Box::new(OneApiExecutableWrapper {
2083                inner: OneApiExecutable::compile_rng(graph, options.rng),
2084            })
2085        }
2086    }
2087
2088    struct OneApiExecutableWrapper {
2089        inner: OneApiExecutable,
2090    }
2091
2092    unsafe impl Send for OneApiExecutableWrapper {}
2093
2094    impl ExecutableGraph for OneApiExecutableWrapper {
2095        fn set_param(&mut self, name: &str, data: &[f32]) {
2096            self.inner.set_param(name, data);
2097        }
2098
2099        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
2100            self.inner.run(inputs)
2101        }
2102
2103        fn run_read_outputs(
2104            &mut self,
2105            inputs: &[(&str, &[f32])],
2106            read_indices: Option<&[usize]>,
2107        ) -> Vec<Vec<f32>> {
2108            self.inner.run_read_outputs(inputs, read_indices)
2109        }
2110
2111        fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
2112            self.inner.set_active_extent(extent);
2113        }
2114
2115        fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
2116            self.inner.set_rng(rng);
2117        }
2118
2119        fn rng(&self) -> rlx_ir::RngOptions {
2120            self.inner.rng()
2121        }
2122
2123        /// The oneAPI arena is f32-uniform: widen F16/BF16/int params to f32.
2124        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
2125            match dtype {
2126                rlx_ir::DType::U8 | rlx_ir::DType::I8 => self.inner.set_param_bytes(name, data),
2127                rlx_ir::DType::F32 => {
2128                    let n = data.len() / 4;
2129                    let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
2130                    self.inner.set_param(name, s);
2131                }
2132                other => {
2133                    let f = super::widen_bytes_to_f32(data, other);
2134                    self.inner.set_param(name, &f);
2135                }
2136            }
2137        }
2138
2139        /// Widen typed inputs to f32, run, then narrow each output back to its
2140        /// declared dtype (byte-identical with native-dtype backends).
2141        fn run_typed(
2142            &mut self,
2143            inputs: &[(&str, &[u8], rlx_ir::DType)],
2144        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
2145            let mut owned: Vec<(String, Vec<f32>)> = Vec::with_capacity(inputs.len());
2146            for (name, data, dt) in inputs {
2147                let v = if *dt == rlx_ir::DType::F32 {
2148                    let n = data.len() / 4;
2149                    unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) }.to_vec()
2150                } else {
2151                    super::widen_bytes_to_f32(data, *dt)
2152                };
2153                owned.push((name.to_string(), v));
2154            }
2155            let refs: Vec<(&str, &[f32])> = owned
2156                .iter()
2157                .map(|(n, d)| (n.as_str(), d.as_slice()))
2158                .collect();
2159            let dtypes = self.inner.output_dtypes();
2160            let outs = self.inner.run(&refs);
2161            outs.into_iter()
2162                .zip(
2163                    dtypes
2164                        .into_iter()
2165                        .chain(std::iter::repeat(rlx_ir::DType::F32)),
2166                )
2167                .map(|(v, dt)| (super::narrow_f32_to_bytes(&v, dt), dt))
2168                .collect()
2169        }
2170
2171        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
2172            Box::new(OneApiExecutableWrapper {
2173                inner: self.inner.clone_for_cache(),
2174            })
2175        }
2176    }
2177}
2178
2179// ── MLX Backend ─────────────────────────────────────────────────────────
2180
2181#[cfg(all(feature = "mlx", rlx_mlx_host))]
2182pub mod mlx_backend {
2183    use super::*;
2184    use rlx_mlx::MlxExecutable;
2185
2186    pub struct MlxBackend;
2187
2188    /// PLAN L4: ops the MLX backend can lower today. MLX has the
2189    /// widest IR coverage of any GPU backend — handles everything
2190    /// including If/While via topo unrolling, and lowers
2191    /// ElementwiseRegion natively via the per-step composition in
2192    /// rlx-mlx/src/lower.rs (PLAN L2).
2193    ///
2194    /// `GroupNorm` / `BatchNormInference` are intentionally omitted — lowered
2195    /// to primitives via [`LowerGroupNorm`] / [`LowerBatchNormInference`]
2196    /// before MLX lowering (no native MLX kernel).
2197    const MLX_SUPPORTED_OPS: &[rlx_ir::OpKind] = {
2198        use rlx_ir::OpKind::*;
2199        &[
2200            Input,
2201            Param,
2202            Constant,
2203            Activation,
2204            Cast,
2205            StopGradient,
2206            Binary,
2207            Compare,
2208            Where,
2209            ElementwiseRegion,
2210            TransformRegion,
2211            BatchElementwiseRegion,
2212            MatMul,
2213            DotGeneral,
2214            DenseSolve,
2215            BatchedDenseSolve,
2216            LayerNorm,
2217            LayerNorm2d,
2218            GroupNorm,
2219            ResizeNearest2x,
2220            RmsNorm,
2221            Attention,
2222            Rope,
2223            Reshape,
2224            Transpose,
2225            Narrow,
2226            Concat,
2227            Expand,
2228            Gather,
2229            Reverse,
2230            Reduce,
2231            Softmax,
2232            Cumsum,
2233            ArgMax,
2234            ArgMin,
2235            TopK,
2236            RngNormal,
2237            RngUniform,
2238            Sample,
2239            Conv,
2240            Im2Col,
2241            ConvTranspose2d,
2242            Pool,
2243            GroupedMatMul,
2244            DequantGroupedMatMul,
2245            DequantMoEWeights,
2246            ScatterAdd,
2247            LoraMatMul,
2248            DequantMatMul,
2249            SelectiveScan,
2250            GatedDeltaNet,
2251            FusedSwiGLU,
2252            FusedMatMulBiasAct,
2253            FusedResidualLN,
2254            FusedResidualRmsNorm,
2255            FusedAttentionBlock,
2256            FusedTransformerLayer,
2257            If,
2258            While,
2259            // Loop-unrolled scan (Op::Scan body is statically unrolled
2260            // `length` times into MLX ops; mirror of Op::While's
2261            // bounded-unroll lowering). ScanBackward is the AD
2262            // companion — handled the same way.
2263            Scan,
2264            ScanBackward,
2265            ScanBackwardXs,
2266            // Tier 1 autodiff backward ops — lowered as primitive
2267            // compositions in `rlx-mlx/src/lower.rs`.
2268            ReluBackward,
2269            ActivationBackward,
2270            SoftmaxCrossEntropy,
2271            SoftmaxCrossEntropyWithLogits,
2272            SoftmaxCrossEntropyBackward,
2273            AttentionBackward,
2274            LayerNormBackwardInput,
2275            LayerNormBackwardGamma,
2276            // GroupNorm backward — native MLX lowering in `lower.rs`
2277            // (group-reshape + reduce, mirrors GroupNormBackwardInput).
2278            GroupNormBackwardInput,
2279            GroupNormBackwardGamma,
2280            GroupNormBackwardBeta,
2281            // Tier 2 — conv backward via `mc::conv_general` with the
2282            // same parameter-mapping MLX uses inside its built-in vjp.
2283            // Currently groups=1 only; grouped conv backward will
2284            // surface as a clear error from `lower.rs`.
2285            Conv2dBackwardInput,
2286            Conv2dBackwardWeight,
2287            // Tier 3 — max-pool backward via slice-strided argmax over
2288            // pool windows + a per-kernel-slot scatter-add, matching
2289            // the CPU thunk's "first-hit-wins" tiebreaking.
2290            MaxPool2dBackward,
2291            // QAT — `FakeQuantize` (PerBatch + Fixed scale modes;
2292            // EMA returns a clear error from `lower.rs`) and the
2293            // `FakeQuantizeBackward` family covering all 4 STE
2294            // variants. Closes the last gap vs `CPU_SUPPORTED_OPS`.
2295            FakeQuantize,
2296            FakeQuantizeBackward,
2297            // User-registered custom ops dispatched through
2298            // `rlx_mlx::op_registry`. Lowering looks up the
2299            // registered `MlxKernel` and calls its `execute` method
2300            // to produce the lazy MLX `Array` for this node.
2301            Custom,
2302            Fft,
2303            LogMel,
2304            LogMelBackward,
2305            WelchPeaks,
2306            GaussianSplatRender,
2307            GaussianSplatRenderBackward,
2308            // Op::Fft on MLX: native `mlx::fft::fft` via rlx_mlx_op_fft shim.
2309            // 2N real-block f32/f64 and complex64 inputs supported.
2310        ]
2311    };
2312
2313    impl Backend for MlxBackend {
2314        fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
2315            MLX_SUPPORTED_OPS
2316        }
2317
2318        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
2319            let compile_result = crate::stages::compile_graph_stages_for_backend(
2320                rlx_driver::Device::Mlx,
2321                graph,
2322                options,
2323                MLX_SUPPORTED_OPS,
2324            );
2325            crate::stages::maybe_log_fusion(&compile_result.fusion);
2326            self.compile_lir(compile_result.lir, options)
2327        }
2328
2329        fn compile_lir(
2330            &self,
2331            lir: LirModule,
2332            options: &CompileOptions,
2333        ) -> Box<dyn ExecutableGraph> {
2334            use rlx_opt::pass::Pass as _;
2335            let mut graph = lir.into_graph();
2336            graph = rlx_opt::LowerControlFlow.run(graph);
2337            let graph = prepare_fused_graph(graph, options, MLX_SUPPORTED_OPS, "mlx");
2338            Box::new(build_mlx_executable(graph, options.rng))
2339        }
2340    }
2341
2342    fn build_mlx_executable(graph: Graph, rng: rlx_ir::RngOptions) -> MlxExecutableWrapper {
2343        let (graph, io_manifest) = cpu_low_precision::prepare_f32_exec_graph(graph);
2344        let mode = mlx_mode_from_env();
2345        let mut exe = MlxExecutable::compile_from_fused_with_rng(graph, mode, rng);
2346        if mode == rlx_mlx::lower::MlxMode::Compiled {
2347            if let Err(e) = exe.warm_compile() {
2348                eprintln!(
2349                    "[rlx-runtime] MLX warm_compile failed ({e}); first run will pay the trace cost"
2350                );
2351            }
2352        }
2353        MlxExecutableWrapper {
2354            inner: exe,
2355            io_manifest,
2356        }
2357    }
2358
2359    fn mlx_mode_from_env() -> rlx_mlx::lower::MlxMode {
2360        match rlx_ir::env::var("RLX_MLX_MODE").as_deref() {
2361            Some(s) if s.eq_ignore_ascii_case("eager") => rlx_mlx::lower::MlxMode::Eager,
2362            Some(s) if s.eq_ignore_ascii_case("lazy") => rlx_mlx::lower::MlxMode::Lazy,
2363            Some(s) if s.eq_ignore_ascii_case("compiled") => rlx_mlx::lower::MlxMode::Compiled,
2364            _ => rlx_mlx::lower::MlxMode::Compiled,
2365        }
2366    }
2367
2368    struct MlxExecutableWrapper {
2369        inner: MlxExecutable,
2370        io_manifest: cpu_low_precision::IoDtypeManifest,
2371    }
2372
2373    unsafe impl Send for MlxExecutableWrapper {}
2374
2375    impl ExecutableGraph for MlxExecutableWrapper {
2376        fn set_param(&mut self, name: &str, data: &[f32]) {
2377            self.inner.set_param(name, data);
2378        }
2379        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
2380            self.inner.run(inputs)
2381        }
2382        fn run_read_outputs(
2383            &mut self,
2384            inputs: &[(&str, &[f32])],
2385            read_indices: Option<&[usize]>,
2386        ) -> Vec<Vec<f32>> {
2387            self.inner
2388                .run_read_outputs(inputs, read_indices)
2389                .unwrap_or_else(|e| panic!("MLX run_read_outputs failed: {e}"))
2390        }
2391        fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
2392            self.inner.run_slots(inputs)
2393        }
2394        fn arena_ptr(&self) -> *const u8 {
2395            self.inner.arena_ptr()
2396        }
2397        fn commit_no_wait(&mut self, inputs: &[(&str, &[f32])]) {
2398            self.inner.commit_no_wait(inputs);
2399        }
2400        fn sync_pending(&mut self) {
2401            self.inner.sync_pending();
2402        }
2403        fn run_pipelined(&mut self, input_sets: &[Vec<(&str, &[f32])>]) -> Vec<Vec<Vec<f32>>> {
2404            self.inner.run_pipelined(input_sets)
2405        }
2406        fn bind_handle(&mut self, name: &str, data: &[f32]) -> bool {
2407            self.inner.bind_handle(name, data)
2408        }
2409        fn read_handle(&self, name: &str) -> Option<Vec<f32>> {
2410            self.inner.read_handle(name)
2411        }
2412        fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
2413            self.inner.bind_gpu_handle(name, data).is_ok()
2414        }
2415        fn has_gpu_handle(&self, name: &str) -> bool {
2416            self.inner.has_gpu_handle(name)
2417        }
2418        fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
2419            self.inner.set_gpu_handle_feed(handle_name, output_index);
2420            true
2421        }
2422        fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
2423            self.inner.read_gpu_handle(name).ok()
2424        }
2425        fn run_feed_gpu_handle(
2426            &mut self,
2427            inputs: &[(&str, &[f32])],
2428            handle_name: &str,
2429            output_index: usize,
2430        ) -> Option<Vec<f32>> {
2431            self.inner
2432                .run_feed_gpu(inputs, handle_name, output_index)
2433                .ok()
2434        }
2435        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
2436            self.inner.set_param_typed(name, data, dtype);
2437        }
2438        fn run_typed(
2439            &mut self,
2440            inputs: &[(&str, &[u8], rlx_ir::DType)],
2441        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
2442            self.inner.run_typed(inputs)
2443        }
2444        fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
2445            self.inner.set_active_extent(extent);
2446        }
2447
2448        fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
2449            self.inner.set_rng(rng);
2450        }
2451
2452        fn rng(&self) -> rlx_ir::RngOptions {
2453            self.inner.rng()
2454        }
2455
2456        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
2457            Box::new(MlxExecutableWrapper {
2458                inner: self.inner.clone_for_cache(),
2459                io_manifest: self.io_manifest.clone(),
2460            })
2461        }
2462    }
2463}
2464
2465/// Ops with a MIL lowering today (see `rlx_coreml::mil`).
2466///
2467/// Single source of truth, shared by `coreml_backend::CoremlBackend::
2468/// supported_ops` and `device_ext::supports(Device::Ane, ..)` so the two
2469/// never drift. Ungated so the support probe compiles on every target
2470/// (it's only *consulted* when the `coreml` backend is available).
2471pub(crate) const COREML_SUPPORTED_OPS: &[rlx_ir::OpKind] = {
2472    use rlx_ir::OpKind::*;
2473    &[
2474        Input,
2475        Param,
2476        Constant,
2477        Activation,
2478        Cast,
2479        Binary,
2480        MatMul,
2481        LayerNorm,
2482        RmsNorm,
2483        Reduce,
2484        Softmax,
2485        Reshape,
2486        Transpose,
2487        Narrow,
2488        Concat,
2489        Gather,
2490        Rope,
2491        Attention,
2492        // Claimed first-class; `CoremlExecutable::compile_with_options`
2493        // decomposes it to the primitive chain (matmul → narrow → rope →
2494        // attention → matmul) since the MIL lowering has no fused-attention
2495        // op. FAB-only decompose, so native LoraMatMul below is untouched.
2496        FusedAttentionBlock,
2497        Compare,
2498        Where,
2499        Expand,
2500        Cumsum,
2501        ScatterAdd,
2502        BatchNormInference,
2503        GroupNorm,
2504        LayerNorm2d,
2505        LoraMatMul,
2506        Conv,
2507        ConvTranspose2d,
2508        Pool,
2509        TopK,
2510        AxialRope2d,
2511        ResizeNearest2x,
2512        StopGradient,
2513        GroupedMatMul,
2514        DequantMatMul,
2515        DequantMoEWeights,
2516        DequantGroupedMatMul,
2517        Quantize,
2518        Dequantize,
2519        SelectiveScan,
2520        GatedDeltaNet,
2521        ArgMax,
2522        ArgMin,
2523        Reverse,
2524        Fft,
2525        LogMel,
2526        Sample,
2527        RngNormal,
2528        Lstm,
2529        Gru,
2530        Rnn,
2531        Mamba2,
2532        WelchPeaks,
2533        Custom,
2534    ]
2535};
2536
2537/// Backward / training `OpKind`s the CoreML backend can run **via decomposition**
2538/// (`rlx_autodiff::decompose_backward_ops_except` in the legalize/rewrite pass):
2539/// each lowers to a chain of primitives that are all in [`COREML_SUPPORTED_OPS`].
2540///
2541/// Kept separate from `COREML_SUPPORTED_OPS` on purpose: these must NOT be in the
2542/// list handed to `legalize_or_rewrite_for_backend` (otherwise they'd be treated
2543/// as directly lowerable and skip the decompose, and the MIL lowering would choke
2544/// on a raw `*Backward` op). They feed only the *device-selection* probe
2545/// (`device_ext::coreml_supports`) so the runtime picks `Device::Ane` for a graph
2546/// that carries them. As native MIL backward kernels land (see `rlx_coreml::mil`),
2547/// the corresponding kind graduates into `COREML_SUPPORTED_OPS` and is removed
2548/// here. Only consulted under the `training` feature.
2549///
2550/// Excluded deliberately: `Conv2dBackwardWeight` (decomposes via `Im2Col`, which
2551/// has no MIL lowering yet — lands with the Phase-2 conv kernels) and the
2552/// conditional / domain backward ops (`ScanBackward*`, `LogMelBackward`,
2553/// `GaussianSplatRenderBackward`, `ComplexNormSqBackward`, `FakeQuantizeLSQ*`).
2554#[allow(dead_code)] // only read under `feature = "training"`.
2555pub(crate) const COREML_BACKWARD_OPS: &[rlx_ir::OpKind] = {
2556    use rlx_ir::OpKind::*;
2557    &[
2558        ReluBackward,
2559        ActivationBackward,
2560        LayerNormBackwardInput,
2561        LayerNormBackwardGamma,
2562        GroupNormBackwardInput,
2563        GroupNormBackwardGamma,
2564        GroupNormBackwardBeta,
2565        BatchNormInferenceBackwardInput,
2566        BatchNormInferenceBackwardGamma,
2567        BatchNormInferenceBackwardBeta,
2568        RopeBackward,
2569        AttentionBackward,
2570        SoftmaxCrossEntropyBackward,
2571        CumsumBackward,
2572        GatherBackward,
2573        FakeQuantizeBackward,
2574    ]
2575};
2576
2577/// Backward `OpKind`s the CoreML backend lowers through a **native MIL kernel**
2578/// (`rlx_coreml::mil`, gated by `rlx-coreml/training`) rather than decomposition.
2579/// Unlike [`COREML_BACKWARD_OPS`], these ARE added to the list handed to
2580/// `legalize_or_rewrite_for_backend` (under `training`), so the rewrite leaves
2581/// them intact for the lowering's dedicated arm.
2582///
2583/// These cases land here:
2584/// - **RMSNorm backward** — the dominant norm in modern transformers; a
2585///   hand-composed MIL kernel (implicit broadcasting, ~13 ops) beats the autodiff
2586///   decomposition (~27 ops of `Expand`-with-ones). `ReluBackward`/
2587///   `ActivationBackward` are NOT here: their decomposition is already `dy·f'(x)`
2588///   (~3 MIL ops), so a native kernel would emit identical MIL — they ride the
2589///   decompose route instead.
2590/// - **MaxPool2d backward** — MUST be native: the decomposition builds an
2591///   N²-sized dense scatter that blows RLX's size cap on any real CNN. The native
2592///   kernel is O(input) (reshape + reduce_max/min + select). Non-overlapping,
2593///   unpadded pooling only (the CNN-training norm, e.g. MNIST 2×2/2); other
2594///   configs return `Unsupported`.
2595/// - **Conv2d backward** (input via `conv_transpose`, weight via the transpose-conv
2596///   trick) — native because the autodiff input decomposition emits the wrong op.
2597/// - **Softmax-cross-entropy (forward `WithLogits` + backward)** — MUST be native
2598///   for LLM-scale training: the decompose builds the one-hot by concatenating C
2599///   class columns, O(C) graph nodes that explode at vocab size. MIL `one_hot` is a
2600///   single node. Both halves are listed so the loss op stays out of `bad` and the
2601///   shared `LowerSoftmaxCrossEntropy` pass never re-decomposes the backward.
2602///
2603/// MUST stay in lock-step with the lowering arms in `rlx_coreml::mil` (a kind
2604/// here without an arm would skip decompose and hit the `Unsupported` fallback).
2605/// The norm arms mirror the autodiff decomposition's math — the RMSNorm input
2606/// cross-term is `inv_r³` (finite-difference-verified, the same formula every
2607/// backend now uses) — so ANE gradients stay consistent with the rest of the
2608/// training path.
2609#[allow(dead_code)] // only read under `feature = "training"`.
2610pub(crate) const COREML_NATIVE_BACKWARD_OPS: &[rlx_ir::OpKind] = {
2611    use rlx_ir::OpKind::*;
2612    &[
2613        RmsNormBackwardInput,
2614        RmsNormBackwardGamma,
2615        RmsNormBackwardBeta,
2616        LayerNormBackwardInput,
2617        LayerNormBackwardGamma,
2618        GroupNormBackwardInput,
2619        GroupNormBackwardGamma,
2620        GroupNormBackwardBeta,
2621        MaxPool2dBackward,
2622        Conv2dBackwardInput,
2623        Conv2dBackwardWeight,
2624        AttentionBackward,
2625        // The SCE training pair (integer-label loss + its gradient). Both native so
2626        // neither lands in `bad` — otherwise the shared `LowerSoftmaxCrossEntropy`
2627        // pass fires on the forward and re-decomposes the backward into the O(C)
2628        // one-hot concat. `SoftmaxCrossEntropyWithLogits` is a forward op but is
2629        // training-only, so it lives here rather than in the base inference set.
2630        SoftmaxCrossEntropyWithLogits,
2631        SoftmaxCrossEntropyBackward,
2632    ]
2633};
2634
2635/// `COREML_SUPPORTED_OPS` ∪ `COREML_NATIVE_BACKWARD_OPS` — the op claim under the
2636/// `training` feature, returned by `CoremlBackend::supported_ops`.
2637///
2638/// This matters because the fusion pipeline (`stages::compile_module_stages`, run
2639/// by the default `Backend::compile_module` *before* the backend's own lowering)
2640/// decides what to decompose from `supported_ops()`. If the native backward
2641/// kernels aren't claimed *here*, the pipeline decomposes them into primitives
2642/// before `rlx_coreml`'s dedicated arm can fire — which silently sent MaxPool2d
2643/// backward down the (rank-6, CoreML-illegal) upsample decomposition instead of
2644/// the native O(input) kernel.
2645#[cfg(feature = "training")]
2646pub(crate) const COREML_SUPPORTED_OPS_TRAINING: [rlx_ir::OpKind;
2647    COREML_SUPPORTED_OPS.len() + COREML_NATIVE_BACKWARD_OPS.len()] = {
2648    let mut arr =
2649        [rlx_ir::OpKind::Input; COREML_SUPPORTED_OPS.len() + COREML_NATIVE_BACKWARD_OPS.len()];
2650    let mut i = 0;
2651    while i < COREML_SUPPORTED_OPS.len() {
2652        arr[i] = COREML_SUPPORTED_OPS[i];
2653        i += 1;
2654    }
2655    let mut j = 0;
2656    while j < COREML_NATIVE_BACKWARD_OPS.len() {
2657        arr[COREML_SUPPORTED_OPS.len() + j] = COREML_NATIVE_BACKWARD_OPS[j];
2658        j += 1;
2659    }
2660    arr
2661};
2662
2663/// Apple CoreML / Neural Engine (ANE) backend wiring.
2664///
2665/// Unlike the GPU backends, CoreML compiles a *static* model with weights
2666/// baked in, so we hand the raw graph (Param nodes intact) straight to
2667/// `rlx_coreml` and skip the fusion/LIR arena pipeline. Weights arrive via
2668/// `set_param`; the `.mlpackage` is built + loaded on `finalize_params`
2669/// (or lazily on first `run`).
2670#[cfg(all(
2671    feature = "coreml",
2672    target_vendor = "apple",
2673    not(target_os = "watchos")
2674))]
2675pub mod coreml_backend {
2676    use super::*;
2677    use crate::Precision;
2678    use rlx_coreml::{CoremlExecutable, default_lower_options};
2679
2680    pub struct CoremlBackend;
2681
2682    impl Backend for CoremlBackend {
2683        fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
2684            // Under `training` the claim includes the native backward kernels so
2685            // the fusion pipeline preserves them for the dedicated MIL arm instead
2686            // of decomposing them first (decompose-route backward ops stay out, so
2687            // the pipeline still turns those into primitives). See
2688            // `COREML_SUPPORTED_OPS_TRAINING`.
2689            #[cfg(feature = "training")]
2690            {
2691                &super::COREML_SUPPORTED_OPS_TRAINING
2692            }
2693            #[cfg(not(feature = "training"))]
2694            {
2695                super::COREML_SUPPORTED_OPS
2696            }
2697        }
2698
2699        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
2700            // `supported_ops()` already includes the native backward kernels under
2701            // `training`, so the rewrite keeps them intact for their MIL arm while
2702            // decomposing the decompose-route backward ops to primitives.
2703            let graph = rlx_opt::legalize_or_rewrite_for_backend(graph, self.supported_ops())
2704                .unwrap_or_else(|errors| {
2705                    panic!("{}", rlx_opt::format_legalize_error("coreml", &errors));
2706                });
2707            // Automatic Floating Point: apply the mixed-precision policy if set.
2708            // AMP keeps boundaries (input/param/const/output) in F32 and casts at
2709            // f16 boundaries, so CoreML lowers a valid mixed f16/f32 ML Program
2710            // (per-node dtypes; consts stay F32 → no storage/type mismatch). This
2711            // is the precise way to get f16 compute — unlike a blanket
2712            // `float_dtype = F16` flip, which mis-sizes float consts.
2713            let (graph, mut lower_opts) = match options.policy.clone() {
2714                Some(policy) => {
2715                    use rlx_opt::pass::Pass as _;
2716                    let g = rlx_opt::AutoMixedPrecision::new(policy).run(graph);
2717                    let opts = default_lower_options(&g);
2718                    (g, opts)
2719                }
2720                None => {
2721                    let mut opts = default_lower_options(&graph);
2722                    // Legacy blanket-F16 path (no AMP policy): kept for inference.
2723                    if options.precision == Precision::F16 {
2724                        opts.float_dtype = rlx_ir::DType::F16;
2725                    }
2726                    (graph, opts)
2727                }
2728            };
2729            if let Some(binding) = &options.dim_binding {
2730                let _ = binding;
2731                lower_opts.flexible_inputs = false;
2732            }
2733            Box::new(CoremlExecutableWrapper {
2734                inner: CoremlExecutable::compile_with_lower_opts(graph, lower_opts),
2735            })
2736        }
2737
2738        fn compile_lir(
2739            &self,
2740            lir: LirModule,
2741            options: &CompileOptions,
2742        ) -> Box<dyn ExecutableGraph> {
2743            // No LIR arena path for CoreML; reconstruct the graph and go
2744            // through the normal compile.
2745            self.compile(lir.into_graph(), options)
2746        }
2747    }
2748
2749    struct CoremlExecutableWrapper {
2750        inner: CoremlExecutable,
2751    }
2752
2753    // The loaded MLModel is owned exclusively and accessed via &mut self.
2754    unsafe impl Send for CoremlExecutableWrapper {}
2755
2756    impl ExecutableGraph for CoremlExecutableWrapper {
2757        fn set_param(&mut self, name: &str, data: &[f32]) {
2758            self.inner.set_param(name, data);
2759        }
2760
2761        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
2762            // GGUF-quantized weights arrive as raw bytes; the lowering
2763            // host-dequantizes them when baking the model.
2764            self.inner.set_param_typed(name, data, dtype);
2765        }
2766
2767        fn finalize_params(&mut self) {
2768            self.inner
2769                .finalize()
2770                .unwrap_or_else(|e| panic!("CoreML finalize failed: {e}"));
2771        }
2772
2773        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
2774            self.inner
2775                .run(inputs)
2776                .unwrap_or_else(|e| panic!("CoreML run failed: {e}"))
2777        }
2778
2779        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
2780            Box::new(CoremlExecutableWrapper {
2781                inner: self.inner.clone_for_cache(),
2782            })
2783        }
2784
2785        fn run_typed(
2786            &mut self,
2787            inputs: &[(&str, &[u8], rlx_ir::DType)],
2788        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
2789            use rlx_ir::DType;
2790            // The CoreML model is promoted to an f32 flow (`promote_int_to_f32`), so
2791            // widen integer/bool inputs (e.g. `phone_ids`) to f32 on the host surface.
2792            let owned: Vec<(String, Vec<f32>)> = inputs
2793                .iter()
2794                .map(|(name, data, dt)| {
2795                    let v: Vec<f32> = match dt {
2796                        DType::I64 => data
2797                            .chunks_exact(8)
2798                            .map(|c| i64::from_le_bytes(c.try_into().unwrap()) as f32)
2799                            .collect(),
2800                        DType::I32 => data
2801                            .chunks_exact(4)
2802                            .map(|c| i32::from_le_bytes(c.try_into().unwrap()) as f32)
2803                            .collect(),
2804                        DType::U8 | DType::Bool => data.iter().map(|&b| b as f32).collect(),
2805                        _ => super::widen_bytes_to_f32(data, *dt),
2806                    };
2807                    (name.to_string(), v)
2808                })
2809                .collect();
2810            let refs: Vec<(&str, &[f32])> = owned
2811                .iter()
2812                .map(|(n, d)| (n.as_str(), d.as_slice()))
2813                .collect();
2814            self.run(&refs)
2815                .into_iter()
2816                .map(|v| {
2817                    let bytes: Vec<u8> = v.iter().flat_map(|f| f.to_le_bytes()).collect();
2818                    (bytes, DType::F32)
2819                })
2820                .collect()
2821        }
2822    }
2823}
2824
2825#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
2826pub mod metal_backend {
2827    use super::*;
2828    use rlx_metal::backend::MetalExecutable;
2829
2830    pub struct MetalBackend;
2831
2832    /// PLAN L4: ops the Metal backend can lower today. Includes
2833    /// DotGeneral (LowerDotGeneral pass) and ElementwiseRegion
2834    /// (decomposed by UnfuseElementwiseRegions). Excludes
2835    /// SelectiveScan, LoraMatMul, Sample,
2836    /// FusedTransformerLayer, If, While —
2837    /// not yet wired in `rlx-metal/src/thunk.rs`'s compile_thunks.
2838    /// `FusedAttentionBlock` IS claimed (so it legalizes / the fusion
2839    /// pipeline may emit it); `MetalExecutable::compile_inner` decomposes
2840    /// it to the primitive chain — there is no monolithic fused-attention
2841    /// MSL kernel yet.
2842    /// DequantMatMul (GGUF K-quants) lowers to a GPU dequant kernel
2843    /// + MPS matmul; legacy Int8 schemes remain CPU-only.
2844    ///
2845    const METAL_SUPPORTED_OPS: &[rlx_ir::OpKind] = {
2846        use rlx_ir::OpKind::*;
2847        &[
2848            Input,
2849            Param,
2850            Constant,
2851            Activation,
2852            Cast,
2853            StopGradient,
2854            Binary,
2855            Compare,
2856            Where,
2857            Fma,
2858            ElementwiseRegion,
2859            TransformRegion,
2860            BatchElementwiseRegion,
2861            MatMul,
2862            ScaledMatMul,
2863            ScaledQuantize,
2864            ScaledQuantScale,
2865            ScaledDequantize,
2866            DotGeneral,
2867            LayerNorm,
2868            LayerNorm2d,
2869            GroupNorm,
2870            RmsNorm,
2871            ResizeNearest2x,
2872            AxialRope2d,
2873            Attention,
2874            AttentionBackward,
2875            RmsNormBackwardInput,
2876            RmsNormBackwardGamma,
2877            RmsNormBackwardBeta,
2878            RopeBackward,
2879            Cumsum,
2880            CumsumBackward,
2881            GatherBackward,
2882            Conv2dBackwardInput,
2883            Conv2dBackwardWeight,
2884            MaxPool2dBackward,
2885            Rope,
2886            Reshape,
2887            Transpose,
2888            Narrow,
2889            Concat,
2890            Expand,
2891            Gather,
2892            Reverse,
2893            Reduce,
2894            Softmax,
2895            SoftmaxCrossEntropy,
2896            SoftmaxCrossEntropyWithLogits,
2897            SoftmaxCrossEntropyBackward,
2898            ArgMax,
2899            ArgMin,
2900            TopK,
2901            Sample,
2902            RngNormal,
2903            RngUniform,
2904            Conv,
2905            Im2Col,
2906            ConvTranspose2d,
2907            Pool,
2908            GroupedMatMul,
2909            DequantGroupedMatMul,
2910            DequantMoEWeights,
2911            ScatterAdd,
2912            DequantMatMul,
2913            GatedDeltaNet,
2914            SelectiveScan,
2915            Lstm,
2916            Gru,
2917            Rnn,
2918            Mamba2,
2919            FusedSwiGLU,
2920            FusedMatMulBiasAct,
2921            FusedResidualLN,
2922            FusedResidualRmsNorm,
2923            // Claimed so the Metal fusion pipeline may emit it;
2924            // `MetalExecutable::compile_inner` decomposes it back to the
2925            // primitive chain (no monolithic fused-attention MSL kernel
2926            // yet — the per-run cost is dominated by wait_until_completed,
2927            // not encode, so a dispatch-wrapper fusion buys nothing).
2928            FusedAttentionBlock,
2929            // User-registered custom ops dispatched through
2930            // `rlx_metal::op_registry`. Lowering panics with a clear
2931            // message if the named MetalKernel isn't registered;
2932            // executor inserts a sync point + runs the host kernel
2933            // against the unified-memory arena.
2934            Custom,
2935            // Op::Fft is supported via the same host-fallback pattern
2936            // as Custom: sync the GPU, run rlx-cpu's FFT against the
2937            // unified-memory arena, restart cmd_buf. A native Metal
2938            // compute kernel will replace this when a workload makes
2939            // the sync the bottleneck.
2940            Fft,
2941            LogMel,
2942            LogMelBackward,
2943            WelchPeaks,
2944            // Host-fallback splat (unified-memory arena + rlx-cpu/splat).
2945            GaussianSplatRender,
2946            GaussianSplatRenderBackward,
2947            GaussianSplatPrepare,
2948            GaussianSplatRasterize,
2949        ]
2950    };
2951
2952    impl Backend for MetalBackend {
2953        fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
2954            METAL_SUPPORTED_OPS
2955        }
2956
2957        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
2958            use rlx_opt::pass::Pass as _;
2959            // Same If/While → primitive rewrite as the CPU pipeline
2960            // (Metal also has no native sub-graph executor wired
2961            // through its thunk schedule).
2962            let graph = rlx_opt::LowerControlFlow.run(graph);
2963            let dispatch = options.kernel_dispatch;
2964            let graph = rlx_opt::legalize_or_rewrite_for_backend_with_config(
2965                graph,
2966                METAL_SUPPORTED_OPS,
2967                dispatch,
2968            )
2969            .unwrap_or_else(|errors| {
2970                panic!("{}", rlx_opt::format_legalize_error("metal", &errors));
2971            });
2972            let graph = crate::precompile::precompile_cleanup(graph, options);
2973
2974            // Hand the policy to MetalExecutable so the rewrite runs AFTER
2975            // its internal fusion passes (avoids breaking pattern matchers).
2976            let (graph, io_manifest) = cpu_low_precision::prepare_f32_exec_graph(graph);
2977            Box::new(MetalExecutableWrapper {
2978                inner: MetalExecutable::compile_with_policy(
2979                    graph,
2980                    options.policy.clone(),
2981                    Some(METAL_SUPPORTED_OPS),
2982                    options.rng,
2983                ),
2984                io_manifest,
2985            })
2986        }
2987
2988        fn compile_lir(
2989            &self,
2990            lir: LirModule,
2991            options: &CompileOptions,
2992        ) -> Box<dyn ExecutableGraph> {
2993            use rlx_opt::pass::Pass as _;
2994            let mut graph = lir.into_graph();
2995            graph = rlx_opt::LowerControlFlow.run(graph);
2996            let dispatch = options.kernel_dispatch;
2997            let mut graph = rlx_opt::legalize_or_rewrite_for_backend_with_config(
2998                graph,
2999                METAL_SUPPORTED_OPS,
3000                dispatch,
3001            )
3002            .unwrap_or_else(|errors| {
3003                panic!("{}", rlx_opt::format_legalize_error("metal", &errors));
3004            });
3005            graph = crate::precompile::precompile_cleanup(graph, options);
3006            let (graph, io_manifest) = cpu_low_precision::prepare_f32_exec_graph(graph);
3007            Box::new(MetalExecutableWrapper {
3008                inner: MetalExecutable::compile_from_fused(
3009                    graph,
3010                    options.policy.clone(),
3011                    Some(METAL_SUPPORTED_OPS),
3012                    options.rng,
3013                ),
3014                io_manifest,
3015            })
3016        }
3017    }
3018
3019    struct MetalExecutableWrapper {
3020        inner: MetalExecutable,
3021        io_manifest: cpu_low_precision::IoDtypeManifest,
3022    }
3023
3024    unsafe impl Send for MetalExecutableWrapper {}
3025
3026    impl ExecutableGraph for MetalExecutableWrapper {
3027        fn set_param(&mut self, name: &str, data: &[f32]) {
3028            self.inner.set_param(name, data);
3029        }
3030
3031        fn finalize_params(&mut self) {
3032            self.inner.preload_qmatmul_weights();
3033        }
3034
3035        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
3036            self.inner.run(inputs)
3037        }
3038        fn run_read_outputs(
3039            &mut self,
3040            inputs: &[(&str, &[f32])],
3041            read_indices: Option<&[usize]>,
3042        ) -> Vec<Vec<f32>> {
3043            self.inner.run_read_outputs(inputs, read_indices)
3044        }
3045        fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
3046            self.inner.bind_gpu_handle(name, data)
3047        }
3048        fn has_gpu_handle(&self, name: &str) -> bool {
3049            self.inner.has_gpu_handle(name)
3050        }
3051        fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
3052            self.inner.set_gpu_handle_feed(handle_name, output_index);
3053            true
3054        }
3055        fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
3056            self.inner.read_gpu_handle(name)
3057        }
3058        fn register_kv_row_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
3059            self.inner.register_kv_row_feed(handle_name, output_index);
3060            true
3061        }
3062        fn feed_kv_row(&mut self, src_row: usize, dst_row: usize, row_elems: usize) -> bool {
3063            self.inner.feed_kv_row(src_row, dst_row, row_elems);
3064            true
3065        }
3066        fn read_output_row(
3067            &self,
3068            out_idx: usize,
3069            row: usize,
3070            row_inner: usize,
3071        ) -> Option<Vec<f32>> {
3072            Some(self.inner.read_graph_output_row(out_idx, row, row_inner))
3073        }
3074        fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
3075            self.inner.run_slots(inputs)
3076        }
3077        fn arena_ptr(&self) -> *const u8 {
3078            self.inner.arena_ptr()
3079        }
3080        fn commit_no_wait(&mut self, inputs: &[(&str, &[f32])]) {
3081            self.inner.commit_no_wait(inputs);
3082        }
3083        fn sync_pending(&mut self) {
3084            self.inner.sync_pending();
3085        }
3086        fn run_pipelined(&mut self, input_sets: &[Vec<(&str, &[f32])>]) -> Vec<Vec<Vec<f32>>> {
3087            self.inner.run_pipelined(input_sets)
3088        }
3089        fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
3090            self.inner.set_active_extent(extent);
3091        }
3092
3093        fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
3094            self.inner.set_rng(rng);
3095        }
3096
3097        fn rng(&self) -> rlx_ir::RngOptions {
3098            self.inner.rng()
3099        }
3100
3101        /// Typed param upload — accepts F16/BF16 host bytes by widening
3102        /// to F32 first, then routing through `set_param`. The Metal
3103        /// arena's `write_from_f32` honors per-node F16 storage when
3104        /// AutoMixedPrecision rewrote the param. U8/I8 packed weights
3105        /// copy directly into the arena for `Op::DequantMatMul`.
3106        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
3107            if matches!(
3108                dtype,
3109                rlx_ir::DType::U8
3110                    | rlx_ir::DType::I8
3111                    | rlx_ir::DType::I32
3112                    | rlx_ir::DType::I64
3113                    | rlx_ir::DType::U32
3114                    | rlx_ir::DType::F64
3115            ) {
3116                self.inner.set_param_bytes(name, data);
3117                return;
3118            }
3119            if dtype == rlx_ir::DType::F32 {
3120                let n = data.len() / 4;
3121                let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
3122                self.inner.set_param(name, s);
3123            } else {
3124                let f32_buf = super::widen_bytes_to_f32(data, dtype);
3125                self.inner.set_param(name, &f32_buf);
3126            }
3127        }
3128
3129        /// Typed run. Integer inputs (I64 token ids, etc.) are copied
3130        /// directly into the unified-memory arena; F32/F16/BF16 widen
3131        /// through the existing host path. Outputs use native arena bytes.
3132        fn run_typed(
3133            &mut self,
3134            inputs: &[(&str, &[u8], rlx_ir::DType)],
3135        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
3136            self.inner.run_typed(inputs)
3137        }
3138
3139        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
3140            Box::new(MetalExecutableWrapper {
3141                inner: self.inner.clone_for_cache(),
3142                io_manifest: self.io_manifest.clone(),
3143            })
3144        }
3145    }
3146}
3147
3148// ── CUDA Backend ────────────────────────────────────────────────────────
3149
3150#[cfg(feature = "cuda")]
3151pub mod cuda_backend {
3152    use super::*;
3153    use rlx_cuda::backend::CudaExecutable;
3154
3155    pub struct CudaBackend;
3156
3157    /// PLAN L4: ops the CUDA backend can lower today. Excludes
3158    /// FusedSwiGLU, LoraMatMul, FusedTransformerLayer (no kernel) +
3159    /// If, While (no executor wiring). `FusedAttentionBlock` IS claimed:
3160    /// the `FuseAttentionBlock` pass fires, then `CudaExecutable`'s own
3161    /// `unfuse` decomposes it back to the primitive chain (matmul →
3162    /// narrow → rope → attention → matmul) — same fuse-then-unfuse the
3163    /// WGPU backend uses. DotGeneral via LowerDotGeneral; ElementwiseRegion
3164    /// lowered natively by an NVRTC interpreted-chain kernel.
3165    const CUDA_SUPPORTED_OPS: &[rlx_ir::OpKind] = {
3166        use rlx_ir::OpKind::*;
3167        &[
3168            Input,
3169            Param,
3170            Constant,
3171            Activation,
3172            Cast,
3173            StopGradient,
3174            Binary,
3175            Compare,
3176            Where,
3177            ElementwiseRegion,
3178            TransformRegion,
3179            BatchElementwiseRegion,
3180            MatMul,
3181            ScaledMatMul,
3182            ScaledQuantize,
3183            ScaledQuantScale,
3184            ScaledDequantize,
3185            DotGeneral,
3186            LayerNorm,
3187            LayerNorm2d,
3188            GroupNorm,
3189            ResizeNearest2x,
3190            AxialRope2d,
3191            Reverse,
3192            ArgMax,
3193            ArgMin,
3194            RmsNorm,
3195            Attention,
3196            AttentionBackward,
3197            RmsNormBackwardInput,
3198            RmsNormBackwardGamma,
3199            RmsNormBackwardBeta,
3200            RopeBackward,
3201            CumsumBackward,
3202            GatherBackward,
3203            Conv2dBackwardInput,
3204            Conv2dBackwardWeight,
3205            MaxPool2dBackward,
3206            Rope,
3207            Reshape,
3208            Transpose,
3209            Narrow,
3210            Concat,
3211            Expand,
3212            Gather,
3213            Reduce,
3214            Softmax,
3215            Cumsum,
3216            TopK,
3217            Sample,
3218            Conv,
3219            ConvTranspose2d,
3220            Pool,
3221            GroupedMatMul,
3222            DequantGroupedMatMul,
3223            DequantMoEWeights,
3224            ScatterAdd,
3225            DequantMatMul,
3226            SelectiveScan,
3227            Lstm,
3228            FusedMatMulBiasAct,
3229            FusedResidualLN,
3230            FusedResidualRmsNorm,
3231            // Fused, then decomposed by the backend's own `unfuse` pass
3232            // (rlx-cuda / rlx-rocm) before lowering — no monolithic
3233            // fused-attention kernel yet, same fuse-then-unfuse as WGPU.
3234            FusedAttentionBlock,
3235            GaussianSplatRender,
3236            GaussianSplatRenderBackward,
3237            GaussianSplatPrepare,
3238            GaussianSplatRasterize,
3239            Custom,
3240            Fft,
3241            LogMel,
3242            LogMelBackward,
3243            WelchPeaks,
3244            Im2Col,
3245            RngNormal,
3246            RngUniform,
3247        ]
3248    };
3249
3250    impl Backend for CudaBackend {
3251        fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
3252            CUDA_SUPPORTED_OPS
3253        }
3254
3255        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
3256            use rlx_opt::pass::Pass as _;
3257            // Decompose FusedSwiGLU / FAB / etc. before legalization (CudaExecutable
3258            // unfuses again; this pass is idempotent).
3259            let graph = rlx_cuda::unfuse::unfuse(graph);
3260            let graph = rlx_opt::legalize_or_rewrite_for_backend(graph, CUDA_SUPPORTED_OPS)
3261                .unwrap_or_else(|errors| {
3262                    panic!("{}", rlx_opt::format_legalize_error("cuda", &errors));
3263                });
3264            let graph = crate::precompile::precompile_cleanup(graph, options);
3265            // Mid-axis broadcasts (EEG patch embed) before elementwise fusion.
3266            let graph = rlx_opt::LegalizeBroadcast.run(graph);
3267            // Backend-aware fusion via the shared compile pipeline.
3268            let compile_result = crate::stages::compile_graph_stages_for_backend(
3269                rlx_driver::Device::Cuda,
3270                graph,
3271                options,
3272                CUDA_SUPPORTED_OPS,
3273            );
3274            crate::stages::maybe_log_fusion(&compile_result.fusion);
3275            let graph = compile_result.lir.into_graph();
3276            let graph = match options.policy.clone() {
3277                Some(p) => rlx_opt::AutoMixedPrecision::new(p).run(graph),
3278                None => graph,
3279            };
3280            let (graph, io_manifest) = cpu_low_precision::prepare_f32_exec_graph(graph);
3281            Box::new(CudaExecutableWrapper {
3282                inner: CudaExecutable::compile_rng(graph, options.rng),
3283                io_manifest,
3284            })
3285        }
3286
3287        fn compile_lir(
3288            &self,
3289            lir: LirModule,
3290            options: &CompileOptions,
3291        ) -> Box<dyn ExecutableGraph> {
3292            use rlx_opt::pass::Pass as _;
3293            let graph = rlx_opt::LegalizeBroadcast.run(lir.into_graph());
3294            let (graph, io_manifest) =
3295                cpu_low_precision::prepare_f32_exec_graph(prepare_fused_graph(
3296                    rlx_cuda::unfuse::unfuse(graph),
3297                    options,
3298                    CUDA_SUPPORTED_OPS,
3299                    "cuda",
3300                ));
3301            Box::new(CudaExecutableWrapper {
3302                inner: CudaExecutable::compile_rng(graph, options.rng),
3303                io_manifest,
3304            })
3305        }
3306    }
3307
3308    struct CudaExecutableWrapper {
3309        inner: CudaExecutable,
3310        io_manifest: cpu_low_precision::IoDtypeManifest,
3311    }
3312
3313    // CudaExecutable owns CudaContext + CudaSlice handles; cudarc claims
3314    // they're Send (CudaContext is Arc-wrapped, CudaSlice is logically
3315    // a device pointer + length). The Backend trait requires Send for
3316    // the executable; we honor that here.
3317    unsafe impl Send for CudaExecutableWrapper {}
3318
3319    impl ExecutableGraph for CudaExecutableWrapper {
3320        fn set_param(&mut self, name: &str, data: &[f32]) {
3321            self.inner.set_param(name, data);
3322        }
3323        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
3324            self.inner.run(inputs)
3325        }
3326        fn run_read_outputs(
3327            &mut self,
3328            inputs: &[(&str, &[f32])],
3329            read_indices: Option<&[usize]>,
3330        ) -> Vec<Vec<f32>> {
3331            self.inner.run_read_outputs(inputs, read_indices)
3332        }
3333        fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
3334            self.inner.bind_gpu_handle(name, data)
3335        }
3336        fn has_gpu_handle(&self, name: &str) -> bool {
3337            self.inner.has_gpu_handle(name)
3338        }
3339        fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
3340            self.inner.set_gpu_handle_feed(handle_name, output_index);
3341            true
3342        }
3343        fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
3344            self.inner.read_gpu_handle(name)
3345        }
3346        fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
3347            self.inner.set_active_extent(extent);
3348        }
3349
3350        fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
3351            self.inner.set_rng(rng);
3352        }
3353
3354        fn rng(&self) -> rlx_ir::RngOptions {
3355            self.inner.rng()
3356        }
3357
3358        fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
3359            self.inner.run_slots(inputs)
3360        }
3361
3362        fn arena_ptr(&self) -> *const u8 {
3363            self.inner.arena_ptr()
3364        }
3365
3366        /// Typed param upload — widens F16/BF16 host bytes to f32
3367        /// before routing through `set_param`. CUDA's arena is
3368        /// f32-uniform; the half-precision matmul tier opts in via
3369        /// the separate `set_param_half` API.
3370        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
3371            if matches!(dtype, rlx_ir::DType::U8 | rlx_ir::DType::I8) {
3372                self.inner.set_param_bytes(name, data);
3373                return;
3374            }
3375            if dtype == rlx_ir::DType::F32 {
3376                let n = data.len() / 4;
3377                let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
3378                self.inner.set_param(name, s);
3379            } else {
3380                let f32_buf = super::widen_bytes_to_f32(data, dtype);
3381                self.inner.set_param(name, &f32_buf);
3382            }
3383        }
3384
3385        /// Typed run — widen each typed input to F32, run, then narrow
3386        /// each output back to its declared graph dtype.
3387        fn run_typed(
3388            &mut self,
3389            inputs: &[(&str, &[u8], rlx_ir::DType)],
3390        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
3391            let mut owned: Vec<(String, Vec<f32>)> = Vec::with_capacity(inputs.len());
3392            for (name, data, dt) in inputs {
3393                let v = super::widen_bytes_to_f32(data, *dt);
3394                owned.push((name.to_string(), v));
3395            }
3396            let refs: Vec<(&str, &[f32])> = owned
3397                .iter()
3398                .map(|(n, d)| (n.as_str(), d.as_slice()))
3399                .collect();
3400            let dtypes =
3401                super::declared_output_dtypes(&self.io_manifest, self.inner.output_dtypes());
3402            let outs = self.inner.run(&refs);
3403            outs.into_iter()
3404                .zip(
3405                    dtypes
3406                        .into_iter()
3407                        .chain(std::iter::repeat(rlx_ir::DType::F32)),
3408                )
3409                .map(|(v, dt)| (super::narrow_f32_to_bytes(&v, dt), dt))
3410                .collect()
3411        }
3412
3413        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
3414            Box::new(CudaExecutableWrapper {
3415                inner: self.inner.clone_for_cache(),
3416                io_manifest: self.io_manifest.clone(),
3417            })
3418        }
3419    }
3420}
3421
3422// ── ROCm Backend ────────────────────────────────────────────────────────
3423
3424#[cfg(feature = "rocm")]
3425pub mod rocm_backend {
3426    use super::*;
3427    use rlx_rocm::backend::RocmExecutable;
3428
3429    pub struct RocmBackend;
3430
3431    /// PLAN L4: ROCm is the sister crate of CUDA; identical Step
3432    /// enum + dispatch shape → identical claimed op set.
3433    const ROCM_SUPPORTED_OPS: &[rlx_ir::OpKind] = {
3434        use rlx_ir::OpKind::*;
3435        &[
3436            Input,
3437            Param,
3438            Constant,
3439            Activation,
3440            Cast,
3441            StopGradient,
3442            Binary,
3443            Compare,
3444            Where,
3445            ElementwiseRegion,
3446            TransformRegion,
3447            BatchElementwiseRegion,
3448            MatMul,
3449            ScaledMatMul,
3450            ScaledQuantize,
3451            ScaledQuantScale,
3452            ScaledDequantize,
3453            DotGeneral,
3454            LayerNorm,
3455            LayerNorm2d,
3456            GroupNorm,
3457            ResizeNearest2x,
3458            AxialRope2d,
3459            Reverse,
3460            ArgMax,
3461            ArgMin,
3462            RmsNorm,
3463            Attention,
3464            AttentionBackward,
3465            RmsNormBackwardInput,
3466            RmsNormBackwardGamma,
3467            RmsNormBackwardBeta,
3468            RopeBackward,
3469            CumsumBackward,
3470            GatherBackward,
3471            Rope,
3472            Reshape,
3473            Transpose,
3474            Narrow,
3475            Concat,
3476            Expand,
3477            Gather,
3478            Reduce,
3479            Softmax,
3480            Cumsum,
3481            TopK,
3482            Sample,
3483            Conv,
3484            ConvTranspose2d,
3485            Pool,
3486            GroupedMatMul,
3487            DequantGroupedMatMul,
3488            DequantMoEWeights,
3489            ScatterAdd,
3490            DequantMatMul,
3491            SelectiveScan,
3492            Lstm,
3493            FusedMatMulBiasAct,
3494            FusedResidualLN,
3495            FusedResidualRmsNorm,
3496            // Fused, then decomposed by the backend's own `unfuse` pass
3497            // (rlx-cuda / rlx-rocm) before lowering — no monolithic
3498            // fused-attention kernel yet, same fuse-then-unfuse as WGPU.
3499            FusedAttentionBlock,
3500            GaussianSplatRender,
3501            GaussianSplatRenderBackward,
3502            GaussianSplatPrepare,
3503            GaussianSplatRasterize,
3504            Custom,
3505            Fft,
3506            LogMel,
3507            LogMelBackward,
3508            WelchPeaks,
3509            Im2Col,
3510            RngNormal,
3511            RngUniform,
3512        ]
3513    };
3514
3515    impl Backend for RocmBackend {
3516        fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
3517            ROCM_SUPPORTED_OPS
3518        }
3519
3520        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
3521            use rlx_opt::pass::Pass as _;
3522            let graph = rlx_rocm::unfuse::unfuse(graph);
3523            let graph = rlx_opt::legalize_or_rewrite_for_backend(graph, ROCM_SUPPORTED_OPS)
3524                .unwrap_or_else(|errors| {
3525                    panic!("{}", rlx_opt::format_legalize_error("rocm", &errors));
3526                });
3527            let graph = crate::precompile::precompile_cleanup(graph, options);
3528            let graph = rlx_opt::LegalizeBroadcast.run(graph);
3529            let compile_result = crate::stages::compile_graph_stages_for_backend(
3530                rlx_driver::Device::Rocm,
3531                graph,
3532                options,
3533                ROCM_SUPPORTED_OPS,
3534            );
3535            crate::stages::maybe_log_fusion(&compile_result.fusion);
3536            let graph = compile_result.lir.into_graph();
3537            let graph = match options.policy.clone() {
3538                Some(p) => rlx_opt::AutoMixedPrecision::new(p).run(graph),
3539                None => graph,
3540            };
3541            let (graph, io_manifest) = cpu_low_precision::prepare_f32_exec_graph(graph);
3542            Box::new(RocmExecutableWrapper {
3543                inner: RocmExecutable::compile_rng(graph, options.rng),
3544                io_manifest,
3545            })
3546        }
3547
3548        fn compile_lir(
3549            &self,
3550            lir: LirModule,
3551            options: &CompileOptions,
3552        ) -> Box<dyn ExecutableGraph> {
3553            let (graph, io_manifest) =
3554                cpu_low_precision::prepare_f32_exec_graph(prepare_fused_graph(
3555                    rlx_rocm::unfuse::unfuse(lir.into_graph()),
3556                    options,
3557                    ROCM_SUPPORTED_OPS,
3558                    "rocm",
3559                ));
3560            Box::new(RocmExecutableWrapper {
3561                inner: RocmExecutable::compile_rng(graph, options.rng),
3562                io_manifest,
3563            })
3564        }
3565    }
3566
3567    struct RocmExecutableWrapper {
3568        inner: RocmExecutable,
3569        io_manifest: cpu_low_precision::IoDtypeManifest,
3570    }
3571
3572    // Same Send-claim shape as CudaExecutableWrapper. RocmExecutable
3573    // owns Arc<RocmContext> + HipBuffer handles; the HipRuntime bundle
3574    // is internally thread-safe per AMD's documentation.
3575    unsafe impl Send for RocmExecutableWrapper {}
3576
3577    impl ExecutableGraph for RocmExecutableWrapper {
3578        fn set_param(&mut self, name: &str, data: &[f32]) {
3579            self.inner.set_param(name, data);
3580        }
3581        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
3582            self.inner.run(inputs)
3583        }
3584        fn run_read_outputs(
3585            &mut self,
3586            inputs: &[(&str, &[f32])],
3587            read_indices: Option<&[usize]>,
3588        ) -> Vec<Vec<f32>> {
3589            self.inner.run_read_outputs(inputs, read_indices)
3590        }
3591        fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
3592            self.inner.bind_gpu_handle(name, data)
3593        }
3594        fn has_gpu_handle(&self, name: &str) -> bool {
3595            self.inner.has_gpu_handle(name)
3596        }
3597        fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
3598            self.inner.set_gpu_handle_feed(handle_name, output_index);
3599            true
3600        }
3601        fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
3602            self.inner.read_gpu_handle(name)
3603        }
3604        fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
3605            self.inner.run_slots(inputs)
3606        }
3607        fn arena_ptr(&self) -> *const u8 {
3608            self.inner.arena_ptr()
3609        }
3610        fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
3611            self.inner.set_active_extent(extent);
3612        }
3613
3614        fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
3615            self.inner.set_rng(rng);
3616        }
3617
3618        fn rng(&self) -> rlx_ir::RngOptions {
3619            self.inner.rng()
3620        }
3621
3622        /// Typed param upload — widens F16/BF16 host bytes to f32
3623        /// before routing through `set_param`. ROCm's arena is
3624        /// f32-uniform; the half-precision matmul tier opts in via
3625        /// the separate `set_param_half` API.
3626        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
3627            if matches!(dtype, rlx_ir::DType::U8 | rlx_ir::DType::I8) {
3628                self.inner.set_param_bytes(name, data);
3629                return;
3630            }
3631            if dtype == rlx_ir::DType::F32 {
3632                let n = data.len() / 4;
3633                let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
3634                self.inner.set_param(name, s);
3635            } else {
3636                let f32_buf = super::widen_bytes_to_f32(data, dtype);
3637                self.inner.set_param(name, &f32_buf);
3638            }
3639        }
3640
3641        /// Typed run — widen each typed input to F32, run, then narrow
3642        /// each output back to its declared graph dtype.
3643        fn run_typed(
3644            &mut self,
3645            inputs: &[(&str, &[u8], rlx_ir::DType)],
3646        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
3647            let mut owned: Vec<(String, Vec<f32>)> = Vec::with_capacity(inputs.len());
3648            for (name, data, dt) in inputs {
3649                let v = super::widen_bytes_to_f32(data, *dt);
3650                owned.push((name.to_string(), v));
3651            }
3652            let refs: Vec<(&str, &[f32])> = owned
3653                .iter()
3654                .map(|(n, d)| (n.as_str(), d.as_slice()))
3655                .collect();
3656            let dtypes =
3657                super::declared_output_dtypes(&self.io_manifest, self.inner.output_dtypes());
3658            let outs = self.inner.run(&refs);
3659            outs.into_iter()
3660                .zip(
3661                    dtypes
3662                        .into_iter()
3663                        .chain(std::iter::repeat(rlx_ir::DType::F32)),
3664                )
3665                .map(|(v, dt)| (super::narrow_f32_to_bytes(&v, dt), dt))
3666                .collect()
3667        }
3668
3669        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
3670            Box::new(RocmExecutableWrapper {
3671                inner: self.inner.clone_for_cache(),
3672                io_manifest: self.io_manifest.clone(),
3673            })
3674        }
3675    }
3676}
3677
3678// ── TPU Backend ─────────────────────────────────────────────────────────
3679
3680#[cfg(feature = "tpu")]
3681pub mod tpu_backend {
3682    use super::*;
3683    use rlx_tpu::TpuExecutable;
3684
3685    pub struct TpuBackend;
3686
3687    /// Ops the TPU backend lowers to HLO. Full inference parity with
3688    /// rlx-cuda / rlx-rocm. Composite ops (FusedSwiGLU /
3689    /// FusedTransformerLayer / LoraMatMul / If / While) are unfused
3690    /// inside `rlx_tpu::unfuse::unfuse` ahead of HLO emission, so they
3691    /// don't appear here. `FusedAttentionBlock` IS claimed (for legalize
3692    /// + op-coverage); the same `unfuse` pass decomposes it to the
3693    /// primitive chain before HLO, so no HLO-level FAB op is emitted.
3694    const TPU_SUPPORTED_OPS: &[rlx_ir::OpKind] = {
3695        use rlx_ir::OpKind::*;
3696        &[
3697            Input,
3698            Param,
3699            Constant,
3700            Activation,
3701            Cast,
3702            StopGradient,
3703            Binary,
3704            Compare,
3705            Where,
3706            ElementwiseRegion,
3707            TransformRegion,
3708            BatchElementwiseRegion,
3709            MatMul,
3710            DotGeneral,
3711            LayerNorm,
3712            RmsNorm,
3713            Attention,
3714            Rope,
3715            Reshape,
3716            Transpose,
3717            Narrow,
3718            Concat,
3719            Expand,
3720            Gather,
3721            Reduce,
3722            Softmax,
3723            Cumsum,
3724            TopK,
3725            Sample,
3726            Conv,
3727            Pool,
3728            GroupedMatMul,
3729            DequantGroupedMatMul,
3730            DequantMoEWeights,
3731            ScatterAdd,
3732            DequantMatMul,
3733            SelectiveScan,
3734            // Real-INT8 path + fake-quant.
3735            QMatMul,
3736            QConv2d,
3737            Quantize,
3738            Dequantize,
3739            FusedMatMulBiasAct,
3740            FusedResidualLN,
3741            FusedResidualRmsNorm,
3742            // Claimed for legalize/coverage; `rlx_tpu::unfuse::unfuse`
3743            // decomposes it to the primitive chain ahead of HLO emission.
3744            FusedAttentionBlock,
3745            Fft,
3746            LogMel,
3747            LogMelBackward,
3748            WelchPeaks,
3749            RngNormal,
3750            RngUniform,
3751            // Splat: no on-chip kernel — lowered to common primitive MIR via logical_kernel.
3752        ]
3753    };
3754
3755    impl Backend for TpuBackend {
3756        fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
3757            TPU_SUPPORTED_OPS
3758        }
3759
3760        fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
3761            let graph = rlx_opt::legalize_or_rewrite_for_backend_with_config(
3762                graph,
3763                TPU_SUPPORTED_OPS,
3764                options.kernel_dispatch,
3765            )
3766            .unwrap_or_else(|errors| {
3767                panic!("{}", rlx_opt::format_legalize_error("tpu", &errors));
3768            });
3769            // The TPU's IR-side pass pipeline (DCE, ConstFold,
3770            // FuseResidualLN, FuseMatMulBiasAct, LegalizeBroadcast,
3771            // MarkElementwiseRegions) lives inside
3772            // `TpuExecutable::compile` so the same passes run whether
3773            // a caller goes through Session or invokes the executable
3774            // directly. We only do backend-cross-cutting work here:
3775            // legalization (must precede the pipeline so we panic
3776            // early on unsupported ops) and AutoMixedPrecision.
3777            //
3778            // Default policy on TPU is `AutoMixedBf16`: BF16 is the
3779            // native compute dtype on TPU silicon and recent GPUs,
3780            // and XLA's CPU plugin handles it natively too. Callers
3781            // can opt out by passing an explicit `PrecisionPolicy`
3782            // (e.g. `AlwaysF32` for accuracy debugging or
3783            // `AlwaysF16` to match a CUDA workload's choice).
3784            use rlx_opt::pass::Pass as _;
3785            let policy = options
3786                .policy
3787                .clone()
3788                .unwrap_or(rlx_opt::PrecisionPolicy::AutoMixedBf16);
3789            let graph = rlx_opt::AutoMixedPrecision::new(policy).run(graph);
3790            let _ = options.dce;
3791            let _ = options.constant_folding;
3792            Box::new(TpuExecutableWrapper {
3793                inner: TpuExecutable::compile_rng_with_param_bytes(
3794                    graph,
3795                    options.rng,
3796                    options.quant_param_bindings.as_ref(),
3797                ),
3798            })
3799        }
3800    }
3801
3802    struct TpuExecutableWrapper {
3803        inner: TpuExecutable,
3804    }
3805
3806    // PJRT clients + buffers are documented as thread-safe per the
3807    // upstream C API. Same Send-claim shape as CudaExecutableWrapper /
3808    // RocmExecutableWrapper.
3809    unsafe impl Send for TpuExecutableWrapper {}
3810
3811    impl ExecutableGraph for TpuExecutableWrapper {
3812        fn set_param(&mut self, name: &str, data: &[f32]) {
3813            self.inner.set_param(name, data);
3814        }
3815        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
3816            self.inner.run(inputs)
3817        }
3818
3819        /// Typed param upload — widens F16/BF16/etc. host bytes to
3820        /// f32 today. Once the HLO emitter speaks bf16 natively
3821        /// (which TPUs prefer over f16), the typed path will hand
3822        /// the original bytes straight through `Buffer_FromHostBuffer`.
3823        fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
3824            if dtype == rlx_ir::DType::F32 {
3825                let n = data.len() / 4;
3826                let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
3827                self.inner.set_param(name, s);
3828            } else {
3829                let f32_buf = super::widen_bytes_to_f32(data, dtype);
3830                self.inner.set_param(name, &f32_buf);
3831            }
3832        }
3833
3834        fn run_typed(
3835            &mut self,
3836            inputs: &[(&str, &[u8], rlx_ir::DType)],
3837        ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
3838            let mut owned: Vec<(String, Vec<f32>)> = Vec::with_capacity(inputs.len());
3839            for (name, data, dt) in inputs {
3840                let v = super::widen_bytes_to_f32(data, *dt);
3841                owned.push((name.to_string(), v));
3842            }
3843            let refs: Vec<(&str, &[f32])> = owned
3844                .iter()
3845                .map(|(n, d)| (n.as_str(), d.as_slice()))
3846                .collect();
3847            let dtypes = self.inner.output_dtypes();
3848            let outs = self.inner.run(&refs);
3849            outs.into_iter()
3850                .zip(
3851                    dtypes
3852                        .into_iter()
3853                        .chain(std::iter::repeat(rlx_ir::DType::F32)),
3854                )
3855                .map(|(v, dt)| (super::narrow_f32_to_bytes(&v, dt), dt))
3856                .collect()
3857        }
3858
3859        fn clone_box(&self) -> Box<dyn ExecutableGraph> {
3860            Box::new(TpuExecutableWrapper {
3861                inner: self.inner.clone_for_cache(),
3862            })
3863        }
3864    }
3865}
3866
3867/// QNN (Hexagon NPU) backend adapter — wraps the `rlx-qnn` FFI runtime
3868/// (`Device::Hexagon`). Milestone 1: a single rank-2 MatMul executed in-process
3869/// on a QNN backend library (libQnnCpu.so / libQnnHtp.so) through the dynamic
3870/// QNN C API. Mirrors `coreml_backend`, the other NPU adapter.
3871#[cfg(feature = "qnn")]
3872pub mod qnn_backend {
3873    use super::*;
3874    use rlx_qnn::runtime::QnnExecutable;
3875
3876    pub struct QnnBackend;
3877
3878    impl Backend for QnnBackend {
3879        // `supported_ops()` keeps the trait default (empty = "accept
3880        // everything"): a non-empty list makes LegalizeForBackend reject every
3881        // other kind, including structural `Input`/`Output`. Milestone 1's
3882        // recognizer (`Model::from_graph`) already errors clearly on any graph
3883        // that isn't a single MatMul, so we don't gate via legalize yet.
3884
3885        fn compile(&self, graph: Graph, _options: &CompileOptions) -> Box<dyn ExecutableGraph> {
3886            let exec = QnnExecutable::compile_graph(&graph)
3887                .unwrap_or_else(|e| panic!("rlx-qnn compile failed: {e}"));
3888            Box::new(QnnExecutableWrapper { inner: exec })
3889        }
3890
3891        fn compile_lir(
3892            &self,
3893            lir: LirModule,
3894            options: &CompileOptions,
3895        ) -> Box<dyn ExecutableGraph> {
3896            // No LIR arena path for QNN (milestone-1 single matmul): reconstruct
3897            // the graph and go through the normal compile.
3898            self.compile(lir.into_graph(), options)
3899        }
3900    }
3901
3902    struct QnnExecutableWrapper {
3903        inner: QnnExecutable,
3904    }
3905
3906    impl ExecutableGraph for QnnExecutableWrapper {
3907        fn set_param(&mut self, name: &str, data: &[f32]) {
3908            // Bind static weights (Param tensors) by name; unknown names are
3909            // ignored inside the executable.
3910            self.inner.set_param(name, data);
3911        }
3912
3913        fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
3914            self.inner
3915                .run(inputs)
3916                .unwrap_or_else(|e| panic!("rlx-qnn run failed: {e}"))
3917        }
3918    }
3919}