Skip to main content

nuvec_core/
file.rs

1//! Arrays backed by a file's bytes, for loaders (safetensors, npy, ...).
2
3use std::path::Path;
4use std::sync::Arc;
5
6use crate::array::Data;
7use crate::buffer::Buffer;
8use crate::{Array, DType, Error, Result, shape};
9
10/// A file's bytes in array storage: on a Mac with a GPU, the file is memory-mapped and
11/// wrapped as GPU-visible memory without a copy, so arrays made from it are views whose
12/// pages are read when first touched (by the CPU or the GPU). Elsewhere the file is read
13/// into memory. The mapping lives as long as any array made from it.
14///
15/// The file must not be modified (written or truncated, by this or another process) while
16/// it is mapped: arrays made from it would change underneath their readers (undefined
17/// behaviour), and reads past a truncated end raise `SIGBUS`. Map only files nothing
18/// writes while they are in use, such as model weights.
19///
20/// ```
21/// use nuvec_core::{DType, MappedFile};
22///
23/// # fn main() -> nuvec_core::Result<()> {
24/// # if cfg!(miri) { return Ok(()); } // no file system under Miri's isolation
25/// let path = std::env::temp_dir().join("nuvec-mapped-file-doc.bin");
26/// let bytes: Vec<u8> = [1.0f32, 2.0, 3.0, 4.0].iter().flat_map(|v| v.to_le_bytes()).collect();
27/// std::fs::write(&path, &bytes).unwrap();
28/// let file = MappedFile::open(&path)?;
29/// let a = file.array(4, DType::F32, &[3])?; // bytes 4..16, no copy
30/// assert_eq!(a.to_vec::<f32>()?, vec![2.0, 3.0, 4.0]);
31/// # Ok(())
32/// # }
33/// ```
34#[derive(Clone)]
35pub struct MappedFile {
36    buffer: Arc<Buffer>,
37}
38
39impl std::fmt::Debug for MappedFile {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("MappedFile")
42            .field("len", &self.len())
43            .finish()
44    }
45}
46
47impl MappedFile {
48    /// Maps (or reads) the file at `path`. The file must not be modified while it is
49    /// mapped (see [`MappedFile`]).
50    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
51        Ok(Self {
52            buffer: Arc::new(Buffer::map_file(path.as_ref())?),
53        })
54    }
55
56    /// The bytes mapping a file of `len` bytes holds in the GPU allocator's `mapped`
57    /// statistic: the whole file, rounded up to pages.
58    pub fn mapping_size(len: u64) -> u64 {
59        nuvec_gpu::mapping_size(len as usize) as u64
60    }
61
62    /// The bytes storage of `len` bytes (as [`MappedFile::read_ranges`] allocates) holds in
63    /// the GPU allocator's `active` statistic: its size class.
64    pub fn copy_size(len: u64) -> u64 {
65        nuvec_gpu::allocation_size(len as usize).map_or(u64::MAX, |n| n as u64)
66    }
67
68    /// The bytes that may still be mapped or allocated before the GPU's memory limit
69    /// (`memory_limit − active − mapped`), or `None` without a GPU.
70    pub fn headroom() -> Option<u64> {
71        let m = nuvec_gpu::Device::shared()?.memory();
72        Some(m.memory_limit.saturating_sub(m.active_with_mapped) as u64)
73    }
74
75    /// The file's size in bytes.
76    pub fn len(&self) -> usize {
77        self.buffer.len()
78    }
79
80    /// Whether the file is empty.
81    pub fn is_empty(&self) -> bool {
82        self.len() == 0
83    }
84
85    /// The file's bytes.
86    pub fn bytes(&self) -> &[u8] {
87        self.buffer.bytes()
88    }
89
90    /// Reads parts of the file at `path` into new array storage of `len` bytes, without
91    /// mapping it: each `(file_offset, offset, n)` of `ranges` puts the file's bytes
92    /// `file_offset..file_offset + n` at `offset..offset + n`. For files whose arrays are
93    /// not aligned to their element sizes (safetensors written without header padding, as
94    /// MLX writes them): the ranges are placed so that every array is aligned, and arrays
95    /// made from the result are views of it. Bytes outside the ranges are unspecified.
96    ///
97    /// The ranges must be in increasing order of `offset` and must not overlap. They are
98    /// read with positioned reads in chunks of 16 MiB, on up to 8 threads (one per 64 MiB
99    /// read), straight into the storage: from the page cache this runs at memory
100    /// bandwidth, and nothing but the storage is allocated (the file is not mapped).
101    ///
102    /// ```
103    /// use nuvec_core::{DType, MappedFile};
104    ///
105    /// # fn main() -> nuvec_core::Result<()> {
106    /// # if cfg!(miri) { return Ok(()); }
107    /// let path = std::env::temp_dir().join("nuvec-read-ranges-doc.bin");
108    /// // One padding byte, then three f32s: misaligned in the file.
109    /// let mut bytes = vec![0u8];
110    /// bytes.extend([1.0f32, 2.0, 3.0].iter().flat_map(|v| v.to_le_bytes()));
111    /// std::fs::write(&path, &bytes).unwrap();
112    /// let file = MappedFile::read_ranges(&path, &[(1, 0, 12)], 12)?;
113    /// assert_eq!(file.array(0, DType::F32, &[3])?.to_vec::<f32>()?, vec![1.0, 2.0, 3.0]);
114    /// # Ok(())
115    /// # }
116    /// ```
117    pub fn read_ranges(
118        path: impl AsRef<Path>,
119        ranges: &[(u64, usize, usize)],
120        len: usize,
121    ) -> Result<Self> {
122        let path = path.as_ref();
123        let mut at = 0;
124        for &(_, offset, n) in ranges {
125            if offset < at || offset.checked_add(n).is_none_or(|end| end > len) {
126                return Err(Error::InvalidArgument(format!(
127                    "{}: read ranges must be increasing, disjoint and within {len} bytes",
128                    path.display()
129                )));
130            }
131            at = offset + n;
132        }
133        let io = |e: std::io::Error| Error::Io(format!("{}: {e}", path.display()));
134        let file = std::fs::File::open(path).map_err(io)?;
135        let mut buffer = Buffer::try_alloc(len)?;
136        read_parallel(&file, buffer.bytes_mut(), ranges).map_err(io)?;
137        Ok(Self {
138            buffer: Arc::new(buffer),
139        })
140    }
141
142    /// A row-major array of `dtype` and `shape` stored at byte `offset`: a view of the file
143    /// if `offset` is a multiple of the element size, otherwise a copy.
144    pub fn array(&self, offset: usize, dtype: DType, shape: &[usize]) -> Result<Array> {
145        let nbytes = shape::checked_nbytes(shape, dtype.size())?;
146        if offset
147            .checked_add(nbytes)
148            .is_none_or(|end| end > self.len())
149        {
150            return Err(Error::Io(format!(
151                "{nbytes} bytes at offset {offset} exceed the file's {} bytes",
152                self.len()
153            )));
154        }
155        if offset.is_multiple_of(dtype.size()) {
156            let data = Data {
157                buffer: self.buffer.clone(),
158                offset: (offset / dtype.size()) as isize,
159            };
160            return Array::leaf(Ok(data), dtype, shape.into());
161        }
162        let mut copy = Buffer::try_alloc(nbytes)?;
163        copy.bytes_mut()
164            .copy_from_slice(&self.bytes()[offset..offset + nbytes]);
165        Array::from_buffer(copy, dtype, shape)
166    }
167}
168
169/// Reads chunk size: large enough that a read's cost is the copy, small enough to spread a
170/// file over the threads.
171const CHUNK: usize = 16 << 20;
172/// Bytes per reading thread (smaller reads use fewer threads).
173const PER_THREAD: usize = 64 << 20;
174/// At most this many reading threads: more do not read faster from the page cache.
175const MAX_THREADS: usize = 8;
176
177/// Fills `dest[offset..offset + n]` with the file's bytes `file_offset..file_offset + n` for
178/// each range (increasing and disjoint in `dest`, checked by the caller), in chunks of
179/// [`CHUNK`] spread over a few threads.
180fn read_parallel(
181    file: &std::fs::File,
182    dest: &mut [u8],
183    ranges: &[(u64, usize, usize)],
184) -> std::io::Result<()> {
185    // The chunks as disjoint mutable slices of `dest`, with their file offsets.
186    let mut chunks: Vec<(u64, &mut [u8])> = Vec::new();
187    let mut rest = dest;
188    let mut consumed = 0;
189    for &(file_offset, offset, n) in ranges {
190        let (_, tail) = std::mem::take(&mut rest).split_at_mut(offset - consumed);
191        let (mut range, tail) = tail.split_at_mut(n);
192        rest = tail;
193        consumed = offset + n;
194        let mut at = file_offset;
195        while !range.is_empty() {
196            let (chunk, tail) = range.split_at_mut(range.len().min(CHUNK));
197            let n = chunk.len() as u64;
198            chunks.push((at, chunk));
199            at += n;
200            range = tail;
201        }
202    }
203    let total: usize = ranges.iter().map(|r| r.2).sum();
204    let threads = std::thread::available_parallelism()
205        .map_or(1, usize::from)
206        .min(MAX_THREADS)
207        .min(total.div_ceil(PER_THREAD))
208        .max(1);
209    if threads == 1 {
210        return chunks
211            .into_iter()
212            .try_for_each(|(at, chunk)| read_exact_at(file, chunk, at));
213    }
214    let work = std::sync::Mutex::new(chunks.into_iter());
215    std::thread::scope(|s| {
216        let workers: Vec<_> = (0..threads)
217            .map(|_| {
218                s.spawn(|| {
219                    loop {
220                        let next = work
221                            .lock()
222                            .unwrap_or_else(std::sync::PoisonError::into_inner)
223                            .next();
224                        let Some((at, chunk)) = next else {
225                            return Ok(());
226                        };
227                        read_exact_at(file, chunk, at)?;
228                    }
229                })
230            })
231            .collect();
232        workers.into_iter().try_for_each(|w| {
233            w.join()
234                .unwrap_or_else(|_| Err(std::io::Error::other("a reading thread panicked")))
235        })
236    })
237}
238
239/// Fills `buf` with the file's bytes from `offset` (a positioned read: the file's cursor is
240/// neither used nor moved, so threads read one file concurrently).
241#[cfg(unix)]
242fn read_exact_at(file: &std::fs::File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
243    std::os::unix::fs::FileExt::read_exact_at(file, buf, offset)
244}
245
246#[cfg(windows)]
247fn read_exact_at(file: &std::fs::File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> {
248    use std::os::windows::fs::FileExt;
249    while !buf.is_empty() {
250        match file.seek_read(buf, offset) {
251            Ok(0) => return Err(std::io::ErrorKind::UnexpectedEof.into()),
252            Ok(n) => {
253                buf = &mut buf[n..];
254                offset += n as u64;
255            }
256            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
257            Err(e) => return Err(e),
258        }
259    }
260    Ok(())
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    /// Ranges of several chunks, gaps and odd offsets land where they are asked to.
268    #[test]
269    #[cfg_attr(miri, ignore)] // no file system under Miri's isolation
270    fn read_ranges_places_every_byte() {
271        let path =
272            std::env::temp_dir().join(format!("nuvec-read-ranges-{}.bin", std::process::id()));
273        let bytes: Vec<u8> = (0..(CHUNK * 2 + 12_345))
274            .map(|i| (i * 31 % 251) as u8)
275            .collect();
276        std::fs::write(&path, &bytes).unwrap();
277        let big = CHUNK * 2 + 1000;
278        let ranges = [
279            (3u64, 0usize, 10usize),
280            (17, 16, big),
281            (bytes.len() as u64 - 5, big + 32, 5),
282        ];
283        let file = MappedFile::read_ranges(&path, &ranges, big + 37).unwrap();
284        for &(at, offset, n) in &ranges {
285            assert_eq!(
286                &file.bytes()[offset..offset + n],
287                &bytes[at as usize..at as usize + n]
288            );
289        }
290        // Past the end of the file, out of order, or past the storage: errors.
291        assert!(MappedFile::read_ranges(&path, &[(bytes.len() as u64 - 2, 0, 4)], 4).is_err());
292        assert!(MappedFile::read_ranges(&path, &[(0, 8, 4), (0, 0, 4)], 12).is_err());
293        assert!(MappedFile::read_ranges(&path, &[(0, 0, 8)], 4).is_err());
294        let empty = MappedFile::read_ranges(&path, &[], 0).unwrap();
295        assert!(empty.is_empty());
296        std::fs::remove_file(&path).unwrap();
297    }
298}