1use std::cmp::{Ordering as CmpOrdering, Reverse};
4use std::collections::BinaryHeap;
5use std::hash::{Hash, Hasher};
6use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
7use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak};
8use std::time::Instant;
9
10use dashmap::DashMap;
11use rustc_hash::{FxHashMap, FxHasher};
12use smallvec::SmallVec;
13use vyre_driver::BackendError;
14
15use super::pool::PoolReturn;
16
17static NEXT_BUFFER_ID: AtomicU64 = AtomicU64::new(1);
18static RESIDENT_BUFFERS: OnceLock<DashMap<u64, Weak<GpuBufferInner>>> = OnceLock::new();
19const STAGING_BUFFER_POOL_CLASS_CAP: usize = 16;
20
21fn resident_buffers() -> &'static DashMap<u64, Weak<GpuBufferInner>> {
22 RESIDENT_BUFFERS.get_or_init(DashMap::new)
23}
24
25fn pointer_identity_key<T>(ptr: *const T) -> u64 {
26 let mut hasher = FxHasher::default();
27 ptr.addr().hash(&mut hasher);
28 hasher.finish()
29}
30
31#[derive(Clone)]
37pub struct GpuBufferHandle {
38 inner: Arc<GpuBufferInner>,
39}
40
41struct GpuBufferInner {
42 id: u64,
43 buffer: Arc<wgpu::Buffer>,
44 byte_len: u64,
45 allocation_len: u64,
46 element_count: usize,
47 usage: wgpu::BufferUsages,
48 pool_return: Option<PoolReturn>,
49}
50
51#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
53pub struct StagingBufferPoolStats {
54 pub allocations: usize,
56 pub hits: usize,
58}
59
60#[derive(Clone, Default)]
68pub struct StagingBufferPool {
69 inner: Arc<Mutex<StagingBufferPoolInner>>,
70}
71
72impl std::fmt::Debug for StagingBufferPool {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("StagingBufferPool").finish_non_exhaustive()
75 }
76}
77
78#[derive(Default)]
79struct StagingBufferPoolInner {
80 free: FxHashMap<(u64, u32), SmallVec<[wgpu::Buffer; STAGING_BUFFER_POOL_CLASS_CAP]>>,
81 allocations: usize,
82 hits: usize,
83}
84
85impl StagingBufferPool {
86 fn lock_inner(&self) -> MutexGuard<'_, StagingBufferPoolInner> {
87 self.inner.lock().unwrap_or_else(|error| {
88 tracing::error!(
89 "Vyre WGPU staging buffer pool lock was poisoned: {error}. Fix: discard the pool after a panic; continuing with recovered state."
90 );
91 error.into_inner()
92 })
93 }
94
95 #[must_use]
97 #[inline]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 #[must_use]
104 pub fn stats(&self) -> StagingBufferPoolStats {
105 let inner = self.lock_inner();
106 StagingBufferPoolStats {
107 allocations: inner.allocations,
108 hits: inner.hits,
109 }
110 }
111
112 pub fn acquire(
117 &self,
118 device: &wgpu::Device,
119 size: u64,
120 usage: wgpu::BufferUsages,
121 ) -> wgpu::Buffer {
122 let key = (size, usage.bits());
123 let mut inner = self.lock_inner();
124 if let Some(buffers) = inner.free.get_mut(&key) {
125 if let Some(buffer) = buffers.pop() {
126 inner.hits += 1;
127 return buffer;
128 }
129 }
130 inner.allocations += 1;
131 drop(inner);
132 device.create_buffer(&wgpu::BufferDescriptor {
133 label: Some("vyre staging readback"),
134 size,
135 usage,
136 mapped_at_creation: false,
137 })
138 }
139
140 pub fn release(&self, buffer: wgpu::Buffer, size: u64, usage: wgpu::BufferUsages) {
145 let key = (size, usage.bits());
146 let mut inner = self.lock_inner();
147 let buffers = inner.free.entry(key).or_insert_with(SmallVec::new);
148 if buffers.len() == STAGING_BUFFER_POOL_CLASS_CAP {
149 buffers.remove(0);
150 }
151 buffers.push(buffer);
152 }
153}
154
155impl GpuBufferHandle {
156 pub fn upload(
165 device: &wgpu::Device,
166 queue: &wgpu::Queue,
167 bytes: &[u8],
168 usage: wgpu::BufferUsages,
169 ) -> Result<Self, BackendError> {
170 let allocation_len = aligned_len(bytes.len())?;
171 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
172 label: Some("vyre persistent upload"),
173 size: allocation_len,
174 usage: usage | wgpu::BufferUsages::COPY_DST,
175 mapped_at_creation: false,
176 });
177 write_padded(queue, &buffer, bytes, allocation_len)?;
178 let logical_len = u64::try_from(bytes.len()).map_err(|source| {
179 BackendError::new(format!(
180 "GPU upload logical byte length cannot fit u64: {source}. Fix: split the dispatch input."
181 ))
182 })?;
183 Ok(Self::from_parts(
184 Arc::new(buffer),
185 logical_len,
186 allocation_len,
187 bytes.len(),
188 usage | wgpu::BufferUsages::COPY_DST,
189 None,
190 ))
191 }
192
193 pub fn alloc(
200 device: &wgpu::Device,
201 len: u64,
202 usage: wgpu::BufferUsages,
203 ) -> Result<Self, BackendError> {
204 let allocation_len = aligned_len_u64(len, "persistent GPU allocation length")?;
205 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
206 label: Some("vyre persistent alloc"),
207 size: allocation_len,
208 usage,
209 mapped_at_creation: false,
210 });
211 let host_len = usize::try_from(len).map_err(|error| {
212 BackendError::new(format!(
213 "GpuBufferHandle::alloc 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."
214 ))
215 })?;
216 Ok(Self::from_parts(
217 Arc::new(buffer),
218 len,
219 allocation_len,
220 host_len,
221 usage,
222 None,
223 ))
224 }
225
226 pub fn readback(
236 &self,
237 device: &wgpu::Device,
238 queue: &wgpu::Queue,
239 out: &mut Vec<u8>,
240 ) -> Result<(), BackendError> {
241 self.readback_until(device, None, queue, out, None)
242 }
243
244 pub fn readback_prefix(
256 &self,
257 device: &wgpu::Device,
258 queue: &wgpu::Queue,
259 len: u64,
260 out: &mut Vec<u8>,
261 ) -> Result<(), BackendError> {
262 self.readback_prefix_until(device, None, queue, len, out, None)
263 }
264
265 pub fn readback_range(
275 &self,
276 device: &wgpu::Device,
277 queue: &wgpu::Queue,
278 byte_offset: u64,
279 len: u64,
280 out: &mut Vec<u8>,
281 ) -> Result<(), BackendError> {
282 self.readback_range_until(device, None, queue, byte_offset, len, out, None)
283 }
284
285 pub(crate) fn readback_until(
286 &self,
287 device: &wgpu::Device,
288 pool: Option<&StagingBufferPool>,
289 queue: &wgpu::Queue,
290 out: &mut Vec<u8>,
291 deadline: Option<Instant>,
292 ) -> Result<(), BackendError> {
293 self.readback_prefix_until(device, pool, queue, self.byte_len(), out, deadline)
294 }
295
296 pub(crate) fn readback_prefix_until(
297 &self,
298 device: &wgpu::Device,
299 pool: Option<&StagingBufferPool>,
300 queue: &wgpu::Queue,
301 len: u64,
302 out: &mut Vec<u8>,
303 deadline: Option<Instant>,
304 ) -> Result<(), BackendError> {
305 self.readback_range_until(device, pool, queue, 0, len, out, deadline)
306 }
307
308 pub(crate) fn readback_range_until(
309 &self,
310 device: &wgpu::Device,
311 pool: Option<&StagingBufferPool>,
312 queue: &wgpu::Queue,
313 byte_offset: u64,
314 len: u64,
315 out: &mut Vec<u8>,
316 deadline: Option<Instant>,
317 ) -> Result<(), BackendError> {
318 if !self.usage().contains(wgpu::BufferUsages::COPY_SRC) {
319 return Err(BackendError::new(
320 "GpuBufferHandle readback requires COPY_SRC usage. Fix: allocate terminal-output buffers with COPY_SRC.",
321 ));
322 }
323 let logical_end = byte_offset.checked_add(len).ok_or_else(|| {
324 BackendError::new(format!(
325 "GpuBufferHandle range readback overflows u64 at offset {byte_offset} len {len}. Fix: split the readback range before dispatch."
326 ))
327 })?;
328 if logical_end > self.byte_len() {
329 return Err(BackendError::new(format!(
330 "GpuBufferHandle range readback requested bytes [{byte_offset}..{logical_end}) from a {} byte buffer. Fix: clamp the requested range to the device-published count.",
331 self.byte_len()
332 )));
333 }
334 if len == 0 {
335 out.clear();
336 return Ok(());
337 }
338 let copy_start = byte_offset & !3;
339 let trim_start = byte_offset - copy_start;
340 let visible_copy_len = trim_start.checked_add(len).ok_or_else(|| {
341 BackendError::new(format!(
342 "GpuBufferHandle range readback copy length overflows u64 at trim {trim_start} len {len}. Fix: split the readback range before dispatch."
343 ))
344 })?;
345 let read_len = aligned_len_u64(visible_copy_len, "GPU readback visible copy length")?;
346 let copy_end = copy_start.checked_add(read_len).ok_or_else(|| {
347 BackendError::new(format!(
348 "GpuBufferHandle range readback aligned copy overflows u64 at start {copy_start} len {read_len}. Fix: split the readback range before dispatch."
349 ))
350 })?;
351 if copy_end > self.inner.allocation_len {
352 return Err(BackendError::new(format!(
353 "GpuBufferHandle range readback rounded bytes [{byte_offset}..{logical_end}) to aligned bytes [{copy_start}..{copy_end}), beyond allocation length {}. Fix: allocate buffers with 4-byte padding.",
354 self.inner.allocation_len
355 )));
356 }
357 let readback_usage = wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ;
358 let readback = if let Some(pool) = pool {
359 pool.acquire(device, read_len, readback_usage)
360 } else {
361 device.create_buffer(&wgpu::BufferDescriptor {
362 label: Some("vyre persistent handle readback"),
363 size: read_len,
364 usage: readback_usage,
365 mapped_at_creation: false,
366 })
367 };
368 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
369 label: Some("vyre persistent handle readback encoder"),
370 });
371 encoder.copy_buffer_to_buffer(self.buffer(), copy_start, &readback, 0, read_len);
372 let submission = queue.submit(std::iter::once(encoder.finish()));
373 let slice = readback.slice(0..read_len);
374 let (sender, receiver) = std::sync::mpsc::channel();
375 slice.map_async(wgpu::MapMode::Read, move |result| {
376 if let Err(error) = sender.send(result) {
377 tracing::error!(
378 ?error,
379 "persistent buffer readback map_async result was lost because the receiver dropped"
380 );
381 }
382 });
383 let mapping = if let Some(deadline) = deadline {
384 let mut backoff = crate::wait_backoff::AdaptiveWaitBackoff::from_micros(64, 2, 50, 5);
385 loop {
386 crate::runtime::device::poll_device_once(device)?;
387 match receiver.try_recv() {
388 Ok(result) => break result,
389 Err(std::sync::mpsc::TryRecvError::Empty) => {}
390 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
391 return Err(BackendError::new(
392 "persistent buffer readback channel closed before completion. Fix: keep the GPU device alive until readback completes.",
393 ));
394 }
395 }
396 let now = Instant::now();
397 if now >= deadline {
398 return Err(BackendError::new(
399 "dispatch cancelled after DispatchConfig.timeout before readback completed. Fix: raise DispatchConfig.timeout or split the program into smaller chunks.",
400 ));
401 }
402 backoff.idle_for(deadline.saturating_duration_since(now));
403 }
404 } else {
405 crate::runtime::device::poll_device_wait_for(device, submission)?;
406 receiver
407 .recv_timeout(std::time::Duration::from_secs(30))
408 .map_err(|source| {
409 BackendError::new(format!(
410 "persistent buffer readback callback did not complete after submission wait: {source}. Fix: keep the GPU device alive and inspect driver callback progress."
411 ))
412 })?
413 };
414 let result = mapping.map_err(|source| {
415 BackendError::new(format!(
416 "persistent buffer readback mapping failed: {source:?}. Fix: use COPY_SRC handles and MAP_READ staging buffers."
417 ))
418 });
419 result?;
420 let mapped = slice.get_mapped_range();
421 let visible_len = usize::try_from(len).map_err(|source| {
422 BackendError::new(format!(
423 "persistent buffer prefix length {len} cannot fit usize: {source}. Fix: split the buffer before readback.",
424 ))
425 })?;
426 let trim_start = usize::try_from(trim_start).map_err(|source| {
427 BackendError::new(format!(
428 "persistent buffer range trim offset {trim_start} cannot fit usize: {source}. Fix: split the buffer before readback.",
429 ))
430 })?;
431 let trim_end = trim_start.checked_add(visible_len).ok_or_else(|| {
432 BackendError::new(format!(
433 "persistent buffer range trim overflows usize at offset {trim_start} len {visible_len}. Fix: split the buffer before readback."
434 ))
435 })?;
436 let visible = &mapped[trim_start..trim_end];
437 if out.len() == visible_len {
438 out.copy_from_slice(visible);
439 } else {
440 if visible_len > out.capacity() {
441 let additional = visible_len - out.capacity();
442 out.try_reserve_exact(additional).map_err(|source| {
443 BackendError::new(format!(
444 "persistent buffer readback could not reserve {visible_len} output bytes exactly: {source}. Fix: lower max_output_bytes or stream readback in smaller shards."
445 ))
446 })?;
447 }
448 out.clear();
449 out.extend_from_slice(visible);
450 }
451 drop(mapped);
452 readback.unmap();
453 if let Some(pool) = pool {
454 pool.release(readback, read_len, readback_usage);
455 }
456 Ok(())
457 }
458
459 #[must_use]
461 pub fn id(&self) -> u64 {
462 self.inner.id
463 }
464
465 #[must_use]
472 pub(crate) fn allocation_identity(&self) -> u64 {
473 pointer_identity_key(Arc::as_ptr(&self.inner.buffer))
474 }
475
476 #[must_use]
478 pub fn from_resident_id(id: u64) -> Option<Self> {
479 let registry = resident_buffers();
480 let entry = registry.get(&id)?;
481 let upgraded = entry.value().upgrade();
482 drop(entry);
483 match upgraded {
484 Some(inner) => Some(Self { inner }),
485 None => {
486 registry.remove(&id);
487 None
488 }
489 }
490 }
491
492 #[must_use]
494 pub fn buffer(&self) -> &wgpu::Buffer {
495 &self.inner.buffer
496 }
497
498 #[must_use]
502 pub fn buffer_arc(&self) -> Arc<wgpu::Buffer> {
503 Arc::clone(&self.inner.buffer)
504 }
505
506 #[must_use]
508 pub fn byte_len(&self) -> u64 {
509 self.inner.byte_len
510 }
511
512 #[must_use]
514 pub fn allocation_len(&self) -> u64 {
515 self.inner.allocation_len
516 }
517
518 #[must_use]
520 pub fn element_count(&self) -> usize {
521 self.inner.element_count
522 }
523
524 #[must_use]
526 pub fn usage(&self) -> wgpu::BufferUsages {
527 self.inner.usage
528 }
529
530 pub(crate) fn from_parts(
531 buffer: Arc<wgpu::Buffer>,
532 byte_len: u64,
533 allocation_len: u64,
534 element_count: usize,
535 usage: wgpu::BufferUsages,
536 pool_return: Option<PoolReturn>,
537 ) -> Self {
538 let inner = Arc::new(GpuBufferInner {
539 id: NEXT_BUFFER_ID.fetch_add(1, Ordering::Relaxed),
540 buffer,
541 byte_len,
542 allocation_len,
543 element_count,
544 usage,
545 pool_return,
546 });
547 resident_buffers().insert(inner.id, Arc::downgrade(&inner));
548 Self { inner }
549 }
550}
551
552impl std::fmt::Debug for GpuBufferHandle {
553 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554 formatter
555 .debug_struct("GpuBufferHandle")
556 .field("id", &self.id())
557 .field("byte_len", &self.byte_len())
558 .field("allocation_len", &self.allocation_len())
559 .field("element_count", &self.element_count())
560 .field("usage", &self.usage())
561 .finish()
562 }
563}
564
565impl Drop for GpuBufferInner {
566 fn drop(&mut self) {
567 resident_buffers().remove(&self.id);
568 if let Some(pool_return) = self.pool_return.take() {
569 pool_return.release(
570 Arc::clone(&self.buffer),
571 self.byte_len,
572 self.allocation_len,
573 self.usage,
574 );
575 }
576 }
577}
578
579pub(crate) fn aligned_len(len: usize) -> Result<u64, BackendError> {
580 let padded = aligned_len_usize(len, "GPU buffer length")?;
581 u64::try_from(padded).map_err(|source| {
582 BackendError::new(format!(
583 "GPU buffer length {padded} cannot fit u64: {source}. Fix: split the dispatch input."
584 ))
585 })
586}
587
588fn aligned_len_u64(len: u64, label: &'static str) -> Result<u64, BackendError> {
589 crate::numeric::align_up_u64(len, 4, label)
590}
591
592fn aligned_len_usize(len: usize, label: &'static str) -> Result<usize, BackendError> {
593 crate::numeric::align_up_usize(len, 4, label)
594}
595
596pub(crate) fn write_padded(
597 queue: &wgpu::Queue,
598 buffer: &wgpu::Buffer,
599 bytes: &[u8],
600 allocation_len: u64,
601) -> Result<(), BackendError> {
602 crate::padded_upload::write_padded_and_zero_fill(queue, buffer, bytes, allocation_len)
603}
604
605const BIND_GROUP_CACHE_CAP: usize = 256;
607
608type BindGroupHandleKey = SmallVec<[u64; 16]>;
611
612#[derive(Clone)]
620pub struct BindGroupCache {
621 cache: Arc<Mutex<BindGroupCacheInner>>,
622 hits: Arc<AtomicUsize>,
623 misses: Arc<AtomicUsize>,
624 evictions: Arc<AtomicUsize>,
625}
626
627struct BindGroupCacheInner {
628 entries: FxHashMap<BindGroupCacheKey, BindGroupCacheEntry>,
629 lru: BinaryHeap<Reverse<BindGroupLruEntry>>,
630 cap: usize,
631 next_generation: u64,
632}
633
634struct BindGroupCacheEntry {
635 bind_group: Arc<wgpu::BindGroup>,
636 last_seen: u64,
637}
638
639#[derive(Clone, Debug, Eq, PartialEq)]
640struct BindGroupLruEntry {
641 last_seen: u64,
642 key: BindGroupCacheKey,
643}
644
645impl Ord for BindGroupLruEntry {
646 fn cmp(&self, other: &Self) -> CmpOrdering {
647 self.last_seen
648 .cmp(&other.last_seen)
649 .then_with(|| self.key.cmp(&other.key))
650 }
651}
652
653impl PartialOrd for BindGroupLruEntry {
654 fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
655 Some(self.cmp(other))
656 }
657}
658
659fn push_bind_group_handle_key(key: &mut BindGroupHandleKey, handle: &GpuBufferHandle) -> bool {
660 key.push(handle.allocation_identity());
661 let Ok(aligned_len) = aligned_len_u64(handle.byte_len(), "bind-group handle key byte length")
662 else {
663 key.pop();
664 return false;
665 };
666 key.push(aligned_len);
667 true
668}
669
670#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
671struct BindGroupCacheKey {
672 layout_id: usize,
673 handles: BindGroupHandleKey,
674}
675
676impl std::fmt::Debug for BindGroupCache {
677 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
678 f.debug_struct("BindGroupCache")
679 .field("hits", &self.hits.load(Ordering::Relaxed))
680 .field("misses", &self.misses.load(Ordering::Relaxed))
681 .field("evictions", &self.evictions.load(Ordering::Relaxed))
682 .field("entries", &self.lock_cache().entries.len())
683 .finish_non_exhaustive()
684 }
685}
686
687impl Default for BindGroupCache {
688 fn default() -> Self {
689 Self::new()
690 }
691}
692
693impl BindGroupCacheInner {
694 fn next_lru_generation(&mut self) -> u64 {
695 let generation = self.next_generation;
696 self.next_generation = self.next_generation.wrapping_add(1);
697 generation
698 }
699
700 fn touch_existing(&mut self, key: &BindGroupCacheKey) -> Option<Arc<wgpu::BindGroup>> {
701 let generation = self.next_lru_generation();
702 let bind_group = {
703 let entry = self.entries.get_mut(key)?;
704 entry.last_seen = generation;
705 Arc::clone(&entry.bind_group)
706 };
707 self.lru.push(Reverse(BindGroupLruEntry {
708 last_seen: generation,
709 key: key.clone(),
710 }));
711 self.compact_lru_if_needed();
712 Some(bind_group)
713 }
714
715 fn insert_entry(&mut self, key: BindGroupCacheKey, bind_group: Arc<wgpu::BindGroup>) {
716 let generation = self.next_lru_generation();
717 self.entries.insert(
718 key.clone(),
719 BindGroupCacheEntry {
720 bind_group,
721 last_seen: generation,
722 },
723 );
724 self.lru.push(Reverse(BindGroupLruEntry {
725 last_seen: generation,
726 key,
727 }));
728 self.compact_lru_if_needed();
729 }
730
731 fn evict_to_cap(&mut self, mut on_evict: impl FnMut()) {
732 while self.entries.len() > self.cap {
733 let Some(key) = self.pop_lru_key() else { break };
734 if self.entries.remove(&key).is_some() {
735 on_evict();
736 }
737 }
738 }
739
740 fn pop_lru_key(&mut self) -> Option<BindGroupCacheKey> {
741 while let Some(Reverse(entry)) = self.lru.pop() {
742 if self
743 .entries
744 .get(&entry.key)
745 .is_some_and(|current| current.last_seen == entry.last_seen)
746 {
747 return Some(entry.key);
748 }
749 }
750 None
751 }
752
753 fn compact_lru_if_needed(&mut self) {
754 let live = self.entries.len();
755 if let Some(limit) = stale_lru_limit(live) {
756 if self.lru.len() <= limit {
757 return;
758 }
759 }
760 self.lru.clear();
761 self.lru.extend(self.entries.iter().map(|(key, entry)| {
762 Reverse(BindGroupLruEntry {
763 last_seen: entry.last_seen,
764 key: key.clone(),
765 })
766 }));
767 }
768}
769
770fn stale_lru_limit(live: usize) -> Option<usize> {
771 live.checked_mul(4).map(|limit| limit.max(8))
772}
773
774impl BindGroupCache {
775 fn lock_cache(&self) -> MutexGuard<'_, BindGroupCacheInner> {
776 self.cache.lock().unwrap_or_else(|error| {
777 tracing::error!(
778 "Vyre WGPU bind-group cache lock was poisoned: {error}. Fix: discard the cache after a panic; continuing with recovered state."
779 );
780 error.into_inner()
781 })
782 }
783
784 #[must_use]
786 pub fn new() -> Self {
787 Self::with_cap(BIND_GROUP_CACHE_CAP)
788 }
789
790 #[must_use]
793 pub fn with_cap(cap: usize) -> Self {
794 Self {
795 cache: Arc::new(Mutex::new(BindGroupCacheInner {
796 entries: FxHashMap::default(),
797 lru: BinaryHeap::new(),
798 cap: cap.max(1),
799 next_generation: 0,
800 })),
801 hits: Arc::new(AtomicUsize::new(0)),
802 misses: Arc::new(AtomicUsize::new(0)),
803 evictions: Arc::new(AtomicUsize::new(0)),
804 }
805 }
806
807 pub fn get_or_create(
815 &self,
816 layout_id: usize,
817 handles: &[GpuBufferHandle],
818 factory: impl FnOnce() -> wgpu::BindGroup,
819 ) -> Arc<wgpu::BindGroup> {
820 let Some(key_part_count) = handles.len().checked_mul(2) else {
821 self.misses.fetch_add(1, Ordering::Relaxed);
822 return Arc::new(factory());
823 };
824 let mut key_parts = SmallVec::with_capacity(key_part_count);
825 for handle in handles {
826 if !push_bind_group_handle_key(&mut key_parts, handle) {
827 self.misses.fetch_add(1, Ordering::Relaxed);
828 return Arc::new(factory());
829 }
830 }
831 self.get_or_create_by_ids(layout_id, key_parts, factory)
832 }
833
834 pub(crate) fn get_or_create_by_ids(
835 &self,
836 layout_id: usize,
837 handles: SmallVec<[u64; 16]>,
838 factory: impl FnOnce() -> wgpu::BindGroup,
839 ) -> Arc<wgpu::BindGroup> {
840 let key = BindGroupCacheKey { layout_id, handles };
841 {
842 let mut cache = self.lock_cache();
843 if let Some(existing) = cache.touch_existing(&key) {
844 self.hits.fetch_add(1, Ordering::Relaxed);
845 return existing;
846 }
847 }
848 let bg = Arc::new(factory());
849 let mut cache = self.lock_cache();
850 cache.insert_entry(key, Arc::clone(&bg));
851 cache.evict_to_cap(|| {
852 self.evictions.fetch_add(1, Ordering::Relaxed);
853 });
854 self.misses.fetch_add(1, Ordering::Relaxed);
855 bg
856 }
857
858 pub(crate) fn get_by_ids(
859 &self,
860 layout_id: usize,
861 handles: &[u64],
862 ) -> Option<Arc<wgpu::BindGroup>> {
863 let key = BindGroupCacheKey {
864 layout_id,
865 handles: SmallVec::from_slice(handles),
866 };
867 let mut cache = self.lock_cache();
868 let existing = cache.touch_existing(&key)?;
869 self.hits.fetch_add(1, Ordering::Relaxed);
870 Some(existing)
871 }
872
873 pub(crate) fn insert_by_ids(
874 &self,
875 layout_id: usize,
876 handles: &[u64],
877 bind_group: wgpu::BindGroup,
878 ) -> Arc<wgpu::BindGroup> {
879 let key = BindGroupCacheKey {
880 layout_id,
881 handles: SmallVec::from_slice(handles),
882 };
883 let mut cache = self.lock_cache();
884 if let Some(existing) = cache.touch_existing(&key) {
885 self.hits.fetch_add(1, Ordering::Relaxed);
886 return existing;
887 }
888 let bg = Arc::new(bind_group);
889 cache.insert_entry(key, Arc::clone(&bg));
890 cache.evict_to_cap(|| {
891 self.evictions.fetch_add(1, Ordering::Relaxed);
892 });
893 self.misses.fetch_add(1, Ordering::Relaxed);
894 bg
895 }
896
897 #[must_use]
899 pub fn stats(&self) -> BindGroupCacheStats {
900 BindGroupCacheStats {
901 hits: self.hits.load(Ordering::Relaxed),
902 misses: self.misses.load(Ordering::Relaxed),
903 evictions: self.evictions.load(Ordering::Relaxed),
904 entries: self.lock_cache().entries.len(),
905 }
906 }
907}
908
909#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
911pub struct BindGroupCacheStats {
912 pub hits: usize,
914 pub misses: usize,
916 pub evictions: usize,
918 pub entries: usize,
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925
926 #[test]
929 fn staging_pool_reuses_buffers_on_hot_readback_loop() {
930 let arc = crate::runtime::cached_device()
931 .expect("Fix: GPU device is required for staging pool test");
932 let (device, queue) = &*arc;
933
934 let contents: Vec<u8> = vec![0xAB; 64];
936 let handle =
937 GpuBufferHandle::upload(device, queue, &contents, wgpu::BufferUsages::COPY_SRC)
938 .expect("Fix: upload should succeed");
939
940 let pool = StagingBufferPool::new();
941
942 for _ in 0..100 {
943 let mut out = Vec::new();
944 handle
945 .readback_until(device, Some(&pool), queue, &mut out, None)
946 .expect("Fix: pooled readback should succeed");
947 assert_eq!(out, contents, "readback bytes must match uploaded bytes");
948 }
949
950 let stats = pool.stats();
951 assert!(
952 stats.allocations <= 2,
953 "hot loop of 100 identical readbacks should allocate at most 2 staging buffers, got {} allocations and {} hits",
954 stats.allocations,
955 stats.hits
956 );
957 }
958
959 #[test]
961 fn readback_without_pool_always_allocates() {
962 let arc = crate::runtime::cached_device()
963 .expect("Fix: GPU device is required for readback regression test");
964 let (device, queue) = &*arc;
965
966 let contents: Vec<u8> = vec![0xCD; 32];
967 let handle =
968 GpuBufferHandle::upload(device, queue, &contents, wgpu::BufferUsages::COPY_SRC)
969 .expect("Fix: upload should succeed");
970
971 for _ in 0..5 {
972 let mut out = Vec::new();
973 handle
974 .readback(device, queue, &mut out)
975 .expect("Fix: unpooled readback should succeed");
976 assert_eq!(out, contents);
977 }
978 }
979
980 #[test]
981 fn resident_registry_handles_concurrent_lookup_and_drop() {
982 let arc = crate::runtime::cached_device()
983 .expect("Fix: GPU device is required for resident registry concurrency test");
984 let (device, queue) = &*arc;
985 let handle =
986 GpuBufferHandle::upload(device, queue, &[1, 2, 3, 4], wgpu::BufferUsages::COPY_SRC)
987 .expect("Fix: upload should register a resident buffer");
988 let id = handle.id();
989
990 let readers = (0..8)
994 .map(|_| {
995 std::thread::spawn(move || {
996 for _ in 0..1_000 {
997 let resident = GpuBufferHandle::from_resident_id(id)
998 .expect("Fix: resident id must resolve while handle is alive");
999 assert_eq!(resident.id(), id);
1000 }
1001 })
1002 })
1003 .collect::<Vec<_>>();
1004 for reader in readers {
1005 reader
1006 .join()
1007 .expect("Fix: concurrent resident lookups must not panic");
1008 }
1009
1010 drop(handle);
1013 assert!(
1014 GpuBufferHandle::from_resident_id(id).is_none(),
1015 "dropped handles must be removed from the resident registry"
1016 );
1017 }
1018
1019 #[test]
1020 fn poisoned_staging_pool_lock_recovers_without_aborting_dispatch_path() {
1021 let pool = StagingBufferPool::new();
1022 let poisoned = pool.clone();
1023 let _ = std::thread::spawn(move || {
1024 let _guard = poisoned.lock_inner();
1025 panic!("poison staging buffer pool");
1026 })
1027 .join();
1028
1029 std::panic::catch_unwind(|| {
1030 let _ = pool.stats();
1031 })
1032 .expect("Fix: poisoned staging pool must recover so GPU readback pooling does not abort");
1033 }
1034
1035 #[test]
1036 fn poisoned_bind_group_cache_lock_recovers_without_aborting_dispatch_path() {
1037 let cache = BindGroupCache::new();
1038 let poisoned = cache.clone();
1039 let _ = std::thread::spawn(move || {
1040 let _guard = poisoned.lock_cache();
1041 panic!("poison bind group cache");
1042 })
1043 .join();
1044
1045 std::panic::catch_unwind(|| {
1046 let _ = cache.stats();
1047 })
1048 .expect("Fix: poisoned bind-group cache must recover so GPU dispatch does not abort");
1049 }
1050
1051 #[test]
1052 fn bind_group_cache_lru_heap_stays_capacity_scale() {
1053 let arc = crate::runtime::cached_device()
1054 .expect("Fix: GPU device is required for bind-group cache test");
1055 let (device, _) = &*arc;
1056 let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1057 label: Some("vyre bind-group cache lru test layout"),
1058 entries: &[wgpu::BindGroupLayoutEntry {
1059 binding: 0,
1060 visibility: wgpu::ShaderStages::COMPUTE,
1061 ty: wgpu::BindingType::Buffer {
1062 ty: wgpu::BufferBindingType::Storage { read_only: true },
1063 has_dynamic_offset: false,
1064 min_binding_size: wgpu::BufferSize::new(4),
1065 },
1066 count: None,
1067 }],
1068 });
1069 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
1070 label: Some("vyre bind-group cache lru test buffer"),
1071 size: 4,
1072 usage: wgpu::BufferUsages::STORAGE,
1073 mapped_at_creation: false,
1074 });
1075 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1076 label: Some("vyre bind-group cache lru test bind group"),
1077 layout: &layout,
1078 entries: &[wgpu::BindGroupEntry {
1079 binding: 0,
1080 resource: buffer.as_entire_binding(),
1081 }],
1082 });
1083 let cache = BindGroupCache::with_cap(4);
1084
1085 for i in 0..64u64 {
1086 cache.insert_by_ids(1, &[i, 4], bind_group.clone());
1087 }
1088
1089 let inner = cache.lock_cache();
1090 assert_eq!(inner.entries.len(), 4);
1091 assert!(
1092 inner.lru.len() <= inner.entries.len().saturating_mul(4).max(8),
1093 "Fix: bind-group LRU heap must compact stale entries to cache-capacity scale"
1094 );
1095 }
1096}