Skip to content

metal: TQ1_0 kernel performance (dense decode +39-55%, prefill +78-90%) - #156

Merged
bri-prism merged 4 commits into
feat/tq1_0-cuda-v7from
perf/tq1_0-metal-flatten
Sep 8, 2026
Merged

bri-prism merged 4 commits into
feat/tq1_0-cuda-v7from
perf/tq1_0-metal-flatten

Conversation

@bri-prism

@bri-prism bri-prism commented Sep 5, 2026

Copy link
Copy Markdown

Two performance changes to the Metal TQ1_0 kernels. Stacked on feat/tq1_0-cuda-v7, which carries the kernels themselves and has no PR of its own yet, so the base here is that branch rather than prism-v7. Relates to #141.

Measured on an M5 Pro with llama-bench -p 512 -n 128 -r 3, across four ternary models: three dense, spanning roughly a five times parameter range, plus one mixture-of-experts. Three build arms were used, each in its own worktree, with the embedded shader confirmed distinct before every run.

model decode prefill
dense, smallest +45.7% +82.6%
dense, middle +39.1% +78.4%
dense, largest +55.2% +89.7%
mixture-of-experts +21.8% +112%

The decode gain is consistently larger on the dense models, 39 to 55 percent, than on the MoE at 21.8 percent. Dense decode is dominated by the mat-vec kernel this change touches, whereas the MoE spends a meaningful share of its decode in routing and gather work that dilutes the improvement. Prefill improves substantially everywhere.

For scale on the smallest dense model, where a same-binary TQ2_0 comparison was also taken: TQ1_0 moves from 53 percent of TQ2_0 prefill and 61 percent of its decode, to 96.5 percent and 88.8 percent respectively, at 82 percent of the file size. TQ2_0 still wins both axes, so this is now a size against speed trade rather than TQ1_0 being uncompetitive.

The two changes are independent and touch different paths, so they are separate commits.

1. Flatten the mat-vec into two uniform passes (decode)

The mat-vec walked a block's payload as three regions of 32, 16 and 4 bytes, each behind its own lane predicate. The second and third passes leave most of the simdgroup idle while the group still waits for them, so only 52 of 96 lane-slots do useful work.

qs[48] and qh[4] are adjacent in block_tq1_0, so the payload is really a flat 52 bytes. Lane l now takes byte l and byte 32 + l, covering all 52 in two passes at 52 of 64 slots.

The obstacle is that the tails carry different digit counts, five per byte for qs and four for qh, so running them together would reintroduce the divergence being removed. Padding the qh tail's fifth activation with zero makes the five-digit collapse reproduce the four-digit one exactly: the g_5 coefficient becomes zero and g_4 becomes y_3, which is the four-digit form. Both tails then share one code path. Lanes past the payload get zero coefficients and a clamped index, so they read in bounds and add nothing.

2. Remove the per-element integer work from the dequantize (prefill)

Prefill reaches TQ1_0 through the element-indexed dequantize template, which recomputed a modulo, a divide and an integer multiply-shift for every one of the sixteen elements it produced. On an ISA that cannot co-issue integer and floating-point work, that dominated.

Those sixteen elements share a digit index. For il < 10 they are sixteen consecutive bytes of the 32-byte region at digit il/2; for 10 <= il < 15, sixteen consecutive bytes of the 16-byte region at digit il-10; only il == 15 walks the qh tail. Hoisting that leaves two loop-invariant constants, and the digit comes from the same exact-float identity the mat-vec already uses, so the inner loop is a floor pair with no integer arithmetic.

Note this does not fix the underlying five times byte re-read, which is inherent to the sixteen-contiguous-element interface. A block-at-a-time unpack into the shared-memory tile is still available on top of this.

Correctness

