henad-compute 0.3.0

CPU and GPU engines that run Henad models, with their sim threads and parallel primitives.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! GPU twin of [`henad_core::spatial_hash::SpatialHash`], rebuilt every tick with the same layout. A kernel walks
//! cell `c` as `sorted[cell_start[c]..cell_start[c + 1]]`.
//!
//! Note that the sort is not stable, unlike the CPU sort. Membership is the same, but a cell's slice comes out in
//! whatever order the atomics resolve in, so a kernel summing floats over a slice will not replay.

use henad_core::authoring::model::field::Extent;
pub use henad_core::spatial_hash::HashGrid;

use crate::gpu::primitives::dispatch::linear_dispatch;
use crate::gpu::primitives::pipeline::{compute_pipeline, storage_buffer, uniform_buffer};
use crate::gpu::primitives::prefix_scan::PrefixScan;
use crate::shader_bindings::primitives::hash_count::HashParams;

/// Counting-sort spatial hash of a GPU population, with the tables, scan and pipelines its rebuild uses.
#[derive(Debug)]
pub struct GpuSpatialHash {
    grid: HashGrid,
    num_agents: u32,
    /// Workgroup rectangle over the agent domain, shared by the count and scatter passes.
    agent_groups: (u32, u32),

    /// Number of agents in each cell, with one extra trailing entry so the scan's last value is the population total
    /// and `cell_start[c + 1]` stays in bounds for the final cell.
    counts: wgpu::Buffer,
    cell_start: wgpu::Buffer,
    cursor: wgpu::Buffer,
    agent_cell: wgpu::Buffer,
    sorted: wgpu::Buffer,
    params: wgpu::Buffer,

    scan: PrefixScan,
    count_layout: wgpu::BindGroupLayout,
    count_pipeline: wgpu::ComputePipeline,
    scatter_pipeline: wgpu::ComputePipeline,
    scatter_bind: wgpu::BindGroup,
}

