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, LazyLock, Mutex, MutexGuard, OnceLock, Weak};
8use std::time::Instant;
9
10use dashmap::DashMap;
11use rustc_hash::{FxHashMap, FxHasher};
12use smallvec::SmallVec;
13use vyre_driver::{BackendError, ResidentHandle, ResidentOwner};
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
25static RESIDENT_OWNER: LazyLock<Result<ResidentOwner, BackendError>> =
35 LazyLock::new(ResidentOwner::new);
36
37fn resident_owner() -> Result<ResidentOwner, BackendError> {
39 match &*RESIDENT_OWNER {
40 Ok(owner) => Ok(*owner),
41 Err(error) => Err(BackendError::new(format!(
42 "WGPU resident buffers have no namespace identity: {error} Fix: reduce the number of backend instances created in this process."
43 ))),
44 }
45}
46
47pub(crate) fn check_resident_owner(
52 handle: ResidentHandle,
53 context: &str,
54) -> Result<(), BackendError> {
55 resident_owner()?.resolve(handle, context)?;
56 Ok(())
57}
58
59fn pointer_identity_key<T>(ptr: *const T) -> u64 {
60 let mut hasher = FxHasher::default();
61 ptr.addr().hash(&mut hasher);
62 hasher.finish()
63}
64
65#[derive(Clone)]
71pub struct GpuBufferHandle {
72 inner: Arc<GpuBufferInner>,
73}
74
75struct GpuBufferInner {
76 id: u64,
77 buffer: Arc<wgpu::Buffer>,
78 byte_len: u64,
79 allocation_len: u64,
80 element_count: usize,
81 usage: wgpu::BufferUsages,
82 pool_return: Option<PoolReturn>,
83}
84
85#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
87pub struct StagingBufferPoolStats {
88 pub allocations: usize,
90 pub hits: usize,
92}
93
94#[derive(Clone, Default)]
102pub struct StagingBufferPool {
103 inner: Arc<Mutex<StagingBufferPoolInner>>,
104}
105
106impl std::fmt::Debug for StagingBufferPool {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("StagingBufferPool").finish_non_exhaustive()
109 }
110}
111
112#[derive(Default)]
113struct StagingBufferPoolInner {
114 free: FxHashMap<(u64, u32), SmallVec<[wgpu::Buffer; STAGING_BUFFER_POOL_CLASS_CAP]>>,
115 allocations: usize,
116 hits: usize,
117}
118
119impl StagingBufferPool {
120 fn lock_inner(&self) -> MutexGuard<'_, StagingBufferPoolInner> {
121 self.inner.lock().unwrap_or_else(|error| {
122 tracing::error!(
123 "Vyre WGPU staging buffer pool lock was poisoned: {error}. Fix: discard the pool after a panic; continuing with recovered state."
124 );
125 error.into_inner()
126 })
127 }
128
129 #[must_use]
131 #[inline]
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 #[must_use]
138 pub fn stats(&self) -> StagingBufferPoolStats {
139 let inner = self.lock_inner();
140 StagingBufferPoolStats {
141 allocations: inner.allocations,
142 hits: inner.hits,
143 }
144 }
145
146 pub fn acquire(
151 &self,
152 device: &wgpu::Device,
153 size: u64,
154 usage: wgpu::BufferUsages,
155 ) -> wgpu::Buffer {
156 let key = (size, usage.bits());
157 let mut inner = self.lock_inner();
158 if let Some(buffers) = inner.free.get_mut(&key) {
159 if let Some(buffer) = buffers.pop() {
160 inner.hits += 1;
161 return buffer;
162 }
163 }
164 inner.allocations += 1;
165 drop(inner);
166 device.create_buffer(&wgpu::BufferDescriptor {
167 label: Some("vyre staging readback"),
168 size,
169 usage,
170 mapped_at_creation: false,
171 })
172 }
173
174 pub fn release(&self, buffer: wgpu::Buffer, size: u64, usage: wgpu::BufferUsages) {
179 let key = (size, usage.bits());
180 let mut inner = self.lock_inner();
181 let buffers = inner.free.entry(key).or_insert_with(SmallVec::new);
182 if buffers.len() == STAGING_BUFFER_POOL_CLASS_CAP {
183 buffers.remove(0);
184 }
185 buffers.push(buffer);
186 }
187}
188
189impl GpuBufferHandle {
190 pub fn upload(
199 device: &wgpu::Device,
200 queue: &wgpu::Queue,
201 bytes: &[u8],
202 usage: wgpu::BufferUsages,
203 ) -> Result<Self, BackendError> {
204 let allocation_len = aligned_len(bytes.len())?;
205 let final_usage = usage | wgpu::BufferUsages::COPY_DST;
206 if allocation_len > 0 {
219 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
220 label: Some("vyre persistent upload"),
221 size: allocation_len,
222 usage: final_usage,
223 mapped_at_creation: true,
224 });
225 {
226 let mut mapped = buffer.slice(..).get_mapped_range_mut();
227 crate::padded_upload::write_padded_into_mapped(&mut mapped, bytes)?;
228 }
229 buffer.unmap();
230 let logical_len = u64::try_from(bytes.len()).map_err(|source| {
231 BackendError::new(format!(
232 "GPU upload logical byte length cannot fit u64: {source}. Fix: split the dispatch input."
233 ))
234 })?;
235 return Ok(Self::from_parts(
236 Arc::new(buffer),
237 logical_len,
238 allocation_len,
239 bytes.len(),
240 final_usage,
241 None,
242 ));
243 }
244 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
248 label: Some("vyre persistent upload"),
249 size: allocation_len,
250 usage: final_usage,
251 mapped_at_creation: false,
252 });
253 write_padded(queue, &buffer, bytes, allocation_len)?;
254 Ok(Self::from_parts(
255 Arc::new(buffer),
256 0,
257 allocation_len,
258 bytes.len(),
259 final_usage,
260 None,
261 ))
262 }
263
264 pub fn alloc(
271 device: &wgpu::Device,
272 len: u64,
273 usage: wgpu::BufferUsages,
274 ) -> Result<Self, BackendError> {
275 let allocation_len = aligned_len_u64(len, "persistent GPU allocation length")?;
276 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
277 label: Some("vyre persistent alloc"),
278 size: allocation_len,
279 usage,
280 mapped_at_creation: false,
281 });
282 let host_len = usize::try_from(len).map_err(|error| {
283 BackendError::new(format!(
284 "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."
285 ))
286 })?;
287 Ok(Self::from_parts(
288 Arc::new(buffer),
289 len,
290 allocation_len,
291 host_len,
292 usage,
293 None,
294 ))
295 }
296
297 pub fn readback(
307 &self,
308 device: &wgpu::Device,
309 queue: &wgpu::Queue,
310 out: &mut Vec<u8>,
311 ) -> Result<(), BackendError> {
312 self.readback_until(device, None, queue, out, None)
313 }
314
315 pub fn readback_prefix(
327 &self,
328 device: &wgpu::Device,
329 queue: &wgpu::Queue,
330 len: u64,
331 out: &mut Vec<u8>,
332 ) -> Result<(), BackendError> {
333 self.readback_prefix_until(device, None, queue, len, out, None)
334 }
335
336 pub fn readback_range(
346 &self,
347 device: &wgpu::Device,
348 queue: &wgpu::Queue,
349 byte_offset: u64,
350 len: u64,
351 out: &mut Vec<u8>,
352 ) -> Result<(), BackendError> {
353 self.readback_range_until(device, None, queue, byte_offset, len, out, None)
354 }
355
356 pub(crate) fn readback_until(
357 &self,
358 device: &wgpu::Device,
359 pool: Option<&StagingBufferPool>,
360 queue: &wgpu::Queue,
361 out: &mut Vec<u8>,
362 deadline: Option<Instant>,
363 ) -> Result<(), BackendError> {
364 self.readback_prefix_until(device, pool, queue, self.byte_len(), out, deadline)
365 }
366
367 pub(crate) fn readback_prefix_until(
368 &self,
369 device: &wgpu::Device,
370 pool: Option<&StagingBufferPool>,
371 queue: &wgpu::Queue,
372 len: u64,
373 out: &mut Vec<u8>,
374 deadline: Option<Instant>,
375 ) -> Result<(), BackendError> {
376 self.readback_range_until(device, pool, queue, 0, len, out, deadline)
377 }
378
379 pub(crate) fn readback_range_until(
380 &self,
381 device: &wgpu::Device,
382 pool: Option<&StagingBufferPool>,
383 queue: &wgpu::Queue,
384 byte_offset: u64,
385 len: u64,
386 out: &mut Vec<u8>,
387 deadline: Option<Instant>,
388 ) -> Result<(), BackendError> {
389 if !self.usage().contains(wgpu::BufferUsages::COPY_SRC) {
390 return Err(BackendError::new(
391 "GpuBufferHandle readback requires COPY_SRC usage. Fix: allocate terminal-output buffers with COPY_SRC.",
392 ));
393 }
394 let logical_end = byte_offset.checked_add(len).ok_or_else(|| {
395 BackendError::new(format!(
396 "GpuBufferHandle range readback overflows u64 at offset {byte_offset} len {len}. Fix: split the readback range before dispatch."
397 ))
398 })?;
399 if logical_end > self.byte_len() {
400 return Err(BackendError::new(format!(
401 "GpuBufferHandle range readback requested bytes [{byte_offset}..{logical_end}) from a {} byte buffer. Fix: clamp the requested range to the device-published count.",
402 self.byte_len()
403 )));
404 }
405 if len == 0 {
406 out.clear();
407 return Ok(());
408 }
409 let copy_start = byte_offset & !3;
410 let trim_start = byte_offset - copy_start;
411 let visible_copy_len = trim_start.checked_add(len).ok_or_else(|| {
412 BackendError::new(format!(
413 "GpuBufferHandle range readback copy length overflows u64 at trim {trim_start} len {len}. Fix: split the readback range before dispatch."
414 ))
415 })?;
416 let read_len = aligned_len_u64(visible_copy_len, "GPU readback visible copy length")?;
417 let copy_end = copy_start.checked_add(read_len).ok_or_else(|| {
418 BackendError::new(format!(
419 "GpuBufferHandle range readback aligned copy overflows u64 at start {copy_start} len {read_len}. Fix: split the readback range before dispatch."
420 ))
421 })?;
422 if copy_end > self.inner.allocation_len {
423 return Err(BackendError::new(format!(
424 "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.",
425 self.inner.allocation_len
426 )));
427 }
428 let readback_usage = wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ;
429 let readback = if let Some(pool) = pool {
430 pool.acquire(device, read_len, readback_usage)
431 } else {
432 device.create_buffer(&wgpu::BufferDescriptor {
433 label: Some("vyre persistent handle readback"),
434 size: read_len,
435 usage: readback_usage,
436 mapped_at_creation: false,
437 })
438 };
439 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
440 label: Some("vyre persistent handle readback encoder"),
441 });
442 encoder.copy_buffer_to_buffer(self.buffer(), copy_start, &readback, 0, read_len);
443 let submission = queue.submit(std::iter::once(encoder.finish()));
444 let slice = readback.slice(0..read_len);
445 let (sender, receiver) = std::sync::mpsc::channel();
446 slice.map_async(wgpu::MapMode::Read, move |result| {
447 if let Err(error) = sender.send(result) {
448 tracing::error!(
449 ?error,
450 "persistent buffer readback map_async result was lost because the receiver dropped"
451 );
452 }
453 });
454 let mapping = if let Some(deadline) = deadline {
455 let mut backoff = crate::wait_backoff::AdaptiveWaitBackoff::from_micros(64, 2, 50, 5);
456 loop {
457 crate::runtime::device::poll_device_once(device)?;
458 match receiver.try_recv() {
459 Ok(result) => break result,
460 Err(std::sync::mpsc::TryRecvError::Empty) => {}
461 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
462 return Err(BackendError::new(
463 "persistent buffer readback channel closed before completion. Fix: keep the GPU device alive until readback completes.",
464 ));
465 }
466 }
467 let now = Instant::now();
468 if now >= deadline {
469 return Err(BackendError::new(
470 "dispatch cancelled after DispatchConfig.timeout before readback completed. Fix: raise DispatchConfig.timeout or split the program into smaller chunks.",
471 ));
472 }
473 backoff.idle_for(deadline.saturating_duration_since(now));
474 }
475 } else {
476 crate::runtime::device::poll_device_wait_for(device, submission)?;
477 receiver
478 .recv_timeout(std::time::Duration::from_secs(30))
479 .map_err(|source| {
480 BackendError::new(format!(
481 "persistent buffer readback callback did not complete after submission wait: {source}. Fix: keep the GPU device alive and inspect driver callback progress."
482 ))
483 })?
484 };
485 let result = mapping.map_err(|source| {
486 BackendError::new(format!(
487 "persistent buffer readback mapping failed: {source:?}. Fix: use COPY_SRC handles and MAP_READ staging buffers."
488 ))
489 });
490 result?;
491 let mapped = slice.get_mapped_range();
492 let visible_len = usize::try_from(len).map_err(|source| {
493 BackendError::new(format!(
494 "persistent buffer prefix length {len} cannot fit usize: {source}. Fix: split the buffer before readback.",
495 ))
496 })?;
497 let trim_start = usize::try_from(trim_start).map_err(|source| {
498 BackendError::new(format!(
499 "persistent buffer range trim offset {trim_start} cannot fit usize: {source}. Fix: split the buffer before readback.",
500 ))
501 })?;
502 let trim_end = trim_start.checked_add(visible_len).ok_or_else(|| {
503 BackendError::new(format!(
504 "persistent buffer range trim overflows usize at offset {trim_start} len {visible_len}. Fix: split the buffer before readback."
505 ))
506 })?;
507 let visible = &mapped[trim_start..trim_end];
508 if out.len() == visible_len {
509 out.copy_from_slice(visible);
510 } else {
511 if visible_len > out.capacity() {
512 let additional = visible_len - out.capacity();
513 out.try_reserve_exact(additional).map_err(|source| {
514 BackendError::new(format!(
515 "persistent buffer readback could not reserve {visible_len} output bytes exactly: {source}. Fix: lower max_output_bytes or stream readback in smaller shards."
516 ))
517 })?;
518 }
519 out.clear();
520 out.extend_from_slice(visible);
521 }
522 drop(mapped);
523 readback.unmap();
524 if let Some(pool) = pool {
525 pool.release(readback, read_len, readback_usage);
526 }
527 Ok(())
528 }
529
530 #[must_use]
532 pub fn id(&self) -> u64 {
533 self.inner.id
534 }
535
536 #[must_use]
543 pub(crate) fn allocation_identity(&self) -> u64 {
544 pointer_identity_key(Arc::as_ptr(&self.inner.buffer))
545 }
546
547 #[must_use]
549 pub fn from_resident_id(id: u64) -> Option<Self> {
550 let registry = resident_buffers();
551 let entry = registry.get(&id)?;
552 let upgraded = entry.value().upgrade();
553 drop(entry);
554 match upgraded {
555 Some(inner) => Some(Self { inner }),
556 None => {
557 registry.remove(&id);
558 None
559 }
560 }
561 }
562
563 pub fn resident_handle(&self) -> Result<ResidentHandle, BackendError> {
570 Ok(resident_owner()?.handle(self.inner.id))
571 }
572
573 pub fn from_resident_handle(
583 handle: ResidentHandle,
584 context: &str,
585 ) -> Result<Option<Self>, BackendError> {
586 let id = resident_owner()?.resolve(handle, context)?;
587 Ok(Self::from_resident_id(id))
588 }
589
590 #[must_use]
592 pub fn buffer(&self) -> &wgpu::Buffer {
593 &self.inner.buffer
594 }
595
596 #[must_use]
600 pub fn buffer_arc(&self) -> Arc<wgpu::Buffer> {
601 Arc::clone(&self.inner.buffer)
602 }
603
604 #[must_use]
606 pub fn byte_len(&self) -> u64 {
607 self.inner.byte_len
608 }
609
610 #[must_use]
612 pub fn allocation_len(&self) -> u64 {
613 self.inner.allocation_len
614 }
615
616 #[must_use]
618 pub fn element_count(&self) -> usize {
619 self.inner.element_count
620 }
621
622 #[must_use]
624 pub fn usage(&self) -> wgpu::BufferUsages {
625 self.inner.usage
626 }
627
628 pub(crate) fn from_parts(
629 buffer: Arc<wgpu::Buffer>,
630 byte_len: u64,
631 allocation_len: u64,
632 element_count: usize,
633 usage: wgpu::BufferUsages,
634 pool_return: Option<PoolReturn>,
635 ) -> Self {
636 let inner = Arc::new(GpuBufferInner {
637 id: NEXT_BUFFER_ID.fetch_add(1, Ordering::Relaxed),
638 buffer,
639 byte_len,
640 allocation_len,
641 element_count,
642 usage,
643 pool_return,
644 });
645 resident_buffers().insert(inner.id, Arc::downgrade(&inner));
646 Self { inner }
647 }
648}
649
650impl std::fmt::Debug for GpuBufferHandle {
651 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
652 formatter
653 .debug_struct("GpuBufferHandle")
654 .field("id", &self.id())
655 .field("byte_len", &self.byte_len())
656 .field("allocation_len", &self.allocation_len())
657 .field("element_count", &self.element_count())
658 .field("usage", &self.usage())
659 .finish()
660 }
661}
662
663impl Drop for GpuBufferInner {
664 fn drop(&mut self) {
665 resident_buffers().remove(&self.id);
666 if let Some(pool_return) = self.pool_return.take() {
667 pool_return.release(
668 Arc::clone(&self.buffer),
669 self.byte_len,
670 self.allocation_len,
671 self.usage,
672 );
673 }
674 }
675}
676
677pub(crate) fn aligned_len(len: usize) -> Result<u64, BackendError> {
678 let padded = aligned_len_usize(len, "GPU buffer length")?;
679 u64::try_from(padded).map_err(|source| {
680 BackendError::new(format!(
681 "GPU buffer length {padded} cannot fit u64: {source}. Fix: split the dispatch input."
682 ))
683 })
684}
685
686fn aligned_len_u64(len: u64, label: &'static str) -> Result<u64, BackendError> {
687 crate::numeric::WGPU_NUMERIC.align_up_u64(len, 4, 4, label)
688}
689
690fn aligned_len_usize(len: usize, label: &'static str) -> Result<usize, BackendError> {
691 crate::numeric::WGPU_NUMERIC.align_up_usize(len, 4, 4, label)
692}
693
694pub(crate) fn write_padded(
695 queue: &wgpu::Queue,
696 buffer: &wgpu::Buffer,
697 bytes: &[u8],
698 allocation_len: u64,
699) -> Result<(), BackendError> {
700 crate::padded_upload::write_padded_and_zero_fill(queue, buffer, bytes, allocation_len)
701}
702
703const BIND_GROUP_CACHE_CAP: usize = 256;
705
706type BindGroupHandleKey = SmallVec<[u64; 16]>;
709
710#[derive(Clone)]
718pub struct BindGroupCache {
719 cache: Arc<Mutex<BindGroupCacheInner>>,
720 hits: Arc<AtomicUsize>,
721 misses: Arc<AtomicUsize>,
722 evictions: Arc<AtomicUsize>,
723}
724
725struct BindGroupCacheInner {
726 entries: FxHashMap<BindGroupCacheKey, BindGroupCacheEntry>,
727 lru: BinaryHeap<Reverse<BindGroupLruEntry>>,
728 cap: usize,
729 next_generation: u64,
730}
731
732struct BindGroupCacheEntry {
733 bind_group: Arc<wgpu::BindGroup>,
734 last_seen: u64,
735}
736
737#[derive(Clone, Debug, Eq, PartialEq)]
738struct BindGroupLruEntry {
739 last_seen: u64,
740 key: BindGroupCacheKey,
741}
742
743impl Ord for BindGroupLruEntry {
744 fn cmp(&self, other: &Self) -> CmpOrdering {
745 self.last_seen
746 .cmp(&other.last_seen)
747 .then_with(|| self.key.cmp(&other.key))
748 }
749}
750
751impl PartialOrd for BindGroupLruEntry {
752 fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
753 Some(self.cmp(other))
754 }
755}
756
757fn push_bind_group_handle_key(key: &mut BindGroupHandleKey, handle: &GpuBufferHandle) -> bool {
758 key.push(handle.allocation_identity());
759 let Ok(aligned_len) = aligned_len_u64(handle.byte_len(), "bind-group handle key byte length")
760 else {
761 key.pop();
762 return false;
763 };
764 key.push(aligned_len);
765 true
766}
767
768#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
769struct BindGroupCacheKey {
770 layout_id: usize,
771 handles: BindGroupHandleKey,
772}
773
774impl std::fmt::Debug for BindGroupCache {
775 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
776 f.debug_struct("BindGroupCache")
777 .field("hits", &self.hits.load(Ordering::Relaxed))
778 .field("misses", &self.misses.load(Ordering::Relaxed))
779 .field("evictions", &self.evictions.load(Ordering::Relaxed))
780 .field("entries", &self.lock_cache().entries.len())
781 .finish_non_exhaustive()
782 }
783}
784
785impl Default for BindGroupCache {
786 fn default() -> Self {
787 Self::new()
788 }
789}
790
791impl BindGroupCacheInner {
792 fn next_lru_generation(&mut self) -> u64 {
793 let generation = self.next_generation;
794 self.next_generation = self.next_generation.wrapping_add(1);
795 generation
796 }
797
798 fn touch_existing(&mut self, key: &BindGroupCacheKey) -> Option<Arc<wgpu::BindGroup>> {
799 let generation = self.next_lru_generation();
800 let bind_group = {
801 let entry = self.entries.get_mut(key)?;
802 entry.last_seen = generation;
803 Arc::clone(&entry.bind_group)
804 };
805 self.lru.push(Reverse(BindGroupLruEntry {
806 last_seen: generation,
807 key: key.clone(),
808 }));
809 self.compact_lru_if_needed();
810 Some(bind_group)
811 }
812
813 fn insert_entry(&mut self, key: BindGroupCacheKey, bind_group: Arc<wgpu::BindGroup>) {
814 let generation = self.next_lru_generation();
815 self.entries.insert(
816 key.clone(),
817 BindGroupCacheEntry {
818 bind_group,
819 last_seen: generation,
820 },
821 );
822 self.lru.push(Reverse(BindGroupLruEntry {
823 last_seen: generation,
824 key,
825 }));
826 self.compact_lru_if_needed();
827 }
828
829 fn evict_to_cap(&mut self, mut on_evict: impl FnMut()) {
830 while self.entries.len() > self.cap {
831 let Some(key) = self.pop_lru_key() else { break };
832 if self.entries.remove(&key).is_some() {
833 on_evict();
834 }
835 }
836 }
837
838 fn pop_lru_key(&mut self) -> Option<BindGroupCacheKey> {
839 while let Some(Reverse(entry)) = self.lru.pop() {
840 if self
841 .entries
842 .get(&entry.key)
843 .is_some_and(|current| current.last_seen == entry.last_seen)
844 {
845 return Some(entry.key);
846 }
847 }
848 None
849 }
850
851 fn compact_lru_if_needed(&mut self) {
852 let live = self.entries.len();
853 if let Some(limit) = stale_lru_limit(live) {
854 if self.lru.len() <= limit {
855 return;
856 }
857 }
858 self.lru.clear();
859 self.lru.extend(self.entries.iter().map(|(key, entry)| {
860 Reverse(BindGroupLruEntry {
861 last_seen: entry.last_seen,
862 key: key.clone(),
863 })
864 }));
865 }
866}
867
868fn stale_lru_limit(live: usize) -> Option<usize> {
869 live.checked_mul(4).map(|limit| limit.max(8))
870}
871
872impl BindGroupCache {
873 fn lock_cache(&self) -> MutexGuard<'_, BindGroupCacheInner> {
874 self.cache.lock().unwrap_or_else(|error| {
875 tracing::error!(
876 "Vyre WGPU bind-group cache lock was poisoned: {error}. Fix: discard the cache after a panic; continuing with recovered state."
877 );
878 error.into_inner()
879 })
880 }
881
882 #[must_use]
884 pub fn new() -> Self {
885 Self::with_cap(BIND_GROUP_CACHE_CAP)
886 }
887
888 #[must_use]
891 pub fn with_cap(cap: usize) -> Self {
892 Self {
893 cache: Arc::new(Mutex::new(BindGroupCacheInner {
894 entries: FxHashMap::default(),
895 lru: BinaryHeap::new(),
896 cap: cap.max(1),
897 next_generation: 0,
898 })),
899 hits: Arc::new(AtomicUsize::new(0)),
900 misses: Arc::new(AtomicUsize::new(0)),
901 evictions: Arc::new(AtomicUsize::new(0)),
902 }
903 }
904
905 pub fn get_or_create(
913 &self,
914 layout_id: usize,
915 handles: &[GpuBufferHandle],
916 factory: impl FnOnce() -> wgpu::BindGroup,
917 ) -> Arc<wgpu::BindGroup> {
918 let Some(key_part_count) = handles.len().checked_mul(2) else {
919 self.misses.fetch_add(1, Ordering::Relaxed);
920 return Arc::new(factory());
921 };
922 let mut key_parts = SmallVec::with_capacity(key_part_count);
923 for handle in handles {
924 if !push_bind_group_handle_key(&mut key_parts, handle) {
925 self.misses.fetch_add(1, Ordering::Relaxed);
926 return Arc::new(factory());
927 }
928 }
929 self.get_or_create_by_ids(layout_id, key_parts, factory)
930 }
931
932 pub(crate) fn get_or_create_by_ids(
933 &self,
934 layout_id: usize,
935 handles: SmallVec<[u64; 16]>,
936 factory: impl FnOnce() -> wgpu::BindGroup,
937 ) -> Arc<wgpu::BindGroup> {
938 let key = BindGroupCacheKey { layout_id, handles };
939 {
940 let mut cache = self.lock_cache();
941 if let Some(existing) = cache.touch_existing(&key) {
942 self.hits.fetch_add(1, Ordering::Relaxed);
943 return existing;
944 }
945 }
946 let bg = Arc::new(factory());
947 let mut cache = self.lock_cache();
948 cache.insert_entry(key, Arc::clone(&bg));
949 cache.evict_to_cap(|| {
950 self.evictions.fetch_add(1, Ordering::Relaxed);
951 });
952 self.misses.fetch_add(1, Ordering::Relaxed);
953 bg
954 }
955
956 pub(crate) fn get_by_ids(
957 &self,
958 layout_id: usize,
959 handles: &[u64],
960 ) -> Option<Arc<wgpu::BindGroup>> {
961 let key = BindGroupCacheKey {
962 layout_id,
963 handles: SmallVec::from_slice(handles),
964 };
965 let mut cache = self.lock_cache();
966 let existing = cache.touch_existing(&key)?;
967 self.hits.fetch_add(1, Ordering::Relaxed);
968 Some(existing)
969 }
970
971 pub(crate) fn insert_by_ids(
972 &self,
973 layout_id: usize,
974 handles: &[u64],
975 bind_group: wgpu::BindGroup,
976 ) -> Arc<wgpu::BindGroup> {
977 let key = BindGroupCacheKey {
978 layout_id,
979 handles: SmallVec::from_slice(handles),
980 };
981 let mut cache = self.lock_cache();
982 if let Some(existing) = cache.touch_existing(&key) {
983 self.hits.fetch_add(1, Ordering::Relaxed);
984 return existing;
985 }
986 let bg = Arc::new(bind_group);
987 cache.insert_entry(key, Arc::clone(&bg));
988 cache.evict_to_cap(|| {
989 self.evictions.fetch_add(1, Ordering::Relaxed);
990 });
991 self.misses.fetch_add(1, Ordering::Relaxed);
992 bg
993 }
994
995 #[must_use]
997 pub fn stats(&self) -> BindGroupCacheStats {
998 BindGroupCacheStats {
999 hits: self.hits.load(Ordering::Relaxed),
1000 misses: self.misses.load(Ordering::Relaxed),
1001 evictions: self.evictions.load(Ordering::Relaxed),
1002 entries: self.lock_cache().entries.len(),
1003 }
1004 }
1005}
1006
1007#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1009pub struct BindGroupCacheStats {
1010 pub hits: usize,
1012 pub misses: usize,
1014 pub evictions: usize,
1016 pub entries: usize,
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023
1024 #[test]
1027 fn staging_pool_reuses_buffers_on_hot_readback_loop() {
1028 let arc = crate::runtime::cached_device()
1029 .expect("Fix: GPU device is required for staging pool test");
1030 let (device, queue) = &*arc;
1031
1032 let contents: Vec<u8> = vec![0xAB; 64];
1034 let handle =
1035 GpuBufferHandle::upload(device, queue, &contents, wgpu::BufferUsages::COPY_SRC)
1036 .expect("Fix: upload should succeed");
1037
1038 let pool = StagingBufferPool::new();
1039
1040 for _ in 0..100 {
1041 let mut out = Vec::new();
1042 handle
1043 .readback_until(device, Some(&pool), queue, &mut out, None)
1044 .expect("Fix: pooled readback should succeed");
1045 assert_eq!(out, contents, "readback bytes must match uploaded bytes");
1046 }
1047
1048 let stats = pool.stats();
1049 assert!(
1050 stats.allocations <= 2,
1051 "hot loop of 100 identical readbacks should allocate at most 2 staging buffers, got {} allocations and {} hits",
1052 stats.allocations,
1053 stats.hits
1054 );
1055 }
1056
1057 #[test]
1059 fn readback_without_pool_always_allocates() {
1060 let arc = crate::runtime::cached_device()
1061 .expect("Fix: GPU device is required for readback regression test");
1062 let (device, queue) = &*arc;
1063
1064 let contents: Vec<u8> = vec![0xCD; 32];
1065 let handle =
1066 GpuBufferHandle::upload(device, queue, &contents, wgpu::BufferUsages::COPY_SRC)
1067 .expect("Fix: upload should succeed");
1068
1069 for _ in 0..5 {
1070 let mut out = Vec::new();
1071 handle
1072 .readback(device, queue, &mut out)
1073 .expect("Fix: unpooled readback should succeed");
1074 assert_eq!(out, contents);
1075 }
1076 }
1077
1078 #[test]
1084 fn mapped_upload_roundtrips_exact_bytes_across_boundaries() {
1085 let arc = crate::runtime::cached_device()
1086 .expect("Fix: live GPU device required for mapped upload roundtrip test");
1087 let (device, queue) = &*arc;
1088 for &len in &[1usize, 3, 4, 5, 257, (1 << 20) + 3] {
1092 let contents: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
1093 let handle =
1094 GpuBufferHandle::upload(device, queue, &contents, wgpu::BufferUsages::COPY_SRC)
1095 .expect("Fix: mapped upload should succeed at every size");
1096 let mut out = Vec::new();
1097 handle
1098 .readback(device, queue, &mut out)
1099 .expect("Fix: readback should succeed");
1100 assert_eq!(
1101 out, contents,
1102 "mapped upload corrupted {len}-byte payload: readback != input"
1103 );
1104 }
1105 }
1106
1107 #[test]
1111 fn write_padded_into_mapped_zeroes_the_tail() {
1112 let mut mapped = [0xAAu8; 8];
1115 let bytes = [1u8, 2, 3, 4, 5];
1116 crate::padded_upload::write_padded_into_mapped(&mut mapped, &bytes)
1117 .expect("Fix: filling a large-enough mapped slice must succeed");
1118 assert_eq!(&mapped[..5], &bytes);
1119 assert_eq!(&mapped[5..], &[0u8, 0, 0], "alignment tail must be zeroed");
1120 let mut too_small = [0u8; 2];
1122 assert!(crate::padded_upload::write_padded_into_mapped(&mut too_small, &bytes).is_err());
1123 }
1124
1125 #[test]
1126 fn foreign_resident_handle_is_refused_not_resolved() {
1127 let foreign = ResidentOwner::new().expect("Fix: owner ids must be available");
1131 let native = resident_owner().expect("Fix: WGPU resident owner must be available");
1132 assert_ne!(foreign, native);
1133
1134 let error = GpuBufferHandle::from_resident_handle(foreign.handle(1), "refusal test")
1135 .expect_err("Fix: a foreign resident handle must never resolve to a WGPU buffer");
1136 let BackendError::InvalidProgram { fix } = error else {
1137 panic!("Fix: foreign resident handle refusal must be BackendError::InvalidProgram");
1138 };
1139 assert!(
1140 fix.contains("owned by backend instance") && fix.contains("Fix: "),
1141 "Fix: refusal must name the owning instance and carry actionable text, got {fix}"
1142 );
1143
1144 assert!(
1145 check_resident_owner(native.handle(u64::MAX), "refusal test").is_ok(),
1146 "Fix: a handle from this namespace must pass the owner check even when its buffer is gone"
1147 );
1148 }
1149
1150 #[test]
1151 fn resident_registry_handles_concurrent_lookup_and_drop() {
1152 let arc = crate::runtime::cached_device()
1153 .expect("Fix: GPU device is required for resident registry concurrency test");
1154 let (device, queue) = &*arc;
1155 let handle =
1156 GpuBufferHandle::upload(device, queue, &[1, 2, 3, 4], wgpu::BufferUsages::COPY_SRC)
1157 .expect("Fix: upload should register a resident buffer");
1158 let id = handle.id();
1159
1160 let readers = (0..8)
1164 .map(|_| {
1165 std::thread::spawn(move || {
1166 for _ in 0..1_000 {
1167 let resident = GpuBufferHandle::from_resident_id(id)
1168 .expect("Fix: resident id must resolve while handle is alive");
1169 assert_eq!(resident.id(), id);
1170 }
1171 })
1172 })
1173 .collect::<Vec<_>>();
1174 for reader in readers {
1175 reader
1176 .join()
1177 .expect("Fix: concurrent resident lookups must not panic");
1178 }
1179
1180 drop(handle);
1183 assert!(
1184 GpuBufferHandle::from_resident_id(id).is_none(),
1185 "dropped handles must be removed from the resident registry"
1186 );
1187 }
1188
1189 #[test]
1190 fn poisoned_staging_pool_lock_recovers_without_aborting_dispatch_path() {
1191 let pool = StagingBufferPool::new();
1192 let poisoned = pool.clone();
1193 let _ = std::thread::spawn(move || {
1194 let _guard = poisoned.lock_inner();
1195 panic!("poison staging buffer pool");
1196 })
1197 .join();
1198
1199 std::panic::catch_unwind(|| {
1200 let _ = pool.stats();
1201 })
1202 .expect("Fix: poisoned staging pool must recover so GPU readback pooling does not abort");
1203 }
1204
1205 #[test]
1206 fn poisoned_bind_group_cache_lock_recovers_without_aborting_dispatch_path() {
1207 let cache = BindGroupCache::new();
1208 let poisoned = cache.clone();
1209 let _ = std::thread::spawn(move || {
1210 let _guard = poisoned.lock_cache();
1211 panic!("poison bind group cache");
1212 })
1213 .join();
1214
1215 std::panic::catch_unwind(|| {
1216 let _ = cache.stats();
1217 })
1218 .expect("Fix: poisoned bind-group cache must recover so GPU dispatch does not abort");
1219 }
1220
1221 #[test]
1222 fn bind_group_cache_lru_heap_stays_capacity_scale() {
1223 let arc = crate::runtime::cached_device()
1224 .expect("Fix: GPU device is required for bind-group cache test");
1225 let (device, _) = &*arc;
1226 let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1227 label: Some("vyre bind-group cache lru test layout"),
1228 entries: &[wgpu::BindGroupLayoutEntry {
1229 binding: 0,
1230 visibility: wgpu::ShaderStages::COMPUTE,
1231 ty: wgpu::BindingType::Buffer {
1232 ty: wgpu::BufferBindingType::Storage { read_only: true },
1233 has_dynamic_offset: false,
1234 min_binding_size: wgpu::BufferSize::new(4),
1235 },
1236 count: None,
1237 }],
1238 });
1239 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
1240 label: Some("vyre bind-group cache lru test buffer"),
1241 size: 4,
1242 usage: wgpu::BufferUsages::STORAGE,
1243 mapped_at_creation: false,
1244 });
1245 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1246 label: Some("vyre bind-group cache lru test bind group"),
1247 layout: &layout,
1248 entries: &[wgpu::BindGroupEntry {
1249 binding: 0,
1250 resource: buffer.as_entire_binding(),
1251 }],
1252 });
1253 let cache = BindGroupCache::with_cap(4);
1254
1255 for i in 0..64u64 {
1256 cache.insert_by_ids(1, &[i, 4], bind_group.clone());
1257 }
1258
1259 let inner = cache.lock_cache();
1260 assert_eq!(inner.entries.len(), 4);
1261 assert!(
1262 inner.lru.len() <= inner.entries.len().saturating_mul(4).max(8),
1263 "Fix: bind-group LRU heap must compact stale entries to cache-capacity scale"
1264 );
1265 }
1266
1267 #[test]
1286 fn bind_group_reuse_keys_on_buffer_identity_not_layout_alone() {
1287 let arc = crate::runtime::cached_device()
1288 .expect("Fix: GPU device is required for bind-group identity test");
1289 let (device, queue) = &*arc;
1290
1291 let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1292 label: Some("vyre bind-group identity test layout"),
1293 entries: &[wgpu::BindGroupLayoutEntry {
1294 binding: 0,
1295 visibility: wgpu::ShaderStages::COMPUTE,
1296 ty: wgpu::BindingType::Buffer {
1297 ty: wgpu::BufferBindingType::Storage { read_only: true },
1298 has_dynamic_offset: false,
1299 min_binding_size: wgpu::BufferSize::new(4),
1300 },
1301 count: None,
1302 }],
1303 });
1304
1305 let buffer_a =
1308 GpuBufferHandle::upload(device, queue, &[1u8; 4], wgpu::BufferUsages::STORAGE)
1309 .expect("Fix: upload of buffer a must succeed");
1310 let buffer_b =
1311 GpuBufferHandle::upload(device, queue, &[2u8; 4], wgpu::BufferUsages::STORAGE)
1312 .expect("Fix: upload of buffer b must succeed");
1313
1314 let cache = BindGroupCache::new();
1315 let layout_id = 1usize;
1316 let mut created = 0usize;
1317
1318 for handle in [
1321 &buffer_a, &buffer_a, &buffer_a, &buffer_b, &buffer_b, &buffer_b,
1322 ] {
1323 let slice = std::slice::from_ref(handle);
1324 cache.get_or_create(layout_id, slice, || {
1325 created += 1;
1326 device.create_bind_group(&wgpu::BindGroupDescriptor {
1327 label: Some("vyre bind-group identity test bind group"),
1328 layout: &layout,
1329 entries: &[wgpu::BindGroupEntry {
1330 binding: 0,
1331 resource: handle.buffer().as_entire_binding(),
1332 }],
1333 })
1334 });
1335 }
1336
1337 let stats = cache.stats();
1338 assert_eq!(
1339 created, 2,
1340 "six lookups over two distinct buffers must construct exactly two bind groups"
1341 );
1342 assert_eq!(
1343 stats.misses, 2,
1344 "each distinct buffer must miss exactly once. A layout-only key would \
1345 report 1 miss and share one bind group across both buffers, binding the \
1346 wrong resource on every dispatch against the second buffer."
1347 );
1348 assert_eq!(
1349 stats.hits, 4,
1350 "the two repeats of each buffer must reuse the cached bind group"
1351 );
1352 assert_eq!(stats.entries, 2, "one cached entry per distinct buffer");
1353
1354 let first = cache.get_or_create(layout_id, std::slice::from_ref(&buffer_a), || {
1357 panic!("Fix: buffer a is already cached and must not be rebuilt")
1358 });
1359 let again = cache.get_or_create(layout_id, std::slice::from_ref(&buffer_a), || {
1360 panic!("Fix: buffer a is already cached and must not be rebuilt")
1361 });
1362 assert!(
1363 Arc::ptr_eq(&first, &again),
1364 "repeated lookups for one buffer must hand back one bind-group instance"
1365 );
1366 }
1367}