Skip to main content

vyre_reference/
oob.rs

1//! Out-of-bounds rules enforced by the parity engine.
2//!
3//! GPU drivers differ on what happens when a shader indexes past the end of a
4//! buffer: some clamp, some return zero, some crash. The reference interpreter
5//! eliminates that ambiguity by defining one deterministic behavior  -  defined-type
6//! zero-fill for scalar loads, empty slice for `Bytes`, and silent no-op for stores.
7//! Any backend that diverges from these rules fails the conform gate.
8
9use vyre::ir::DataType as IrDataType;
10
11use crate::value::Value;
12use vyre::ir::DataType;
13
14use std::cell::Cell;
15use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
16
17/// Count of out-of-bounds accesses the interpreter silently absorbed during one
18/// tracked run (see [`crate::reference_eval_oob_report`]).
19///
20/// The reference interpreter DEFINES OOB loads as zero-fill and OOB stores as a
21/// no-op (see the module docstring) so its output stays deterministic. That
22/// silent absorption is exactly what MASKS a GPU/CPU parity hazard: an IR program
23/// with an ungated data-derived index "works" here but a real GPU (CUDA does no
24/// bounds-checking) reads garbage / corrupts memory. This report surfaces the
25/// masking so a test can assert a program NEVER relies on it, a correctly-gated
26/// program handles an out-of-contract index with explicit control flow and thus
27/// records ZERO OOB accesses even on hostile input.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct OobReport {
30    /// Scalar/`Bytes` loads whose index fell outside the buffer (zero-filled).
31    pub oob_loads: u64,
32    /// Stores whose index fell outside the buffer (dropped).
33    pub oob_stores: u64,
34    /// Atomic loads/stores whose index fell outside the buffer.
35    pub oob_atomics: u64,
36}
37
38impl OobReport {
39    /// Total OOB accesses of every kind. Zero means the run never indexed past a
40    /// buffer end (the invariant a bounds-gated program upholds).
41    #[must_use]
42    pub fn total(&self) -> u64 {
43        self.oob_loads
44            .saturating_add(self.oob_stores)
45            .saturating_add(self.oob_atomics)
46    }
47}
48
49thread_local! {
50    /// Per-thread OOB tally. The interpreter runs single-threaded per call, so a
51    /// thread-local cleanly brackets one run without global cross-run contention.
52    static OOB_COUNTS: Cell<OobReport> = const { Cell::new(OobReport {
53        oob_loads: 0,
54        oob_stores: 0,
55        oob_atomics: 0,
56    }) };
57}
58
59fn record_oob_load() {
60    OOB_COUNTS.with(|c| {
61        let mut r = c.get();
62        r.oob_loads = r.oob_loads.saturating_add(1);
63        c.set(r);
64    });
65}
66
67fn record_oob_store() {
68    OOB_COUNTS.with(|c| {
69        let mut r = c.get();
70        r.oob_stores = r.oob_stores.saturating_add(1);
71        c.set(r);
72    });
73}
74
75fn record_oob_atomic() {
76    OOB_COUNTS.with(|c| {
77        let mut r = c.get();
78        r.oob_atomics = r.oob_atomics.saturating_add(1);
79        c.set(r);
80    });
81}
82
83/// Reset this thread's OOB tally to zero. Call before a tracked run.
84pub fn reset_oob_report() {
85    OOB_COUNTS.with(|c| c.set(OobReport::default()));
86}
87
88/// Read this thread's accumulated OOB tally (does not reset).
89#[must_use]
90pub fn oob_report() -> OobReport {
91    OOB_COUNTS.with(Cell::get)
92}
93
94/// Typed bytes backing one declared IR buffer.
95///
96/// This struct exists to give the reference interpreter a single place to enforce
97/// stride-correct indexing and OOB semantics, independent of how any GPU driver
98/// handles buffer bounds.
99#[derive(Debug, Clone)]
100pub struct Buffer {
101    pub(crate) bytes: Arc<RwLock<Vec<u8>>>,
102    pub(crate) element: IrDataType,
103}
104
105impl Buffer {
106    /// Create a buffer from typed bytes.
107    #[must_use]
108    pub fn new(bytes: Vec<u8>, element: DataType) -> Self {
109        Self {
110            bytes: Arc::new(RwLock::new(bytes)),
111            element,
112        }
113    }
114
115    /// Acquire the byte buffer for reading, failing closed on poison.
116    ///
117    /// A poisoned lock means a writer panicked mid-store, leaving the bytes
118    /// inconsistent. Silently recovering with `into_inner()` would let the CPU
119    /// reference oracle emit corrupt golden values that the conform gate then
120    /// trusts as truth (a silent correctness fallback (Law 10). Surface it).
121    fn read_bytes(&self) -> RwLockReadGuard<'_, Vec<u8>> {
122        self.bytes
123            .read()
124            .unwrap_or_else(|_| panic!("reference Buffer byte lock was poisoned"))
125    }
126
127    /// Acquire the byte buffer for writing, failing closed on poison (see
128    /// [`Buffer::read_bytes`]).
129    fn write_bytes(&self) -> RwLockWriteGuard<'_, Vec<u8>> {
130        self.bytes
131            .write()
132            .unwrap_or_else(|_| panic!("reference Buffer byte lock was poisoned"))
133    }
134
135    pub(crate) fn len(&self) -> u32 {
136        let bytes_guard = self.read_bytes();
137        let count = if let Some(bits) = self.element.bit_width() {
138            bytes_guard
139                .len()
140                .checked_mul(8)
141                .map(|total_bits| total_bits / bits)
142                .unwrap_or(usize::MAX)
143        } else if let Some(stride) = self.element.size_bytes() {
144            if stride == 0 {
145                bytes_guard.len()
146            } else {
147                bytes_guard.len() / stride
148            }
149        } else {
150            bytes_guard.len()
151        };
152        match u32::try_from(count) {
153            Ok(value) => value,
154            Err(_) => {
155                debug_assert!(
156                    false,
157                    "Buffer::len overflowed u32::MAX for byte_len={}; element={:?}. \
158                     Fix: split or downsize the buffer so per-element indexing remains representable.",
159                    bytes_guard.len(),
160                    self.element
161                );
162                u32::MAX
163            }
164        }
165    }
166
167    pub(crate) fn byte_len(&self) -> usize {
168        self.read_bytes().len()
169    }
170
171    pub(crate) fn element(&self) -> &IrDataType {
172        &self.element
173    }
174
175    pub(crate) fn zero_fill(&self) {
176        self.write_bytes().fill(0);
177    }
178
179    pub(crate) fn into_bytes(self) -> Vec<u8> {
180        // Same poison policy as the guard helpers: a poisoned lock is a corrupt
181        // reference buffer, never silently laundered.
182        std::sync::Arc::try_unwrap(self.bytes)
183            .map(|rw| {
184                rw.into_inner()
185                    .unwrap_or_else(|_| panic!("reference Buffer byte lock was poisoned"))
186            })
187            .unwrap_or_else(|shared| {
188                shared
189                    .read()
190                    .unwrap_or_else(|_| panic!("reference Buffer byte lock was poisoned"))
191                    .clone()
192            })
193    }
194
195    /// Consume this buffer and return its contents as a Value.
196    #[must_use]
197    pub fn to_value(self) -> crate::value::Value {
198        crate::value::Value::from(self.into_bytes())
199    }
200}
201
202pub(crate) fn load(buffer: &Buffer, index: u32) -> Value {
203    let bytes_guard = buffer.read_bytes();
204    let stride = buffer.element.min_bytes();
205    let ty = ir_to_conform_type(buffer.element.clone());
206    if matches!(buffer.element, IrDataType::Bytes) {
207        let offset = index as usize;
208        if offset > bytes_guard.len() {
209            record_oob_load();
210            return Value::from(Vec::new());
211        }
212        return Value::from(&bytes_guard[offset..]);
213    }
214    let Some(offset) = byte_offset(index, stride) else {
215        record_oob_load();
216        return Value::try_zero_for(ty).unwrap_or_else(|| Value::from(Vec::new()));
217    };
218    if stride == 0 || offset + stride > bytes_guard.len() {
219        record_oob_load();
220        return Value::try_zero_for(ty).unwrap_or_else(|| Value::from(Vec::new()));
221    }
222    read_element(ty.clone(), &bytes_guard[offset..offset + stride])
223        .unwrap_or_else(|_| Value::try_zero_for(ty).unwrap_or_else(|| Value::from(Vec::new())))
224}
225
226pub(crate) fn store(buffer: &mut Buffer, index: u32, value: &Value) {
227    let mut bytes_guard = buffer.write_bytes();
228    let stride = buffer.element.min_bytes();
229    if matches!(buffer.element, IrDataType::Bytes) {
230        let offset = index as usize;
231        if offset >= bytes_guard.len() {
232            record_oob_store();
233            return;
234        }
235        let bytes = value.to_bytes();
236        let available = bytes_guard.len() - offset;
237        let write_len = bytes.len().min(available);
238        bytes_guard[offset..offset + write_len].copy_from_slice(&bytes[..write_len]);
239        return;
240    }
241    let Some(offset) = byte_offset(index, stride) else {
242        record_oob_store();
243        return;
244    };
245    if stride == 0 || offset + stride > bytes_guard.len() {
246        record_oob_store();
247        return;
248    }
249    write_element(
250        buffer.element.clone(),
251        &mut bytes_guard[offset..offset + stride],
252        value,
253    );
254}
255
256pub(crate) fn atomic_load(buffer: &Buffer, index: u32) -> Option<u32> {
257    let bytes_guard = buffer.read_bytes();
258    let stride = buffer.element.min_bytes().max(4);
259    let Some(offset) = byte_offset(index, stride) else {
260        record_oob_atomic();
261        return None;
262    };
263    if offset + 4 > bytes_guard.len() {
264        record_oob_atomic();
265        None
266    } else {
267        Some(read_u32(&bytes_guard[offset..offset + 4]))
268    }
269}
270
271pub(crate) fn atomic_store(buffer: &mut Buffer, index: u32, value: u32) {
272    let mut bytes_guard = buffer.write_bytes();
273    let stride = buffer.element.min_bytes().max(4);
274    let Some(offset) = byte_offset(index, stride) else {
275        record_oob_atomic();
276        return;
277    };
278    if offset + 4 <= bytes_guard.len() {
279        write_u32(&mut bytes_guard[offset..offset + 4], value);
280    } else {
281        record_oob_atomic();
282    }
283}
284
285fn byte_offset(index: u32, stride: usize) -> Option<usize> {
286    (index as usize).checked_mul(stride)
287}
288
289fn write_element(element: IrDataType, target: &mut [u8], value: &Value) {
290    match element {
291        IrDataType::U32 => {
292            value.write_bytes_width_into(target);
293        }
294        IrDataType::I32 => {
295            value.write_bytes_width_into(target);
296        }
297        IrDataType::Bool => {
298            value.write_bytes_width_into(target);
299        }
300        IrDataType::U64 => {
301            value.write_bytes_width_into(target);
302        }
303        IrDataType::F32 => {
304            // Value::Float carries an f64; the GPU buffer is four bytes
305            // of f32, so narrow via `as f32` before writing. Dropping the
306            // upper four bytes of `v.to_le_bytes()` (what the default
307            // to_bytes_width path does) would mangle the f32 bit pattern.
308            let v = match value {
309                Value::Float(v) => *v as f32,
310                Value::U32(v) => f32::from_bits(*v),
311                _ => 0.0,
312            };
313            let v = crate::execution::typed_ops::canonical_f32(v);
314            target.copy_from_slice(&v.to_le_bytes());
315        }
316        IrDataType::Bytes | IrDataType::Vec2U32 | IrDataType::Vec4U32 => {
317            value.write_bytes_width_into(target);
318        }
319        _ => {
320            value.write_bytes_width_into(target);
321        }
322    }
323}
324
325fn read_element(ty: DataType, bytes: &[u8]) -> Result<Value, String> {
326    Value::from_element_bytes(ty, bytes)
327}
328
329fn read_u32(bytes: &[u8]) -> u32 {
330    u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
331}
332
333fn write_u32(bytes: &mut [u8], value: u32) {
334    bytes.copy_from_slice(&value.to_le_bytes());
335}
336
337fn ir_to_conform_type(ty: IrDataType) -> DataType {
338    match ty {
339        IrDataType::U32 => DataType::U32,
340        IrDataType::I32 => DataType::I32,
341        IrDataType::U64 => DataType::U64,
342        IrDataType::F32 => DataType::F32,
343        IrDataType::F64 => DataType::F64,
344        IrDataType::Vec2U32 => DataType::Vec2U32,
345        IrDataType::Vec4U32 => DataType::Vec4U32,
346        IrDataType::Bool => DataType::U32,
347        IrDataType::Bytes => DataType::Bytes,
348        other => other,
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn f32_bits(value: Value) -> u32 {
357        match value {
358            Value::Float(value) => (value as f32).to_bits(),
359            other => {
360                let bytes = other.to_bytes();
361                u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
362            }
363        }
364    }
365
366    #[test]
367    fn f32_load_canonicalizes_subnormal_and_nan_payloads() {
368        let positive_subnormal = Buffer::new(1u32.to_le_bytes().to_vec(), DataType::F32);
369        assert_eq!(f32_bits(load(&positive_subnormal, 0)), 0x0000_0000);
370
371        let negative_subnormal = Buffer::new(0x8000_0001u32.to_le_bytes().to_vec(), DataType::F32);
372        assert_eq!(f32_bits(load(&negative_subnormal, 0)), 0x8000_0000);
373
374        let payload_nan = Buffer::new(0x7fa0_0001u32.to_le_bytes().to_vec(), DataType::F32);
375        assert_eq!(f32_bits(load(&payload_nan, 0)), 0x7fc0_0000);
376    }
377
378    #[test]
379    fn f32_store_canonicalizes_subnormal_and_nan_payloads() {
380        let mut subnormal = Buffer::new(vec![0; 4], DataType::F32);
381        store(
382            &mut subnormal,
383            0,
384            &Value::Float(f64::from(f32::from_bits(0x8000_0001))),
385        );
386        assert_eq!(f32_bits(subnormal.to_value()), 0x8000_0000);
387
388        let mut payload_nan = Buffer::new(vec![0; 4], DataType::F32);
389        store(&mut payload_nan, 0, &Value::U32(0x7fa0_0001));
390        assert_eq!(f32_bits(payload_nan.to_value()), 0x7fc0_0000);
391    }
392
393    #[test]
394    fn oob_accesses_are_counted_and_in_bounds_are_not() {
395        // The OOB tally must count exactly the accesses the interpreter silently
396        // absorbs (zero-fill loads / dropped stores), and nothing in-bounds, this
397        // is the signal that reveals an ungated data-derived index.
398        reset_oob_report();
399        let buf = Buffer::new(vec![0u8; 8], DataType::U32); // 2 elements
400        let _ = load(&buf, 0);
401        let _ = load(&buf, 1);
402        assert_eq!(oob_report().total(), 0, "in-bounds loads must not count");
403
404        let _ = load(&buf, 2); // element 2 of 2 → OOB
405        let _ = load(&buf, 99); // far OOB
406        let after_loads = oob_report();
407        assert_eq!(after_loads.oob_loads, 2, "two OOB loads counted");
408        assert_eq!(after_loads.oob_stores, 0);
409
410        let mut wbuf = Buffer::new(vec![0u8; 8], DataType::U32);
411        store(&mut wbuf, 1, &Value::U32(7)); // in bounds
412        store(&mut wbuf, 5, &Value::U32(9)); // OOB → dropped
413        let after_store = oob_report();
414        assert_eq!(
415            after_store.oob_stores, 1,
416            "one OOB store counted, in-bounds not"
417        );
418
419        let mut abuf = Buffer::new(vec![0u8; 8], DataType::U32);
420        atomic_store(&mut abuf, 7, 3); // OOB atomic
421        assert_eq!(oob_report().oob_atomics, 1, "OOB atomic store counted");
422
423        reset_oob_report();
424        assert_eq!(oob_report().total(), 0, "reset clears the tally");
425    }
426
427    #[test]
428    fn poisoned_reference_buffer_lock_is_not_silently_recovered() {
429        // A writer that panics mid-store poisons the lock. The reference oracle
430        // must fail closed on a subsequent access rather than handing back the
431        // half-mutated bytes (which would silently produce a corrupt golden
432        // value the conform gate then trusts). Law 10.
433        let buffer = Buffer::new(vec![0u8; 8], DataType::U32);
434        let poisoner = buffer.clone();
435        let _ = std::thread::spawn(move || {
436            let _guard = poisoner.write_bytes();
437            panic!("poison reference buffer lock mid-store");
438        })
439        .join();
440
441        let panic = std::panic::catch_unwind(|| {
442            let _ = buffer.len();
443        })
444        .expect_err("poisoned reference Buffer lock must panic instead of recovering");
445        let message = panic
446            .downcast_ref::<String>()
447            .map(String::as_str)
448            .or_else(|| panic.downcast_ref::<&'static str>().copied())
449            .unwrap_or("<non-string panic>");
450        assert!(
451            message.contains("reference Buffer byte lock was poisoned"),
452            "panic must name the poisoned reference buffer lock, got: {message}"
453        );
454    }
455}