Skip to main content

trueno_db/gpu/
mod.rs

1//! GPU compute backend using wgpu (WebGPU)
2//!
3//! Toyota Way Principles:
4//! - Muda elimination: GPU only when compute > 5x transfer time
5//! - Genchi Genbutsu: Empirical benchmarks prove 50-100x speedups
6//!
7//! Architecture:
8//! - WGSL compute shaders for parallel reduction
9//! - Workgroup size: 256 threads (GPU warp size optimization)
10//! - Two-stage reduction: workgroup-local + global
11//!
12//! References:
13//! - `HeavyDB` (2017): GPU aggregation patterns
14//! - Harris (2007): Optimizing parallel reduction in CUDA
15//! - Leis et al. (2014): Morsel-driven parallelism
16
17use crate::{Error, Result};
18use arrow::array::{Array, Float32Array, Int32Array};
19use wgpu;
20use wgpu::util::DeviceExt;
21
22pub mod jit;
23pub mod kernels;
24pub mod multigpu;
25
26/// Platform-appropriate wgpu backend mask for adapter enumeration.
27///
28/// PMAT-927 (class follow-up to PMAT-925): `wgpu::Backends::all()` includes
29/// [`wgpu::Backends::GL`], which on Linux hosts that expose both Vulkan and
30/// GLES/EGL (notably the intel AMD-RADV cross-silicon baseline box) instantiates
31/// a GLES adapter whose `EglContext::make_current` **panics inside `Drop`**. A
32/// panic in a destructor during cleanup aborts the whole process with SIGABRT
33/// ("panic in a destructor during cleanup"); standalone enumeration can also
34/// spin/hang on the broken EGL path. The compute kernels themselves are correct
35/// — the fragility is purely in adapter *enumeration* and the GLES `Drop` path.
36///
37/// We return [`wgpu::Backends::PRIMARY`], which in wgpu 22 (this crate's pin) is
38/// `VULKAN | METAL | DX12 | BROWSER_WEBGPU` and **excludes** `GL` (GL lives only
39/// in `Backends::SECONDARY`). This keeps the real GPU on every platform — Vulkan
40/// on Linux/AMD-RADV, Metal on Apple, DX12 on Windows — while guaranteeing the
41/// broken GLES/EGL adapter is never created.
42///
43/// The mask is applied at BOTH the `wgpu::Instance` construction site (so the
44/// GLES backend is never even registered on the instance) and every
45/// `enumerate_adapters` call site.
46#[must_use]
47pub const fn gpu_backends() -> wgpu::Backends {
48    // PRIMARY = VULKAN | METAL | DX12 | BROWSER_WEBGPU (never GL/GLES).
49    wgpu::Backends::PRIMARY
50}
51
52/// GPU compute engine for aggregations
53pub struct GpuEngine {
54    /// GPU device handle (public for benchmarking)
55    pub device: wgpu::Device,
56    /// GPU command queue (public for benchmarking)
57    pub queue: wgpu::Queue,
58    /// JIT compiler for kernel fusion
59    jit: jit::JitCompiler,
60}
61
62impl GpuEngine {
63    /// Initialize GPU engine
64    ///
65    /// # Errors
66    /// Returns error if GPU initialization fails (no GPU available, driver issues, etc.)
67    pub async fn new() -> Result<Self> {
68        // Request GPU adapter.
69        // PMAT-927: constrain to a non-GLES backend mask so the broken GLES/EGL
70        // adapter (SIGABRT-in-Drop on Linux/AMD-RADV) is never registered.
71        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
72            backends: gpu_backends(),
73            ..Default::default()
74        });
75
76        let adapter = instance
77            .request_adapter(&wgpu::RequestAdapterOptions {
78                power_preference: wgpu::PowerPreference::HighPerformance,
79                compatible_surface: None,
80                force_fallback_adapter: false,
81            })
82            .await
83            .ok_or_else(|| Error::GpuInitFailed("No GPU adapter found".to_string()))?;
84
85        // Request device and queue
86        let (device, queue) = adapter
87            .request_device(
88                &wgpu::DeviceDescriptor {
89                    label: Some("Trueno-DB GPU Device"),
90                    required_features: wgpu::Features::empty(),
91                    required_limits: wgpu::Limits::default(),
92                    memory_hints: wgpu::MemoryHints::default(),
93                },
94                None,
95            )
96            .await
97            .map_err(|e| Error::GpuInitFailed(format!("Failed to create device: {e}")))?;
98
99        Ok(Self { device, queue, jit: jit::JitCompiler::new() })
100    }
101
102    /// Execute SUM aggregation on GPU
103    ///
104    /// # Arguments
105    /// * `data` - Input array (Int32 or Float32)
106    ///
107    /// # Returns
108    /// Sum of all elements
109    ///
110    /// # Errors
111    /// Returns error if GPU execution fails
112    pub async fn sum_i32(&self, data: &Int32Array) -> Result<i32> {
113        kernels::sum_i32(&self.device, &self.queue, data).await
114    }
115
116    /// Execute SUM aggregation on GPU (f32)
117    ///
118    /// # Errors
119    /// Returns error if GPU execution fails
120    pub async fn sum_f32(&self, data: &Float32Array) -> Result<f32> {
121        kernels::sum_f32(&self.device, &self.queue, data).await
122    }
123
124    /// Execute COUNT aggregation on GPU
125    ///
126    /// # Errors
127    /// Returns error if GPU execution fails
128    pub async fn count(&self, data: &dyn Array) -> Result<usize> {
129        kernels::count(&self.device, &self.queue, data).await
130    }
131
132    /// Execute MIN aggregation on GPU
133    ///
134    /// # Errors
135    /// Returns error if GPU execution fails
136    pub async fn min_i32(&self, data: &Int32Array) -> Result<i32> {
137        kernels::min_i32(&self.device, &self.queue, data).await
138    }
139
140    /// Execute MAX aggregation on GPU
141    ///
142    /// # Errors
143    /// Returns error if GPU execution fails
144    pub async fn max_i32(&self, data: &Int32Array) -> Result<i32> {
145        kernels::max_i32(&self.device, &self.queue, data).await
146    }
147
148    /// Execute AVG aggregation on GPU (reuses sum + count)
149    ///
150    /// # Errors
151    /// Returns error if GPU execution fails
152    #[allow(clippy::cast_precision_loss)]
153    pub async fn avg_f32(&self, data: &Float32Array) -> Result<f32> {
154        let sum = self.sum_f32(data).await?;
155        let count = self.count(data).await?;
156        if count == 0 {
157            Ok(0.0)
158        } else {
159            Ok(sum / count as f32)
160        }
161    }
162
163    /// Execute fused filter+sum aggregation on GPU (JIT-compiled kernel)
164    ///
165    /// Toyota Way: Muda elimination - fuses filter and sum in single pass,
166    /// eliminating intermediate buffer write.
167    ///
168    /// # Arguments
169    /// * `data` - Input array (Int32)
170    /// * `filter_threshold` - Filter threshold value (e.g., WHERE value > 1000)
171    /// * `filter_op` - Filter operator ("gt", "lt", "eq", "gte", "lte", "ne")
172    ///
173    /// # Returns
174    /// Sum of filtered elements
175    ///
176    /// # Errors
177    /// Returns error if GPU execution fails
178    ///
179    /// # Example
180    /// ```ignore
181    /// // Equivalent to: SELECT SUM(value) FROM data WHERE value > 1000
182    /// let result = engine.fused_filter_sum(&data, 1000, "gt").await?;
183    /// ```
184    #[allow(clippy::too_many_lines)]
185    #[allow(clippy::cast_possible_truncation)]
186    pub async fn fused_filter_sum(
187        &self,
188        data: &Int32Array,
189        filter_threshold: i32,
190        filter_op: &str,
191    ) -> Result<i32> {
192        // JIT compile the fused kernel (cached automatically)
193        let shader_module =
194            self.jit.compile_fused_filter_sum(&self.device, filter_threshold, filter_op);
195
196        // Prepare input data
197        let input_data: Vec<i32> = data.values().to_vec();
198        let input_size = input_data.len();
199
200        if input_size == 0 {
201            return Ok(0);
202        }
203
204        // Create GPU buffers
205        let input_buffer = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
206            label: Some("Fused Filter+Sum Input"),
207            contents: bytemuck::cast_slice(&input_data),
208            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
209        });
210
211        let output_buffer = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
212            label: Some("Fused Filter+Sum Output"),
213            contents: bytemuck::cast_slice(&[0i32]),
214            usage: wgpu::BufferUsages::STORAGE
215                | wgpu::BufferUsages::COPY_SRC
216                | wgpu::BufferUsages::COPY_DST,
217        });
218
219        // Create bind group layout
220        let bind_group_layout =
221            self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
222                label: Some("Fused Filter+Sum Bind Group Layout"),
223                entries: &[
224                    wgpu::BindGroupLayoutEntry {
225                        binding: 0,
226                        visibility: wgpu::ShaderStages::COMPUTE,
227                        ty: wgpu::BindingType::Buffer {
228                            ty: wgpu::BufferBindingType::Storage { read_only: true },
229                            has_dynamic_offset: false,
230                            min_binding_size: None,
231                        },
232                        count: None,
233                    },
234                    wgpu::BindGroupLayoutEntry {
235                        binding: 1,
236                        visibility: wgpu::ShaderStages::COMPUTE,
237                        ty: wgpu::BindingType::Buffer {
238                            ty: wgpu::BufferBindingType::Storage { read_only: false },
239                            has_dynamic_offset: false,
240                            min_binding_size: None,
241                        },
242                        count: None,
243                    },
244                ],
245            });
246
247        // Create bind group
248        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
249            label: Some("Fused Filter+Sum Bind Group"),
250            layout: &bind_group_layout,
251            entries: &[
252                wgpu::BindGroupEntry { binding: 0, resource: input_buffer.as_entire_binding() },
253                wgpu::BindGroupEntry { binding: 1, resource: output_buffer.as_entire_binding() },
254            ],
255        });
256
257        // Create compute pipeline
258        let pipeline_layout = self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
259            label: Some("Fused Filter+Sum Pipeline Layout"),
260            bind_group_layouts: &[&bind_group_layout],
261            push_constant_ranges: &[],
262        });
263
264        let compute_pipeline =
265            self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
266                label: Some("Fused Filter+Sum Pipeline"),
267                layout: Some(&pipeline_layout),
268                module: &shader_module,
269                entry_point: "fused_filter_sum",
270                compilation_options: wgpu::PipelineCompilationOptions::default(),
271                cache: None,
272            });
273
274        // Create command encoder and execute
275        let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
276            label: Some("Fused Filter+Sum Encoder"),
277        });
278
279        {
280            let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
281                label: Some("Fused Filter+Sum Pass"),
282                timestamp_writes: None,
283            });
284            compute_pass.set_pipeline(&compute_pipeline);
285            compute_pass.set_bind_group(0, &bind_group, &[]);
286
287            // Dispatch workgroups (256 threads per workgroup)
288            let workgroup_count = (input_size as u32).div_ceil(256);
289            compute_pass.dispatch_workgroups(workgroup_count, 1, 1);
290        }
291
292        // Copy output to staging buffer
293        let staging_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
294            label: Some("Fused Filter+Sum Staging Buffer"),
295            size: 4,
296            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
297            mapped_at_creation: false,
298        });
299
300        encoder.copy_buffer_to_buffer(&output_buffer, 0, &staging_buffer, 0, 4);
301
302        // Submit commands
303        self.queue.submit(Some(encoder.finish()));
304
305        // Read result
306        let buffer_slice = staging_buffer.slice(..);
307        let (tx, rx) = futures_intrusive::channel::shared::oneshot_channel();
308        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
309            tx.send(result).ok();
310        });
311
312        self.device.poll(wgpu::Maintain::Wait);
313
314        rx.receive()
315            .await
316            .ok_or_else(|| Error::Other("Failed to receive buffer map result".to_string()))?
317            .map_err(|e| Error::Other(format!("Buffer mapping failed: {e}")))?;
318
319        let data_view = buffer_slice.get_mapped_range();
320        let result = i32::from_le_bytes([data_view[0], data_view[1], data_view[2], data_view[3]]);
321
322        drop(data_view);
323        staging_buffer.unmap();
324
325        Ok(result)
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use arrow::array::Int32Array;
333
334    /// PMAT-927 FALSIFIER: the wgpu adapter-enumeration backend mask used by
335    /// aprender-db MUST NOT contain GLES (`wgpu::Backends::GL`), and MUST contain
336    /// the platform's real backend.
337    ///
338    /// RED on `Backends::all()` (contains GL → GLES/EGL adapter → SIGABRT-in-Drop
339    /// on Linux/AMD-RADV). GREEN on `Backends::PRIMARY`. Host-independent: it
340    /// inspects the bitmask, it does not create any adapter.
341    #[test]
342    fn test_gpu_backends_excludes_gles() {
343        let mask = gpu_backends();
344
345        // The whole point: GLES/EGL must never be enumerated.
346        assert!(
347            !mask.contains(wgpu::Backends::GL),
348            "gpu_backends() must NOT include Backends::GL (GLES/EGL panics in Drop \
349             on Linux/AMD-RADV → SIGABRT). mask = {mask:?}"
350        );
351
352        // The real GPU backend on each platform must still be present.
353        #[cfg(any(target_os = "linux", target_os = "android"))]
354        assert!(
355            mask.contains(wgpu::Backends::VULKAN),
356            "gpu_backends() must include VULKAN on Linux (AMD-RADV/NVIDIA). mask = {mask:?}"
357        );
358        #[cfg(target_os = "macos")]
359        assert!(
360            mask.contains(wgpu::Backends::METAL),
361            "gpu_backends() must include METAL on macOS (Apple Silicon). mask = {mask:?}"
362        );
363        #[cfg(target_os = "windows")]
364        assert!(
365            mask.contains(wgpu::Backends::VULKAN) || mask.contains(wgpu::Backends::DX12),
366            "gpu_backends() must include VULKAN or DX12 on Windows. mask = {mask:?}"
367        );
368    }
369
370    #[tokio::test]
371    async fn test_gpu_init() {
372        // This test may fail on machines without GPU
373        match GpuEngine::new().await {
374            Ok(_engine) => {
375                // GPU initialization succeeded
376            }
377            Err(e) => {
378                // Expected on machines without GPU
379                eprintln!("GPU initialization failed (expected on CI): {e}");
380            }
381        }
382    }
383
384    #[tokio::test]
385    async fn test_gpu_sum_basic() {
386        let Ok(engine) = GpuEngine::new().await else {
387            eprintln!("Skipping GPU test (no GPU available)");
388            return;
389        };
390
391        let data = Int32Array::from(vec![1, 2, 3, 4, 5]);
392        let result = engine.sum_i32(&data).await.unwrap();
393        assert_eq!(result, 15);
394    }
395
396    #[tokio::test]
397    async fn test_gpu_sum_empty() {
398        let Ok(engine) = GpuEngine::new().await else {
399            eprintln!("Skipping GPU test (no GPU available)");
400            return;
401        };
402
403        let data = Int32Array::from(vec![] as Vec<i32>);
404        let result = engine.sum_i32(&data).await.unwrap();
405        assert_eq!(result, 0);
406    }
407
408    #[tokio::test]
409    async fn test_gpu_min_i32() {
410        let Ok(engine) = GpuEngine::new().await else {
411            eprintln!("Skipping GPU test (no GPU available)");
412            return;
413        };
414
415        let data = Int32Array::from(vec![5, 2, 8, 1, 9]);
416        let result = engine.min_i32(&data).await.unwrap();
417        assert_eq!(result, 1);
418    }
419
420    #[tokio::test]
421    async fn test_gpu_min_empty() {
422        let Ok(engine) = GpuEngine::new().await else {
423            eprintln!("Skipping GPU test (no GPU available)");
424            return;
425        };
426
427        let data = Int32Array::from(vec![] as Vec<i32>);
428        let result = engine.min_i32(&data).await.unwrap();
429        assert_eq!(result, i32::MAX);
430    }
431
432    #[tokio::test]
433    async fn test_gpu_max_i32() {
434        let Ok(engine) = GpuEngine::new().await else {
435            eprintln!("Skipping GPU test (no GPU available)");
436            return;
437        };
438
439        let data = Int32Array::from(vec![5, 2, 8, 1, 9]);
440        let result = engine.max_i32(&data).await.unwrap();
441        assert_eq!(result, 9);
442    }
443
444    #[tokio::test]
445    async fn test_gpu_max_empty() {
446        let Ok(engine) = GpuEngine::new().await else {
447            eprintln!("Skipping GPU test (no GPU available)");
448            return;
449        };
450
451        let data = Int32Array::from(vec![] as Vec<i32>);
452        let result = engine.max_i32(&data).await.unwrap();
453        assert_eq!(result, i32::MIN);
454    }
455
456    #[tokio::test]
457    async fn test_gpu_count() {
458        let Ok(engine) = GpuEngine::new().await else {
459            eprintln!("Skipping GPU test (no GPU available)");
460            return;
461        };
462
463        let data = Int32Array::from(vec![1, 2, 3, 4, 5]);
464        let result = engine.count(&data).await.unwrap();
465        assert_eq!(result, 5);
466    }
467
468    #[tokio::test]
469    async fn test_gpu_sum_f32_not_implemented() {
470        let Ok(engine) = GpuEngine::new().await else {
471            eprintln!("Skipping GPU test (no GPU available)");
472            return;
473        };
474
475        let data = Float32Array::from(vec![1.0, 2.0, 3.0]);
476        let result = engine.sum_f32(&data).await;
477        assert!(result.is_err());
478        assert!(result.unwrap_err().to_string().contains("not yet implemented"));
479    }
480
481    #[tokio::test]
482    async fn test_gpu_avg_f32_not_implemented() {
483        let Ok(engine) = GpuEngine::new().await else {
484            eprintln!("Skipping GPU test (no GPU available)");
485            return;
486        };
487
488        let data = Float32Array::from(vec![2.0, 4.0, 6.0]);
489        let result = engine.avg_f32(&data).await;
490        // avg_f32 calls sum_f32 which returns error
491        assert!(result.is_err());
492    }
493
494    #[tokio::test]
495    async fn test_gpu_fused_filter_sum_gt() {
496        let Ok(engine) = GpuEngine::new().await else {
497            eprintln!("Skipping GPU test (no GPU available)");
498            return;
499        };
500
501        // Data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
502        // Filter: value > 5
503        // Expected: 6 + 7 + 8 + 9 + 10 = 40
504        let data = Int32Array::from(vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
505        let result = engine.fused_filter_sum(&data, 5, "gt").await.unwrap();
506        assert_eq!(result, 40);
507    }
508
509    #[tokio::test]
510    async fn test_gpu_fused_filter_sum_lt() {
511        let Ok(engine) = GpuEngine::new().await else {
512            eprintln!("Skipping GPU test (no GPU available)");
513            return;
514        };
515
516        // Data: [1, 2, 3, 4, 5]
517        // Filter: value < 4
518        // Expected: 1 + 2 + 3 = 6
519        let data = Int32Array::from(vec![1, 2, 3, 4, 5]);
520        let result = engine.fused_filter_sum(&data, 4, "lt").await.unwrap();
521        assert_eq!(result, 6);
522    }
523
524    #[tokio::test]
525    async fn test_gpu_fused_filter_sum_eq() {
526        let Ok(engine) = GpuEngine::new().await else {
527            eprintln!("Skipping GPU test (no GPU available)");
528            return;
529        };
530
531        // Data: [1, 5, 5, 3, 5]
532        // Filter: value == 5
533        // Expected: 5 + 5 + 5 = 15
534        let data = Int32Array::from(vec![1, 5, 5, 3, 5]);
535        let result = engine.fused_filter_sum(&data, 5, "eq").await.unwrap();
536        assert_eq!(result, 15);
537    }
538
539    #[tokio::test]
540    async fn test_gpu_fused_filter_sum_empty() {
541        let Ok(engine) = GpuEngine::new().await else {
542            eprintln!("Skipping GPU test (no GPU available)");
543            return;
544        };
545
546        let data = Int32Array::from(vec![] as Vec<i32>);
547        let result = engine.fused_filter_sum(&data, 5, "gt").await.unwrap();
548        assert_eq!(result, 0);
549    }
550
551    #[tokio::test]
552    async fn test_gpu_fused_filter_sum_no_matches() {
553        let Ok(engine) = GpuEngine::new().await else {
554            eprintln!("Skipping GPU test (no GPU available)");
555            return;
556        };
557
558        // All values < 100, so filter passes nothing
559        let data = Int32Array::from(vec![1, 2, 3, 4, 5]);
560        let result = engine.fused_filter_sum(&data, 100, "gt").await.unwrap();
561        assert_eq!(result, 0);
562    }
563}