Skip to main content

onnx_runtime_virtual_memory/
lib.rs

1//! # `onnx-runtime-virtual-memory`
2//!
3//! Virtually contiguous, physically scattered memory.
4//!
5//! ## The problem this solves
6//!
7//! Paged KV storage and attention operators want opposite things. Paging wants
8//! small, individually reclaimable, individually migratable blocks. A
9//! `GroupQueryAttention` kernel wants one flat buffer per layer, because that is
10//! what the ONNX graph declares its `past_key`/`past_value` inputs to be.
11//!
12//! The usual reconciliations are to copy the pages into a contiguous staging
13//! buffer every step, or to change the model graph so the operator understands
14//! block tables. The first costs a full KV copy per decode step; the second only
15//! works for models we control the export of.
16//!
17//! There is a third option: **reserve a contiguous range of virtual addresses
18//! and map physically separate blocks into it**. The operator sees one flat
19//! buffer and runs unmodified. The blocks stay individually reclaimable, and
20//! growing the range costs a mapping call rather than a copy.
21//!
22//! ## What decides whether this is cheap
23//!
24//! Mapping granularity, which is a platform and device property rather than
25//! something this crate chooses. Measured on a Windows host with an RTX 4060:
26//!
27//! | mapping | granularity | tokens per granule, 8B GQA (2048 B/token) |
28//! |---|---|---|
29//! | Windows host | 64 KiB | 32 |
30//! | Linux / macOS host | page size, 4 KiB or 16 KiB | 2 to 8 |
31//! | CUDA VMM | 2 MiB | 1024 |
32//!
33//! On the host that is as fine as a KV page, so virtual contiguity costs nothing
34//! in wasted memory. On CUDA a sequence rounds up to `num_layers * 2 * 2 MiB`
35//! whatever its length, which only matters with many concurrent short
36//! sequences. See #596 for why that trade was accepted.
37//!
38//! ## Apple Silicon
39//!
40//! macOS uses the same `mmap` path as Linux. `MAP_NORESERVE` is defined there
41//! but is effectively ignored; it is an accounting hint, not a correctness
42//! requirement, so the reservation behaves the same. Note that Apple Silicon
43//! pages are **16 KiB**, not 4 KiB, so [`granularity`] must be queried rather
44//! than assumed — a hard-coded 4096 would misalign every offset.
45//!
46//! The bigger Apple consequence is not in this crate: CPU and GPU share one
47//! physical pool, so "device memory" and "host memory" are the same bytes.
48//! Anything holding separate per-tier budgets will over-commit there unless it
49//! knows the tiers alias.
50//!
51//! ## What this crate is not
52//!
53//! It does not decide *whether* memory may be held — that is
54//! `onnx-runtime-memory-governor`. A [`VirtualRange`] is a mapping mechanism; a
55//! lease is the permission to use one.
56
57#![allow(unsafe_code)]
58
59pub mod backing;
60pub mod buffer;
61
62pub use backing::{HostBacking, PhysicalMemoryAccounting, VirtualBacking};
63pub use buffer::{VirtualBuffer, VirtualBufferError};
64
65use std::ptr::NonNull;
66
67mod sys;
68
69/// Why a virtual range could not be reserved, mapped, or unmapped.
70///
71/// Not `Clone`/`PartialEq`: a refusal that carries the cause underneath it
72/// cannot be meaningfully duplicated or compared, and keeping the cause is
73/// worth more than either.
74#[derive(Debug, thiserror::Error)]
75pub enum VirtualMemoryError {
76    /// The requested size or offset is not a multiple of the mapping
77    /// granularity.
78    ///
79    /// Reported rather than rounded, because silently rounding an offset would
80    /// place a block somewhere the caller did not ask for and every subsequent
81    /// read would be of the wrong data.
82    #[error(
83        "{what} of {value} bytes is not a multiple of the {granularity} byte mapping \
84         granularity; round it up to {rounded} or ask this platform for its granularity via \
85         `granularity()` before splitting a buffer"
86    )]
87    Misaligned {
88        /// Which quantity was misaligned.
89        what: &'static str,
90        /// The value supplied.
91        value: usize,
92        /// This platform's granularity.
93        granularity: usize,
94        /// The next legal value at or above `value`.
95        rounded: usize,
96    },
97    /// A layer this call delegated to refused the request.
98    ///
99    /// Distinct from [`VirtualMemoryError::Os`], which means the *kernel*
100    /// refused it. Keeping the two apart matters because they call for
101    /// opposite responses: a governor that declines a reservation is telling
102    /// the caller to ask for less or free something first, while a driver that
103    /// fails a mapping is telling it that the request cannot be served at all.
104    /// Flattening a decision into an OS error also has to invent an `errno`,
105    /// and `os error 0` in a log sends the next reader hunting for a kernel
106    /// fault that never happened.
107    ///
108    /// The refusal is kept whole rather than stringified so callers can still
109    /// match on it after it has crossed this boundary.
110    #[error("{operation} failed: {source}")]
111    Delegated {
112        /// Which call was being made when the lower layer refused.
113        operation: &'static str,
114        /// The refusal itself, kept whole so it survives `downcast_ref`.
115        #[source]
116        source: Box<dyn std::error::Error + Send + Sync>,
117    },
118    /// The operating system refused the request.
119    #[error("{operation} failed: {reason} (os error {code})")]
120    Os {
121        /// Which call failed.
122        operation: &'static str,
123        /// What the OS reported.
124        reason: String,
125        /// The raw error code.
126        code: i32,
127    },
128    /// A mapping would fall outside the reserved range.
129    #[error(
130        "cannot map {length} bytes at offset {offset} of a {reserved} byte range; the mapping \
131         would run {overrun} bytes past the end. Reserve a larger range, or map at a lower offset"
132    )]
133    OutOfRange {
134        /// Where the caller asked to map.
135        offset: usize,
136        /// How much.
137        length: usize,
138        /// The reservation's size.
139        reserved: usize,
140        /// How far past the end it would reach.
141        overrun: usize,
142    },
143    /// The range is already mapped at that offset.
144    ///
145    /// Replacing a live mapping silently would leave the previous block
146    /// allocated but unreachable, so it is refused.
147    #[error("offset {offset} of this range is already mapped; unmap it before mapping again")]
148    AlreadyMapped {
149        /// The offset in question.
150        offset: usize,
151    },
152    /// A caller-supplied `PhysicalLocation` (or platform-specific stand-in for
153    /// it) did not match the physical backing a pool/handle actually holds.
154    ///
155    /// Rejected before any lease charge, handle acquisition, mapping, or
156    /// accounting mutation happens: this is a caller-programming-error check,
157    /// not a driver refusal, and must not have any side effect on the pool it
158    /// was refused against.
159    #[error(
160        "requested location {requested} does not match this pool's backing location {actual}; \
161         a mismatched location must never be silently accepted, ask the pool for its own \
162         `location()` instead of asserting one"
163    )]
164    LocationMismatch {
165        /// What the caller asked to commit against.
166        requested: String,
167        /// What the pool is actually backed by.
168        actual: String,
169    },
170}
171
172/// This platform's minimum mapping granularity, in bytes.
173///
174/// Every offset and length handed to [`VirtualRange`] must be a multiple of it.
175pub fn granularity() -> usize {
176    sys::granularity()
177}
178
179/// A reserved range of virtual addresses with nothing behind it yet.
180///
181/// Reserving costs address space, not memory. Blocks are mapped in afterwards
182/// with [`VirtualRange::map`], and the range is readable only where something
183/// has been mapped — touching an unmapped offset faults rather than reading
184/// zeroes, which is deliberate: a silent zero would look like KV that was
185/// written but empty.
186#[derive(Debug)]
187pub struct VirtualRange {
188    base: NonNull<u8>,
189    len: usize,
190    /// Offsets currently backed by a block, each with its mapped length.
191    mapped: Vec<(usize, usize)>,
192}
193
194// The pointer is an owned reservation; nothing in it is thread-affine.
195unsafe impl Send for VirtualRange {}
196unsafe impl Sync for VirtualRange {}
197
198impl VirtualRange {
199    /// Reserve `len` bytes of address space.
200    ///
201    /// `len` must be a multiple of [`granularity`].
202    pub fn reserve(len: usize) -> Result<Self, VirtualMemoryError> {
203        check_aligned("reservation length", len)?;
204        if len == 0 {
205            return Err(VirtualMemoryError::Misaligned {
206                what: "reservation length",
207                value: 0,
208                granularity: granularity(),
209                rounded: granularity(),
210            });
211        }
212        let base = sys::reserve(len)?;
213        Ok(Self {
214            base,
215            len,
216            mapped: Vec::new(),
217        })
218    }
219
220    /// Bytes of address space reserved.
221    pub fn len(&self) -> usize {
222        self.len
223    }
224
225    /// Whether the reservation covers no bytes.
226    ///
227    /// Always false: a zero-length reservation is refused at construction.
228    pub fn is_empty(&self) -> bool {
229        self.len == 0
230    }
231
232    /// Bytes currently backed by a mapped block.
233    pub fn mapped_bytes(&self) -> usize {
234        self.mapped.iter().map(|&(_, len)| len).sum()
235    }
236
237    /// The base address. Only offsets that have been mapped may be read.
238    pub fn as_ptr(&self) -> *const u8 {
239        self.base.as_ptr()
240    }
241
242    /// The base address, mutable.
243    pub fn as_mut_ptr(&mut self) -> *mut u8 {
244        self.base.as_ptr()
245    }
246
247    /// Back `offset..offset + len` with freshly committed memory.
248    ///
249    /// Both `offset` and `len` must be multiples of [`granularity`].
250    pub fn map(&mut self, offset: usize, len: usize) -> Result<(), VirtualMemoryError> {
251        check_aligned("mapping offset", offset)?;
252        check_aligned("mapping length", len)?;
253        let end = offset.saturating_add(len);
254        if end > self.len {
255            return Err(VirtualMemoryError::OutOfRange {
256                offset,
257                length: len,
258                reserved: self.len,
259                overrun: end - self.len,
260            });
261        }
262        if self.mapped.iter().any(|&(at, mapped_len)| {
263            // Any overlap counts: partially replacing a mapping would strand the
264            // rest of the block it belonged to.
265            offset < at + mapped_len && at < end
266        }) {
267            return Err(VirtualMemoryError::AlreadyMapped { offset });
268        }
269        // SAFETY: the offset and length are aligned and within the reservation,
270        // and the overlap check above proves nothing is mapped there yet.
271        //
272        // Windows needs to know whether this span is a strict subset of the
273        // placeholder that contains it, which depends on the surrounding *free*
274        // gap rather than on the whole reservation. Computing it here keeps the
275        // platform layer free of the range's bookkeeping.
276        let gap = self.free_gap_containing(offset);
277        // SAFETY: as above.
278        unsafe { sys::map(self.base, gap, offset, len)? };
279        self.mapped.push((offset, len));
280        Ok(())
281    }
282
283    /// The free span surrounding `offset`, as `(start, len)`.
284    ///
285    /// A reservation starts as one placeholder and is carved as blocks are
286    /// mapped, so the span a new block must be split out of is bounded by its
287    /// mapped neighbours, not by the reservation.
288    fn free_gap_containing(&self, offset: usize) -> (usize, usize) {
289        let mut start = 0;
290        let mut end = self.len;
291        for &(at, len) in &self.mapped {
292            let block_end = at + len;
293            if block_end <= offset {
294                start = start.max(block_end);
295            } else if at > offset {
296                end = end.min(at);
297            }
298        }
299        (start, end - start)
300    }
301
302    /// Release the block backing `offset`, leaving the address space reserved.
303    ///
304    /// The offset must be one previously passed to [`Self::map`]; releasing a
305    /// sub-range would strand the remainder of the block.
306    pub fn unmap(&mut self, offset: usize) -> Result<(), VirtualMemoryError> {
307        let Some(index) = self.mapped.iter().position(|&(at, _)| at == offset) else {
308            return Ok(());
309        };
310        let (_, len) = self.mapped[index];
311        // SAFETY: this offset and length came from a successful `map`.
312        unsafe { sys::unmap(self.base, offset, len)? };
313        self.mapped.remove(index);
314        Ok(())
315    }
316
317    /// A read-only view of a mapped span.
318    ///
319    /// # Safety
320    ///
321    /// The caller must ensure `offset..offset + len` is entirely mapped.
322    /// Reading unmapped address space faults.
323    pub unsafe fn slice_unchecked(&self, offset: usize, len: usize) -> &[u8] {
324        // SAFETY: the caller guarantees the span is mapped, and the base pointer
325        // is valid for the life of `self`.
326        unsafe { std::slice::from_raw_parts(self.base.as_ptr().add(offset), len) }
327    }
328
329    /// A mutable view of a mapped span.
330    ///
331    /// # Safety
332    ///
333    /// The caller must ensure `offset..offset + len` is entirely mapped.
334    pub unsafe fn slice_unchecked_mut(&mut self, offset: usize, len: usize) -> &mut [u8] {
335        // SAFETY: as above, plus `&mut self` rules out aliasing views.
336        unsafe { std::slice::from_raw_parts_mut(self.base.as_ptr().add(offset), len) }
337    }
338}
339
340impl Drop for VirtualRange {
341    fn drop(&mut self) {
342        for &(offset, len) in &self.mapped {
343            // SAFETY: every entry came from a successful `map`.
344            let _ = unsafe { sys::unmap(self.base, offset, len) };
345        }
346        // SAFETY: `base` came from `sys::reserve` and is released exactly once.
347        unsafe { sys::release(self.base, self.len) };
348    }
349}
350
351fn check_aligned(what: &'static str, value: usize) -> Result<(), VirtualMemoryError> {
352    let granularity = granularity();
353    if value.is_multiple_of(granularity) {
354        return Ok(());
355    }
356    Err(VirtualMemoryError::Misaligned {
357        what,
358        value,
359        granularity,
360        rounded: value.div_ceil(granularity) * granularity,
361    })
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    /// `Delegated` and `Os` are kept apart because they call for opposite
369    /// responses, and flattening the first into the second has to invent an
370    /// `errno` -- an `os error 0` in a log sends the next reader hunting for a
371    /// kernel fault that never happened.
372    #[test]
373    fn a_delegated_refusal_is_reported_as_itself_not_as_an_os_error() {
374        #[derive(Debug, thiserror::Error)]
375        #[error("the tier is full")]
376        struct TierFull;
377
378        let error = VirtualMemoryError::Delegated {
379            operation: "growing physical handle pool lease",
380            source: Box::new(TierFull),
381        };
382
383        let rendered = error.to_string();
384        assert_eq!(
385            rendered,
386            "growing physical handle pool lease failed: the tier is full"
387        );
388        assert!(
389            !rendered.contains("os error"),
390            "a refusal from a lower layer must not be dressed up as a kernel failure: {rendered}"
391        );
392
393        let cause = std::error::Error::source(&error).expect("the refusal must be reachable");
394        assert!(
395            cause.downcast_ref::<TierFull>().is_some(),
396            "the cause must arrive as itself, not as a box around itself: {cause}"
397        );
398    }
399
400    /// Two separately mapped blocks must read back as one flat buffer.
401    ///
402    /// This is the entire point of the crate: an operator that requires
403    /// contiguity gets it, while the memory behind it stays in independently
404    /// mappable pieces.
405    #[test]
406    fn separately_mapped_blocks_read_back_as_one_contiguous_buffer() {
407        let g = granularity();
408        let mut range = VirtualRange::reserve(g * 2).expect("two granules of address space");
409        range.map(0, g).expect("first block maps");
410        range.map(g, g).expect("second block maps");
411
412        // SAFETY: both granules were just mapped.
413        unsafe {
414            let buffer = range.slice_unchecked_mut(0, g * 2);
415            for (index, byte) in buffer.iter_mut().enumerate() {
416                *byte = (index % 251) as u8;
417            }
418        }
419
420        // SAFETY: still mapped.
421        let read = unsafe { range.slice_unchecked(0, g * 2) };
422        for (index, &byte) in read.iter().enumerate() {
423            assert_eq!(
424                byte,
425                (index % 251) as u8,
426                "byte {index} read back wrong across the block boundary"
427            );
428        }
429        assert_eq!(range.mapped_bytes(), g * 2);
430    }
431
432    /// A write through the virtual range must survive unmapping its neighbour.
433    ///
434    /// If the two blocks were secretly one allocation, releasing one would
435    /// disturb the other -- which would mean the pieces are not independently
436    /// reclaimable and the whole premise fails.
437    #[test]
438    fn unmapping_one_block_leaves_its_neighbour_intact() {
439        let g = granularity();
440        let mut range = VirtualRange::reserve(g * 2).expect("address space");
441        range.map(0, g).expect("first block");
442        range.map(g, g).expect("second block");
443
444        // SAFETY: mapped above.
445        unsafe {
446            range.slice_unchecked_mut(0, g)[0] = 0xAB;
447            range.slice_unchecked_mut(g, g)[0] = 0xCD;
448        }
449        range.unmap(g).expect("second block releases");
450
451        // SAFETY: the first block is still mapped.
452        assert_eq!(unsafe { range.slice_unchecked(0, g) }[0], 0xAB);
453        assert_eq!(range.mapped_bytes(), g);
454    }
455
456    /// Reserving costs address space, not memory.
457    ///
458    /// A reservation far larger than RAM must succeed, or growth would have to
459    /// re-reserve and copy -- exactly what this crate exists to avoid.
460    #[test]
461    fn reserving_far_more_than_ram_succeeds_because_nothing_is_committed() {
462        let g = granularity();
463        let huge = g * 1024 * 64;
464        let range = VirtualRange::reserve(huge).expect("address space is not memory");
465        assert_eq!(range.len(), huge);
466        assert_eq!(range.mapped_bytes(), 0, "reserving committed memory");
467    }
468
469    /// A misaligned request is refused with the value that would work.
470    #[test]
471    fn a_misaligned_offset_is_refused_and_names_the_next_legal_value() {
472        let g = granularity();
473        let mut range = VirtualRange::reserve(g * 2).expect("address space");
474        let error = range
475            .map(1, g)
476            .expect_err("an offset of 1 cannot be a multiple of the granularity");
477        match error {
478            VirtualMemoryError::Misaligned { value, rounded, .. } => {
479                assert_eq!(value, 1);
480                assert_eq!(rounded, g, "the suggested value must itself be legal");
481            }
482            other => panic!("expected a misalignment error, got {other}"),
483        }
484    }
485
486    /// Mapping past the end is refused rather than silently truncated.
487    #[test]
488    fn mapping_past_the_end_reports_how_far_it_overruns() {
489        let g = granularity();
490        let mut range = VirtualRange::reserve(g).expect("address space");
491        let error = range.map(g, g).expect_err("offset g is already the end");
492        assert!(
493            matches!(error, VirtualMemoryError::OutOfRange { overrun, .. } if overrun == g),
494            "expected a range error naming the overrun, got {error}"
495        );
496    }
497
498    /// Mapping over a live block is refused.
499    ///
500    /// Silently replacing it would leave the previous block allocated but
501    /// unreachable through this range.
502    #[test]
503    fn mapping_over_a_live_block_is_refused() {
504        let g = granularity();
505        let mut range = VirtualRange::reserve(g * 2).expect("address space");
506        range
507            .map(0, g * 2)
508            .expect("one block covering both granules");
509        let error = range
510            .map(g, g)
511            .expect_err("the second granule is inside the live block");
512        assert!(
513            matches!(error, VirtualMemoryError::AlreadyMapped { .. }),
514            "expected an already-mapped error, got {error}"
515        );
516    }
517
518    /// Unmapping an offset that was never mapped is not an error.
519    ///
520    /// Cleanup paths run without knowing what succeeded, and making them check
521    /// first would just move the race.
522    #[test]
523    fn unmapping_an_unmapped_offset_is_a_no_op() {
524        let g = granularity();
525        let mut range = VirtualRange::reserve(g).expect("address space");
526        range.unmap(0).expect("unmapping nothing is fine");
527    }
528    /// Granularity is queried, never assumed.
529    ///
530    /// Apple Silicon pages are 16 KiB and Windows carves at 64 KiB, so a
531    /// hard-coded 4096 would misalign every offset on two of the three hosts
532    /// this project targets. Asserting the property rather than a number is the
533    /// only form of this test that can run everywhere.
534    #[test]
535    fn granularity_is_a_power_of_two_that_every_legal_offset_is_a_multiple_of() {
536        let g = granularity();
537        assert!(g > 0, "a zero granularity would divide by zero");
538        assert!(
539            g.is_power_of_two(),
540            "granularity {g} is not a power of two, so alignment rounding is wrong"
541        );
542        // Whatever it is, a range of it must be reservable and mappable.
543        let mut range = VirtualRange::reserve(g).expect("one granule");
544        range.map(0, g).expect("one granule maps");
545    }
546
547    /// Rounding suggested by a misalignment error must itself be accepted.
548    ///
549    /// An error that names an unusable value is worse than one that names
550    /// nothing, because a caller will follow it. Note the suggestion is about
551    /// *alignment* only — the reservation still has to be large enough, which
552    /// is why this reserves three granules to map one at the rounded offset.
553    #[test]
554    fn the_rounded_value_a_misalignment_error_suggests_is_itself_legal() {
555        let g = granularity();
556        let mut range = VirtualRange::reserve(g * 3).expect("address space");
557        let VirtualMemoryError::Misaligned { rounded, .. } = range
558            .map(g + 1, g)
559            .expect_err("an offset one past a granule boundary is misaligned")
560        else {
561            panic!("expected a misalignment error");
562        };
563        range
564            .map(rounded, g)
565            .expect("the value the error suggested must be usable");
566    }
567    /// Blocks mapped out of order, with gaps, must each land correctly.
568    ///
569    /// This is where the platform split logic actually gets exercised: a block
570    /// in the middle of free space has to be carved out of the placeholder
571    /// containing it, and that placeholder is bounded by whatever was mapped
572    /// before -- not by the reservation. Getting that wrong fails only for
573    /// interior blocks, so the simple adjacent-blocks test does not catch it.
574    #[test]
575    fn blocks_mapped_out_of_order_with_gaps_each_land_at_their_own_offset() {
576        let g = granularity();
577        let mut range = VirtualRange::reserve(g * 5).expect("five granules");
578
579        // Deliberately not in address order, and leaving holes.
580        for (index, &slot) in [3usize, 0, 4].iter().enumerate() {
581            range.map(slot * g, g).unwrap_or_else(|error| {
582                panic!("mapping granule {slot} (step {index}) failed: {error}")
583            });
584            // SAFETY: just mapped.
585            unsafe {
586                range.slice_unchecked_mut(slot * g, g)[0] = 0x10 + slot as u8;
587            }
588        }
589
590        for &slot in &[3usize, 0, 4] {
591            // SAFETY: mapped above and never unmapped.
592            let seen = unsafe { range.slice_unchecked(slot * g, g) }[0];
593            assert_eq!(
594                seen,
595                0x10 + slot as u8,
596                "granule {slot} read back another block's data"
597            );
598        }
599        assert_eq!(range.mapped_bytes(), g * 3, "the holes were backed too");
600    }
601}