Skip to content
Merged
41 changes: 34 additions & 7 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ jobs:
components: rustfmt
- run: cargo fmt --check --all

clippy-test:
name: Clippy + Tests
# Clippy lints the whole matrix — `--all-features` pulls in the
# cuda / hip / vulkan backends, which only compile-check on Linux (the
# macOS test job below can't build them, there is no CUDA/HIP SDK there).
# Lints only; runs no tests.
clippy:
name: Clippy
runs-on: ubuntu-latest
# `cargo test --workspace` runs the codegen-every-registered-kernel test
# (`every_registered_benchspec_codegens`), which alone takes ~14 min and
# grew with the MLX A/B bench additions; with clippy --all-features + the
# build on top, 15 min no longer fits.
timeout-minutes: 30
steps:
- uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
Expand All @@ -85,8 +85,35 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@nextest
- run: cargo clippy --all-targets --all-features -- -D warnings

# Tests run on macOS so the Metal GPU correctness suite ACTUALLY executes.
# `crates/wh-iron-std/tests/kernel_tests_harness.rs` (the
# `all_registered_kernel_tests_pass` GPU-vs-CPU-oracle sweep over every
# registered `#[test_kernel]`) is `#![cfg(target_os = "macos")]` — on a
# Linux runner it compiles to nothing, so `cargo test` there passes
# WITHOUT ever touching the GPU. Running it here on `macos-26` closes that
# silent bypass and also exercises `every_registered_benchspec_codegens`
# on the real Metal codegen path. Default features = the Metal backend.
test:
name: Tests (macOS)
runs-on: macos-26
timeout-minutes: 30
steps:
# harden-runner blocking mode is Linux-only; use audit on macOS,
# matching coverage.yml.
- uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
# Separate key from the coverage job's instrumented artifacts.
key: macos-test
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@nextest
- run: cargo nextest run --workspace

commit-hygiene:
Expand Down
49 changes: 43 additions & 6 deletions crates/wh-iron-std/src/kernels/convolution/conv2d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,16 @@ pub fn conv2d<T>(
#[constexpr] pad_w: u32,
) {
// Flat output index → (n, oc, oh, ow). One thread per output.
let idx = program_id::<0>();
let raw = program_id::<0>();
// Over-dispatch guard: `grid_1d` rounds the launch up to a whole
// threadgroup, so when the flat output count is not a multiple of the
// threadgroup size the tail threads carry `raw` past the last output.
// Clamp them onto element 0 (always in-bounds) for the reads and skip
// their store — otherwise they index `out`/`input` out of bounds
// (nondeterministic inf/NaN in neighbouring GPU memory). Same guard as
// `winograd_conv`, generalised to a multi-axis flat index.
let in_range = raw < batch * out_ch * out_h * out_w;
let idx = select(in_range, raw, 0u32);
let ow = idx % out_w;
let t1 = idx / out_w;
let oh = t1 % out_h;
Expand Down Expand Up @@ -122,7 +131,9 @@ pub fn conv2d<T>(
}
}

store(out[idx], acc.cast::<T>());
if in_range {
store(out[idx], acc.cast::<T>());
}
}

// ── Generic variant ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -150,7 +161,16 @@ pub fn conv2d_generic<T>(
#[constexpr] pad_h: u32,
#[constexpr] pad_w: u32,
) {
let idx = program_id::<0>();
let raw = program_id::<0>();
// Over-dispatch guard: `grid_1d` rounds the launch up to a whole
// threadgroup, so when the flat output count is not a multiple of the
// threadgroup size the tail threads carry `raw` past the last output.
// Clamp them onto element 0 (always in-bounds) for the reads and skip
// their store — otherwise they index `out`/`input` out of bounds
// (nondeterministic inf/NaN in neighbouring GPU memory). Same guard as
// `winograd_conv`, generalised to a multi-axis flat index.
let in_range = raw < batch * out_ch * out_h * out_w;
let idx = select(in_range, raw, 0u32);
let ow = idx % out_w;
let t1 = idx / out_w;
let oh = t1 % out_h;
Expand Down Expand Up @@ -192,7 +212,9 @@ pub fn conv2d_generic<T>(
}
}

store(out[idx], acc.cast::<T>());
if in_range {
store(out[idx], acc.cast::<T>());
}
}

