Skip to content

fix(conv): bounds-guard all grid_1d convolution kernels + run tests on macOS CI - #77

Merged
ekryski merged 8 commits into
devfrom
ek/fix-depthwise-conv-bounds
Jul 29, 2026
Merged

fix(conv): bounds-guard all grid_1d convolution kernels + run tests on macOS CI#77
ekryski merged 8 commits into
devfrom
ek/fix-depthwise-conv-bounds

Conversation

@ekryski

@ekryski ekryski commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What

Two related changes, discovered together:

1. Bounds-guard every grid_1d convolution kernel (535403a1)

The forward convolution kernels index a flat output element by program_id::<0>() and store to out[idx] with no range check. Dispatch goes through grid_1d(n_out, 256), which div_ceils the launch up to a whole threadgroup — so whenever n_out isn't a multiple of 256, the tail threads run with idx past the last output, decode a channel/batch index beyond range, and read input/weight + write out out of bounds. On Apple GPUs that touches whatever memory neighbours the buffers → nondeterministic inf/NaN.

Surfaced as a flaky test_depthwise_conv1d_strided [f16] (max|Δ|=inf; channels·out_len = 8·15 = 120, 136 tail threads). The other kernels' existing test shapes happened to be exact multiples of 256, masking it.

Fix (matches winograd_conv's existing if idx < total idiom): clamp tail threads onto element 0 (always in-bounds) for the reads and skip their store. Applied to all 11 kernel fns: depthwise_conv1d, conv2d{,_generic,_grouped}, conv3d{_generic,_grouped}, depthwise_conv2d{,_nhwc}, and the three *_block_scaled variants. conv2d_grouped and conv3d_{generic,grouped} lacked a batch constexpr to compute the bound, so it was added (they're codegen-only, no production caller; all test/bench call sites updated). Added test_depthwise_conv1d_odd_tail (3·17 = 51) as a second non-threadgroup-multiple regression shape.

2. Run the test suite on macOS CI (e9b7293c)

This bug reached dev because no PR gate ever ran the GPU tests. The clippy-test job ran cargo test --workspace on ubuntu-latest, but the GPU correctness sweep (kernel_tests_harness.rs, all_registered_kernel_tests_pass) is #![cfg(target_os = "macos")] — on Linux it compiles to nothing, so the job passed without dispatching a single kernel.

Split check.yml's job:

  • clippy (ubuntu-latest): cargo clippy --all-targets --all-features--all-features pulls in cuda/hip/vulkan, which only compile-check on Linux, so this must stay off macOS.
  • test (macos-26): cargo nextest run --workspace with default (Metal) features, so the GPU harness + every_registered_benchspec_codegens actually run on real hardware, every PR.

Runner selection (runs-on: macos-26) and audit-mode harden-runner match the existing coverage.yml / iron.yml jobs.

Verification

  • cargo test -p wh-iron-std --test kernel_tests_harness — GPU harness green incl. the new tail shapes (rebased on current dev).
  • Full workspace suite previously 938 passed / 0 failed with the fix.
  • cargo fmt --check clean; actionlint clean on the workflow change.

ekryski added 2 commits July 29, 2026 11:36
The forward convolution kernels index a flat output element by
`program_id::<0>()` and stored to `out[idx]` with no range check. Test
and production dispatch goes through `grid_1d(n_out, 256)`, which
`div_ceil`s the launch up to a whole threadgroup — so whenever `n_out`
is not a multiple of 256 the tail threads run with `idx` past the last
output, decode a channel/batch index beyond range, and read `input`/
`weight` and write `out` out of bounds. On Apple GPUs that reads/writes
whatever memory neighbours the buffers: nondeterministic inf/NaN.

Surfaced as a flaky `test_depthwise_conv1d_strided [f16]` (max|Δ|=inf,
channels·out_len = 8·15 = 120, 136 tail threads); the other kernels'
existing test shapes happened to be exact multiples of 256 and masked
it. CI never caught the class — the clippy/test job runs on
ubuntu-latest with no Metal GPU, so the correctness harness is skipped
there.

