makepad/platform/script/compute/tests/soundness.rs

369 lines
18 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Regression tests for the kernel soundness review (findings 1–5, 19):
//! record overlap and capacity, memory access through raw words, compile-
//! and init-time budgets, the IR validator as a panic-free barrier, and
//! cancellation reaching running native code.
use makepad_script_compute_sched::kernel::KernelExt;
use makepad_script_compute::ir::{self, Bin, Block, Cmp, Op, Program, Region, Regions, Stmt, Ty, Un, Val, Var};
use makepad_script_compute::kernel::{compile, compile_with};
use makepad_script_compute_sched::kernel::{KernelError};
use makepad_script_compute::Backend;
use std::time::{Duration, Instant};
fn err_of(src: &str) -> String {
match compile(src) {
Ok(_) => panic!("expected a compile error for:\n{}", src),
Err(e) => e[0].message.clone(),
}
}
// -- 1. records never overlap; capacity is checked --------------------------
#[test]
fn strides_smaller_than_a_record_are_rejected() {
assert!(err_of("let o = output(vec3, 2)\nfn element(i) { o[i] = vec3(1.0) }").contains("smaller than"));
assert!(err_of("let o = output(f32, 0)\nfn element(i) { o[i] = 1.0 }").contains("smaller than"));
assert!(err_of("let o = output(vec3, 4, 2)\nfn element(i) { o[i] = vec3(1.0) }").contains("does not fit"));
}
#[test]
fn views_with_different_strides_are_not_split_across_threads() {
let k = compile("let a = output(f32, 1, 0, buf)\nlet b = output(vec2, 2, 0, buf)\nfn element(i) { a[i] = 1.0\n b[i] = vec2(2.0) }").unwrap();
assert!(!k.parallel_safe);
let k = compile("let a = output(f32, 4, 0, buf)\nlet b = output(vec2, 4, 1, buf)\nfn element(i) { a[i] = 1.0\n b[i] = vec2(2.0) }").unwrap();
assert!(k.parallel_safe, "same stride, disjoint fields: element-local");
}
#[test]
fn undersized_outputs_are_refused_before_a_split_run() {
let k = compile("let o = output(vec3)\nfn element(i) { o[i] = vec3(float(i)) }").unwrap();
assert!(k.parallel_safe);
let n = 20000;
let mut small = vec![0.0f32; n * 3 - 1];
let mut c = k.call();
c.output("o", &mut small).unwrap();
match c.run_parallel(n, 4) {
Err(KernelError::TooSmall { need, have, .. }) => assert_eq!((need, have), ((n * 3) as u64, n * 3 - 1)),
other => panic!("expected TooSmall, got {:?}", other.map(|s| s.elements)),
}
// Emit buffers are always checked (their slots are per element).
let e = compile("let s = emit_buffer(2, 4)\nfn primitive(i) { emit(s, 1.0, 2.0) }").unwrap();
let mut data = vec![0.0f32; 10];
let mut counts = vec![0u32; 5];
let mut c = e.call();
c.output("s", &mut data).unwrap();
c.output_u32("s_count", &mut counts).unwrap();
assert!(matches!(c.run(5), Err(KernelError::TooSmall { .. })));
}
#[test]
fn a_right_sized_split_run_equals_the_serial_run() {
let k = compile("let o = output(vec4, 8, 2)\nfn element(i) { o[i] = vec4(float(i), hash01(i, 1), 0.5, -float(i)) }").unwrap();
let n = 50_000;
let mut a = vec![0.0f32; n * 8];
let mut b = vec![0.0f32; n * 8];
let mut c = k.call();
c.output("o", &mut a).unwrap();
c.run(n).unwrap();
let mut c = k.call();
c.output("o", &mut b).unwrap();
c.run_parallel(n, 8).unwrap();
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()));
}
#[test]
fn addresses_do_not_wrap_before_the_clamp() {
// base + off past 2^32 must clamp to the last word, not wrap to 0.
let k = compile("let o = output(f32, 4, 3)\nfn element(i) { o[1073741823 + i] = 7.0 }").unwrap();
for interp in [false, true] {
let mut out = vec![0.0f32; 8];
let mut c = k.call();
c.output("o", &mut out).unwrap();
if interp { c.run_interp(1).unwrap() } else { c.run(1).unwrap() };
assert_eq!(out, vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 7.0], "interp {}", interp);
}
}
// -- 2. read-only memory stays read-only ---------------------------------------
#[test]
fn one_slice_bound_to_two_inputs_is_fine_and_inputs_are_never_written() {
let k = compile("let a = input(f32)\nlet b = input(f32)\nlet o = output(f32)\nfn element(i) { o[i] = a[i] + b[i] }").unwrap();
let data = vec![1.5f32; 64];
for interp in [false, true] {
let mut out = vec![0.0f32; 64];
let mut c = k.call();
c.input("a", &data).unwrap();
c.input("b", &data).unwrap();
c.output("o", &mut out).unwrap();
if interp { c.run_interp(64).unwrap() } else { c.run(64).unwrap() };
assert!(out.iter().all(|x| *x == 3.0));
}
assert!(data.iter().all(|x| *x == 1.5));
// Writing an input is a compile error, and binding a written buffer
// read-only is refused.
assert!(err_of("let a = input(f32)\nfn element(i) { a[i] = 1.0 }").contains("read-only"));
let k = compile("let o = output(f32)\nfn element(i) { o[i] = 1.0 }").unwrap();
let ro = vec![0.0f32; 4];
let mut c = k.call();
assert!(matches!(c.input("o", &ro), Err(KernelError::ReadOnly(_))));
}
// -- 3. budgets hold before work happens ----------------------------------------
#[test]
fn huge_initializers_fail_fast_without_allocating() {
let t0 = Instant::now();
let e = err_of("let T = [[[0.0; 4096]; 4096]; 4096]\nlet o = output(f32)\nfn element(i) { o[i] = T[0][0][0] }");
assert!(e.contains("limit"), "{}", e);
assert!(t0.elapsed() < Duration::from_secs(1), "rejected after {:?}", t0.elapsed());
}
#[test]
fn inlining_blowups_are_rejected() {
// 16^5 unrolled calls.
let src = "let o = output(f32)\nfn f(x) { x * 1.0001 + 0.5 }\nfn element(i) { let s = 0.0\n for a in 0..16 { for b in 0..16 { for c in 0..16 { for d in 0..16 { for e in 0..16 { s = f(s) } } } } }\n o[i] = s }";
let t0 = Instant::now();
let e = err_of(src);
assert!(e.contains("too large") || e.contains("too much work"), "{}", e);
assert!(t0.elapsed() < Duration::from_secs(20));
}
#[test]
fn init_is_budgeted_and_cannot_see_host_buffers() {
let e = err_of("let src = input(f32)\nlet T = [0.0; 16]\nfn init() { T[0] = src[0] }\nlet o = output(f32)\nfn element(i) { o[i] = T[0] }");
assert!(e.contains("unknown name `src`"), "{}", e);
let e = err_of(
"let T = [0.0; 16]\nfn init() { let s = 0.0\n for a in 0..100000 { for b in 0..100000 { s = s + 1.0 } }\n T[0] = s }\nlet o = output(f32)\nfn element(i) { o[i] = T[0] }",
);
assert!(e.contains("init()") && e.contains("limit"), "{}", e);
}
#[test]
fn a_call_stops_past_its_counted_work() {
let k = compile("let o = output(f32)\nfn element(i) { let s = 0.0\n for a in 0..1000 { s = s + sin(float(a)) }\n o[i] = s }").unwrap();
let mut out = vec![0.0f32; 1000];
let mut c = k.call();
c.output("o", &mut out).unwrap();
// The worst case bounds what a call counts.
let worst = makepad_script_compute::cost::element_ops(&k) * 1000;
let used = c.run(1000).unwrap().work;
assert!(used > 0 && used <= worst, "{used} of {worst}");
c.set_work_limit(used / 100);
assert!(matches!(c.run(1000), Err(KernelError::OverBudget { .. })));
c.set_work_limit(used);
assert_eq!(c.run(1000).unwrap().work, used);
}
// -- 4. the validator never panics and rejects malformed IR -----------------------
fn regions() -> Regions {
Regions { ctx: 8, state: 4, shared: 4, frame: 4, shared_writable: false, bufs: vec![false, true], io: false }
}
fn prog(vals: Vec<Ty>, vars: Vec<Ty>, body: Block) -> Program {
Program { vals, vars, body, frame_words: 4, ..Default::default() }
}
#[test]
fn validator_rejects_the_classic_mistakes() {
use Stmt::*;
let r = regions();
let c = |v: u32, x: i32| Def(Val(v), Op::ConstI(x));
let b = |v: u32| Def(Val(v), Op::ConstB(true));
// A value defined in a branch, used after it.
let p = prog(vec![Ty::Bool, Ty::I32, Ty::I32], vec![], vec![b(0), If(Val(0), vec![c(1, 1)], vec![]), Def(Val(2), Op::Bin(Bin::AddI, Val(1), Val(1)))]);
assert!(ir::validate(&p, &r).is_err());
// Out-of-range identifiers.
let p = prog(vec![Ty::I32], vec![], vec![Def(Val(5), Op::ConstI(1))]);
assert!(ir::validate(&p, &r).is_err());
let p = prog(vec![Ty::I32], vec![], vec![Def(Val(0), Op::Get(Var(3)))]);
assert!(ir::validate(&p, &r).is_err());
// Extent overflow and real frame size.
let p = prog(vec![Ty::F32], vec![], vec![Def(Val(0), Op::Load { region: Region::Frame, base: u32::MAX, extent: 2, off: None })]);
assert!(ir::validate(&p, &r).is_err());
let p = prog(vec![Ty::F32], vec![], vec![Def(Val(0), Op::Load { region: Region::Frame, base: 3, extent: 2, off: None })]);
assert!(ir::validate(&p, &r).is_err());
// Buffers: missing, and read-only.
let p = prog(vec![Ty::I32], vec![], vec![c(0, 1), Store { region: Region::Buf(7), base: 0, extent: 1, off: None, val: Val(0) }]);
assert!(ir::validate(&p, &r).is_err());
let p = prog(vec![Ty::I32], vec![], vec![c(0, 1), Store { region: Region::Buf(0), base: 0, extent: 1, off: None, val: Val(0) }]);
assert!(ir::validate(&p, &r).is_err());
let p = prog(vec![Ty::I32], vec![], vec![c(0, 1), Store { region: Region::Shared, base: 0, extent: 1, off: None, val: Val(0) }]);
assert!(ir::validate(&p, &r).is_err());
// Operand types.
let p = prog(vec![Ty::I32, Ty::F32], vec![], vec![c(0, 1), Def(Val(1), Op::Un(Un::SqrtF, Val(0)))]);
assert!(ir::validate(&p, &r).is_err());
let p = prog(vec![Ty::F32, Ty::Bool], vec![], vec![Def(Val(0), Op::ConstF(1.0)), Def(Val(1), Op::CmpI(Cmp::Lt, Val(0), Val(0)))]);
assert!(ir::validate(&p, &r).is_err());
// Audio I/O where there is none.
let p = prog(vec![Ty::I32, Ty::F32], vec![], vec![c(0, 0), Def(Val(1), Op::In { ch: 0, idx: Val(0) })]);
assert!(ir::validate(&p, &r).is_err());
// And a well-formed one passes.
let p = prog(vec![Ty::I32, Ty::I32], vec![Ty::I32], vec![c(0, 1), Set(Var(0), Val(0)), Def(Val(1), Op::Get(Var(0))), Store { region: Region::Buf(1), base: 0, extent: 1, off: Some(Val(1)), val: Val(1) }]);
assert!(ir::validate(&p, &r).is_ok(), "{:?}", ir::validate(&p, &r));
}
/// A tiny deterministic generator.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: u64) -> u64 {
self.next() % n.max(1)
}
}
fn random_program(r: &mut Rng) -> Program {
let tys = [Ty::F32, Ty::I32, Ty::Bool];
let nvals = 1 + r.below(24) as usize;
let vals: Vec<Ty> = (0..nvals).map(|_| tys[r.below(3) as usize]).collect();
let vars: Vec<Ty> = (0..r.below(4) as usize).map(|_| tys[r.below(3) as usize]).collect();
fn block(r: &mut Rng, depth: u32, nvals: usize, nvars: usize) -> Block {
let regions = [Region::Ctx, Region::State, Region::Shared, Region::Frame, Region::Buf(0), Region::Buf(1), Region::Buf(9)];
let mut b = Vec::new();
for _ in 0..r.below(8) {
let v = |r: &mut Rng| Val(r.below(nvals as u64 + 2) as u32);
let s = match r.below(9) {
0 => Stmt::Def(v(r), Op::ConstF(r.next() as f32)),
1 => Stmt::Def(v(r), Op::Bin(Bin::AddI, v(r), v(r))),
2 => Stmt::Def(v(r), Op::Load { region: regions[r.below(7) as usize], base: r.below(12) as u32, extent: r.below(5) as u32, off: if r.below(2) == 0 { None } else { Some(v(r)) } }),
3 => Stmt::Store { region: regions[r.below(7) as usize], base: r.next() as u32, extent: r.next() as u32, off: Some(v(r)), val: v(r) },
4 => Stmt::Set(Var(r.below(nvars as u64 + 1) as u32), v(r)),
5 => Stmt::If(v(r), block(r, depth, nvals, nvars), block(r, depth, nvals, nvars)),
6 if depth < 3 => Stmt::Loop { cap: r.below(5) as u32, body: block(r, depth + 1, nvals, nvars) },
7 => Stmt::Break(r.below(3) as u32),
_ => Stmt::Def(v(r), Op::Sel(v(r), v(r), v(r))),
};
b.push(s);
}
b
}
let nvars = vars.len();
Program { body: block(r, 0, nvals, nvars), vals, vars, frame_words: r.below(6) as u32, ..Default::default() }
}
#[test]
fn validator_fuzz_never_panics_and_valid_programs_run() {
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
let reg = regions();
let mut valid = 0;
for _ in 0..50_000 {
let p = random_program(&mut r);
let res = std::panic::catch_unwind(|| ir::validate(&p, &reg));
let ok = res.expect("validate panicked");
if ok.is_ok() {
valid += 1;
// A valid program runs in the interpreter without panicking.
let mut ctx = vec![0u32; 8];
let mut state = vec![0u32; 4];
let shared = vec![0u32; 4];
let mut b0 = [0u32; 2];
let mut b1 = [0u32; 3];
let bufs = [
ir::RawBuf { ptr: b0.as_mut_ptr(), len: 2, writable: false },
ir::RawBuf { ptr: b1.as_mut_ptr(), len: 3, writable: true },
];
let mut scratch = vec![0u32; p.scratch_words()];
let zeros = [0f32; 1];
let (mut o0, mut o1) = ([0f32; 1], [0f32; 1]);
let mut mem = ir::Mem { ctx: &mut ctx, state: &mut state, shared: ir::Shared::Read(&shared), bufs: &bufs };
let mut io = ir::Io { ins: [&zeros, &zeros], outs: [&mut o0, &mut o1] };
ir::run(&p, &mut scratch, &mut mem, &mut io, 1);
assert_eq!(b0, [0, 0], "a read-only buffer was written");
// Native code on the same program: same memory, bit for bit, and
// guard words around the buffers stay untouched.
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
if let Some(code) = makepad_script_compute::native::compile(&p) {
let mut nctx = vec![0u32; 8];
let mut nstate = vec![0u32; 4];
let nshared = vec![0u32; 4];
let mut g0 = [0xDEADu32; 6];
let mut g1 = [0xDEADu32; 7];
g0[2..4].fill(0);
g1[2..5].fill(0);
let table = [g0[2..].as_mut_ptr() as u64, 2, g1[2..].as_mut_ptr() as u64, 3];
unsafe { code.run_kernel(nctx.as_mut_ptr(), nstate.as_mut_ptr(), nshared.as_ptr() as *mut u32, table.as_ptr(), 1) };
assert_eq!(&g0[2..4], &[0, 0], "native wrote a read-only buffer");
assert_eq!(&g1[2..5], &b1, "native and interpreter disagree on the buffer");
assert_eq!(nctx, ctx, "native and interpreter disagree on ctx");
assert_eq!(nstate, state, "native and interpreter disagree on state");
assert!(g0[..2].iter().chain(&g0[4..]).chain(&g1[..2]).chain(&g1[5..]).all(|w| *w == 0xDEAD), "native wrote outside a buffer");
assert_eq!(nshared, shared, "native wrote shared tables");
}
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
if let Some(code) = makepad_script_compute::native::compile(&p) {
let mut nctx = vec![0u32; 8];
let mut nstate = vec![0u32; 4];
let nshared = vec![0u32; 4];
let mut g0 = [0xDEADu32; 6];
let mut g1 = [0xDEADu32; 7];
g0[2..4].fill(0);
g1[2..5].fill(0);
let table = [g0[2..].as_mut_ptr() as u64, 2, g1[2..].as_mut_ptr() as u64, 3];
unsafe { code.run_kernel(nctx.as_mut_ptr(), nstate.as_mut_ptr(), nshared.as_ptr() as *mut u32, table.as_ptr(), 1) };
assert_eq!(&g0[2..4], &[0, 0], "native wrote a read-only buffer");
assert_eq!(&g1[2..5], &b1, "native and interpreter disagree on the buffer");
assert_eq!(nctx, ctx, "native and interpreter disagree on ctx");
assert_eq!(nstate, state, "native and interpreter disagree on state");
assert!(g0[..2].iter().chain(&g0[4..]).chain(&g1[..2]).chain(&g1[5..]).all(|w| *w == 0xDEAD), "native wrote outside a buffer");
assert_eq!(nshared, shared, "native wrote shared tables");
}
}
}
assert!(valid > 100, "the fuzzer produced only {} valid programs", valid);
}
// -- 5. cancellation reaches running native code ------------------------------------
#[test]
fn cancel_stops_a_running_kernel_within_milliseconds() {
// ~20 µs per element natively, 500k elements: ~10 s if not cancelled.
let src = "let o = output(f32)\nfn element(i) { let s = float(i)\n for a in 0..1000 { s = s * 0.999 + sin(s) } \n o[i] = s }";
for backend in [Backend::Native, Backend::Interp] {
let k = compile_with(src, &[], backend).unwrap();
let n = 500_000;
let mut out = vec![0.0f32; n];
let mut c = k.call();
c.output("o", &mut out).unwrap();
let token = c.cancel_token();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
let at = Instant::now();
token.cancel();
tx.send(at).unwrap();
});
let r = c.run(n);
let done = Instant::now();
let cancelled_at = rx.recv().unwrap();
let latency = done.duration_since(cancelled_at);
eprintln!("{:?}: cancel -> return {:.2} ms", backend, latency.as_secs_f64() * 1e3);
assert!(matches!(r, Err(KernelError::Cancelled)), "{:?}", r.map(|s| s.elements));
assert!(latency < Duration::from_millis(25), "{:?} took {:?} to stop", backend, latency);
}
}
// -- 19. emit_buffer(Layout, n) and whole records ------------------------------------
#[test]
fn emit_takes_layout_records() {
use makepad_script_compute::boundary::{LayoutFieldView, LayoutView};
use makepad_script_compute::kernel::FieldTy;
use makepad_script_compute_sched::kernel::{compact};
let seg = LayoutView { name: "Seg", stride: 6, fields: &[LayoutFieldView { name: "a", ty: FieldTy::Vec3, offset: 0 }, LayoutFieldView { name: "w", ty: FieldTy::F32, offset: 4 }] };
let src = "let s = emit_buffer(Seg, 2)\nfn primitive(i) { let r = Seg{}\n r.a = vec3(float(i), 1.0, 2.0)\n r.w = 0.5\n emit(s, r) }";
let k = compile_with(src, &[seg], Backend::Native).unwrap_or_else(|e| panic!("{:?}", e));
let mut data = vec![0.0f32; 3 * 2 * 6];
let mut counts = vec![0u32; 3];
let mut c = k.call();
c.output("s", &mut data).unwrap();
c.output_u32("s_count", &mut counts).unwrap();
c.run(3).unwrap();
let flat = compact(&data, &counts, 6, 2);
assert_eq!(flat, vec![0.0, 1.0, 2.0, 0.0, 0.5, 0.0, 1.0, 1.0, 2.0, 0.0, 0.5, 0.0, 2.0, 1.0, 2.0, 0.0, 0.5, 0.0]);
}