impl GpuSpatialHash {
    /// Builds the tables and pipelines of a hash over `extent`, with cells of `cell_size`, for `num_agents` agents.
    pub fn new(
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        label: &str,
        extent: Extent,
        cell_size: f32,
        num_agents: u32,
    ) -> Self {
        let grid = HashGrid::new(extent, cell_size);
        let table_len = grid.num_cells() as usize + 1;
        let agent_groups = linear_dispatch(num_agents);

        let counts = storage_buffer(device, &format!("{label}_hash_counts"), table_len);
        let cell_start = storage_buffer(device, &format!("{label}_hash_cell_start"), table_len);
        let cursor = storage_buffer(device, &format!("{label}_hash_cursor"), table_len);
        let agent_cell = storage_buffer(device, &format!("{label}_hash_agent_cell"), num_agents as usize);
        let sorted = storage_buffer(device, &format!("{label}_hash_sorted"), num_agents as usize);

        let params = uniform_buffer(
            device,
            queue,
            &format!("{label}_hash_params"),
            bytemuck::bytes_of(&HashParams {
                grid_w: grid.grid_w,
                grid_h: grid.grid_h,
                num_agents,
                groups_x: agent_groups.0,
                cell_w_inv: 1.0 / grid.cell_w,
                cell_h_inv: 1.0 / grid.cell_h,
                _pad0: 0,
                _pad1: 0,
            }),
        );

        let scan = PrefixScan::new(
            device,
            queue,
            &format!("{label}_hash"),
            &counts,
            &cell_start,
            table_len as u32,
        );

        // Positions are the model's own ping-ponged buffers, so `bind_positions` fills this in.
        let count_layout = device.create_bind_group_layout(
            &crate::shader_bindings::primitives::hash_count::WgpuBindGroup0::LAYOUT_DESCRIPTOR,
        );
        let count_pipeline = compute_pipeline(
            device,
            &format!("{label}_hash_count"),
            crate::shader_bindings::primitives::hash_count::SHADER_STRING,
            &count_layout,
        );

        let scatter_layout = device.create_bind_group_layout(
            &crate::shader_bindings::primitives::hash_scatter::WgpuBindGroup0::LAYOUT_DESCRIPTOR,
        );
        let scatter_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some(&format!("{label}_hash_scatter_bind")),
            layout: &scatter_layout,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: agent_cell.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: cursor.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: sorted.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 3,
                    resource: params.as_entire_binding(),
                },
            ],
        });
        let scatter_pipeline = compute_pipeline(
            device,
            &format!("{label}_hash_scatter"),
            crate::shader_bindings::primitives::hash_scatter::SHADER_STRING,
            &scatter_layout,
        );

        Self {
            grid,
            num_agents,
            agent_groups,
            counts,
            cell_start,
            cursor,
            agent_cell,
            sorted,
            params,
            scan,
            count_layout,
            count_pipeline,
            scatter_pipeline,
            scatter_bind,
        }
    }

    /// Binds one side of a model's ping-ponged position lane to the counting pass.
    ///
    /// `pos` is a single `array<vec2<f32>>` lane, since two lanes would cost another storage binding. Build one bind
    /// group per side up front, so stepping never rebuilds a bind group.
    pub fn bind_positions(&self, device: &wgpu::Device, label: &str, pos: &wgpu::Buffer) -> wgpu::BindGroup {
        device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some(label),
            layout: &self.count_layout,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: pos.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: self.counts.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: self.agent_cell.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 3,
                    resource: self.params.as_entire_binding(),
                },
            ],
        })
    }

    /// Cell geometry, for a model to mirror into its step uniform.
    pub fn grid(&self) -> HashGrid {
        self.grid
    }

    /// Returns the cell-start table, bound as `array<u32>`. Cell `c` owns `cell_start[c]..cell_start[c + 1]` of
    /// [`Self::sorted_binding`].
    pub fn cell_start_binding(&self) -> wgpu::BindingResource<'_> {
        self.cell_start.as_entire_binding()
    }

    /// Returns the agent ids grouped by cell, bound as `array<u32>`.
    pub fn sorted_binding(&self) -> wgpu::BindingResource<'_> {
        self.sorted.as_entire_binding()
    }

    /// Records a full rebuild, one pass per stage. Each stage reads what the last one wrote, and wgpu synchronises
    /// only between passes.
    ///
    /// `begin_stamp` marks the start of a batch. It has to go here rather than on an empty pass. A stamp on an empty
    /// pass is not reliably written, and reads back as a zero `start`.
    pub fn encode_build(
        &self,
        encoder: &mut wgpu::CommandEncoder,
        count_bind: &wgpu::BindGroup,
        begin_stamp: Option<(&wgpu::QuerySet, u32)>,
    ) {
        encoder.clear_buffer(&self.counts, 0, None);

        {
            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
                label: Some("henad_hash_count_pass"),
                timestamp_writes: begin_stamp.map(|(query_set, index)| wgpu::ComputePassTimestampWrites {
                    query_set,
                    beginning_of_pass_write_index: Some(index),
                    end_of_pass_write_index: None,
                }),
            });
            pass.set_pipeline(&self.count_pipeline);
            pass.set_bind_group(0, count_bind, &[]);
            pass.dispatch_workgroups(self.agent_groups.0, self.agent_groups.1, 1);
        }

        self.scan.encode(encoder);

        // The scatter bumps the cursor, so it works on a copy and `cell_start` survives.
        encoder.copy_buffer_to_buffer(&self.cell_start, 0, &self.cursor, 0, self.table_bytes());

        {
            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
                label: Some("henad_hash_scatter_pass"),
                timestamp_writes: None,
            });
            pass.set_pipeline(&self.scatter_pipeline);
            pass.set_bind_group(0, &self.scatter_bind, &[]);
            pass.dispatch_workgroups(self.agent_groups.0, self.agent_groups.1, 1);
        }
    }

    fn table_bytes(&self) -> u64 {
        u64::from(self.grid.num_cells() + 1) * std::mem::size_of::<u32>() as u64
    }

    /// Approximate device memory held by the three tables and the two agent arrays, in bytes.
    pub fn heap_bytes(&self) -> usize {
        let table = (self.grid.num_cells() as usize + 1) * std::mem::size_of::<u32>();
        let agent = self.num_agents as usize * std::mem::size_of::<u32>();
        // Three tables plus two agent arrays. The scan's intermediates are a rounding error.
        3 * table + 2 * agent
    }
}

