diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d316b4d..9fc8fa94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- `GarbageCollector` pool wrapper for retaining only cached acceleration structures, buffers, and + images that support recently observed requests. + ## [0.14.5] - 2026-07-13 ### Added diff --git a/guide/src/resource.md b/guide/src/resource.md index 927b6937..4bb9840e 100644 --- a/guide/src/resource.md +++ b/guide/src/resource.md @@ -4,7 +4,8 @@ API docs: [`Graph::bind_resource`](https://docs.rs/vk-graph/latest/vk_graph/stru [`Graph::resource`](https://docs.rs/vk-graph/latest/vk_graph/struct.Graph.html#method.resource), [`Node`](https://docs.rs/vk-graph/latest/vk_graph/node/trait.Node.html), [`Pool`](https://docs.rs/vk-graph/latest/vk_graph/pool/trait.Pool.html), -[`Cache`](https://docs.rs/vk-graph/latest/vk_graph/pool/cache/struct.Cache.html). +[`Cache`](https://docs.rs/vk-graph/latest/vk_graph/pool/cache/struct.Cache.html), +[`GarbageCollector`](https://docs.rs/vk-graph/latest/vk_graph/pool/garbage_collector/struct.GarbageCollector.html). > [!CAUTION] > All pipelines and resources (_buffers, images, and acceleration structures_) used in a `Graph` @@ -66,6 +67,17 @@ over any `Pool`. Cached resources let complex graphs reuse compatible resources while keeping the pooling strategy separate from the reuse policy. +## Garbage Collection + +The built-in pools may be wrapped in `GarbageCollector` to automatically remove cached acceleration +structures, buffers, and images that no longer match the application's requests. Successful requests +are observed until `GarbageCollector::collect_resources` is called. Collection retains resources +supporting those requests, then starts a new observation interval. + +Calling `collect_resources` without making any requests clears all managed resources. Checked-out +resources remain valid during collection; a resource returning to a retained bucket is considered by +the next collection. + ## Queue Sharing `BufferInfo` and `ImageInfo` both expose `sharing_mode`. diff --git a/src/pool/fifo.rs b/src/pool/fifo.rs index 471c9e48..08b00592 100644 --- a/src/pool/fifo.rs +++ b/src/pool/fifo.rs @@ -3,6 +3,7 @@ use { super::{ BufferHostMappingCompatibility, Cache, Lease, Pool, PoolConfig, compatible_buffer_info, + garbage_collector::{CollectResources, ResourceRequests}, with_cache, }, crate::driver::{ @@ -19,6 +20,30 @@ use { std::{collections::HashMap, sync::Arc}, }; +fn compatible_accel_struct_info( + item_info: &AccelerationStructureInfo, + requested_info: &AccelerationStructureInfo, +) -> bool { + item_info.size >= requested_info.size + && item_info.acceleration_structure_type == requested_info.acceleration_structure_type +} + +fn compatible_fifo_image_info(item_info: &ImageInfo, requested_info: &ImageInfo) -> bool { + item_info.array_layer_count == requested_info.array_layer_count + && item_info.alloc_dedicated == requested_info.alloc_dedicated + && item_info.depth == requested_info.depth + && item_info.format == requested_info.format + && item_info.height == requested_info.height + && item_info.mip_level_count == requested_info.mip_level_count + && item_info.sample_count == requested_info.sample_count + && item_info.sharing_mode == requested_info.sharing_mode + && item_info.tiling == requested_info.tiling + && item_info.image_type == requested_info.image_type + && item_info.width == requested_info.width + && item_info.flags.contains(requested_info.flags) + && item_info.usage.contains(requested_info.usage) +} + /// A memory-efficient resource allocator. /// /// The information for each resource request is compared against the stored resources for @@ -116,6 +141,52 @@ impl FifoPool { } } +impl CollectResources for FifoPool { + fn collect_resources(&mut self, requests: &ResourceRequests) { + if requests.accel_structs.is_empty() { + self.clear_accel_structs(); + } else { + with_cache(&self.accel_struct_cache, |cache| { + cache.retain(|item| { + requests + .accel_structs + .iter() + .any(|info| compatible_accel_struct_info(&item.info, info)) + }); + }); + } + + if requests.buffers.is_empty() { + self.clear_buffers(); + } else { + with_cache(&self.buffer_cache, |cache| { + cache.retain(|item| { + requests.buffers.iter().any(|info| { + compatible_buffer_info( + &item.info, + info, + BufferHostMappingCompatibility::Exact, + ) + }) + }); + }); + } + + if requests.images.is_empty() { + self.clear_images(); + } else { + with_cache(&self.image_cache, |cache| { + cache.retain(|item| { + requests + .images + .iter() + .any(|info| compatible_fifo_image_info(&item.info, info)) + }); + }); + } + } +} + impl Pool for FifoPool { #[profiling::function] fn resource( @@ -131,9 +202,7 @@ impl Pool for FifoPool { // Look for a compatible acceleration structure (big enough and same type) for idx in 0..cache.len() { let item = unsafe { cache.get_unchecked(idx) }; - if item.info.size >= info.size - && item.info.acceleration_structure_type == info.acceleration_structure_type - { + if compatible_accel_struct_info(&item.info, &info) { let item = cache.swap_remove(idx); return Some(Lease::new(cache_ref.clone(), item)); @@ -276,20 +345,7 @@ impl Pool for FifoPool { // usage flags) for idx in 0..cache.len() { let item = unsafe { cache.get_unchecked(idx) }; - if item.info.array_layer_count == info.array_layer_count - && item.info.alloc_dedicated == info.alloc_dedicated - && item.info.depth == info.depth - && item.info.format == info.format - && item.info.height == info.height - && item.info.mip_level_count == info.mip_level_count - && item.info.sample_count == info.sample_count - && item.info.sharing_mode == info.sharing_mode - && item.info.tiling == info.tiling - && item.info.image_type == info.image_type - && item.info.width == info.width - && item.info.flags.contains(info.flags) - && item.info.usage.contains(info.usage) - { + if compatible_fifo_image_info(&item.info, &info) { let item = cache.swap_remove(idx); return Some(Lease::new(cache_ref.clone(), item)); @@ -332,3 +388,38 @@ impl Pool for FifoPool { Ok(Lease::new(Arc::downgrade(cache_ref), item)) } } + +#[cfg(test)] +mod test { + use { + super::*, + crate::{ + driver::device::{Device, DeviceInfo}, + pool::garbage_collector::GarbageCollector, + }, + ash::vk, + }; + + #[test] + #[ignore = "requires Vulkan device"] + fn vulkan_garbage_collector_retains_supported_fifo_resources() -> Result<(), DriverError> { + let device = Device::create(DeviceInfo::default())?; + let mut collector = GarbageCollector::new(FifoPool::with_capacity(&device, 4)); + let retained_info = BufferInfo::device_mem(64, vk::BufferUsageFlags::TRANSFER_SRC); + let removed_info = BufferInfo::device_mem(64, vk::BufferUsageFlags::STORAGE_BUFFER); + + drop(collector.resource(retained_info)?); + drop(collector.resource(removed_info)?); + collector.collect_resources(); + assert_eq!(with_cache(&collector.buffer_cache, |cache| cache.len()), 2); + + drop(collector.resource(retained_info)?); + collector.collect_resources(); + assert_eq!(with_cache(&collector.buffer_cache, |cache| cache.len()), 1); + + collector.collect_resources(); + assert_eq!(with_cache(&collector.buffer_cache, |cache| cache.len()), 0); + + Ok(()) + } +} diff --git a/src/pool/garbage_collector.rs b/src/pool/garbage_collector.rs new file mode 100644 index 00000000..7d38dbab --- /dev/null +++ b/src/pool/garbage_collector.rs @@ -0,0 +1,242 @@ +//! Pool wrapper which removes cached resources that no longer support observed requests. + +use { + super::{Lease, Pool}, + crate::driver::{ + DriverError, + accel_struct::{AccelerationStructure, AccelerationStructureInfo}, + buffer::{Buffer, BufferInfo}, + cmd_buf::{CommandBuffer, CommandBufferInfo}, + descriptor_set::{DescriptorPool, DescriptorPoolInfo}, + image::{Image, ImageInfo}, + render_pass::{RenderPass, RenderPassInfo}, + }, + std::{ + collections::HashSet, + ops::{Deref, DerefMut}, + }, +}; + +#[derive(Default)] +pub(super) struct ResourceRequests { + pub(super) accel_structs: HashSet, + pub(super) buffers: HashSet, + pub(super) images: HashSet, +} + +impl ResourceRequests { + fn clear(&mut self) { + self.accel_structs.clear(); + self.buffers.clear(); + self.images.clear(); + } +} + +pub(super) trait CollectResources { + fn collect_resources(&mut self, requests: &ResourceRequests); +} + +/// A request-aware garbage collector for built-in [`Pool`] types. +/// +/// Successful acceleration-structure, buffer, and image requests are recorded until +/// [`GarbageCollector::collect_resources`] is called. Collection retains only cached resources that +/// the wrapped pool could use to satisfy those requests, then begins a new observation interval. +/// Calling `collect_resources` without making any requests clears all managed resources. +/// +/// Checked-out resources remain valid during collection. Resources returning to a retained bucket +/// are evaluated by the next collection, while resources returning to a removed bucket are dropped. +/// Command buffers, descriptor pools, and render passes are forwarded without being collected. +/// +/// # Examples +/// +/// ```no_run +/// # use ash::vk; +/// # use vk_graph::driver::DriverError; +/// # use vk_graph::driver::buffer::BufferInfo; +/// # use vk_graph::driver::device::{Device, DeviceInfo}; +/// # use vk_graph::pool::Pool; +/// # use vk_graph::pool::garbage_collector::GarbageCollector; +/// # use vk_graph::pool::lazy::LazyPool; +/// # fn main() -> Result<(), DriverError> { +/// # let device = Device::create(DeviceInfo::default())?; +/// let mut pool = GarbageCollector::new(LazyPool::new(&device)); +/// +/// let buffer = pool.resource(BufferInfo::device_mem( +/// 1024, +/// vk::BufferUsageFlags::STORAGE_BUFFER, +/// ))?; +/// drop(buffer); +/// +/// // Retain cached resources supporting requests made since the previous collection. +/// pool.collect_resources(); +/// # Ok(()) } +/// ``` +pub struct GarbageCollector { + pool: T, + requests: ResourceRequests, +} + +impl GarbageCollector { + /// Creates a garbage collector wrapper over the given pool. + pub fn new(pool: T) -> Self { + Self { + pool, + requests: Default::default(), + } + } +} + +#[allow(private_bounds)] +impl GarbageCollector +where + T: CollectResources, +{ + /// Collects cached resources and begins a new request observation interval. + /// + /// Only acceleration structures, buffers, and images supporting successful requests made since + /// the previous call are retained. If there were no such requests, all managed resources are + /// removed. + pub fn collect_resources(&mut self) { + self.pool.collect_resources(&self.requests); + self.requests.clear(); + } +} + +macro_rules! tracked_pool { + ($info:ty => $item:ty, $requests:ident) => { + impl Pool<$info, $item> for GarbageCollector + where + T: Pool<$info, $item>, + { + fn resource(&mut self, info: $info) -> Result, DriverError> { + let item = self.pool.resource(info)?; + self.requests.$requests.insert(info); + + Ok(item) + } + } + }; +} + +tracked_pool!(AccelerationStructureInfo => AccelerationStructure, accel_structs); +tracked_pool!(BufferInfo => Buffer, buffers); +tracked_pool!(ImageInfo => Image, images); + +macro_rules! forwarded_pool { + ($info:ty => $item:ty) => { + impl Pool<$info, $item> for GarbageCollector + where + T: Pool<$info, $item>, + { + fn resource(&mut self, info: $info) -> Result, DriverError> { + self.pool.resource(info) + } + } + }; +} + +forwarded_pool!(CommandBufferInfo => CommandBuffer); +forwarded_pool!(DescriptorPoolInfo => DescriptorPool); +forwarded_pool!(RenderPassInfo => RenderPass); + +impl Deref for GarbageCollector { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.pool + } +} + +impl DerefMut for GarbageCollector { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.pool + } +} + +#[cfg(test)] +mod test { + use { + super::*, + crate::{ + driver::{ + accel_struct::AccelerationStructureInfoBuilder, buffer::BufferInfoBuilder, + image::ImageInfoBuilder, + }, + pool::{SubmissionPool, fifo::FifoPool, hash::HashPool, lazy::LazyPool}, + }, + ash::vk, + }; + + #[derive(Default)] + struct CollectSpy { + calls: Vec<(usize, usize, usize)>, + } + + impl CollectResources for CollectSpy { + fn collect_resources(&mut self, requests: &ResourceRequests) { + self.calls.push(( + requests.accel_structs.len(), + requests.buffers.len(), + requests.images.len(), + )); + } + } + + struct FailingPool; + + impl Pool for FailingPool { + fn resource(&mut self, _: BufferInfo) -> Result, DriverError> { + Err(DriverError::Unsupported) + } + } + + fn assert_pool_capabilities() + where + T: Pool + + Pool + + Pool + + Pool + + Pool + + Pool + + Pool + + SubmissionPool, + { + } + + fn assert_collect_resources() {} + + #[test] + fn collect_resources_sweeps_and_resets_observed_requests() { + let mut collector = GarbageCollector::new(CollectSpy::default()); + let info = BufferInfo::device_mem(64, vk::BufferUsageFlags::STORAGE_BUFFER); + + collector.requests.buffers.insert(info); + collector.requests.buffers.insert(info); + collector.collect_resources(); + collector.collect_resources(); + + assert_eq!(collector.pool.calls, [(0, 1, 0), (0, 0, 0)]); + } + + #[test] + fn failed_requests_are_not_observed() { + let mut collector = GarbageCollector::new(FailingPool); + let info = BufferInfo::device_mem(64, vk::BufferUsageFlags::STORAGE_BUFFER); + + assert!(matches!( + Pool::::resource(&mut collector, info), + Err(DriverError::Unsupported) + )); + assert!(collector.requests.buffers.is_empty()); + } + + #[test] + fn built_in_pools_support_collection_and_pool_capabilities() { + assert_collect_resources::(); + assert_collect_resources::(); + assert_collect_resources::(); + assert_pool_capabilities::>(); + assert_pool_capabilities::>(); + assert_pool_capabilities::>(); + } +} diff --git a/src/pool/hash.rs b/src/pool/hash.rs index e40da9dd..2273c0fe 100644 --- a/src/pool/hash.rs +++ b/src/pool/hash.rs @@ -1,7 +1,10 @@ //! Pool which requests by exactly matching the information before creating new resources. use { - super::{Cache, Lease, Pool, PoolConfig}, + super::{ + Cache, Lease, Pool, PoolConfig, + garbage_collector::{CollectResources, ResourceRequests}, + }, crate::driver::{ DriverError, accel_struct::{AccelerationStructure, AccelerationStructureInfo}, @@ -94,6 +97,17 @@ impl HashPool { } } +impl CollectResources for HashPool { + fn collect_resources(&mut self, requests: &ResourceRequests) { + self.acceleration_structure_cache + .retain(|info, _| requests.accel_structs.contains(info)); + self.buffer_cache + .retain(|info, _| requests.buffers.contains(info)); + self.image_cache + .retain(|info, _| requests.images.contains(info)); + } +} + macro_rules! resource_mgmt_fns { ($fn_plural:literal, $doc_singular:literal, $ty:ty, $field:ident) => { paste! { @@ -266,3 +280,39 @@ macro_rules! lease { lease!(AccelerationStructureInfo => AccelerationStructure, accel_struct_capacity); lease!(BufferInfo => Buffer, buffer_capacity); lease!(ImageInfo => Image, image_capacity); + +#[cfg(test)] +mod test { + use { + super::*, + crate::{ + driver::device::{Device, DeviceInfo}, + pool::garbage_collector::GarbageCollector, + }, + ash::vk, + }; + + #[test] + #[ignore = "requires Vulkan device"] + fn vulkan_garbage_collector_retains_requested_hash_buckets() -> Result<(), DriverError> { + let device = Device::create(DeviceInfo::default())?; + let mut collector = GarbageCollector::new(HashPool::with_capacity(&device, 4)); + let retained_info = BufferInfo::device_mem(64, vk::BufferUsageFlags::TRANSFER_SRC); + let removed_info = BufferInfo::device_mem(64, vk::BufferUsageFlags::STORAGE_BUFFER); + + drop(collector.resource(retained_info)?); + drop(collector.resource(removed_info)?); + collector.collect_resources(); + assert_eq!(collector.buffer_cache.len(), 2); + + drop(collector.resource(retained_info)?); + collector.collect_resources(); + assert_eq!(collector.buffer_cache.len(), 1); + assert!(collector.buffer_cache.contains_key(&retained_info)); + + collector.collect_resources(); + assert!(collector.buffer_cache.is_empty()); + + Ok(()) + } +} diff --git a/src/pool/lazy.rs b/src/pool/lazy.rs index 846166dc..9bb32387 100644 --- a/src/pool/lazy.rs +++ b/src/pool/lazy.rs @@ -3,6 +3,8 @@ use { super::{ BufferHostMappingCompatibility, Cache, Lease, Pool, PoolConfig, compatible_buffer_info, + garbage_collector::{CollectResources, ResourceRequests}, + with_cache, }, crate::driver::{ DriverError, @@ -19,6 +21,33 @@ use { std::{collections::HashMap, sync::Arc}, }; +type BufferKey = (bool, vk::DeviceSize, vk::SharingMode); + +fn buffer_key(info: &BufferInfo) -> BufferKey { + ( + info.host_readable | info.host_writable, + info.alignment, + info.sharing_mode, + ) +} + +fn compatible_accel_struct_info( + item_info: &AccelerationStructureInfo, + requested_info: &AccelerationStructureInfo, +) -> bool { + item_info.acceleration_structure_type == requested_info.acceleration_structure_type + && item_info.size >= requested_info.size +} + +fn compatible_lazy_buffer_info(item_info: &BufferInfo, requested_info: &BufferInfo) -> bool { + buffer_key(item_info) == buffer_key(requested_info) + && compatible_buffer_info( + item_info, + requested_info, + BufferHostMappingCompatibility::Superset, + ) +} + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] struct ImageKey { array_layer_count: u32, @@ -50,6 +79,12 @@ impl From for ImageKey { } } +fn compatible_lazy_image_info(item_info: &ImageInfo, requested_info: &ImageInfo) -> bool { + ImageKey::from(*item_info) == ImageKey::from(*requested_info) + && item_info.flags.contains(requested_info.flags) + && item_info.usage.contains(requested_info.usage) +} + /// A balanced resource allocator. /// /// The information for each resource request is compared against the stored resources for @@ -80,7 +115,7 @@ impl From for ImageKey { #[read_only::cast] pub struct LazyPool { accel_struct_cache: HashMap>, - buffer_cache: HashMap<(bool, vk::DeviceSize, vk::SharingMode), Cache>, + buffer_cache: HashMap>, command_buffer_cache: HashMap>, descriptor_pool_cache: Cache, @@ -179,6 +214,67 @@ impl LazyPool { } } +impl CollectResources for LazyPool { + fn collect_resources(&mut self, requests: &ResourceRequests) { + self.accel_struct_cache.retain(|accel_struct_ty, cache| { + let retain_bucket = requests + .accel_structs + .iter() + .any(|info| info.acceleration_structure_type == *accel_struct_ty); + + if retain_bucket { + with_cache(cache, |cache| { + cache.retain(|item| { + requests + .accel_structs + .iter() + .any(|info| compatible_accel_struct_info(&item.info, info)) + }); + }); + } + + retain_bucket + }); + + self.buffer_cache.retain(|key, cache| { + let retain_bucket = requests.buffers.iter().any(|info| buffer_key(info) == *key); + + if retain_bucket { + with_cache(cache, |cache| { + cache.retain(|item| { + requests + .buffers + .iter() + .any(|info| compatible_lazy_buffer_info(&item.info, info)) + }); + }); + } + + retain_bucket + }); + + self.image_cache.retain(|key, cache| { + let retain_bucket = requests + .images + .iter() + .any(|info| ImageKey::from(*info) == *key); + + if retain_bucket { + with_cache(cache, |cache| { + cache.retain(|item| { + requests + .images + .iter() + .any(|info| compatible_lazy_image_info(&item.info, info)) + }); + }); + } + + retain_bucket + }); + } +} + impl Pool for LazyPool { #[profiling::function] fn resource( @@ -203,7 +299,7 @@ impl Pool for LazyPool { // Look for a compatible acceleration structure (big enough) for idx in 0..cache.len() { let item = unsafe { cache.get_unchecked(idx) }; - if item.info.size >= info.size { + if compatible_accel_struct_info(&item.info, &info) { let item = cache.swap_remove(idx); return Ok(Lease::new(cache_ref, item)); @@ -224,11 +320,7 @@ impl Pool for LazyPool { fn resource(&mut self, info: BufferInfo) -> Result, DriverError> { let cache = self .buffer_cache - .entry(( - info.host_readable | info.host_writable, - info.alignment, - info.sharing_mode, - )) + .entry(buffer_key(&info)) .or_insert_with(|| PoolConfig::explicit_cache(self.info.buffer_capacity)); let cache_ref = Arc::downgrade(cache); @@ -244,11 +336,7 @@ impl Pool for LazyPool { // Look for a compatible buffer (big enough and superset of usage flags) for idx in 0..cache.len() { let item = unsafe { cache.get_unchecked(idx) }; - if compatible_buffer_info( - &item.info, - &info, - BufferHostMappingCompatibility::Superset, - ) { + if compatible_lazy_buffer_info(&item.info, &info) { let item = cache.swap_remove(idx); return Ok(Lease::new(cache_ref, item)); @@ -361,7 +449,7 @@ impl Pool for LazyPool { // Look for a compatible image (superset of creation flags and usage flags) for idx in 0..cache.len() { let item = unsafe { cache.get_unchecked(idx) }; - if item.info.flags.contains(info.flags) && item.info.usage.contains(info.usage) { + if compatible_lazy_image_info(&item.info, &info) { let item = cache.swap_remove(idx); return Ok(Lease::new(cache_ref, item)); @@ -407,3 +495,44 @@ impl Pool for LazyPool { Ok(Lease::new(Arc::downgrade(cache_ref), item)) } } + +#[cfg(test)] +mod test { + use { + super::*, + crate::{ + driver::device::{Device, DeviceInfo}, + pool::garbage_collector::GarbageCollector, + }, + }; + + #[test] + #[ignore = "requires Vulkan device"] + fn vulkan_garbage_collector_retains_supported_lazy_resources() -> Result<(), DriverError> { + let device = Device::create(DeviceInfo::default())?; + let mut collector = GarbageCollector::new(LazyPool::with_capacity(&device, 4)); + let retained_info = BufferInfo::device_mem(64, vk::BufferUsageFlags::TRANSFER_SRC); + let removed_info = BufferInfo::device_mem(64, vk::BufferUsageFlags::STORAGE_BUFFER); + let key = buffer_key(&retained_info); + + drop(collector.resource(retained_info)?); + drop(collector.resource(removed_info)?); + collector.collect_resources(); + assert_eq!( + with_cache(&collector.buffer_cache[&key], |cache| cache.len()), + 2 + ); + + drop(collector.resource(retained_info)?); + collector.collect_resources(); + assert_eq!( + with_cache(&collector.buffer_cache[&key], |cache| cache.len()), + 1 + ); + + collector.collect_resources(); + assert!(collector.buffer_cache.is_empty()); + + Ok(()) + } +} diff --git a/src/pool/mod.rs b/src/pool/mod.rs index 86d7db78..b600a419 100644 --- a/src/pool/mod.rs +++ b/src/pool/mod.rs @@ -82,9 +82,17 @@ //! * Non-zero cost: atomic load and compatibility check per active cached resource //! * May cause GPU stalling if there is not enough work being submitted //! * Cached resources are typed `Arc>` and are not guaranteed to be mutable or unique +//! +//! # Garbage Collection +//! +//! Wrapping a built-in pool using [`garbage_collector::GarbageCollector::new`] records successful +//! resource requests. Calling [`garbage_collector::GarbageCollector::collect_resources`] retains +//! only cached acceleration structures, buffers, and images that support requests made since the +//! previous collection. pub mod cache; pub mod fifo; +pub mod garbage_collector; pub mod hash; pub mod lazy;