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}