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}