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, Mutex, MutexGuard, OnceLock, Weak};
8use std::time::Instant;
9
10use dashmap::DashMap;
11use rustc_hash::{FxHashMap, FxHasher};
12use smallvec::SmallVec;
13use vyre_driver::BackendError;
14
15use super::pool::PoolReturn;
16
17static NEXT_BUFFER_ID: AtomicU64 = AtomicU64::new(1);
18static RESIDENT_BUFFERS: OnceLock<DashMap<u64, Weak<GpuBufferInner>>> = OnceLock::new();
19const STAGING_BUFFER_POOL_CLASS_CAP: usize = 16;
20
21fn resident_buffers() -> &'static DashMap<u64, Weak<GpuBufferInner>> {
22    RESIDENT_BUFFERS.get_or_init(DashMap::new)
23}
24
25fn pointer_identity_key<T>(ptr: *const T) -> u64 {
26    let mut hasher = FxHasher::default();
27    ptr.addr().hash(&mut hasher);
28    hasher.finish()
29}
30
31/// Cheaply cloneable handle for a GPU-resident buffer.
32///
33/// The handle records the byte length originally requested by the caller,
34/// the backing allocation length, the logical element count, and the actual
35/// usage flags used to create the underlying `wgpu::Buffer`.
36#[derive(Clone)]
37pub struct GpuBufferHandle {
38    inner: Arc<GpuBufferInner>,
39}
40
41struct GpuBufferInner {
42    id: u64,
43    buffer: Arc<wgpu::Buffer>,
44    byte_len: u64,
45    allocation_len: u64,
46    element_count: usize,
47    usage: wgpu::BufferUsages,
48    pool_return: Option<PoolReturn>,
49}
50
51/// Snapshot of [`StagingBufferPool`] counters.
52#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
53pub struct StagingBufferPoolStats {
54    /// Number of fresh GPU buffer allocations.
55    pub allocations: usize,
56    /// Number of times a free buffer was reused.
57    pub hits: usize,
58}
59
60/// Device-local staging buffer pool keyed by `(size, usage)`.
61///
62/// Hot dispatch paths (e.g. [`GpuBufferHandle::readback_until`]) acquire
63/// readback staging buffers from this pool instead of creating a fresh
64/// `wgpu::Buffer` on every call. Each `(size, usage)` class is capped at
65/// [`STAGING_BUFFER_POOL_CLASS_CAP`] entries; evictions drop the
66/// least-recently-used buffer.
67#[derive(Clone, Default)]
68pub struct StagingBufferPool {
69    inner: Arc<Mutex<StagingBufferPoolInner>>,
70}
71
72impl std::fmt::Debug for StagingBufferPool {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("StagingBufferPool").finish_non_exhaustive()
75    }
76}
77
78#[derive(Default)]
79struct StagingBufferPoolInner {
80    free: FxHashMap<(u64, u32), SmallVec<[wgpu::Buffer; STAGING_BUFFER_POOL_CLASS_CAP]>>,
81    allocations: usize,
82    hits: usize,
83}
84
85impl StagingBufferPool {
86    fn lock_inner(&self) -> MutexGuard<'_, StagingBufferPoolInner> {
87        self.inner.lock().unwrap_or_else(|error| {
88            tracing::error!(
89                "Vyre WGPU staging buffer pool lock was poisoned: {error}. Fix: discard the pool after a panic; continuing with recovered state."
90            );
91            error.into_inner()
92        })
93    }
94
95    /// Create an empty staging buffer pool.
96    #[must_use]
97    #[inline]
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Return allocation and hit counters.
103    #[must_use]
104    pub fn stats(&self) -> StagingBufferPoolStats {
105        let inner = self.lock_inner();
106        StagingBufferPoolStats {
107            allocations: inner.allocations,
108            hits: inner.hits,
109        }
110    }
111
112    /// Acquire a staging buffer with exactly `size` bytes and `usage`.
113    ///
114    /// Reuses a free buffer when one is available; otherwise creates a fresh
115    /// GPU allocation and increments the allocation counter.
116    pub fn acquire(
117        &self,
118        device: &wgpu::Device,
119        size: u64,
120        usage: wgpu::BufferUsages,
121    ) -> wgpu::Buffer {
122        let key = (size, usage.bits());
123        let mut inner = self.lock_inner();
124        if let Some(buffers) = inner.free.get_mut(&key) {
125            if let Some(buffer) = buffers.pop() {
126                inner.hits += 1;
127                return buffer;
128            }
129        }
130        inner.allocations += 1;
131        drop(inner);
132        device.create_buffer(&wgpu::BufferDescriptor {
133            label: Some("vyre staging readback"),
134            size,
135            usage,
136            mapped_at_creation: false,
137        })
138    }
139
140    /// Release a staging buffer back to the pool.
141    ///
142    /// The buffer is pushed to the MRU position of its `(size, usage)` class.
143    /// If the class already holds 16 buffers, the LRU entry is dropped.
144    pub fn release(&self, buffer: wgpu::Buffer, size: u64, usage: wgpu::BufferUsages) {
145        let key = (size, usage.bits());
146        let mut inner = self.lock_inner();
147        let buffers = inner.free.entry(key).or_insert_with(SmallVec::new);
148        if buffers.len() == STAGING_BUFFER_POOL_CLASS_CAP {
149            buffers.remove(0);
150        }
151        buffers.push(buffer);
152    }
153}
154
155impl GpuBufferHandle {
156    /// Upload `bytes` into a new GPU buffer.
157    ///
158    /// The created buffer always includes `COPY_DST` so the upload is legal.
159    ///
160    /// # Errors
161    ///
162    /// Returns a backend error when the requested allocation length cannot fit
163    /// `u64`.
164    pub fn upload(
165        device: &wgpu::Device,
166        queue: &wgpu::Queue,
167        bytes: &[u8],
168        usage: wgpu::BufferUsages,
169    ) -> Result<Self, BackendError> {
170        let allocation_len = aligned_len(bytes.len())?;
171        let final_usage = usage | wgpu::BufferUsages::COPY_DST;
172        // Fast path: create the buffer already mapped and memcpy host bytes
173        // DIRECTLY into its host-visible / BAR backing store, then unmap. This
174        // is ONE host copy with no wgpu-internal staging buffer and no GPU copy
175        // command, the slow `queue.write_buffer` path routes every large upload
176        // through wgpu's `StagingBelt`, which on Vulkan allocates + maps a fresh
177        // staging buffer per write (the ~90 MB/s catalog-upload bottleneck on
178        // the ~1 GB megakernel DFA catalog). `mapped_at_creation` works for ANY
179        // usage flags (it does not require MAP_WRITE) and is correct for ALL
180        // sizes, so it replaces the staged path unconditionally for non-empty
181        // uploads. Zero-length buffers cannot be mapped at creation, so they
182        // take the (no-op) `write_padded` path below: `aligned_len(0) == 0`,
183        // and wgpu rejects a 0-byte `mapped_at_creation` buffer.
184        if allocation_len > 0 {
185            let buffer = device.create_buffer(&wgpu::BufferDescriptor {
186                label: Some("vyre persistent upload"),
187                size: allocation_len,
188                usage: final_usage,
189                mapped_at_creation: true,
190            });
191            {
192                let mut mapped = buffer.slice(..).get_mapped_range_mut();
193                crate::padded_upload::write_padded_into_mapped(&mut mapped, bytes)?;
194            }
195            buffer.unmap();
196            let logical_len = u64::try_from(bytes.len()).map_err(|source| {
197                BackendError::new(format!(
198                    "GPU upload logical byte length cannot fit u64: {source}. Fix: split the dispatch input."
199                ))
200            })?;
201            return Ok(Self::from_parts(
202                Arc::new(buffer),
203                logical_len,
204                allocation_len,
205                bytes.len(),
206                final_usage,
207                None,
208            ));
209        }
210        // Zero-length upload: allocate a minimal buffer (wgpu forbids both a
211        // 0-byte allocation and a 0-byte mapped_at_creation buffer). `write_padded`
212        // is a no-op here; the handle reports a logical length of 0.
213        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
214            label: Some("vyre persistent upload"),
215            size: allocation_len,
216            usage: final_usage,
217            mapped_at_creation: false,
218        });
219        write_padded(queue, &buffer, bytes, allocation_len)?;
220        Ok(Self::from_parts(
221            Arc::new(buffer),
222            0,
223            allocation_len,
224            bytes.len(),
225            final_usage,
226            None,
227        ))
228    }
229
230    /// Allocate a GPU-resident buffer without uploading host contents.
231    ///
232    /// # Errors
233    ///
234    /// Returns a backend error when `len` cannot be represented as a valid
235    /// wgpu buffer size.
236    pub fn alloc(
237        device: &wgpu::Device,
238        len: u64,
239        usage: wgpu::BufferUsages,
240    ) -> Result<Self, BackendError> {
241        let allocation_len = aligned_len_u64(len, "persistent GPU allocation length")?;
242        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
243            label: Some("vyre persistent alloc"),
244            size: allocation_len,
245            usage,
246            mapped_at_creation: false,
247        });
248        let host_len = usize::try_from(len).map_err(|error| {
249            BackendError::new(format!(
250                "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."
251            ))
252        })?;
253        Ok(Self::from_parts(
254            Arc::new(buffer),
255            len,
256            allocation_len,
257            host_len,
258            usage,
259            None,
260        ))
261    }
262
263    /// Download this GPU buffer into `out`.
264    ///
265    /// This is intended for terminal output and test assertions, not hot-loop
266    /// dispatch. The buffer must have `COPY_SRC` usage.
267    ///
268    /// # Errors
269    ///
270    /// Returns a backend error when the handle is not copy-readable or the GPU
271    /// mapping fails.
272    pub fn readback(
273        &self,
274        device: &wgpu::Device,
275        queue: &wgpu::Queue,
276        out: &mut Vec<u8>,
277    ) -> Result<(), BackendError> {
278        self.readback_until(device, None, queue, out, None)
279    }
280
281    /// Download the first `len` logical bytes of this GPU buffer into `out`.
282    ///
283    /// Hot paths that publish a device-side count should read back only the
284    /// counted prefix instead of the whole capacity-sized buffer. The copy is
285    /// rounded up to wgpu's 4-byte copy granularity internally, then truncated
286    /// back to exactly `len` bytes before returning.
287    ///
288    /// # Errors
289    ///
290    /// Returns a backend error when the handle is not copy-readable, `len`
291    /// exceeds the logical buffer length, or the GPU mapping fails.
292    pub fn readback_prefix(
293        &self,
294        device: &wgpu::Device,
295        queue: &wgpu::Queue,
296        len: u64,
297        out: &mut Vec<u8>,
298    ) -> Result<(), BackendError> {
299        self.readback_prefix_until(device, None, queue, len, out, None)
300    }
301
302    /// Download `len` logical bytes starting at `byte_offset` into `out`.
303    ///
304    /// The internal GPU copy is alignment-expanded when necessary, then the
305    /// returned host slice is trimmed back to exactly the requested range.
306    ///
307    /// # Errors
308    ///
309    /// Returns a backend error when the handle is not copy-readable, the range
310    /// exceeds the logical buffer length, or the GPU mapping fails.
311    pub fn readback_range(
312        &self,
313        device: &wgpu::Device,
314        queue: &wgpu::Queue,
315        byte_offset: u64,
316        len: u64,
317        out: &mut Vec<u8>,
318    ) -> Result<(), BackendError> {
319        self.readback_range_until(device, None, queue, byte_offset, len, out, None)
320    }
321
322    pub(crate) fn readback_until(
323        &self,
324        device: &wgpu::Device,
325        pool: Option<&StagingBufferPool>,
326        queue: &wgpu::Queue,
327        out: &mut Vec<u8>,
328        deadline: Option<Instant>,
329    ) -> Result<(), BackendError> {
330        self.readback_prefix_until(device, pool, queue, self.byte_len(), out, deadline)
331    }
332
333    pub(crate) fn readback_prefix_until(
334        &self,
335        device: &wgpu::Device,
336        pool: Option<&StagingBufferPool>,
337        queue: &wgpu::Queue,
338        len: u64,
339        out: &mut Vec<u8>,
340        deadline: Option<Instant>,
341    ) -> Result<(), BackendError> {
342        self.readback_range_until(device, pool, queue, 0, len, out, deadline)
343    }
344
345    pub(crate) fn readback_range_until(
346        &self,
347        device: &wgpu::Device,
348        pool: Option<&StagingBufferPool>,
349        queue: &wgpu::Queue,
350        byte_offset: u64,
351        len: u64,
352        out: &mut Vec<u8>,
353        deadline: Option<Instant>,
354    ) -> Result<(), BackendError> {
355        if !self.usage().contains(wgpu::BufferUsages::COPY_SRC) {
356            return Err(BackendError::new(
357                "GpuBufferHandle readback requires COPY_SRC usage. Fix: allocate terminal-output buffers with COPY_SRC.",
358            ));
359        }
360        let logical_end = byte_offset.checked_add(len).ok_or_else(|| {
361            BackendError::new(format!(
362                "GpuBufferHandle range readback overflows u64 at offset {byte_offset} len {len}. Fix: split the readback range before dispatch."
363            ))
364        })?;
365        if logical_end > self.byte_len() {
366            return Err(BackendError::new(format!(
367                "GpuBufferHandle range readback requested bytes [{byte_offset}..{logical_end}) from a {} byte buffer. Fix: clamp the requested range to the device-published count.",
368                self.byte_len()
369            )));
370        }
371        if len == 0 {
372            out.clear();
373            return Ok(());
374        }
375        let copy_start = byte_offset & !3;
376        let trim_start = byte_offset - copy_start;
377        let visible_copy_len = trim_start.checked_add(len).ok_or_else(|| {
378            BackendError::new(format!(
379                "GpuBufferHandle range readback copy length overflows u64 at trim {trim_start} len {len}. Fix: split the readback range before dispatch."
380            ))
381        })?;
382        let read_len = aligned_len_u64(visible_copy_len, "GPU readback visible copy length")?;
383        let copy_end = copy_start.checked_add(read_len).ok_or_else(|| {
384            BackendError::new(format!(
385                "GpuBufferHandle range readback aligned copy overflows u64 at start {copy_start} len {read_len}. Fix: split the readback range before dispatch."
386            ))
387        })?;
388        if copy_end > self.inner.allocation_len {
389            return Err(BackendError::new(format!(
390                "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.",
391                self.inner.allocation_len
392            )));
393        }
394        let readback_usage = wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ;
395        let readback = if let Some(pool) = pool {
396            pool.acquire(device, read_len, readback_usage)
397        } else {
398            device.create_buffer(&wgpu::BufferDescriptor {
399                label: Some("vyre persistent handle readback"),
400                size: read_len,
401                usage: readback_usage,
402                mapped_at_creation: false,
403            })
404        };
405        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
406            label: Some("vyre persistent handle readback encoder"),
407        });
408        encoder.copy_buffer_to_buffer(self.buffer(), copy_start, &readback, 0, read_len);
409        let submission = queue.submit(std::iter::once(encoder.finish()));
410        let slice = readback.slice(0..read_len);
411        let (sender, receiver) = std::sync::mpsc::channel();
412        slice.map_async(wgpu::MapMode::Read, move |result| {
413            if let Err(error) = sender.send(result) {
414                tracing::error!(
415                    ?error,
416                    "persistent buffer readback map_async result was lost because the receiver dropped"
417                );
418            }
419        });
420        let mapping = if let Some(deadline) = deadline {
421            let mut backoff = crate::wait_backoff::AdaptiveWaitBackoff::from_micros(64, 2, 50, 5);
422            loop {
423                crate::runtime::device::poll_device_once(device)?;
424                match receiver.try_recv() {
425                    Ok(result) => break result,
426                    Err(std::sync::mpsc::TryRecvError::Empty) => {}
427                    Err(std::sync::mpsc::TryRecvError::Disconnected) => {
428                        return Err(BackendError::new(
429                            "persistent buffer readback channel closed before completion. Fix: keep the GPU device alive until readback completes.",
430                        ));
431                    }
432                }
433                let now = Instant::now();
434                if now >= deadline {
435                    return Err(BackendError::new(
436                        "dispatch cancelled after DispatchConfig.timeout before readback completed. Fix: raise DispatchConfig.timeout or split the program into smaller chunks.",
437                    ));
438                }
439                backoff.idle_for(deadline.saturating_duration_since(now));
440            }
441        } else {
442            crate::runtime::device::poll_device_wait_for(device, submission)?;
443            receiver
444                .recv_timeout(std::time::Duration::from_secs(30))
445                .map_err(|source| {
446                    BackendError::new(format!(
447                        "persistent buffer readback callback did not complete after submission wait: {source}. Fix: keep the GPU device alive and inspect driver callback progress."
448                    ))
449                })?
450        };
451        let result = mapping.map_err(|source| {
452            BackendError::new(format!(
453                "persistent buffer readback mapping failed: {source:?}. Fix: use COPY_SRC handles and MAP_READ staging buffers."
454            ))
455        });
456        result?;
457        let mapped = slice.get_mapped_range();
458        let visible_len = usize::try_from(len).map_err(|source| {
459            BackendError::new(format!(
460                "persistent buffer prefix length {len} cannot fit usize: {source}. Fix: split the buffer before readback.",
461            ))
462        })?;
463        let trim_start = usize::try_from(trim_start).map_err(|source| {
464            BackendError::new(format!(
465                "persistent buffer range trim offset {trim_start} cannot fit usize: {source}. Fix: split the buffer before readback.",
466            ))
467        })?;
468        let trim_end = trim_start.checked_add(visible_len).ok_or_else(|| {
469            BackendError::new(format!(
470                "persistent buffer range trim overflows usize at offset {trim_start} len {visible_len}. Fix: split the buffer before readback."
471            ))
472        })?;
473        let visible = &mapped[trim_start..trim_end];
474        if out.len() == visible_len {
475            out.copy_from_slice(visible);
476        } else {
477            if visible_len > out.capacity() {
478                let additional = visible_len - out.capacity();
479                out.try_reserve_exact(additional).map_err(|source| {
480                    BackendError::new(format!(
481                        "persistent buffer readback could not reserve {visible_len} output bytes exactly: {source}. Fix: lower max_output_bytes or stream readback in smaller shards."
482                    ))
483                })?;
484            }
485            out.clear();
486            out.extend_from_slice(visible);
487        }
488        drop(mapped);
489        readback.unmap();
490        if let Some(pool) = pool {
491            pool.release(readback, read_len, readback_usage);
492        }
493        Ok(())
494    }
495
496    /// Stable process-local handle id used for cache signatures.
497    #[must_use]
498    pub fn id(&self) -> u64 {
499        self.inner.id
500    }
501
502    /// Stable process-local identity for the backing GPU allocation.
503    ///
504    /// Unlike [`Self::id`], this survives pool release/reacquire cycles for the
505    /// same underlying `wgpu::Buffer`. Bind-group caches must key on this value
506    /// plus the logical binding range; otherwise hot dispatches miss every time
507    /// a pooled allocation is wrapped in a fresh handle.
508    #[must_use]
509    pub(crate) fn allocation_identity(&self) -> u64 {
510        pointer_identity_key(Arc::as_ptr(&self.inner.buffer))
511    }
512
513    /// Resolve a process-local resident buffer id back into a live GPU handle.
514    #[must_use]
515    pub fn from_resident_id(id: u64) -> Option<Self> {
516        let registry = resident_buffers();
517        let entry = registry.get(&id)?;
518        let upgraded = entry.value().upgrade();
519        drop(entry);
520        match upgraded {
521            Some(inner) => Some(Self { inner }),
522            None => {
523                registry.remove(&id);
524                None
525            }
526        }
527    }
528
529    /// Underlying `wgpu::Buffer`.
530    #[must_use]
531    pub fn buffer(&self) -> &wgpu::Buffer {
532        &self.inner.buffer
533    }
534
535    /// Clone the internal `Arc<wgpu::Buffer>`  -  cheap, reference-
536    /// count only. Used by the indirect dispatch path (C-B4) which
537    /// needs to stash the buffer alongside other args.
538    #[must_use]
539    pub fn buffer_arc(&self) -> Arc<wgpu::Buffer> {
540        Arc::clone(&self.inner.buffer)
541    }
542
543    /// Logical byte length requested by the caller.
544    #[must_use]
545    pub fn byte_len(&self) -> u64 {
546        self.inner.byte_len
547    }
548
549    /// Backing allocation length.
550    #[must_use]
551    pub fn allocation_len(&self) -> u64 {
552        self.inner.allocation_len
553    }
554
555    /// Logical element count. Byte buffers report one element per byte.
556    #[must_use]
557    pub fn element_count(&self) -> usize {
558        self.inner.element_count
559    }
560
561    /// Actual usage flags on the underlying GPU allocation.
562    #[must_use]
563    pub fn usage(&self) -> wgpu::BufferUsages {
564        self.inner.usage
565    }
566
567    pub(crate) fn from_parts(
568        buffer: Arc<wgpu::Buffer>,
569        byte_len: u64,
570        allocation_len: u64,
571        element_count: usize,
572        usage: wgpu::BufferUsages,
573        pool_return: Option<PoolReturn>,
574    ) -> Self {
575        let inner = Arc::new(GpuBufferInner {
576            id: NEXT_BUFFER_ID.fetch_add(1, Ordering::Relaxed),
577            buffer,
578            byte_len,
579            allocation_len,
580            element_count,
581            usage,
582            pool_return,
583        });
584        resident_buffers().insert(inner.id, Arc::downgrade(&inner));
585        Self { inner }
586    }
587}
588
589impl std::fmt::Debug for GpuBufferHandle {
590    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
591        formatter
592            .debug_struct("GpuBufferHandle")
593            .field("id", &self.id())
594            .field("byte_len", &self.byte_len())
595            .field("allocation_len", &self.allocation_len())
596            .field("element_count", &self.element_count())
597            .field("usage", &self.usage())
598            .finish()
599    }
600}
601
602impl Drop for GpuBufferInner {
603    fn drop(&mut self) {
604        resident_buffers().remove(&self.id);
605        if let Some(pool_return) = self.pool_return.take() {
606            pool_return.release(
607                Arc::clone(&self.buffer),
608                self.byte_len,
609                self.allocation_len,
610                self.usage,
611            );
612        }
613    }
614}
615
616pub(crate) fn aligned_len(len: usize) -> Result<u64, BackendError> {
617    let padded = aligned_len_usize(len, "GPU buffer length")?;
618    u64::try_from(padded).map_err(|source| {
619        BackendError::new(format!(
620            "GPU buffer length {padded} cannot fit u64: {source}. Fix: split the dispatch input."
621        ))
622    })
623}
624
625fn aligned_len_u64(len: u64, label: &'static str) -> Result<u64, BackendError> {
626    crate::numeric::WGPU_NUMERIC.align_up_u64(len, 4, 4, label)
627}
628
629fn aligned_len_usize(len: usize, label: &'static str) -> Result<usize, BackendError> {
630    crate::numeric::WGPU_NUMERIC.align_up_usize(len, 4, 4, label)
631}
632
633pub(crate) fn write_padded(
634    queue: &wgpu::Queue,
635    buffer: &wgpu::Buffer,
636    bytes: &[u8],
637    allocation_len: u64,
638) -> Result<(), BackendError> {
639    crate::padded_upload::write_padded_and_zero_fill(queue, buffer, bytes, allocation_len)
640}
641
642/// Default cap for the [`BindGroupCache`] LRU.
643const BIND_GROUP_CACHE_CAP: usize = 256;
644
645/// Inline storage for bind-group cache keys: typical shaders use few bindings;
646/// `SmallVec` avoids a heap `Vec` on most `get_or_create` calls.
647type BindGroupHandleKey = SmallVec<[u64; 16]>;
648
649/// Bounded LRU cache for wgpu bind groups, keyed by layout identity and
650/// the ordered set of buffer handles bound to that layout.
651///
652/// wgpu bind-group creation is non-trivial; this cache eliminates the
653/// redundant cost on repeated dispatches that share the same buffer
654/// handles.  Capped at 256 entries with LRU eviction to prevent
655/// descriptor-heap exhaustion on long-running servers.
656#[derive(Clone)]
657pub struct BindGroupCache {
658    cache: Arc<Mutex<BindGroupCacheInner>>,
659    hits: Arc<AtomicUsize>,
660    misses: Arc<AtomicUsize>,
661    evictions: Arc<AtomicUsize>,
662}
663
664struct BindGroupCacheInner {
665    entries: FxHashMap<BindGroupCacheKey, BindGroupCacheEntry>,
666    lru: BinaryHeap<Reverse<BindGroupLruEntry>>,
667    cap: usize,
668    next_generation: u64,
669}
670
671struct BindGroupCacheEntry {
672    bind_group: Arc<wgpu::BindGroup>,
673    last_seen: u64,
674}
675
676#[derive(Clone, Debug, Eq, PartialEq)]
677struct BindGroupLruEntry {
678    last_seen: u64,
679    key: BindGroupCacheKey,
680}
681
682impl Ord for BindGroupLruEntry {
683    fn cmp(&self, other: &Self) -> CmpOrdering {
684        self.last_seen
685            .cmp(&other.last_seen)
686            .then_with(|| self.key.cmp(&other.key))
687    }
688}
689
690impl PartialOrd for BindGroupLruEntry {
691    fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
692        Some(self.cmp(other))
693    }
694}
695
696fn push_bind_group_handle_key(key: &mut BindGroupHandleKey, handle: &GpuBufferHandle) -> bool {
697    key.push(handle.allocation_identity());
698    let Ok(aligned_len) = aligned_len_u64(handle.byte_len(), "bind-group handle key byte length")
699    else {
700        key.pop();
701        return false;
702    };
703    key.push(aligned_len);
704    true
705}
706
707#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
708struct BindGroupCacheKey {
709    layout_id: usize,
710    handles: BindGroupHandleKey,
711}
712
713impl std::fmt::Debug for BindGroupCache {
714    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715        f.debug_struct("BindGroupCache")
716            .field("hits", &self.hits.load(Ordering::Relaxed))
717            .field("misses", &self.misses.load(Ordering::Relaxed))
718            .field("evictions", &self.evictions.load(Ordering::Relaxed))
719            .field("entries", &self.lock_cache().entries.len())
720            .finish_non_exhaustive()
721    }
722}
723
724impl Default for BindGroupCache {
725    fn default() -> Self {
726        Self::new()
727    }
728}
729
730impl BindGroupCacheInner {
731    fn next_lru_generation(&mut self) -> u64 {
732        let generation = self.next_generation;
733        self.next_generation = self.next_generation.wrapping_add(1);
734        generation
735    }
736
737    fn touch_existing(&mut self, key: &BindGroupCacheKey) -> Option<Arc<wgpu::BindGroup>> {
738        let generation = self.next_lru_generation();
739        let bind_group = {
740            let entry = self.entries.get_mut(key)?;
741            entry.last_seen = generation;
742            Arc::clone(&entry.bind_group)
743        };
744        self.lru.push(Reverse(BindGroupLruEntry {
745            last_seen: generation,
746            key: key.clone(),
747        }));
748        self.compact_lru_if_needed();
749        Some(bind_group)
750    }
751
752    fn insert_entry(&mut self, key: BindGroupCacheKey, bind_group: Arc<wgpu::BindGroup>) {
753        let generation = self.next_lru_generation();
754        self.entries.insert(
755            key.clone(),
756            BindGroupCacheEntry {
757                bind_group,
758                last_seen: generation,
759            },
760        );
761        self.lru.push(Reverse(BindGroupLruEntry {
762            last_seen: generation,
763            key,
764        }));
765        self.compact_lru_if_needed();
766    }
767
768    fn evict_to_cap(&mut self, mut on_evict: impl FnMut()) {
769        while self.entries.len() > self.cap {
770            let Some(key) = self.pop_lru_key() else { break };
771            if self.entries.remove(&key).is_some() {
772                on_evict();
773            }
774        }
775    }
776
777    fn pop_lru_key(&mut self) -> Option<BindGroupCacheKey> {
778        while let Some(Reverse(entry)) = self.lru.pop() {
779            if self
780                .entries
781                .get(&entry.key)
782                .is_some_and(|current| current.last_seen == entry.last_seen)
783            {
784                return Some(entry.key);
785            }
786        }
787        None
788    }
789
790    fn compact_lru_if_needed(&mut self) {
791        let live = self.entries.len();
792        if let Some(limit) = stale_lru_limit(live) {
793            if self.lru.len() <= limit {
794                return;
795            }
796        }
797        self.lru.clear();
798        self.lru.extend(self.entries.iter().map(|(key, entry)| {
799            Reverse(BindGroupLruEntry {
800                last_seen: entry.last_seen,
801                key: key.clone(),
802            })
803        }));
804    }
805}
806
807fn stale_lru_limit(live: usize) -> Option<usize> {
808    live.checked_mul(4).map(|limit| limit.max(8))
809}
810
811impl BindGroupCache {
812    fn lock_cache(&self) -> MutexGuard<'_, BindGroupCacheInner> {
813        self.cache.lock().unwrap_or_else(|error| {
814            tracing::error!(
815                "Vyre WGPU bind-group cache lock was poisoned: {error}. Fix: discard the cache after a panic; continuing with recovered state."
816            );
817            error.into_inner()
818        })
819    }
820
821    /// Create a bind-group cache with the default 256-entry cap.
822    #[must_use]
823    pub fn new() -> Self {
824        Self::with_cap(BIND_GROUP_CACHE_CAP)
825    }
826
827    /// Create with an explicit cap (used by tests and consumers that
828    /// want to size the LRU against known working-set bounds).
829    #[must_use]
830    pub fn with_cap(cap: usize) -> Self {
831        Self {
832            cache: Arc::new(Mutex::new(BindGroupCacheInner {
833                entries: FxHashMap::default(),
834                lru: BinaryHeap::new(),
835                cap: cap.max(1),
836                next_generation: 0,
837            })),
838            hits: Arc::new(AtomicUsize::new(0)),
839            misses: Arc::new(AtomicUsize::new(0)),
840            evictions: Arc::new(AtomicUsize::new(0)),
841        }
842    }
843
844    /// Return a cached bind group or create one with `factory`.
845    ///
846    /// `layout_id` must uniquely identify the `wgpu::BindGroupLayout`
847    /// (e.g. `Arc::as_ptr(layout).addr()`).
848    /// `handles` must be in the same order as the `wgpu::BindGroupEntry`
849    /// slice that the caller will pass to `create_bind_group` so that
850    /// identical handle sets map to the same cache key.
851    pub fn get_or_create(
852        &self,
853        layout_id: usize,
854        handles: &[GpuBufferHandle],
855        factory: impl FnOnce() -> wgpu::BindGroup,
856    ) -> Arc<wgpu::BindGroup> {
857        let Some(key_part_count) = handles.len().checked_mul(2) else {
858            self.misses.fetch_add(1, Ordering::Relaxed);
859            return Arc::new(factory());
860        };
861        let mut key_parts = SmallVec::with_capacity(key_part_count);
862        for handle in handles {
863            if !push_bind_group_handle_key(&mut key_parts, handle) {
864                self.misses.fetch_add(1, Ordering::Relaxed);
865                return Arc::new(factory());
866            }
867        }
868        self.get_or_create_by_ids(layout_id, key_parts, factory)
869    }
870
871    pub(crate) fn get_or_create_by_ids(
872        &self,
873        layout_id: usize,
874        handles: SmallVec<[u64; 16]>,
875        factory: impl FnOnce() -> wgpu::BindGroup,
876    ) -> Arc<wgpu::BindGroup> {
877        let key = BindGroupCacheKey { layout_id, handles };
878        {
879            let mut cache = self.lock_cache();
880            if let Some(existing) = cache.touch_existing(&key) {
881                self.hits.fetch_add(1, Ordering::Relaxed);
882                return existing;
883            }
884        }
885        let bg = Arc::new(factory());
886        let mut cache = self.lock_cache();
887        cache.insert_entry(key, Arc::clone(&bg));
888        cache.evict_to_cap(|| {
889            self.evictions.fetch_add(1, Ordering::Relaxed);
890        });
891        self.misses.fetch_add(1, Ordering::Relaxed);
892        bg
893    }
894
895    pub(crate) fn get_by_ids(
896        &self,
897        layout_id: usize,
898        handles: &[u64],
899    ) -> Option<Arc<wgpu::BindGroup>> {
900        let key = BindGroupCacheKey {
901            layout_id,
902            handles: SmallVec::from_slice(handles),
903        };
904        let mut cache = self.lock_cache();
905        let existing = cache.touch_existing(&key)?;
906        self.hits.fetch_add(1, Ordering::Relaxed);
907        Some(existing)
908    }
909
910    pub(crate) fn insert_by_ids(
911        &self,
912        layout_id: usize,
913        handles: &[u64],
914        bind_group: wgpu::BindGroup,
915    ) -> Arc<wgpu::BindGroup> {
916        let key = BindGroupCacheKey {
917            layout_id,
918            handles: SmallVec::from_slice(handles),
919        };
920        let mut cache = self.lock_cache();
921        if let Some(existing) = cache.touch_existing(&key) {
922            self.hits.fetch_add(1, Ordering::Relaxed);
923            return existing;
924        }
925        let bg = Arc::new(bind_group);
926        cache.insert_entry(key, Arc::clone(&bg));
927        cache.evict_to_cap(|| {
928            self.evictions.fetch_add(1, Ordering::Relaxed);
929        });
930        self.misses.fetch_add(1, Ordering::Relaxed);
931        bg
932    }
933
934    /// Return cache statistics for diagnostics and tests.
935    #[must_use]
936    pub fn stats(&self) -> BindGroupCacheStats {
937        BindGroupCacheStats {
938            hits: self.hits.load(Ordering::Relaxed),
939            misses: self.misses.load(Ordering::Relaxed),
940            evictions: self.evictions.load(Ordering::Relaxed),
941            entries: self.lock_cache().entries.len(),
942        }
943    }
944}
945
946/// Bind-group cache statistics for a compiled wgpu pipeline.
947#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
948pub struct BindGroupCacheStats {
949    /// Number of cached bind-group hits.
950    pub hits: usize,
951    /// Number of bind-group creations caused by cache misses.
952    pub misses: usize,
953    /// Number of cached bind-group entries evicted to honor the cap.
954    pub evictions: usize,
955    /// Current number of entries held.
956    pub entries: usize,
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    /// StagingBufferPool must reuse buffers across readback calls so that 100
964    /// readbacks of the same size allocate only ~1 buffer.
965    #[test]
966    fn staging_pool_reuses_buffers_on_hot_readback_loop() {
967        let arc = crate::runtime::cached_device()
968            .expect("Fix: GPU device is required for staging pool test");
969        let (device, queue) = &*arc;
970
971        // Create a small COPY_SRC buffer with known contents.
972        let contents: Vec<u8> = vec![0xAB; 64];
973        let handle =
974            GpuBufferHandle::upload(device, queue, &contents, wgpu::BufferUsages::COPY_SRC)
975                .expect("Fix: upload should succeed");
976
977        let pool = StagingBufferPool::new();
978
979        for _ in 0..100 {
980            let mut out = Vec::new();
981            handle
982                .readback_until(device, Some(&pool), queue, &mut out, None)
983                .expect("Fix: pooled readback should succeed");
984            assert_eq!(out, contents, "readback bytes must match uploaded bytes");
985        }
986
987        let stats = pool.stats();
988        assert!(
989            stats.allocations <= 2,
990            "hot loop of 100 identical readbacks should allocate at most 2 staging buffers, got {} allocations and {} hits",
991            stats.allocations,
992            stats.hits
993        );
994    }
995
996    /// Without a pool, readback must still work and always create fresh buffers.
997    #[test]
998    fn readback_without_pool_always_allocates() {
999        let arc = crate::runtime::cached_device()
1000            .expect("Fix: GPU device is required for readback regression test");
1001        let (device, queue) = &*arc;
1002
1003        let contents: Vec<u8> = vec![0xCD; 32];
1004        let handle =
1005            GpuBufferHandle::upload(device, queue, &contents, wgpu::BufferUsages::COPY_SRC)
1006                .expect("Fix: upload should succeed");
1007
1008        for _ in 0..5 {
1009            let mut out = Vec::new();
1010            handle
1011                .readback(device, queue, &mut out)
1012                .expect("Fix: unpooled readback should succeed");
1013            assert_eq!(out, contents);
1014        }
1015    }
1016
1017    /// The mapped-at-creation upload fast path (the StagingBelt replacement)
1018    /// must produce a buffer whose contents byte-for-byte equal the input across
1019    /// every boundary class: sub-word, exactly-a-word, word+tail, and a large
1020    /// payload (the catalog-scale path). A regression here is a silent data
1021    /// corruption on the ~1 GB DFA-catalog upload.
1022    #[test]
1023    fn mapped_upload_roundtrips_exact_bytes_across_boundaries() {
1024        let arc = crate::runtime::cached_device()
1025            .expect("Fix: live GPU device required for mapped upload roundtrip test");
1026        let (device, queue) = &*arc;
1027        // 1,3 exercise the 4-byte tail; 4 is exactly aligned; 5 is word+tail;
1028        // 257 is multi-word+tail; 1 MiB + 3 is the large, tail-padded path that
1029        // used to route through the slow per-write StagingBelt.
1030        for &len in &[1usize, 3, 4, 5, 257, (1 << 20) + 3] {
1031            let contents: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
1032            let handle = GpuBufferHandle::upload(
1033                device,
1034                queue,
1035                &contents,
1036                wgpu::BufferUsages::COPY_SRC,
1037            )
1038            .expect("Fix: mapped upload should succeed at every size");
1039            let mut out = Vec::new();
1040            handle
1041                .readback(device, queue, &mut out)
1042                .expect("Fix: readback should succeed");
1043            assert_eq!(
1044                out, contents,
1045                "mapped upload corrupted {len}-byte payload: readback != input"
1046            );
1047        }
1048    }
1049
1050    /// `write_padded_into_mapped` (the fast-path filler) must copy the logical
1051    /// bytes and zero the alignment tail deterministically, proved without a
1052    /// GPU so the contract holds on every host.
1053    #[test]
1054    fn write_padded_into_mapped_zeroes_the_tail() {
1055        // Allocation of 8 bytes, 5 logical: bytes 0..5 copied, 5..8 zeroed even
1056        // if the destination started with garbage.
1057        let mut mapped = [0xAAu8; 8];
1058        let bytes = [1u8, 2, 3, 4, 5];
1059        crate::padded_upload::write_padded_into_mapped(&mut mapped, &bytes)
1060            .expect("Fix: filling a large-enough mapped slice must succeed");
1061        assert_eq!(&mapped[..5], &bytes);
1062        assert_eq!(&mapped[5..], &[0u8, 0, 0], "alignment tail must be zeroed");
1063        // A slice smaller than the data must fail closed, never truncate.
1064        let mut too_small = [0u8; 2];
1065        assert!(crate::padded_upload::write_padded_into_mapped(&mut too_small, &bytes).is_err());
1066    }
1067
1068    #[test]
1069    fn resident_registry_handles_concurrent_lookup_and_drop() {
1070        let arc = crate::runtime::cached_device()
1071            .expect("Fix: GPU device is required for resident registry concurrency test");
1072        let (device, queue) = &*arc;
1073        let handle =
1074            GpuBufferHandle::upload(device, queue, &[1, 2, 3, 4], wgpu::BufferUsages::COPY_SRC)
1075                .expect("Fix: upload should register a resident buffer");
1076        let id = handle.id();
1077
1078        // Phase 1: while the handle is alive, 8 concurrent readers
1079        // must always resolve the resident id. Join BEFORE the drop so
1080        // there is no readers-vs-drop race producing flaky panics.
1081        let readers = (0..8)
1082            .map(|_| {
1083                std::thread::spawn(move || {
1084                    for _ in 0..1_000 {
1085                        let resident = GpuBufferHandle::from_resident_id(id)
1086                            .expect("Fix: resident id must resolve while handle is alive");
1087                        assert_eq!(resident.id(), id);
1088                    }
1089                })
1090            })
1091            .collect::<Vec<_>>();
1092        for reader in readers {
1093            reader
1094                .join()
1095                .expect("Fix: concurrent resident lookups must not panic");
1096        }
1097
1098        // Phase 2: dropping the handle must remove the id from the
1099        // registry so subsequent lookups return None.
1100        drop(handle);
1101        assert!(
1102            GpuBufferHandle::from_resident_id(id).is_none(),
1103            "dropped handles must be removed from the resident registry"
1104        );
1105    }
1106
1107    #[test]
1108    fn poisoned_staging_pool_lock_recovers_without_aborting_dispatch_path() {
1109        let pool = StagingBufferPool::new();
1110        let poisoned = pool.clone();
1111        let _ = std::thread::spawn(move || {
1112            let _guard = poisoned.lock_inner();
1113            panic!("poison staging buffer pool");
1114        })
1115        .join();
1116
1117        std::panic::catch_unwind(|| {
1118            let _ = pool.stats();
1119        })
1120        .expect("Fix: poisoned staging pool must recover so GPU readback pooling does not abort");
1121    }
1122
1123    #[test]
1124    fn poisoned_bind_group_cache_lock_recovers_without_aborting_dispatch_path() {
1125        let cache = BindGroupCache::new();
1126        let poisoned = cache.clone();
1127        let _ = std::thread::spawn(move || {
1128            let _guard = poisoned.lock_cache();
1129            panic!("poison bind group cache");
1130        })
1131        .join();
1132
1133        std::panic::catch_unwind(|| {
1134            let _ = cache.stats();
1135        })
1136        .expect("Fix: poisoned bind-group cache must recover so GPU dispatch does not abort");
1137    }
1138
1139    #[test]
1140    fn bind_group_cache_lru_heap_stays_capacity_scale() {
1141        let arc = crate::runtime::cached_device()
1142            .expect("Fix: GPU device is required for bind-group cache test");
1143        let (device, _) = &*arc;
1144        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1145            label: Some("vyre bind-group cache lru test layout"),
1146            entries: &[wgpu::BindGroupLayoutEntry {
1147                binding: 0,
1148                visibility: wgpu::ShaderStages::COMPUTE,
1149                ty: wgpu::BindingType::Buffer {
1150                    ty: wgpu::BufferBindingType::Storage { read_only: true },
1151                    has_dynamic_offset: false,
1152                    min_binding_size: wgpu::BufferSize::new(4),
1153                },
1154                count: None,
1155            }],
1156        });
1157        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
1158            label: Some("vyre bind-group cache lru test buffer"),
1159            size: 4,
1160            usage: wgpu::BufferUsages::STORAGE,
1161            mapped_at_creation: false,
1162        });
1163        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1164            label: Some("vyre bind-group cache lru test bind group"),
1165            layout: &layout,
1166            entries: &[wgpu::BindGroupEntry {
1167                binding: 0,
1168                resource: buffer.as_entire_binding(),
1169            }],
1170        });
1171        let cache = BindGroupCache::with_cap(4);
1172
1173        for i in 0..64u64 {
1174            cache.insert_by_ids(1, &[i, 4], bind_group.clone());
1175        }
1176
1177        let inner = cache.lock_cache();
1178        assert_eq!(inner.entries.len(), 4);
1179        assert!(
1180            inner.lru.len() <= inner.entries.len().saturating_mul(4).max(8),
1181            "Fix: bind-group LRU heap must compact stale entries to cache-capacity scale"
1182        );
1183    }
1184}