Skip to main content

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}