open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-omni/tests/metal_kernels.rs
Claude bdeccb7403
Some checks failed
jouleclaw fabrics / cuda (linux, RTX 4050 + Vulkan loader) (pull_request) Successful in 18s
jouleclaw fabrics / vulkan (linux, RTX 4050 + Vulkan loader) (pull_request) Successful in 18s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 5s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 43s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 2m25s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 2m4s
standards workspaces build + test / joulecontract-rs (joulecontract/joulecontract-rs, joulecontract-rs, linux-arm64, true) (pull_request) Successful in 55s
standards workspaces build + test / map-rs (map-conformance, map/map-rs, map-rs, linux-arm64) (pull_request) Successful in 58s
standards workspaces build + test / aac-rs (aac-conformance, aac/aac-rs, aac-rs, linux-arm64) (pull_request) Successful in 3m17s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 56s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 3m19s
jouleclaw fabrics / metal (macOS, Apple GPU) (pull_request) Successful in 42s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 59s
standards workspaces build + test / macOS energy backends (pull_request) Failing after 3m58s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m37s
standards workspaces build + test / arl-rs (arl/arl-rs, arl-rs, linux) (pull_request) Successful in 1m9s
standards workspaces build + test / eoc-rs (eoc/eoc-rs, -p eoc-meter --features cuda, eoc-rs, linux) (pull_request) Successful in 2m7s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m42s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m13s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m13s
standards workspaces build + test / proof-rs (proof-conformance, proof/proof-rs, proof-rs, linux, true) (pull_request) Successful in 1m36s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m23s
standards workspaces build + test / sense-rs (sense/sense-rs, sense-rs, linux) (pull_request) Successful in 1m14s
standards workspaces build + test / smart-byte-rs (smart-byte/smart-byte-rs, smart-byte-rs, linux) (pull_request) Successful in 4m10s
jouleclaw: metal clippy reaches zero, and -D warnings now gates it
10983 -> 0, and `jouleclaw-omni --features metal` joins the `-D warnings`
clippy step in standards-build.yml, so it cannot regress. The pinned
ratchet added earlier is deleted: a denial is strictly stronger than a
count that may not rise.

The honest accounting, because "zero" is doing some work in that sentence:

  6774  fixed by `cargo clippy --fix` (earlier commit)
  3896  allowed as crate policy, grouped with reasons (earlier commit)
   282  allowed here, grouped with reasons
    ~31 fixed here

Of the ~31 fixed here, three were defects rather than style:

- the GPT-2 BPE pre-tokenizer regex, which never compiled (its own commit)
- `unet.rs` computed a kernel-size heuristic and shadowed it on the very
  next line, so the first was dead; and a `to_q/to_k/to_v` branch returned
  the same `cin` as the arm below it
- a `///` block documenting nothing

The rest were mechanical: checked_div for guarded divisions, clone_from,
an `#[ignore]` given its reason, 6.28 -> TAU, 0.6931472 -> LN_2.

Two allows are soundness notes, not style, and say so at the attribute:

- `cast_ptr_alignment` (8): these cast mmap'd GGUF bytes to f32/u16
  slices. GGUF aligns tensor data, so they are aligned in practice, but
  that is a file-format invariant the type system is not carrying.
  bytemuck::try_cast_slice would check it.
- `mut_from_ref` (1): `as_mut_slice(&self)` hands out a `&mut` from a
  `&`. It is unsafe and the caller carries aliasing, but the signature
  invites misuse.

One allow is a correctness argument against the lint: `manual_clamp`.
`x.min(hi).max(lo)` is not `x.clamp(lo, hi)` for floats, because
`f32::min` returns the non-NaN operand -- the chain scrubs NaN while
clamp propagates it. Taking clippy's advice would let a NaN timestep
through a diffusion schedule.

Verified after every edit, not at the end: 0 errors, 44/44 shaders,
26/26 Metal kernel tests on a GPU, 44 omni tests, 343 loader-gguf tests,
and `clippy -- -D warnings` exits 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-20 09:10:05 -04:00

1284 lines
45 KiB
Rust

