Skip to content

Commit db7b79c

Browse files
committed
feat: map virtq buffers lazily
Signed-off-by: Tomasz Andrzejak <andreiltd@gmail.com>
1 parent c0d33c9 commit db7b79c

20 files changed

Lines changed: 624 additions & 267 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2020
snapshot capture, restore, and cloning.
2121

2222
### Changed
23+
* Guest transport aliases follow buffer ownership. Restored buffers keep
24+
captured backing while their scratch slots become reusable.
2325
* `Sandbox` is the primary initialized sandbox type. `MultiUseSandbox` remains
2426
as a deprecated alias.
2527
* `Sandbox` lives in the private `sandbox::initialized` module and is reached

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/virtio-host-guest-communication.md‎

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -364,8 +364,8 @@ drains and acknowledges them during the same VM exit.
364364
Guest `SlotPool` instances own all transport buffers. Pool clones share one
365365
allocation bitmap with each producer.
366366
Each producer pairs its backend with that pool. Completion leases carry the
367-
original slot addresses and full capacities. Backing owns fixed scratch-to-alias
368-
translation and paging. The backend holds only scratch bounds.
367+
original slot addresses and full capacities. Backing owns alias allocation and
368+
paging. The backend holds only scratch bounds.
369369
Guest completion mapping checks scratch bounds. Allocation ownership and
370370
initialized lengths follow the `BufferMap` safety contract.
371371

@@ -381,8 +381,8 @@ external `ByteChunks`:
381381
* G2H host responses can become owner-backed guest `Bytes`.
382382
* `VecBytes` values copy into a contiguous `Vec<u8>`.
383383
* Multiple `Bytes` clones or slices backed by one owner keep one slot live.
384-
* The final owner returns its original `BufferLease`. Active pool mappings
385-
remain available across ordinary calls.
384+
* The final owner unmaps its alias and returns the virtual range for reuse.
385+
Its lease releases the scratch slot only in the slot's current generation.
386386

387387
Producer reset releases allocations still owned by queue bookkeeping. After
388388
both producers reset and before H2G prefill, every live pool slot belongs to
@@ -393,14 +393,16 @@ handles. The host resets both consumers before processing more queue traffic.
393393

394394
### Retained virtual addresses
395395

396-
Each pool has a stable virtual alias range, mapped eagerly at initialization.
397-
Completed `GuestMapping` owners keep their original leases.
398-
Capture, restore, and cloning preserve their pointers and contents.
396+
Both pools share one guest-global alias allocator. Its state is captured with
397+
the mappings and keeps retained ranges reserved across pool generations.
398+
Each completed `GuestMapping` owns a separate page-aligned range. Mapping occurs
399+
when the guest receives the buffer. Capture, restore, and cloning preserve its
400+
pointer and contents.
399401

400-
Checkpoint preparation records the live slots. After restore, aliases of their
401-
pages reach captured data. The first transport entry copies the recorded slots
402-
into scratch and maps every pool alias to scratch. Only recorded slots are
403-
copied, so neighboring slots keep host writes such as the first request.
402+
Retained aliases reach captured memory after restore. The first transport
403+
entry advances the pool generations and recycles slots outside posted chains.
404+
Retained values keep their aliases until their final owner drops. Reusing
405+
freed virtual ranges bounds page-table growth by the alias high-water mark.
404406

405407
### Trust boundary
406408

@@ -437,8 +439,6 @@ snapshot uses this flow:
437439
| | reclaim completed G2H work
438440
| | reset G2H producer
439441
| | reset H2G producer
440-
| | record live slots
441-
| | unmap pages outside live slots
442442
| | prefill free H2G slots
443443
| | mailbox = CheckpointComplete
444444
|<------------------------------------| halt
@@ -459,7 +459,7 @@ The canonical state is:
459459
bounded by queue size. Each descriptor names a distinct, configured-size
460460
slot aligned relative to the pool start. Available descriptors form a prefix
461461
followed by zeroed descriptors.
462-
* Guest producer and pool bookkeeping matches the rings and retained leases.
462+
* Guest producer and pool bookkeeping matches the rings and current leases.
463463
* Driver and device event suppression is normalized.
464464
* Host consumers start at cursor zero.
465465

@@ -490,11 +490,13 @@ The first request fits the H2G capacity posted at checkpoint. Retained slots
490490
reduce pool capacity but hold no ring descriptors. Framing, slot rounding, ring
491491
size, and the external-byte control reserve still apply.
492492

