Skip to main content

scirs2_core/gpu/backends/opencl/
mod.rs

1//! OpenCL backend implementation for GPU operations.
2//!
3//! This module provides OpenCL-specific implementations for GPU operations.
4//! It talks to the OpenCL ICD entirely through the in-repo pure-Rust runtime
5//! loader in the internal `ffi` submodule — there is no `#[link]`, no `build.rs`, and no
6//! `-lOpenCL`, so the crate builds on machines without an OpenCL development
7//! package. When no ICD is present at runtime the backend reports itself
8//! unavailable and callers fall through to another backend, exactly as with
9//! the wgpu/Metal/CPU paths.
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13
14use crate::gpu::{GpuBufferImpl, GpuCompilerImpl, GpuContextImpl, GpuError, GpuKernelImpl};
15
16mod ffi;
17mod memory_pool;
18
19use ffi::{ClBuffer, ClContext, ClKernel, ClProgram, ClQueue};
20use memory_pool::OpenCLMemoryPool;
21
22// OpenCL kernel source templates
23#[allow(dead_code)]
24const ADAM_KERNEL_OPENCL: &str = r#"
25__kernel void adam_update_f32(
26    __global float* params, __global const float* grads, __global float* m, __global float* v,
27    const float lr,
28    const float beta1,
29    const float beta2,
30    const float eps,
31    const float weight_decay,
32    const float bias_correction1,
33    const float bias_correction2,
34    const int n
35) {
36    const int idx = get_global_id(0);
37
38    if (idx < n) {
39        float grad = grads[idx];
40
41        // Apply weight decay
42        if (weight_decay > 0.0f) {
43            grad += weight_decay * params[idx];
44        }
45
46        // Update biased first moment estimate
47        m[idx] = beta1 * m[idx] + (1.0f - beta1) * grad;
48
49        // Update biased second raw moment estimate
50        v[idx] = beta2 * v[idx] + (1.0f - beta2) * grad * grad;
51
52        // Compute bias-corrected moment estimates
53        float m_hat = m[idx] / bias_correction1;
54        float v_hat = v[idx] / bias_correction2;
55
56        // Update parameters
57        params[idx] -= lr * m_hat / (sqrt(v_hat) + eps);
58    }
59}
60"#;
61
62#[allow(dead_code)]
63const GEMM_KERNEL_OPENCL: &str = r#"
64__kernel void gemm_f32(
65    __global const float* A, __global const float* B, __global float* C,
66    const int M,
67    const int N,
68    const int K,
69    const float alpha,
70    const float beta
71) {
72    const int row = get_global_id(0);
73    const int col = get_global_id(1);
74
75    if (row < M && col < N) {
76        float sum = 0.0f;
77        for (int k = 0; k < K; k++) {
78            sum += A[row * K + k] * B[k * N + col];
79        }
80        C[row * N + col] = alpha * sum + beta * C[row * N + col];
81    }
82}
83"#;
84
85/// OpenCL context wrapper
86pub struct OpenCLContext {
87    /// Device id used for program builds; a plain handle (not released here).
88    device: ffi::cl_device_id,
89    context: Arc<ClContext>,
90    queue: Arc<ClQueue>,
91    compiled_kernels: Arc<Mutex<HashMap<String, OpenCLKernel>>>,
92    memory_pool: Arc<Mutex<OpenCLMemoryPool>>,
93}
94
95// SAFETY: `OpenCLContext` only holds thread-safe OpenCL handles (the RAII
96// newtypes are `Send + Sync`) plus a raw `cl_device_id` handle. The device id
97// is an immutable handle used solely to build programs; all mutable queue
98// state is guarded by `Arc<Mutex<..>>`. OpenCL runtime objects are safe to
99// reference across threads, so the context is `Send + Sync`.
100unsafe impl Send for OpenCLContext {}
101unsafe impl Sync for OpenCLContext {}
102
103impl OpenCLContext {
104    /// Create a new OpenCL context backed by the first available GPU device.
105    pub fn new() -> Result<Self, GpuError> {
106        let api = ffi::api().ok_or_else(|| GpuError::BackendNotAvailable("OpenCL".to_string()))?;
107
108        let platforms = api.platform_ids()?;
109        if platforms.is_empty() {
110            return Err(GpuError::Other("No OpenCL platforms found".to_string()));
111        }
112
113        let device_ids = api.device_ids(ffi::CL_DEVICE_TYPE_GPU)?;
114        if device_ids.is_empty() {
115            return Err(GpuError::Other("No OpenCL GPU devices found".to_string()));
116        }
117        let device = device_ids[0];
118
119        let context = ClContext(api.create_context(device)?);
120        let queue = ClQueue(api.create_command_queue(context.0, device)?);
121
122        Ok(Self {
123            device,
124            context: Arc::new(context),
125            queue: Arc::new(queue),
126            compiled_kernels: Arc::new(Mutex::new(HashMap::new())),
127            memory_pool: Arc::new(Mutex::new(OpenCLMemoryPool::new(1024 * 1024 * 1024))), // 1GB pool
128        })
129    }
130
131    /// Check if OpenCL is available and at least one GPU device exists.
132    pub fn is_available() -> bool {
133        ffi::api().is_some_and(|api| {
134            api.device_ids(ffi::CL_DEVICE_TYPE_GPU)
135                .is_ok_and(|devices| !devices.is_empty())
136        })
137    }
138
139    /// Compile a kernel from OpenCL source.
140    fn compile_kernel_internal(&self, source: &str, name: &str) -> Result<OpenCLKernel, GpuError> {
141        let api = ffi::api().ok_or_else(|| GpuError::BackendNotAvailable("OpenCL".to_string()))?;
142
143        let program = ClProgram(api.build_program(self.context.0, self.device, source)?);
144        let kernel = ClKernel(api.create_kernel(program.0, name)?);
145
146        Ok(OpenCLKernel {
147            program,
148            kernel,
149            queue: Arc::clone(&self.queue),
150            name: name.to_string(),
151        })
152    }
153
154    /// Allocate a device memory buffer of `size` bytes.
155    pub fn allocate_device_memory(&self, size: usize) -> Result<ClBuffer, GpuError> {
156        let api = ffi::api().ok_or_else(|| GpuError::BackendNotAvailable("OpenCL".to_string()))?;
157        let mem = api.create_buffer(self.context.0, ffi::CL_MEM_READ_WRITE, size)?;
158        Ok(ClBuffer { mem, size })
159    }
160}
161
162impl GpuContextImpl for OpenCLContext {
163    fn create_buffer(&self, size: usize) -> Arc<dyn GpuBufferImpl> {
164        // Try to allocate from memory pool first
165        if let Ok(mut pool) = self.memory_pool.lock() {
166            if let Some(buffer) = pool.allocate(size) {
167                return Arc::new(OpenCLBuffer {
168                    buffer: Some(buffer),
169                    queue: Arc::clone(&self.queue),
170                    size,
171                    memory_pool: Arc::clone(&self.memory_pool),
172                });
173            }
174        }
175
176        // Fallback to direct allocation
177        match self.allocate_device_memory(size) {
178            Ok(buffer) => Arc::new(OpenCLBuffer {
179                buffer: Some(buffer),
180                queue: Arc::clone(&self.queue),
181                size,
182                memory_pool: Arc::clone(&self.memory_pool),
183            }),
184            Err(e) => {
185                // Create a CPU fallback buffer when OpenCL memory is exhausted
186                eprintln!(
187                    "Warning: OpenCL buffer allocation failed ({e}), creating CPU fallback buffer"
188                );
189                Arc::new(OpenCLCpuFallbackBuffer {
190                    data: vec![0u8; size],
191                    size,
192                    memory_pool: Arc::clone(&self.memory_pool),
193                })
194            }
195        }
196    }
197
198    fn create_compiler(&self) -> Arc<dyn GpuCompilerImpl> {
199        Arc::new(OpenCLCompiler {
200            context: Arc::new(OpenCLContext {
201                device: self.device,
202                context: Arc::clone(&self.context),
203                queue: Arc::clone(&self.queue),
204                compiled_kernels: Arc::clone(&self.compiled_kernels),
205                memory_pool: Arc::clone(&self.memory_pool),
206            }),
207        })
208    }
209}
210
211/// OpenCL kernel wrapper
212struct OpenCLKernel {
213    // Retained so the program outlives the kernel created from it; released
214    // through `ClProgram`'s `Drop`.
215    #[allow(dead_code)]
216    program: ClProgram,
217    kernel: ClKernel,
218    queue: Arc<ClQueue>,
219    #[allow(dead_code)]
220    name: String,
221}
222
223/// OpenCL compiler implementation
224struct OpenCLCompiler {
225    context: Arc<OpenCLContext>,
226}
227
228impl GpuCompilerImpl for OpenCLCompiler {
229    fn compile(&self, source: &str) -> Result<Arc<dyn GpuKernelImpl>, GpuError> {
230        let kernel = self.context.compile_kernel_internal(source, "kernel")?;
231        let name = kernel.name.clone();
232        // Store the compiled kernel so `dispatch` can look it up by name.
233        if let Ok(mut kernels) = self.context.compiled_kernels.lock() {
234            kernels.insert(name.clone(), kernel);
235        }
236        Ok(Arc::new(OpenCLKernelHandle {
237            kernel_name: name,
238            compiled_kernels: Arc::clone(&self.context.compiled_kernels),
239            params: Arc::new(Mutex::new(Vec::new())),
240        }))
241    }
242
243    fn compile_typed(
244        &self,
245        name: &str,
246        _input_type: std::any::TypeId,
247        _output_type: std::any::TypeId,
248    ) -> Result<Arc<dyn GpuKernelImpl>, GpuError> {
249        // No source and no registry access are available here, so a real
250        // OpenCL program can't actually be built for an arbitrary `name`.
251        // Previously this handed back a handle referencing a kernel name
252        // that was never inserted into `compiled_kernels`, so `dispatch`
253        // silently found nothing every time. Fail honestly instead: real
254        // compilation is available via `GpuCompilerImpl::compile` (real
255        // OpenCL C source) or `GpuContext::get_kernel` (registry-backed
256        // named kernels).
257        Err(GpuError::KernelCompilationError(format!(
258            "compile_typed has no generated OpenCL source for kernel '{name}'; use \
259             GpuCompiler::compile with real OpenCL C source, or GpuContext::get_kernel for \
260             registry-backed named kernels"
261        )))
262    }
263}
264
265/// OpenCL kernel handle for execution
266struct OpenCLKernelHandle {
267    kernel_name: String,
268    compiled_kernels: Arc<Mutex<HashMap<String, OpenCLKernel>>>,
269    // Insertion-ordered parameter list. The order in which parameters are set
270    // is the stable OpenCL argument index order used when binding.
271    params: Arc<Mutex<Vec<(String, KernelParam)>>>,
272}
273
274enum KernelParam {
275    Buffer(Arc<dyn GpuBufferImpl>),
276    U32(u32),
277    I32(i32),
278    F32(f32),
279    F64(f64),
280}
281
282impl OpenCLKernelHandle {
283    /// Insert or replace a parameter by name while preserving insertion order.
284    fn set_param(&self, name: &str, param: KernelParam) {
285        if let Ok(mut params) = self.params.lock() {
286            if let Some(slot) = params.iter_mut().find(|(n, _)| n == name) {
287                slot.1 = param;
288            } else {
289                params.push((name.to_string(), param));
290            }
291        }
292    }
293}
294
295impl GpuKernelImpl for OpenCLKernelHandle {
296    fn set_buffer(&self, name: &str, buffer: &Arc<dyn GpuBufferImpl>) {
297        self.set_param(name, KernelParam::Buffer(Arc::clone(buffer)));
298    }
299
300    fn set_u32(&self, name: &str, value: u32) {
301        self.set_param(name, KernelParam::U32(value));
302    }
303
304    fn set_i32(&self, name: &str, value: i32) {
305        self.set_param(name, KernelParam::I32(value));
306    }
307
308    fn set_f32(&self, name: &str, value: f32) {
309        self.set_param(name, KernelParam::F32(value));
310    }
311
312    fn set_f64(&self, name: &str, value: f64) {
313        self.set_param(name, KernelParam::F64(value));
314    }
315
316    fn dispatch(&self, workgroups: [u32; 3]) {
317        // Every step degrades to a no-op (never a panic) when a precondition
318        // is unmet: no ICD, poisoned lock, or unknown kernel name.
319        let Some(api) = ffi::api() else {
320            return;
321        };
322        let Ok(kernels) = self.compiled_kernels.lock() else {
323            return;
324        };
325        let Some(kernel) = kernels.get(&self.kernel_name) else {
326            return;
327        };
328        let Ok(params) = self.params.lock() else {
329            return;
330        };
331
332        let kernel_handle = kernel.kernel.0;
333
334        // Bind every argument in stable (insertion) index order.
335        for (index, (_name, param)) in params.iter().enumerate() {
336            let index = index as ffi::cl_uint;
337            let bind = match param {
338                KernelParam::Buffer(buffer) => {
339                    match buffer.as_any().downcast_ref::<OpenCLBuffer>() {
340                        Some(cl_buffer) => match cl_buffer.mem_handle() {
341                            // A buffer arg is bound as the address of its
342                            // `cl_mem` handle with size `size_of::<cl_mem>()`.
343                            Some(mem) => api.set_arg_mem(kernel_handle, index, &mem),
344                            None => Ok(()),
345                        },
346                        // A CPU fallback buffer has no device handle to bind.
347                        None => Ok(()),
348                    }
349                }
350                KernelParam::U32(value) => {
351                    api.set_arg_bytes(kernel_handle, index, &value.to_ne_bytes())
352                }
353                KernelParam::I32(value) => {
354                    api.set_arg_bytes(kernel_handle, index, &value.to_ne_bytes())
355                }
356                KernelParam::F32(value) => {
357                    api.set_arg_bytes(kernel_handle, index, &value.to_ne_bytes())
358                }
359                KernelParam::F64(value) => {
360                    api.set_arg_bytes(kernel_handle, index, &value.to_ne_bytes())
361                }
362            };
363            if bind.is_err() {
364                return;
365            }
366        }
367
368        // Enqueue the kernel and block for completion.
369        let global = [workgroups[0] as usize];
370        let local = [64usize];
371        if api
372            .enqueue_nd_range(kernel.queue.0, kernel_handle, &global, Some(&local))
373            .is_err()
374        {
375            return;
376        }
377        let _ = api.finish(kernel.queue.0);
378    }
379}
380
381/// OpenCL buffer implementation
382struct OpenCLBuffer {
383    // `None` only transiently while being returned to the pool in `Drop`.
384    buffer: Option<ClBuffer>,
385    queue: Arc<ClQueue>,
386    size: usize,
387    memory_pool: Arc<Mutex<OpenCLMemoryPool>>,
388}
389
390impl OpenCLBuffer {
391    /// The underlying `cl_mem` handle, if this buffer still owns one.
392    fn mem_handle(&self) -> Option<ffi::cl_mem> {
393        self.buffer.as_ref().map(|b| b.mem)
394    }
395}
396
397impl GpuBufferImpl for OpenCLBuffer {
398    fn size(&self) -> usize {
399        self.size
400    }
401
402    unsafe fn copy_from_host(&self, data: *const u8, size: usize) {
403        if size > self.size {
404            return;
405        }
406        let Some(api) = ffi::api() else {
407            return;
408        };
409        let Some(buffer) = self.buffer.as_ref() else {
410            return;
411        };
412        let data_slice = std::slice::from_raw_parts(data, size);
413        if let Err(e) = api.enqueue_write(self.queue.0, buffer.mem, 0, data_slice) {
414            eprintln!("Warning: OpenCL write buffer failed: {e}");
415        }
416    }
417
418    unsafe fn copy_to_host(&self, data: *mut u8, size: usize) {
419        if size > self.size {
420            return;
421        }
422        let Some(api) = ffi::api() else {
423            return;
424        };
425        let Some(buffer) = self.buffer.as_ref() else {
426            return;
427        };
428        let data_slice = std::slice::from_raw_parts_mut(data, size);
429        if let Err(e) = api.enqueue_read(self.queue.0, buffer.mem, 0, data_slice) {
430            eprintln!("Warning: OpenCL read buffer failed: {e}");
431        }
432    }
433
434    fn as_any(&self) -> &dyn std::any::Any {
435        self
436    }
437}
438
439impl Drop for OpenCLBuffer {
440    fn drop(&mut self) {
441        // Return the buffer to the memory pool when possible; otherwise it is
442        // dropped here, releasing the device memory via `ClBuffer`'s `Drop`.
443        if let Some(buffer) = self.buffer.take() {
444            if let Ok(mut pool) = self.memory_pool.lock() {
445                pool.deallocate(buffer);
446            }
447        }
448    }
449}
450
451/// CPU fallback buffer for when OpenCL buffer allocation fails
452/// This provides a graceful degradation when GPU memory is exhausted
453struct OpenCLCpuFallbackBuffer {
454    data: Vec<u8>,
455    size: usize,
456    #[allow(dead_code)]
457    memory_pool: Arc<Mutex<OpenCLMemoryPool>>,
458}
459
460impl GpuBufferImpl for OpenCLCpuFallbackBuffer {
461    fn size(&self) -> usize {
462        self.size
463    }
464
465    unsafe fn copy_from_host(&self, data: *const u8, size: usize) {
466        if size > self.size {
467            eprintln!("Warning: OpenCL CPU fallback buffer copy_from_host size mismatch");
468            return;
469        }
470
471        // Since this is a CPU fallback, we can use safe Rust internally
472        let _data_slice = std::slice::from_raw_parts(data, size);
473        // We can't mutate self.data directly since &self is immutable
474        // In a real implementation, this would require interior mutability
475        eprintln!("Warning: CPU fallback buffer copy_from_host called (size: {size})");
476    }
477
478    unsafe fn copy_to_host(&self, data: *mut u8, size: usize) {
479        if size > self.size {
480            eprintln!("Warning: OpenCL CPU fallback buffer copy_to_host size mismatch");
481            return;
482        }
483
484        // Copy from CPU buffer to host
485        let data_slice = std::slice::from_raw_parts_mut(data, size);
486        let copy_size = size.min(self.data.len());
487        data_slice[..copy_size].copy_from_slice(&self.data[..copy_size]);
488
489        eprintln!("Warning: CPU fallback buffer copy_to_host called (size: {size})");
490    }
491
492    fn device_ptr(&self) -> u64 {
493        self.data.as_ptr() as u64
494    }
495
496    fn as_any(&self) -> &dyn std::any::Any {
497        self
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    /// The ICD probe must never panic, must be cached (stable across calls),
506    /// and its availability is logged either way. On the build host
507    /// `libOpenCL.so.1` is present so it typically resolves; bare CI machines
508    /// may lack it — both outcomes keep this test green.
509    #[test]
510    fn ffi_api_probe_does_not_panic_and_is_stable() {
511        let available = ffi::api().is_some();
512        println!("OpenCL ICD loadable: {available}");
513        assert_eq!(available, ffi::api().is_some());
514    }
515
516    /// `OpenCLContext::is_available()` must return a value without panicking,
517    /// regardless of whether an ICD or GPU device is present.
518    #[test]
519    fn is_available_does_not_panic() {
520        let available = OpenCLContext::is_available();
521        println!("OpenCLContext::is_available() = {available}");
522    }
523
524    /// `OpenCLContext::new()` degrades gracefully: `Ok` on a machine with a
525    /// usable OpenCL GPU, `Err` otherwise — never a panic.
526    #[test]
527    fn context_new_degrades_gracefully() {
528        match OpenCLContext::new() {
529            Ok(_ctx) => println!("OpenCL context created (GPU device present)"),
530            Err(e) => println!("OpenCL unavailable, graceful error: {e}"),
531        }
532    }
533
534    /// The public `GpuContext` entry point for the OpenCL backend must also
535    /// degrade gracefully to an error (never a panic) when unavailable.
536    #[test]
537    fn gpu_context_opencl_degrades_gracefully() {
538        use crate::gpu::{GpuBackend, GpuContext};
539
540        // Detection is panic-free.
541        let _ = GpuBackend::OpenCL.is_available();
542
543        // Construction yields Ok (real GPU) or Err (BackendNotAvailable / no
544        // device), never a panic.
545        match GpuContext::new(GpuBackend::OpenCL) {
546            Ok(_ctx) => println!("GpuContext(OpenCL) created"),
547            Err(e) => println!("GpuContext(OpenCL) graceful error: {e}"),
548        }
549    }
550}