Skip to content
Merged
52 changes: 44 additions & 8 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,9 +85,45 @@ 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
- run: cargo nextest run --workspace

# 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
# The macos-26 image ships Xcode without the Metal Toolchain — both
# the offline `metal` compiler and the runtime `makeLibrary(source:)`
# JIT path need it, or MPP/bgemm cooperative-tensor kernels fail at
# PSO creation. Install it before any GPU test runs.
- name: Install Metal Toolchain
run: xcodebuild -downloadComponent MetalToolchain
- 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
# --no-fail-fast: run the whole GPU suite even after a failure so one
# run surfaces every failing test, not just the first (these jobs are
# ~15 min, so a second diagnostic round is expensive).
- run: cargo nextest run --workspace --no-fail-fast

commit-hygiene:
name: Commits
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/iron.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ jobs:
- name: Add iron to PATH
run: echo "${GITHUB_WORKSPACE}/target/ci" >> "$GITHUB_PATH"

# The macos-26 image ships Xcode without the Metal Toolchain, so the
# offline `metal` compiler that `iron build`'s MSL codegen shells out
# to is missing ("cannot execute tool 'metal'"). Install it first.
- name: Install Metal Toolchain
run: xcodebuild -downloadComponent MetalToolchain

- name: Build
run: iron build

Expand Down Expand Up @@ -240,6 +246,9 @@ jobs:
- name: Add iron to PATH
run: echo "${GITHUB_WORKSPACE}/target/ci" >> "$GITHUB_PATH"

- name: Install Metal Toolchain
run: xcodebuild -downloadComponent MetalToolchain

- name: Bench (heavy)
run: iron bench -vv --allow-dirty --match-group '^(gemm|moe|ssm|quant|kv_cache|hyper_connections|sdpa)$' --json /tmp/bench-heavy.json

Expand Down Expand Up @@ -285,6 +294,9 @@ jobs:
- name: Add iron to PATH
run: echo "${GITHUB_WORKSPACE}/target/ci" >> "$GITHUB_PATH"

- name: Install Metal Toolchain
run: xcodebuild -downloadComponent MetalToolchain

- name: Bench (light)
run: iron bench -vv --allow-dirty --match-group '^(norm|ops|sampling|convolution|rope|vision|audio)$' --json /tmp/bench-light.json

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
Loading
Loading