493-
The first transport entry after checkpoint maps missing pool pages to scratch.
494-
After restore, it also copies recorded slots from captured aliases into scratch
495-
and remaps their pages. This applies to logs and host callbacks before request
496-
dispatch as well as ordinary H2G calls. Pool ownership, descriptors, and
497-
cursors stay unchanged, preserving the submitted request.
493+
The first transport entry after restore recycles slots held by captured
494+
leases. Posted chains keep their reservations, preserving the submitted
495+
request. Descriptors and cursors stay unchanged. Retained aliases keep their
496+
captured mappings without payload copying.
497+
498+
Source continuation keeps the pool generation and live scratch leases intact.
499+
Checkpointing alone does not detach retained payloads from scratch.
498500

499501
Result completion prefills free H2G slots. This requires no preparatory guest
500502
entry or application warmup for the checkpoint-posted capacity.

‎src/hyperlight_common/src/arch/aarch64/layout.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ pub const WHP_GITS_TRANSLATOR_BASE_GPA: u64 = 0xeff6_8000;
1818
pub const WHP_GICR_BASE_GPA: u64 = 0xeffe_e000;
1919
pub const WHP_GICD_BASE_GPA: u64 = 0xffff_0000;
2020

21+
const _: () = assert!(VIRTQ_BUFFER_GVA_END <= 1 << 48);
22+
2123
pub const fn io_page() -> Option<(crate::vmem::PhysAddr, crate::vmem::VirtAddr)> {
2224
Some((IO_PAGE_GPA, IO_PAGE_GVA))
2325
}

‎src/hyperlight_common/src/arch/amd64/layout.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ pub const VIRTQ_BUFFER_GVA_END: u64 = 0xffff_fd00_0000_0000;
2020
/// ever memory-constrained.
2121
pub const SCRATCH_TOP_GPA: usize = 0x0000_000f_ffff_ffff;
2222

23+
const _: () = {
24+
assert!(VIRTQ_BUFFER_GVA_START >> 47 == 0x1ffff);
25+
assert!((VIRTQ_BUFFER_GVA_END - 1) >> 47 == 0x1ffff);
26+
};
27+
2328
pub fn io_page() -> Option<(u64, u64)> {
2429
None
2530
}

‎src/hyperlight_common/src/layout.rs‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,6 @@ const _: () = {
109109
assert!((VIRTQ_BUFFER_GVA_END as usize).is_multiple_of(crate::vmem::PAGE_SIZE));
110110
assert!(VIRTQ_BUFFER_GVA_START > SNAPSHOT_PT_GVA_MAX as u64);
111111

112-
#[cfg(target_arch = "x86_64")]
113-
assert!(VIRTQ_BUFFER_GVA_START >> 47 == 0x1ffff);
114-
#[cfg(target_arch = "x86_64")]
115-
assert!((VIRTQ_BUFFER_GVA_END - 1) >> 47 == 0x1ffff);
116-
#[cfg(target_arch = "aarch64")]
117-
assert!(VIRTQ_BUFFER_GVA_END <= 1 << 48);
118-
119112
assert!(VIRTQ_BUFFER_GVA_END < scratch_base_gva(16 * 1024 * 1024 * 1024));
120113
assert!((SCRATCH_TOP_GPA as u64) < VIRTQ_BUFFER_GVA_END - VIRTQ_BUFFER_GVA_START);
121114
};

‎src/hyperlight_common/src/virtq/buffer.rs‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,18 +245,26 @@ impl Buf for SegmentsBuf<'_> {
245245
}
246246
}
247247

248-
/// An exclusively owned buffer allocation returned to its pool on drop.
248+
/// A buffer allocation returned on drop while its pool generation is current.
249249
pub struct BufferLease {
250250
/// The pool that allocated the buffer.
251251
pool: SlotPool,
252+
/// The pool generation at the time of allocation.
253+
generation: u64,
252254
/// The buffer's start address and full allocation capacity.
253255
allocation: Allocation,
254256
}
255257