//! Kernels in `sources::PHASE27_OPS` and the matvec pair, executed on a GPU.
//!
//! Every shader in this crate is built at runtime by `compile_pipeline`, and
//! until this file existed nothing ever dispatched one: `cargo test --features
//! metal` ran exactly the same 46 tests as the default build. Compiling a
//! kernel says nothing about whether it computes the right thing, so each test
//! here checks a dispatch against a CPU reference, or against the kernel that
//! is supposed to invert it.
//!
//! These need a real Metal device. They do not skip when one is missing --
//! a test that passes by doing nothing is worse than no test.
#![cfg(feature = "metal")]
// The CPU reference loops walk an output by sample index while reading the
// delay line at a different index (n % delay_len), which is the whole point
// of the recurrence; iterator form cannot express it.
#![allow(clippy::needless_range_loop)]
use half::f16;
use jouleclaw_omni::hal::metal::shader::sources;
use jouleclaw_omni::hal::metal::{MetalCompute, MetalDevice};
use jouleclaw_omni::inference::gpu_ops;
use jouleclaw_omni::tensor::{DType, Shape, Tensor};
use std::sync::Arc;
fn compute() -> MetalCompute {
let device = MetalDevice::new().expect("a Metal device is required by these tests");
MetalCompute::new(Arc::new(device))
}
/// Deterministic pseudo-random values in roughly [-1, 1].
fn ramp(n: usize, seed: u32) -> Vec<f16> {
(0..n)
.map(|i| {
let x = (i as u32).wrapping_mul(2654435761).wrapping_add(seed);
let u = ((x >> 8) & 0xffff) as f32 / 32768.0 - 1.0;
f16::from_f32(u)
})
.collect()
}
fn tensor(c: &MetalCompute, data: &[f16], shape: impl Into<Shape>) -> Tensor {
Tensor::from_slice(data, shape, DType::F16, c.device().info().id).expect("tensor")
}
fn read(t: &Tensor) -> Vec<f32> {
t.to_vec::<f16>()
.expect("read back")
.iter()
.map(|v| v.to_f32())
.collect()
}
#[track_caller]
fn assert_close(got: &[f32], want: &[f32], tol: f32, what: &str) {
assert_eq!(got.len(), want.len(), "{what}: length");
for (i, (g, w)) in got.iter().zip(want).enumerate() {
let scale = w.abs().max(1.0);
assert!(
(g - w).abs() <= tol * scale,
"{what}: index {i} got {g}, want {w} (tol {tol})"
);
}
}
// ─── shape plumbing: an inverse pair is its own reference ───────────────────
#[test]
fn window_partition_then_unpartition_is_identity() {
let c = compute();
let part = c
.compile_pipeline("t_wp", sources::PHASE27_OPS, "window_partition_f16")
.expect("window_partition_f16");
let unpart = c
.compile_pipeline("t_wu", sources::PHASE27_OPS, "window_unpartition_f16")
.expect("window_unpartition_f16");
let (h, w, dim, wh, ww) = (8usize, 12usize, 5usize, 4usize, 4usize);
let src = ramp(h * w * dim, 11);
let x = tensor(&c, &src, [h * w, dim]);
let cb = c.new_command_buffer();
let windows = gpu_ops::window_partition_on(&c, &part, cb.as_ref(), &x, h, w, dim, wh, ww);
cb.commit();
cb.wait_until_completed();
assert_eq!(windows.shape().dims(), &[(h / wh) * (w / ww), wh * ww, dim]);
let cb = c.new_command_buffer();
let back = gpu_ops::window_unpartition_on(&c, &unpart, cb.as_ref(), &windows, h, w, dim, wh, ww);
cb.commit();
cb.wait_until_completed();
let want: Vec<f32> = src.iter().map(|v| v.to_f32()).collect();
assert_close(&read(&back), &want, 0.0, "window round trip");
}
#[test]
fn patchify_3d_then_unpatchify_is_identity() {
let c = compute();
let pat = c
.compile_pipeline("t_p3", sources::PHASE27_OPS, "patchify_3d_f16")
.expect("patchify_3d_f16");
let unpat = c
.compile_pipeline("t_u3", sources::PHASE27_OPS, "unpatchify_3d_f16")
.expect("unpatchify_3d_f16");
let (ch, t, h, w, pt, ph, pw) = (3usize, 2usize, 8usize, 6usize, 1usize, 2usize, 2usize);
let src = ramp(ch * t * h * w, 23);
let x = tensor(&c, &src, [ch, t, h, w]);
let cb = c.new_command_buffer();
let patches = gpu_ops::patchify_3d_on(&c, &pat, cb.as_ref(), &x, ch, t, h, w, pt, ph, pw);
cb.commit();
cb.wait_until_completed();
let num_patches = (t / pt) * (h / ph) * (w / pw);
assert_eq!(patches.shape().dims(), &[num_patches, ch * pt * ph * pw]);
let cb = c.new_command_buffer();
let back = gpu_ops::unpatchify_3d_on(&c, &unpat, cb.as_ref(), &patches, ch, t, h, w, pt, ph, pw);
cb.commit();
cb.wait_until_completed();
let want: Vec<f32> = src.iter().map(|v| v.to_f32()).collect();
assert_close(&read(&back), &want, 0.0, "patchify round trip");
}
#[test]
fn split_qkv_separates_the_three_projections() {
let c = compute();
let k = c
.compile_pipeline("t_sq", sources::PHASE27_OPS, "split_qkv_f16")
.expect("split_qkv_f16");
let (seq, dim) = (7usize, 6usize);
let src = ramp(seq * 3 * dim, 31);
let qkv = tensor(&c, &src, [seq, 3 * dim]);
let cb = c.new_command_buffer();
let (q, kk, v) = gpu_ops::split_qkv_on(&c, &k, cb.as_ref(), &qkv, seq, dim).expect("split");
cb.commit();
cb.wait_until_completed();
for (which, got) in [(0usize, &q), (1, &kk), (2, &v)] {
let mut want = Vec::with_capacity(seq * dim);
for s in 0..seq {
for d in 0..dim {
want.push(src[s * 3 * dim + which * dim + d].to_f32());
}
}
assert_close(&read(got), &want, 0.0, &format!("split_qkv part {which}"));
}
}
#[test]
fn kv_head_expand_repeats_each_kv_head() {
let c = compute();
let k = c
.compile_pipeline("t_kv", sources::PHASE27_OPS, "kv_head_expand_f16")
.expect("kv_head_expand_f16");
let (seq, kv_heads, heads, hd) = (4usize, 2usize, 6usize, 3usize);
let src = ramp(seq * kv_heads * hd, 41);
let x = tensor(&c, &src, [seq, kv_heads, hd]);
let cb = c.new_command_buffer();
let out = gpu_ops::kv_head_expand_on(&c, &k, cb.as_ref(), &x, seq, kv_heads, heads, hd);
cb.commit();
cb.wait_until_completed();
let group = heads / kv_heads;
let mut want = Vec::with_capacity(seq * heads * hd);
for s in 0..seq {
for h in 0..heads {
let kvh = h / group;
for d in 0..hd {
want.push(src[(s * kv_heads + kvh) * hd + d].to_f32());
}
}
}
assert_close(&read(&out), &want, 0.0, "kv_head_expand");
}
#[test]
fn max_pool2d_takes_the_window_maximum() {
let c = compute();
let k = c
.compile_pipeline("t_mp", sources::PHASE27_OPS, "max_pool2d_f16")
.expect("max_pool2d_f16");
let (ch, h, w) = (2usize, 6usize, 8usize);
let src = ramp(ch * h * w, 53);
let x = tensor(&c, &src, [ch, h, w]);
let cb = c.new_command_buffer();
let out = gpu_ops::max_pool2d_on(&c, &k, cb.as_ref(), &x, ch, h, w);
cb.commit();
cb.wait_until_completed();
let (oh, ow) = (h / 2, w / 2);
let mut want = Vec::with_capacity(ch * oh * ow);
for cc in 0..ch {
for y in 0..oh {
for x0 in 0..ow {
let mut m = f32::NEG_INFINITY;
for dy in 0..2 {
for dx in 0..2 {
m = m.max(src[cc * h * w + (y * 2 + dy) * w + (x0 * 2 + dx)].to_f32());
}
}
want.push(m);
}
}
}
assert_close(&read(&out), &want, 0.0, "max_pool2d");
}
#[test]
fn modulation_split_splits_six_ways() {
let c = compute();
let k = c
.compile_pipeline("t_ms", sources::PHASE27_OPS, "modulation_split_f16")
.expect("modulation_split_f16");
let hidden = 9usize;
let src = ramp(hidden * 6, 61);
let params = tensor(&c, &src, [hidden * 6]);
let cb = c.new_command_buffer();
let parts = gpu_ops::modulation_split_on(&c, &k, cb.as_ref(), &params, hidden).expect("split");
cb.commit();
cb.wait_until_completed();
for (i, part) in parts.iter().enumerate() {
let want: Vec<f32> = (0..hidden).map(|j| src[i * hidden + j].to_f32()).collect();
assert_close(&read(part), &want, 0.0, &format!("modulation part {i}"));
}
}
// ─── arithmetic: checked against a CPU reference ────────────────────────────
#[test]
fn elu_matches_cpu() {
let c = compute();
let k = c
.compile_pipeline("t_elu", sources::PHASE27_OPS, "elu_f16")
.expect("elu_f16");
let src = ramp(257, 71); // deliberately not a multiple of the threadgroup
let x = tensor(&c, &src, [src.len()]);
let alpha = 1.3f32;
let cb = c.new_command_buffer();
let out = gpu_ops::elu_on(&c, &k, cb.as_ref(), &x, alpha);
cb.commit();
cb.wait_until_completed();
let want: Vec<f32> = src
.iter()
.map(|v| {
let v = v.to_f32();
if v >= 0.0 { v } else { alpha * (v.exp() - 1.0) }
})
.collect();
assert_close(&read(&out), &want, 2e-3, "elu");
}
#[test]
fn euler_step_matches_cpu() {
let c = compute();
let k = c
.compile_pipeline("t_eu", sources::PHASE27_OPS, "euler_step_f16")
.expect("euler_step_f16");
let xs = ramp(130, 83);
let vs = ramp(130, 97);
let dt = 0.25f32;
let xt = tensor(&c, &xs, [xs.len()]);
let vt = tensor(&c, &vs, [vs.len()]);
let cb = c.new_command_buffer();
let out = gpu_ops::euler_step_on(&c, &k, cb.as_ref(), &xt, &vt, dt);
cb.commit();
cb.wait_until_completed();
let want: Vec<f32> = xs
.iter()
.zip(&vs)
.map(|(a, b)| a.to_f32() + dt * b.to_f32())
.collect();
assert_close(&read(&out), &want, 2e-3, "euler_step");
}
#[test]
fn rope_batched_matches_cpu_rotation() {
let c = compute();
let k = c
.compile_pipeline("t_rope", sources::PHASE27_OPS, "rope_batched_f16")
.expect("rope_batched_f16");
let (seq, heads, hd, start) = (5usize, 3usize, 8usize, 2usize);
let theta = 10000.0f32;
let src = ramp(seq * heads * hd, 101);
let io = tensor(&c, &src, [seq, heads, hd]);
let cb = c.new_command_buffer();
gpu_ops::rope_batched_on(&c, &k, cb.as_ref(), &io, theta, seq, heads, hd, start);
cb.commit();
cb.wait_until_completed();
let halfd = hd / 2;
let mut want: Vec<f32> = src.iter().map(|v| v.to_f32()).collect();
for s in 0..seq {
for h in 0..heads {
let base = (s * heads + h) * hd;
for j in 0..halfd {
let inv = theta.powf(-((2 * j) as f32) / hd as f32);
let angle = (start + s) as f32 * inv;
let (sn, cs) = angle.sin_cos();
let x0 = src[base + j].to_f32();
let x1 = src[base + halfd + j].to_f32();
want[base + j] = x0 * cs - x1 * sn;
want[base + halfd + j] = x0 * sn + x1 * cs;
}
}
}
assert_close(&read(&io), &want, 3e-3, "rope_batched");
}
#[test]
fn matvec_matches_cpu() {
let c = compute();
let k = c
.compile_pipeline("t_mv", sources::MATVEC, "matvec_f16")
.expect("matvec_f16");
let (n, kdim) = (12usize, 64usize);
let xs = ramp(kdim, 113);
let ws = ramp(n * kdim, 127);
let x = tensor(&c, &xs, [1, kdim]);
let w = tensor(&c, &ws, [n, kdim]);
let device = c.device().raw();
let out_buf = device.new_buffer((n * 2) as u64, metal_shared());
let cb = c.new_command_buffer();
let (nn, kk) = (n as u32, kdim as u32);
c.dispatch(cb.as_ref(), &k, (n, 1, 1), (32, 1, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &x);
gpu_ops::set_tensor_buffer(e, 1, &w);
e.set_buffer(2, Some(&out_buf), 0);
e.set_bytes(3, 4, &nn as *const u32 as *const _);
e.set_bytes(4, 4, &kk as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let got = unsafe { std::slice::from_raw_parts(out_buf.contents() as *const f16, n) };
let got: Vec<f32> = got.iter().map(|v| v.to_f32()).collect();
let want: Vec<f32> = (0..n)
.map(|r| {
(0..kdim)
.map(|i| ws[r * kdim + i].to_f32() * xs[i].to_f32())
.sum()
})
.collect();
assert_close(&got, &want, 5e-3, "matvec");
}
#[test]
fn matmul_q8_0_matches_cpu_dequantized_matmul() {
let c = compute();
let k = c
.compile_pipeline("t_q8", sources::MATMUL_Q8_0, "matmul_q8_0_f16")
.expect("matmul_q8_0_f16");
// Q8_0: 32 values per block, 34 bytes -- f16 scale then 32 int8.
const QK: usize = 32;
const BLK: usize = 34;
let (n, kdim) = (6usize, 64usize);
let blocks_per_row = kdim / QK;
let xs = ramp(kdim, 131);
let mut raw = vec![0u8; n * blocks_per_row * BLK];
let mut dequant = vec![0f32; n * kdim];
for r in 0..n {
for b in 0..blocks_per_row {
let scale = 0.01 + 0.003 * ((r * blocks_per_row + b) as f32);
let off = (r * blocks_per_row + b) * BLK;
raw[off..off + 2].copy_from_slice(&f16::from_f32(scale).to_le_bytes());
for i in 0..QK {
let q = (((r * 7 + b * 13 + i * 3) % 255) as i32 - 127) as i8;
raw[off + 2 + i] = q as u8;
dequant[r * kdim + b * QK + i] = q as f32 * f16::from_f32(scale).to_f32();
}
}
}
let x = tensor(&c, &xs, [1, kdim]);
let device = c.device().raw();
let w_buf = device.new_buffer_with_data(
raw.as_ptr() as *const _,
raw.len() as u64,
metal_shared(),
);
let out_buf = device.new_buffer((n * 2) as u64, metal_shared());
let cb = c.new_command_buffer();
let (nn, kk) = (n as u32, kdim as u32);
c.dispatch(cb.as_ref(), &k, (n, 1, 1), (32, 1, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &x);
e.set_buffer(1, Some(&w_buf), 0);
e.set_buffer(2, Some(&out_buf), 0);
e.set_bytes(3, 4, &nn as *const u32 as *const _);
e.set_bytes(4, 4, &kk as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let got = unsafe { std::slice::from_raw_parts(out_buf.contents() as *const f16, n) };
let got: Vec<f32> = got.iter().map(|v| v.to_f32()).collect();
let want: Vec<f32> = (0..n)
.map(|r| {
(0..kdim)
.map(|i| dequant[r * kdim + i] * xs[i].to_f32())
.sum()
})
.collect();
assert_close(&got, &want, 1e-2, "matmul_q8_0");
}
#[test]
fn flash_attention_matches_naive_softmax_attention() {
let c = compute();
let k = c
.compile_pipeline("t_fa", sources::PHASE27_OPS, "flash_attention_f16")
.expect("flash_attention_f16");
// kv_seq deliberately not a multiple of the 32-wide tile, and q_seq
// spans more than one query block.
let (heads, q_seq, kv_seq, hd) = (2usize, 40usize, 37usize, 16usize);
let scale = 1.0f32 / (hd as f32).sqrt();
let qs = ramp(heads * q_seq * hd, 211);
let ks = ramp(heads * kv_seq * hd, 223);
let vs = ramp(heads * kv_seq * hd, 227);
let q = tensor(&c, &qs, [heads, q_seq, hd]);
let kt = tensor(&c, &ks, [heads, kv_seq, hd]);
let vt = tensor(&c, &vs, [heads, kv_seq, hd]);
let device = c.device().raw();
let out_buf = device.new_buffer((heads * q_seq * hd * 2) as u64, metal_shared());
let block = 32usize;
let q_blocks = q_seq.div_ceil(block);
let tg_mem = (3 * block * hd * 2) as u64;
let (qs_u, kv_u, hd_u) = (q_seq as u32, kv_seq as u32, hd as u32);
let cb = c.new_command_buffer();
c.dispatch(cb.as_ref(), &k, (heads, q_blocks, 1), (block, 1, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &q);
gpu_ops::set_tensor_buffer(e, 1, &kt);
gpu_ops::set_tensor_buffer(e, 2, &vt);
e.set_buffer(3, Some(&out_buf), 0);
e.set_bytes(4, 4, &qs_u as *const u32 as *const _);
e.set_bytes(5, 4, &kv_u as *const u32 as *const _);
e.set_bytes(6, 4, &hd_u as *const u32 as *const _);
e.set_bytes(7, 4, &scale as *const f32 as *const _);
e.set_threadgroup_memory_length(0, tg_mem);
});
cb.commit();
cb.wait_until_completed();
let got = unsafe {
std::slice::from_raw_parts(out_buf.contents() as *const f16, heads * q_seq * hd)
};
let got: Vec<f32> = got.iter().map(|v| v.to_f32()).collect();
// Naive reference: full softmax over all keys, non-causal.
let mut want = vec![0f32; heads * q_seq * hd];
for h in 0..heads {
for i in 0..q_seq {
let qb = (h * q_seq + i) * hd;
let mut logits = Vec::with_capacity(kv_seq);
for j in 0..kv_seq {
let kb = (h * kv_seq + j) * hd;
let dot: f32 = (0..hd)
.map(|d| qs[qb + d].to_f32() * ks[kb + d].to_f32())
.sum();
logits.push(dot * scale);
}
let m = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let exps: Vec<f32> = logits.iter().map(|l| (l - m).exp()).collect();
let denom: f32 = exps.iter().sum();
for d in 0..hd {
let acc: f32 = (0..kv_seq)
.map(|j| exps[j] * vs[(h * kv_seq + j) * hd + d].to_f32())
.sum();
want[(h * q_seq + i) * hd + d] = acc / denom;
}
}
}
assert_close(&got, &want, 1e-2, "flash_attention");
}
/// `StorageModeShared`, without naming the `metal` crate as a dev-dependency.
fn metal_shared() -> metal::MTLResourceOptions {
metal::MTLResourceOptions::StorageModeShared
}
// ─── the Mochi video-VAE decode stages ──────────────────────────────────────
//
// These six are dispatched directly from `architecture/mochi.rs` rather than
// through a `*_on` helper, so each test reproduces that call site's grid shape
// as well as its buffer layout. Note the grids are threadgroup counts, not
// thread counts, so they oversubscribe heavily -- that is the existing
// convention, and the bounds guards in the kernels are what make it safe.
#[test]
fn vae_conv1x1_matches_cpu() {
let c = compute();
let k = c
.compile_pipeline("t_v1", sources::PHASE27_OPS, "vae_conv1x1_f16")
.expect("vae_conv1x1_f16");
let (in_ch, out_ch, spatial) = (5usize, 3usize, 7usize);
let xs = ramp(in_ch * spatial, 301);
let ws = ramp(out_ch * in_ch, 307);
let bs = ramp(out_ch, 311);
let x = tensor(&c, &xs, [in_ch, spatial]);
let w = tensor(&c, &ws, [out_ch, in_ch]);
let b = tensor(&c, &bs, [out_ch]);
let out = c
.device()
.raw()
.new_buffer((out_ch * spatial * 2) as u64, metal_shared());
let (i_u, o_u, s_u) = (in_ch as u32, out_ch as u32, spatial as u32);
let cb = c.new_command_buffer();
c.dispatch(cb.as_ref(), &k, (spatial, out_ch, 1), (16, 16, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &x);
gpu_ops::set_tensor_buffer(e, 1, &w);
gpu_ops::set_tensor_buffer(e, 2, &b);
e.set_buffer(3, Some(&out), 0);
e.set_bytes(4, 4, &i_u as *const u32 as *const _);
e.set_bytes(5, 4, &o_u as *const u32 as *const _);
e.set_bytes(6, 4, &s_u as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let mut want = vec![0f32; out_ch * spatial];
for oc in 0..out_ch {
for s in 0..spatial {
let mut acc = bs[oc].to_f32();
for ic in 0..in_ch {
acc += ws[oc * in_ch + ic].to_f32() * xs[ic * spatial + s].to_f32();
}
want[oc * spatial + s] = acc;
}
}
assert_close(&read_buf_f16(&out, out_ch * spatial), &want, 5e-3, "vae_conv1x1");
}
#[test]
fn vae_spatial_upsample_2x_repeats_each_pixel() {
let c = compute();
let k = c
.compile_pipeline("t_vsu", sources::PHASE27_OPS, "vae_spatial_upsample_2x_f16")
.expect("vae_spatial_upsample_2x_f16");
let (ch, t, ih, iw) = (2usize, 3usize, 4usize, 5usize);
let (oh, ow) = (ih * 2, iw * 2);
let xs = ramp(ch * t * ih * iw, 317);
let x = tensor(&c, &xs, [ch, t * ih * iw]);
let out = c
.device()
.raw()
.new_buffer((ch * t * oh * ow * 2) as u64, metal_shared());
let (c_u, t_u, h_u, w_u) = (ch as u32, t as u32, ih as u32, iw as u32);
let cb = c.new_command_buffer();
c.dispatch(cb.as_ref(), &k, (ow, oh, ch * t), (16, 16, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &x);
e.set_buffer(1, Some(&out), 0);
e.set_bytes(2, 4, &c_u as *const u32 as *const _);
e.set_bytes(3, 4, &t_u as *const u32 as *const _);
e.set_bytes(4, 4, &h_u as *const u32 as *const _);
e.set_bytes(5, 4, &w_u as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let mut want = vec![0f32; ch * t * oh * ow];
for cc in 0..ch {
for tt in 0..t {
for y in 0..oh {
for x0 in 0..ow {
let src = xs[cc * t * ih * iw + tt * ih * iw + (y / 2) * iw + (x0 / 2)];
want[cc * t * oh * ow + tt * oh * ow + y * ow + x0] = src.to_f32();
}
}
}
}
assert_close(
&read_buf_f16(&out, ch * t * oh * ow),
&want,
0.0,
"vae_spatial_upsample_2x",
);
}
#[test]
fn vae_channel_reduce_averages_each_channel_group() {
let c = compute();
let k = c
.compile_pipeline("t_vcr", sources::PHASE27_OPS, "vae_channel_reduce_f16")
.expect("vae_channel_reduce_f16");
let (in_ch, out_ch, spatial) = (8usize, 3usize, 6usize); // 8/3 = group of 2, remainder
let xs = ramp(in_ch * spatial, 331);
let x = tensor(&c, &xs, [in_ch, spatial]);
let out = c
.device()
.raw()
.new_buffer((out_ch * spatial * 2) as u64, metal_shared());
let (i_u, o_u, s_u) = (in_ch as u32, out_ch as u32, spatial as u32);
let cb = c.new_command_buffer();
c.dispatch(cb.as_ref(), &k, (spatial, out_ch, 1), (16, 16, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &x);
e.set_buffer(1, Some(&out), 0);
e.set_bytes(2, 4, &i_u as *const u32 as *const _);
e.set_bytes(3, 4, &o_u as *const u32 as *const _);
e.set_bytes(4, 4, &s_u as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let group = (in_ch / out_ch).max(1);
let mut want = vec![0f32; out_ch * spatial];
for oc in 0..out_ch {
for s in 0..spatial {
let mut acc = 0f32;
let mut n = 0usize;
for g in 0..group {
let ic = oc * group + g;
if ic >= in_ch {
break;
}
acc += xs[ic * spatial + s].to_f32();
n += 1;
}
want[oc * spatial + s] = if n > 0 { acc / n as f32 } else { 0.0 };
}
}
assert_close(&read_buf_f16(&out, out_ch * spatial), &want, 3e-3, "vae_channel_reduce");
}
#[test]
fn vae_temporal_upsample_holds_each_source_frame() {
let c = compute();
let k = c
.compile_pipeline("t_vtu", sources::PHASE27_OPS, "vae_temporal_upsample_f16")
.expect("vae_temporal_upsample_f16");
let (ch, in_t, out_t, hw) = (2usize, 3usize, 7usize, 5usize); // 7/3 is not integral
let xs = ramp(ch * in_t * hw, 337);
let x = tensor(&c, &xs, [ch, in_t * hw]);
let out = c
.device()
.raw()
.new_buffer((ch * out_t * hw * 2) as u64, metal_shared());
let (c_u, i_u, o_u, hw_u) = (ch as u32, in_t as u32, out_t as u32, hw as u32);
let cb = c.new_command_buffer();
c.dispatch(cb.as_ref(), &k, (hw, out_t, ch), (16, 16, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &x);
e.set_buffer(1, Some(&out), 0);
e.set_bytes(2, 4, &c_u as *const u32 as *const _);
e.set_bytes(3, 4, &i_u as *const u32 as *const _);
e.set_bytes(4, 4, &o_u as *const u32 as *const _);
e.set_bytes(5, 4, &hw_u as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let mut want = vec![0f32; ch * out_t * hw];
for cc in 0..ch {
for ot in 0..out_t {
let it = ((ot * in_t) / out_t).min(in_t - 1);
for p in 0..hw {
want[cc * out_t * hw + ot * hw + p] = xs[cc * in_t * hw + it * hw + p].to_f32();
}
}
}
assert_close(
&read_buf_f16(&out, ch * out_t * hw),
&want,
0.0,
"vae_temporal_upsample",
);
}
#[test]
fn vae_final_conv_sigmoid_maps_to_three_channels_in_zero_one() {
let c = compute();
let k = c
.compile_pipeline("t_vfc", sources::PHASE27_OPS, "vae_final_conv_sigmoid_f16")
.expect("vae_final_conv_sigmoid_f16");
let (in_ch, spatial) = (9usize, 11usize);
let xs = ramp(in_ch * spatial, 347);
let x = tensor(&c, &xs, [in_ch, spatial]);
let out = c
.device()
.raw()
.new_buffer((3 * spatial * 2) as u64, metal_shared());
let (i_u, s_u) = (in_ch as u32, spatial as u32);
let cb = c.new_command_buffer();
c.dispatch(cb.as_ref(), &k, (spatial, 3, 1), (256, 1, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &x);
e.set_buffer(1, Some(&out), 0);
e.set_bytes(2, 4, &i_u as *const u32 as *const _);
e.set_bytes(3, 4, &s_u as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let group = (in_ch / 3).max(1);
let mut want = vec![0f32; 3 * spatial];
for oc in 0..3usize {
for s in 0..spatial {
let mut acc = 0f32;
let mut n = 0usize;
for g in 0..group {
let ic = oc * group + g;
if ic >= in_ch {
break;
}
acc += xs[ic * spatial + s].to_f32();
n += 1;
}
let mean = if n > 0 { acc / n as f32 } else { 0.0 };
want[oc * spatial + s] = 1.0 / (1.0 + (-mean).exp());
}
}
let got = read_buf_f16(&out, 3 * spatial);
assert!(
got.iter().all(|v| (0.0..=1.0).contains(v)),
"a sigmoid must land in [0, 1]"
);
assert_close(&got, &want, 3e-3, "vae_final_conv_sigmoid");
}
#[test]
fn vae_extract_frame_interleaves_one_frame_as_f32() {
let c = compute();
let k = c
.compile_pipeline("t_vef", sources::PHASE27_OPS, "vae_extract_frame_f16")
.expect("vae_extract_frame_f16");
let (t, h, w) = (4usize, 3usize, 5usize);
let xs = ramp(3 * t * h * w, 353);
let rgb = tensor(&c, &xs, [3, t * h * w]);
let frame_idx = 2usize;
// f32 destination: four bytes per element, as the Mochi call site expects.
let out = c
.device()
.raw()
.new_buffer((h * w * 3 * 4) as u64, metal_shared());
let (t_u, h_u, w_u, f_u) = (t as u32, h as u32, w as u32, frame_idx as u32);
let cb = c.new_command_buffer();
c.dispatch(cb.as_ref(), &k, (w, h, 1), (16, 16, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &rgb);
e.set_buffer(1, Some(&out), 0);
e.set_bytes(2, 4, &t_u as *const u32 as *const _);
e.set_bytes(3, 4, &h_u as *const u32 as *const _);
e.set_bytes(4, 4, &w_u as *const u32 as *const _);
e.set_bytes(5, 4, &f_u as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let got = unsafe { std::slice::from_raw_parts(out.contents() as *const f32, h * w * 3) };
let plane = t * h * w;
let mut want = vec![0f32; h * w * 3];
for y in 0..h {
for x in 0..w {
let off = frame_idx * h * w + y * w + x;
for ch in 0..3usize {
want[(y * w + x) * 3 + ch] = xs[ch * plane + off].to_f32();
}
}
}
assert_close(got, &want, 0.0, "vae_extract_frame");
}
// ─── the remaining three ────────────────────────────────────────────────────
#[test]
fn im2col_patch_embed_matches_cpu_with_zero_padding() {
let c = compute();
let k = c
.compile_pipeline("t_i2c", sources::PHASE27_OPS, "im2col_patch_embed_f16")
.expect("im2col_patch_embed_f16");
// Padding and a stride that does not divide the input: the taps that fall
// outside must read as zero.
let (ch, h, w, kh, kw, sh, sw, ph, pw) = (2usize, 7usize, 6usize, 3, 3, 2, 2, 1, 1);
let xs = ramp(ch * h * w, 401);
let x = tensor(&c, &xs, [ch, h, w]);
let cb = c.new_command_buffer();
let out = gpu_ops::im2col_patch_embed_on(
&c, &k, cb.as_ref(), &x, ch, h, w, kh, kw, sh, sw, ph, pw,
);
cb.commit();
cb.wait_until_completed();
let out_h = (h + 2 * ph - kh) / sh + 1;
let out_w = (w + 2 * pw - kw) / sw + 1;
let patch_dim = ch * kh * kw;
assert_eq!(out.shape().dims(), &[out_h * out_w, patch_dim]);
let mut want = vec![0f32; out_h * out_w * patch_dim];
let mut zeros = 0usize;
for oy in 0..out_h {
for ox in 0..out_w {
for cc in 0..ch {
for ky in 0..kh {
for kx in 0..kw {
let iy = (oy * sh + ky) as isize - ph as isize;
let ix = (ox * sw + kx) as isize - pw as isize;
let e = cc * kh * kw + ky * kw + kx;
let v = if iy >= 0 && iy < h as isize && ix >= 0 && ix < w as isize {
xs[(cc * h + iy as usize) * w + ix as usize].to_f32()
} else {
zeros += 1;
0.0
};
want[(oy * out_w + ox) * patch_dim + e] = v;
}
}
}
}
}
assert!(zeros > 0, "this geometry must exercise the padding path");
assert_close(&read(&out), &want, 0.0, "im2col_patch_embed");
}
#[test]
fn dequantize_rvq_sums_the_per_codebook_embeddings() {
let c = compute();
let k = c
.compile_pipeline("t_rvq", sources::PHASE27_OPS, "dequantize_rvq_f16")
.expect("dequantize_rvq_f16");
let (n_cb, n_frames, cb_size, embed) = (3usize, 5usize, 7usize, 4usize);
let embeds = ramp(n_cb * cb_size * embed, 409);
let et = tensor(&c, &embeds, [n_cb, cb_size, embed]);
// One code per (codebook, frame); the last is out of range on purpose and
// must be skipped rather than read past the table.
let mut codes: Vec<u32> = (0..n_cb * n_frames)
.map(|i| ((i * 3 + 1) % cb_size) as u32)
.collect();
*codes.last_mut().unwrap() = cb_size as u32 + 2;
let codes_bytes: &[u8] = bytemuck_cast(&codes);
let codes_buf = c.device().raw().new_buffer_with_data(
codes_bytes.as_ptr() as *const _,
codes_bytes.len() as u64,
metal_shared(),
);
let cb = c.new_command_buffer();
let out = gpu_ops::dequantize_rvq_on(
&c, &k, cb.as_ref(), &codes_buf, &et, n_cb, n_frames, cb_size, embed,
);
cb.commit();
cb.wait_until_completed();
let mut want = vec![0f32; n_frames * embed];
for f in 0..n_frames {
for d in 0..embed {
let mut acc = 0f32;
for q in 0..n_cb {
let code = codes[q * n_frames + f] as usize;
if code >= cb_size {
continue;
}
acc += embeds[(q * cb_size + code) * embed + d].to_f32();
}
want[f * embed + d] = acc;
}
}
assert_close(&read(&out), &want, 3e-3, "dequantize_rvq");
}
#[test]
fn dilated_conv1d_matches_cpu_with_same_padding() {
let c = compute();
let k = c
.compile_pipeline("t_dc1", sources::PHASE27_OPS, "dilated_conv1d_f16")
.expect("dilated_conv1d_f16");
let (in_ch, out_ch, len, ksize, dilation) = (3usize, 2usize, 9usize, 3usize, 2usize);
let xs = ramp(in_ch * len, 419);
let ws = ramp(out_ch * in_ch * ksize, 421);
let bs = ramp(out_ch, 431);
let x = tensor(&c, &xs, [in_ch, len]);
let w = tensor(&c, &ws, [out_ch, in_ch, ksize]);
let b = tensor(&c, &bs, [out_ch]);
let out = c
.device()
.raw()
.new_buffer((out_ch * len * 2) as u64, metal_shared());
let dims: [u32; 5] = [
in_ch as u32, out_ch as u32, len as u32, ksize as u32, dilation as u32,
];
let cb = c.new_command_buffer();
c.dispatch(cb.as_ref(), &k, (len, out_ch, 1), (16, 16, 1), |e| {
gpu_ops::set_tensor_buffer(e, 0, &x);
gpu_ops::set_tensor_buffer(e, 1, &w);
gpu_ops::set_tensor_buffer(e, 2, &b);
e.set_buffer(3, Some(&out), 0);
for (i, d) in dims.iter().enumerate() {
e.set_bytes(4 + i as u64, 4, d as *const u32 as *const _);
}
});
cb.commit();
cb.wait_until_completed();
let pad = ((ksize - 1) * dilation) / 2;
let mut want = vec![0f32; out_ch * len];
for oc in 0..out_ch {
for t in 0..len {
let mut acc = bs[oc].to_f32();
for ic in 0..in_ch {
for kk in 0..ksize {
let s = t as isize + (kk * dilation) as isize - pad as isize;
if s < 0 || s >= len as isize {
continue;
}
acc += ws[(oc * in_ch + ic) * ksize + kk].to_f32()
* xs[ic * len + s as usize].to_f32();
}
}
want[oc * len + t] = acc;
}
}
assert_close(&read_buf_f16(&out, out_ch * len), &want, 5e-3, "dilated_conv1d");
}
/// Read `count` f16 values out of a shared Metal buffer.
fn read_buf_f16(buf: &metal::Buffer, count: usize) -> Vec<f32> {
let s = unsafe { std::slice::from_raw_parts(buf.contents() as *const f16, count) };
s.iter().map(|v| v.to_f32()).collect()
}
/// `u32` slice as bytes, without pulling in bytemuck as a dev-dependency.
fn bytemuck_cast(v: &[u32]) -> &[u8] {
unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
}
// ─── sources::PROCEDURAL ────────────────────────────────────────────────────
//
// The fusion pipeline's kernels. `FusionPipeline` is not constructed anywhere
// yet, so these dispatch the shaders the way `inference/fusion.rs` does and
// check them directly. All five work in f32 buffers, not tensors.
fn f32_buffer(c: &MetalCompute, v: &[f32]) -> metal::Buffer {
let bytes =
unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) };
c.device()
.raw()
.new_buffer_with_data(bytes.as_ptr() as *const _, bytes.len() as u64, metal_shared())
}
fn read_buf_f32(buf: &metal::Buffer, count: usize) -> Vec<f32> {
unsafe { std::slice::from_raw_parts(buf.contents() as *const f32, count) }.to_vec()
}
#[test]
fn perlin_noise_is_deterministic_bounded_and_blends() {
let c = compute();
let k = c
.compile_pipeline("t_pn", sources::PROCEDURAL, "perlin_noise_f32")
.expect("perlin_noise_f32");
let (w, h) = (16usize, 12usize);
let amplitude = 2.0f32;
// blend: 0 replace, 1 add. noise_type: 0 perlin, 1 simplex, 2 worley, 3 value.
let run = |noise_type: u32, blend: u32, seed: u32, init: &[f32]| -> Vec<f32> {
let buf = f32_buffer(&c, init);
let (w_u, h_u) = (w as u32, h as u32);
let (oct, lac, per, scale) = (3u32, 2.0f32, 0.5f32, 0.07f32);
let cb = c.new_command_buffer();
c.dispatch_2d(cb.as_ref(), &k, w, h, |e| {
e.set_buffer(0, Some(&buf), 0);
e.set_bytes(1, 4, &w_u as *const u32 as *const _);
e.set_bytes(2, 4, &h_u as *const u32 as *const _);
e.set_bytes(3, 4, &noise_type as *const u32 as *const _);
e.set_bytes(4, 4, &oct as *const u32 as *const _);
e.set_bytes(5, 4, &lac as *const f32 as *const _);
e.set_bytes(6, 4, &per as *const f32 as *const _);
e.set_bytes(7, 4, &scale as *const f32 as *const _);
e.set_bytes(8, 4, &amplitude as *const f32 as *const _);
e.set_bytes(9, 4, &seed as *const u32 as *const _);
e.set_bytes(10, 4, &blend as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
read_buf_f32(&buf, w * h)
};
let zero = vec![0f32; w * h];
for nt in 0..4u32 {
let a = run(nt, 0, 7, &zero);
let b = run(nt, 0, 7, &zero);
assert_eq!(a, b, "noise type {nt} must be deterministic for a fixed seed");
// fbm is a weighted mean of bases in [-1, 1], so |v| <= amplitude.
assert!(
a.iter().all(|v| v.abs() <= amplitude + 1e-4),
"noise type {nt} exceeded its amplitude"
);
assert!(
a.iter().any(|v| v.abs() > 1e-6),
"noise type {nt} produced an all-zero field"
);
// A different seed must give a different field.
let other = run(nt, 0, 8, &zero);
assert_ne!(a, other, "noise type {nt} ignored its seed");
// Additive blend onto an existing field adds to it.
let added = run(nt, 1, 7, &a);
let want: Vec<f32> = a.iter().map(|v| v + v).collect();
assert_close(&added, &want, 1e-5, &format!("noise {nt} add blend"));
}
}
#[test]
fn apply_palette_interpolates_between_stops() {
let c = compute();
let k = c
.compile_pipeline("t_ap", sources::PROCEDURAL, "apply_palette_f32")
.expect("apply_palette_f32");
let (w, h) = (8usize, 4usize);
// A field spanning [-1, 1], which the kernel remaps to [0, 1].
let field: Vec<f32> = (0..w * h)
.map(|i| -1.0 + 2.0 * (i as f32) / ((w * h - 1) as f32))
.collect();
// Three stops: black at 0, red at 0.5, white at 1.
let palette: Vec<f32> = vec![
0.0, 0.0, 0.0, 0.0, //
0.5, 1.0, 0.0, 0.0, //
1.0, 1.0, 1.0, 1.0,
];
let stops = 3u32;
let fb = f32_buffer(&c, &field);
let pb = f32_buffer(&c, &palette);
let out = c
.device()
.raw()
.new_buffer((w * h * 3 * 4) as u64, metal_shared());
let (w_u, h_u) = (w as u32, h as u32);
let cb = c.new_command_buffer();
c.dispatch_2d(cb.as_ref(), &k, w, h, |e| {
e.set_buffer(0, Some(&fb), 0);
e.set_buffer(1, Some(&pb), 0);
e.set_buffer(2, Some(&out), 0);
e.set_bytes(3, 4, &w_u as *const u32 as *const _);
e.set_bytes(4, 4, &h_u as *const u32 as *const _);
e.set_bytes(5, 4, &stops as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let mut want = vec![0f32; w * h * 3];
for (i, f) in field.iter().enumerate() {
let t = (f * 0.5 + 0.5).clamp(0.0, 1.0);
let (c0, c1, lo, hi) = if t <= 0.5 {
([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 0.0, 0.5)
} else {
([1.0, 0.0, 0.0], [1.0, 1.0, 1.0], 0.5, 1.0)
};
let u = (t - lo) / (hi - lo);
for ch in 0..3 {
want[i * 3 + ch] = c0[ch] + u * (c1[ch] - c0[ch]);
}
}
assert_close(&read_buf_f32(&out, w * h * 3), &want, 1e-5, "apply_palette");
}
#[test]
fn sdf_raymarch_hits_a_sphere_and_misses_past_its_edge() {
let c = compute();
let k = c
.compile_pipeline("t_sdf", sources::PROCEDURAL, "sdf_raymarch_f32")
.expect("sdf_raymarch_f32");
let (w, h) = (32usize, 32usize);
// One node: a sphere of radius 1 at the origin (op code 0).
let mut scene = vec![0f32; 16];
scene[0] = 0.0;
scene[1] = 1.0;
// eye(3), target(3), fov, max_dist, max_steps, background rgb(3), light(3)
let camera: Vec<f32> = vec![
0.0, 0.0, 4.0, //
0.0, 0.0, 0.0, //
0.6, 32.0, 128.0, //
0.0, 0.25, 0.75, // a background nothing shaded can equal
0.0, 0.0, 1.0,
];
let sb = f32_buffer(&c, &scene);
let camb = f32_buffer(&c, &camera);
let out = c
.device()
.raw()
.new_buffer((w * h * 3 * 4) as u64, metal_shared());
let (w_u, h_u, n_u) = (w as u32, h as u32, 1u32);
let cb = c.new_command_buffer();
c.dispatch_2d(cb.as_ref(), &k, w, h, |e| {
e.set_buffer(0, Some(&sb), 0);
e.set_buffer(1, Some(&camb), 0);
e.set_buffer(2, Some(&out), 0);
e.set_bytes(3, 4, &w_u as *const u32 as *const _);
e.set_bytes(4, 4, &h_u as *const u32 as *const _);
e.set_bytes(5, 4, &n_u as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
let px = read_buf_f32(&out, w * h * 3);
let bg = [0.0f32, 0.25, 0.75];
let at = |y: usize, x: usize| {
let i = (y * w + x) * 3;
[px[i], px[i + 1], px[i + 2]]
};
let is_bg = |c: [f32; 3]| (0..3).all(|i| (c[i] - bg[i]).abs() < 1e-6);
// The sphere subtends the middle of the frame; the corners cannot reach it.
let centre = at(h / 2, w / 2);
assert!(!is_bg(centre), "centre pixel should have hit the sphere, got {centre:?}");
// Facing the light head-on, the centre is the brightest point: 0.1 + 0.9.
assert!(
(centre[0] - 1.0).abs() < 2e-2 && (centre[1] - 1.0).abs() < 2e-2,
"centre should be fully lit, got {centre:?}"
);
for (y, x) in [(0, 0), (0, w - 1), (h - 1, 0), (h - 1, w - 1)] {
let c = at(y, x);
assert!(is_bg(c), "corner ({y},{x}) should be background, got {c:?}");
}
// Shading must vary across the sphere: a lit sphere is not a flat disc.
let edge = at(h / 2, w / 2 + 9);
assert!(
!is_bg(edge) && edge[0] < centre[0] - 1e-2,
"the sphere should fall off towards its limb: centre {centre:?}, edge {edge:?}"
);
}
#[test]
fn ks_excite_fills_the_delay_line_within_the_velocity() {
let c = compute();
let k = c
.compile_pipeline("t_kse", sources::PROCEDURAL, "ks_excite_f32")
.expect("ks_excite_f32");
let delay_len = 97usize; // not a multiple of any threadgroup width
let velocity = 0.8f32;
let excite = |etype: u32, position: f32, seed: u32| -> Vec<f32> {
let buf = f32_buffer(&c, &vec![0f32; delay_len]);
let dl = delay_len as u32;
let cb = c.new_command_buffer();
c.dispatch_1d(cb.as_ref(), &k, delay_len, |e| {
e.set_buffer(0, Some(&buf), 0);
e.set_bytes(1, 4, &dl as *const u32 as *const _);
e.set_bytes(2, 4, &etype as *const u32 as *const _);
e.set_bytes(3, 4, &position as *const f32 as *const _);
e.set_bytes(4, 4, &velocity as *const f32 as *const _);
e.set_bytes(5, 4, &seed as *const u32 as *const _);
});
cb.commit();
cb.wait_until_completed();
read_buf_f32(&buf, delay_len)
};
for etype in 0..3u32 {
let v = excite(etype, 0.5, 11);
assert_eq!(v.len(), delay_len);
assert!(
v.iter().all(|x| x.abs() <= velocity + 1e-5),
"excitation {etype} exceeded the velocity envelope"
);
assert!(
v.iter().any(|x| x.abs() > 1e-4),
"excitation {etype} left the string silent"
);
assert_eq!(v, excite(etype, 0.5, 11), "excitation {etype} is not deterministic");
}
// A pluck is a triangle with its corner at the pluck point, so moving the
// point moves where the maximum sits.
let argmax = |v: &[f32]| {
v.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.unwrap()
.0
};
let early = argmax(&excite(0, 0.2, 5));
let late = argmax(&excite(0, 0.8, 5));
assert!(
early < late,
"the pluck peak should follow the pluck position: {early} vs {late}"
);
}
#[test]
fn ks_synthesize_matches_the_cpu_waveguide_loop() {
let c = compute();
let k = c
.compile_pipeline("t_kss", sources::PROCEDURAL, "ks_synthesize_f32")
.expect("ks_synthesize_f32");
let (delay_len, num_samples) = (23usize, 200usize);
let (damping, brightness) = (0.98f32, 0.35f32);
// A deterministic starting delay line.
let init: Vec<f32> = (0..delay_len)
.map(|i| ((i as f32) / delay_len as f32) * 2.0 - 1.0)
.collect();
let delay = f32_buffer(&c, &init);
let out = c
.device()
.raw()
.new_buffer((num_samples * 4) as u64, metal_shared());
let (dl, ns) = (delay_len as u32, num_samples as u32);
let cb = c.new_command_buffer();
c.dispatch_1d(cb.as_ref(), &k, 1, |e| {
e.set_buffer(0, Some(&delay), 0);
e.set_buffer(1, Some(&out), 0);
e.set_bytes(2, 4, &dl as *const u32 as *const _);
e.set_bytes(3, 4, &ns as *const u32 as *const _);
e.set_bytes(4, 4, &damping as *const f32 as *const _);
e.set_bytes(5, 4, &brightness as *const f32 as *const _);
});
cb.commit();
cb.wait_until_completed();
// The same serial recurrence on the CPU.
let mut d = init.clone();
let mut want = vec![0f32; num_samples];
for n in 0..num_samples {
let i = n % delay_len;
let j = (i + 1) % delay_len;
let cur = d[i];
let nxt = d[j];
want[n] = cur;
let lowpass = 0.5 * (cur + nxt);
d[i] = (lowpass + brightness * (cur - lowpass)) * damping;
}
let got = read_buf_f32(&out, num_samples);
assert_close(&got, &want, 1e-4, "ks_synthesize");
// The loop gain is below one, so the string must decay rather than ring on.
let head: f32 = got[..delay_len].iter().map(|v| v.abs()).sum();
let tail: f32 = got[num_samples - delay_len..].iter().map(|v| v.abs()).sum();
assert!(tail < head, "a damped waveguide must decay: {head} -> {tail}");
}