Skip to main content

vyre_driver_wgpu/buffer/
pool.rs

1//! Power-of-two GPU buffer pool for persistent dispatch.
2
3use 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/// Snapshot of [`BufferPool`] counters.
55#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub struct BufferPoolStats {
57    /// docs
58    pub allocations: usize,
59    /// docs
60    pub hits: usize,
61    /// docs
62    pub releases: usize,
63    /// docs
64    pub evictions: usize,
65    /// docs
66    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/// Reusable GPU buffer pool.
96#[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/// Canonical usage masks used to key each size-class sub-bucket.
114///
115/// Reduces the full `wgpu::BufferUsages` bitfield to a small enum so
116/// that alternating workloads (e.g. INPUT vs OUTPUT) no longer
117/// collide in the same queue and fall through to fresh allocation.
118#[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
160/// Opt-in hot/cold tiered metadata layered over the power-of-two pool.
161///
162/// Off by default. Consumers that batch many small dispatches (inference
163/// servers, Karyx streaming scanners, Soleno batched probes) wire one
164/// via [`BufferPool::with_tiering`] and tag hot allocations through the
165/// returned handle. The tiering layer records allocation reuse through
166/// a bounded non-blocking event queue and drains it into `TieredCache`
167/// on a dedicated metadata worker. This keeps acquire/release free of
168/// a global mutex while preserving the cache policy's per-tier O(1)
169/// LRU accounting.
170///
171/// Kept as `pub(crate) Option<Arc<...>>` so the absence of a tiering
172/// policy costs exactly one `Option::is_none()` branch on the hot
173/// acquire path.
174pub(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        // The metadata worker is woken via crossbeam channel; under
235        // contention from multiple acquire/release threads it can
236        // accumulate a backlog before the OS schedules it. Use bounded
237        // adaptive parking rather than a fixed millisecond sleep; fixed
238        // sleeps create thundering-herd latency under high test fanout.
239        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    /// Optional tiered cache. `None` = power-of-two pool only.
301    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    /// docs
313    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    /// Opt-in hot/cold tiered caching on top of the power-of-two pool.
333    ///
334    /// Returns a new `BufferPool` that shares the underlying device
335    /// and queue but wraps every acquire/release in a `TieredCache`
336    /// governed by the supplied tiers + policy. Consumers that batch
337    /// many small dispatches (inference servers, streaming scanners,
338    /// batched probes) use this to keep hot allocations resident and
339    /// demote/evict cold ones via per-tier O(1) LRU.
340    ///
341    /// The `tiers` vector is ordered coldest-first; `policy` controls
342    /// promotion/eviction. Defaults at
343    /// `TieredCache::try_new(vec![CacheTier::try_new("hot", 1 << 24)?,
344    /// CacheTier::try_new("cold", 1 << 30)?])` are a reasonable starting
345    /// point for 16 MiB hot / 1 GiB cold.
346    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    /// docs
371    pub fn queue(&self) -> &wgpu::Queue {
372        &self.inner.queue
373    }
374
375    #[must_use]
376    /// docs
377    pub fn device(&self) -> &wgpu::Device {
378        &self.inner.device
379    }
380
381    /// docs
382    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        // O(1) free-class search via trailing_zeros on the masked
392        // non_empty_classes bitmap.  Within each size class we probe
393        // only the sub-bucket that matches the canonical usage mask;
394        // if that sub-bucket is empty we mask the class out and keep
395        // scanning larger classes.  This eliminates the old "pop wrong
396        // usage, push back, fall through to fresh alloc" path.
397        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                // Defensive: if the stored usage doesn't cover the request,
418                // route it to its correct canonical sub-bucket rather than
419                // leaving it stranded in the wrong queue (POOL-1 point 4).
420                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            // Sub-bucket empty (lost race or genuinely empty).  Clear the
490            // class bit only when *every* sub-bucket is empty so that other
491            // usage kinds are not disturbed.
492            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    /// docs
523    pub fn release(&self, handle: GpuBufferHandle) {
524        drop(handle);
525    }
526
527    #[must_use]
528    /// docs
529    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            // Round 1: acquire alternating usages, then release everything.
883            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            // Round 2: identical pattern.
899            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}