Skip to main content

vyre_driver_wgpu/buffer/
handle.rs

1//! Public persistent GPU buffer handle.
2
3use 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
25/// Identity of the WGPU driver's resident-buffer namespace.
26///
27/// `NEXT_BUFFER_ID` and `RESIDENT_BUFFERS` are process-wide rather than per
28/// backend instance, so every live WGPU resident buffer shares one namespace
29/// and therefore one owner. Minting the owner here, next to the registry it
30/// describes, keeps that structural: a WGPU resident handle stays valid for as
31/// long as its buffer lives, and a handle minted by any other driver is
32/// refused instead of being resolved against an unrelated buffer of the same
33/// id.
34static RESIDENT_OWNER: LazyLock<Result<ResidentOwner, BackendError>> =
35    LazyLock::new(ResidentOwner::new);
36
37/// Owner of every WGPU resident handle in this process.
38fn 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
47/// Refuse a resident handle minted outside the WGPU resident namespace.
48///
49/// Resolving one anyway would look up a foreign id in this driver's registry,
50/// where the same number names an unrelated live buffer.
51pub(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/// Cheaply cloneable handle for a GPU-resident buffer.
66///
67/// The handle records the byte length originally requested by the caller,
68/// the backing allocation length, the logical element count, and the actual
69/// usage flags used to create the underlying `wgpu::Buffer`.
70#[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/// Snapshot of [`StagingBufferPool`] counters.
86#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
87pub struct StagingBufferPoolStats {
88    /// Number of fresh GPU buffer allocations.
89    pub allocations: usize,
90    /// Number of times a free buffer was reused.
91    pub hits: usize,
92}
93
94/// Device-local staging buffer pool keyed by `(size, usage)`.
95///
96/// Hot dispatch paths (e.g. [`GpuBufferHandle::readback_until`]) acquire
97/// readback staging buffers from this pool instead of creating a fresh
98/// `wgpu::Buffer` on every call. Each `(size, usage)` class is capped at
99/// [`STAGING_BUFFER_POOL_CLASS_CAP`] entries; evictions drop the
100/// least-recently-used buffer.
101#[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    /// Create an empty staging buffer pool.
130    #[must_use]
131    #[inline]
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// Return allocation and hit counters.
137    #[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    /// Acquire a staging buffer with exactly `size` bytes and `usage`.
147    ///
148    /// Reuses a free buffer when one is available; otherwise creates a fresh
149    /// GPU allocation and increments the allocation counter.
150    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    /// Release a staging buffer back to the pool.
175    ///
176    /// The buffer is pushed to the MRU position of its `(size, usage)` class.
177    /// If the class already holds 16 buffers, the LRU entry is dropped.
178    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    /// Upload `bytes` into a new GPU buffer.
191    ///
192    /// The created buffer always includes `COPY_DST` so the upload is legal.
193    ///
194    /// # Errors
195    ///
196    /// Returns a backend error when the requested allocation length cannot fit
197    /// `u64`.
198    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        // Fast path: create the buffer already mapped and memcpy host bytes
207        // DIRECTLY into its host-visible / BAR backing store, then unmap. This
208        // is ONE host copy with no wgpu-internal staging buffer and no GPU copy
209        // command, the slow `queue.write_buffer` path routes every large upload
210        // through wgpu's `StagingBelt`, which on Vulkan allocates + maps a fresh
211        // staging buffer per write (the ~90 MB/s catalog-upload bottleneck on
212        // the ~1 GB megakernel DFA catalog). `mapped_at_creation` works for ANY
213        // usage flags (it does not require MAP_WRITE) and is correct for ALL
214        // sizes, so it replaces the staged path unconditionally for non-empty
215        // uploads. Zero-length buffers cannot be mapped at creation, so they
216        // take the (no-op) `write_padded` path below: `aligned_len(0) == 0`,
217        // and wgpu rejects a 0-byte `mapped_at_creation` buffer.
218        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        // Zero-length upload: allocate a minimal buffer (wgpu forbids both a
245        // 0-byte allocation and a 0-byte mapped_at_creation buffer). `write_padded`
246        // is a no-op here; the handle reports a logical length of 0.
247        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    /// Allocate a GPU-resident buffer without uploading host contents.
265    ///
266    /// # Errors
267    ///
268    /// Returns a backend error when `len` cannot be represented as a valid
269    /// wgpu buffer size.
270    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    /// Download this GPU buffer into `out`.
298    ///
299    /// This is intended for terminal output and test assertions, not hot-loop
300    /// dispatch. The buffer must have `COPY_SRC` usage.
301    ///
302    /// # Errors
303    ///
304    /// Returns a backend error when the handle is not copy-readable or the GPU
305    /// mapping fails.
306    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    /// Download the first `len` logical bytes of this GPU buffer into `out`.
316    ///
317    /// Hot paths that publish a device-side count should read back only the
318    /// counted prefix instead of the whole capacity-sized buffer. The copy is
319    /// rounded up to wgpu's 4-byte copy granularity internally, then truncated
320    /// back to exactly `len` bytes before returning.
321    ///
322    /// # Errors
323    ///
324    /// Returns a backend error when the handle is not copy-readable, `len`
325    /// exceeds the logical buffer length, or the GPU mapping fails.
326    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    /// Download `len` logical bytes starting at `byte_offset` into `out`.
337    ///
338    /// The internal GPU copy is alignment-expanded when necessary, then the
339    /// returned host slice is trimmed back to exactly the requested range.
340    ///
341    /// # Errors
342    ///
343    /// Returns a backend error when the handle is not copy-readable, the range
344    /// exceeds the logical buffer length, or the GPU mapping fails.
345    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    /// Stable process-local handle id used for cache signatures.
531    #[must_use]
532    pub fn id(&self) -> u64 {
533        self.inner.id
534    }
535
536    /// Stable process-local identity for the backing GPU allocation.
537    ///
538    /// Unlike [`Self::id`], this survives pool release/reacquire cycles for the
539    /// same underlying `wgpu::Buffer`. Bind-group caches must key on this value
540    /// plus the logical binding range; otherwise hot dispatches miss every time
541    /// a pooled allocation is wrapped in a fresh handle.
542    #[must_use]
543    pub(crate) fn allocation_identity(&self) -> u64 {
544        pointer_identity_key(Arc::as_ptr(&self.inner.buffer))
545    }
546
547    /// Resolve a process-local resident buffer id back into a live GPU handle.
548    #[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    /// Resident handle naming this buffer, owner included.
564    ///
565    /// # Errors
566    ///
567    /// Returns a backend error when this process could not mint an identity
568    /// for the WGPU resident namespace.
569    pub fn resident_handle(&self) -> Result<ResidentHandle, BackendError> {
570        Ok(resident_owner()?.handle(self.inner.id))
571    }
572
573    /// Resolve a resident handle back into a live GPU handle.
574    ///
575    /// `Ok(None)` means the handle named a WGPU buffer that is no longer live.
576    ///
577    /// # Errors
578    ///
579    /// Returns a backend error when `handle` was minted by a different backend
580    /// instance, which is refused rather than resolved: its id would name an
581    /// unrelated buffer here.
582    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    /// Underlying `wgpu::Buffer`.
591    #[must_use]
592    pub fn buffer(&self) -> &wgpu::Buffer {
593        &self.inner.buffer
594    }
595
596    /// Clone the internal `Arc<wgpu::Buffer>`  -  cheap, reference-
597    /// count only. Used by the indirect dispatch path (C-B4) which
598    /// needs to stash the buffer alongside other args.
599    #[must_use]
600    pub fn buffer_arc(&self) -> Arc<wgpu::Buffer> {
601        Arc::clone(&self.inner.buffer)
602    }
603
604    /// Logical byte length requested by the caller.
605    #[must_use]
606    pub fn byte_len(&self) -> u64 {
607        self.inner.byte_len
608    }
609
610    /// Backing allocation length.
611    #[must_use]
612    pub fn allocation_len(&self) -> u64 {
613        self.inner.allocation_len
614    }
615
616    /// Logical element count. Byte buffers report one element per byte.
617    #[must_use]
618    pub fn element_count(&self) -> usize {
619        self.inner.element_count
620    }
621
622    /// Actual usage flags on the underlying GPU allocation.
623    #[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
703/// Default cap for the [`BindGroupCache`] LRU.
704const BIND_GROUP_CACHE_CAP: usize = 256;
705
706/// Inline storage for bind-group cache keys: typical shaders use few bindings;
707/// `SmallVec` avoids a heap `Vec` on most `get_or_create` calls.
708type BindGroupHandleKey = SmallVec<[u64; 16]>;
709
710/// Bounded LRU cache for wgpu bind groups, keyed by layout identity and
711/// the ordered set of buffer handles bound to that layout.
712///
713/// wgpu bind-group creation is non-trivial; this cache eliminates the
714/// redundant cost on repeated dispatches that share the same buffer
715/// handles.  Capped at 256 entries with LRU eviction to prevent
716/// descriptor-heap exhaustion on long-running servers.
717#[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    /// Create a bind-group cache with the default 256-entry cap.
883    #[must_use]
884    pub fn new() -> Self {
885        Self::with_cap(BIND_GROUP_CACHE_CAP)
886    }
887
888    /// Create with an explicit cap (used by tests and consumers that
889    /// want to size the LRU against known working-set bounds).
890    #[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    /// Return a cached bind group or create one with `factory`.
906    ///
907    /// `layout_id` must uniquely identify the `wgpu::BindGroupLayout`
908    /// (e.g. `Arc::as_ptr(layout).addr()`).
909    /// `handles` must be in the same order as the `wgpu::BindGroupEntry`
910    /// slice that the caller will pass to `create_bind_group` so that
911    /// identical handle sets map to the same cache key.
912    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    /// Return cache statistics for diagnostics and tests.
996    #[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/// Bind-group cache statistics for a compiled wgpu pipeline.
1008#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1009pub struct BindGroupCacheStats {
1010    /// Number of cached bind-group hits.
1011    pub hits: usize,
1012    /// Number of bind-group creations caused by cache misses.
1013    pub misses: usize,
1014    /// Number of cached bind-group entries evicted to honor the cap.
1015    pub evictions: usize,
1016    /// Current number of entries held.
1017    pub entries: usize,
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    /// StagingBufferPool must reuse buffers across readback calls so that 100
1025    /// readbacks of the same size allocate only ~1 buffer.
1026    #[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        // Create a small COPY_SRC buffer with known contents.
1033        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    /// Without a pool, readback must still work and always create fresh buffers.
1058    #[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    /// The mapped-at-creation upload fast path (the StagingBelt replacement)
1079    /// must produce a buffer whose contents byte-for-byte equal the input across
1080    /// every boundary class: sub-word, exactly-a-word, word+tail, and a large
1081    /// payload (the catalog-scale path). A regression here is a silent data
1082    /// corruption on the ~1 GB DFA-catalog upload.
1083    #[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        // 1,3 exercise the 4-byte tail; 4 is exactly aligned; 5 is word+tail;
1089        // 257 is multi-word+tail; 1 MiB + 3 is the large, tail-padded path that
1090        // used to route through the slow per-write StagingBelt.
1091        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    /// `write_padded_into_mapped` (the fast-path filler) must copy the logical
1108    /// bytes and zero the alignment tail deterministically, proved without a
1109    /// GPU so the contract holds on every host.
1110    #[test]
1111    fn write_padded_into_mapped_zeroes_the_tail() {
1112        // Allocation of 8 bytes, 5 logical: bytes 0..5 copied, 5..8 zeroed even
1113        // if the destination started with garbage.
1114        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        // A slice smaller than the data must fail closed, never truncate.
1121        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        // A handle minted outside the WGPU namespace carries an id that is
1128        // perfectly valid here, so resolving it by bare id would hand back an
1129        // unrelated live buffer. The boundary must refuse instead.
1130        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        // Phase 1: while the handle is alive, 8 concurrent readers
1161        // must always resolve the resident id. Join BEFORE the drop so
1162        // there is no readers-vs-drop race producing flaky panics.
1163        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        // Phase 2: dropping the handle must remove the id from the
1181        // registry so subsequent lookups return None.
1182        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    /// Pins that bind-group reuse is keyed on the concrete buffers bound, not
1268    /// on the binding layout alone, and counts the creations it saves.
1269    ///
1270    /// This exists because a `patterns::bind_group_reuse` module in
1271    /// vyre-emit-naga was removed after an audit found it grouped
1272    /// `KernelDescriptor`s for bind-group sharing by hashing only their
1273    /// binding LAYOUT (slot, dtype, count, memory class, visibility). A
1274    /// `wgpu::BindGroup` binds a layout PLUS concrete resources, so that rule
1275    /// declares two dispatches reading different buffers to be shareable.
1276    /// Acting on it would bind the wrong buffer and silently compute on stale
1277    /// data. This cache is the correct implementation and the only one that
1278    /// ships; the assertion below is the difference between the two rules.
1279    ///
1280    /// The counts are the contention-proof evidence that reuse actually fires:
1281    /// six lookups over two distinct buffers must create exactly two bind
1282    /// groups and reuse four. A layout-only key would create ONE and wrongly
1283    /// share it across both buffers, which `misses == 2` rejects. Dropping the
1284    /// buffer identity from the key regresses to exactly that bug.
1285    #[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        // Two DISTINCT buffers of identical size and usage. Same layout, same
1306        // byte length: a layout-only reuse rule cannot tell them apart.
1307        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        // A repeated-dispatch sequence: three dispatches against buffer a,
1319        // then three against buffer b, all through one layout.
1320        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        // The same buffer through the same layout must resolve to the very same
1355        // instance, which is what makes the four hits above a real saving.
1356        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}