use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock, RwLock};
#[derive(Debug, thiserror::Error)]
pub enum GpuError {
#[error("GPU device poll failed while awaiting readback: {0}")]
Poll(String),
#[error("GPU staging buffer map failed: {0}")]
Map(String),
#[error("GPU buffer map callback never fired after wait-indefinitely poll")]
MapCallbackMissing,
#[error("GPU dispatch needs {needed} workgroups but the device limit is {limit}")]
TooManyWorkgroups {
needed: u32,
limit: u32,
},
#[error("GPU kernel binds {needed} storage buffers but the device limit is {limit}")]
TooManyStorageBuffers {
needed: u32,
limit: u32,
},
}
pub const WORKGROUP_SIZE: u32 = 64;
#[derive(Debug)]
pub struct GpuContext {
device: Arc<wgpu::Device>,
queue: Arc<wgpu::Queue>,
adapter_label: String,
max_workgroups_per_dim: u32,
max_storage_buffers: u32,
pipelines: RwLock<HashMap<&'static str, Arc<wgpu::ComputePipeline>>>,
}
impl GpuContext {
#[must_use]
pub fn adapter_label(&self) -> &str {
&self.adapter_label
}
#[must_use]
pub fn device(&self) -> &Arc<wgpu::Device> {
&self.device
}
#[must_use]
pub fn queue(&self) -> &Arc<wgpu::Queue> {
&self.queue
}
#[must_use]
pub fn max_lanes(&self) -> u64 {
u64::from(self.max_workgroups_per_dim) * u64::from(WORKGROUP_SIZE)
}
#[must_use]
pub fn max_storage_buffers(&self) -> u32 {
self.max_storage_buffers
}
pub fn dispatch(
&self,
label: &'static str,
wgsl: &str,
inputs: &[&[u8]],
output_bytes: u64,
params: &[u8],
lanes: u32,
) -> Result<Vec<u8>, GpuError> {
use wgpu::util::DeviceExt;
let workgroups = lanes.div_ceil(WORKGROUP_SIZE).max(1);
if workgroups > self.max_workgroups_per_dim {
return Err(GpuError::TooManyWorkgroups {
needed: workgroups,
limit: self.max_workgroups_per_dim,
});
}
let storage_needed = inputs.len() as u32 + 1;
if storage_needed > self.max_storage_buffers {
return Err(GpuError::TooManyStorageBuffers {
needed: storage_needed,
limit: self.max_storage_buffers,
});
}
let pipeline = self.pipeline_for(label, wgsl);
let padded: Vec<Vec<u8>> = inputs
.iter()
.map(|b| {
if b.is_empty() {
vec![0u8; 4]
} else {
b.to_vec()
}
})
.collect();
let input_buffers: Vec<wgpu::Buffer> = padded
.iter()
.map(|bytes| {
self.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
})
})
.collect();
let out_size = output_bytes.max(4);
let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: out_size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: out_size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut param_bytes = params.to_vec();
while param_bytes.is_empty() || !param_bytes.len().is_multiple_of(16) {
param_bytes.push(0);
}
let param_buffer = self
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: ¶m_bytes,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let mut entries: Vec<wgpu::BindGroupEntry> = Vec::with_capacity(inputs.len() + 2);
for (i, buf) in input_buffers.iter().enumerate() {
entries.push(wgpu::BindGroupEntry {
binding: i as u32,
resource: buf.as_entire_binding(),
});
}
entries.push(wgpu::BindGroupEntry {
binding: inputs.len() as u32,
resource: output_buffer.as_entire_binding(),
});
entries.push(wgpu::BindGroupEntry {
binding: inputs.len() as u32 + 1,
resource: param_buffer.as_entire_binding(),
});
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(label),
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
});
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
{
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(label),
timestamp_writes: None,
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(workgroups, 1, 1);
}
encoder.copy_buffer_to_buffer(&output_buffer, 0, &readback, 0, out_size);
self.queue.submit(Some(encoder.finish()));
let mapped = Arc::new(Mutex::new(None));
let cb = Arc::clone(&mapped);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, move |res| {
*cb.lock().unwrap() = Some(res);
});
const MAX_POLLS: u32 = 10_000;
let mut result = None;
for _ in 0..MAX_POLLS {
self.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| GpuError::Poll(format!("{e:?}")))?;
result = mapped.lock().unwrap().take();
if result.is_some() {
break;
}
std::thread::yield_now();
}
match result {
Some(Ok(())) => {}
Some(Err(e)) => return Err(GpuError::Map(format!("{e:?}"))),
None => return Err(GpuError::MapCallbackMissing),
}
let out = {
let view = readback
.slice(..)
.get_mapped_range()
.map_err(|e| GpuError::Map(format!("{e:?}")))?;
view[..output_bytes as usize].to_vec()
};
readback.unmap();
Ok(out)
}
fn pipeline_for(&self, label: &'static str, wgsl: &str) -> Arc<wgpu::ComputePipeline> {
if let Some(p) = self.pipelines.read().unwrap().get(label) {
return Arc::clone(p);
}
let module = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Wgsl(wgsl.into()),
});
let pipeline = Arc::new(self.device.create_compute_pipeline(
&wgpu::ComputePipelineDescriptor {
label: Some(label),
layout: None,
module: &module,
entry_point: Some("main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
cache: None,
},
));
self.pipelines
.write()
.unwrap()
.insert(label, Arc::clone(&pipeline));
pipeline
}
}
fn adapter_score(info: &wgpu::AdapterInfo) -> u32 {
let type_score = match info.device_type {
wgpu::DeviceType::DiscreteGpu => 300,
wgpu::DeviceType::IntegratedGpu => 200,
wgpu::DeviceType::VirtualGpu => 100,
_ => 0,
};
let backend_score = match info.backend {
wgpu::Backend::Vulkan | wgpu::Backend::Metal | wgpu::Backend::Dx12 => 30,
wgpu::Backend::BrowserWebGpu => 20,
wgpu::Backend::Gl => 1,
_ => 0,
};
type_score + backend_score
}
static CONTEXT: OnceLock<Option<GpuContext>> = OnceLock::new();
#[must_use]
pub fn context() -> Option<&'static GpuContext> {
CONTEXT.get_or_init(acquire).as_ref()
}
fn acquire() -> Option<GpuContext> {
let instance = wgpu::Instance::default();
let adapter = block_on(instance.enumerate_adapters(wgpu::Backends::all()))
.into_iter()
.filter(|a| a.get_info().device_type != wgpu::DeviceType::Cpu)
.max_by_key(|a| adapter_score(&a.get_info()))
.or_else(|| {
block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: None,
apply_limit_buckets: false,
}))
.ok()
})?;
let info = adapter.get_info();
let adapter_limits = adapter.limits();
let mut limits = wgpu::Limits::downlevel_defaults();
limits.max_storage_buffers_per_shader_stage = adapter_limits
.max_storage_buffers_per_shader_stage
.min(8)
.max(limits.max_storage_buffers_per_shader_stage);
limits.max_compute_workgroups_per_dimension = adapter_limits
.max_compute_workgroups_per_dimension
.max(limits.max_compute_workgroups_per_dimension);
limits.max_storage_buffer_binding_size = adapter_limits
.max_storage_buffer_binding_size
.max(limits.max_storage_buffer_binding_size);
limits.max_buffer_size = adapter_limits.max_buffer_size.max(limits.max_buffer_size);
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("outram-foam-basic-lib headless compute"),
required_features: wgpu::Features::empty(),
required_limits: limits.clone(),
..Default::default()
}))
.ok()?;
Some(GpuContext {
device: Arc::new(device),
queue: Arc::new(queue),
adapter_label: format!("{} ({:?}, {:?})", info.name, info.device_type, info.backend),
max_workgroups_per_dim: limits.max_compute_workgroups_per_dimension,
max_storage_buffers: limits.max_storage_buffers_per_shader_stage,
pipelines: RwLock::new(HashMap::new()),
})
}
#[must_use]
pub fn f64_to_f32_bytes(values: &[f64]) -> Vec<u8> {
let mut out = Vec::with_capacity(values.len() * 4);
for &v in values {
out.extend_from_slice(&(v as f32).to_le_bytes());
}
out
}
#[must_use]
pub fn u32_to_bytes(values: &[u32]) -> Vec<u8> {
let mut out = Vec::with_capacity(values.len() * 4);
for &v in values {
out.extend_from_slice(&v.to_le_bytes());
}
out
}
#[must_use]
pub fn bytes_to_f64_vec(bytes: &[u8]) -> Vec<f64> {
bytes
.as_chunks::<4>()
.0
.iter()
.map(|c| f32::from_le_bytes(*c) as f64)
.collect()
}
#[must_use]
pub fn bytes_to_u32_vec(bytes: &[u8]) -> Vec<u32> {
bytes
.as_chunks::<4>()
.0
.iter()
.map(|c| u32::from_le_bytes(*c))
.collect()
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::pin::Pin;
use std::task::{Context, Poll, Wake, Waker};
struct ThreadWaker(std::thread::Thread);
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let mut future = std::pin::pin!(future);
let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
let mut cx = Context::from_waker(&waker);
loop {
match Pin::as_mut(&mut future).poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => std::thread::park(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SCALE_WGSL: &str = r#"
@group(0) @binding(0) var<storage, read> src: array<f32>;
@group(0) @binding(1) var<storage, read_write> dst: array<f32>;
@group(0) @binding(2) var<uniform> params: vec4<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let i = gid.x;
if (i >= u32(params.y)) { return; }
dst[i] = src[i] * params.x;
}
"#;
#[test]
fn context_is_cached_and_never_panics() {
let a = context().is_some();
let b = context().is_some();
assert_eq!(a, b, "the probe must be cached, not re-run");
}
#[test]
fn dispatch_scales_a_buffer_or_skips_without_a_gpu() {
let Some(ctx) = context() else {
eprintln!("no GPU adapter — skipping (this is a valid outcome)");
return;
};
let src: Vec<f64> = (0..1000).map(|i| i as f64 * 0.5).collect();
let mut params = 3.0f32.to_le_bytes().to_vec();
params.extend_from_slice(&(src.len() as f32).to_le_bytes());
let bytes = ctx
.dispatch(
"test-scale",
SCALE_WGSL,
&[&f64_to_f32_bytes(&src)],
(src.len() * 4) as u64,
¶ms,
src.len() as u32,
)
.expect("dispatch on a present adapter");
let got = bytes_to_f64_vec(&bytes);
assert_eq!(got.len(), src.len());
for (i, (g, s)) in got.iter().zip(&src).enumerate() {
assert!(
(g - s * 3.0).abs() <= 1e-5 * (s * 3.0).abs().max(1.0),
"lane {i}: got {g}, want {}",
s * 3.0
);
}
}
#[test]
fn pipeline_cache_reuses_one_compilation() {
let Some(ctx) = context() else { return };
let src = vec![1.0f64; 64];
let mut params = 2.0f32.to_le_bytes().to_vec();
params.extend_from_slice(&(src.len() as f32).to_le_bytes());
let mut sizes = Vec::new();
for _ in 0..3 {
ctx.dispatch(
"test-scale-cache",
SCALE_WGSL,
&[&f64_to_f32_bytes(&src)],
(src.len() * 4) as u64,
¶ms,
src.len() as u32,
)
.expect("repeat dispatch");
sizes.push(ctx.pipelines.read().unwrap().len());
}
assert!(
ctx.pipelines
.read()
.unwrap()
.contains_key("test-scale-cache"),
"the label must be cached after the first dispatch"
);
assert_eq!(
sizes[1], sizes[2],
"a repeat dispatch of an already-compiled label must not grow the cache"
);
}
#[test]
fn round_trip_through_f32_is_within_f32_epsilon() {
let src = vec![1.0, -2.5, 1e6, 1e-6, std::f64::consts::PI];
let back = bytes_to_f64_vec(&f64_to_f32_bytes(&src));
for (a, b) in src.iter().zip(&back) {
assert!((a - b).abs() <= 1.2e-7 * a.abs().max(1.0));
}
}
#[test]
fn u32_round_trip_is_lossless() {
let src = vec![0u32, 1, 4_294_967_295, 12_345];
assert_eq!(bytes_to_u32_vec(&u32_to_bytes(&src)), src);
}
#[test]
fn empty_input_is_padded_not_rejected() {
let Some(ctx) = context() else { return };
let mut params = 1.0f32.to_le_bytes().to_vec();
params.extend_from_slice(&0.0f32.to_le_bytes());
let out = ctx.dispatch("test-scale-empty", SCALE_WGSL, &[&[]], 4, ¶ms, 0);
assert!(out.is_ok(), "empty dispatch must be handled, got {out:?}");
}
}