Skip to main content

unlit_wgpu/
buffer_pool.rs

1//! A pool that packs many small ranges into one GPU buffer.
2//!
3//! Uploading a mesh per buffer is wasteful: every `wgpu::Buffer` carries
4//! driver overhead, and a draw can only bind a handful of them. A
5//! [`BufferPool`] instead owns one large buffer and hands out byte ranges
6//! inside it with an [`Allocator`], so
7//! every mesh of a kind shares the same buffer.
8//!
9//! A range is a [`BufferRange`]: a byte offset and a size, plus the
10//! [`Allocation`] that keeps the range reserved until it is handed back with
11//! [`BufferPool::release`]. A range names no buffer of its own, so it stays
12//! cheap to store and copy; read the buffer from [`BufferPool::buffer`]
13//! whenever you need to bind or write into it.
14//!
15//! Ranges never move while they live — growing the pool copies the buffer and
16//! appends free space rather than re-packing — so an offset stays valid across
17//! a grow and only the buffer handle changes.
18//!
19//! Every range starts at a multiple of [`COPY_BUFFER_ALIGNMENT`](wgpu::COPY_BUFFER_ALIGNMENT), which is
20//! what a GPU buffer sub-allocation needs. The pool grows by 1.5x when a
21//! range does not fit, copying the old contents into the new buffer.
22//!
23//! # Sizing
24//!
25//! A range's allocation covers the requested size rounded up to the
26//! alignment, which [`BufferRange::size`] reports. Sizing the pool with
27//! [`min_allocator_size`] is not
28//! needed here: the pool grows on demand.
29//!
30//! # Example
31//!
32//! ```rust
33//! use unlit_wgpu::buffer_pool::BufferPool;
34//!
35//! let (device, queue) = wgpu::Device::noop(&wgpu::DeviceDescriptor::default());
36//! let mut pool = BufferPool::new(
37//!     &device,
38//!     "example::pool",
39//!     wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
40//!     1024,
41//! );
42//!
43//! let range = pool.allocate(&device, &queue, 64).expect("the pool has room");
44//! queue.write_buffer(pool.buffer(), range.allocation.offset as u64, &[0u8; 64]);
45//! pool.release(range.allocation);
46//! ```
47
48use crate::offset_allocator::{Allocation, Allocator, min_allocator_size};
49use core::num::NonZeroU32;
50
51/// The alignment every range starts at, in bytes.
52///
53/// This is wgpu's own copy alignment, which a vertex- or index-buffer offset
54/// also has to satisfy; a multiple of it is a multiple of an index format's
55/// size too, so one alignment serves every buffer kind.
56const ALIGNMENT: NonZeroU32 = match NonZeroU32::new(wgpu::COPY_BUFFER_ALIGNMENT as u32) {
57    Some(alignment) => alignment,
58    None => unreachable!(),
59};
60
61/// How many ranges a pool tracks unless the caller says otherwise.
62///
63/// The allocator's own default (128 Ki nodes, roughly 3.5 MB of metadata) is
64/// sized for a general-purpose allocator; a pool holds one range per mesh
65/// part, so it needs far fewer.
66const DEFAULT_MAX_RANGES: u32 = 4096;
67
68/// The smallest pool size, so that the first grow has something to grow from.
69const MIN_SIZE: u64 = wgpu::COPY_BUFFER_ALIGNMENT;
70
71/// A byte range inside a [`BufferPool`]'s buffer.
72///
73/// The range stays valid — and its data stays put — until its
74/// [`allocation`](Self::allocation) field is handed back with
75/// [`BufferPool::release`]. Growing the pool replaces the buffer but keeps
76/// every offset, so read the buffer from [`BufferPool::buffer`] instead of
77/// holding one.
78#[derive(Clone, Copy, Debug)]
79pub struct BufferRange {
80    /// The allocation that keeps the range reserved.
81    pub allocation: Allocation,
82    /// The size reserved for the range, in bytes.
83    ///
84    /// Cached at allocation time so the handle stays self-contained: the
85    /// allocator entry is the pool's, and the pool may grow behind it.
86    pub size: u32,
87}
88
89/// One GPU buffer sub-allocated into many ranges.
90///
91/// See the [module documentation](self) for what a pool is for.
92pub struct BufferPool {
93    /// The buffer every range lives in.
94    buffer: wgpu::Buffer,
95    /// The label the buffer is created with, reused when it is replaced.
96    label: &'static str,
97    /// The usages the buffer is created with.
98    usage: wgpu::BufferUsages,
99    /// Where the free space is.
100    allocator: Allocator,
101}
102
103impl BufferPool {
104    /// Creates a pool over a buffer of `size` bytes with the given usages.
105    ///
106    /// The buffer is created immediately. `size` is rounded up to a multiple
107    /// of [`COPY_BUFFER_ALIGNMENT`](wgpu::COPY_BUFFER_ALIGNMENT).
108    ///
109    /// # Panics
110    ///
111    /// Panics if the usages do not include [`wgpu::BufferUsages::COPY_DST`],
112    /// which the pool needs to grow.
113    pub fn new(
114        device: &wgpu::Device,
115        label: &'static str,
116        usage: wgpu::BufferUsages,
117        size: u64,
118    ) -> Self {
119        assert!(
120            usage.contains(wgpu::BufferUsages::COPY_DST),
121            "a pool grows by copying, so it needs COPY_DST"
122        );
123
124        let size = round_up_size(size);
125        let buffer = create_buffer(device, label, usage, size);
126        Self {
127            buffer,
128            label,
129            usage,
130            allocator: Allocator::with_max_nodes_and_alignment(
131                size as u32,
132                DEFAULT_MAX_RANGES,
133                ALIGNMENT,
134            ),
135        }
136    }
137
138    /// The buffer every range lives in.
139    ///
140    /// This changes when the pool grows, so read it again instead of holding
141    /// one: a range stays valid across a grow, but names no buffer of its own
142    /// to read it from.
143    pub fn buffer(&self) -> &wgpu::Buffer {
144        &self.buffer
145    }
146
147    /// The pool's total size, in bytes.
148    pub fn size(&self) -> u64 {
149        u64::from(self.allocator.size())
150    }
151
152    /// The free space left, in bytes.
153    pub fn free_space(&self) -> u32 {
154        self.allocator.storage_report().total_free_space
155    }
156
157    /// The size reserved for `allocation`, in bytes.
158    ///
159    /// This is the requested size rounded up to the alignment.
160    pub fn allocation_size(&self, allocation: Allocation) -> u32 {
161        self.allocator.allocation_size(allocation)
162    }
163
164    /// Reserves a range of `size` bytes, growing the pool if it does not fit.
165    ///
166    /// Returns `None` only if the pool cannot grow any further (its size would
167    /// overflow a `u32`, or it has run out of node slots for its ranges). The
168    /// returned range's offset is a multiple of [`COPY_BUFFER_ALIGNMENT`](wgpu::COPY_BUFFER_ALIGNMENT), and
169    /// its allocation covers `size` rounded up to it.
170    ///
171    /// Growing allocates a new buffer 1.5x the pool's size, copies the old
172    /// contents into it, and appends the difference to the allocator. Existing
173    /// ranges keep their offsets, but the buffer they name changes, so a
174    /// caller holding one must re-read [`BufferPool::buffer`].
175    pub fn allocate(
176        &mut self,
177        device: &wgpu::Device,
178        queue: &wgpu::Queue,
179        size: u32,
180    ) -> Option<BufferRange> {
181        if let Some(allocation) = self.allocator.allocate(size) {
182            return Some(self.range(allocation));
183        }
184
185        // The allocation may fail because the pool is full or because the
186        // ranges are fragmented; growing helps in both cases as long as there
187        // is a free node to describe the appended space.
188        self.grow(device, queue, size)?;
189        let allocation = self
190            .allocator
191            .allocate(size)
192            .expect("the grown pool has room for the range that did not fit");
193        Some(self.range(allocation))
194    }
195
196    /// Hands a range back to the pool.
197    ///
198    /// # Panics
199    ///
200    /// Panics if the allocation was already released.
201    pub fn release(&mut self, allocation: Allocation) {
202        self.allocator.free(allocation);
203    }
204
205    /// Builds the handle for a fresh allocation.
206    fn range(&self, allocation: Allocation) -> BufferRange {
207        BufferRange {
208            allocation,
209            size: self.allocator.allocation_size(allocation),
210        }
211    }
212
213    /// Grows the pool by 1.5x until `size` fits, copying the contents across.
214    ///
215    /// Returns `None` if the pool cannot grow, leaving it unchanged.
216    fn grow(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, size: u32) -> Option<()> {
217        // Grow by 1.5x and at least as much as the range that did not fit
218        // needs, so one grow is always enough for it.
219        //
220        // The size to grow by is the allocator's minimum for `size`, not
221        // `size` itself: a free range is filed under its rounded-down size
222        // while an allocation searches from its rounded-up size, so a range
223        // sized exactly `size` would not be found by an allocation of `size`.
224        // `min_allocator_size` is the smallest size that both holds `size` and
225        // is representable by a bin, which is what makes the range findable.
226        let current = self.allocator.size();
227        let needed = min_allocator_size(size, ALIGNMENT);
228        let additional = needed.max(current / 2);
229        let target = current.checked_add(additional)?;
230
231        // Reserve the node for the appended range before allocating anything:
232        // `extend` is the step that can fail, and a failure here must not
233        // leave a replaced buffer behind.
234        if !self.allocator.extend(additional) {
235            return None;
236        }
237        debug_assert_eq!(self.allocator.size(), target);
238
239        let new_buffer = create_buffer(
240            device,
241            self.label,
242            self.usage,
243            u64::from(self.allocator.size()),
244        );
245
246        // Copy the old contents across. Everything the allocator tracks lives
247        // below the old size, so the copy covers every live range.
248        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
249            label: Some(self.label),
250        });
251        encoder.copy_buffer_to_buffer(&self.buffer, 0, &new_buffer, 0, u64::from(current));
252        queue.submit([encoder.finish()]);
253
254        self.buffer = new_buffer;
255        Some(())
256    }
257}
258
259impl core::fmt::Debug for BufferPool {
260    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
261        let report = self.allocator.storage_report();
262        f.debug_struct("BufferPool")
263            .field("label", &self.label)
264            .field("size", &self.size())
265            .field("free_space", &report.total_free_space)
266            .field("largest_free_range", &report.largest_free_region)
267            .finish()
268    }
269}
270
271/// Rounds a byte size up to the pool's alignment, with a floor of one unit.
272fn round_up_size(size: u64) -> u64 {
273    let alignment = u64::from(ALIGNMENT.get());
274    let rounded = size.max(MIN_SIZE).div_ceil(alignment) * alignment;
275    assert!(
276        rounded <= u64::from(u32::MAX),
277        "a pool cannot be larger than u32::MAX bytes"
278    );
279    rounded
280}
281
282/// Creates the pool's buffer with the usages it needs to grow.
283fn create_buffer(
284    device: &wgpu::Device,
285    label: &'static str,
286    usage: wgpu::BufferUsages,
287    size: u64,
288) -> wgpu::Buffer {
289    device.create_buffer(&wgpu::BufferDescriptor {
290        label: Some(label),
291        size,
292        usage: usage | wgpu::BufferUsages::COPY_SRC,
293        mapped_at_creation: false,
294    })
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn noop_device() -> (wgpu::Device, wgpu::Queue) {
302        wgpu::Device::noop(&wgpu::DeviceDescriptor::default())
303    }
304
305    fn pool(device: &wgpu::Device, size: u64) -> BufferPool {
306        BufferPool::new(
307            device,
308            "test::pool",
309            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
310            size,
311        )
312    }
313
314    #[test]
315    fn ranges_are_aligned_and_do_not_overlap() {
316        let (device, queue) = noop_device();
317        let mut pool = pool(&device, 1024);
318
319        let mut ranges = Vec::new();
320        for size in [1u32, 3, 4, 5, 100, 200, 300] {
321            let range = pool
322                .allocate(&device, &queue, size)
323                .expect("the pool has room");
324            assert_eq!(range.allocation.offset % ALIGNMENT.get(), 0);
325            assert!(range.size >= size);
326            ranges.push((range.allocation.offset, range.size));
327        }
328
329        ranges.sort_unstable();
330        for pair in ranges.windows(2) {
331            let (offset, size) = pair[0];
332            assert!(
333                offset + size <= pair[1].0,
334                "range {offset}..{} overlaps {}",
335                offset + size,
336                pair[1].0
337            );
338        }
339    }
340
341    #[test]
342    fn growing_keeps_offsets_and_preserves_the_old_ranges() {
343        let (device, queue) = noop_device();
344        let mut pool = pool(&device, 64);
345
346        let first = pool
347            .allocate(&device, &queue, 32)
348            .expect("the pool has room");
349        assert_eq!(first.allocation.offset, 0);
350        let size_before = pool.size();
351
352        // This does not fit in the remaining 32 bytes, so the pool grows.
353        let second = pool.allocate(&device, &queue, 200).expect("the pool grows");
354        assert!(pool.size() > size_before, "the pool grew");
355        assert_eq!(first.allocation.offset, 0, "the first range did not move");
356        assert!(
357            second.allocation.offset + second.size <= pool.size() as u32,
358            "the new range fits in the pool"
359        );
360        assert!(second.size >= 200);
361        // The old range and the new one do not overlap.
362        assert!(
363            second.allocation.offset >= first.allocation.offset + first.size
364                || second.allocation.offset + second.size <= first.allocation.offset
365        );
366
367        // Both ranges are still reserved: releasing them returns the whole
368        // pool to free space.
369        pool.release(first.allocation);
370        pool.release(second.allocation);
371        assert_eq!(pool.free_space(), pool.size() as u32);
372    }
373
374    #[test]
375    fn growing_replaces_the_buffer_and_keeps_the_ranges() {
376        // A caller syncing a pool into a resource graph detects a grow by
377        // comparing the buffer it holds against the pool's, so a grow has to
378        // hand out a buffer that compares unequal to the old one.
379        let (device, queue) = noop_device();
380        let mut pool = pool(&device, 64);
381
382        let before = pool.buffer().clone();
383        let first = pool
384            .allocate(&device, &queue, 32)
385            .expect("the pool has room");
386        assert_eq!(pool.buffer(), &before, "allocating replaces nothing");
387
388        pool.allocate(&device, &queue, 200).expect("the pool grows");
389        assert_ne!(pool.buffer(), &before, "growing replaces the buffer");
390        assert_eq!(first.allocation.offset, 0, "the first range did not move");
391    }
392
393    #[test]
394    fn a_released_range_is_reused() {
395        let (device, queue) = noop_device();
396        let mut pool = pool(&device, 4096);
397
398        let first = pool
399            .allocate(&device, &queue, 512)
400            .expect("the pool has room");
401        assert_eq!(first.allocation.offset, 0);
402        pool.release(first.allocation);
403
404        let second = pool
405            .allocate(&device, &queue, 512)
406            .expect("the pool has room");
407        assert_eq!(
408            second.allocation.offset, 0,
409            "the freed range is handed out again"
410        );
411    }
412
413    #[test]
414    fn a_range_larger_than_the_pool_grows_it_once() {
415        let (device, queue) = noop_device();
416        let mut pool = pool(&device, 64);
417
418        let range = pool
419            .allocate(&device, &queue, 4096)
420            .expect("the pool grows");
421        assert!(range.size >= 4096);
422        assert!(
423            pool.size() >= 4096,
424            "the pool grew enough for the range that did not fit"
425        );
426        assert_eq!(range.allocation.offset, 0);
427    }
428
429    #[test]
430    fn releasing_twice_panics() {
431        let (device, queue) = noop_device();
432        let mut pool = pool(&device, 1024);
433        let range = pool
434            .allocate(&device, &queue, 16)
435            .expect("the pool has room");
436        pool.release(range.allocation);
437        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
438            pool.release(range.allocation);
439        }));
440        assert!(result.is_err());
441    }
442
443    #[test]
444    #[should_panic(expected = "COPY_DST")]
445    fn a_pool_without_copy_dst_is_rejected() {
446        let (device, _queue) = noop_device();
447        BufferPool::new(&device, "test::pool", wgpu::BufferUsages::VERTEX, 1024);
448    }
449}