Skip to content

perf(model): exact Metal speed-ups of the forward pass - #45

Merged
pilotspacex-byte merged 16 commits into
mainfrom
perf/model-kernels
Oct 9, 2026
Merged

pilotspacex-byte merged 16 commits into
mainfrom
perf/model-kernels

Conversation

@pilotspacex-byte

Copy link
Copy Markdown
Contributor

Exact speed-ups of the Metal forward pass. Outputs are unchanged.

Measured: the student's model stage goes from 156 to about 121 ms at 12 candidates, and the hook p50 from about 163 to about 130 ms on a quiet machine. r1 also gets faster (503 → 449 ms).

Four changes, found by profiling one hook-sized forward pass:

  • The decision head's last layer runs only at the two answer-marker positions. This is exact: keys and values still cover every token.
  • Rotary embedding uses a 32-bit-indexed kernel. candle's version did 64-bit division per element and was 12× slower.
  • The fused attention output is written straight into token rows.
  • Biases are added with a contiguous kernel.

Exactness:

  • max |ΔP| = 0 over 3,360 scored candidates, for both models.
  • The replay inlines identical files on all 240 prompts.
  • Parity with PyTorch passes on CPU and Metal.

Review fixes:

  • bf16 kernels are guarded by __HAVE_BFLOAT__. If a kernel can't be built, that's cached and candle's op is used instead, so macOS 12–13 keeps today's speed.
  • Unit-stride checks for the fused attention.
  • Offset masks are copied before the kernel binds them.
  • candle is pinned to =0.11.0, because one change calls a candle-internal function.
  • LAYA_REQUIRE_METAL=1 makes the Metal tests fail instead of skipping. Hosted runners may have no Metal device, so run it locally before merging: LAYA_REQUIRE_METAL=1 cargo test --workspace --release --locked.

The student scorer spends 157 ms per hook at 12 candidates, and the next
speed-up has to come from the kernels without changing a single ranking.
To choose which kernels to touch, profile_forward times the hook's scoring
path and every op of an encoder and a head layer at the model's real
shapes, pipelined as in a real forward, and sets each op's share against
the measured batch. At b=8, s=187 the ops sum to 97% of the batch, so the
table explains where the time goes.

The parity test also reads its fixtures from LAYA_CODEX_PARITY_FIXTURES,
so that the student export and r1 can be checked against fixtures made
from their own model dirs.

author: Tin Dang
…rkers

Only the [MASK] option positions reach the scorer, yet the head's last
layer computed queries, the output projection and its 4x-wide FFN for
every token. Keys and values still need every position, but everything
after them is row-wise and attention is independent per query row, so
the last layer now projects its queries, attends, and runs its FFN on the
b * kmax marker rows alone. The scored rows are the same.

On the student at W=128, K=12 (Metal, f16) the hook's scoring call drops
from 156.7 ms to 148 ms (p50, alternating runs). Parity against the
Python fixtures is unchanged for the student and r1 on CPU and Metal.

Attention over a subset of query rows gets its own entry point. With a
single query row it takes the explicit path on Metal, because the fused
single-query kernel ignores the mask; a unit test covers one, two and
three rows against the matching rows of full attention, and a
layer-by-layer f32 reference checks the head's logits on CPU and Metal.

author: Tin Dang
candle's rope_thd kernel computes every element's position with 64-bit
integer division, which the GPU emulates: at b=8, s=187 it took 0.41 ms
per layer, about 9% of the student's forward pass, for a memory-bound
elementwise op. The crate now compiles its own kernel with the same
arithmetic on a 3-D grid (half-dim, head, row) with 32-bit indices:
0.034 ms for the same tensor, 12x faster.

The output is bit-identical to candle's (a unit test compares f16 and
f32 on Metal with zero tolerance), the kernel is compiled once per
device with Metal's default fast-math options, the ones candle uses for
its own kernels, and the CPU path is unchanged. Student and r1 parity
against the Python fixtures is unchanged on CPU and Metal.

candle-metal-kernels and objc2-metal come in under the metal feature at
the versions candle already uses.

author: Tin Dang
The fused attention kernel wrote (b, h, s, hd) and every layer then
assembled the (b, s, h * hd) rows the output projection reads from
b * h blits plus a zero fill: 0.21 ms per layer at b=8, about 5% of the
student's forward pass. The kernel already takes output strides, so a
small custom op calls it with the strides of a (b, s, h, hd) buffer and
the transpose disappears.

The kernel, its tiling and its inputs are unchanged, so the output is
bit-identical (unit test with zero tolerance, full and two-row queries);
the mask must start at offset 0 because the kernel binds it without one,
so an offset view is made contiguous first. Student and r1 parity
against the Python fixtures is unchanged on CPU and Metal.

author: Tin Dang
candle adds a linear layer's bias through its strided broadcast kernel,
which computes every element's offset with 64-bit '%' per dimension: on
Metal the decision head's biased projections took about twice as long
as their matmuls (e.g. 1.49 ms instead of 0.67 ms for the packed q|k
projection at b=8, s=187). The encoder has no biases; the head's first
layer and its last layer's key/value projections still run over every
token.

Dense keeps candle's matmul and adds the bias with the crate's 2-D
kernel, the same x + b in the same dtype: bit-identical to
candle_nn::Linear on Metal and identical code on the CPU (unit tests
with zero tolerance on both). Student and r1 parity against the Python
fixtures is unchanged.