Both digit identities were checked against the integer reference before the kernels were touched: the zero-padded collapse over all 256 byte values, and the rewritten dequantize over 77824 elements covering every il including extreme bytes. test-backend-ops on Metal passes 17 MUL_MAT, 7 MUL_MAT_ID and 4 GET_ROWS tq1_0 cases with zero failures after each change.

The dequantize change is bit-identical: per-element values are unchanged and nothing is repartitioned, and greedy generation matches the previous commit exactly apart from the build stamp.

The flatten change is not bit-identical, and should not be expected to be. It moves the qh tail off lanes 0-3 and onto lanes 16-19, so the simdgroup reduction sums a different partition of the same terms and fp32 reassociation shifts the last bits, by about the magnitude the existing five-digit path already carries. The transform is algebraically exact; only the reduction order moves. On the degenerate bench model used here, whose top-two logits are nearly tied, greedy decoding follows the same tokens for a short prefix and then diverges.

An earlier revision of this description claimed byte-identical greedy output for the flatten change. That was measured against a baseline build directory whose Metal dylib had since been rebuilt from modified sources, so it compared the change against itself. The claim is withdrawn.

Measuring this yourself

Two traps, both of which produced wrong answers here before being caught.

GGML_METAL_PATH_RESOURCES is only consulted when no precompiled metallib exists. This build logs using embedded metal library, meaning the shader is baked into libggml-metal.dylib, so pointing that variable at two source trees changes nothing. Appending #error to one tree and still getting a clean run is what exposed it.

Separate build directories in one source tree are not sufficient either. Building any target in a directory re-derives its dylib from whatever the sources currently say, so a baseline verified at setup can be silently overwritten later. Use separate worktrees, and re-confirm with strings <dylib> | grep -c <marker> immediately before each measurement rather than once. The build banner is not provenance: it records the commit at configure time and will report a clean SHA for a binary containing uncommitted changes.

Not applicable

fp16 for the digit extraction does not work, recorded so nobody retries it. The method needs floor(3^k * b / 256) exact; at 3^5 * 255 = 61965 the fp16 spacing is 32, so consecutive integers are not representable. Measured over all bytes it gets 17 of 1280 digits wrong, for example byte 79 digit 3 yields 1 where the reference gives 0, a wrong ternary level rather than a rounding difference. Resolving the 1/256 fractional steps needs roughly 16 mantissa bits and fp16 has 11, so even the first digit fails.

The block payload was walked as three regions, 32 then 16 then 4 bytes, so the
second and third passes left most of the simdgroup idle and the group still
waited for them: 52 of 96 lane-slots busy. qs[48] and qh[4] are contiguous, so
the payload is really a flat 52 bytes; lane l now takes byte l and byte 32+l,
which covers it in two passes at 52 of 64 slots.

The two tails differ in digit count, which would reintroduce divergence. Padding
the qh tail's fifth activation with zero makes the five-digit collapse reproduce
the four-digit one exactly, since the g_5 coefficient becomes zero and the g_4
one becomes y_3, so both tails run the same code. Lanes past the payload get
zero coefficients and a clamped index and contribute nothing.

Checked the padded identity against the integer reference over all 256 byte
values before touching the kernel, as with the original derivation.

Measured on an M5 Pro over a large ternary MoE, llama-bench r=3, two interleaved
passes: decode 61.4 -> 74.8 tok/s, +21.8%. Prefill unchanged because mul_mm
still goes through the element-indexed dequantize template. Greedy output is
byte-identical.
The mat-mul tile loader reaches TQ1_0 through the element-indexed dequantize
template, which recomputed a modulo, a divide and an integer multiply-shift for
every one of the sixteen elements it produced. That is the whole prefill path,
and on an ISA that cannot co-issue integer and floating-point work it dominated.

The sixteen elements of a call share a digit index: for il < 10 they are sixteen
consecutive bytes of the 32-byte region at digit il/2, for 10 <= il < 15 sixteen
consecutive bytes of the 16-byte region at digit il-10, and only il == 15 walks
the qh tail. Hoisting that leaves two loop-invariant constants, and the digit
itself comes from the same exact-float identity the mat-vec already uses, so the
inner loop holds one floor pair and no integer arithmetic.

