Skip to content

perf: stage the Apple7/8 register attention tile's queries in threadgroup memory - #24

Merged
paperniuk merged 2 commits into
paperniuk:apple7-m1-1.4from
sady4850:apple7-sgf-attention-regs
Oct 24, 2026
Merged

paperniuk merged 2 commits into
paperniuk:apple7-m1-1.4from
sady4850:apple7-sgf-attention-regs

Conversation

@sady4850

Copy link
Copy Markdown

With the sweep fixed (#23) I looked at the _sgf attention tile, because at agent contexts it is a big part of the M1 cycle: decode-profile on M1 Pro, 27B, B1 at 24K history, verify_attention_q8_split_sgf is 49.7 of 226 ms (22%), and prefill attention 15% of a 24K prefill. Per FLOP it ran ~2x below the linear tiles next to it.

Its pipelines report maxTotalThreadsPerThreadgroup 384, the lowest of the hot kernels (the decode linear tile is 832, GDN 448), so register pressure looked like the limit. Each lane keeps uint queries[4][8] (32 registers) for the whole page loop, next to output[32]. This change moves the queries to threadgroup memory, step-major [b * 8 + t][lane]: every lane reads only the words it wrote, so no barrier and no bank conflicts. 24 KB per threadgroup for G = 6, 32 KB for kv2_g8. The arithmetic is untouched, so output is bit-identical (same attention-sweep digests in every case).

maxTotalThreadsPerThreadgroup stays 384 (output still dominates), but it is faster anyway.

attention-sweep, M1 Pro (16-core GPU), 1 lane, --compare-metallib, medians of 21:

case before after
27B verify, 2K history 0.279 ms 0.237 ms 1.18x
27B verify, 24K 2.143 ms 1.811 ms 1.18x
27B verify, 48K 4.181 ms 3.546 ms 1.18x
27B prefill, 2K history 62.8 ms 52.7 ms 1.19x
27B prefill, 24K history 512.9 ms 425.9 ms 1.20x
35B verify, 24K 1.446 ms 1.234 ms 1.17x
35B prefill, 24K 355.9 ms 286.8 ms 1.24x

In the model it helps more (decode-profile, 27B, 24K prompt, fp32 state):

before after
verify attention per B1 cycle 49.7 ms 33.4 ms (1.49x)
B1 cycle 226.2 ms 206.6 ms (-8.7%)
B2 / B3 / B4 cycle 407 / 653 / 787 ms 373 / 530 / 717 ms
prefill attention, 24K prompt 59.5 s 39.9 s (1.49x)
whole 24K prefill (GPU only, no ANE split) 408 s 376 s (-8%)

Agent replay (the public junie_replay from erokhins/splash: warm-up 14.5K + 11 append-only requests growing to 31.7K, mlx-community/Qwen3.8-27B-4bit, --max-context 64K --max-memory 21G, fresh server each run, ABBA, on AC):

before after after before
decode (generation tok/s) 16.8 17.8 17.9 16.8
prefill of appended tokens 85 tok/s 88 tok/s 90 tok/s 85 tok/s
decode on requests 09 / 10 (~30K) 16.1 / 19.2 17.2 / 21.1 17.5 / 21.0 15.6 / 19.4
session 401 s 392 s 387 s 403 s

All four runs produce the same 1164 tokens with the same acceptance, so token-identical end to end. Decode +6% over the session, ~+10% at 30K; cold 14.5K prompt unchanged (~127 s), short histories don't spend much in attention.

Short context, no regression: your serving methodology with my prompts (256 tokens, temperature 0, reasoning off, ABBA, 2 reps), undisturbed runs before / after: technical 21.1 / 21.3, python_code 27.0 / 27.3, typescript_code 22.0 / 22.2, repeat 45.5 / 45.7 tok/s, same drafted/accepted counts on every prompt.

Tests: q8_flash_attention_metal_test passes with MTL_SHADER_VALIDATION=1 (it runs the _sgf tiles on Apple7/8), attention_sweep_cli.sh passes, build clean. Two things fail on apple7-m1-1.4 with the base metallib too, so not from this change: paged-attention-plan (RoPE table precision checks) and q8_chunked_prefill_metal_test.mm:334 does not compile (missing field 'block_stride' initializer).

Not tried on M1 Max / M2. Next I want to look at output[32], the rest of the register pressure (splitting D between two simdgroups with a score exchange), if you think it is worth it.

@ghisguth

Copy link
Copy Markdown

@sady4850 @paperniuk I measured #24 on my M2 Max (38-core GPU) against apple7-m1-1.4. Outputs match bit for bit in attention-sweep on every case I ran (2K to 64K history, int8 and bf16 KV).

attention-sweep, 27B shape, attention kernel only, bf16 KV (A = apple7-m1-1.4, B = pr-24):

History Phase Lanes A ms B ms Δ B/A
2K verify 1 0.139 0.124 -11.2%
2K verify 4 0.403 0.337 -16.2%
32K verify 1 1.237 1.066 -13.8%
32K verify 4 4.386 3.784 -13.7%
64K verify 1 2.293 1.878 -18.1%
32K prefill 1 264.4 225.4 -14.7%

int8 KV is similar, 10-20% faster. I only ran the 27B shape, not 35B.

Full 27B decode cycle (decode-profile, MLX 4-bit, ABBA), 32K prompt, fused cycle ms:

Lanes A B Δ B/A
1 100.0 97.1 -2.9%
2 174.9 169.0 -3.3%
3 241.9 233.2 -3.6%
4 322.6 312.5 -3.1%

At 2K it is flat, attention is only ~3% of the cycle there. GPU prefill of 32K tokens goes 199.7 s -> 194.1 s (-2.8%). So it helps exactly the long context agent sessions I care about.

q8-flash-attention and q8-chunked-prefill pass with and without MTL_SHADER_VALIDATION=1. BTW your test fix in 03bcee2 is the same one I had on my side, so I will drop mine.

Great job, thank you!

@sady4850

Copy link
Copy Markdown
Author

@ghisguth thanks for checking on M2 Max, I only have M1 here so this is very useful. good to see it helps on long context, prefill is next thing I want to look at.

off-topic question, I am choosing harness for long local agent sessions. from your writeup I see Qwen Code was your pick for some time, and now you file issues in oh-my-pi with 27B on 262K window. did you fully move to omp? what made the switch, compaction/prefix cache behaviour or something else?

@ghisguth

Copy link
Copy Markdown

@sady4850 you are welcome, it was easy to run and nice to see it helps on M2 Max too.

Regarding oh-my-pi and qwen-code, I was back and forth with opencode and qwen-code. I liked qwen-code more, but I found that qwen-code is less reliable, tool calling was failing slightly more often. I tried to move to pi agent, and actually made some progress tuning it for myself. E.g. implemented plugins to save/load context to/from disk in llama.cpp when I switch models. I liked it, and then I tried oh-my-pi, and it really surprised me. It is fast, a lot of things done out of the box. And it has great context management.

With Splash + oh-my-pi + Qwen 27B I was able to finish one project, which both AGY and Claude Code were refusing to do. It took one week of running agent almost non stop, but it completed the work. This was real test for omp for me, and it worked really-really well. So I would probably stay with oh-my-pi for now, and as backup will try opencode.

@paperniuk
paperniuk merged commit dd2d60c into paperniuk:apple7-m1-1.4 Oct 24, 2026
5 checks passed
@paperniuk

paperniuk commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Nice one, thanks! Confirmed on M1 Max (32-core GPU), digests identical in every case.

attention-sweep --compare-metallib, 1 lane, int8 KV, median of 21, fused ms at 2K / 24K / 48K history:

Case Before (ms) After (ms) Speedup
27B verify 0.257 / 1.111 / 2.213 0.225 / 0.947 / 1.826 1.14x / 1.17x / 1.21x
27B prefill 34.3 / 284.5 / 568.4 28.0 / 232.9 / 451.3 1.22x / 1.22x / 1.26x
35B verify 0.152 / 0.758 / 1.458 0.129 / 0.658 / 1.247 1.18x / 1.15x / 1.17x
35B prefill 25.5 / 226.9 / 456.7 20.6 / 186.0 / 370.3 1.24x / 1.22x / 1.23x

BF16 KV gains the same: 27B verify 1.18x / 1.21x at 2K / 24K on 1 lane, 1.31x / 1.23x on 4 lanes.

End to end on unsloth/Qwen3.8-27B-GGUF:UD-Q5_K_M, backend-benchmark --scenario context, ANE share 0.35, ABBA. Same output tokens everywhere.

Context Decode (tok/s) Cold prefill (s)
2K 57.2 → 57.5 (+0.5%) 11.7 → 11.6
10K 58.2 → 59.6 (+2.4%) 59.9 → 58.8
50K 44.6 → 49.0 (+10.0%) 377.5 → 360.6 (−4.5%)

q8-flash-attention, q8-chunked-prefill and paged-attention-plan pass. The paged-attention-plan failure you saw on the base is real and not from your change: Apple7's fast cos/sin give the draft range RoPE rows different bits than the listed rows, and the target rows miss the 1e-6 bound at large positions. I fixed it with precise sincos in the RoPE tables (no speed change); it goes in with the next push of apple7-m1-1.4, together with the same block_stride test fix you made. Merged, and I'll port this to apple7-m1-1.3.2 too.

paperniuk pushed a commit that referenced this pull request Oct 24, 2026
…roup memory

Ported from 818344a (#24) on apple7-m1-1.4; the 1.3.2 verify kernel takes its tile from splash_verify_attention_tile_at.
paperniuk added a commit that referenced this pull request Oct 24, 2026
…port) into the 1.4 port

Conflicts:
- paged_attention.metal, rope.metal: 1.4's own versions, which already
  hold #24 and the precise RoPE tables.
- LinearGguf.cpp, linear_plan_test.mm: #27's one-pass check for the F32
  register tile next to 1.4's 16-row tile check; #27's one-pass scratch
  with LinearPlan::table().
- gguf_projection_test.mm: #27's register one-pass test where the device
  is not Apple9, before 1.4's prefill.
- gguf_projection_benchmark.mm: register_gu rows next to 1.4's staged16
  rows; the staged rows take one pass only from a staged policy.
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.

3 participants