Skip to main content

rlx_driver/
arena.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-agnostic arena trait — the contract every backend's memory
6//! plan obeys.
7//!
8//! Lifted from CpuExecutable / MetalExecutable's previously duplicated
9//! arena helpers. Each new backend (CUDA, ROCm, wgpu, WASM, TPU) implements
10//! this trait once and gets:
11//!   - typed input feed (`f32 → arena_dtype`)
12//!   - typed output read (`arena_dtype → f32`)
13//!   - per-node byte offset resolution
14//!
15//! The trait deliberately exposes raw pointers / byte offsets rather than
16//! Rust slices so the same implementation works for host-resident memory
17//! (CPU/WASM), unified memory (Apple Silicon Metal/MPSGraph), and
18//! discrete-VRAM backends (CUDA/ROCm) where reading involves a copy.
19
20use rlx_ir::{DType, NodeId};
21
22/// Per-backend arena interface.
23///
24/// All concrete arenas — `rlx-cpu::Arena`, `rlx-metal::Arena`, future
25/// `rlx-cuda::Arena`, `rlx-wgpu::Arena` — implement this trait so the
26/// runtime can drive them uniformly. The actual byte layout is owned
27/// by the backend; we only require offset-based access.
28pub trait DeviceArena {
29    /// Byte offset of `id`'s buffer slot in the arena. `usize::MAX` for
30    /// nodes that don't have an arena slot (e.g. fused-away intermediates).
31    fn byte_offset(&self, id: NodeId) -> usize;
32
33    /// True if `id` has a real arena slot.
34    fn has_buffer(&self, id: NodeId) -> bool;
35
36    /// Total arena size in bytes.
37    fn size_bytes(&self) -> usize;
38
39    /// Write a host-side `f32` slice into `id`'s slot, casting to `dtype`
40    /// if necessary. Truncates to the buffer's capacity (no panic on overflow).
41    ///
42    /// For discrete-memory backends this involves a host→device copy; for
43    /// unified-memory backends (Apple Silicon, integrated GPUs) it's a
44    /// direct write.
45    fn write_input_f32(&mut self, id: NodeId, dtype: DType, data: &[f32]);
46
47    /// Read `id`'s slot as a host-side `Vec<f32>`, casting from `dtype` if
48    /// necessary. The number of elements is determined by the backend
49    /// based on the memory plan (typically `shape.num_elements()`).
50    fn read_output_f32(&self, id: NodeId, dtype: DType, n_elements: usize) -> Vec<f32>;
51}
52
53/// Helper: cast f32 input to bytes of `dtype` and write to `dst_ptr`.
54/// Used by every CPU-resident-arena backend. GPU backends can call this
55/// after staging into a host buffer, then upload.
56///
57/// `max_elems` is a count of `dtype` ELEMENTS, so every arm must write
58/// `dtype.size_bytes()` per element. The F32 fall-through writes 4 B/elem,
59/// which is only sound for the 4-byte dtypes (F32/I32/U32) — a narrower slot
60/// takes it as a 2x (I16) or 4x (U8/I8/Bool) buffer overrun. The narrow arms
61/// below convert numerically, the exact inverse of
62/// `rlx_runtime::backend::widen_bytes_to_f32`.
63pub unsafe fn write_typed_from_f32(dst_ptr: *mut u8, dtype: DType, src: &[f32], max_elems: usize) {
64    let n = src.len().min(max_elems);
65    match dtype {
66        DType::F64 => unsafe {
67            // F64 slots are 8 B/elem; widen the f32 input. (Values carry
68            // f32 precision — this is the f32 entry path; `run_typed`'s
69            // `all_f64` branch is the full-precision route.)
70            let dst = dst_ptr as *mut f64;
71            for i in 0..n {
72                *dst.add(i) = src[i] as f64;
73            }
74        },
75        DType::F16 => unsafe {
76            let dst = dst_ptr as *mut half::f16;
77            for i in 0..n {
78                *dst.add(i) = half::f16::from_f32(src[i]);
79            }
80        },
81        DType::BF16 => unsafe {
82            let dst = dst_ptr as *mut half::bf16;
83            for i in 0..n {
84                *dst.add(i) = half::bf16::from_f32(src[i]);
85            }
86        },
87        DType::C64 => unsafe {
88            // Interleaved [re, im, re, im, ...]; `max_elems` is complex count.
89            let dst = dst_ptr as *mut f32;
90            let n = src.len().min(max_elems.saturating_mul(2));
91            std::ptr::copy_nonoverlapping(src.as_ptr(), dst, n);
92        },
93        DType::C128 => unsafe {
94            // Interleaved [re, im, ...] f64 pairs (16 B/elem); `max_elems`
95            // is complex count. This is the f32 entry path: widen each
96            // interleaved f32 lane to f64 (values carry f32 precision).
97            let dst = dst_ptr as *mut f64;
98            let n = src.len().min(max_elems.saturating_mul(2));
99            for i in 0..n {
100                *dst.add(i) = src[i] as f64;
101            }
102        },
103        // 1-byte slots. `as` casts on floats saturate (and map NaN to 0),
104        // so an out-of-range value clamps rather than being UB.
105        DType::U8 => unsafe {
106            for i in 0..n {
107                *dst_ptr.add(i) = src[i] as u8;
108            }
109        },
110        DType::Bool => unsafe {
111            for i in 0..n {
112                *dst_ptr.add(i) = u8::from(src[i] != 0.0);
113            }
114        },
115        DType::I8 => unsafe {
116            let dst = dst_ptr as *mut i8;
117            for i in 0..n {
118                *dst.add(i) = src[i] as i8;
119            }
120        },
121        // 2-byte slot.
122        DType::I16 => unsafe {
123            let dst = dst_ptr as *mut i16;
124            for i in 0..n {
125                *dst.add(i) = src[i] as i16;
126            }
127        },
128        // F32 / I32 / U32: 4 B/elem, so the width matches. Integers keep the
129        // long-standing f32-bit-aliased representation of the f32 arena here.
130        _ => unsafe {
131            let dst = dst_ptr as *mut f32;
132            std::ptr::copy_nonoverlapping(src.as_ptr(), dst, n);
133        },
134    }
135}
136
137/// Helper: read `n_elems` of `dtype` from `src_ptr`, returning `Vec<f32>`.
138pub unsafe fn read_typed_to_f32(src_ptr: *const u8, dtype: DType, n_elems: usize) -> Vec<f32> {
139    match dtype {
140        DType::F64 => {
141            // F64 slots are 8 B/elem; narrow to f32 for the f32 read path.
142            let mut out = Vec::with_capacity(n_elems);
143            unsafe {
144                let src = src_ptr as *const f64;
145                for i in 0..n_elems {
146                    out.push(*src.add(i) as f32);
147                }
148            }
149            out
150        }
151        DType::F16 => {
152            let mut out = Vec::with_capacity(n_elems);
153            unsafe {
154                let src = src_ptr as *const half::f16;
155                for i in 0..n_elems {
156                    out.push((*src.add(i)).to_f32());
157                }
158            }
159            out
160        }
161        DType::BF16 => {
162            let mut out = Vec::with_capacity(n_elems);
163            unsafe {
164                let src = src_ptr as *const half::bf16;
165                for i in 0..n_elems {
166                    out.push((*src.add(i)).to_f32());
167                }
168            }
169            out
170        }
171        DType::C64 => unsafe {
172            // Interleaved [re, im, re, im, ...]; `n_elems` is complex count.
173            let src = src_ptr as *const f32;
174            std::slice::from_raw_parts(src, n_elems.saturating_mul(2)).to_vec()
175        },
176        DType::C128 => unsafe {
177            // Interleaved [re, im, ...] f64 pairs (16 B/elem); `n_elems` is
178            // complex count. f32 read path: narrow each f64 lane to f32.
179            let src = src_ptr as *const f64;
180            let lanes = n_elems.saturating_mul(2);
181            let mut out = Vec::with_capacity(lanes);
182            for i in 0..lanes {
183                out.push(*src.add(i) as f32);
184            }
185            out
186        },
187        // NOTE: narrow (U8/I8/Bool/I16) slots deliberately keep the f32 read
188        // below. This is `read_output`'s path, and on the CPU backend — which
189        // plans with `ArenaWidthPolicy::Native` — an integer/bool ACTIVATION is
190        // written by its kernel as f32 ("widen at compute", see the
191        // `plan_memory_hybrid` doc), so the f32 read is what matches it. That
192        // read runs past a natively-sized slot: it is the "integer-overrun
193        // risk" `plan_memory_hybrid` names, and closing it means settling
194        // whether a native-width U8 slot carries codes or widened f32 — a
195        // policy change, not a local fix. The WRITE path above is different:
196        // it is host input, where a native slot has no room for f32 at all.
197        _ => unsafe {
198            let src = src_ptr as *const f32;
199            std::slice::from_raw_parts(src, n_elems).to_vec()
200        },
201    }
202}