open-standards/wai/wai-ops-gpu/tests/wgsl_rules.rs
Transaction Science 4b5f73c1dc wai-ops-gpu rules: rule 8 finds naga's signed wrapping, not a bitcast (#452 review S1)
#452 review S1. Rule 8 checked signed add, subtract and multiply by looking for
`as_type<uint>(` in the MSL and `asuint(` in the HLSL, which every
`bitcast<u32>(i32)` also writes: modules with no signed arithmetic carried both, and
ops32 passed for a reason the check did not test. It now requires naga's nesting,
`as_type<int>(as_type<uint>(` (and its two-component form) in MSL and
`asint(asuint(` in HLSL. A new test takes the wrapping out of ops32's translations,
leaving its bitcasts, and shows each refused; wai-ops-gpu.yml counts 17 tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:59:21 -04:00

563 lines
30 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.

//! The shipped WGSL modules held, on naga 30.0.1's IR (the translator wgpu runs them
//! through), to the rules the `decode-equivalence` tier on a GPU rests on
//! (determinism-tiers §5, *Claiming the tier on a GPU*). No adapter is needed: these run
//! on every leg.
//!
//! 1. Each module parses, and validates with every validation flag and no capability:
//! so no `f16`, no 64-bit type, no subgroup or quad operation, no extension.
//! 2. Every entry point is a compute entry point; the globals are exactly the six
//! bindings of `wgsl/common.wgsl`, and there is no workgroup or private memory.
//! 3. Every scalar, in the type arena and in the type of every expression, is `bool`,
//! `i32` or `u32`; every literal is one of those. No floating-point value exists.
//! 4. Every expression is one of a closed list: no derivative, image, ray-query,
//! subgroup or cooperative-matrix expression; every built-in function is one of the
//! integer functions the semantics suite tests; every conversion is between `bool`,
//! `i32` and `u32`.
//! 5. In the kernels (every module but `semantics`, which exists to evaluate them), every
//! shift amount is a literal in 0..=31 or masked to 0..=31 (`e & 31u`), and every
//! `clamp` has literal bounds in order: the two integer operations whose WGSL result
//! naga 30.0.1 does not produce itself in MSL, HLSL and SPIR-V
//! (`semantics::UNGUARDED`) are reached only where every backend defines them. No
//! kernel shifts a signed value left: MSL leaves a left shift of a negative or
//! overflowing signed value undefined, and naga writes the shift as it is, so a kernel
//! shifts the `u32` and bitcasts.
//! 6. The only atomic operation is `atomicOr`, its result unused, and no `atomicLoad` or
//! `atomicStore` reaches an atomic word (naga lowers both to a plain load and store
//! through a pointer to it, which this rule reads): so every atomic operation on a word
//! is of one kind, and no result depends on the order in which invocations reach it;
//! there is no barrier.
//! 7. Every integer operation the kernels use (by operator and by signedness) is one the
//! semantics suite runs on every adapter (`src/semantics.rs`).
//! 8. Each module translates to MSL 2.4, HLSL shader model 5.1 and SPIR-V; the MSL and
//! HLSL carry naga's guards for every guarded operation the module uses (signed
//! arithmetic through unsigned, found by its nesting, `as_type<int>(as_type<uint>(`
//! and `asint(asuint(`, not by a bitcast; `naga_div`, `naga_mod`, `naga_neg`,
//! `naga_abs`), and
//! the MSL guards for division vanish when `emit_int_div_checks` is turned off,
//! which is what the crate must never do.
//!
//! Then each planted violation (a floating-point value, `f16`, a subgroup operation, an
//! `atomicAdd`, an atomic's result read, an `atomicLoad`, an `atomicStore`, `atomicOr`
//! and `atomicStore` on one word, a workgroup variable and barrier, a storage barrier
//! alone, an unmasked shift, a signed left shift, a run-time `clamp`) is shown to be refused, by the rule it
//! breaks.
use std::collections::BTreeSet;
use naga::back::{hlsl, msl, spv};
use naga::valid::{Capabilities, ModuleInfo, ValidationFlags, Validator};
use naga::{AddressSpace, AtomicFunction, BinaryOperator, Block, Expression, Literal, MathFunction, Module, Scalar, ScalarKind, ShaderStage, Statement, StorageAccess, TypeInner, UnaryOperator};
use wai_ops_gpu::kernels::Sources;
use wai_ops_gpu::semantics::Op;
type Check<T = ()> = Result<T, String>;
/// Step 1.
fn compile(text: &str) -> Check<(Module, ModuleInfo)> {
let module = naga::front::wgsl::parse_str(text).map_err(|e| format!("rule 1: parse: {}", e.emit_to_string(text)))?;
let info = Validator::new(ValidationFlags::all(), Capabilities::empty()).validate(&module).map_err(|e| format!("rule 1: validation: {}", e.emit_to_string(text)))?;
Ok((module, info))
}
/// The functions and entry points, with their expression-info tables.
fn functions<'a>(m: &'a Module, info: &'a ModuleInfo) -> Vec<(String, &'a naga::Function, &'a naga::valid::FunctionInfo)> {
let mut v: Vec<(String, &naga::Function, &naga::valid::FunctionInfo)> = m.functions.iter().map(|(h, f)| (f.name.clone().unwrap_or_default(), f, &info[h])).collect();
for (i, e) in m.entry_points.iter().enumerate() {
v.push((e.name.clone(), &e.function, info.get_entry_point(i)));
}
v
}
/// Step 2.
fn interface(m: &Module) -> Check {
for e in &m.entry_points {
if e.stage != ShaderStage::Compute {
return Err(format!("rule 2: entry point `{}` is a {:?} stage", e.name, e.stage));
}
if e.workgroup_size_overrides.is_some() || e.workgroup_size[1] != 1 || e.workgroup_size[2] != 1 {
return Err(format!("rule 2: entry point `{}` has workgroup size {:?}", e.name, e.workgroup_size));
}
}
let mut found = Vec::new();
for (_, v) in m.global_variables.iter() {
let name = v.name.clone().unwrap_or_default();
let space = match v.space {
AddressSpace::Uniform => "uniform",
AddressSpace::Storage { access } if access == StorageAccess::LOAD => "read",
AddressSpace::Storage { access } if access.contains(StorageAccess::LOAD | StorageAccess::STORE) => "read_write",
other => return Err(format!("rule 2: global `{name}` is in address space {other:?}")),
};
let b = v.binding.ok_or(format!("rule 2: global `{name}` has no binding"))?;
found.push((b.group, b.binding, space));
}
found.sort();
let want = vec![(0, 0, "uniform"), (0, 1, "read"), (0, 2, "read"), (0, 3, "read"), (0, 4, "read_write"), (0, 5, "read_write")];
if found != want {
return Err(format!("rule 2: the globals are {found:?}, not the six bindings of common.wgsl"));
}
Ok(())
}
fn scalar_ok(s: Scalar) -> bool {
s.kind == ScalarKind::Bool || (matches!(s.kind, ScalarKind::Sint | ScalarKind::Uint) && s.width == 4)
}
fn type_ok(t: &TypeInner) -> Check {
match *t {
TypeInner::Scalar(s) | TypeInner::Vector { scalar: s, .. } | TypeInner::Atomic(s) | TypeInner::ValuePointer { scalar: s, .. } => {
if scalar_ok(s) { Ok(()) } else { Err(format!("{:?} of width {}", s.kind, s.width)) }
}
TypeInner::Pointer { .. } | TypeInner::Array { .. } | TypeInner::Struct { .. } => Ok(()),
ref other => Err(format!("{other:?}")),
}
}
/// Step 3.
fn types(m: &Module, info: &ModuleInfo) -> Check {
for (_, t) in m.types.iter() {
type_ok(&t.inner).map_err(|e| format!("rule 3: the type {} is not made of bool, i32 and u32: {e}", t.name.clone().unwrap_or_default()))?;
}
for (name, f, fi) in functions(m, info) {
for (h, e) in f.expressions.iter() {
type_ok(fi[h].ty.inner_with(&m.types)).map_err(|err| format!("rule 3: in `{name}`, {e:?} has a type that is not bool, i32 or u32: {err}"))?;
if let Expression::Literal(l) = e {
literal_ok(l).map_err(|err| format!("rule 3: in `{name}`: {err}"))?;
}
}
}
for (_, e) in m.global_expressions.iter() {
if let Expression::Literal(l) = e {
literal_ok(l).map_err(|err| format!("rule 3: at module scope: {err}"))?;
}
}
Ok(())
}
fn literal_ok(l: &Literal) -> Check {
match l {
Literal::U32(_) | Literal::I32(_) | Literal::Bool(_) => Ok(()),
other => Err(format!("the literal {other:?}")),
}
}
/// The integer functions the semantics suite tests.
fn math_ok(f: MathFunction) -> bool {
use MathFunction::*;
matches!(f, Min | Max | Clamp | Abs | Sign | CountLeadingZeros | CountTrailingZeros | CountOneBits | ReverseBits | FirstLeadingBit | FirstTrailingBit | ExtractBits | InsertBits)
}
/// The integer value of a literal, or of a constant initialised with one.
fn literal_value(m: &Module, arena: &naga::Arena<Expression>, h: naga::Handle<Expression>) -> Option<i64> {
let lit = |l: &Literal| match *l {
Literal::U32(v) => Some(i64::from(v)),
Literal::I32(v) => Some(i64::from(v)),
_ => None,
};
match &arena[h] {
Expression::Literal(l) => lit(l),
Expression::Constant(c) => match &m.global_expressions[m.constants[*c].init] {
Expression::Literal(l) => lit(l),
_ => None,
},
_ => None,
}
}
/// A shift amount the kernels may use: a literal in 0..=31, or `e & k` with `k` a literal
/// in 0..=31 (either side).
fn amount_bounded(m: &Module, arena: &naga::Arena<Expression>, h: naga::Handle<Expression>) -> bool {
if let Some(v) = literal_value(m, arena, h) {
return (0..=31).contains(&v);
}
match arena[h] {
Expression::Binary { op: BinaryOperator::And, left, right } => [left, right].iter().any(|&x| literal_value(m, arena, x).is_some_and(|v| (0..=31).contains(&v))),
_ => false,
}
}
/// The scalar kind of an expression and whether it is a vector.
fn kind_of(m: &Module, fi: &naga::valid::FunctionInfo, h: naga::Handle<Expression>) -> (Option<ScalarKind>, bool) {
let t = fi[h].ty.inner_with(&m.types);
(t.scalar_kind(), matches!(t, TypeInner::Vector { .. }))
}
/// Whether a pointer expression points at an atomic: naga lowers `atomicLoad` to a plain
/// `Load` and `atomicStore` to a plain `Store` through such a pointer.
fn points_at_atomic(m: &Module, fi: &naga::valid::FunctionInfo, p: naga::Handle<Expression>) -> bool {
match *fi[p].ty.inner_with(&m.types) {
TypeInner::Pointer { base, .. } => matches!(m.types[base].inner, TypeInner::Atomic(_)),
_ => false,
}
}
/// Steps 4, 5 and 7: the closed list of expressions, the kernel rules on shifts and
/// `clamp`, and the semantics operation each integer operation is. Returns the
/// operations used.
fn expressions(m: &Module, info: &ModuleInfo, kernel: bool) -> Check<BTreeSet<Op>> {
let mut used = BTreeSet::new();
for (name, f, fi) in functions(m, info) {
let arena = &f.expressions;
for (h, e) in arena.iter() {
let at = |what: String| format!("in `{name}`, {what}");
match *e {
Expression::Literal(_) | Expression::Constant(_) | Expression::ZeroValue(_) | Expression::Compose { .. } | Expression::Access { .. } | Expression::AccessIndex { .. } | Expression::Splat { .. } | Expression::Swizzle { .. } | Expression::FunctionArgument(_) | Expression::GlobalVariable(_) | Expression::LocalVariable(_) | Expression::CallResult(_) | Expression::ArrayLength(_) => {}
// An `atomicLoad`: its value depends on which invocations reached the word first.
Expression::Load { pointer } => {
if points_at_atomic(m, fi, pointer) {
return Err(format!("rule 6: {}", at("an atomic word is loaded (atomicLoad)".into())));
}
}
Expression::Relational { fun, .. } => {
if !matches!(fun, naga::RelationalFunction::All | naga::RelationalFunction::Any) {
return Err(format!("rule 4: {}", at(format!("the relational function {fun:?}"))));
}
}
// naga gives every atomic call a result; the call statement does not count
// as a reference to it, so a count above zero is a read.
Expression::AtomicResult { .. } => {
if fi[h].ref_count > 0 {
return Err(format!("rule 6: {}", at("an atomic operation's result is read".into())));
}
}
Expression::Unary { op, expr } => {
let (k, _) = kind_of(m, fi, expr);
match (op, k) {
(UnaryOperator::Negate, Some(ScalarKind::Sint)) => {
used.insert(Op::NegI32);
}
(UnaryOperator::BitwiseNot, Some(ScalarKind::Sint | ScalarKind::Uint)) => {
used.insert(Op::Not);
}
(UnaryOperator::LogicalNot, Some(ScalarKind::Bool)) | (UnaryOperator::BitwiseNot, Some(ScalarKind::Bool)) => {}
(op, k) => return Err(format!("rule 4: {}", at(format!("the unary {op:?} on {k:?}")))),
}
}
Expression::Binary { op, left, right } => {
let (k, vector) = kind_of(m, fi, left);
use BinaryOperator as B;
let s = k == Some(ScalarKind::Sint);
let int = matches!(k, Some(ScalarKind::Sint | ScalarKind::Uint));
let v = vector;
let op_used = match op {
B::Add if int && v && !s => Some(Op::AddVec2U32),
B::Multiply if int && v => Some(if s { Op::MulVec2I32 } else { Op::MulVec2U32 }),
B::ShiftRight if v => Some(if s { Op::ShrVec2I32 } else { Op::ShrVec2U32 }),
B::Add if int && !v => Some(if s { Op::AddI32 } else { Op::AddU32 }),
B::Subtract if int && !v => Some(if s { Op::SubI32 } else { Op::SubU32 }),
B::Multiply if int && !v => Some(if s { Op::MulI32 } else { Op::MulU32 }),
B::Divide if int && !v => Some(if s { Op::DivI32 } else { Op::DivU32 }),
B::Modulo if int && !v => Some(if s { Op::RemI32 } else { Op::RemU32 }),
B::ShiftLeft if int && !v => Some(if s { Op::ShlI32 } else { Op::ShlU32 }),
B::ShiftRight if int && !v => Some(if s { Op::ShrI32 } else { Op::ShrU32 }),
B::And if int && !v => Some(Op::And),
B::InclusiveOr if int && !v => Some(Op::Or),
B::ExclusiveOr if int && !v => Some(Op::Xor),
B::Less | B::Greater if int && !v => Some(if s { Op::LtI32 } else { Op::LtU32 }),
B::LessEqual | B::GreaterEqual if int && !v => Some(if s { Op::LeI32 } else { Op::LeU32 }),
B::Equal if int && !v => Some(Op::Eq),
B::NotEqual if int && !v => Some(Op::Ne),
B::LogicalAnd | B::LogicalOr | B::And | B::InclusiveOr | B::Equal | B::NotEqual if k == Some(ScalarKind::Bool) => None,
_ => return Err(format!("rule 7: {}", at(format!("{op:?} on {k:?} (vector {vector}), which the semantics suite does not test")))),
};
// MSL leaves a left shift of a negative or overflowing signed value undefined,
// and naga passes it through: a kernel shifts a u32 and bitcasts.
if kernel && op == B::ShiftLeft && s {
return Err(format!("rule 5: {}", at("a left shift of a signed value: shift the u32 and bitcast".into())));
}
if kernel && matches!(op, B::ShiftLeft | B::ShiftRight) && !amount_bounded(m, arena, right) {
return Err(format!("rule 5: {}", at(format!("{op:?} by an amount that is neither a literal in 0..=31 nor masked to it: {:?}", arena[right]))));
}
used.extend(op_used);
}
Expression::Select { accept, .. } => {
let (k, vector) = kind_of(m, fi, accept);
used.insert(match (k, vector) {
(_, true) => Op::SelectVec2U32,
(Some(ScalarKind::Sint), false) => Op::SelectI32,
_ => Op::SelectU32,
});
}
Expression::Math { fun, arg, arg1, arg2, .. } => {
if !math_ok(fun) {
return Err(format!("rule 4: {}", at(format!("the built-in function {fun:?}, not an integer function the suite tests"))));
}
let (k, _) = kind_of(m, fi, arg);
let s = k == Some(ScalarKind::Sint);
use MathFunction as F;
if kernel && fun == F::Clamp {
let lo = arg1.and_then(|x| literal_value(m, arena, x));
let hi = arg2.and_then(|x| literal_value(m, arena, x));
match (lo, hi) {
(Some(lo), Some(hi)) if lo <= hi => {}
_ => return Err(format!("rule 5: {}", at("a clamp whose bounds are not literals in order: write min(max(e, low), high)".into()))),
}
}
used.insert(match fun {
F::Min => if s { Op::MinI32 } else { Op::MinU32 },
F::Max => if s { Op::MaxI32 } else { Op::MaxU32 },
F::Clamp => if s { Op::ClampI32 } else { Op::ClampU32 },
F::Abs => Op::AbsI32,
F::Sign => Op::SignI32,
F::CountLeadingZeros => if s { Op::CountLeadingZerosI32 } else { Op::CountLeadingZerosU32 },
F::CountTrailingZeros => Op::CountTrailingZerosU32,
F::CountOneBits => Op::CountOneBitsU32,
F::ReverseBits => Op::ReverseBitsU32,
F::FirstLeadingBit => if s { Op::FirstLeadingBitI32 } else { Op::FirstLeadingBitU32 },
F::FirstTrailingBit => Op::FirstTrailingBitU32,
F::ExtractBits => if s { Op::ExtractBitsI32 } else { Op::ExtractBitsU32 },
F::InsertBits => if s { Op::InsertBitsI32 } else { Op::InsertBitsU32 },
_ => unreachable!(),
});
}
Expression::As { expr, kind, convert } => {
let (from, _) = kind_of(m, fi, expr);
let ok = |k: Option<ScalarKind>| matches!(k, Some(ScalarKind::Sint | ScalarKind::Uint | ScalarKind::Bool));
if !ok(Some(kind)) || !ok(from) || convert.is_some_and(|w| w != 4) {
return Err(format!("rule 4: {}", at(format!("a conversion from {from:?} to {kind:?} ({convert:?})"))));
}
if convert.is_some() {
used.insert(match (from, kind) {
(Some(ScalarKind::Bool), _) => Op::U32OfBool,
(Some(ScalarKind::Sint), ScalarKind::Uint) => Op::U32OfI32,
(Some(ScalarKind::Uint), ScalarKind::Sint) => Op::I32OfU32,
_ => continue,
});
}
}
ref other => return Err(format!("rule 4: {}", at(format!("the expression {other:?}")))),
}
}
}
Ok(used)
}
/// Step 6, recursively.
fn statements(m: &Module, fi: &naga::valid::FunctionInfo, block: &Block, name: &str) -> Check {
for s in block.iter() {
match s {
// An `atomicStore`: on a word an atomic also reaches, the result is order-dependent.
Statement::Store { pointer, .. } if points_at_atomic(m, fi, *pointer) => {
return Err(format!("rule 6: in `{name}`, an atomic word is stored to (atomicStore)"));
}
// Whether its result is read is the expression rule's (rule 6 there).
Statement::Atomic { fun, .. } => {
if !matches!(fun, AtomicFunction::InclusiveOr) {
return Err(format!("rule 6: in `{name}`, the atomic operation {fun:?}"));
}
}
Statement::ControlBarrier(_) | Statement::MemoryBarrier(_) | Statement::WorkGroupUniformLoad { .. } => return Err(format!("rule 6: in `{name}`, a barrier")),
Statement::Block(b) => statements(m, fi, b, name)?,
Statement::If { accept, reject, .. } => {
statements(m, fi, accept, name)?;
statements(m, fi, reject, name)?;
}
Statement::Switch { cases, .. } => {
for c in cases {
statements(m, fi, &c.body, name)?;
}
}
Statement::Loop { body, continuing, .. } => {
statements(m, fi, body, name)?;
statements(m, fi, continuing, name)?;
}
Statement::Emit(_) | Statement::Break | Statement::Continue | Statement::Return { .. } | Statement::Store { .. } | Statement::Call { .. } => {}
other => return Err(format!("rule 4: in `{name}`, the statement {other:?}")),
}
}
Ok(())
}
/// What one module was found to use.
struct Found {
module: Module,
info: ModuleInfo,
used: BTreeSet<Op>,
}
/// Rules 1–7 on one module's text; `kernel` is false only for the semantics module.
fn check(text: &str, kernel: bool) -> Check<Found> {
let (module, info) = compile(text)?;
interface(&module)?;
types(&module, &info)?;
let used = expressions(&module, &info, kernel)?;
for (name, f, fi) in functions(&module, &info) {
statements(&module, fi, &f.body, &name)?;
}
Ok(Found { module, info, used })
}
fn msl_text(f: &Found, int_div_checks: bool) -> Check<String> {
let options = msl::Options { lang_version: (2, 4), emit_int_div_checks: int_div_checks, ..Default::default() };
msl::write_string(&f.module, &f.info, &options, &msl::PipelineOptions::default()).map(|(s, _)| s).map_err(|e| format!("rule 8: MSL: {e}"))
}
fn hlsl_text(f: &Found) -> Check<String> {
let mut s = String::new();
hlsl::Writer::new(&mut s, &hlsl::Options::default(), &hlsl::PipelineOptions::default()).write(&f.module, &f.info, None).map_err(|e| format!("rule 8: HLSL: {e}"))?;
Ok(s)
}
/// Rule 8's signed arithmetic: naga writes a signed add, subtract or multiply through
/// unsigned, `as_type<int>(as_type<uint>(a) + ...)` in MSL and `asint(asuint(a) + ...)` in
/// HLSL. The nesting is what is looked for: a bare bitcast (`bitcast<u32>(i32)`) gives the
/// inner form alone, and is no evidence of the wrapping.
fn signed_guards(name: &str, u: &BTreeSet<Op>, m: &str, h: &str) -> Check {
let scalar = [Op::AddI32, Op::SubI32, Op::MulI32].iter().any(|o| u.contains(o));
let vector = u.contains(&Op::MulVec2I32);
let fail = |lang: &str, what: &str| Err(format!("rule 8: {name}: the {lang} lacks naga's wrapping of {what} signed arithmetic through unsigned"));
if scalar && !m.contains("as_type<int>(as_type<uint>(") {
return fail("MSL", "scalar");
}
if vector && !["as_type<metal::int2>(as_type<metal::uint2>(", "as_type<int2>(as_type<uint2>("].iter().any(|p| m.contains(p)) {
return fail("MSL", "vector");
}
if (scalar || vector) && !h.contains("asint(asuint(") {
return fail("HLSL", "");
}
Ok(())
}
/// Rule 8 on one module: the translations, and the guards each must carry.
fn translations(name: &str, f: &Found) -> Check {
let m = msl_text(f, true)?;
let h = hlsl_text(f)?;
spv::write_vec(&f.module, &f.info, &spv::Options::default(), None).map_err(|e| format!("rule 8: {name}: SPIR-V: {e}"))?;
let u = &f.used;
let need = |cond: bool, text: &str, pat: &str, lang: &str| -> Check {
if cond && !text.contains(pat) {
return Err(format!("rule 8: {name}: the {lang} lacks `{pat}`, the guard for an operation the module uses"));
}
Ok(())
};
signed_guards(name, u, &m, &h)?;
let div = [Op::DivU32, Op::DivI32].iter().any(|o| u.contains(o));
let rem = [Op::RemU32, Op::RemI32].iter().any(|o| u.contains(o));
for (text, lang) in [(&m, "MSL"), (&h, "HLSL")] {
need(div, text, "naga_div(", lang)?;
need(rem, text, "naga_mod(", lang)?;
need(u.contains(&Op::NegI32), text, "naga_neg(", lang)?;
need(u.contains(&Op::AbsI32), text, "naga_abs(", lang)?;
}
if div || rem {
let raw = msl_text(f, false)?;
if raw.contains("naga_div(") || raw.contains("naga_mod(") {
return Err(format!("rule 8: {name}: the MSL keeps its division guards with emit_int_div_checks off, so this check does not see the switch"));
}
}
Ok(())
}
#[test]
fn every_shipped_module_meets_the_rules() {
let src = Sources::shipped();
let mut all = BTreeSet::new();
for (name, text) in src.named() {
let kernel = name != "semantics";
let f = check(text, kernel).unwrap_or_else(|e| panic!("{name}: {e}"));
translations(name, &f).unwrap_or_else(|e| panic!("{e}"));
let ops: Vec<&str> = f.used.iter().map(|o| o.name()).collect();
println!("ops-gpu wgsl rules: {name}: rules 1-8 hold; integer operations used: {}", ops.join(" "));
if kernel {
all.extend(f.used);
}
}
let ops: Vec<&str> = all.iter().map(|o| o.name()).collect();
println!("ops-gpu wgsl rules: the kernels use {} of the suite's operations: {}", all.len(), ops.join(" "));
}
/// Rule 7 the other way: the semantics module evaluates every operation of the suite,
/// so a kernel operation the suite names is one the device is held to.
#[test]
fn the_semantics_module_evaluates_every_operation_of_the_suite() {
let src = Sources::shipped();
let f = check(&src.semantics, false).unwrap();
for op in wai_ops_gpu::semantics::ALL {
// The conversions u32(i32) and i32(u32) are bit-preserving, as bitcast is; the
// comparisons with > and >= are those with < and <= on swapped operands.
assert!(f.used.contains(&op), "the semantics module does not evaluate {op:?}");
}
}
/// The crate creates every module through `create_shader_module`, which keeps wgpu's
/// run-time checks, the division guards among them, on; never through the unchecked
/// form or with checks of its own choosing.
#[test]
fn the_crate_never_turns_the_translators_checks_off() {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap() {
let p = entry.unwrap().path();
if p.extension().is_some_and(|e| e == "rs") {
let text = std::fs::read_to_string(&p).unwrap();
for (n, line) in text.lines().enumerate() {
let code = line.split("//").next().unwrap_or("");
for bad in ["create_shader_module_trusted", "ShaderRuntimeChecks", "create_shader_module_passthrough", "create_shader_module_spirv"] {
assert!(!code.contains(bad), "{}:{}: {bad}", p.display(), n + 1);
}
seen += code.matches("create_shader_module(").count();
}
}
}
assert!(seen >= 1, "no module is created through create_shader_module");
}
/// Rule 8 sees naga's wrapping of signed arithmetic, not a bitcast: a translation of
/// `ops32` (which adds signed values) with the wrapping taken out, its bitcasts left, is
/// refused, in MSL and in HLSL.
#[test]
fn rule_8_finds_the_wrapping_of_signed_arithmetic() {
let src = Sources::shipped();
let f = check(&src.ops32, true).unwrap();
assert!(f.used.contains(&Op::AddI32), "ops32 adds signed values");
let (m, h) = (msl_text(&f, true).unwrap(), hlsl_text(&f).unwrap());
signed_guards("ops32", &f.used, &m, &h).unwrap();
let unwrapped = m.replace("as_type<int>(as_type<uint>(", "as_type<int>((");
assert_ne!(unwrapped, m);
assert!(unwrapped.contains("as_type<uint>("), "the bitcasts alone remain, which the old check took for the wrapping");
assert!(signed_guards("ops32", &f.used, &unwrapped, &h).unwrap_err().starts_with("rule 8"));
let unwrapped = h.replace("asint(asuint(", "asint((");
assert_ne!(unwrapped, h);
assert!(signed_guards("ops32", &f.used, &m, &unwrapped).unwrap_err().starts_with("rule 8"));
println!("ops-gpu wgsl rules: rule 8 found the signed wrapping in ops32's MSL and HLSL, and refused each with it taken out");
}
/// Each violation, planted in a shipped module, is refused by the rule it breaks.
#[test]
fn each_planted_violation_is_refused() {
let src = Sources::shipped();
let ops32 = &src.ops32;
let anchor = " let v = A[t];\n switch P.mode {";
assert_eq!(ops32.matches(anchor).count(), 1);
let plant = |code: &str| ops32.replacen(anchor, &format!("{code}\n{anchor}"), 1);
let cases: Vec<(&str, String, &str)> = vec![
("a floating-point value", plant(" let fl = f32(P.n) * 0.5;\n O[t] = u32(fl);"), "rule 3"),
("an f16 value", format!("enable f16;\n{}", plant(" let h = f16(A[t]);\n O[t] = u32(h);")), "rule 1"),
("a subgroup reduction", format!("enable subgroups;\n{}", plant(" O[t] = subgroupAdd(A[t]);")), "rule 1"),
("an atomicAdd", plant(" atomicAdd(&F[P.flag], 1u);"), "rule 6"),
("an atomic's result read", plant(" let old = atomicOr(&F[P.flag], 1u);\n O[t] = old;"), "rule 6"),
("an atomicLoad read into the output", plant(" O[t] = atomicLoad(&F[P.flag]);"), "rule 6"),
("an atomicStore", plant(" atomicStore(&F[P.flag], 0u);"), "rule 6"),
("atomicOr and atomicStore on one word", plant(" atomicOr(&F[P.flag], 1u);\n atomicStore(&F[P.flag], 0u);"), "rule 6"),
("a storage barrier alone", plant(" storageBarrier();"), "rule 6"),
("a workgroup variable and a barrier", format!("var<workgroup> wg: array<u32, 64>;\n{}", plant(" wg[t & 63u] = A[t];\n workgroupBarrier();")), "rule 2"),
("an unmasked shift", plant(" O[t] = A[t] << P.f;"), "rule 5"),
("a left shift of a signed value", plant(" O[t] = bitcast<u32>(bitcast<i32>(A[t]) << 31u);"), "rule 5"),
("a clamp with run-time bounds", plant(" O[t] = clamp(A[t], P.lo_x, P.hi_x);"), "rule 5"),
("a floating-point built-in", plant(" O[t] = u32(sqrt(f32(A[t])));"), "rule 3"),
];
for (what, text, rule) in &cases {
assert_ne!(text, ops32, "{what}: the plant did not change the text");
match check(text, true) {
Ok(_) => panic!("{what}: the rules accepted it"),
Err(e) => {
assert!(e.starts_with(rule), "{what}: refused, but by `{e}`, not {rule}");
println!("ops-gpu wgsl rules: planted {what}: refused: {}", e.lines().next().unwrap_or(""));
}
}
}
// And the shift rule reads the masks the kernels carry: unmask one of arith.wgsl's.
let unmasked = ops32.replacen("(a.y << ((32u - s) & 31u))", "(a.y << (32u - s))", 1);
assert_ne!(&unmasked, ops32);
let e = check(&unmasked, true).err().expect("an unmasked shift in arith.wgsl was accepted");
assert!(e.starts_with("rule 5"), "{e}");
}