An acc64 graph's conv and conv_transpose run on narrow kernels (conv_n, conv_t_n in ops64.wgsl) where the operator's node in the graph's bounds pass places its input and weights in i32 and every product within +-T, T <= 2^31 - 1: each product in one 32-bit word, a sum of at most K = floor((2^31 - 1)/T) products in another (every partial sum where the accumulator interval lies in i32), each sum folded into the 64-bit accumulator, whose final value the certificate places in i64. The bound is read from the graph (prepare::narrow_chunk), operator by operator; every other operator and every acc32 graph runs as before, and Gpu::set_narrowing(false) runs them all on the 64-bit kernels. The WGSL rules hold the module as before; the crate's docs state each kernel's bound. tests/narrow.rs holds both kernels to the interpreter on 178 one-operator graphs, random and at the bound's edge (products of exactly 2^31 - 1, chunk sums reaching +-K*T, a product of 2^31 the plan sends to the 64-bit kernel), with narrowing on and off, and catches eight planted faults (a chunk folded after 2K taps, a chunk zero-extended, a rounding shift one short, an index slip in each kernel). The corpus runs every acc64 vector with narrowing on and off; 293 operators of 181 vectors take the narrow kernels. Run on 2026-11-11 on Metal (an arm64 laptop's GPU and an arm64 desktop's, macOS 27.2.1) and on Vulkan (the 0x1002 integrated GPU and llvmpipe, Linux x86_64): every test of the crate and the capability corpora of wai-rs tests/ops_gpu.rs passed, clippy clean; the reports are runs 3 to 5 of wai/research/ops-gpu-runs.txt, and determinism-tiers §5 now claims those adapters on this kernel text. The browser leg is not claimed on it until the forge's run records it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
221 lines
8.4 KiB
Rust
221 lines
8.4 KiB
Rust
//! The graph vectors of `ops-conformance/` (`vectors/` and `graphs/`), run on a device and
|
|
//! held to the committed expectations and to the CPU interpreter.
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use wai_ops::json::{self, Value, unhex};
|
|
use wai_ops::vectors::read_tensor;
|
|
use wai_ops::{Loaded, Profile, Sequence, Tensor};
|
|
use wai_ops_gpu::{Gpu, GpuError, GpuSequence, Width, blocking, support};
|
|
|
|
pub fn conformance() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ops-conformance")
|
|
}
|
|
|
|
/// Every graph-vector file: `vectors/*.jsonl`, then `graphs/*.jsonl`, each sorted by name.
|
|
pub fn files() -> Vec<(String, String)> {
|
|
let mut out = Vec::new();
|
|
for dir in ["vectors", "graphs"] {
|
|
let d = conformance().join(dir);
|
|
let mut names: Vec<String> = std::fs::read_dir(&d)
|
|
.unwrap_or_else(|e| panic!("{}: {e}", d.display()))
|
|
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
|
|
.filter(|n| n.ends_with(".jsonl"))
|
|
.collect();
|
|
names.sort();
|
|
for n in names {
|
|
let text = std::fs::read_to_string(d.join(&n)).unwrap();
|
|
out.push((format!("{dir}/{n}"), text));
|
|
}
|
|
}
|
|
assert!(out.len() > 30, "the corpus was not found");
|
|
out
|
|
}
|
|
|
|
/// A vector's expectation for one decode: the outputs, or a refusal code.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum Expect {
|
|
Outputs(Vec<Tensor>),
|
|
Refuse(String),
|
|
}
|
|
|
|
pub struct Decode {
|
|
pub inputs: Vec<Tensor>,
|
|
pub scalars: Vec<i64>,
|
|
pub expect: Expect,
|
|
}
|
|
|
|
pub struct Vector {
|
|
pub file: String,
|
|
pub id: String,
|
|
pub graph: Vec<u8>,
|
|
pub load: String,
|
|
pub decodes: Vec<Decode>,
|
|
}
|
|
|
|
fn dec(v: &Value) -> i64 {
|
|
v.as_dec_i64().expect("an i64")
|
|
}
|
|
|
|
pub fn vectors() -> Vec<Vector> {
|
|
files().iter().flat_map(|(file, text)| parse(file, text)).collect()
|
|
}
|
|
|
|
/// The vectors of one file.
|
|
pub fn parse(file: &str, text: &str) -> Vec<Vector> {
|
|
let mut out = Vec::new();
|
|
for line in text.split_terminator('\n') {
|
|
let v = json::parse(line.as_bytes()).expect("a canonical line");
|
|
let id = v.get("id").and_then(Value::as_str).unwrap().to_string();
|
|
let graph = unhex(v.get("graph").and_then(Value::as_str).unwrap()).unwrap();
|
|
let load = v.get("load").and_then(Value::as_str).unwrap().to_string();
|
|
let decodes = v
|
|
.get("decodes")
|
|
.and_then(Value::as_arr)
|
|
.unwrap()
|
|
.iter()
|
|
.map(|d| {
|
|
let inputs = d.get("inputs").and_then(Value::as_arr).unwrap().iter().map(|t| read_tensor(t).unwrap()).collect();
|
|
let scalars = d.get("scalars").and_then(Value::as_arr).unwrap().iter().map(dec).collect();
|
|
let e = d.get("expect").unwrap();
|
|
let expect = match e.get("refuse") {
|
|
Some(c) => Expect::Refuse(c.as_str().unwrap().to_string()),
|
|
None => Expect::Outputs(e.get("outputs").and_then(Value::as_arr).unwrap().iter().map(|t| read_tensor(t).unwrap()).collect()),
|
|
};
|
|
Decode { inputs, scalars, expect }
|
|
})
|
|
.collect();
|
|
out.push(Vector { file: file.to_string(), id, graph, load, decodes });
|
|
}
|
|
out
|
|
}
|
|
|
|
/// What one adapter did with the corpus.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct Tally {
|
|
/// Vectors run, and their decodes, at each width.
|
|
pub vectors32: u32,
|
|
pub decodes32: u32,
|
|
pub vectors64: u32,
|
|
pub decodes64: u32,
|
|
/// The same `acc64` vectors run again with narrowing off: every (transposed)
|
|
/// convolution on the 64-bit kernels.
|
|
pub vectors64w: u32,
|
|
pub decodes64w: u32,
|
|
/// Operators the `acc64` runs gave the narrow kernels (`prepare::narrow_plan`), and
|
|
/// the vectors holding one.
|
|
pub narrow_ops: u32,
|
|
pub narrow_vectors: u32,
|
|
/// Of those decodes, the ones whose expectation is a refusal.
|
|
pub refusals: u32,
|
|
/// Graphs with an operator outside wai-ops/1-i32: run on the CPU, not here.
|
|
pub cpu: u32,
|
|
/// Vectors whose load is refused: nothing for a device to run.
|
|
pub load_refused: u32,
|
|
/// Decodes whose device result differs from the expectation, with what differed.
|
|
pub mismatches: Vec<String>,
|
|
/// Device errors.
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
fn got_of(r: Result<Result<Vec<Tensor>, wai_ops::Refusal>, GpuError>) -> Result<Expect, String> {
|
|
match r {
|
|
Ok(Ok(t)) => Ok(Expect::Outputs(t)),
|
|
Ok(Err(r)) => Ok(Expect::Refuse(r.code.as_str().to_string())),
|
|
Err(e) => Err(e.to_string()),
|
|
}
|
|
}
|
|
|
|
fn cpu_of(l: &Loaded, d: &Decode, seq: &mut Sequence) -> Expect {
|
|
match wai_ops::interp::run(l, &d.inputs, &d.scalars, seq) {
|
|
Ok(t) => Expect::Outputs(t),
|
|
Err(r) => Expect::Refuse(r.code.as_str().to_string()),
|
|
}
|
|
}
|
|
|
|
/// Run every vector on `gpu` at every width its graph admits (acc64 always, with
|
|
/// narrowing on and then off; acc32 too where its certificate meets it), stopping a
|
|
/// width's sequence at its first mismatch. `limit` caps the mismatches recorded. The
|
|
/// device's narrowing is on when this returns.
|
|
pub fn run_all(gpu: &Gpu, vs: &[Vector], limit: usize) -> Tally {
|
|
let mut t = Tally::default();
|
|
for v in vs {
|
|
let l = match wai_ops::graph::load(&v.graph, Profile::Acc64) {
|
|
Ok(l) => l,
|
|
Err(r) => {
|
|
assert_eq!(r.code.as_str(), v.load, "{}: the load the vector records", v.id);
|
|
t.load_refused += 1;
|
|
continue;
|
|
}
|
|
};
|
|
assert_eq!(v.load, "ok", "{}", v.id);
|
|
if support(&l).is_err() {
|
|
t.cpu += 1;
|
|
continue;
|
|
}
|
|
let narrow = wai_ops_gpu::prepare::narrow_plan(l.graph()).iter().filter(|c| c.is_some()).count() as u32;
|
|
t.narrow_ops += narrow;
|
|
t.narrow_vectors += (narrow > 0) as u32;
|
|
let mut widths = vec![(Width::Acc64, true), (Width::Acc64, false)];
|
|
if l.meets(Profile::Acc32) {
|
|
widths.push((Width::Acc32, true));
|
|
}
|
|
for (w, narrowing) in widths {
|
|
gpu.set_narrowing(narrowing);
|
|
let mut seq = GpuSequence::new();
|
|
let mut cpu = Sequence::new();
|
|
match (w, narrowing) {
|
|
(Width::Acc32, _) => t.vectors32 += 1,
|
|
(Width::Acc64, true) => t.vectors64 += 1,
|
|
(Width::Acc64, false) => t.vectors64w += 1,
|
|
}
|
|
for (k, d) in v.decodes.iter().enumerate() {
|
|
let want = cpu_of(&l, d, &mut cpu);
|
|
assert_eq!(want, d.expect, "{}: decode {k}: the CPU interpreter's result is the committed one", v.id);
|
|
match (w, narrowing) {
|
|
(Width::Acc32, _) => t.decodes32 += 1,
|
|
(Width::Acc64, true) => t.decodes64 += 1,
|
|
(Width::Acc64, false) => t.decodes64w += 1,
|
|
}
|
|
if matches!(d.expect, Expect::Refuse(_)) && narrowing {
|
|
t.refusals += 1;
|
|
}
|
|
match got_of(blocking::run_at(gpu, &l, w, &d.inputs, &d.scalars, &mut seq)) {
|
|
Ok(got) if got == d.expect => {}
|
|
Ok(got) => {
|
|
if t.mismatches.len() < limit {
|
|
let n = if narrowing { "" } else { " (narrowing off)" };
|
|
t.mismatches.push(format!("{} ({}) decode {k} at {}{n}: {}", v.id, v.file, w.as_str(), diff(&d.expect, &got)));
|
|
}
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
t.errors.push(format!("{} decode {k} at {}: {e}", v.id, w.as_str()));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
gpu.set_narrowing(true);
|
|
t
|
|
}
|
|
|
|
fn diff(want: &Expect, got: &Expect) -> String {
|
|
match (want, got) {
|
|
(Expect::Outputs(a), Expect::Outputs(b)) => {
|
|
for (k, (x, y)) in a.iter().zip(b).enumerate() {
|
|
if (x.c, x.h, x.w) != (y.c, y.h, y.w) {
|
|
return format!("output {k} shape {:?} vs {:?}", (x.c, x.h, x.w), (y.c, y.h, y.w));
|
|
}
|
|
if let Some(i) = (0..x.data.len()).find(|&i| x.data[i] != y.data[i]) {
|
|
return format!("output {k} element {i}: want {} got {}", x.data[i], y.data[i]);
|
|
}
|
|
}
|
|
"output count".into()
|
|
}
|
|
_ => format!("want {want:?} got {got:?}").chars().take(300).collect(),
|
|
}
|
|
}
|