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}