use oxicuda_backend::{BackendError, BinaryOp, ComputeBackend, CpuBackend, ReduceOp, UnaryOp};
use oxicuda_webgpu::WebGpuBackend;
fn try_init() -> Option<WebGpuBackend> {
let mut b = WebGpuBackend::new();
b.init().ok().map(|()| b)
}
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_unit(&mut self) -> f32 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let bits = (self.0 >> 32) as u32;
(f64::from(bits) / f64::from(u32::MAX)) as f32 }
fn range(&mut self, lo: f32, hi: f32) -> f32 {
lo + self.next_unit() * (hi - lo)
}
fn vec(&mut self, len: usize, lo: f32, hi: f32) -> Vec<f32> {
(0..len).map(|_| self.range(lo, hi)).collect()
}
}
fn upload_f32<B: ComputeBackend>(be: &B, data: &[f32]) -> u64 {
let ptr = be.alloc((data.len() * 4).max(4)).expect("alloc");
let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
be.copy_htod(ptr, &bytes).expect("copy_htod");
ptr
}
fn download_f32<B: ComputeBackend>(be: &B, ptr: u64, len: usize) -> Vec<f32> {
let mut bytes = vec![0u8; len * 4];
be.copy_dtoh(&mut bytes, ptr).expect("copy_dtoh");
bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().expect("4 bytes")))
.collect()
}
fn assert_close(got: &[f32], want: &[f32], tol: f32, what: &str) {
assert_eq!(got.len(), want.len(), "{what}: length mismatch");
for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() {
let limit = tol * w.abs().max(1.0);
assert!(
(g - w).abs() <= limit,
"{what}: element {i} = {g}, expected {w} (tolerance {limit})"
);
}
}
#[test]
fn unary_ops_match_cpu_reference() {
let Some(webgpu) = try_init() else { return };
let cpu = CpuBackend::new();
let n = 257;
let ops: &[(UnaryOp, f32, f32)] = &[
(UnaryOp::Relu, -3.0, 3.0),
(UnaryOp::Sigmoid, -6.0, 6.0),
(UnaryOp::Tanh, -3.0, 3.0),
(UnaryOp::Exp, -4.0, 4.0),
(UnaryOp::Log, 0.01, 10.0), (UnaryOp::Sqrt, 0.0, 10.0), (UnaryOp::Abs, -5.0, 5.0),
(UnaryOp::Neg, -5.0, 5.0),
];
for &(op, lo, hi) in ops {
let mut rng = Lcg::new(0x0000_1234 ^ (op as u64));
let input = rng.vec(n, lo, hi);
let cpu_in = upload_f32(&cpu, &input);
let cpu_out = cpu.alloc(n * 4).expect("alloc");
cpu.unary(op, cpu_in, cpu_out, n).expect("cpu unary");
let want = download_f32(&cpu, cpu_out, n);
let g_in = upload_f32(&webgpu, &input);
let g_out = webgpu.alloc(n * 4).expect("alloc");
webgpu.unary(op, g_in, g_out, n).expect("webgpu unary");
let got = download_f32(&webgpu, g_out, n);
assert_close(&got, &want, 1e-4, &format!("unary {op}"));
}
}
#[test]
fn binary_ops_match_cpu_reference() {
let Some(webgpu) = try_init() else { return };
let cpu = CpuBackend::new();
let n = 259;
for &op in &[
BinaryOp::Add,
BinaryOp::Sub,
BinaryOp::Mul,
BinaryOp::Div,
BinaryOp::Max,
BinaryOp::Min,
] {
let mut rng = Lcg::new(0x0000_5678 ^ (op as u64));
let a = rng.vec(n, -5.0, 5.0);
let b: Vec<f32> = (0..n)
.map(|_| {
let v = rng.range(0.5, 5.0);
if rng.next_unit() < 0.5 { -v } else { v }
})
.collect();
let cpu_a = upload_f32(&cpu, &a);
let cpu_b = upload_f32(&cpu, &b);
let cpu_out = cpu.alloc(n * 4).expect("alloc");
cpu.binary(op, cpu_a, cpu_b, cpu_out, n)
.expect("cpu binary");
let want = download_f32(&cpu, cpu_out, n);
let g_a = upload_f32(&webgpu, &a);
let g_b = upload_f32(&webgpu, &b);
let g_out = webgpu.alloc(n * 4).expect("alloc");
webgpu
.binary(op, g_a, g_b, g_out, n)
.expect("webgpu binary");
let got = download_f32(&webgpu, g_out, n);
assert_close(&got, &want, 1e-4, &format!("binary {op}"));
}
}
#[test]
fn reduce_matches_cpu_all_ops_various_shapes() {
let Some(webgpu) = try_init() else { return };
let cpu = CpuBackend::new();
let cases: &[(&[usize], usize)] = &[
(&[6, 7], 0),
(&[6, 7], 1),
(&[100], 0), (&[1], 0),
];
for &op in &[ReduceOp::Sum, ReduceOp::Max, ReduceOp::Min, ReduceOp::Mean] {
for &(shape, axis) in cases {
let total: usize = shape.iter().product();
let outer: usize = shape[..axis].iter().product();
let inner: usize = shape[axis + 1..].iter().product();
let out_len = outer * inner;
let mut rng = Lcg::new(0x0000_9ABC ^ (op as u64) ^ (total as u64) ^ (axis as u64));
let input = rng.vec(total, -10.0, 10.0);
let cpu_in = upload_f32(&cpu, &input);
let cpu_out = cpu.alloc(out_len.max(1) * 4).expect("alloc");
cpu.reduce(op, cpu_in, cpu_out, shape, axis)
.expect("cpu reduce");
let want = download_f32(&cpu, cpu_out, out_len);
let g_in = upload_f32(&webgpu, &input);
let g_out = webgpu.alloc(out_len.max(1) * 4).expect("alloc");
webgpu
.reduce(op, g_in, g_out, shape, axis)
.expect("webgpu reduce");
let got = download_f32(&webgpu, g_out, out_len);
assert_close(
&got,
&want,
1e-3,
&format!("reduce {op} shape={shape:?} axis={axis}"),
);
}
}
}
#[test]
fn reduce_min_finds_the_true_minimum() {
let Some(webgpu) = try_init() else { return };
let cpu = CpuBackend::new();
let data: [f32; 10] = [
5.0, 3.0, -7.0, 9.0, 2.0, -1.0, -4.0, 0.5, -2.0, 8.0, ];
let shape = [2usize, 5usize];
let cpu_in = upload_f32(&cpu, &data);
let cpu_out = cpu.alloc(2 * 4).expect("alloc");
cpu.reduce(ReduceOp::Min, cpu_in, cpu_out, &shape, 1)
.expect("cpu reduce min");
let cpu_got = download_f32(&cpu, cpu_out, 2);
assert_eq!(cpu_got, vec![-7.0, -4.0], "cpu oracle sanity check");
let g_in = upload_f32(&webgpu, &data);
let g_out = webgpu.alloc(2 * 4).expect("alloc");
webgpu
.reduce(ReduceOp::Min, g_in, g_out, &shape, 1)
.expect("webgpu reduce min");
let got = download_f32(&webgpu, g_out, 2);
assert_close(&got, &cpu_got, 1e-5, "webgpu reduce Min vs cpu oracle");
}
#[test]
fn reduce_zero_length_dimension_is_a_silent_noop() {
let Some(webgpu) = try_init() else { return };
let ptr = webgpu.alloc(4).expect("alloc");
let out = webgpu.alloc(4).expect("alloc");
let sentinel = 12345.0f32;
webgpu
.copy_htod(out, &sentinel.to_le_bytes())
.expect("seed sentinel");
let result = webgpu.reduce(ReduceOp::Mean, ptr, out, &[0, 3], 0);
assert!(
result.is_ok(),
"expected Ok(()) (silent no-op) for a zero-length reduced dimension, got {result:?}"
);
let got = download_f32(&webgpu, out, 1);
assert_eq!(
got[0], sentinel,
"a zero-length reduce must leave the output buffer untouched"
);
}
#[test]
fn conv2d_matches_cpu_reference() {
let Some(webgpu) = try_init() else { return };
let cpu = CpuBackend::new();
let (n, c_in, h, w) = (1usize, 2usize, 5usize, 5usize);
let (k_out, fh, fw) = (3usize, 3usize, 3usize);
let (sh, sw) = (1usize, 1usize);
let (ph, pw) = (1usize, 1usize);
let oh = (h + 2 * ph - fh) / sh + 1;
let ow = (w + 2 * pw - fw) / sw + 1;
let mut rng = Lcg::new(0xC0432D);
let input = rng.vec(n * c_in * h * w, -2.0, 2.0);
let filter = rng.vec(k_out * c_in * fh * fw, -1.0, 1.0);
let cpu_in = upload_f32(&cpu, &input);
let cpu_filt = upload_f32(&cpu, &filter);
let cpu_out = cpu.alloc(n * k_out * oh * ow * 4).expect("alloc");
cpu.conv2d_forward(
cpu_in,
&[n, c_in, h, w],
cpu_filt,
&[k_out, c_in, fh, fw],
cpu_out,
&[n, k_out, oh, ow],
&[sh, sw],
&[ph, pw],
)
.expect("cpu conv2d");
let want = download_f32(&cpu, cpu_out, n * k_out * oh * ow);
let g_in = upload_f32(&webgpu, &input);
let g_filt = upload_f32(&webgpu, &filter);
let g_out = webgpu.alloc(n * k_out * oh * ow * 4).expect("alloc");
webgpu
.conv2d_forward(
g_in,
&[n, c_in, h, w],
g_filt,
&[k_out, c_in, fh, fw],
g_out,
&[n, k_out, oh, ow],
&[sh, sw],
&[ph, pw],
)
.expect("webgpu conv2d");
let got = download_f32(&webgpu, g_out, n * k_out * oh * ow);
assert_close(&got, &want, 1e-3, "conv2d_forward");
}
#[test]
fn attention_matches_cpu_reference_causal_and_noncausal() {
let Some(webgpu) = try_init() else { return };
let cpu = CpuBackend::new();
let (batch, heads, seq, head_dim) = (1usize, 2usize, 4usize, 8usize);
let scale = 1.0 / (head_dim as f64).sqrt();
let qkv_elems = batch * heads * seq * head_dim;
for causal in [false, true] {
let mut rng = Lcg::new(0xA77E17 ^ u64::from(causal));
let q = rng.vec(qkv_elems, -1.0, 1.0);
let k = rng.vec(qkv_elems, -1.0, 1.0);
let v = rng.vec(qkv_elems, -1.0, 1.0);
let cpu_q = upload_f32(&cpu, &q);
let cpu_k = upload_f32(&cpu, &k);
let cpu_v = upload_f32(&cpu, &v);
let cpu_o = cpu.alloc(qkv_elems * 4).expect("alloc");
cpu.attention(
cpu_q, cpu_k, cpu_v, cpu_o, batch, heads, seq, seq, head_dim, scale, causal,
)
.expect("cpu attention");
let want = download_f32(&cpu, cpu_o, qkv_elems);
let g_q = upload_f32(&webgpu, &q);
let g_k = upload_f32(&webgpu, &k);
let g_v = upload_f32(&webgpu, &v);
let g_o = webgpu.alloc(qkv_elems * 4).expect("alloc");
webgpu
.attention(
g_q, g_k, g_v, g_o, batch, heads, seq, seq, head_dim, scale, causal,
)
.expect("webgpu attention");
let got = download_f32(&webgpu, g_o, qkv_elems);
assert_close(&got, &want, 5e-3, &format!("attention causal={causal}"));
}
}
#[test]
fn softmax_is_unsupported() {
let Some(webgpu) = try_init() else { return };
let ptr = webgpu.alloc(4 * 4).expect("alloc");
let out = webgpu.alloc(4 * 4).expect("alloc");
let result = webgpu.softmax(ptr, out, &[4], 0);
assert!(
matches!(result, Err(BackendError::Unsupported(_))),
"expected Unsupported (WebGpuBackend has no softmax override), got {result:?}"
);
}