/// Raw buffers, for debugging and tests.
impl GpuSpatialHash {
    /// Buffer behind [`Self::cell_start_binding`].
    pub fn cell_start_buffer(&self) -> &wgpu::Buffer {
        &self.cell_start
    }

    /// Buffer behind [`Self::sorted_binding`].
    pub fn sorted_buffer(&self) -> &wgpu::Buffer {
        &self.sorted
    }
}

#[cfg(test)]
mod tests {
    use super::{GpuSpatialHash, HashGrid};
    use crate::gpu::headless_context;
    use crate::gpu::primitives::dispatch::WORKGROUP;
    use crate::gpu::primitives::pipeline::storage_buffer;
    use henad_core::authoring::model::field::Extent;
    use henad_core::authoring::primitives::rng::xorshift64;
    use henad_core::spatial_hash::SpatialHash;

    fn extent(w: f32, h: f32) -> Extent {
        Extent { w, h }
    }

    /// The two grids must agree cell for cell, or the queries walk different worlds.
    #[test]
    fn grid_geometry_matches_the_cpu_hash() {
        for (w, h, cell) in [
            (1_000.0, 1_000.0, 50.0),
            (1_000.0, 730.0, 47.0),
            (300.0, 300.0, 100.0),
            (100.0, 100.0, 200.0),
        ] {
            let gpu = HashGrid::new(extent(w, h), cell);
            let cpu = SpatialHash::new(cell, w, h);
            let (cpu_w, cpu_h) = cpu.grid_dims();
            assert_eq!(
                (gpu.grid_w, gpu.grid_h),
                (cpu_w, cpu_h),
                "grid dims for {w}x{h} @ {cell}"
            );
            let (cpu_cw, cpu_ch) = cpu.cell_extents();
            assert_eq!(
                (gpu.cell_w, gpu.cell_h),
                (cpu_cw, cpu_ch),
                "cell extents for {w}x{h} @ {cell}"
            );
        }
    }

    /// Returns `n` deterministic positions in a `w` by `h` world, so a failure is reproducible.
    fn positions(n: usize, w: f32, h: f32) -> (Vec<f32>, Vec<f32>) {
        let mut seed = 0x1234_5678_9ABC_DEF0u64;
        let mut unit = || {
            seed = xorshift64(seed);
            (seed >> 40) as f32 / 16_777_216.0
        };
        (0..n).map(|_| (unit() * w, unit() * h)).unzip()
    }

    /// Reads a `u32` storage buffer back through a staging copy.
    fn read_u32(ctx: &crate::gpu::GpuContext, buffer: &wgpu::Buffer, len: usize) -> Vec<u32> {
        let size = (len * std::mem::size_of::<u32>()) as u64;
        let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("hash_test_staging"),
            size,
            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let mut encoder = ctx
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
        encoder.copy_buffer_to_buffer(buffer, 0, &staging, 0, size);
        ctx.queue.submit(Some(encoder.finish()));

        let (tx, rx) = flume::bounded(1);
        staging
            .slice(..)
            .map_async(wgpu::MapMode::Read, move |r| drop(tx.send(r)));
        ctx.device
            .poll(wgpu::PollType::wait_indefinitely())
            .expect("device poll");
        rx.recv().expect("map channel").expect("map");

        let data = staging.slice(..).get_mapped_range().expect("map range");
        let out = bytemuck::cast_slice::<u8, u32>(&data)[..len].to_vec();
        drop(data);
        staging.unmap();
        out
    }