/// Fully general 2D convolution — strides, dilation, padding, and
Expand Down Expand Up @@ -247,6 +269,7 @@ pub fn conv2d_grouped<T>(
weight: Tensor<T>,
bias: Tensor<T>,
out: Tensor<T>,
#[constexpr] batch: u32,
#[constexpr] in_ch: u32,
#[constexpr] in_h: u32,
#[constexpr] in_w: u32,
Expand All @@ -268,7 +291,17 @@ pub fn conv2d_grouped<T>(
#[constexpr] ocpg: u32,
) {
// Flat output index → (n, oc, oh, ow). One thread per output.
let idx = program_id::<0>();
let raw = program_id::<0>();
// Over-dispatch guard: `grid_1d` rounds the launch up to a whole
// threadgroup, so when the flat output count is not a multiple of the
// threadgroup size the tail threads carry `raw` past the last output.
// Clamp them onto element 0 (always in-bounds) for the reads and skip
// their store — otherwise they index `out`/`input` out of bounds
// (nondeterministic inf/NaN in neighbouring GPU memory). Same guard as
// `winograd_conv`, generalised to a multi-axis flat index. `batch` is a
// constexpr purely so this bound is computable in-kernel.
let in_range = raw < batch * out_ch * out_h * out_w;
let idx = select(in_range, raw, 0u32);
let ow = idx % out_w;
let t1 = idx / out_w;
let oh = t1 % out_h;
Expand Down Expand Up @@ -315,7 +348,9 @@ pub fn conv2d_grouped<T>(
}
}
}
store(out[idx], acc.cast::<T>());
if in_range {
store(out[idx], acc.cast::<T>());
}
}

pub mod kernel_tests {
Expand Down Expand Up @@ -511,6 +546,7 @@ pub mod kernel_tests {
.input(TestBuffer::from_vec("weight", pack_f32(&weight_f, dt), dt))
.input(TestBuffer::from_vec("bias", pack_f32(&bias_f, dt), dt))
.input(TestBuffer::zeros("out", n_out, dt))
.constexpr("batch", batch as u32)
.constexpr("in_ch", in_ch as u32)
.constexpr("in_h", in_h as u32)
.constexpr("in_w", in_w as u32)
Expand Down Expand Up @@ -622,6 +658,7 @@ pub mod kernel_benches {
.buffer(BenchBuffer::random("weight", ch * kh * kw, dt))
.buffer(BenchBuffer::random("bias", ch, dt))
.buffer(BenchBuffer::zeros("out", n_out, dt).output())
.constexpr("batch", batch as u32)
.constexpr("in_ch", ch as u32)
.constexpr("in_h", in_h as u32)
.constexpr("in_w", in_w as u32)
Expand Down
15 changes: 13 additions & 2 deletions crates/wh-iron-std/src/kernels/convolution/conv2d_block_scaled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,16 @@ pub fn iron<T>(
#[constexpr] block_size: u32,
#[constexpr(only_when = "SKIND == 1u32")] global: f32,
) {
let idx = program_id::<0>();
let raw = program_id::<0>();
// Over-dispatch guard: `grid_1d` rounds the launch up to a whole
// threadgroup, so when the flat output count is not a multiple of the
// threadgroup size the tail threads carry `raw` past the last output.
// Clamp them onto element 0 (always in-bounds) for the reads and skip
// their store — otherwise they index `out`/`input` out of bounds
// (nondeterministic inf/NaN in neighbouring GPU memory). Same guard as
// `winograd_conv`, generalised to a multi-axis flat index.
let in_range = raw < batch * out_ch * out_h * out_w;
let idx = select(in_range, raw, 0u32);
let ow = idx % out_w;
let t1 = idx / out_w;
let oh = t1 % out_h;
Expand Down Expand Up @@ -168,7 +177,9 @@ pub fn iron<T>(
}
}
}
store(out[idx], acc.cast::<T>());
if in_range {
store(out[idx], acc.cast::<T>());
}
}

pub mod kernel_tests {
Expand Down
38 changes: 34 additions & 4 deletions crates/wh-iron-std/src/kernels/convolution/conv3d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ pub fn conv3d_generic<T>(
weight: Tensor<T>,
bias: Tensor<T>,
out: Tensor<T>,
#[constexpr] batch: u32,
#[constexpr] in_ch: u32,
#[constexpr] in_d: u32,
#[constexpr] in_h: u32,
Expand All @@ -97,7 +98,17 @@ pub fn conv3d_generic<T>(
#[constexpr] pad_w: u32,
) {
// Flat output index → (n, oc, od, oh, ow). One thread per output.
let idx = program_id::<0>();
let raw = program_id::<0>();
// Over-dispatch guard: `grid_1d` rounds the launch up to a whole
// threadgroup, so when the flat output count is not a multiple of the
// threadgroup size the tail threads carry `raw` past the last output.
// Clamp them onto element 0 (always in-bounds) for the reads and skip
// their store — otherwise they index `out`/`input` out of bounds
// (nondeterministic inf/NaN in neighbouring GPU memory). Same guard as
// `winograd_conv`, generalised to a multi-axis flat index. `batch` is a
// constexpr purely so this bound is computable in-kernel.
let in_range = raw < batch * out_ch * out_d * out_h * out_w;
let idx = select(in_range, raw, 0u32);
let ow = idx % out_w;
let t1 = idx / out_w;
let oh = t1 % out_h;
Expand Down Expand Up @@ -149,7 +160,9 @@ pub fn conv3d_generic<T>(
}
}
}
store(out[idx], acc.cast::<T>());
if in_range {
store(out[idx], acc.cast::<T>());
}
}

/// Fully general 3D convolution — strides, dilation, padding, and
Expand Down Expand Up @@ -200,6 +213,7 @@ pub fn conv3d_grouped<T>(
weight: Tensor<T>,
bias: Tensor<T>,
out: Tensor<T>,
#[constexpr] batch: u32,
#[constexpr] in_ch: u32,
#[constexpr] in_d: u32,
#[constexpr] in_h: u32,
Expand Down Expand Up @@ -227,7 +241,17 @@ pub fn conv3d_grouped<T>(
#[constexpr] ocpg: u32,
) {
// Flat output index → (n, oc, od, oh, ow). One thread per output.
let idx = program_id::<0>();
let raw = program_id::<0>();
// Over-dispatch guard: `grid_1d` rounds the launch up to a whole
// threadgroup, so when the flat output count is not a multiple of the
// threadgroup size the tail threads carry `raw` past the last output.
// Clamp them onto element 0 (always in-bounds) for the reads and skip
// their store — otherwise they index `out`/`input` out of bounds
// (nondeterministic inf/NaN in neighbouring GPU memory). Same guard as
// `winograd_conv`, generalised to a multi-axis flat index. `batch` is a
// constexpr purely so this bound is computable in-kernel.
let in_range = raw < batch * out_ch * out_d * out_h * out_w;
let idx = select(in_range, raw, 0u32);
let ow = idx % out_w;
let t1 = idx / out_w;
let oh = t1 % out_h;
Expand Down Expand Up @@ -283,7 +307,9 @@ pub fn conv3d_grouped<T>(
}
}
}
store(out[idx], acc.cast::<T>());
if in_range {
store(out[idx], acc.cast::<T>());
}
}

pub mod kernel_tests {
Expand Down Expand Up @@ -418,6 +444,7 @@ pub mod kernel_tests {
.input(TestBuffer::from_vec("weight", pack_f32(&weight_f, dt), dt))
.input(TestBuffer::from_vec("bias", pack_f32(&bias_f, dt), dt))
.input(TestBuffer::zeros("out", n_out, dt))
.constexpr("batch", batch as u32)
.constexpr("in_ch", in_ch as u32)
.constexpr("in_d", in_d as u32)
.constexpr("in_h", in_h as u32)
Expand Down Expand Up @@ -485,6 +512,7 @@ pub mod kernel_tests {
.input(TestBuffer::from_vec("weight", pack_f32(&weight_f, dt), dt))
.input(TestBuffer::from_vec("bias", pack_f32(&bias_f, dt), dt))
.input(TestBuffer::zeros("out", n_out, dt))
.constexpr("batch", batch as u32)
.constexpr("in_ch", in_ch as u32)
.constexpr("in_d", in_d as u32)
.constexpr("in_h", in_h as u32)
Expand Down Expand Up @@ -558,6 +586,7 @@ pub mod kernel_benches {
.buffer(BenchBuffer::random("weight", out_ch * in_ch * kd * kh * kw, dt))
.buffer(BenchBuffer::random("bias", out_ch, dt))
.buffer(BenchBuffer::zeros("out", n_out, dt).output())
.constexpr("batch", batch as u32)
.constexpr("in_ch", in_ch as u32)
.constexpr("in_d", in_d as u32)
.constexpr("in_h", in_h as u32)
Expand Down Expand Up @@ -596,6 +625,7 @@ pub mod kernel_benches {
.buffer(BenchBuffer::random("weight", ch * kd * kh * kw, dt))
.buffer(BenchBuffer::random("bias", ch, dt))
.buffer(BenchBuffer::zeros("out", n_out, dt).output())
.constexpr("batch", batch as u32)
.constexpr("in_ch", ch as u32)
.constexpr("in_d", in_d as u32)
.constexpr("in_h", in_h as u32)
Expand Down
15 changes: 13 additions & 2 deletions crates/wh-iron-std/src/kernels/convolution/conv3d_block_scaled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,16 @@ pub fn iron<T>(
#[constexpr] block_size: u32,
#[constexpr(only_when = "SKIND == 1u32")] global: f32,
) {
let idx = program_id::<0>();
let raw = program_id::<0>();
// Over-dispatch guard: `grid_1d` rounds the launch up to a whole
// threadgroup, so when the flat output count is not a multiple of the
// threadgroup size the tail threads carry `raw` past the last output.
// Clamp them onto element 0 (always in-bounds) for the reads and skip
// their store — otherwise they index `out`/`input` out of bounds
// (nondeterministic inf/NaN in neighbouring GPU memory). Same guard as
// `winograd_conv`, generalised to a multi-axis flat index.
let in_range = raw < batch * out_ch * out_d * out_h * out_w;
let idx = select(in_range, raw, 0u32);
let ow = idx % out_w;
let t1 = idx / out_w;
let oh = t1 % out_h;
Expand Down Expand Up @@ -185,7 +194,9 @@ pub fn iron<T>(
}
}
}
store(out[idx], acc.cast::<T>());
if in_range {
store(out[idx], acc.cast::<T>());
}
}

pub mod kernel_tests {
Expand Down
19 changes: 16 additions & 3 deletions crates/wh-iron-std/src/kernels/convolution/depthwise_conv1d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ pub fn iron_depthwise_conv1d<T>(
#[constexpr] pad: u32,
#[constexpr] dilation: u32,
) {
let idx = program_id::<0>();
let raw = program_id::<0>();
let in_range = raw < channels * out_len;
let idx = select(in_range, raw, 0u32);
let op = idx % out_len;
let c = idx / out_len;
let in_base = c * in_len;
Expand All @@ -65,7 +67,9 @@ pub fn iron_depthwise_conv1d<T>(
let w = load(weight[w_base + kx]).cast::<f32>();
acc = acc + x_m * w;
}
store(out[idx], acc.cast::<T>());
if in_range {
store(out[idx], acc.cast::<T>());
}
}

pub mod kernel_tests {
Expand Down Expand Up @@ -150,9 +154,18 @@ pub mod kernel_tests {
#[test_kernel(dtypes = [f32, f16, bf16], tol = [1e-4, 1e-2, 5e-2])]
fn test_depthwise_conv1d_conformer(dt: DType) -> TestSetup { setup(dt, 256, 200, 15, 1, 7, 1) }

// Strided + dilated variant (codec / downsample).
// Strided + dilated variant (codec / downsample). channels·out_len =
// 8·15 = 120, deliberately NOT a multiple of the 256 threadgroup — so
// `grid_1d` over-dispatches 136 tail threads, exercising the bounds
// guard. Regression cover for the pre-guard OOB flake (max|Δ|=inf).
#[test_kernel(dtypes = [f32, f16, bf16], tol = [1e-4, 1e-2, 5e-2])]
fn test_depthwise_conv1d_strided(dt: DType) -> TestSetup { setup(dt, 8, 32, 3, 2, 1, 2) }

// Small prime-ish tail: channels·out_len = 3·17 = 51, a second, very
// differently-sized over-dispatch case so the guard is covered
// independent of the strided shape's arithmetic.
#[test_kernel(dtypes = [f32, f16, bf16], tol = [1e-4, 1e-2, 5e-2])]
fn test_depthwise_conv1d_odd_tail(dt: DType) -> TestSetup { setup(dt, 3, 17, 3, 1, 1, 1) }
}

pub mod kernel_benches {
Expand Down
Loading
Loading