Repository navigation
perf(hip): keep the matrix tile's inner loop free of selects and divisions - #1663
Merged
Merged
Conversation
This was referenced Oct 30, 2026
Contributor
Author
|
An independent measurement of this PR on another checkpoint. @gilby ran it on Qwen3.8-Flash-Next-MLX-4bit-MTP (uniform 4-bit) on two gfx1151 machines, on a tree with #1650 underneath and dq off, so #1650's tile was not used: #1650 (comment)
Thanks @gilby. |
…sions A group's first product started from `first ? zero : d`, a select per accumulator element, and the group arithmetic divided by the stages a group spans at run time. With the accumulators that pushed the tile to 256 VGPRs and spilled to scratch inside the K loop. A group spans one, two or four stages, so its index and bounds are a shift and a mask, and the first product takes zero on a uniform branch. Every sum is the same in the same order, so the bytes are unchanged; the kernel no longer spills. Qwen3.8-27B prompt GEMMs on gfx1151, 2048 rows, weights from DRAM: the seven shapes take 63.7 ms instead of 105.8 ms (15.3 to 24.4 TFLOPS on gate_up). A prompt pass of 2048 tokens: 8.49 to 5.78 s.
ashhart
force-pushed
the
perf/hip-matrix-inner-loop
branch
from
October 30, 2026 23:00
9d47ca8 to
6800e90
Compare
Owner
|
Thanks @AbrahamCete. This shipped in TensorFold 1.2.0 today, with your credit in the release notes and the changelog: https://github.com/ashhart/TensorFold/releases/tag/v1.2.0 |
Owner
|
Thank you @AbrahamCete. This is in TensorFold 1.2.0, released today: https://github.com/ashhart/TensorFold/releases/tag/v1.2.0 It landed with your commits under your own name, so you're listed as a TensorFold contributor, and it's credited to you in the release notes and the changelog. |
Contributor
Author
gilby
added a commit
to gilby/TensorFold
that referenced
this pull request
Nov 1, 2026
The pairs-per-item bound reads the plan's item capacity, which grows with the pairs, so it admitted the 64-row tile at every chunk size. With the matrix tile's loop free of selects (ashhart#1663) the 128-row tile is faster once an expert holds the rows of a longer chunk, so the 64-row tile now also needs a chunk of at most 1,024 rows.
9 tasks done
gilby
added a commit
to gilby/TensorFold
that referenced
this pull request
Nov 2, 2026
The pairs-per-item bound reads the plan's item capacity, which grows with the pairs, so it admitted the 64-row tile at every chunk size. With the matrix tile's loop free of selects (ashhart#1663) the 128-row tile is faster once an expert holds the rows of a longer chunk, so the 64-row tile now also needs a chunk of at most 1,024 rows.
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.
What this changes
The HIP matrix tile (
affine_wmma_gemm, the prompt GEMM on gfx11) spilled to scratch inside its K loop. Two things caused it:first ? zero : d[mt][nt], which became av_cndmaskper accumulator element (145 in the loop body).per = group / 32at run time, about 570 scalar instructions a loop body.Together with the 64 + 64 accumulators, that put the kernel at 256 VGPRs with 48 bytes of scratch. A group is 32, 64 or 128 codes (
groupOk), soperis 1, 2 or 4. Its index and bounds are now a shift and a mask (group_of,group_start,group_end), and the first product callsmma(af, bf, zero)on a uniform branch.Every sum is the same in the same order, so the bytes are unchanged. The kernel no longer spills: 248 VGPRs, 0 scratch. On Qwen3.8-27B on gfx1151, cold prefill is 24.5-32% faster from 2k to 64k tokens. Decode does not use this tile.
This is independent of #1648 (banded block order). It applies to
mainalone and on top of #1648 with no conflicts. Together, the seven 27B prompt GEMMs take 60.4 ms, against 63.7 ms with this change alone and 105.8 ms onmain. It touches only the default tile's source, so it does not conflict with the opt-indqtile in #1650.Receipt
Environment: Ryzen AI Max+ 395 (gfx1151), Ubuntu 26.04, Zig 0.17.0, Ubuntu clang 21 hipcc (ROCm 7.13 from TheRock for the contract limits), checkpoint Qwen3.8-27B-MLX-4bit. Before is
mainf350cd3; after is this branch on top of it.Exactness:
kernel_tests/affine/order.zig) locally on both trees. It requires the matrix tile's output digest to equal the dot2 GEMM tile's on 16 shapes, and the digests are equal on both trees. The shapes are the 27B's prompt projections at 2048 rows, rows off the 128 tile, one row block, one column block, a 35B routed product, and 8-bit.token_shais identical between before and after at every size (2kfaceb02af600, 8k1ebfd15d62c7, 32kc8dfa0175e40, 64kd92b23432e24).tools/bench_concurrent.py --levels 1,4 --tokens 256 --reps 3 --temperatures 0 --alone --serial, run twice on each side:token_shais the same before and after: code2b28aa2108de, chata06fab348fbe.tf-qwen35-test checkon this branch: 27B--only invariants42 passed, 0 failed, 5 skipped; 35B-A3B 46/0/1. Both are the same asmain.Decode speed before and after: unchanged, because the matrix tile does not run inside lane rounds.
--context 4096 --parallel 4 --prompt-cache-gib 0. Runs alternated B1 C1 B2 C2.tools/bench_openai.py --tokens 256 --reps 3 --temperatures 0, median decode in tok/s:fibonacci-raw: 13.31, 13.31 before; 13.34, 13.26 after.gpu-chat-no-think: 13.56, 13.62 before; 13.65, 13.65 after.bench_concurrent, aggregate tok/s:Prompt speed before and after: cold first request
ttft_sfromtools/bench_cache.py --require-cold --tokens 16.tensorfold-native serve --context 66560 --parallel 1 --prompt-cache-gib 0 --snapshot-dir none --max-snapshots 0, with a fresh server for each run.cached_tokens = 0, and every run qualified.bench_cacheJSON.GEMM time alone: a local timing test runs each of the 27B's seven prompt GEMMs at 2048 rows, with the weights rotated past 512 MiB so they come from DRAM, and takes the median of 7 batches.
How I found it:
Tests run:
hip-kernel-test(binary run directly): 106 passed, 0 failed, 0 skipped, on both trees. With the two local tests (perf(hip): band the prefill WMMA GEMM's blocks so row blocks share weights #1648's order test and the timing test), it is 108/108.test-qwen35-hip-host,hip-host-test,check-native-hip-host: exit 0.SUMMARY gfx1151 49 kernels: 49 pass gates 1-4, 0 fail, 0 known findings; 5 controls: 5 red as planted, 0 missed; 126 limit problems, the same onmainand on this branch. The only line that differs between runs is where the planted atomic-add control first differs, which changes from run to run.affine_wmma_gemmrecord.tools/zig/lean_check.py: 426 problems onmainf350cd3 and on this branch, none new.Not run, and why:
caps.matrix == .wmma11). The 256-VGPR wave limit that caused the spill is the same there, but I have not measured it.Checklist
"draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (Bytes unchanged: matrix-vs-dot2 digests, servedtoken_sha, andbench_concurrentalone and serial equal;tf-qwen35-test checkmatchesmain.)SUMMARYline is in the receipt.main.)