    /// Builds the hash for one set of positions and reads back `(cell_start, sorted)`.
    fn build(
        ctx: &crate::gpu::GpuContext,
        pos_x: &[f32],
        pos_y: &[f32],
        ext: Extent,
        cell: f32,
    ) -> (Vec<u32>, Vec<u32>) {
        let n = pos_x.len() as u32;
        let hash = GpuSpatialHash::new(&ctx.device, &ctx.queue, "test", ext, cell, n);

        // The lane is `array<vec2<f32>>`, so interleave the two test vectors on upload.
        let interleaved: Vec<f32> = pos_x.iter().zip(pos_y).flat_map(|(&x, &y)| [x, y]).collect();
        let pos = storage_buffer(&ctx.device, "test_pos", interleaved.len());
        ctx.queue.write_buffer(&pos, 0, bytemuck::cast_slice(&interleaved));

        let bind = hash.bind_positions(&ctx.device, "test_count_bind", &pos);

        let mut encoder = ctx
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
        hash.encode_build(&mut encoder, &bind, None);
        ctx.queue.submit(Some(encoder.finish()));

        let cells = hash.grid().num_cells() as usize + 1;
        (
            read_u32(ctx, hash.cell_start_buffer(), cells),
            read_u32(ctx, hash.sorted_buffer(), n as usize),
        )
    }

    /// Each cell holds the agents the CPU hash puts there, compared as sets since the atomic scatter does not fix the
    /// order within a cell.
    #[test]
    fn buckets_match_the_cpu_hash() {
        let Some(ctx) = headless_context("gpu_spatial_hash_test", wgpu::Features::empty()) else {
            log::warn!("skipping buckets_match_the_cpu_hash: no adapter");
            return;
        };

        let (w, h, cell, n) = (1_000.0f32, 730.0f32, 47.0f32, 5_000usize);
        let (pos_x, pos_y) = positions(n, w, h);
        let (cell_start, sorted) = build(&ctx, &pos_x, &pos_y, extent(w, h), cell);

        let mut cpu = SpatialHash::new(cell, w, h);
        cpu.build(&pos_x, &pos_y);
        let (cpu_start, cpu_sorted) = cpu.buckets();

        assert_eq!(
            cell_start, cpu_start,
            "cell offsets must match the CPU counting sort exactly"
        );
        assert_eq!(
            *cell_start.last().expect("offset table"),
            n as u32,
            "the trailing offset must be the population"
        );

        for c in 0..cell_start.len() - 1 {
            let range = cell_start[c] as usize..cell_start[c + 1] as usize;
            let mut gpu_cell = sorted[range.clone()].to_vec();
            let mut cpu_cell = cpu_sorted[range].to_vec();
            gpu_cell.sort_unstable();
            cpu_cell.sort_unstable();
            assert_eq!(gpu_cell, cpu_cell, "cell {c} holds a different set of agents");
        }
    }

    /// Exercises the 2D dispatch fold and a multi-level scan.
    #[test]
    fn a_large_population_still_sorts_every_agent() {
        let Some(ctx) = headless_context("gpu_spatial_hash_large_test", wgpu::Features::empty()) else {
            log::warn!("skipping a_large_population_still_sorts_every_agent: no adapter");
            return;
        };

        // 4000 world units at cell size 5 gives 640_000 cells, so the scan needs three levels.
        let (w, h, cell, n) = (4_000.0f32, 4_000.0f32, 5.0f32, 200_000usize);
        let (pos_x, pos_y) = positions(n, w, h);
        let (cell_start, sorted) = build(&ctx, &pos_x, &pos_y, extent(w, h), cell);

        assert!(
            cell_start.len() > (WORKGROUP * WORKGROUP) as usize,
            "this test is meant to need more than two scan levels"
        );
        assert_eq!(
            *cell_start.last().expect("offset table"),
            n as u32,
            "the scan lost agents somewhere in the level chain"
        );

        let mut seen = sorted.clone();
        seen.sort_unstable();
        seen.dedup();
        assert_eq!(
            seen.len(),
            n,
            "every agent must appear in the sorted array exactly once"
        );
    }
}