Skip to main content

oxiblas_matrix/
mmap.rs

1//! Memory-mapped matrix storage.
2//!
3//! This module provides memory-mapped matrices for working with large datasets
4//! that may not fit in RAM, or for sharing matrices between processes.
5//!
6//! # Features
7//!
8//! - **Large matrix support**: Work with matrices larger than available RAM
9//! - **Process sharing**: Multiple processes can map the same file
10//! - **Persistence**: Matrix data persists on disk
11//! - **Zero-copy**: No data copying when opening existing matrices
12//!
13//! # File Format
14//!
15//! Memory-mapped matrices use a simple binary format:
16//! - 8-byte magic number: "OXIBLAS\0"
17//! - 8-byte version (u64, little-endian)
18//! - 8-byte element type identifier
19//! - 8-byte nrows (u64, little-endian)
20//! - 8-byte ncols (u64, little-endian)
21//! - 8-byte row_stride (u64, little-endian)
22//! - Padding to 64-byte alignment
23//! - Column-major matrix data
24//!
25//! # Example
26//!
27//! ```
28//! # #[cfg(feature = "mmap")] {
29//! use oxiblas_matrix::mmap::{MmapMat, MmapMatMut};
30//!
31//! // Never hardcode a path: build one from the OS temp directory plus a
32//! // name unique to this process, so concurrent test runs cannot collide.
33//! let path = std::env::temp_dir().join(format!("oxiblas-doctest-{}.oxiblas", std::process::id()));
34//!
35//! // Create a new memory-mapped matrix
36//! {
37//!     let mut mmat = MmapMatMut::<f64>::create(&path, 1000, 1000)?;
38//!     // Initialize data...
39//!     mmat[(0, 0)] = 1.0;
40//! }
41//!
42//! // Open for reading
43//! let mmat = MmapMat::<f64>::open(&path)?;
44//! assert_eq!(mmat[(0, 0)], 1.0);
45//!
46//! std::fs::remove_file(&path)?;
47//! # }
48//! # Ok::<(), Box<dyn std::error::Error>>(())
49//! ```
50
51use crate::mat_mut::MatMut;
52use crate::mat_ref::MatRef;
53use memmap2::{Mmap, MmapMut, MmapOptions};
54use oxiblas_core::memory::DEFAULT_ALIGN;
55use oxiblas_core::scalar::Scalar;
56use std::fs::{File, OpenOptions};
57use std::io;
58use std::marker::PhantomData;
59use std::path::Path;
60
61/// Magic number for OxiBLAS matrix files.
62const MAGIC: &[u8; 8] = b"OXIBLAS\0";
63
64/// Current file format version.
65const VERSION: u64 = 1;
66
67/// Header size (padded to 64 bytes for alignment).
68const HEADER_SIZE: usize = 64;
69
70/// Type identifiers for elements.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[repr(u64)]
73pub enum ElementType {
74    /// 32-bit floating point
75    F32 = 1,
76    /// 64-bit floating point
77    F64 = 2,
78    /// 32-bit complex (2x f32)
79    C32 = 3,
80    /// 64-bit complex (2x f64)
81    C64 = 4,
82    /// 32-bit signed integer
83    I32 = 5,
84    /// 64-bit signed integer
85    I64 = 6,
86}
87
88impl ElementType {
89    /// Returns the size in bytes for this element type.
90    #[inline]
91    pub const fn size(self) -> usize {
92        match self {
93            Self::F32 => 4,
94            Self::F64 => 8,
95            Self::C32 => 8,
96            Self::C64 => 16,
97            Self::I32 => 4,
98            Self::I64 => 8,
99        }
100    }
101
102    /// Returns the element type for a given type parameter.
103    fn from_type<T: Scalar>() -> Option<Self> {
104        let size = core::mem::size_of::<T>();
105        let name = core::any::type_name::<T>();
106
107        if name.contains("f32") && size == 4 {
108            Some(Self::F32)
109        } else if name.contains("f64") && size == 8 {
110            Some(Self::F64)
111        } else if name.contains("Complex") && size == 8 {
112            Some(Self::C32)
113        } else if name.contains("Complex") && size == 16 {
114            Some(Self::C64)
115        } else if name.contains("i32") && size == 4 {
116            Some(Self::I32)
117        } else if name.contains("i64") && size == 8 {
118            Some(Self::I64)
119        } else {
120            None
121        }
122    }
123
124    fn from_u64(v: u64) -> Option<Self> {
125        match v {
126            1 => Some(Self::F32),
127            2 => Some(Self::F64),
128            3 => Some(Self::C32),
129            4 => Some(Self::C64),
130            5 => Some(Self::I32),
131            6 => Some(Self::I64),
132            _ => None,
133        }
134    }
135}
136
137/// Error type for memory-mapped matrix operations.
138#[derive(Debug)]
139pub enum MmapError {
140    /// I/O error from the underlying file system.
141    Io(io::Error),
142    /// Invalid file format (bad magic number or version).
143    InvalidFormat(String),
144    /// Type mismatch between requested type and file contents.
145    TypeMismatch {
146        /// The expected element type based on the requested type parameter.
147        expected: ElementType,
148        /// The actual element type found in the file.
149        found: ElementType,
150    },
151    /// Unsupported element type.
152    UnsupportedType,
153    /// Invalid dimensions.
154    InvalidDimensions(String),
155}
156
157impl std::fmt::Display for MmapError {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        match self {
160            Self::Io(e) => write!(f, "I/O error: {e}"),
161            Self::InvalidFormat(msg) => write!(f, "Invalid format: {msg}"),
162            Self::TypeMismatch { expected, found } => {
163                write!(f, "Type mismatch: expected {expected:?}, found {found:?}")
164            }
165            Self::UnsupportedType => write!(f, "Unsupported element type"),
166            Self::InvalidDimensions(msg) => write!(f, "Invalid dimensions: {msg}"),
167        }
168    }
169}
170
171impl std::error::Error for MmapError {
172    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
173        match self {
174            Self::Io(e) => Some(e),
175            _ => None,
176        }
177    }
178}
179
180impl From<io::Error> for MmapError {
181    fn from(e: io::Error) -> Self {
182        Self::Io(e)
183    }
184}
185
186/// File header for memory-mapped matrices.
187#[repr(C)]
188struct Header {
189    magic: [u8; 8],
190    version: u64,
191    elem_type: u64,
192    nrows: u64,
193    ncols: u64,
194    row_stride: u64,
195    _padding: [u8; 16], // Pad to 64 bytes
196}
197
198impl Header {
199    fn new<T: Scalar>(nrows: usize, ncols: usize, row_stride: usize) -> Result<Self, MmapError> {
200        let elem_type = ElementType::from_type::<T>().ok_or(MmapError::UnsupportedType)?;
201
202        Ok(Header {
203            magic: *MAGIC,
204            version: VERSION,
205            elem_type: elem_type as u64,
206            nrows: nrows as u64,
207            ncols: ncols as u64,
208            row_stride: row_stride as u64,
209            _padding: [0; 16],
210        })
211    }
212
213    fn validate<T: Scalar>(&self) -> Result<(), MmapError> {
214        // Check magic
215        if &self.magic != MAGIC {
216            return Err(MmapError::InvalidFormat("Invalid magic number".to_string()));
217        }
218
219        // Check version
220        if self.version != VERSION {
221            return Err(MmapError::InvalidFormat(format!(
222                "Unsupported version: {}",
223                self.version
224            )));
225        }
226
227        // Check element type
228        let file_type = ElementType::from_u64(self.elem_type).ok_or(MmapError::InvalidFormat(
229            format!("Unknown element type: {}", self.elem_type),
230        ))?;
231
232        let expected_type = ElementType::from_type::<T>().ok_or(MmapError::UnsupportedType)?;
233
234        if file_type != expected_type {
235            return Err(MmapError::TypeMismatch {
236                expected: expected_type,
237                found: file_type,
238            });
239        }
240
241        Ok(())
242    }
243
244    /// Validates that a mapping of `mmap_len` bytes is actually large enough to
245    /// back the matrix these header dimensions describe, and that `row_stride`
246    /// can hold `nrows` elements per column.
247    ///
248    /// # Why this check is safety-critical (not just a nicety)
249    ///
250    /// [`Header::validate`] only checks magic / version / element-type — it never
251    /// cross-checks the *claimed dimensions* against the *real file length*. A
252    /// truncated or hand-crafted `.oxiblas` file would therefore map successfully,
253    /// after which every element accessor (`get`, `Index`, `as_ref`, and — on the
254    /// writable map — `set` / `get_mut` / `IndexMut`) computes byte offsets from
255    /// these header dimensions and reads or **writes** past the end of the mapping.
256    /// That is out-of-bounds memory access (UB / SIGBUS / memory corruption)
257    /// reachable from a 100%-safe API. This must run *before* any pointer
258    /// arithmetic that trusts the header dimensions.
259    ///
260    /// A `checked_mul` overflow is itself proof the dimensions are bogus — they
261    /// cannot describe any real allocation — so the `None` (overflow) case is
262    /// treated as an error, not silently clamped.
263    fn validate_layout<T: Scalar>(&self, mmap_len: usize) -> Result<(), MmapError> {
264        let nrows = self.nrows as usize;
265        let ncols = self.ncols as usize;
266        let row_stride = self.row_stride as usize;
267
268        // A stride shorter than the row count would make columns overlap and
269        // desynchronize offset math from the layout the writer produced.
270        if row_stride < nrows {
271            return Err(MmapError::InvalidDimensions(format!(
272                "row_stride ({row_stride}) is smaller than nrows ({nrows})"
273            )));
274        }
275
276        // required = HEADER_SIZE + row_stride * ncols * size_of::<T>()
277        let required = row_stride
278            .checked_mul(ncols)
279            .and_then(|elems| elems.checked_mul(core::mem::size_of::<T>()))
280            .and_then(|data_bytes| data_bytes.checked_add(HEADER_SIZE))
281            .ok_or_else(|| {
282                MmapError::InvalidDimensions(format!(
283                    "dimensions overflow usize: nrows={nrows}, ncols={ncols}, row_stride={row_stride}"
284                ))
285            })?;
286
287        if mmap_len < required {
288            return Err(MmapError::InvalidDimensions(format!(
289                "file too small: {mmap_len} bytes present, {required} required for \
290                 {nrows}x{ncols} matrix (row_stride={row_stride})"
291            )));
292        }
293
294        Ok(())
295    }
296
297    fn to_bytes(&self) -> [u8; HEADER_SIZE] {
298        let mut bytes = [0u8; HEADER_SIZE];
299        bytes[0..8].copy_from_slice(&self.magic);
300        bytes[8..16].copy_from_slice(&self.version.to_le_bytes());
301        bytes[16..24].copy_from_slice(&self.elem_type.to_le_bytes());
302        bytes[24..32].copy_from_slice(&self.nrows.to_le_bytes());
303        bytes[32..40].copy_from_slice(&self.ncols.to_le_bytes());
304        bytes[40..48].copy_from_slice(&self.row_stride.to_le_bytes());
305        bytes
306    }
307
308    fn from_bytes(bytes: &[u8]) -> Result<Self, MmapError> {
309        if bytes.len() < HEADER_SIZE {
310            return Err(MmapError::InvalidFormat("Header too short".to_string()));
311        }
312
313        let mut magic = [0u8; 8];
314        magic.copy_from_slice(&bytes[0..8]);
315
316        Ok(Header {
317            magic,
318            version: u64::from_le_bytes(bytes[8..16].try_into().expect("slice is exactly 8 bytes")),
319            elem_type: u64::from_le_bytes(
320                bytes[16..24].try_into().expect("slice is exactly 8 bytes"),
321            ),
322            nrows: u64::from_le_bytes(bytes[24..32].try_into().expect("slice is exactly 8 bytes")),
323            ncols: u64::from_le_bytes(bytes[32..40].try_into().expect("slice is exactly 8 bytes")),
324            row_stride: u64::from_le_bytes(
325                bytes[40..48].try_into().expect("slice is exactly 8 bytes"),
326            ),
327            _padding: [0; 16],
328        })
329    }
330}
331
332/// Computes the row stride with padding for alignment.
333///
334/// # Errors
335///
336/// Returns [`MmapError::InvalidDimensions`] if rounding `nrows` up to a whole
337/// number of cache lines overflows `usize`.
338///
339/// # Why this is checked
340///
341/// The stride this returns is recorded in the on-disk header *and* used by
342/// every accessor to compute element offsets. A release-mode wraparound would
343/// produce a small stride (hence a small file) while the struct kept the
344/// caller's huge `nrows`/`ncols`, so subsequent `get`/`set` would index far
345/// past the end of the mapping. See [`Header::validate_layout`].
346fn compute_row_stride<T>(nrows: usize) -> Result<usize, MmapError> {
347    if nrows == 0 {
348        return Ok(0);
349    }
350
351    let elem_size = core::mem::size_of::<T>();
352    let elems_per_cacheline = DEFAULT_ALIGN / elem_size;
353
354    nrows
355        .div_ceil(elems_per_cacheline)
356        .checked_mul(elems_per_cacheline)
357        .ok_or_else(|| {
358            MmapError::InvalidDimensions(format!(
359                "row stride overflow: nrows={nrows} cannot be padded to a multiple \
360                 of {elems_per_cacheline} elements without exceeding usize::MAX"
361            ))
362        })
363}
364
365/// A read-only memory-mapped matrix.
366///
367/// This type maps a matrix file into memory for read-only access. Changes to
368/// the underlying file by other processes may be visible.
369pub struct MmapMat<T: Scalar> {
370    mmap: Mmap,
371    nrows: usize,
372    ncols: usize,
373    row_stride: usize,
374    _phantom: PhantomData<T>,
375}
376
377impl<T: Scalar> MmapMat<T> {
378    /// Opens an existing memory-mapped matrix file for reading.
379    ///
380    /// # Errors
381    ///
382    /// Returns an error if:
383    /// - The file cannot be opened or mapped
384    /// - The file format is invalid
385    /// - The element type doesn't match `T`
386    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, MmapError> {
387        let file = File::open(path)?;
388        let mmap = unsafe { MmapOptions::new().map(&file)? };
389
390        // Read and validate header
391        let header = Header::from_bytes(&mmap)?;
392        header.validate::<T>()?;
393        // Reject truncated/corrupt files BEFORE any accessor can dereference off
394        // the header dimensions (out-of-bounds read hazard from a safe API).
395        header.validate_layout::<T>(mmap.len())?;
396
397        Ok(MmapMat {
398            mmap,
399            nrows: header.nrows as usize,
400            ncols: header.ncols as usize,
401            row_stride: header.row_stride as usize,
402            _phantom: PhantomData,
403        })
404    }
405
406    /// Returns the number of rows.
407    #[inline]
408    pub fn nrows(&self) -> usize {
409        self.nrows
410    }
411
412    /// Returns the number of columns.
413    #[inline]
414    pub fn ncols(&self) -> usize {
415        self.ncols
416    }
417
418    /// Returns the shape as (nrows, ncols).
419    #[inline]
420    pub fn shape(&self) -> (usize, usize) {
421        (self.nrows, self.ncols)
422    }
423
424    /// Returns the row stride.
425    #[inline]
426    pub fn row_stride(&self) -> usize {
427        self.row_stride
428    }
429
430    /// Returns a pointer to the first element.
431    #[inline]
432    pub fn as_ptr(&self) -> *const T {
433        unsafe { self.mmap.as_ptr().add(HEADER_SIZE).cast() }
434    }
435
436    /// Returns an immutable view of the matrix.
437    #[inline]
438    pub fn as_ref(&self) -> MatRef<'_, T> {
439        // SAFETY: the memory map backs `nrows * ncols` (padded to `row_stride`)
440        // initialized, aligned elements for the borrow's lifetime, and
441        // `row_stride >= nrows`, so every in-bounds `(i, j)` offset is valid.
442        unsafe { MatRef::new(self.as_ptr(), self.nrows, self.ncols, self.row_stride) }
443    }
444
445    /// Returns the element at (row, col).
446    #[inline]
447    pub fn get(&self, row: usize, col: usize) -> Option<&T> {
448        if row < self.nrows && col < self.ncols {
449            Some(unsafe { &*self.as_ptr().add(row + col * self.row_stride) })
450        } else {
451            None
452        }
453    }
454
455    /// Advise the kernel about intended access pattern.
456    ///
457    /// This can improve performance for sequential or random access patterns.
458    #[cfg(unix)]
459    pub fn advise_sequential(&self) -> Result<(), MmapError> {
460        self.mmap.advise(memmap2::Advice::Sequential)?;
461        Ok(())
462    }
463
464    /// Advise the kernel that this region will be needed soon.
465    #[cfg(unix)]
466    pub fn advise_willneed(&self) -> Result<(), MmapError> {
467        self.mmap.advise(memmap2::Advice::WillNeed)?;
468        Ok(())
469    }
470}
471
472impl<T: Scalar> core::ops::Index<(usize, usize)> for MmapMat<T> {
473    type Output = T;
474
475    #[inline]
476    fn index(&self, (row, col): (usize, usize)) -> &Self::Output {
477        assert!(row < self.nrows && col < self.ncols, "Index out of bounds");
478        unsafe { &*self.as_ptr().add(row + col * self.row_stride) }
479    }
480}
481
482/// A mutable memory-mapped matrix.
483///
484/// This type maps a matrix file into memory for read-write access. Changes
485/// are automatically synced to the file.
486pub struct MmapMatMut<T: Scalar> {
487    mmap: MmapMut,
488    nrows: usize,
489    ncols: usize,
490    row_stride: usize,
491    _phantom: PhantomData<T>,
492}
493
494impl<T: Scalar> MmapMatMut<T> {
495    /// Creates a new memory-mapped matrix file.
496    ///
497    /// The file is created with the specified dimensions and initialized to zeros.
498    ///
499    /// # Errors
500    ///
501    /// Returns an error if:
502    /// - The file cannot be created
503    /// - The element type is not supported
504    pub fn create<P: AsRef<Path>>(path: P, nrows: usize, ncols: usize) -> Result<Self, MmapError> {
505        let row_stride = compute_row_stride::<T>(nrows)?;
506        // Every one of these products/sums is checked. With plain arithmetic a
507        // release build would wrap `data_size` down to a small value, create a
508        // tiny file, map it, and then construct `Self` with the caller's
509        // *original* huge `nrows`/`ncols`/`row_stride` — after which every safe
510        // `set`/`get_mut`/`IndexMut` would write past the end of the mapping.
511        // An overflow here proves the dimensions cannot describe any real
512        // allocation, so it is an error rather than a clamp.
513        let total_size = row_stride
514            .checked_mul(ncols)
515            .and_then(|elems| elems.checked_mul(core::mem::size_of::<T>()))
516            .and_then(|data_bytes| data_bytes.checked_add(HEADER_SIZE))
517            .ok_or_else(|| {
518                MmapError::InvalidDimensions(format!(
519                    "dimensions overflow usize: nrows={nrows}, ncols={ncols}, \
520                     row_stride={row_stride}"
521                ))
522            })?;
523
524        // Create and size the file
525        let file = OpenOptions::new()
526            .read(true)
527            .write(true)
528            .create(true)
529            .truncate(true)
530            .open(path)?;
531
532        file.set_len(total_size as u64)?;
533
534        // Map the file
535        let mut mmap = unsafe { MmapOptions::new().map_mut(&file)? };
536
537        // Write header
538        let header = Header::new::<T>(nrows, ncols, row_stride)?;
539
540        // Belt-and-braces: re-derive the required size from the header and
541        // compare it against the mapping we actually got. `set_len` can be
542        // truncated by a filesystem limit (and a racing writer can shrink the
543        // file between `set_len` and `map_mut`), and this map is WRITABLE, so
544        // an unchecked map here is an out-of-bounds *write* hazard exactly like
545        // the one `open` guards against.
546        header.validate_layout::<T>(mmap.len())?;
547
548        mmap[0..HEADER_SIZE].copy_from_slice(&header.to_bytes());
549
550        // Zero-initialize data (file should already be zero, but be safe)
551        mmap[HEADER_SIZE..].fill(0);
552
553        Ok(MmapMatMut {
554            mmap,
555            nrows,
556            ncols,
557            row_stride,
558            _phantom: PhantomData,
559        })
560    }
561
562    /// Opens an existing memory-mapped matrix file for reading and writing.
563    ///
564    /// # Errors
565    ///
566    /// Returns an error if:
567    /// - The file cannot be opened or mapped
568    /// - The file format is invalid
569    /// - The element type doesn't match `T`
570    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, MmapError> {
571        let file = OpenOptions::new().read(true).write(true).open(path)?;
572
573        let mmap = unsafe { MmapOptions::new().map_mut(&file)? };
574
575        // Read and validate header
576        let header = Header::from_bytes(&mmap)?;
577        header.validate::<T>()?;
578        // Reject truncated/corrupt files BEFORE any accessor can dereference off
579        // the header dimensions. This map is WRITABLE (set / IndexMut), so an
580        // unchecked map here is an out-of-bounds *write* hazard.
581        header.validate_layout::<T>(mmap.len())?;
582
583        Ok(MmapMatMut {
584            mmap,
585            nrows: header.nrows as usize,
586            ncols: header.ncols as usize,
587            row_stride: header.row_stride as usize,
588            _phantom: PhantomData,
589        })
590    }
591
592    /// Returns the number of rows.
593    #[inline]
594    pub fn nrows(&self) -> usize {
595        self.nrows
596    }
597
598    /// Returns the number of columns.
599    #[inline]
600    pub fn ncols(&self) -> usize {
601        self.ncols
602    }
603
604    /// Returns the shape as (nrows, ncols).
605    #[inline]
606    pub fn shape(&self) -> (usize, usize) {
607        (self.nrows, self.ncols)
608    }
609
610    /// Returns the row stride.
611    #[inline]
612    pub fn row_stride(&self) -> usize {
613        self.row_stride
614    }
615
616    /// Returns a pointer to the first element.
617    #[inline]
618    pub fn as_ptr(&self) -> *const T {
619        unsafe { self.mmap.as_ptr().add(HEADER_SIZE).cast() }
620    }
621
622    /// Returns a mutable pointer to the first element.
623    #[inline]
624    pub fn as_mut_ptr(&mut self) -> *mut T {
625        unsafe { self.mmap.as_mut_ptr().add(HEADER_SIZE).cast() }
626    }
627
628    /// Returns an immutable view of the matrix.
629    #[inline]
630    pub fn as_ref(&self) -> MatRef<'_, T> {
631        // SAFETY: the memory map backs `nrows * ncols` (padded to `row_stride`)
632        // initialized, aligned elements for the borrow's lifetime, and
633        // `row_stride >= nrows`, so every in-bounds `(i, j)` offset is valid.
634        unsafe { MatRef::new(self.as_ptr(), self.nrows, self.ncols, self.row_stride) }
635    }
636
637    /// Returns a mutable view of the matrix.
638    #[inline]
639    pub fn as_mut(&mut self) -> MatMut<'_, T> {
640        // SAFETY: `Header::validate_layout` (run by both `create` and `open`)
641        // proved the mapping is at least `row_stride * ncols` elements long and
642        // that `row_stride >= nrows`, so every in-bounds `(i, j)` offset lies
643        // within the map and no two indices alias; `&mut self` keeps the
644        // mapping alive and exclusive for the view's lifetime.
645        unsafe { MatMut::new(self.as_mut_ptr(), self.nrows, self.ncols, self.row_stride) }
646    }
647
648    /// Returns the element at (row, col).
649    #[inline]
650    pub fn get(&self, row: usize, col: usize) -> Option<&T> {
651        if row < self.nrows && col < self.ncols {
652            Some(unsafe { &*self.as_ptr().add(row + col * self.row_stride) })
653        } else {
654            None
655        }
656    }
657
658    /// Returns a mutable reference to the element at (row, col).
659    #[inline]
660    pub fn get_mut(&mut self, row: usize, col: usize) -> Option<&mut T> {
661        if row < self.nrows && col < self.ncols {
662            Some(unsafe { &mut *self.as_mut_ptr().add(row + col * self.row_stride) })
663        } else {
664            None
665        }
666    }
667
668    /// Sets the element at (row, col).
669    #[inline]
670    pub fn set(&mut self, row: usize, col: usize, value: T) {
671        assert!(row < self.nrows && col < self.ncols, "Index out of bounds");
672        unsafe {
673            *self.as_mut_ptr().add(row + col * self.row_stride) = value;
674        }
675    }
676
677    /// Flushes changes to disk synchronously.
678    ///
679    /// This ensures all modifications are written to the underlying file.
680    pub fn flush(&self) -> Result<(), MmapError> {
681        self.mmap.flush()?;
682        Ok(())
683    }
684
685    /// Flushes changes to disk asynchronously.
686    ///
687    /// This initiates a write but may return before completion.
688    pub fn flush_async(&self) -> Result<(), MmapError> {
689        self.mmap.flush_async()?;
690        Ok(())
691    }
692
693    /// Fills the matrix with a value.
694    pub fn fill(&mut self, value: T) {
695        for j in 0..self.ncols {
696            for i in 0..self.nrows {
697                self.set(i, j, value);
698            }
699        }
700    }
701
702    /// Copies data from a regular matrix.
703    pub fn copy_from(&mut self, src: &MatRef<'_, T>) {
704        assert_eq!(
705            self.shape(),
706            src.shape(),
707            "Matrix shapes must match for copy"
708        );
709
710        for j in 0..self.ncols {
711            for i in 0..self.nrows {
712                self.set(i, j, src[(i, j)]);
713            }
714        }
715    }
716
717    /// Advise the kernel about intended access pattern.
718    #[cfg(unix)]
719    pub fn advise_sequential(&self) -> Result<(), MmapError> {
720        self.mmap.advise(memmap2::Advice::Sequential)?;
721        Ok(())
722    }
723
724    /// Advise the kernel that this region will be needed soon.
725    #[cfg(unix)]
726    pub fn advise_willneed(&self) -> Result<(), MmapError> {
727        self.mmap.advise(memmap2::Advice::WillNeed)?;
728        Ok(())
729    }
730}
731
732impl<T: Scalar> core::ops::Index<(usize, usize)> for MmapMatMut<T> {
733    type Output = T;
734
735    #[inline]
736    fn index(&self, (row, col): (usize, usize)) -> &Self::Output {
737        assert!(row < self.nrows && col < self.ncols, "Index out of bounds");
738        unsafe { &*self.as_ptr().add(row + col * self.row_stride) }
739    }
740}
741
742impl<T: Scalar> core::ops::IndexMut<(usize, usize)> for MmapMatMut<T> {
743    #[inline]
744    fn index_mut(&mut self, (row, col): (usize, usize)) -> &mut Self::Output {
745        assert!(row < self.nrows && col < self.ncols, "Index out of bounds");
746        unsafe { &mut *self.as_mut_ptr().add(row + col * self.row_stride) }
747    }
748}
749
750/// Builder for creating memory-mapped matrices from existing data.
751pub struct MmapBuilder<T: Scalar> {
752    nrows: usize,
753    ncols: usize,
754    _phantom: PhantomData<T>,
755}
756
757impl<T: Scalar> MmapBuilder<T> {
758    /// Creates a new builder with the specified dimensions.
759    pub fn new(nrows: usize, ncols: usize) -> Self {
760        MmapBuilder {
761            nrows,
762            ncols,
763            _phantom: PhantomData,
764        }
765    }
766
767    /// Creates a new memory-mapped matrix file from a Mat.
768    pub fn from_mat<P: AsRef<Path>>(
769        self,
770        path: P,
771        mat: &MatRef<'_, T>,
772    ) -> Result<MmapMatMut<T>, MmapError> {
773        if mat.shape() != (self.nrows, self.ncols) {
774            return Err(MmapError::InvalidDimensions(format!(
775                "Builder dimensions ({}, {}) don't match matrix ({}, {})",
776                self.nrows,
777                self.ncols,
778                mat.nrows(),
779                mat.ncols()
780            )));
781        }
782
783        let mut mmat = MmapMatMut::create(path, self.nrows, self.ncols)?;
784        mmat.copy_from(mat);
785        mmat.flush()?;
786        Ok(mmat)
787    }
788
789    /// Creates a new memory-mapped matrix file from a flat slice (column-major).
790    pub fn from_slice<P: AsRef<Path>>(
791        self,
792        path: P,
793        data: &[T],
794    ) -> Result<MmapMatMut<T>, MmapError> {
795        let expected_len = self.nrows * self.ncols;
796        if data.len() != expected_len {
797            return Err(MmapError::InvalidDimensions(format!(
798                "Slice length {} doesn't match dimensions {} x {} = {}",
799                data.len(),
800                self.nrows,
801                self.ncols,
802                expected_len
803            )));
804        }
805
806        let mut mmat = MmapMatMut::create(path, self.nrows, self.ncols)?;
807
808        // Copy data column by column
809        for j in 0..self.ncols {
810            for i in 0..self.nrows {
811                mmat.set(i, j, data[i + j * self.nrows]);
812            }
813        }
814
815        mmat.flush()?;
816        Ok(mmat)
817    }
818}
819
820/// Utility function to write a Mat directly to a memory-mapped file.
821pub fn write_mat<T: Scalar, P: AsRef<Path>>(path: P, mat: &MatRef<'_, T>) -> Result<(), MmapError> {
822    let mut mmat = MmapMatMut::create(path, mat.nrows(), mat.ncols())?;
823    mmat.copy_from(mat);
824    mmat.flush()?;
825    Ok(())
826}
827
828/// Utility function to read dimensions from a memory-mapped matrix file without mapping the data.
829pub fn read_dimensions<P: AsRef<Path>>(path: P) -> Result<(usize, usize), MmapError> {
830    let mut file = File::open(path)?;
831    let mut header_bytes = [0u8; HEADER_SIZE];
832
833    use std::io::Read;
834    file.read_exact(&mut header_bytes)?;
835
836    let header = Header::from_bytes(&header_bytes)?;
837
838    // Basic validation (just magic and version)
839    if &header.magic != MAGIC {
840        return Err(MmapError::InvalidFormat("Invalid magic number".to_string()));
841    }
842    if header.version != VERSION {
843        return Err(MmapError::InvalidFormat(format!(
844            "Unsupported version: {}",
845            header.version
846        )));
847    }
848
849    Ok((header.nrows as usize, header.ncols as usize))
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    #[test]
857    fn test_mmap_create_and_open() {
858        let dir = std::env::temp_dir();
859        let path = dir.join("test_mmap_basic.oxiblas");
860
861        // Create a matrix
862        {
863            let mut mmat = MmapMatMut::<f64>::create(&path, 10, 10).unwrap();
864            for i in 0..10 {
865                for j in 0..10 {
866                    mmat[(i, j)] = (i * 10 + j) as f64;
867                }
868            }
869            mmat.flush().unwrap();
870        }
871
872        // Open and verify
873        {
874            let mmat = MmapMat::<f64>::open(&path).unwrap();
875            assert_eq!(mmat.shape(), (10, 10));
876            for i in 0..10 {
877                for j in 0..10 {
878                    assert_eq!(mmat[(i, j)], (i * 10 + j) as f64);
879                }
880            }
881        }
882
883        std::fs::remove_file(path).ok();
884    }
885
886    #[test]
887    fn test_mmap_views() {
888        let dir = std::env::temp_dir();
889        let path = dir.join("test_mmap_views.oxiblas");
890
891        let mut mmat = MmapMatMut::<f64>::create(&path, 5, 5).unwrap();
892
893        // Initialize through view
894        {
895            let mut view = mmat.as_mut();
896            for i in 0..5 {
897                view[(i, i)] = 1.0;
898            }
899        }
900
901        // Verify through immutable view
902        {
903            let view = mmat.as_ref();
904            for i in 0..5 {
905                for j in 0..5 {
906                    if i == j {
907                        assert_eq!(view[(i, j)], 1.0);
908                    } else {
909                        assert_eq!(view[(i, j)], 0.0);
910                    }
911                }
912            }
913        }
914
915        std::fs::remove_file(path).ok();
916    }
917
918    #[test]
919    fn test_mmap_f32() {
920        let dir = std::env::temp_dir();
921        let path = dir.join("test_mmap_f32.oxiblas");
922
923        {
924            let mut mmat = MmapMatMut::<f32>::create(&path, 3, 3).unwrap();
925            mmat[(0, 0)] = 1.0f32;
926            mmat[(1, 1)] = 2.0f32;
927            mmat[(2, 2)] = 3.0f32;
928            mmat.flush().unwrap();
929        }
930
931        {
932            let mmat = MmapMat::<f32>::open(&path).unwrap();
933            assert_eq!(mmat[(0, 0)], 1.0f32);
934            assert_eq!(mmat[(1, 1)], 2.0f32);
935            assert_eq!(mmat[(2, 2)], 3.0f32);
936        }
937
938        std::fs::remove_file(path).ok();
939    }
940
941    #[test]
942    fn test_mmap_type_mismatch() {
943        let dir = std::env::temp_dir();
944        let path = dir.join("test_mmap_type_mismatch.oxiblas");
945
946        // Create as f64
947        {
948            let _mmat = MmapMatMut::<f64>::create(&path, 5, 5).unwrap();
949        }
950
951        // Try to open as f32 - should fail
952        {
953            let result = MmapMat::<f32>::open(&path);
954            assert!(result.is_err());
955            if let Err(MmapError::TypeMismatch { expected, found }) = result {
956                assert_eq!(expected, ElementType::F32);
957                assert_eq!(found, ElementType::F64);
958            } else {
959                panic!("Expected TypeMismatch error");
960            }
961        }
962
963        std::fs::remove_file(path).ok();
964    }
965
966    #[test]
967    fn test_mmap_builder() {
968        use crate::Mat;
969
970        let dir = std::env::temp_dir();
971        let path = dir.join("test_mmap_builder.oxiblas");
972
973        // Create a regular matrix
974        let mat = Mat::<f64>::from_rows(&[&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]]);
975
976        // Build mmap from matrix
977        {
978            let builder = MmapBuilder::<f64>::new(2, 3);
979            let mmat = builder.from_mat(&path, &mat.as_ref()).unwrap();
980            assert_eq!(mmat.shape(), (2, 3));
981        }
982
983        // Verify
984        {
985            let mmat = MmapMat::<f64>::open(&path).unwrap();
986            assert_eq!(mmat[(0, 0)], 1.0);
987            assert_eq!(mmat[(0, 2)], 3.0);
988            assert_eq!(mmat[(1, 0)], 4.0);
989            assert_eq!(mmat[(1, 2)], 6.0);
990        }
991
992        std::fs::remove_file(path).ok();
993    }
994
995    #[test]
996    fn test_read_dimensions() {
997        let dir = std::env::temp_dir();
998        let path = dir.join("test_read_dims.oxiblas");
999
1000        {
1001            let _mmat = MmapMatMut::<f64>::create(&path, 100, 200).unwrap();
1002        }
1003
1004        let (nrows, ncols) = read_dimensions(&path).unwrap();
1005        assert_eq!(nrows, 100);
1006        assert_eq!(ncols, 200);
1007
1008        std::fs::remove_file(path).ok();
1009    }
1010
1011    #[test]
1012    fn test_mmap_large_matrix() {
1013        let dir = std::env::temp_dir();
1014        let path = dir.join("test_mmap_large.oxiblas");
1015
1016        let nrows = 1000;
1017        let ncols = 500;
1018
1019        // Create large matrix
1020        {
1021            let mut mmat = MmapMatMut::<f64>::create(&path, nrows, ncols).unwrap();
1022
1023            // Set diagonal
1024            for i in 0..nrows.min(ncols) {
1025                mmat[(i, i)] = (i + 1) as f64;
1026            }
1027
1028            // Set corners
1029            mmat[(0, 0)] = -1.0;
1030            mmat[(nrows - 1, ncols - 1)] = -2.0;
1031
1032            mmat.flush().unwrap();
1033        }
1034
1035        // Verify
1036        {
1037            let mmat = MmapMat::<f64>::open(&path).unwrap();
1038            assert_eq!(mmat.shape(), (nrows, ncols));
1039            assert_eq!(mmat[(0, 0)], -1.0);
1040            assert_eq!(mmat[(nrows - 1, ncols - 1)], -2.0);
1041            assert_eq!(mmat[(100, 100)], 101.0);
1042        }
1043
1044        std::fs::remove_file(path).ok();
1045    }
1046
1047    #[test]
1048    fn test_mmap_fill() {
1049        let dir = std::env::temp_dir();
1050        let path = dir.join("test_mmap_fill.oxiblas");
1051
1052        {
1053            let mut mmat = MmapMatMut::<f64>::create(&path, 5, 5).unwrap();
1054            mmat.fill(42.0);
1055            mmat.flush().unwrap();
1056        }
1057
1058        {
1059            let mmat = MmapMat::<f64>::open(&path).unwrap();
1060            for i in 0..5 {
1061                for j in 0..5 {
1062                    assert_eq!(mmat[(i, j)], 42.0);
1063                }
1064            }
1065        }
1066
1067        std::fs::remove_file(path).ok();
1068    }
1069
1070    #[test]
1071    fn test_write_mat() {
1072        use crate::Mat;
1073
1074        let dir = std::env::temp_dir();
1075        let path = dir.join("test_write_mat.oxiblas");
1076
1077        let mat = Mat::<f64>::from_rows(&[&[1.0, 2.0], &[3.0, 4.0]]);
1078
1079        write_mat(&path, &mat.as_ref()).unwrap();
1080
1081        let mmat = MmapMat::<f64>::open(&path).unwrap();
1082        assert_eq!(mmat[(0, 0)], 1.0);
1083        assert_eq!(mmat[(0, 1)], 2.0);
1084        assert_eq!(mmat[(1, 0)], 3.0);
1085        assert_eq!(mmat[(1, 1)], 4.0);
1086
1087        std::fs::remove_file(path).ok();
1088    }
1089
1090    /// Writes a syntactically valid `.oxiblas` header (correct magic, version,
1091    /// and `f64` element type) advertising `nrows x ncols` with `row_stride`,
1092    /// followed by exactly `data_bytes` bytes of zero payload. Callers make the
1093    /// payload shorter than those dimensions demand to exercise the truncation
1094    /// guard in `open` without corrupting the header itself.
1095    fn write_oxiblas_with_short_data(
1096        path: &std::path::Path,
1097        nrows: usize,
1098        ncols: usize,
1099        row_stride: usize,
1100        data_bytes: usize,
1101    ) {
1102        use std::io::Write;
1103        let header =
1104            Header::new::<f64>(nrows, ncols, row_stride).expect("f64 is a supported element type");
1105        let mut file = std::fs::File::create(path).expect("create temp file");
1106        file.write_all(&header.to_bytes()).expect("write header");
1107        file.write_all(&vec![0u8; data_bytes])
1108            .expect("write short data section");
1109        file.flush().expect("flush temp file");
1110    }
1111
1112    #[test]
1113    fn test_mmap_open_truncated_rejected() {
1114        let dir = std::env::temp_dir();
1115        let path = dir.join("test_mmap_open_truncated_ro.oxiblas");
1116
1117        // Header claims a 100x100 f64 matrix: with row_stride=104 that needs
1118        // 104 * 100 * 8 = 83_200 data bytes, but we write only 64. Opening this
1119        // read-only must fail rather than hand back a map whose accessors would
1120        // read past the mapping.
1121        write_oxiblas_with_short_data(&path, 100, 100, 104, 64);
1122
1123        match MmapMat::<f64>::open(&path) {
1124            Err(MmapError::InvalidDimensions(_)) => {}
1125            Err(other) => panic!("expected InvalidDimensions, got {other:?}"),
1126            Ok(_) => panic!("truncated read-only file was accepted (out-of-bounds read hazard)"),
1127        }
1128
1129        std::fs::remove_file(path).ok();
1130    }
1131
1132    #[test]
1133    fn test_mmap_mut_open_truncated_rejected() {
1134        let dir = std::env::temp_dir();
1135        let path = dir.join("test_mmap_open_truncated_rw.oxiblas");
1136
1137        // Same corrupt-but-parseable header, opened read-write. An unchecked map
1138        // here would expose set()/IndexMut over an undersized region — an
1139        // out-of-bounds *write* hazard — so this must be rejected too.
1140        write_oxiblas_with_short_data(&path, 100, 100, 104, 64);
1141
1142        match MmapMatMut::<f64>::open(&path) {
1143            Err(MmapError::InvalidDimensions(_)) => {}
1144            Err(other) => panic!("expected InvalidDimensions, got {other:?}"),
1145            Ok(_) => panic!("truncated writable file was accepted (out-of-bounds write hazard)"),
1146        }
1147
1148        std::fs::remove_file(path).ok();
1149    }
1150
1151    #[test]
1152    fn test_mmap_open_bad_row_stride_rejected() {
1153        let dir = std::env::temp_dir();
1154        let path = dir.join("test_mmap_open_bad_stride.oxiblas");
1155
1156        // row_stride (4) < nrows (100): even with a generously long payload this
1157        // is an inconsistent layout that must be rejected before any offset math.
1158        write_oxiblas_with_short_data(&path, 100, 10, 4, 4 * 10 * 8);
1159
1160        match MmapMat::<f64>::open(&path) {
1161            Err(MmapError::InvalidDimensions(_)) => {}
1162            Err(other) => panic!("expected InvalidDimensions, got {other:?}"),
1163            Ok(_) => panic!("file with row_stride < nrows was accepted"),
1164        }
1165
1166        std::fs::remove_file(path).ok();
1167    }
1168
1169    #[test]
1170    fn test_mmap_open_overflow_dims_rejected() {
1171        let dir = std::env::temp_dir();
1172        let path = dir.join("test_mmap_open_overflow.oxiblas");
1173
1174        // row_stride * ncols * size_of::<f64>() overflows usize. The claimed
1175        // dimensions cannot describe any real allocation, so opening must fail
1176        // (the checked_mul None case) instead of wrapping to a small size.
1177        write_oxiblas_with_short_data(&path, usize::MAX, 1024, usize::MAX, 64);
1178
1179        match MmapMat::<f64>::open(&path) {
1180            Err(MmapError::InvalidDimensions(_)) => {}
1181            Err(other) => panic!("expected InvalidDimensions, got {other:?}"),
1182            Ok(_) => panic!("file with overflowing dimensions was accepted"),
1183        }
1184
1185        std::fs::remove_file(path).ok();
1186    }
1187
1188    // --- Regression: the CREATE path must be as strict as the OPEN path ------
1189    //
1190    // `create` used to compute `row_stride * ncols * size_of::<T>() +
1191    // HEADER_SIZE` with plain arithmetic. In a release build that wraps to a
1192    // small value, so `set_len` makes a tiny file, the mapping is tiny, but the
1193    // returned struct records the caller's ORIGINAL huge dimensions — after
1194    // which every safe `set` / `get_mut` / `IndexMut` writes past the end of
1195    // the mapping.
1196
1197    #[test]
1198    fn test_mmap_create_overflow_dims_rejected() {
1199        let dir = std::env::temp_dir();
1200        let path = dir.join("test_mmap_create_overflow.oxiblas");
1201
1202        // row_stride(2^32) * ncols(2^32) already exceeds usize::MAX.
1203        match MmapMatMut::<f64>::create(&path, 1usize << 32, 1usize << 32) {
1204            Err(MmapError::InvalidDimensions(_)) => {}
1205            Err(other) => panic!("expected InvalidDimensions, got {other:?}"),
1206            Ok(_) => panic!("create() accepted overflowing dimensions"),
1207        }
1208
1209        std::fs::remove_file(path).ok();
1210    }
1211
1212    #[test]
1213    fn test_mmap_create_row_stride_overflow_rejected() {
1214        let dir = std::env::temp_dir();
1215        let path = dir.join("test_mmap_create_stride_overflow.oxiblas");
1216
1217        // Padding `usize::MAX` rows up to a whole number of cache lines
1218        // overflows on its own, before any ncols multiplication.
1219        match MmapMatMut::<f64>::create(&path, usize::MAX, 1) {
1220            Err(MmapError::InvalidDimensions(_)) => {}
1221            Err(other) => panic!("expected InvalidDimensions, got {other:?}"),
1222            Ok(_) => panic!("create() accepted an overflowing row stride"),
1223        }
1224
1225        std::fs::remove_file(path).ok();
1226    }
1227
1228    #[test]
1229    fn test_mmap_create_sane_dims_still_work() {
1230        // The overflow guards must not reject ordinary matrices.
1231        let dir = std::env::temp_dir();
1232        let path = dir.join("test_mmap_create_sane.oxiblas");
1233
1234        {
1235            let mut m = MmapMatMut::<f64>::create(&path, 8, 4)
1236                .expect("creating an 8x4 matrix must succeed");
1237            m.set(0, 0, 1.5);
1238            m.set(7, 3, 2.5);
1239            assert_eq!(m.get(0, 0), Some(&1.5));
1240            assert_eq!(m.get(7, 3), Some(&2.5));
1241        }
1242
1243        let reopened = MmapMat::<f64>::open(&path).expect("reopening must succeed");
1244        assert_eq!(reopened.shape(), (8, 4));
1245        assert_eq!(reopened.get(7, 3), Some(&2.5));
1246
1247        std::fs::remove_file(path).ok();
1248    }
1249}