Skip to main content

rlx_runtime/backend/
mod.rs

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