Repository navigation
perf: decode norm loads at clamped columns on Apple7/8 - #26
Merged
paperniuk merged 2 commits intoOct 24, 2026
Merged
Conversation
The wide decode norms (norm_rms_table, rows wider than 2048) load their first chunk and the table's span pairs as `column < width ? row[column] : 0`. On Apple7/8 those conditional loads were most of the norm's time: the 27B's eight-row norm of 5120 took 15 us. The Table16F instances, which only Apple7/8 run, now load every column at an index clamped into the row and select the zero after the load; the values and their order are unchanged, and the outputs, tables and sums are the same bit for bit (widths 2048 to 17408, 8 to 32 rows, bf16 and F32 weights). M2 Max: the 27B's 141 decode norms back to back 15.3 -> 6.1 us each at eight rows, 17.8 -> 5.7 at 32. decode-profile, 27B MLX 4-bit, 2048-token prompt: norm_rms_table16f_decode 4.50 -> 2.43 ms per B1 cycle (separate dispatches); the cycle 81.1 -> 79.4 ms (-2.1%), 139.8 -> 138.6 (-0.8%), 192.3 -> 190.5 (-1.0%), 258.0 -> 256.3 (-0.7%).
norm_rms_table16_decode and norm_rms_table16f_decode had no test of their own. norm-table runs both, with bf16 and F32 weights, at widths 1024 to 17408 (both the streaming and the chunked path), one to four lanes and inputs of very different magnitudes; their rows must match the plain norm's bit for bit, and their table and sums the register tile's own preparation of those rows byte for byte.
Author
|
@paperniuk Same as #25, I also pushed this change on top of apple7-m1-1.4: https://github.com/ghisguth/splash/tree/apple7-m1-norm-loads. It is two commits (the change, then the test) and they cherry-pick without conflicts. If you prefer a separate PR to apple7-m1-1.4, let me know and I will open it. BTW, #25 and #26 touch different files and merge cleanly together. With both, decode cycle on my M2 Max is −4.6% at one lane. |
Owner
|
Thanks, and thanks for the test! Confirmed on M1 Max (32-core GPU).
Merged. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Base:
paperniuk/splashapple7-m1-1.3.2(branchapple7-m1-norm-loads-1.3.2: the change, then the test).The same two commits are on
apple7-m1-1.4asapple7-m1-norm-loads; they cherry-pick without conflicts.Independent of the GDN verify PR.
This one is a small change with a large effect: the decode RMS norm that writes the register tile's Table16F
(
norm_rms_table16f_decode, 141 per 27B cycle) takes 60% less time on my M2 Max, which is about 2% of a one-lane decodecycle, and decode is 1–2% faster. The bits are unchanged. As before, I used Claude Code to author it and tested it by hand.
What changed
Only the Table16F instances of
norm_rms_table(kernels/shared/normalization.metal) change, which only Apple7/8run. The plain and Table16 norms are untouched.
column < width ? row[column] : 0. OnApple7/8 these conditional loads were most of the norm's time. With
Clamped(set fromis_same_v<Table, Table16F>), every load is issued at a column clamped into the row, and the zero is selectedafter the load. The values, their order and the arithmetic are unchanged.
norm-table(second commit): these norms had no test of their own. It runs the Table16 and Table16Fdecode norms with bf16 and F32 weights at widths 1024 to 17408 (the streaming and the chunked path), one to four
lanes and inputs of very different magnitudes, and requires their rows to match the plain norm's bit for bit and
their table and sums to match the register tile's own preparation byte for byte. It passes before and after the
change.
and sums; widths 2048 to 17408, 8 to 32 rows, both weight types, three input scales).
I found this while working on the GDN verify kernel, where a per-load pointer choice cost 30% of the kernel.
Results (M2 Max, 38-core GPU)
The 27B's 141 eight-row norms back to back, each with its own weights: 15.3 → 6.1 µs each at 8 rows (−60%), 17.8 →
5.7 µs at 32 rows.
decode-profile, MLX 4-bit, 2048-token prompt, ms per cycle at 1 / 2 / 3 / 4 lanes, ABBA, onapple7-m1-1.3.2(the norm's line is from separate dispatches):
On
apple7-m1-1.4the cycle gains are −2.3 / −1.7 / −0.8 / −0.8%.make test-performance-real(ANE share 0.35, MLX first and last), decode in tok/s at1 / 2 / 4 lanes, on
apple7-m1-1.3.2. Prefill doesn't change (within 0.2%), and both MLX runs of each build agreewithin 0.2%.
The UD-Q5_K_M 4-lane number is one run. The norm is the same kernel for every quant and
decode-profileshows the4-lane cycle 0.7% faster, so I think it's noise, but I didn't rerun it. On
apple7-m1-1.4: MLX 4-bit+2.8 / +1.6 / +0.8%, UD-Q4_K_M +2.0 / +1.5 / +0.4%.
Together with the GDN verify PR (they touch different files and merge cleanly),
decode-profileonapple7-m1-1.3.2gives a cycle of 81.2 / 140.3 / 192.6 / 257.7 → 77.4 / 136.9 / 187.7 / 252.1 ms(−4.6 / −2.4 / −2.5 / −2.2%), about the sum of the two. I didn't run the full benchmark on both together.
The new norm-table test is a separate commit and adds a file to
dev/native.mk. If it's too much to carry onupstream merges, feel free to drop that commit. The change is checked the same way by the harness I mention above.
Testing
norm-tableand the byte-for-byte harness, each with and withoutMTL_SHADER_VALIDATION.gguf-projectionpasses on the M2 Max with and without shader validation.make checkpasses on the M2 Max onapple7-m1-1.3.2.make test-performance-realwith MLX 4-bit, Q4_0, UD-Q4_K_M and UD-Q5_K_M before and after onapple7-m1-1.3.2,and with MLX 4-bit and UD-Q4_K_M on
apple7-m1-1.4.