With the three previous changes, the student's scoring call at W=128,
K=12 goes from 157-167 ms to 132-138 ms (p50 in alternating runs on a
loaded machine).

author: Tin Dang
profile_forward's op table described candle's ops before the kernel
work. It now times the ops the model runs (the crate's rope and
row-major attention kernels, Dense projections, a head whose last layer
works at the marker rows) with their real call counts, and keeps the
replaced candle ops as zero-count reference rows. At b=8, s=187 the ops
sum to 96% of the measured batch; the encoder's matmuls are 77% of it.

author: Tin Dang
…uild

The crate's kernel library declared its bf16 variants unconditionally,
and `bfloat` needs MSL 3.1 (macOS 14). On macOS 12-13 the library would
fail to compile, every forward pass would error, the hook would fall
back to lexical ranking, and the compile was retried under the global
cache lock on every call.

The bf16 kernels are now behind `#if defined(__HAVE_BFLOAT__)`, as
candle guards its own. A library or kernel that cannot be built is
remembered as unavailable per (device, source, kernel) and logged once,
and rope_thd and add_bias then use candle's op: older macOS keeps the
speed it has today instead of losing the model.

Tests force a library that does not compile: both ops match candle bit
for bit, the compile is attempted once, and a missing kernel is
unavailable. The bit-equality tests now also assert that the crate's
kernel, not the fallback, produced the result, and bf16 is checked
wherever candle itself supports it.

author: Tin Dang
…ds it

The fused attention kernel binds the mask buffer without its start
offset, so sdpa_rows meant to move an offset mask into a fresh buffer
first. It used contiguous(), which returns an already-contiguous view
unchanged, offset included; such a mask then failed the op's own
offset check, contrary to what the commit that added sdpa_rows said.

force_contiguous() always copies into a new buffer at offset 0. A test
with a contiguous mask view one batch row into its buffer failed before
and now matches the copied mask bit for bit. The model's own masks start
at offset 0, so its forward pass is unchanged.

author: Tin Dang
…them

candle's fused attention kernel takes only the (batch, head, row)
strides of q, k, v, the mask and the output and reads every row with
unit stride, but sdpa_rows never checked that and its doc promised
"any strides". A q or mask whose last dim was not unit-stride (e.g. a
transposed view) gave silently wrong attention.

The op now refuses such inputs, and sdpa_rows copies them to contiguous
tensors first (the same values, so the same result; candle's own sdpa
has the same assumption, so it is not a safer fallback). The doc states
what is read in place and what is copied. The model's q, k, v and masks
are unit-stride already, so its forward pass makes no extra copy.

A test with transposed q, k, v and mask views failed before (wrong
values) and now matches the contiguous inputs bit for bit.

author: Tin Dang
metal_ops calls candle-metal-kernels' call_sdpa_full, an internal
function whose argument list and output-stride handling can change in
any release, and its rope and bias kernels must stay bit-identical to
candle's. A caret requirement would let `cargo update` pull a 0.11.x
that silently changes either. candle-core, candle-nn and
candle-metal-kernels are now `=0.11.0`, with a comment saying why and
what to re-run on an upgrade. Cargo.lock already resolved 0.11.0 and is
unchanged (the --locked builds pass with and without the metal
feature).

author: Tin Dang
…n paths

Every Metal test returned early when Metal was missing, so a build
without the metal feature, or a runner without a GPU, passed them all.
With LAYA_REQUIRE_METAL=1 the shared Metal test device and the Metal
parity test now fail instead of skipping (documented in the crate docs).
Before: `LAYA_REQUIRE_METAL=1 cargo test -p laya-model --lib` without
the feature passed 16 of 16; now 3 tests fail and say why.

The fused-attention equality test now runs f16 and f32, a batch of one
and of three, key lengths 37, 64 and 704 (the kernel's aligned paths up
to the student's max_len), with full and two-row queries. New tests:
a single query row is refused rather than computed with the mask
ignored, and the bias add on a non-contiguous input takes candle's path
and matches it. The bf16, offset-mask and failed-build cases are covered
by the earlier fixes.

author: Tin Dang
The crate's kernels index with u32 and dispatched whatever grid they
were given. Their docs now state the limit, and a tensor with more
than u32::MAX elements takes candle's op instead of erroring (the
student at max_len 704 and 64 rows has about 3.5e7, so this never
triggers in practice). Empty inputs return an empty result without a
zero-size dispatch.

The empty-input test passed before the change too (candle tolerated
the zero-size launches here); it now guards the early returns.

author: Tin Dang
The head's last layer took the first kmax query rows of the square
(b, 1, s, s) padding mask as the mask of its marker rows. That is right
only because every row of that mask is the same, an assumption nothing
enforced. AttnMasks now keeps the (b, 1, 1, s) key-padding row it is
built from, the head takes that row (and checks its shape), broadcasts
it over every query for its full layer, and passes it as is to the
marker-row attention.

The values the kernels read are unchanged, so the logits are too. The
subset-attention test now checks both the square mask's rows and the
broadcast key row, on CPU and Metal.

author: Tin Dang
Since the parity tests can read fixtures from
LAYA_CODEX_PARITY_FIXTURES, a missing or malformed file still failed
with "fixtures/laya_parity.json", pointing at the wrong file. The panic
now names the path it tried and the error.

author: Tin Dang
# Conflicts:
#	crates/laya-model/tests/parity.rs
@pilotspacex-byte
pilotspacex-byte merged commit d4a4513 into main Oct 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants