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
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]>
1284 lines
45 KiB
Rust
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(), ¶ms, 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, &litude 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}");
|
|
}
|