//! Composable element-wise GPU operations over a Boolean/u8 buffer representation. //! //! Representation: one `u8` per element; canonical Booleans are `0` (false) and `1` (true). //! `Negate` accepts only canonical input (it returns an error on any other byte) and //! writes canonical output. `Add` is wrapping `u8` addition, so its output is canonical //! only when no position has both inputs true. mod add; mod agents; mod canonical; mod expr; mod kernels; mod negate; pub use add::Add; pub use agents::{ExecutionAgent, ValidationAgent, VerificationAgent, Workflow}; pub use canonical::{ CANONICAL_FALSE, CANONICAL_TRUE, bools_to_gpu, gpu_to_bools, is_canonical, validate_buffer, }; pub use expr::Expr; pub use kernels::{ADD_U8_SIGNATURE, KERNEL_REGISTRY, NEGATE_U8_SIGNATURE, validate_registry}; pub use negate::Negate; use ocl::{Buffer, ProQue}; use std::fmt; const DEFAULT_LOCAL_SIZE: usize = 64; static INIT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[derive(Debug)] pub enum GpuError { Ocl(ocl::Error), /// Element count cannot be represented as the kernel's `uint` argument. InvalidCount(usize), /// A buffer is shorter than the requested element count. BufferTooShort { which: &'static str, len: usize, count: usize, }, /// `Expr::Input(index)` refers to an input that was not supplied. MissingInput { index: usize, supplied: usize, }, /// A zero-length device buffer was requested (OpenCL cannot allocate one). EmptyBuffer, /// A zero local work-group size was requested. InvalidLocalSize, } impl fmt::Display for GpuError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { GpuError::Ocl(e) => write!(f, "OpenCL error: {e}"), GpuError::InvalidCount(n) => write!(f, "invalid element count {n} (must fit in u32)"), GpuError::BufferTooShort { which, len, count } => { write!(f, "{which} buffer has {len} elements but count is {count}") } GpuError::MissingInput { index, supplied } => { write!(f, "expression input {index} missing ({supplied} supplied)") } GpuError::EmptyBuffer => write!(f, "zero-length device buffers cannot be allocated"), GpuError::InvalidLocalSize => write!(f, "local work-group size must be non-zero"), } } } impl std::error::Error for GpuError {} impl From for GpuError { fn from(e: ocl::Error) -> Self { GpuError::Ocl(e) } } pub type Result = std::result::Result; /// OpenCL context, queue and compiled program for all functor kernels. pub struct GpuContext { proque: ProQue, local_size: usize, } impl GpuContext { /// Create a context on the default OpenCL device and compile all functor kernels. pub fn new() -> Result { Self::with_source(&Self::kernel_source()) } /// Like [`GpuContext::new`] but with a requested work-group size (clamped to the device limit). pub fn with_local_size(local_size: usize) -> Result { let mut ctx = Self::new()?; ctx.set_local_size(local_size)?; Ok(ctx) } /// Build a context from arbitrary OpenCL source (compile errors are returned, not swallowed). pub fn with_source(src: &str) -> Result { // Validate kernel registry before creating context. validate_registry().map_err(|e| GpuError::Ocl(ocl::Error::from(e)))?; // Observed with pocl: concurrent first-time platform/device discovery from several // threads makes clGetDeviceIDs fail intermittently, so creation is serialised. let _guard = INIT_LOCK.lock().unwrap_or_else(|p| p.into_inner()); let proque = ProQue::builder().src(src.to_string()).build()?; let max = proque.device().max_wg_size()?; Ok(GpuContext { proque, local_size: DEFAULT_LOCAL_SIZE.min(max), }) } pub fn set_local_size(&mut self, local_size: usize) -> Result<()> { if local_size == 0 { return Err(GpuError::InvalidLocalSize); } let max = self.proque.device().max_wg_size()?; self.local_size = local_size.min(max); Ok(()) } pub fn local_size(&self) -> usize { self.local_size } pub fn device_name(&self) -> Result { Ok(self.proque.device().name()?) } /// All functor kernels in one program. pub fn kernel_source() -> String { format!("{}\n{}", negate::KERNEL_SRC, add::KERNEL_SRC) } /// Allocate a zero-initialised device buffer of `len` bytes (len must be > 0). pub fn alloc(&self, len: usize) -> Result> { self.alloc_filled(len, 0) } /// Allocate a device buffer filled with `val` (used to detect stale/out-of-bounds writes). pub fn alloc_filled(&self, len: usize, val: u8) -> Result> { if len == 0 { return Err(GpuError::EmptyBuffer); } Ok(Buffer::::builder() .queue(self.proque.queue().clone()) .len(len) .fill_val(val) .build()?) } /// Upload raw bytes to a new device buffer. pub fn upload_raw(&self, data: &[u8]) -> Result> { if data.is_empty() { return Err(GpuError::EmptyBuffer); } Ok(Buffer::::builder() .queue(self.proque.queue().clone()) .len(data.len()) .copy_host_slice(data) .build()?) } pub fn upload_bools(&self, data: &[bool]) -> Result> { let raw: Vec = data.iter().map(|&b| b as u8).collect(); self.upload_raw(&raw) } pub fn download_raw(&self, buf: &Buffer) -> Result> { let mut out = vec![0u8; buf.len()]; buf.cmd().queue(self.proque.queue()).read(&mut out).enq()?; Ok(out) } pub fn download_bools(&self, buf: &Buffer, count: usize) -> Result> { let raw = self.download_raw(buf)?; let slice = raw.get(..count).ok_or(GpuError::BufferTooShort { which: "download target", len: raw.len(), count, })?; validate_buffer(slice)?; Ok(slice.iter().map(|&b| b != 0).collect()) } pub(crate) fn proque(&self) -> &ProQue { &self.proque } /// Validate `count` against the buffers and return `(global, local)` work sizes. /// Global size is rounded up to a multiple of local size, so kernels MUST guard `gid < count`. pub(crate) fn work_sizes( &self, count: usize, bufs: &[(&'static str, usize)], ) -> Result<(usize, usize)> { if count > u32::MAX as usize { return Err(GpuError::InvalidCount(count)); } for &(which, len) in bufs { if len < count { return Err(GpuError::BufferTooShort { which, len, count }); } } let local = self.local_size; Ok((count.div_ceil(local) * local, local)) } } /// A unary device operation `F(A) -> F(A)` that is composable. pub trait UnaryOp { /// Enqueue `output[i] = op(input[i])` for `i < count`. Elements at `i >= count` are untouched. /// `count == 0` is a no-op. fn launch( &self, ctx: &GpuContext, input: &Buffer, output: &Buffer, count: usize, ) -> Result<()>; /// Sequential composition: `self.then(g)` computes `g(self(x))`. fn then(self, g: G) -> Compose where Self: Sized, { Compose { first: self, second: g, } } /// Convenience: run on host bools, executing on the device, returning a fresh Vec. fn run_bools(&self, ctx: &GpuContext, input: &[bool]) -> Result> { if input.is_empty() { return Ok(Vec::new()); } let inp = ctx.upload_bools(input)?; let out = ctx.alloc(input.len())?; self.launch(ctx, &inp, &out, input.len())?; ctx.download_bools(&out, input.len()) } } /// `Compose { first: F, second: G }` computes `G(F(x))` through a device temporary. pub struct Compose { first: F, second: G, } impl UnaryOp for Compose { fn launch( &self, ctx: &GpuContext, input: &Buffer, output: &Buffer, count: usize, ) -> Result<()> { if count == 0 { return Ok(()); } let tmp = ctx.alloc(count)?; self.first.launch(ctx, input, &tmp, count)?; self.second.launch(ctx, &tmp, output, count) } }