Guard, matching `winograd_conv`'s existing `if idx < total`: clamp tail
threads onto element 0 (always in-bounds) for the reads and skip their
store. Applied to all 11 kernel fns across the convolution family:
depthwise_conv1d, conv2d{,_generic,_grouped}, conv3d{_generic,_grouped},
depthwise_conv2d{,_nhwc}, and the three *_block_scaled variants.

conv2d_grouped and conv3d_{generic,grouped} lacked a `batch` constexpr
needed to compute the bound, so it was added (they are codegen-only with
no production caller; all test/bench call sites updated to pass it).

Adds `test_depthwise_conv1d_odd_tail` (3·17 = 51) as a second, small
non-threadgroup-multiple shape so the guard path stays covered.

Workspace suite: 938 passed / 0 failed (incl. Metal GPU correctness);
clippy + fmt clean.
The `clippy-test` job ran `cargo test --workspace` (via nextest) on
`ubuntu-latest`. But the GPU correctness sweep —
`crates/wh-iron-std/tests/kernel_tests_harness.rs`, the
`all_registered_kernel_tests_pass` test that dispatches every registered
`#[test_kernel]` on Metal and checks it against a CPU oracle — is gated
`#![cfg(target_os = "macos")]`. On a Linux runner it compiles to nothing,
so the workspace test job passed on every PR WITHOUT running a single GPU
kernel. That is how the conv-kernel out-of-bounds bug (this PR's other
commit) reached dev: no PR gate ever executed the harness.

Split the job:
  - `clippy` (ubuntu-latest): `cargo clippy --all-targets --all-features`.
    `--all-features` pulls in cuda/hip/vulkan, which only compile-check on
    Linux, so this must stay off macOS.
  - `test` (macos-26): `cargo nextest run --workspace` with default
    (Metal) features, so the GPU harness and the codegen-every-benchspec
    test actually run on real hardware, on every PR.

macOS runner selection (`runs-on: macos-26`) and the audit-mode
harden-runner match the existing coverage.yml / iron.yml jobs.
@github-actions github-actions Bot added the bug Something isn't working label Jul 29, 2026

@TheTom TheTom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Verified the load-bearing details rather than trusting the prose:

Guard correctness

  • select(cond, a, b) argument order: cond-first matches the existing winograd_conv usage (select(row_ok_0, pr_0 - pad_h, 0u32)), so the clamp is right. The new odd-tail tests would hard-fail all dtypes if the order were flipped, and the harness is green, which independently confirms it.
  • Per-kernel bounds check out: depthwise_conv1d correctly omits batch (no batch dim in its flat index), the NHWC variant's bound matches its channel-fastest layout, and the block_scaled variants' scale reads are also safe at the clamped index 0.
  • Clamp-reads-plus-guarded-store is the right idiom here vs early return; tail threads do dead arithmetic but that is negligible against the conv inner loop.

