use super::*;
#[test]
fn attention_reused_output_buffer_does_not_accumulate_stale_data() {
let Some(b) = try_init() else { return };
let o_h = b.alloc(2 * 4).expect("alloc output");
let q1 = [1.0f32, 0.0];
let k1 = [1.0f32, 0.0, 1.0, 0.0];
let v1 = [1.0f32, 2.0, 3.0, 4.0];
let q1_h = upload_f32(&b, &q1);
let k1_h = upload_f32(&b, &k1);
let v1_h = upload_f32(&b, &v1);
b.attention(q1_h, k1_h, v1_h, o_h, 1, 1, 1, 2, 2, 1.0, false)
.expect("attention #1");
let first = download_f32(&b, o_h, 2);
assert!((first[0] - 2.0).abs() < 1e-4);
assert!((first[1] - 3.0).abs() < 1e-4);
let q2 = [1.0f32, 0.0];
let k2 = [10.0f32, 0.0, 0.0, 0.0];
let v2 = [100.0f32, 200.0, 0.0, 0.0];
let q2_h = upload_f32(&b, &q2);
let k2_h = upload_f32(&b, &k2);
let v2_h = upload_f32(&b, &v2);
b.attention(q2_h, k2_h, v2_h, o_h, 1, 1, 1, 2, 2, 1.0, false)
.expect("attention #2 (reused buffer)");
let second = download_f32(&b, o_h, 2);
assert!(
(second[0] - 100.0).abs() < 0.1,
"got {}, expected ~100 (stale accumulation bug?)",
second[0]
);
assert!(
(second[1] - 200.0).abs() < 0.1,
"got {}, expected ~200 (stale accumulation bug?)",
second[1]
);
b.free(q1_h).expect("free");
b.free(k1_h).expect("free");
b.free(v1_h).expect("free");
b.free(q2_h).expect("free");
b.free(k2_h).expect("free");
b.free(v2_h).expect("free");
b.free(o_h).expect("free");
}
#[allow(clippy::too_many_arguments)]
fn run_conv2d_gpu_vs_cpu_case(
batch: usize,
c_in: usize,
h_in: usize,
w_in: usize,
k_out: usize,
fh: usize,
fw: usize,
sh: usize,
sw: usize,
ph: usize,
pw: usize,
) {
let Some(b) = try_init() else { return };
let oh = (h_in + 2 * ph - fh) / sh + 1;
let ow = (w_in + 2 * pw - fw) / sw + 1;
let in_data: Vec<f32> = (0..batch * c_in * h_in * w_in)
.map(|x| ((x % 13) as f32) * 0.3 - 1.5)
.collect();
let f_data: Vec<f32> = (0..k_out * c_in * fh * fw)
.map(|x| ((x % 7) as f32) * 0.2 - 0.6)
.collect();
let expected = conv2d_cpu_reference(
&in_data, &f_data, batch, c_in, h_in, w_in, k_out, fh, fw, oh, ow, sh, sw, ph, pw,
);
let in_h = upload_f32(&b, &in_data);
let f_h = upload_f32(&b, &f_data);
let out_h = b.alloc(batch * k_out * oh * ow * 4).expect("alloc output");
b.conv2d_forward(
in_h,
&[batch, c_in, h_in, w_in],
f_h,
&[k_out, c_in, fh, fw],
out_h,
&[batch, k_out, oh, ow],
&[sh, sw],
&[ph, pw],
)
.expect("conv2d_forward");
let result = download_f32(&b, out_h, batch * k_out * oh * ow);
for (idx, (r, e)) in result.iter().zip(expected.iter()).enumerate() {
assert!(
(r - e).abs() < 1e-3 * (1.0 + e.abs()),
"batch={batch} c_in={c_in} k_out={k_out} slot={idx}: got {r}, expected {e}"
);
}
b.free(in_h).expect("free");
b.free(f_h).expect("free");
b.free(out_h).expect("free");
}
#[test]
fn conv2d_multi_channel_multi_filter_multi_batch() {
run_conv2d_gpu_vs_cpu_case(2, 3, 6, 6, 4, 3, 3, 1, 1, 0, 0);
}
#[test]
fn conv2d_spans_multiple_workgroups() {
run_conv2d_gpu_vs_cpu_case(1, 2, 22, 22, 5, 3, 3, 1, 1, 0, 0);
}
#[test]
fn conv2d_strided_with_padding_multi_channel() {
run_conv2d_gpu_vs_cpu_case(1, 2, 9, 9, 3, 3, 3, 2, 2, 1, 1);
}
#[test]
fn attention_spans_multiple_workgroups() {
let Some(b) = try_init() else { return };
let batch = 2usize;
let heads = 1usize;
let seq_q = 40usize;
let seq_kv = 6usize;
let head_dim = 8usize;
let batch_heads = batch * heads;
let q_data: Vec<f32> = (0..batch_heads * seq_q * head_dim)
.map(|x| ((x % 11) as f32) * 0.1 - 0.5)
.collect();
let k_data: Vec<f32> = (0..batch_heads * seq_kv * head_dim)
.map(|x| ((x % 7) as f32) * 0.15 + 0.2)
.collect();
let v_data: Vec<f32> = (0..batch_heads * seq_kv * head_dim)
.map(|x| ((x % 5) as f32) * 0.3 - 0.6)
.collect();
let expected = attention_cpu_reference(
&q_data,
&k_data,
&v_data,
batch_heads,
seq_q,
seq_kv,
head_dim,
0.125,
true,
);
let q_h = upload_f32(&b, &q_data);
let k_h = upload_f32(&b, &k_data);
let v_h = upload_f32(&b, &v_data);
let o_h = b
.alloc(batch_heads * seq_q * head_dim * 4)
.expect("alloc output");
b.attention(
q_h, k_h, v_h, o_h, batch, heads, seq_q, seq_kv, head_dim, 0.125, true,
)
.expect("attention multi-workgroup");
let result = download_f32(&b, o_h, batch_heads * seq_q * head_dim);
for (idx, (r, e)) in result.iter().zip(expected.iter()).enumerate() {
assert!(
(r - e).abs() < 1e-3 * (1.0 + e.abs()),
"slot={idx}: got {r}, expected {e}"
);
}
b.free(q_h).expect("free");
b.free(k_h).expect("free");
b.free(v_h).expect("free");
b.free(o_h).expect("free");
}
#[test]
fn batched_gemm_rejects_oversize_batch_count() {
let Some(b) = try_init() else { return };
let err = b
.batched_gemm(
BackendTranspose::NoTrans,
BackendTranspose::NoTrans,
2,
2,
2,
1.0,
0,
2,
4,
0,
2,
4,
0.0,
0,
2,
4,
70_000, )
.unwrap_err();
assert!(matches!(err, BackendError::InvalidArgument(_)));
}
#[test]
fn batched_gemm_rejects_oversize_stride() {
let Some(b) = try_init() else { return };
let oversize = (u32::MAX as usize) + 1;
let err = b
.batched_gemm(
BackendTranspose::NoTrans,
BackendTranspose::NoTrans,
2,
2,
2,
1.0,
0,
2,
oversize,
0,
2,
4,
0.0,
0,
2,
4,
2,
)
.unwrap_err();
assert!(matches!(err, BackendError::InvalidArgument(_)));
}
#[test]
fn reduce_1d_rejects_undersized_input_buffer() {
let Some(b) = try_init() else { return };
let in_h = b.alloc(2 * 4).expect("alloc undersized input");
let out_h = b.alloc(4).expect("alloc output");
let err = b.reduce(ReduceOp::Sum, in_h, out_h, &[8], 0).unwrap_err();
assert!(matches!(err, BackendError::InvalidArgument(_)));
b.free(in_h).expect("free");
b.free(out_h).expect("free");
}
#[test]
fn reduce_nd_rejects_undersized_input_buffer() {
let Some(b) = try_init() else { return };
let in_h = b.alloc(4 * 4).expect("alloc undersized input");
let out_h = b.alloc(4 * 4).expect("alloc output");
let err = b
.reduce(ReduceOp::Sum, in_h, out_h, &[3, 4], 0)
.unwrap_err();
assert!(matches!(err, BackendError::InvalidArgument(_)));
b.free(in_h).expect("free");
b.free(out_h).expect("free");
}
#[test]
fn reduce_nd_rejects_undersized_output_buffer() {
let Some(b) = try_init() else { return };
let data: Vec<f32> = (0..12).map(|x| x as f32).collect();
let in_h = upload_f32(&b, &data);
let out_h = b.alloc(4).expect("alloc undersized output");
let err = b
.reduce(ReduceOp::Sum, in_h, out_h, &[3, 4], 0)
.unwrap_err();
assert!(matches!(err, BackendError::InvalidArgument(_)));
b.free(in_h).expect("free");
b.free(out_h).expect("free");
}
#[test]
fn gpu_device_is_live_when_required() {
for (batch, k_out, oh, ow) in [
(3usize, 4usize, 4usize, 4usize),
(1, 5, 20, 20),
(1, 3, 5, 5),
] {
assert!(
conv2d_gpu_dispatch_grid(batch, k_out, oh, ow).is_some(),
"conv2d {batch}x{k_out}x{oh}x{ow} must dispatch on the GPU, not the CPU fallback"
);
}
assert!(
attention_gpu_dispatch_grid(2, 40).is_some(),
"attention 2x40 must dispatch on the GPU, not the CPU fallback"
);
let Some(b) = try_init() else {
assert!(
!require_gpu(),
"OXICUDA_REQUIRE_GPU=1 but no WebGPU adapter is available"
);
return;
};
let caps = b.capabilities();
assert!(
caps.max_threads_per_block > 0,
"an initialised adapter must report a nonzero threads-per-workgroup limit"
);
let adapter_name = b
.device
.as_ref()
.map(|d| d.adapter_name.clone())
.unwrap_or_default();
assert!(
!adapter_name.is_empty(),
"an initialised backend must hold an adapter with a real name"
);
println!(
"WEBGPU WITNESS: adapter={adapter_name:?} max_threads={} max_workgroups_per_dim={}",
caps.max_threads_per_block,
gpu_limits().max_workgroups_per_dim
);
}