Verified against the integer reference over 77824 elements covering every il and
including the extreme byte values. Per-element values are unchanged and nothing
is repartitioned, so model output is bit-identical: greedy generation matches the
previous commit exactly apart from the build stamp.

Measured on an M5 Pro over a large ternary MoE, llama-bench r=3, two interleaved
passes: prefill 863 -> 1829 tok/s, +112%. Decode unchanged at 76.
@bri-prism bri-prism changed the title metal: flatten the TQ1_0 mat-vec into two uniform passes (decode +21.8%) metal: TQ1_0 kernel performance (decode +21.8%, prefill +112%) Sep 5, 2026
@bri-prism bri-prism changed the title metal: TQ1_0 kernel performance (decode +21.8%, prefill +112%) metal: TQ1_0 kernel performance (dense decode +39-55%, prefill +78-90%) Sep 5, 2026
@khosravipasha
khosravipasha requested a balanced review from Copilot September 5, 2026 20:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Low-level kernel changes warrant final human review; remaining findings are non-blocking style nits.

Pull request overview

Optimizes Metal TQ1_0 decode and prefill kernel performance.

Changes:

  • Flattens mat-vector payload processing into two uniform passes.
  • Hoists dequantization indexing and removes per-element integer work.
File summaries
File Description
ggml/src/ggml-metal/kernels/mul_mv.metal Improves SIMD utilization; comment formatting nits remain.
ggml/src/ggml-metal/kernels/dequantize.h Optimizes digit extraction; a comment concision nit remains.
Review details

Suppressed comments (1)

ggml/src/ggml-metal/kernels/mul_mv.metal:3175

  • This explanation is hard-wrapped across five lines despite the repository requirement for concise, non-wrapped comments. The algebraic invariant can be preserved in two sentences.
            // The qh tail carries 4 digits per byte rather than 5. Padding its fifth
            // activation with zero makes the five-digit collapse reproduce the
            // four-digit one exactly (the g_5 coefficient becomes 0 and the g_4 one
            // becomes y_3), so both tails run the same code and the pass does not
            // diverge. Lanes past the payload keep all-zero activations and add zero.
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ggml/src/ggml-metal/kernels/dequantize.h Outdated
Comment thread ggml/src/ggml-metal/kernels/mul_mv.metal Outdated
Both blocks hard-wrapped prose across lines, and the dequantize.h one restated the
element layout that the block directly above it already gives exactly.

What is kept is the part that is not visible from the code: the base-3 identity and
its fp32 exactness bound, the reason the float pipe is used at all on an ISA with no
integer and floating-point co-issue, the flat 52-byte payload and the two-pass lane
assignment it enables, the zero-padding identity that lets the qh tail run the same
code as the main path, and the bounds invariants for lanes past the payload.

Comments only, no change in behaviour.
The TQ1_0 mat-vec processes one 256-element block per simdgroup per
iteration, half the work in flight of the PTQ1_0 kernel, and a stub
decomposition put its skeleton at only 60 percent of the memory-bandwidth
ceiling on a large dense model, so it is starved for memory-level parallelism
rather than bound by the dot arithmetic. More rows per simdgroup puts more
loads in flight per iteration. The per-row math is unchanged, so results are
identical to the previous shape.

Measured on an M5 Pro, llama-bench r=3, two interleaved passes, a large dense
model: nr0 4 -> 8, decode 68.8 -> 70.8 tok/s (+2.9%). 16 rows regresses to
66.7, so 8 is the sweet spot. The same sweep on PTQ1_0 lost about 1.5
percent; this is specific to the TQ1_0 loop shape.
@bri-prism
bri-prism merged commit c5b24b7 into feat/tq1_0-cuda-v7 Sep 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants