1use std::fmt;
4use std::hash::{Hash, Hasher};
5use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
6use std::sync::{Arc, Weak};
7
8use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
9use crossbeam_queue::ArrayQueue;
10use vyre_driver::accounting::{
11 pinning_atomic_add_usize_with_order, repair_atomic_sub_usize_fetch_with_order,
12};
13use vyre_driver::{BackendError, DispatchConfig};
14
15use super::handle::GpuBufferHandle;
16
17#[derive(Debug, Default)]
18#[repr(align(64))]
19struct PaddedAtomicUsize(AtomicUsize);
20
21impl PaddedAtomicUsize {
22 fn new(value: usize) -> Self {
23 Self(AtomicUsize::new(value))
24 }
25
26 fn load(&self, order: Ordering) -> usize {
27 self.0.load(order)
28 }
29
30 fn fetch_add(&self, value: usize, order: Ordering) -> usize {
31 pinning_atomic_add_usize_with_order(&self.0, value, order, Ordering::Relaxed, |_, _| {
32 tracing::error!(
33 "WGPU buffer-pool counter reached usize::MAX. Fix: reset pool stats or shard retained-buffer accounting before counters wrap."
34 );
35 })
36 }
37
38 fn fetch_sub(&self, value: usize, order: Ordering) -> usize {
39 repair_atomic_sub_usize_fetch_with_order(
40 &self.0,
41 value,
42 Ordering::Relaxed,
43 order,
44 Ordering::Relaxed,
45 |_, _| {
46 tracing::error!(
47 "WGPU buffer-pool counter underflow was repaired to zero. Fix: rebuild buffer-pool accounting before continuing."
48 );
49 },
50 )
51 }
52}
53
54#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub struct BufferPoolStats {
57 pub allocations: usize,
59 pub hits: usize,
61 pub releases: usize,
63 pub evictions: usize,
65 pub retained_bytes: usize,
67}
68
69#[derive(Debug)]
70pub(crate) struct PoolStats {
71 allocations: PaddedAtomicUsize,
72 hits: PaddedAtomicUsize,
73 releases: PaddedAtomicUsize,
74 evictions: PaddedAtomicUsize,
75 retained_bytes: PaddedAtomicUsize,
76}
77
78impl Default for PoolStats {
79 fn default() -> Self {
80 Self {
81 allocations: PaddedAtomicUsize::new(0),
82 hits: PaddedAtomicUsize::new(0),
83 releases: PaddedAtomicUsize::new(0),
84 evictions: PaddedAtomicUsize::new(0),
85 retained_bytes: PaddedAtomicUsize::new(0),
86 }
87 }
88}
89
90#[derive(Clone)]
91pub(crate) struct PoolReturn {
92 inner: Weak<PoolInner>,
93}
94
95#[derive(Clone)]
97pub struct BufferPool {
98 inner: Arc<PoolInner>,
99}
100
101impl fmt::Debug for BufferPool {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.debug_struct("BufferPool")
104 .field("stats", &self.stats())
105 .finish_non_exhaustive()
106 }
107}
108
109const NUM_SIZE_CLASSES: usize = 64;
110const DEFAULT_MAX_RETAINED_BYTES: usize = 1 << 30;
111const MAX_FREE_ENTRIES_PER_BUCKET: usize = 1024;
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119#[repr(usize)]
120enum UsageKind {
121 Input = 0,
122 Output = 1,
123 Uniform = 2,
124 Workgroup = 3,
125 Other = 4,
126}
127
128const NUM_USAGE_KINDS: usize = 5;
129
130impl UsageKind {
131 fn index(self) -> usize {
132 match self {
133 Self::Input => 0,
134 Self::Output => 1,
135 Self::Uniform => 2,
136 Self::Workgroup => 3,
137 Self::Other => 4,
138 }
139 }
140}
141
142fn canonical_usage_kind(usage: wgpu::BufferUsages) -> UsageKind {
143 use wgpu::BufferUsages as U;
144 if usage == U::STORAGE | U::COPY_DST {
145 UsageKind::Input
146 } else if usage == U::STORAGE | U::COPY_SRC | U::COPY_DST | U::INDIRECT {
147 UsageKind::Output
148 } else if usage == U::UNIFORM | U::COPY_DST {
149 UsageKind::Uniform
150 } else if usage == U::STORAGE | U::COPY_SRC | U::COPY_DST {
151 UsageKind::Workgroup
152 } else {
153 UsageKind::Other
154 }
155}
156
157const TIERING_EVENT_CAPACITY_MIN: usize = 1024;
158const TIERING_EVENT_CAPACITY_MAX: usize = 65_536;
159
160pub(crate) struct PoolTiering {
175 events: Sender<TieringEvent>,
176 pending_events: Arc<AtomicUsize>,
177 dropped_events: AtomicUsize,
178}
179
180#[derive(Clone, Copy, Debug)]
181enum TieringEvent {
182 Retain { key: u64, size: u64 },
183 Access { key: u64 },
184}
185
186impl PoolTiering {
187 fn new(
188 cache: crate::runtime::cache::TieredCache,
189 capacity: usize,
190 ) -> Result<Self, BackendError> {
191 let capacity = capacity.clamp(TIERING_EVENT_CAPACITY_MIN, TIERING_EVENT_CAPACITY_MAX);
192 let (events, receiver) = bounded(capacity);
193 let pending_events = Arc::new(AtomicUsize::new(0));
194 let worker_pending = Arc::clone(&pending_events);
195 std::thread::Builder::new()
196 .name("vyre-buffer-tiering".to_string())
197 .spawn(move || drain_tiering_events(cache, receiver, worker_pending))
198 .map_err(|error| {
199 BackendError::new(format!(
200 "failed to spawn vyre buffer tiering worker: {error}. Fix: raise process thread limits or disable buffer-pool tiering."
201 ))
202 })?;
203 Ok(Self {
204 events,
205 pending_events,
206 dropped_events: AtomicUsize::new(0),
207 })
208 }
209
210 #[inline]
211 fn record_retained(&self, key: u64, size: u64) {
212 self.enqueue(TieringEvent::Retain { key, size });
213 }
214
215 #[inline]
216 fn record_access(&self, key: u64) {
217 self.enqueue(TieringEvent::Access { key });
218 }
219
220 #[inline]
221 fn enqueue(&self, event: TieringEvent) {
222 self.pending_events.fetch_add(1, Ordering::Release);
223 match self.events.try_send(event) {
224 Ok(()) => {}
225 Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {
226 self.pending_events.fetch_sub(1, Ordering::AcqRel);
227 self.dropped_events.fetch_add(1, Ordering::Relaxed);
228 }
229 }
230 }
231
232 #[cfg(test)]
233 fn drain_all_for_test(&self) {
234 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
240 let mut backoff = crate::wait_backoff::AdaptiveWaitBackoff::from_micros(64, 2, 50, 5);
241 while std::time::Instant::now() < deadline {
242 if self.pending_events.load(Ordering::Acquire) == 0 {
243 return;
244 }
245 backoff.idle_until(deadline);
246 }
247 panic!("Fix: tiering metadata worker did not drain pending buffer-pool events");
248 }
249
250 #[cfg(test)]
251 fn dropped_events_for_test(&self) -> usize {
252 self.dropped_events.load(Ordering::Relaxed)
253 }
254}
255
256fn drain_tiering_events(
257 mut cache: crate::runtime::cache::TieredCache,
258 receiver: Receiver<TieringEvent>,
259 pending_events: Arc<AtomicUsize>,
260) {
261 while let Ok(event) = receiver.recv() {
262 match event {
263 TieringEvent::Retain { key, size } => {
264 if cache.get(key).is_none() {
265 if let Err(error) = cache.insert(key, size) {
266 tracing::warn!(
267 "buffer pool tiering rejected retained buffer {key} ({size} bytes): {error}. Fix: increase tier capacity or disable tiering for oversized buffers."
268 );
269 pending_events.fetch_sub(1, Ordering::AcqRel);
270 continue;
271 }
272 }
273 cache.record_access(key);
274 if let Err(error) = cache.promote(key) {
275 tracing::warn!(
276 "buffer pool tier promotion failed for retained buffer {key}: {error}. Fix: repair tier sizing or promotion accounting."
277 );
278 }
279 }
280 TieringEvent::Access { key } => {
281 cache.record_access(key);
282 if let Err(error) = cache.promote(key) {
283 tracing::warn!(
284 "buffer pool tier promotion failed for accessed buffer {key}: {error}. Fix: repair tier sizing or promotion accounting."
285 );
286 }
287 }
288 }
289 pending_events.fetch_sub(1, Ordering::AcqRel);
290 }
291}
292
293struct PoolInner {
294 device: wgpu::Device,
295 queue: wgpu::Queue,
296 free: [[ArrayQueue<FreeEntry>; NUM_USAGE_KINDS]; NUM_SIZE_CLASSES],
297 non_empty_classes: AtomicU64,
298 stats: PoolStats,
299 max_retained_bytes: usize,
300 tiering: Option<Arc<PoolTiering>>,
302}
303
304struct FreeEntry {
305 buffer: Arc<wgpu::Buffer>,
306 allocation_len: u64,
307 usage: wgpu::BufferUsages,
308}
309
310impl BufferPool {
311 #[must_use]
312 pub fn new(device: wgpu::Device, queue: wgpu::Queue, config: &DispatchConfig) -> Self {
314 let max_retained_bytes = config
315 .max_output_bytes
316 .unwrap_or(DEFAULT_MAX_RETAINED_BYTES);
317 let capacity = free_bucket_capacity(max_retained_bytes);
318 let free = std::array::from_fn(|_| std::array::from_fn(|_| ArrayQueue::new(capacity)));
319 Self {
320 inner: Arc::new(PoolInner {
321 device,
322 queue,
323 free,
324 non_empty_classes: AtomicU64::new(0),
325 stats: PoolStats::default(),
326 max_retained_bytes,
327 tiering: None,
328 }),
329 }
330 }
331
332 pub fn with_tiering(
347 device: wgpu::Device,
348 queue: wgpu::Queue,
349 config: &DispatchConfig,
350 tiers: Vec<crate::runtime::cache::CacheTier>,
351 ) -> Result<Self, BackendError> {
352 let mut pool = Self::new(device, queue, config);
353 let tiered = crate::runtime::cache::TieredCache::try_new(tiers)?;
354 let max_retained_bytes = config
355 .max_output_bytes
356 .unwrap_or(DEFAULT_MAX_RETAINED_BYTES);
357 let event_capacity =
358 free_bucket_capacity(max_retained_bytes).max(TIERING_EVENT_CAPACITY_MIN);
359 let tiering = Arc::new(PoolTiering::new(tiered, event_capacity)?);
360 let inner = Arc::get_mut(&mut pool.inner).ok_or_else(|| {
361 BackendError::new(
362 "buffer pool tiering could not get unique pool ownership during construction. Fix: attach tiering before cloning the pool.",
363 )
364 })?;
365 inner.tiering = Some(tiering);
366 Ok(pool)
367 }
368
369 #[must_use]
370 pub fn queue(&self) -> &wgpu::Queue {
372 &self.inner.queue
373 }
374
375 #[must_use]
376 pub fn device(&self) -> &wgpu::Device {
378 &self.inner.device
379 }
380
381 pub fn acquire(
383 &self,
384 len: u64,
385 usage: wgpu::BufferUsages,
386 ) -> Result<GpuBufferHandle, BackendError> {
387 let allocation_len = size_class(len)?;
388 let class_idx = class_index(allocation_len)?;
389 let usage_kind = canonical_usage_kind(usage);
390
391 let mut mask: u64 = if class_idx >= NUM_SIZE_CLASSES {
398 0
399 } else {
400 !((1u64 << class_idx).wrapping_sub(1))
401 };
402 loop {
403 let non_empty = self.inner.non_empty_classes.load(Ordering::Relaxed) & mask;
404 if non_empty == 0 {
405 break;
406 }
407 let idx = u32_to_usize(non_empty.trailing_zeros(), "buffer-pool non-empty class")?;
408 if idx >= NUM_SIZE_CLASSES {
409 break;
410 }
411
412 if let Some(entry) = self.inner.free[idx][usage_kind.index()].pop() {
413 if let Some(tiering) = &self.inner.tiering {
414 let key = buffer_identity_key(Arc::as_ptr(&entry.buffer));
415 tiering.record_access(key);
416 }
417 if !entry.usage.contains(usage) {
421 let correct_kind = canonical_usage_kind(entry.usage);
422 let correct_class = match class_index(entry.allocation_len) {
423 Ok(class) => class,
424 Err(error) => {
425 tracing::warn!(
426 "buffer pool encountered an invalid retained entry while correcting usage metadata: {error}. Dropping the entry."
427 );
428 self.inner.stats.retained_bytes.fetch_sub(
429 u64_to_usize(entry.allocation_len, "retained buffer bytes")
430 .unwrap_or(usize::MAX),
431 Ordering::Relaxed,
432 );
433 self.inner.stats.evictions.fetch_add(1, Ordering::Relaxed);
434 mask &= !(1 << idx);
435 continue;
436 }
437 };
438 match self.inner.free[correct_class][correct_kind.index()].push(entry) {
439 Ok(()) => {
440 if correct_class != idx {
441 self.inner
442 .non_empty_classes
443 .fetch_or(1 << correct_class, Ordering::Relaxed);
444 }
445 }
446 Err(overflow) => {
447 tracing::warn!(
448 "buffer pool class {correct_class} usage bucket {correct_kind:?} is full while correcting a wrong-usage entry; dropping {} retained bytes. Fix: increase max_output_bytes or inspect usage canonicalization drift.",
449 overflow.allocation_len
450 );
451 self.inner.stats.retained_bytes.fetch_sub(
452 u64_to_usize(overflow.allocation_len, "retained buffer bytes")
453 .unwrap_or(usize::MAX),
454 Ordering::Relaxed,
455 );
456 self.inner.stats.evictions.fetch_add(1, Ordering::Relaxed);
457 }
458 }
459 mask &= !(1 << idx);
460 continue;
461 }
462
463 if self.inner.free[idx].iter().all(|q| q.is_empty()) {
464 self.inner
465 .non_empty_classes
466 .fetch_and(!(1 << idx), Ordering::Relaxed);
467 }
468 self.inner.stats.retained_bytes.fetch_sub(
469 u64_to_usize(entry.allocation_len, "retained buffer bytes")
470 .unwrap_or(usize::MAX),
471 Ordering::Relaxed,
472 );
473 let host_len = usize::try_from(len).map_err(|error| {
474 BackendError::new(format!(
475 "GpuBufferPool::acquire received logical byte length {len} that does not fit usize on this host while reusing a pooled buffer: {error}. Fix: shard the GPU buffer before allocating or run on a host with a wide enough address space."
476 ))
477 })?;
478 self.inner.stats.hits.fetch_add(1, Ordering::Relaxed);
479 return Ok(GpuBufferHandle::from_parts(
480 entry.buffer,
481 len,
482 entry.allocation_len,
483 host_len,
484 entry.usage,
485 Some(self.pool_return()),
486 ));
487 }
488
489 if self.inner.free[idx].iter().all(|q| q.is_empty()) {
493 self.inner
494 .non_empty_classes
495 .fetch_and(!(1 << idx), Ordering::Relaxed);
496 }
497 mask &= !(1 << idx);
498 }
499
500 let host_len = usize::try_from(len).map_err(|error| {
501 BackendError::new(format!(
502 "GpuBufferPool::acquire received logical byte length {len} that does not fit usize on this host: {error}. Fix: shard the GPU buffer before allocating or run on a host with a wide enough address space."
503 ))
504 })?;
505 let buffer = self.inner.device.create_buffer(&wgpu::BufferDescriptor {
506 label: Some("vyre persistent pooled buffer"),
507 size: allocation_len,
508 usage,
509 mapped_at_creation: false,
510 });
511 self.inner.stats.allocations.fetch_add(1, Ordering::Relaxed);
512 Ok(GpuBufferHandle::from_parts(
513 Arc::new(buffer),
514 len,
515 allocation_len,
516 host_len,
517 usage,
518 Some(self.pool_return()),
519 ))
520 }
521
522 pub fn release(&self, handle: GpuBufferHandle) {
524 drop(handle);
525 }
526
527 #[must_use]
528 pub fn stats(&self) -> BufferPoolStats {
530 BufferPoolStats {
531 allocations: self.inner.stats.allocations.load(Ordering::Relaxed),
532 hits: self.inner.stats.hits.load(Ordering::Relaxed),
533 releases: self.inner.stats.releases.load(Ordering::Relaxed),
534 evictions: self.inner.stats.evictions.load(Ordering::Relaxed),
535 retained_bytes: self.inner.stats.retained_bytes.load(Ordering::Relaxed),
536 }
537 }
538
539 fn pool_return(&self) -> PoolReturn {
540 PoolReturn {
541 inner: Arc::downgrade(&self.inner),
542 }
543 }
544}
545
546impl PoolReturn {
547 pub(crate) fn release(
548 self,
549 buffer: Arc<wgpu::Buffer>,
550 _byte_len: u64,
551 allocation_len: u64,
552 usage: wgpu::BufferUsages,
553 ) {
554 let Some(inner) = self.inner.upgrade() else {
555 return;
556 };
557 let class_idx = match class_index(allocation_len) {
558 Ok(class) => class,
559 Err(error) => {
560 tracing::warn!(
561 "dropping persistent pooled buffer with invalid allocation size {allocation_len}: {error}. Fix: keep GpuBufferHandle allocation metadata produced by BufferPool::acquire."
562 );
563 inner.stats.evictions.fetch_add(1, Ordering::Relaxed);
564 return;
565 }
566 };
567 let usage_kind = canonical_usage_kind(usage);
568
569 if let Some(tiering) = &inner.tiering {
570 let key = buffer_identity_key(Arc::as_ptr(&buffer));
571 tiering.record_retained(key, allocation_len);
572 }
573
574 if inner.free[class_idx][usage_kind.index()]
575 .push(FreeEntry {
576 buffer,
577 allocation_len,
578 usage,
579 })
580 .is_ok()
581 {
582 inner
583 .non_empty_classes
584 .fetch_or(1 << class_idx, Ordering::Relaxed);
585 inner.stats.releases.fetch_add(1, Ordering::Relaxed);
586 let Ok(allocation_len_usize) = u64_to_usize(allocation_len, "retained buffer bytes")
587 else {
588 inner.stats.evictions.fetch_add(1, Ordering::Relaxed);
589 return;
590 };
591 inner
592 .stats
593 .retained_bytes
594 .fetch_add(allocation_len_usize, Ordering::Relaxed);
595
596 while inner.stats.retained_bytes.load(Ordering::Relaxed) > inner.max_retained_bytes {
597 let mask = inner.non_empty_classes.load(Ordering::Relaxed);
598 if mask == 0 {
599 break;
600 }
601 let leading_zeros = match u32_to_usize(
602 mask.leading_zeros(),
603 "buffer-pool non-empty leading-zero count",
604 ) {
605 Ok(value) => value,
606 Err(error) => {
607 tracing::warn!(
608 "buffer-pool eviction could not convert leading-zero count: {error}. Fix: keep buffer-pool class bitmap width representable by host usize."
609 );
610 inner.non_empty_classes.store(0, Ordering::Relaxed);
611 break;
612 }
613 };
614 let highest_class = if leading_zeros >= NUM_SIZE_CLASSES {
615 inner.non_empty_classes.store(0, Ordering::Relaxed);
616 break;
617 } else {
618 NUM_SIZE_CLASSES - 1 - leading_zeros
619 };
620 let mut evicted = None;
621 for kind in 0..NUM_USAGE_KINDS {
622 if let Some(e) = inner.free[highest_class][kind].pop() {
623 evicted = Some(e);
624 break;
625 }
626 }
627 if let Some(evicted) = evicted {
628 inner.stats.retained_bytes.fetch_sub(
629 u64_to_usize(evicted.allocation_len, "retained buffer bytes")
630 .unwrap_or(usize::MAX),
631 Ordering::Relaxed,
632 );
633 inner.stats.evictions.fetch_add(1, Ordering::Relaxed);
634 if inner.free[highest_class].iter().all(|q| q.is_empty()) {
635 inner
636 .non_empty_classes
637 .fetch_and(!(1 << highest_class), Ordering::Relaxed);
638 }
639 } else {
640 inner
641 .non_empty_classes
642 .fetch_and(!(1 << highest_class), Ordering::Relaxed);
643 }
644 }
645 }
646 }
647}
648
649fn buffer_identity_key(buffer: *const wgpu::Buffer) -> u64 {
650 let mut hasher = rustc_hash::FxHasher::default();
651 buffer.addr().hash(&mut hasher);
652 hasher.finish()
653}
654
655fn u64_to_usize(value: u64, label: &'static str) -> Result<usize, BackendError> {
656 usize::try_from(value).map_err(|source| {
657 BackendError::new(format!(
658 "WGPU buffer-pool {label} value {value} cannot fit usize: {source}. Fix: shard retained-buffer accounting before pooling."
659 ))
660 })
661}
662
663fn u32_to_usize(value: u32, label: &'static str) -> Result<usize, BackendError> {
664 usize::try_from(value).map_err(|source| {
665 BackendError::new(format!(
666 "WGPU buffer-pool {label} value {value} cannot fit usize: {source}. Fix: keep buffer-pool class indices within host index width."
667 ))
668 })
669}
670
671fn size_class(len: u64) -> Result<u64, BackendError> {
672 len.max(4).checked_next_power_of_two().ok_or_else(|| {
673 BackendError::new(format!(
674 "buffer length {len} cannot be rounded to a power-of-two persistent pool size class without overflowing u64. Fix: split the dispatch into smaller buffers."
675 ))
676 })
677}
678
679fn class_index(len: u64) -> Result<usize, BackendError> {
680 let normalized = len.max(4);
681 if !normalized.is_power_of_two() {
682 return Err(BackendError::new(format!(
683 "buffer allocation length {len} is not a power-of-two persistent pool size class. Fix: only release handles produced by BufferPool::acquire."
684 )));
685 }
686 let idx = u32_to_usize(normalized.trailing_zeros(), "buffer size class")?;
687 if idx >= NUM_SIZE_CLASSES {
688 return Err(BackendError::new(format!(
689 "buffer size class index {idx} exceeds the {NUM_SIZE_CLASSES}-class persistent buffer pool. Fix: split the dispatch into smaller buffers."
690 )));
691 }
692 Ok(idx)
693}
694
695fn free_bucket_capacity(max_retained_bytes: usize) -> usize {
696 (max_retained_bytes / 4)
697 .max(1)
698 .min(MAX_FREE_ENTRIES_PER_BUCKET)
699}
700
701#[cfg(test)]
702mod tests {
703 use super::{class_index, free_bucket_capacity, size_class, BufferPool};
704 use proptest::prelude::*;
705
706 #[test]
707 fn retained_byte_budget_is_not_used_as_queue_capacity() {
708 assert_eq!(
709 free_bucket_capacity(1 << 30),
710 1024,
711 "Fix: a 1 GiB byte budget must not allocate 1 GiB queue slots per bucket"
712 );
713 assert_eq!(
714 free_bucket_capacity(8),
715 2,
716 "Fix: tiny retained-byte budgets should still translate to bounded entry capacity"
717 );
718 }
719
720 #[test]
721 fn oversized_size_classes_return_errors_instead_of_panicking() {
722 let error = size_class((1u64 << 63) + 1)
723 .expect_err("oversized buffer length must be rejected before pool indexing");
724 assert!(
725 error
726 .to_string()
727 .contains("power-of-two persistent pool size class"),
728 "unexpected error: {error}"
729 );
730
731 assert_eq!(
732 class_index(0).expect("Fix: minimum size class should fit"),
733 2
734 );
735 let error =
736 class_index(u64::MAX).expect_err("invalid retained allocation length must be rejected");
737 assert!(
738 error.to_string().contains("not a power-of-two"),
739 "unexpected error: {error}"
740 );
741 }
742
743 #[test]
744 fn production_pool_class_selection_has_no_narrowing_casts() {
745 let src =
746 std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/buffer/pool.rs"))
747 .expect("Fix: buffer pool source must be readable");
748 let production = src
749 .split("\n#[cfg(test)]\nmod tests")
750 .next()
751 .expect("Fix: meta-test scans production sources; update fixture path if module moved - production section must exist");
752 assert!(
753 !production.contains(" as usize"),
754 "buffer-pool release/acquire class selection must use checked conversion helpers"
755 );
756 assert!(production.contains("u32_to_usize("));
757 assert!(production.contains("mask.leading_zeros()"));
758 }
759
760 #[test]
761 fn acquire_release_reuses_power_of_two_classes() {
762 let arc = crate::runtime::cached_device()
763 .expect("Fix: GPU device is required for persistent buffer pool test");
764 let (device, queue) = &*arc;
765 let config = vyre_driver::DispatchConfig::default();
766 let pool = BufferPool::new(device.clone(), queue.clone(), &config);
767 for len in 1..=1000 {
768 let handle = pool
769 .acquire(
770 len,
771 wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
772 )
773 .expect("Fix: pooled allocation should succeed");
774 pool.release(handle);
775 }
776 assert!(
777 pool.stats().allocations <= 16,
778 "Fix: pool should allocate by power-of-two classes, stats={:?}",
779 pool.stats()
780 );
781 }
782
783 #[test]
784 fn pooled_reuse_updates_logical_element_count() {
785 let arc = crate::runtime::cached_device()
786 .expect("Fix: GPU device is required for persistent buffer pool test");
787 let (device, queue) = &*arc;
788 let config = vyre_driver::DispatchConfig::default();
789 let pool = BufferPool::new(device.clone(), queue.clone(), &config);
790 let usage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST;
791
792 let large = pool
793 .acquire(64, usage)
794 .expect("Fix: initial pooled allocation should succeed");
795 assert_eq!(large.element_count(), 64);
796 pool.release(large);
797
798 let small = pool
799 .acquire(7, usage)
800 .expect("Fix: pooled reuse should succeed");
801 assert_eq!(
802 small.element_count(),
803 7,
804 "Fix: reusing a larger allocation must not leak the previous logical element count"
805 );
806 assert_eq!(small.byte_len(), 7);
807 }
808
809 #[test]
810 fn tiering_acquire_release_is_nonblocking_under_contention() {
811 let arc = crate::runtime::cached_device()
812 .expect("Fix: GPU device is required for persistent buffer pool test");
813 let (device, queue) = &*arc;
814 let config = vyre_driver::DispatchConfig::default();
815 let pool = BufferPool::with_tiering(
816 device.clone(),
817 queue.clone(),
818 &config,
819 vec![crate::runtime::cache::CacheTier::new("hot", 1 << 20)],
820 )
821 .expect("Fix: tiered buffer pool construction should succeed");
822 let handle = pool
823 .acquire(
824 64,
825 wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
826 )
827 .expect("Fix: acquire before poisoning should succeed");
828 let tiering = pool
829 .inner
830 .tiering
831 .as_ref()
832 .expect("Fix: with_tiering must attach a tiering policy")
833 .clone();
834 pool.release(handle);
835 let mut workers = Vec::new();
836 for _ in 0..4 {
837 let pool = pool.clone();
838 workers.push(std::thread::spawn(move || {
839 for _ in 0..32 {
840 let handle = pool
841 .acquire(
842 64,
843 wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
844 )
845 .expect("Fix: pooled allocation should not fail under tiering contention");
846 pool.release(handle);
847 }
848 }));
849 }
850 for worker in workers {
851 worker
852 .join()
853 .expect("Fix: buffer-pool contention worker must not panic");
854 }
855 tiering.drain_all_for_test();
856 assert_eq!(
857 tiering.dropped_events_for_test(),
858 0,
859 "Fix: normal contention must not drop tiering metadata events"
860 );
861 }
862
863 proptest! {
864 #![proptest_config(ProptestConfig::with_cases(64))]
865
866 #[test]
867 fn alternating_usage_hit_rate(
868 sizes in prop::collection::vec(1u64..=65536, 20..=200),
869 ) {
870 let arc = crate::runtime::cached_device()
871 .expect("Fix: GPU device is required for persistent buffer pool test");
872 let (device, queue) = &*arc;
873 let config = vyre_driver::DispatchConfig::default();
874 let pool = BufferPool::new(device.clone(), queue.clone(), &config);
875
876 let usage_a = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST;
877 let usage_b = wgpu::BufferUsages::STORAGE
878 | wgpu::BufferUsages::COPY_SRC
879 | wgpu::BufferUsages::COPY_DST
880 | wgpu::BufferUsages::INDIRECT;
881
882 let mut handles = Vec::with_capacity(sizes.len());
884 for (i, &len) in sizes.iter().enumerate() {
885 let usage = if i % 2 == 0 { usage_a } else { usage_b };
886 handles.push(pool.acquire(len, usage).unwrap());
887 }
888 for h in handles {
889 pool.release(h);
890 }
891
892 let stats_after_first = pool.stats();
893 prop_assert_eq!(
894 stats_after_first.hits, 0,
895 "first round should be 100% fresh allocations"
896 );
897
898 let mut handles = Vec::with_capacity(sizes.len());
900 for (i, &len) in sizes.iter().enumerate() {
901 let usage = if i % 2 == 0 { usage_a } else { usage_b };
902 handles.push(pool.acquire(len, usage).unwrap());
903 }
904 for h in handles {
905 pool.release(h);
906 }
907
908 let stats_after_second = pool.stats();
909 let second_round_hits = stats_after_second.hits - stats_after_first.hits;
910 let total = sizes.len();
911 let hit_rate = second_round_hits as f64 / total as f64;
912 prop_assert!(
913 hit_rate >= 0.95,
914 "second round hit rate should be >= 95%, got {:.2}% ({}/{}), stats={:?}",
915 hit_rate * 100.0,
916 second_round_hits,
917 total,
918 stats_after_second
919 );
920 }
921 }
922}