eventcv_core/accel.rs
1//! Where a computation runs — the CPU (always) or a GPU (when the `gpu` feature is built in and an
2//! adapter exists).
3//!
4//! Every representation has a CPU implementation, and that implementation is the reference: it is
5//! what the tests assert against, what the benchmarks compare to, and what runs unless something
6//! asks otherwise. A GPU kernel is an *alternative* to it, never a replacement, so a build without
7//! the feature, a machine without a GPU, and a machine with one all produce the same answers to
8//! within the tolerance each kernel documents.
9//!
10//! The backend is [`wgpu`], which compiles to Metal on macOS, Vulkan on Linux and Android, and
11//! DX12 on Windows — one set of shaders rather than a CUDA path plus a Metal path, and no vendor
12//! toolkit at build time. It is already in the tree for the viewer.
13//!
14//! # Choosing
15//!
16//! The default is [`Device::Cpu`]. It is read once from `EVENTCV_DEVICE` (`cpu` / `gpu`) and can be
17//! changed for the session with [`set_default_device`]; the bindings expose both, plus a per-call
18//! `device=`. Asking for a GPU that is not there is an **error**, never a quiet fall back to the
19//! CPU — "my GPU is not being used" should not be something a user has to time a benchmark to find
20//! out.
21
22use std::sync::atomic::{AtomicU8, Ordering};
23
24#[cfg(feature = "gpu")]
25pub(crate) mod gpu;
26#[cfg(feature = "gpu")]
27pub(crate) mod sim;
28
29/// What a representation asks a kernel to do. Named here rather than in [`gpu`] so the call sites
30/// describing their kernel compile whether or not the feature is on — only the dispatch itself is
31/// behind the flag.
32#[cfg(feature = "gpu")]
33pub(crate) type GpuDispatch = gpu::Dispatch;
34
35/// The same shape when the feature is off, so `representation` needs no `cfg` of its own. Nothing
36/// reads the fields in that build — the dispatch is constructed and then refused — which is exactly
37/// what keeps every kernel's description in one place instead of behind a `cfg` at each call site.
38#[cfg(not(feature = "gpu"))]
39#[allow(dead_code)]
40pub(crate) struct GpuDispatch {
41 pub(crate) entry: &'static str,
42 pub(crate) cells: usize,
43 pub(crate) initial: i32,
44 pub(crate) bins: u32,
45 pub(crate) span_ms: f32,
46 pub(crate) fixed_one: f32,
47 pub(crate) window_ms: Option<f64>,
48 pub(crate) needs_ages: bool,
49}
50
51/// Fixed-point scale the accumulating kernels use; see [`gpu::FIXED_ONE`].
52#[cfg(feature = "gpu")]
53pub(crate) const FIXED_ONE: f32 = gpu::FIXED_ONE;
54#[cfg(not(feature = "gpu"))]
55pub(crate) const FIXED_ONE: f32 = 65536.0;
56
57/// Which backend a representation runs on.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum Device {
60 /// The reference implementation. Parallel where it pays, and fast enough that this remains the
61 /// default for everything but large-batch work.
62 #[default]
63 Cpu,
64 /// A wgpu compute kernel. Worth it once a call is large enough to cover the upload and
65 /// readback — see the `representations` benchmark group for where that crossover sits.
66 Gpu,
67}
68
69impl Device {
70 /// Parses `"cpu"` / `"gpu"` (case-insensitively), the spelling used by `EVENTCV_DEVICE`, the
71 /// Python `device=` argument, and `set_device`.
72 pub fn parse(name: &str) -> Option<Self> {
73 match name.trim().to_ascii_lowercase().as_str() {
74 "cpu" => Some(Self::Cpu),
75 "gpu" | "cuda" | "metal" => Some(Self::Gpu),
76 _ => None,
77 }
78 }
79
80 pub fn as_str(self) -> &'static str {
81 match self {
82 Self::Cpu => "cpu",
83 Self::Gpu => "gpu",
84 }
85 }
86}
87
88/// The session default, as a `u8` so it can be swapped without a lock. `u8::MAX` means "not yet
89/// read from the environment".
90static DEFAULT_DEVICE: AtomicU8 = AtomicU8::new(u8::MAX);
91
92/// The device used when a caller does not name one.
93///
94/// Seeded once from `EVENTCV_DEVICE`, so a CI job or a shell can select the GPU without touching
95/// any call site; an unset or unrecognised value leaves it on the CPU.
96pub fn default_device() -> Device {
97 match DEFAULT_DEVICE.load(Ordering::Relaxed) {
98 0 => Device::Cpu,
99 1 => Device::Gpu,
100 _ => {
101 let device = std::env::var("EVENTCV_DEVICE")
102 .ok()
103 .and_then(|name| Device::parse(&name))
104 .unwrap_or_default();
105 set_default_device(device);
106 device
107 }
108 }
109}
110
111/// Sets the device used when a caller does not name one, for the rest of the session.
112pub fn set_default_device(device: Device) {
113 DEFAULT_DEVICE.store(device as u8, Ordering::Relaxed);
114}
115
116/// Whether a GPU kernel can actually run here: the `gpu` feature is built in *and* an adapter was
117/// found. Probing opens the adapter once and caches it, so the first call is the expensive one.
118pub fn gpu_available() -> bool {
119 #[cfg(feature = "gpu")]
120 {
121 gpu::with_context(|_| ()).is_some()
122 }
123 #[cfg(not(feature = "gpu"))]
124 {
125 false
126 }
127}
128
129/// Closes the GPU if one was opened, waiting for anything still queued.
130///
131/// Call at the end of a process. A GPU device that is still open while the process tears itself
132/// down faults intermittently on some drivers — after the last line of the program has run, which
133/// makes it look like the library corrupted something when it did not. The Python bindings register
134/// this with `atexit`, so nothing has to remember. Idempotent, and a later call simply reopens.
135pub fn shutdown() {
136 #[cfg(feature = "gpu")]
137 {
138 gpu::shutdown();
139 }
140}
141
142/// [`unavailable_reason`] for callers outside the crate — the bindings, which raise it when
143/// `set_device("gpu")` is asked for on a machine or a build that cannot do it.
144pub fn unavailable_reason_public() -> String {
145 unavailable_reason()
146}
147
148/// Why a GPU run could not happen, as a sentence that says what to do about it. Returned rather
149/// than falling back, so a caller that asked for the GPU learns it did not get one.
150pub(crate) fn unavailable_reason() -> String {
151 #[cfg(feature = "gpu")]
152 {
153 "device=\"gpu\" was requested but no compatible adapter was found (wgpu could not open a \
154 Vulkan, Metal or DX12 device here); use device=\"cpu\""
155 .to_owned()
156 }
157 #[cfg(not(feature = "gpu"))]
158 {
159 "device=\"gpu\" was requested but this build has no GPU support; rebuild with \
160 --features gpu, or use device=\"cpu\""
161 .to_owned()
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{default_device, set_default_device, Device};
168
169 #[test]
170 fn device_names_round_trip() {
171 for device in [Device::Cpu, Device::Gpu] {
172 assert_eq!(Device::parse(device.as_str()), Some(device));
173 }
174 // The vendor names people reach for map onto the one portable backend.
175 assert_eq!(Device::parse("CUDA"), Some(Device::Gpu));
176 assert_eq!(Device::parse(" Metal "), Some(Device::Gpu));
177 assert_eq!(Device::parse("tpu"), None);
178 }
179
180 #[test]
181 fn the_default_is_the_cpu_and_can_be_moved() {
182 set_default_device(Device::Cpu);
183 assert_eq!(default_device(), Device::Cpu);
184 set_default_device(Device::Gpu);
185 assert_eq!(default_device(), Device::Gpu);
186 set_default_device(Device::Cpu);
187 }
188}
189
190/// Every GPU kernel, checked against the CPU implementation it mirrors.
191///
192/// These skip when no adapter is available rather than failing, so the suite is honest on a machine
193/// without a GPU and on CI. `EVENTCV_REQUIRE_GPU=1` turns the skip into a failure, which is how a
194/// machine that *should* have one keeps these from quietly going silent.
195#[cfg(test)]
196mod gpu_tests {
197 use super::{gpu_available, Device};
198 use crate::representation::{
199 AveragedTimeSurface, CountMask, EventCount, EventFrameData, Polarity, Representation,
200 TimeSurface, VoxelGrid,
201 };
202 use crate::{EventStream, EventStreamBuilder};
203
204 fn skip_without_gpu() -> bool {
205 if gpu_available() {
206 return false;
207 }
208 assert!(
209 std::env::var("EVENTCV_REQUIRE_GPU").is_err(),
210 "EVENTCV_REQUIRE_GPU is set but no adapter was found"
211 );
212 true
213 }
214
215 /// A recording busy enough that pixels collide — which is the whole point, since collisions are
216 /// where an order-dependent accumulator would show up.
217 fn stream() -> EventStream {
218 let mut builder = EventStreamBuilder::new(64, 48, 0.001);
219 for index in 0..20_000i64 {
220 let x = ((index * 37) % 64) as u16;
221 let y = ((index * 11) % 48) as u16;
222 builder.push(x, y, index * 3, index % 3 != 0);
223 }
224 builder.build()
225 }
226
227 fn floats(frame: &crate::representation::EventFrame) -> Vec<f32> {
228 match frame.data() {
229 EventFrameData::F32(values) => values.clone(),
230 other => panic!("expected float data, got {other:?}"),
231 }
232 }
233
234 #[test]
235 fn integer_kernels_match_the_cpu_exactly() {
236 if skip_without_gpu() {
237 return;
238 }
239 let stream = stream();
240 for normalize in [false, true] {
241 let counter = EventCount::new(normalize);
242 assert_eq!(
243 counter.generate_on(&stream, Device::Gpu).unwrap().data(),
244 counter.generate(&stream).unwrap().data(),
245 "event counts are integer sums and must be identical, not merely close"
246 );
247 let polarity = Polarity::new(normalize);
248 assert_eq!(
249 polarity.generate_on(&stream, Device::Gpu).unwrap().data(),
250 polarity.generate(&stream).unwrap().data()
251 );
252 }
253 let mask = CountMask::new(99.0, false);
254 assert_eq!(
255 mask.generate_on(&stream, Device::Gpu).unwrap().data(),
256 mask.generate(&stream).unwrap().data()
257 );
258 }
259
260 #[test]
261 fn float_kernels_match_the_cpu_within_the_fixed_point_quantum() {
262 if skip_without_gpu() {
263 return;
264 }
265 let stream = stream();
266 // Q16.16 rounding plus `f32` versus the CPU's `f64` age arithmetic. A voxel cell here holds
267 // hundreds of events, so this bounds the *accumulated* difference, not a single one.
268 let tolerance = 1e-3;
269 for (name, cpu, gpu) in [
270 (
271 "voxel",
272 floats(&VoxelGrid::new(5, 30.0).generate(&stream).unwrap()),
273 floats(
274 &VoxelGrid::new(5, 30.0)
275 .generate_on(&stream, Device::Gpu)
276 .unwrap(),
277 ),
278 ),
279 (
280 "tsurf",
281 floats(&TimeSurface::new(30.0).generate(&stream).unwrap()),
282 floats(
283 &TimeSurface::new(30.0)
284 .generate_on(&stream, Device::Gpu)
285 .unwrap(),
286 ),
287 ),
288 (
289 "atsurf",
290 floats(&AveragedTimeSurface::new(30.0).generate(&stream).unwrap()),
291 floats(
292 &AveragedTimeSurface::new(30.0)
293 .generate_on(&stream, Device::Gpu)
294 .unwrap(),
295 ),
296 ),
297 ] {
298 assert_eq!(cpu.len(), gpu.len(), "{name}: shape");
299 let worst = cpu
300 .iter()
301 .zip(&gpu)
302 .map(|(cpu, gpu)| (cpu - gpu).abs())
303 .fold(0.0_f32, f32::max);
304 assert!(worst <= tolerance, "{name}: worst cell differs by {worst}");
305 }
306 }
307
308 #[test]
309 fn a_kernel_gives_the_same_answer_every_run() {
310 if skip_without_gpu() {
311 return;
312 }
313 let stream = stream();
314 let first = floats(
315 &VoxelGrid::new(5, 30.0)
316 .generate_on(&stream, Device::Gpu)
317 .unwrap(),
318 );
319 for _ in 0..3 {
320 assert_eq!(
321 first,
322 floats(
323 &VoxelGrid::new(5, 30.0)
324 .generate_on(&stream, Device::Gpu)
325 .unwrap()
326 ),
327 "integer accumulation must make repeated runs bit-identical"
328 );
329 }
330 }
331}