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