Skip to main content

henad_compute/gpu/
capacity.rs

1//! Resources a model would allocate, checked against the device before anything is created.
2//!
3//! Over budget, wgpu panics on the UI thread with a message that refers to a bind group. Checking first lets the
4//! message refer to the model's own buffer instead.
5
6use henad_core::helpers::fmt_bytes;
7
8use crate::display_scale::display_dims;
9use crate::gpu::primitives::pipeline::{storage_entry, uniform_entry};
10use henad_core::authoring::model::binding::{BindingDecl, BindingKind};
11
12/// Returns the number of bindings that count against `max_storage_buffers_per_shader_stage`. A uniform and a storage
13/// texture each count against a separate limit.
14pub(crate) fn storage_bindings(decls: &[BindingDecl]) -> u32 {
15    decls.iter().filter(|d| d.kind.is_storage_buffer()).count() as u32
16}
17
18/// Returns the layout entry that a declaration requires.
19pub(crate) fn layout_entry(i: u32, decl: &BindingDecl) -> wgpu::BindGroupLayoutEntry {
20    match decl.kind {
21        BindingKind::Storage { read_only } => storage_entry(i, read_only),
22        BindingKind::Uniform => uniform_entry(i),
23        BindingKind::StorageTexture => wgpu::BindGroupLayoutEntry {
24            binding: i,
25            visibility: wgpu::ShaderStages::COMPUTE,
26            ty: wgpu::BindingType::StorageTexture {
27                access: wgpu::StorageTextureAccess::WriteOnly,
28                format: wgpu::TextureFormat::Rgba8Unorm,
29                view_dimension: wgpu::TextureViewDimension::D2,
30            },
31            count: None,
32        },
33    }
34}
35
36/// One buffer a model would allocate, labelled as wgpu would label it so a message points at something greppable.
37#[derive(Debug)]
38pub struct Alloc {
39    /// Label of the buffer.
40    pub label: String,
41    /// Size in bytes.
42    pub bytes: u64,
43}
44
45/// Storage buffers one pass binds, counted against `max_storage_buffers_per_shader_stage`.
46#[derive(Debug)]
47pub struct PassBindings {
48    /// Label of the pass.
49    pub label: String,
50    /// Number of storage buffers the pass binds.
51    pub storage: u32,
52}
53
54/// Allocations and passes a model would produce for one set of params.
55///
56/// Reduction intermediates and the engine's own index and scan passes are left out: both are fixed
57/// rather than declared, and both are negligible next to what they sit beside.
58#[derive(Debug, Default)]
59pub struct Demand {
60    /// Buffers the model would allocate.
61    pub buffers: Vec<Alloc>,
62    /// Size of the display texture, already capped by [`crate::display_scale`].
63    pub texture: Option<(u32, u32)>,
64    /// Storage bindings of each pass.
65    pub passes: Vec<PassBindings>,
66}
67
68impl Demand {
69    /// Records buffer `label` of `words` `u32` words.
70    pub fn push(&mut self, label: String, words: usize) {
71        self.buffers.push(Alloc {
72            label,
73            bytes: words as u64 * std::mem::size_of::<u32>() as u64,
74        });
75    }
76
77    /// Records side `_a` of buffer `label`, and side `_b` as well when `doubled` is set.
78    pub fn push_sides(&mut self, label: &str, words: usize, doubled: bool) {
79        self.push(format!("{label}_a"), words);
80        if doubled {
81            self.push(format!("{label}_b"), words);
82        }
83    }
84
85    /// Records the five tables a [`GpuSpatialHash`](crate::gpu::GpuSpatialHash) of `n_cells` cells rebuilds every
86    /// tick for `num_agents` agents.
87    pub fn push_index(&mut self, label: &str, n_cells: u32, num_agents: u32) {
88        let table = n_cells as usize + 1;
89        for name in ["counts", "cell_start", "cursor"] {
90            self.push(format!("{label}_hash_{name}"), table);
91        }
92        for name in ["agent_cell", "sorted"] {
93            self.push(format!("{label}_hash_{name}"), num_agents as usize);
94        }
95    }
96
97    /// Records the display texture of a `width` by `height` grid, capped for `limits` as the engine would create it.
98    pub fn set_display(&mut self, width: u32, height: u32, limits: &wgpu::Limits) {
99        self.texture = Some(display_dims(width, height, limits.max_texture_dimension_2d));
100    }
101
102    /// Records pass `label`, binding `storage` storage buffers.
103    pub fn push_pass(&mut self, label: String, storage: u32) {
104        self.passes.push(PassBindings { label, storage });
105    }
106
107    /// Device bytes the model would occupy.
108    pub fn bytes(&self) -> u64 {
109        let buffers: u64 = self.buffers.iter().map(|alloc| alloc.bytes).sum();
110        let texture = self
111            .texture
112            .map_or(0, |(w, h)| u64::from(w) * u64::from(h) * RGBA_BYTES);
113        buffers + texture
114    }
115
116    /// Returns the reasons `limits` cannot host the model, empty when it can.
117    ///
118    /// Buffers of one size share a line. A ping-ponged buffer's two sides would otherwise say the same
119    /// sentence twice.
120    pub fn shortfalls(&self, limits: &wgpu::Limits) -> Vec<String> {
121        let mut lines = self.size_shortfalls(limits);
122        let allowed = limits.max_storage_buffers_per_shader_stage;
123        lines.extend(self.passes.iter().filter(|pass| pass.storage > allowed).map(|pass| {
124            format!(
125                "pass '{}' binds {} storage buffers, past the {allowed} this device allows per shader stage",
126                pass.label, pass.storage
127            )
128        }));
129        lines
130    }
131
132    fn size_shortfalls(&self, limits: &wgpu::Limits) -> Vec<String> {
133        let mut groups: Vec<Group<'_>> = Vec::new();
134        for alloc in &self.buffers {
135            // Binding first, since it is the tighter of the two everywhere seen so far.
136            let (limit, what) = if alloc.bytes > limits.max_storage_buffer_binding_size {
137                (limits.max_storage_buffer_binding_size, "storage binding")
138            } else if alloc.bytes > limits.max_buffer_size {
139                (limits.max_buffer_size, "buffer")
140            } else {
141                continue;
142            };
143            match groups.iter_mut().find(|g| g.bytes == alloc.bytes && g.limit == limit) {
144                Some(group) => group.count += 1,
145                None => groups.push(Group {
146                    bytes: alloc.bytes,
147                    limit,
148                    what,
149                    first: &alloc.label,
150                    count: 1,
151                }),
152            }
153        }
154        groups.iter().map(Group::message).collect()
155    }
156}
157
158const RGBA_BYTES: u64 = 4;
159
160/// Buffers of one size missing one limit, reported as a single line.
161struct Group<'a> {
162    bytes: u64,
163    limit: u64,
164    what: &'static str,
165    first: &'a str,
166    count: usize,
167}
168
169impl Group<'_> {
170    fn message(&self) -> String {
171        let subject = match self.count {
172            1 => format!("'{}' needs", self.first),
173            n => format!("'{}' and {} more each need", self.first, n - 1),
174        };
175        format!(
176            "{subject} {}, past the {} this device allows for one {}",
177            fmt_bytes(self.bytes),
178            fmt_bytes(self.limit),
179            self.what
180        )
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::Demand;
187
188    fn limits(binding: u64, buffer: u64) -> wgpu::Limits {
189        wgpu::Limits {
190            max_storage_buffer_binding_size: binding,
191            max_buffer_size: buffer,
192            ..wgpu::Limits::default()
193        }
194    }
195
196    /// A model that fits reports no shortfall. Otherwise the UI would reject a model that would have built.
197    #[test]
198    fn a_model_that_fits_has_no_shortfalls() {
199        let mut demand = Demand::default();
200        demand.push_sides("state", 1024 * 1024, true);
201        demand.set_display(1024, 1024, &wgpu::Limits::default());
202        assert!(demand.shortfalls(&wgpu::Limits::default()).is_empty());
203    }
204
205    /// The `gpu_sir` 6000x6000 case from issue #9, which would otherwise be a wgpu panic.
206    #[test]
207    fn a_buffer_past_the_binding_limit_is_named() {
208        let mut demand = Demand::default();
209        demand.push_sides("gpu_sir_buffer0", 36_000_000, true);
210        demand.push_sides("gpu_sir_buffer1", 36_000_000, true);
211        let found = demand.shortfalls(&limits(134_217_728, 268_435_456));
212        assert_eq!(
213            found.len(),
214            1,
215            "four same-sized sides are one line, not four: {found:?}"
216        );
217        assert!(
218            found[0].contains("gpu_sir_buffer0_a")
219                && found[0].contains("3 more")
220                && found[0].contains("storage binding"),
221            "{}",
222            found[0]
223        );
224    }
225
226    /// Two over-budget sizes are two problems, so neither may hide the other.
227    #[test]
228    fn different_sizes_get_their_own_line() {
229        let mut demand = Demand::default();
230        demand.push("small".to_owned(), 36_000_000);
231        demand.push("large".to_owned(), 50_000_000);
232        let found = demand.shortfalls(&limits(134_217_728, 268_435_456));
233        assert_eq!(found.len(), 2, "{found:?}");
234    }
235
236    /// The message must change, since raising the binding cap alone would not help here.
237    #[test]
238    fn a_buffer_past_the_buffer_limit_says_so() {
239        let mut demand = Demand::default();
240        demand.push("huge".to_owned(), 1_000_000_000);
241        let found = demand.shortfalls(&limits(u64::MAX, 1 << 30));
242        assert_eq!(found.len(), 1);
243        assert!(found[0].contains("for one buffer"), "{}", found[0]);
244    }
245
246    /// The limit behind issue #30. No amount of shrinking the grid fixes it, so it reads
247    /// differently from a size.
248    #[test]
249    fn a_pass_over_the_binding_count_is_named() {
250        let mut demand = Demand::default();
251        demand.push_pass("gpu_ants_step".to_owned(), 9);
252        demand.push_pass("gpu_ants_merge".to_owned(), 2);
253        let found = demand.shortfalls(&wgpu::Limits::default());
254        assert_eq!(found.len(), 1, "only the step pass is over: {found:?}");
255        assert!(
256            found[0].contains("gpu_ants_step") && found[0].contains("per shader stage"),
257            "{}",
258            found[0]
259        );
260    }
261
262    /// Independent failures, so one must not mask the other.
263    #[test]
264    fn an_oversized_buffer_and_an_overbound_pass_are_both_reported() {
265        let mut demand = Demand::default();
266        demand.push("huge".to_owned(), 36_000_000);
267        demand.push_pass("wide".to_owned(), 99);
268        assert_eq!(demand.shortfalls(&wgpu::Limits::default()).len(), 2);
269    }
270
271    /// The cap is the point, so display bytes must not grow with the grid.
272    #[test]
273    fn display_bytes_stop_growing_with_the_grid() {
274        let mut small = Demand::default();
275        small.set_display(4096, 4096, &wgpu::Limits::default());
276        let mut huge = Demand::default();
277        huge.set_display(100_000, 100_000, &wgpu::Limits::default());
278        assert_eq!(small.bytes(), huge.bytes());
279    }
280}