Skip to main content

ruda_test_utils/test_tensor/
io.rs

1//! On-disk format for [`HostData`].
2//!
3//! Used by the tuner to compare the output of two metal runs (current commit
4//! vs. trusted reference) without materializing both at the same time. Files
5//! are intended to live in a temp directory — there's no forward-compatibility
6//! story beyond a version byte that lets a reader reject an unknown format.
7//!
8//! Layout (little-endian throughout):
9//!
10//! ```text
11//!   offset  size   field
12//!   ------  ----   -----
13//!     0     4      magic = "CKHD"
14//!     4     1      version (currently 1)
15//!     5     1      dtype tag (0=F32, 1=I32, 2=Bool)
16//!     6     4      rank
17//!    10     8*rank shape
18//!    +     8*rank  strides
19//!    +     8       element count
20//!    +     n       packed element bytes
21//! ```
22//!
23//! Booleans are written as one byte each (0/1). The element count is the
24//! length of the packed data array (not `shape.product()` — strides may make
25//! the physical extent larger than the logical one).
26use ruda_kernel::dsl as kernel_dsl;
27use std::fs::File;
28use std::io::{self, BufReader, BufWriter, Read, Write};
29use std::path::Path;
30
31use ruda_kernel::dsl::zspace::Shape;
32use ruda_kernel::dsl::zspace::Strides;
33
34use crate::test_tensor::host_data::{HostData, HostDataVec};
35
36const MAGIC: &[u8; 4] = b"CKHD";
37const VERSION: u8 = 1;
38
39const TAG_F32: u8 = 0;
40const TAG_I32: u8 = 1;
41const TAG_BOOL: u8 = 2;
42
43/// Write `data` to `path` in the binary format documented at the module level.
44///
45/// Truncates any existing file. Returns the number of bytes written so callers
46/// can surface a "wrote N MiB" log line.
47pub fn write_host_data(path: &Path, data: &HostData) -> io::Result<u64> {
48    let f = File::create(path)?;
49    let mut w = BufWriter::new(f);
50
51    w.write_all(MAGIC)?;
52    w.write_all(&[VERSION])?;
53
54    let (tag, elem_count) = match &data.data {
55        HostDataVec::F32(v) => (TAG_F32, v.len()),
56        HostDataVec::I32(v) => (TAG_I32, v.len()),
57        HostDataVec::Bool(v) => (TAG_BOOL, v.len()),
58    };
59    w.write_all(&[tag])?;
60
61    let rank = data.shape.as_slice().len();
62    w.write_all(&(rank as u32).to_le_bytes())?;
63    for d in data.shape.as_slice() {
64        w.write_all(&(*d as u64).to_le_bytes())?;
65    }
66    let strides_slice: &[usize] = &data.strides;
67    if strides_slice.len() != rank {
68        return Err(io::Error::new(
69            io::ErrorKind::InvalidInput,
70            format!(
71                "strides rank {} != shape rank {}",
72                strides_slice.len(),
73                rank,
74            ),
75        ));
76    }
77    for s in strides_slice {
78        w.write_all(&(*s as u64).to_le_bytes())?;
79    }
80    w.write_all(&(elem_count as u64).to_le_bytes())?;
81
82    match &data.data {
83        HostDataVec::F32(v) => w.write_all(bytemuck::cast_slice(v))?,
84        HostDataVec::I32(v) => w.write_all(bytemuck::cast_slice(v))?,
85        HostDataVec::Bool(v) => {
86            // One byte per bool — keeps reads alignment-free and rare enough
87            // not to be worth bit-packing.
88            for b in v {
89                w.write_all(&[u8::from(*b)])?;
90            }
91        }
92    }
93
94    w.flush()?;
95    Ok(w.into_inner()
96        .map_err(|e| e.into_error())?
97        .metadata()?
98        .len())
99}
100
101/// Read a [`HostData`] previously produced by [`write_host_data`].
102///
103/// Errors with `InvalidData` for any header/version/tag mismatch — these
104/// usually mean the file came from a different Ruda version and should be
105/// regenerated.
106pub fn read_host_data(path: &Path) -> io::Result<HostData> {
107    let f = File::open(path)?;
108    let mut r = BufReader::new(f);
109
110    let mut magic = [0u8; 4];
111    r.read_exact(&mut magic)?;
112    if &magic != MAGIC {
113        return Err(invalid("wrong magic — file is not a HostData blob"));
114    }
115    let version = read_u8(&mut r)?;
116    if version != VERSION {
117        return Err(invalid(format!(
118            "unsupported HostData file version: {version} (expected {VERSION})"
119        )));
120    }
121    let tag = read_u8(&mut r)?;
122    let rank = read_u32(&mut r)? as usize;
123
124    let mut shape_dims = Vec::with_capacity(rank);
125    for _ in 0..rank {
126        shape_dims.push(read_u64(&mut r)? as usize);
127    }
128    let mut stride_dims = Vec::with_capacity(rank);
129    for _ in 0..rank {
130        stride_dims.push(read_u64(&mut r)? as usize);
131    }
132    let elem_count = read_u64(&mut r)? as usize;
133
134    let data = match tag {
135        TAG_F32 => {
136            let mut buf = vec![0u8; elem_count * std::mem::size_of::<f32>()];
137            r.read_exact(&mut buf)?;
138            // Guaranteed-aligned re-cast: build the Vec<f32> from the byte
139            // chunks rather than transmuting the buffer in place.
140            let mut v = Vec::with_capacity(elem_count);
141            for chunk in buf.chunks_exact(4) {
142                v.push(f32::from_le_bytes(chunk.try_into().unwrap()));
143            }
144            HostDataVec::F32(v)
145        }
146        TAG_I32 => {
147            let mut buf = vec![0u8; elem_count * std::mem::size_of::<i32>()];
148            r.read_exact(&mut buf)?;
149            let mut v = Vec::with_capacity(elem_count);
150            for chunk in buf.chunks_exact(4) {
151                v.push(i32::from_le_bytes(chunk.try_into().unwrap()));
152            }
153            HostDataVec::I32(v)
154        }
155        TAG_BOOL => {
156            let mut buf = vec![0u8; elem_count];
157            r.read_exact(&mut buf)?;
158            HostDataVec::Bool(buf.into_iter().map(|b| b != 0).collect())
159        }
160        other => return Err(invalid(format!("unknown HostData dtype tag: {other}"))),
161    };
162
163    Ok(HostData {
164        data,
165        shape: Shape::from(shape_dims),
166        strides: Strides::new(&stride_dims),
167    })
168}
169
170fn read_u8<R: Read>(r: &mut R) -> io::Result<u8> {
171    let mut b = [0u8; 1];
172    r.read_exact(&mut b)?;
173    Ok(b[0])
174}
175
176fn read_u32<R: Read>(r: &mut R) -> io::Result<u32> {
177    let mut b = [0u8; 4];
178    r.read_exact(&mut b)?;
179    Ok(u32::from_le_bytes(b))
180}
181
182fn read_u64<R: Read>(r: &mut R) -> io::Result<u64> {
183    let mut b = [0u8; 8];
184    r.read_exact(&mut b)?;
185    Ok(u64::from_le_bytes(b))
186}
187
188fn invalid<E: Into<String>>(msg: E) -> io::Error {
189    io::Error::new(io::ErrorKind::InvalidData, msg.into())
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn round_trip(label: &str, data: HostData) {
197        let dir =
198            std::env::temp_dir().join(format!("ruda-test-utils-iotest-{}", std::process::id(),));
199        std::fs::create_dir_all(&dir).unwrap();
200        let path = dir.join(format!("blob-{label}.bin"));
201        write_host_data(&path, &data).unwrap();
202        let read_back = read_host_data(&path).unwrap();
203        assert_eq!(data.shape, read_back.shape);
204        assert_eq!(data.strides, read_back.strides);
205        match (&data.data, &read_back.data) {
206            (HostDataVec::F32(a), HostDataVec::F32(b)) => assert_eq!(a, b),
207            (HostDataVec::I32(a), HostDataVec::I32(b)) => assert_eq!(a, b),
208            (HostDataVec::Bool(a), HostDataVec::Bool(b)) => assert_eq!(a, b),
209            _ => panic!("dtype mismatch on round-trip"),
210        }
211        let _ = std::fs::remove_file(&path);
212    }
213
214    #[test]
215    fn round_trip_f32() {
216        round_trip(
217            "f32",
218            HostData {
219                data: HostDataVec::F32(vec![1.0, -2.0, std::f32::consts::PI, 0.5, 0.0]),
220                shape: Shape::from(vec![5]),
221                strides: Strides::new(&[1]),
222            },
223        );
224    }
225
226    #[test]
227    fn round_trip_i32_2d() {
228        round_trip(
229            "i32",
230            HostData {
231                data: HostDataVec::I32(vec![1, 2, 3, 4, 5, 6]),
232                shape: Shape::from(vec![2, 3]),
233                strides: Strides::new(&[3, 1]),
234            },
235        );
236    }
237
238    #[test]
239    fn round_trip_bool() {
240        round_trip(
241            "bool",
242            HostData {
243                data: HostDataVec::Bool(vec![true, false, true, true, false]),
244                shape: Shape::from(vec![5]),
245                strides: Strides::new(&[1]),
246            },
247        );
248    }
249}