256258
impl BufferLease {
257259
/// Create a new buffer lease from a pool and allocation.
258260
pub fn new(pool: SlotPool, allocation: Allocation) -> Self {
259-
Self { pool, allocation }
261+
let generation = pool.generation();
262+
263+
Self {
264+
pool,
265+
allocation,
266+
generation,
267+
}
260268
}
261269

262270
/// The buffer's start address and full allocation capacity.
@@ -267,6 +275,10 @@ impl BufferLease {
267275

268276
impl Drop for BufferLease {
269277
fn drop(&mut self) {
278+
if self.generation != self.pool.generation() {
279+
return;
280+
}
281+
270282
if let Err(error) = self.pool.dealloc(self.allocation.addr) {
271283
log::error!("Failed to release a virtqueue buffer: {error}");
272284
debug_assert!(false, "BufferLease deallocation failed: {error}");
@@ -302,6 +314,42 @@ mod tests {
302314
pool.dealloc(reused.addr).unwrap();
303315
}
304316

317+
#[test]
318+
fn retired_lease_does_not_release_a_reused_slot() {
319+
let pool = SlotPool::new(SlotLayout::new(0x1000, 4096, 1).unwrap()).unwrap();
320+
let allocation = pool.alloc(4096).unwrap();
321+
let retained = BufferLease::new(pool.clone(), allocation);
322+
323+
// SAFETY: This test has no views or queue reservations.
324+
unsafe { pool.retire_generation(core::iter::empty()) }.unwrap();
325+
let reused = pool.alloc(4096).unwrap();
326+
assert_eq!(reused.addr, allocation.addr);
327+
328+
drop(retained);
329+
assert_eq!(pool.num_free(), 0);
330+
drop(BufferLease::new(pool.clone(), reused));
331+
assert_eq!(pool.num_free(), 1);
332+
}
333+
334+
#[test]
335+
fn generation_recovery_preserves_queue_reservations() {
336+
let pool = SlotPool::new(SlotLayout::new(0x1000, 4096, 2).unwrap()).unwrap();
337+
let reserved = pool.alloc(4096).unwrap();
338+
let captured = pool.alloc(4096).unwrap();
339+
let retained = BufferLease::new(pool.clone(), captured);
340+
341+
// SAFETY: No view exists. The iterator names the only queue reservation.
342+
unsafe { pool.retire_generation(core::iter::once(reserved.addr)) }.unwrap();
343+
assert_eq!(pool.num_free(), 1);
344+
assert_eq!(pool.alloc(4096).unwrap().addr, captured.addr);
345+
346+
drop(retained);
347+
assert_eq!(pool.num_free(), 0);
348+
pool.dealloc(reserved.addr).unwrap();
349+
pool.dealloc(captured.addr).unwrap();
350+
assert_eq!(pool.num_free(), 2);
351+
}
352+
305353
#[test]
306354
fn segments_cursor_advances_across_segments() {
307355
let segments = Segments::new([

‎src/hyperlight_common/src/virtq/pool/slot.rs‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,28 @@ impl Tier {
186186
Ok(())
187187
}
188188

189+
/// Keep one queue-owned slot unavailable during generation recovery.
190+
fn reserve_addr(&mut self, addr: u64) -> Result<(), AllocError> {
191+
let slot = self.slot_of(addr)?;
192+
193+
if self.allocated.contains(slot) {
194+
return Err(AllocError::InvalidArg);
195+
}
196+
197+
self.allocated.insert(slot);
198+
Ok(())
199+
}
200+
201+
/// Rebuild the free list from the recovered ownership bitmap.
202+
fn rebuild_free(&mut self) {
203+
self.free.clear();
204+
205+
for slot in self.allocated.zeroes() {
206+
self.free
207+
.push(self.base_addr + slot as u64 * u64::from(self.slot_size));
208+
}
209+
}
210+
189211
fn allocation_len(&self, addr: u64) -> Result<usize, AllocError> {
190212
self.live_slot_of(addr)?;
191213
Ok(self.slot_size as usize)
@@ -219,6 +241,7 @@ impl Tier {
219241
struct Inner {
220242
lower: Option<Tier>,
221243
upper: Tier,
244+
generation: u64,
222245
}
223246

224247
impl Inner {
@@ -227,6 +250,7 @@ impl Inner {
227250
return Ok(Self {
228251
lower: None,
229252
upper: Tier::from_layout(upper)?,
253+
generation: 0,
230254
});
231255
};
232256

@@ -249,12 +273,14 @@ impl Inner {
249273
return Ok(Self {
250274
lower: None,
251275
upper: Tier::from_layout(layout)?,
276+
generation: 0,
252277
});
253278
}
254279

255280
Ok(Self {
256281
lower: Some(Tier::from_layout(lower)?),
257282
upper: Tier::from_layout(upper)?,
283+
generation: 0,
258284
})
259285
}
260286

@@ -285,6 +311,43 @@ impl Inner {
285311
self.upper.dealloc_addr(addr)
286312
}
287313

314+
/// Start a new generation and rebuild allocation state from `reserved`.
315+
fn retire_generation(
316+
&mut self,
317+
reserved: impl Iterator<Item = u64> + Clone,
318+
) -> Result<(), AllocError> {
319+
let generation = self.generation.checked_add(1).ok_or(AllocError::Overflow)?;
320+
321+
for addr in reserved.clone() {
322+
self.allocation_len(addr)?;
323+
}
324+
325+
self.generation = generation;
326+
self.upper.allocated.clear();
327+
328+
if let Some(lower) = &mut self.lower {
329+
lower.allocated.clear();
330+
}
331+
332+
for addr in reserved {
333+
if let Some(lower) = &mut self.lower
334+
&& lower.contains(addr)
335+
{
336+
lower.reserve_addr(addr)?;
337+
} else {
338+
self.upper.reserve_addr(addr)?;
339+
}
340+
}
341+
342+
self.upper.rebuild_free();
343+
344+
if let Some(lower) = &mut self.lower {
345+
lower.rebuild_free();
346+
}
347+
348+
Ok(())
349+
}
350+
288351
fn allocation_len(&self, addr: u64) -> Result<usize, AllocError> {
289352
if let Some(lower) = &self.lower
290353
&& lower.contains(addr)
@@ -352,6 +415,25 @@ pub struct SlotPool {
352415
}
353416

354417
impl SlotPool {
418+
/// Generation owning the current scratch allocations.
419+
pub(crate) fn generation(&self) -> u64 {
420+
self.inner.borrow().generation
421+
}
422+
423+
/// Retire existing leases and rebuild the pool around the supplied reservations.
424+
///
425+
/// # Safety
426+
///
427+
/// Retired views must already use backing independent of these scratch slots.
428+
/// `reserved` must name every other live allocation exactly once.
429+
/// No allocation or peer access may race this call.
430+
pub(crate) unsafe fn retire_generation(
431+
&self,
432+
reserved: impl Iterator<Item = u64> + Clone,
433+
) -> Result<(), AllocError> {
434+
self.inner.borrow_mut().retire_generation(reserved)
435+
}
436+
355437
/// Create a single-tier recycling pool from exact slot placement.
356438
pub fn new(layout: SlotLayout) -> Result<Self, AllocError> {
357439
Self::from_layouts(None, layout)

‎src/hyperlight_common/src/virtq/producer.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,30 @@ where
240240
self.inner.mem()
241241
}
242242

243+
/// Reclaim retained scratch slots while preserving in-flight chains.
244+
///
245+
/// # Safety
246+
///
247+
/// Every live allocation outside this producer's in-flight chains must
248+
/// belong to a returned view with backing independent of reused scratch.
249+
/// No unsent chains or builders may remain. Peer accesses must be stopped.
250+
pub unsafe fn reclaim_slots(&mut self) -> Result<(), VirtqError> {
251+
if !self.pending.is_empty() {
252+
return Err(VirtqError::InvalidState);
253+
}
254+
255+
let reserved = self
256+
.inflight
257+
.live
258+
.iter()
259+
.flat_map(|inflight| inflight.chain.buffers.iter().map(|buffer| buffer.addr));
260+
261+
// SAFETY: In-flight records preserve all non-retired allocations.
262+
// The caller guarantees independent backing and excludes untracked chains.
263+
unsafe { self.pool.retire_generation(reserved)? };
264+
Ok(())
265+
}
266+
243267
/// Begin building a descriptor chain for submission.
244268
///
245269
/// The builder captures the current free-descriptor budget.

‎src/hyperlight_guest/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ Provides only the essential building blocks for interacting with the host enviro
1515
anyhow = { version = "1.0.102", default-features = false }
1616
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
1717
hyperlight-common = { workspace = true, default-features = false }
18-
fixedbitset = { version = "0.5.7", default-features = false }
1918
flatbuffers = { version= "25.12.19", default-features = false }
2019
tracing = { version = "0.1.44", default-features = false, features = ["attributes"] }
20+
itertools = { version = "0.15.0", default-features = false }
2121

2222
[target.'cfg(target_arch = "x86_64")'.dependencies]
2323
hyperlight-guest-tracing = { workspace = true, default-features = false, optional = true }

0 commit comments

Comments
 (0)