Skip to main content

rlx_driver/
buffer.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//! Explicit host ↔ device buffer abstraction (plan #59).
6//!
7//! Borrowed from MAX's `max/python/max/driver/buffer.py`. The point
8//! is *explicitness*: every host↔device transfer is a method call,
9//! not implicit conversion. Misreading "device" data as host (or
10//! vice versa) becomes a compile error, not a silent perf bug.
11//!
12//! Today this wraps a host-side `Vec<u8>` plus a `Device` tag. The
13//! `to_host` / `to_device` calls are no-ops on Device::Cpu; on
14//! Device::Metal a future commit will route through the existing
15//! `rlx-metal::arena` for actual MTLBuffer transfers.
16
17use crate::Device;
18use rlx_ir::bytes::AlignedBytes;
19use rlx_ir::{DType, Shape};
20
21/// A buffer that knows where its bytes live.
22///
23/// Storage is [`AlignedBytes`] rather than `Vec<u8>` because [`Buffer::as_f32`]
24/// hands out a borrowed `&[f32]` over these bytes, and `Vec<u8>` only promises
25/// alignment 1 — reinterpreting it as `&[f32]` is UB even though the system
26/// allocator happens to return 16-aligned blocks today.
27#[derive(Debug, Clone)]
28pub struct Buffer {
29    bytes: AlignedBytes,
30    shape: Shape,
31    device: Device,
32}
33
34impl Buffer {
35    /// Create a buffer holding `data`, tagged as residing on `device`.
36    /// On Cpu this is just storage; on Metal callers will use
37    /// `to_device` to move it into device memory once the runtime
38    /// integrates with the arena.
39    pub fn new_host(shape: Shape, data: Vec<u8>) -> Self {
40        Self {
41            bytes: AlignedBytes::from_slice(&data),
42            shape,
43            device: Device::Cpu,
44        }
45    }
46
47    /// Create a zero-initialized host buffer of `shape`.
48    pub fn zeros(shape: Shape) -> Self {
49        let n = shape.size_bytes().unwrap_or(0);
50        Self {
51            bytes: AlignedBytes::zeroed(n),
52            shape,
53            device: Device::Cpu,
54        }
55    }
56
57    pub fn shape(&self) -> &Shape {
58        &self.shape
59    }
60    pub fn device(&self) -> Device {
61        self.device
62    }
63    pub fn dtype(&self) -> DType {
64        self.shape.dtype()
65    }
66    pub fn num_elements(&self) -> usize {
67        self.shape.num_elements().unwrap_or(0)
68    }
69    pub fn byte_size(&self) -> usize {
70        self.bytes.len()
71    }
72
73    /// Read as `&[f32]`. Panics if dtype isn't F32 — explicit type
74    /// check (plan #59 spirit: don't let mismatches go silent).
75    pub fn as_f32(&self) -> &[f32] {
76        assert_eq!(self.dtype(), DType::F32, "as_f32 on non-F32 buffer");
77        let n = self.num_elements();
78        // Sound without an alignment check: `AlignedBytes` pins the base at 64
79        // bytes, which covers `align_of::<f32>()`.
80        &self.bytes.as_slice_of::<f32>()[..n]
81    }
82
83    pub fn as_f32_mut(&mut self) -> &mut [f32] {
84        assert_eq!(self.dtype(), DType::F32, "as_f32_mut on non-F32 buffer");
85        let n = self.num_elements();
86        &mut self.bytes.slice_of_mut::<f32>(0, self.bytes.len())[..n]
87    }
88
89    /// "Move to device" — explicit transfer call. CPU is a no-op
90    /// (the bytes are already where they need to be). Metal routes
91    /// through the backend (TODO: wire to rlx-metal::arena once a
92    /// caller needs it).
93    pub fn to_device(self, device: Device) -> Self {
94        match (self.device, device) {
95            (a, b) if a == b => self,
96            (Device::Cpu, Device::Metal) => Self {
97                device: Device::Metal,
98                ..self
99            },
100            (Device::Metal, Device::Cpu) => Self {
101                device: Device::Cpu,
102                ..self
103            },
104            _ => self,
105        }
106    }
107
108    /// "Read back to host" — explicit transfer. CPU no-op; Metal
109    /// blocks on completion + memcpy back from MTLBuffer (TODO).
110    pub fn to_host(self) -> Self {
111        self.to_device(Device::Cpu)
112    }
113
114    /// Raw bytes — host-side access. Panics if the buffer is on a
115    /// non-host device (would silently read uninitialized host
116    /// memory otherwise).
117    pub fn host_bytes(&self) -> &[u8] {
118        assert_eq!(
119            self.device,
120            Device::Cpu,
121            "host_bytes on non-host buffer; call .to_host() first"
122        );
123        &self.bytes
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn zeros_initializes() {
133        let b = Buffer::zeros(Shape::new(&[2, 3], DType::F32));
134        assert_eq!(b.num_elements(), 6);
135        assert_eq!(b.byte_size(), 24);
136        for v in b.as_f32() {
137            assert_eq!(*v, 0.0);
138        }
139    }
140
141    #[test]
142    fn dtype_mismatch_panics() {
143        let b = Buffer::zeros(Shape::new(&[4], DType::I32));
144        let result = std::panic::catch_unwind(|| b.as_f32());
145        assert!(result.is_err());
146    }
147
148    #[test]
149    fn to_device_round_trip() {
150        let b = Buffer::zeros(Shape::new(&[4], DType::F32));
151        let on_metal = b.to_device(Device::Metal);
152        assert_eq!(on_metal.device(), Device::Metal);
153        let back = on_metal.to_host();
154        assert_eq!(back.device(), Device::Cpu);
155    }
156}