Signature changes (batch constexpr on conv2d_grouped / conv3d_generic / conv3d_grouped)

  • Checked butter: no production callers, only OpsCoverageNotes mentions. The emitted wrapper signatures will change on butter's next make regenerate-kernels; mechanical, but worth a line in whichever butter PR does the regenerate. That regenerate also needs #78's BK32 kernel in the tree (see the cross-repo note on butter #76), so one combined regenerate after both land is the clean path.

CI split

  • This is the important half of the PR. Real-world confirmation from the other direction: #78's current "Clippy + Tests" green never dispatched a kernel, exactly the bypass described here. Suggest landing #77 first, then I'll rebase #78 so its GPU harness actually runs on the new macOS job before merge.
  • runs-on: macos-26 precedent confirmed (coverage.yml, iron.yml).
  • One watch item: the old comment noted the codegen test alone took ~14 min and clippy+tests no longer fit in 15. macOS runner + build + nextest + GPU harness under a 30 min timeout could be tight as the kernel registry grows (each new #[test_kernel] adds to the sweep). If the first few runs land near the limit, bump to 45 rather than letting it flake.

Also lines up with the F-85 lesson from the Laguna campaign: the dual-slab BK64 body was unit-green but empty-gen in the full model. Getting the harness onto every PR closes the cheapest half of that gap.

ekryski added 2 commits July 29, 2026 13:12
…e MPP kernel

Moving `cargo nextest run --workspace` to the macOS CI runner (previous
commit) surfaced a real failure the ubuntu job hid: the `macos-26` GitHub
runner's Metal compiler rejects `iron_gdn_wy_plan` at PSO creation —

    unsupported deferred-static-alloca-size function body in
    metal::cooperative_tensor<...> mpp::tensor_ops::__mutmul2d ...

The kernel lowers a *dynamic-extent* `mpp::tensor_ops::matmul2d`
cooperative tensor (`extents<i, SIZE_MAX, SIZE_MAX>`), which that
toolchain image can't compile. It is a valid kernel — it builds and runs
on every dev machine, verified locally on an Apple7 M1 Max (family < 10)
through the M5 Max — so this is a toolchain gap, not a kernel or
numerical bug.

Gate on the ACTUAL blocker, not GPU family: `run_pipeline` now returns
`Option`, mapping the plan kernel's `IronError::PipelineCreation`
(matched on the `cooperative_tensor` / `deferred-static-alloca` reason)
to a skip; every other dispatch error still panics. Each test skips via
`let Some(..) = run_pipeline(..) else { return }`.

Deliberately NOT `skip_unless_apple10` (as the sibling moe_mpp tests use):
those kernels need Apple10 hardware features, but `iron_gdn_wy_plan` runs
on Apple7 — a family gate would wrongly disable this test on M1/M2/M3 dev
hardware where it passes. The toolchain probe skips only where the kernel
genuinely cannot be built, so it still runs for real everywhere it can.
…t build

The macOS test job surfaced more of the same class as the gdn-wy fix: the
GitHub `macos-26` runner's Metal toolchain rejects the whole MPP / bgemm
cooperative-tensor kernel family at runtime PSO creation ("unsupported
deferred-static-alloca-size ... cooperative_tensor"). `iron build`'s
offline `xcrun metal` compiles them fine; only the runtime
`makeLibrary(source:)` JIT path fails. The kernels are valid and run on
every dev machine (verified from an Apple7 M1 Max through the M5 Max).

Shared, precise gate — `common::is_unsupported_coop_tensor` (matches the
unique `deferred-static-alloca` marker, so it never masks a real
numerical failure):

- `kernel_tests_harness.rs` (`all_registered_kernel_tests_pass`): skip and
  count such kernels instead of failing the whole harness, so the non-MPP
  correctness sweep — conv (this PR's fix), norm, sdpa, sampling, … — still
  gates every PR on real GPU. Reports the skip count.
- `moe_bm64_ragged_correctness.rs`: `run_bm64` returns `Option`, skipping
  when its bm64 MPP kernel can't build.

Also `--no-fail-fast` on the macOS nextest run so a single ~15-min job
reports every failure rather than cancelling on the first — cheaper than
iterating one straggler per round.

Verified locally on Apple7 (M1 Max): both still RUN and pass (the skip
path is inert wherever the toolchain can build the kernel).
@TheTom

TheTom commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Reviewed the two follow-up commits (59357b1, 1800479), approval stands. The gate is precise: matches only the unique deferred-static-alloca marker, every other dispatch error still panics/fails, and the registered-kernel-count assertion still catches link regressions. Skipping on the actual toolchain error instead of a family gate is the right call given gdn_wy runs fine on Apple7.

One consequence worth tracking rather than forgetting: on the macos-26 image the entire MPP cooperative-tensor family is now skipped, so a green Tests (macOS) job no longer implies any MPP kernel ran. The skip count in the log is the only tell. Suggest a tracking issue for re-enabling when the runner's Metal toolchain catches up (or a self-hosted M-series canary), so the skip does not quietly become permanent.

ekryski added 4 commits July 29, 2026 14:27
The `macos-26` image ships Xcode without the Metal Toolchain component,
so anything invoking the `metal` compiler fails:

    error: cannot execute tool 'metal' due to missing Metal Toolchain;
    use: xcodebuild -downloadComponent MetalToolchain

This broke the Iron workflow's `Build` job (`iron build`'s MSL codegen
shells out to `xcrun metal`) and is the likely root cause of the runtime
`makeLibrary(source:)` failures on the MPP / bgemm cooperative-tensor
kernels in the new macОS test job — the JIT path needs the same toolchain.

Add `xcodebuild -downloadComponent MetalToolchain` before every Metal step:
check.yml `Tests (macOS)`, and iron.yml `Build` + both `Bench` shards.
With the toolchain present the GPU tests should compile and RUN (not skip),
so a breaking kernel change surfaces on the PR instead of hiding.
…ileplan, view_u16)

Complete the toolchain-probe fallback across the last two ungated MPP
integration files that the --no-fail-fast run surfaced:
- moe_gather_qmm_tileplan_correctness.rs (run_tileplan -> Option)
- moe_view_u16_correctness.rs (dispatch closure -> Option)

Both use the shared `common::is_unsupported_coop_tensor` gate. This is a
safety net: with the Metal Toolchain now installed (previous commit) these
should compile and run for real; if a given runner still can't build the
cooperative-tensor body, they skip rather than red the suite. Verified
locally on Apple7 (M1 Max): both RUN and pass — the skip path is inert
wherever the kernel builds.
…l is the real fix

Drops the toolchain-probe skip fallbacks across kernel_tests_harness,
gated_delta_wy_pipeline_integration, moe_bm64_ragged_correctness,
moe_gather_qmm_tileplan_correctness, moe_view_u16_correctness, and the
shared common::is_unsupported_coop_tensor helper.

Those skips were added while the macos-26 failures looked like a permanent
toolchain limitation. The real cause was the missing Metal Toolchain
component; installing it (`xcodebuild -downloadComponent MetalToolchain`,
prior commit) lets every MPP/bgemm cooperative-tensor kernel compile and
run at runtime — the last green CI run executed all 937 test binaries with
ZERO skips. With the toolchain present the skips are dead code and would
only ever mask that one compiler error, so remove them: the GPU suite is
now strict again, and any real kernel compile/correctness regression reds
the PR immediately.
…-26 runners

Restores the skip fallbacks removed in the prior commit. Removing them was
premature: it was based on ONE green run that happened to land on a
`macos-26` runner whose Metal runtime could JIT-compile the MPP / bgemm
cooperative-tensor kernels. The next run (skips gone) hit a runner that
can't — 596 `deferred-static-alloca` errors across exactly the 5 MPP test
files and NOTHING else (zero real regressions).

The `Build` job (offline `xcrun metal`) passes on both, so the downloaded
Metal Toolchain fixes AOT compilation — but the runtime `makeLibrary(source:)`
path depends on the runner's Metal framework, which is not uniform across
GitHub's macos-26 pool. So:
  - without the skips: CI is FLAKY (green or red by luck of the runner);
  - with the skips: CI is STABLE — MPP kernels run wherever the runner can
    build them and skip (counted, logged) where it can't, while the whole
    non-MPP sweep (conv, norm, sdpa, sampling, …) always gates the PR.

The skips match only the unique `deferred-static-alloca` marker, so a
genuine numeric or non-toolchain compile regression still reds the PR.
@TheTom

TheTom commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Deep-dive on the cooperative-tensor CI failures. TL;DR: it is not your hardware and not the kernels. Two separate layers, and only one of them is fixable by us.

Root cause

1. The compile failure you are seeing is an OS-version gap, not a GPU issue. The macos-26 runner image is on macOS 26.4 (25E246), verified in the failed job's setup log. The unsupported deferred-static-alloca-size error comes from the OS-resident Metal backend compiler (MTLCompilerService) at PSO creation: 26.4's backend cannot lower dynamic-extent cooperative tensors, and 26.5's can. Your M1 Max works because it is on a newer OS, on bare metal. The M1-vs-M5 difference is irrelevant: compilation of this construct is not gated on GPU family at all.

Demangling the failing symbol makes the trigger visible: cooperative_tensor<half, extents<int, SIZE_MAX, SIZE_MAX>, mpp::tensor_ops::__matmul2d operand_layout<desc{16,32,32}, ...>>.MTL_SIZEAS. The tile sizes in the matmul2d descriptor are static; the SIZE_MAX extents (dynamic_extent) come from inside Apple's mpp operand-layout machinery, and .MTL_SIZEAS is the deferred size-assist symbol old backends reject.

Known prior art with the exact same error: metaltile PR #257 hit it on macos-26 runners (26.4 fails, 26.5 passes, on M1 Max no less) and established that pinning DEVELOPER_DIR/Xcode does not help because newLibraryWithSource + PSO creation use the OS toolchain, not Xcode's. metaltile PR #258 landed the same skip-on-compile-failure pattern you just added. Butter itself hit this once too (PR #25) and gated on GPU family.

2. Separately: even after the image updates, hosted runners will never really execute MPP. The paravirtualized GPU reports a low family, and per the comment in our own mpp_matmul_probe test, mpp::tensor_ops::matmul2d needs Apple10-class hardware to run for real (below that it falls through to the stub branch). So on hosted runners the realistic ceiling is: PSOs compile (once the image is on >= 26.5), family gate skips execution. Your skip fallback is the right permanent posture for hosted CI, not a temporary hack.

For calibration: 922/937 tests passed on the paravirt GPU with the harness doing 450s of real work, so the runner is genuinely useful for everything outside the MPP family.

Recommendations

  1. Keep the skip gate as-is. It is correct and matches what the sibling repos converged on.
  2. Log sw_vers in the test job (one line). When the image rolls to 26.5+ the compile-side skips should disappear on their own; the log line makes that visible instead of mysterious.
  3. Real MPP coverage needs bare metal. The only way to run these kernels in CI is a self-hosted M-series runner (a Mac mini works; nightly or a metal-mpp label-gated job rather than per-PR keeps it cheap). Until then the M-series dev machines remain the coverage for the MPP family, which is exactly what the skip message says.
  4. Optional, low priority: a static-extents emission experiment in the iron codegen would likely dodge the deferred-alloca path on old backends, but metaltile's investigation notes MPP instantiates dynamic-extent temporaries internally, so it may not be fully within our control. Not worth it given 26.5 fixes compilation.

Sources: runner image OS from the job setup log of the failed run; metaltile PRs #257/#258; butter PR #25; runner-images issues #7085 (paravirtual GPU limits) and #14172/#14344 (image toolchain cadence); Apple MPP Programming Guide.

@TheTom

TheTom commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Addendum from the second research pass (hosted-runner GPU capabilities and how peer projects handle this). One correction to my comment above and some concrete options.

Correction/nuance: I said PSO compilation should self-heal once the image rolls to macOS 26.5. That is only proven for bare metal (metaltile's data point is a local M1 Max on 26.5). In the VM, the guest talks to AppleParavirtDevice, a separate conservatively-versioned Metal stack: it reports roughly Apple5-class family, is missing newer API surface outright (Godot hits unrecognized-selector crashes on argument buffers, godotengine/godot#101773), and MLX hard-codes device_name == "Apple Paravirtual device" to skip MTLHeap/residency-set creation because those are broken in VMs. There is no public report of cooperative tensors working in any Virtualization.framework guest. So the safe assumption is: hosted runners are permanently no-MPP, compile or execute, and the skip gate is load-bearing forever there, not just until an image update.

How the big Metal projects run CI (verified from their live workflow files):

Project Pattern
MLX Build + CPU tests on hosted macos-26, upload wheel + metallib artifact; Metal tests run in a separate job on self-hosted Apple machines that downloads the artifact
llama.cpp Hosted arm64 builds Metal ON and passes ctest via fallback kernels; real GPU coverage on self-hosted [macOS, ARM64] runners
PyTorch MPS Never touches hosted runners for MPS; bare-metal Mac mini fleet
tinygrad Basic Metal compute on hosted (paravirt handles it); benchmarks and feature-sensitive work self-hosted

The common shape is exactly where this PR landed: hosted VM for build + everything-but-the-special-paths, bare metal for the rest. MLX's artifact handoff (build hosted, test self-hosted) is the cleanest template if we add a box.

Options for real MPP coverage, ranked: (1) self-hosted runner on an owned Mac mini, restricted to same-repo branches, nightly or label-gated (M4-class covers coop-tensor correctness; M5-class also covers the neural-accelerator paths); (2) MacStadium bare-metal tier (their Orka product is Virtualization.framework VMs, same paravirt GPU, avoid for this); (3) EC2 Mac is bare metal but has a 24-hour minimum host allocation, poor fit for CI; (4) Tart-based providers (Cirrus, Namespace, WarpBuild) are all VZ VMs with the same AppleParavirtDevice, they do not solve this. GitHub itself: no ETA, "on our radar" (community discussion #160669).

One cheap hardening: alongside the error-string gate, MLX's exact predicate device.name == "Apple Paravirtual device" is a clean belt-and-suspenders check if we ever want to skip proactively instead of reactively.

@ekryski

ekryski commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

How the big Metal projects run CI (verified from their live workflow files):

Project Pattern
MLX Build + CPU tests on hosted macos-26, upload wheel + metallib artifact; Metal tests run in a separate job on self-hosted Apple machines that downloads the artifact
llama.cpp Hosted arm64 builds Metal ON and passes ctest via fallback kernels; real GPU coverage on self-hosted [macOS, ARM64] runners
PyTorch MPS Never touches hosted runners for MPS; bare-metal Mac mini fleet
tinygrad Basic Metal compute on hosted (paravirt handles it); benchmarks and feature-sensitive work self-hosted
The common shape is exactly where this PR landed: hosted VM for build + everything-but-the-special-paths, bare metal for the rest. MLX's artifact handoff (build hosted, test self-hosted) is the cleanest template if we add a box.

Options for real MPP coverage, ranked: (1) self-hosted runner on an owned Mac mini, restricted to same-repo branches, nightly or label-gated (M4-class covers coop-tensor correctness; M5-class also covers the neural-accelerator paths); (2) MacStadium bare-metal tier (their Orka product is Virtualization.framework VMs, same paravirt GPU, avoid for this); (3) EC2 Mac is bare metal but has a 24-hour minimum host allocation, poor fit for CI; (4) Tart-based providers (Cirrus, Namespace, WarpBuild) are all VZ VMs with the same AppleParavirtDevice, they do not solve this. GitHub itself: no ETA, "on our radar" (community discussion #160669).

One cheap hardening: alongside the error-string gate, MLX's exact predicate device.name == "Apple Paravirtual device" is a clean belt-and-suspenders check if we ever want to skip proactively instead of reactively.

Agreed. I think this is the right approach and yes we'll need hosted bare metal runners eventually. I'm updating CI stuff in a follow up PR #80. Will potentially include similar MLX device.name guard approach in that.

I think this PR is good to go. Thanks for looking into that @TheTom.

@ekryski
ekryski merged commit 67024f8 into dev Jul 29, 2026
28 checks passed
@ekryski
ekryski deleted the ek/fix-depthwise-conv-bounds branch July 29, 2026 22:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants