1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
name: CI
on:
push:
branches:
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
# A newer push to the same branch supersedes an in-flight run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------------------
# Formatting is the only check that does not need a Metal toolchain, because
# rustfmt parses rather than builds. It runs on Linux so a broken macOS image
# cannot hide a formatting regression.
# ---------------------------------------------------------------------------
fmt:
name: fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all --check
# ---------------------------------------------------------------------------
# Everything that compiles, on a runner that can compile shaders.
#
# `build.rs` AOT-compiles kernels/*.metal by shelling out to `xcrun metal`, so
# this is not only the test job's problem: `cargo check` and `cargo clippy`
# both run build scripts, and neither can succeed without the Metal compiler.
# That compiler is NOT part of Xcode. Locally it resolves to
# `~/Library/Developer/DVTDownloads/MetalToolchain/.../metal`, which is a
# separately downloaded cryptex component. On a fresh runner it is absent, and
# without the download step below every cargo invocation here dies inside
# build.rs with a spawn failure that names no cause.
# ---------------------------------------------------------------------------
check:
name: build + clippy + docs
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Report the toolchain this runner actually has
# Recorded rather than assumed: the image's default Xcode floats, and
# GitHub has already announced one move for macOS 26. If a future image
# ships an Xcode whose Metal compiler rejects these kernels, the version
# that did it is in the log.
run: |
echo "xcode-select: $(xcode-select -p)"
xcodebuild -version
sw_vers
- name: Install the Metal Toolchain
# Idempotent: already-present is the common case once the cache warms,
# and downloading takes minutes.
run: |
if xcrun --find metal >/dev/null 2>&1; then
echo "Metal compiler already present: $(xcrun --find metal)"
else
echo "Metal compiler absent; downloading the component"
xcodebuild -downloadComponent MetalToolchain
fi
- name: Prove the Metal compiler runs
# `-downloadComponent` can report success while leaving nothing runnable.
# A component that failed to install and one that installed correctly
# must not produce the same CI result, so this asserts the compiler
# actually executes before anything depends on it.
run: |
xcrun --find metal
xcrun metal --version
- name: Build
run: cargo build --release --all-targets
- name: Clippy
run: cargo clippy --release --all-targets -- -D warnings
- name: Rustdoc
# `[package.metadata.docs.rs]` renders this crate for
# aarch64-apple-darwin, so the configuration readers see is the one
# checked here.
run: cargo doc --no-deps
env:
RUSTDOCFLAGS: -D warnings
- name: Static tile audit
# The one correctness gate on this job that needs no GPU: it parses the
# `constexpr int SM/SN` values out of matmul_tensorops.metal and
# cross-references every Rust TileGeom against them. A mismatch makes the
# host dispatch the wrong threadgroup grid and silently leave output
# tiles unwritten — wrong numbers, no error. Source analysis catches that
# on any machine.
run: python3 scripts/audit_gemm_tiles.py
# ---------------------------------------------------------------------------
# The GPU suite. Answered by running it, on 2026-08-31.
#
# This job was written to gate on `macos-26` because the question could not be
# settled from documentation: macOS 26 supplies the Metal 4 API and these
# runners do expose a Metal device — sparsl's probe disproved the opposite
# assumption — but MPP TensorOps `matmul2d` needs the neural accelerators on
# M5-class silicon, and the runner image manifest documents no GPU at all.
#
# It ran, and the device probe failed. Everything upstream of it succeeded:
# the Metal Toolchain installed, `xcrun metal --version` executed, and the
# `check` job compiled all 18 kernel sources and linted clean on the same
# image. So the shaders build on a hosted runner; what fails is executing
# them. Whether the runner has no Metal 4 device or a device without
# TensorOps, the consequence for this workflow is identical.
#
# Applied here is the remedy this comment named in advance: the job moves to
# the gated self-hosted runner. NOT `continue-on-error`, which would make a
# suite that could not run report the same green as one that ran and passed.
#
# Gated on a repository variable rather than merely targeting `self-hosted`,
# because a job whose runner labels match nothing queues indefinitely instead
# of skipping. Set HAS_METAL_RUNNER to `true` under
# Settings -> Secrets and variables -> Actions -> Variables once an M5-class
# runner is registered; until then this skips, and the README's Known Gaps
# table says so rather than implying the suite runs unattended.
# ---------------------------------------------------------------------------
gpu:
name: GPU suite on bare metal
if: ${{ vars.HAS_METAL_RUNNER == 'true' }}
runs-on:
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install the Metal Toolchain
run: |
if xcrun --find metal >/dev/null 2>&1; then
echo "Metal compiler already present: $(xcrun --find metal)"
else
xcodebuild -downloadComponent MetalToolchain
fi
xcrun metal --version
- name: Report which GPU this runner has
# Printed before anything depends on it, so a later kernel failure can be
# read against the hardware that produced it instead of guessing.
run: system_profiler SPDisplaysDataType | head -20
- name: Device probe
# The example constructs a GpuRuntime, prints the chipset, and checks a
# known GEMM result. It is the narrowest thing that distinguishes "no
# Metal 4 device" from "device present, TensorOps unusable" from
# "working", and it runs before the suite so the distinction appears at
# the top of the log rather than inside 199 tests.
run: cargo run --release --example gemm
- name: Full suite
# --test-threads=1 is mandatory, not tuning. GPU tests share default
# command encoders across threads and are not safe to run concurrently.
#
# This covers doc tests too: passing a harness argument does not
# suppress them, and the `Doc-tests tessl` section is part of the 199.
run: cargo test --release -- --test-threads=1
- name: Examples run, since the README says they do
# The quickstart claims these "are compiled and run as examples, so they
# cannot drift from the API". That claim is only true if something runs
# them.
run: |
cargo run --release --example nn_layer
cargo run --release --example epilogue_cost
# ---------------------------------------------------------------------------
# Cargo.toml claims `rust-version = "1.82"`. Unverified that is just a string
# in a manifest, so this builds against exactly that toolchain.
#
# `--locked` because the MSRV promise is about building the shipped lockfile,
# not about whatever a fresh resolve happens to pick today.
# ---------------------------------------------------------------------------
msrv:
name: MSRV 1.82
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.82.0
- uses: Swatinem/rust-cache@v2
- name: Install the Metal Toolchain
run: |
if xcrun --find metal >/dev/null 2>&1; then
echo "Metal compiler already present"
else
xcodebuild -downloadComponent MetalToolchain
fi
xcrun metal --version
- name: Check the library and all targets
# `--all-targets` is safe here: this crate declares no dev-dependencies,
# so the tests build against the same toolchain floor as the library.
run: cargo check --locked --all-targets
- name: Check with quant-prep enabled
# The feature is off by default, which means nothing else in this
# workflow compiles `mtl_tensor`'s quantized path. A feature that is
# never built is a feature that has already broken.
run: cargo check --locked --features quant-prep
# ---------------------------------------------------------------------------
# Bare-metal measurement, for numbers rather than coverage.
#
# A hosted runner is virtualised and shared, so timings from one describe the
# runner, not the hardware anyone runs this on. Every GFLOP/s figure in the
# README came from an M5 Pro; this is where a regression against those would
# show up.
#
# Gated on a repository variable rather than merely targeting `self-hosted`,
# because a job whose runner labels match nothing queues indefinitely instead
# of skipping. Set HAS_METAL_RUNNER to `true` under
# Settings -> Secrets and variables -> Actions -> Variables once a runner
# exists.
# ---------------------------------------------------------------------------
bench:
name: GEMM sweep on bare metal
if: ${{ vars.HAS_METAL_RUNNER == 'true' }}
runs-on:
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# No `cargo test` here: the `gpu` job above runs the suite on this same
# runner. This job is for numbers.
- name: Bit-exact parity probe against the reference SIMD path
run: cargo run --release --bin probe_gemm_parity
- name: GEMM sweep
# Not a gate — the numbers land in the job log so a throughput
# regression is visible without anyone running it by hand.
run: cargo run --release --bin bench_gemm_sweep