Skip to content

metal: PQ2_0 and Q2_0 mat-vec dot in exact float (+5-7% decode) - #159

Merged
khosravipasha merged 2 commits into
prismfrom
perf/q2-metal-fp-dot-prism
Sep 6, 2026
Merged

metal: PQ2_0 and Q2_0 mat-vec dot in exact float (+5-7% decode)#159
khosravipasha merged 2 commits into
prismfrom
perf/q2-metal-fp-dot-prism

Conversation

@bri-prism

Copy link
Copy Markdown

The base-4 analogue of the exact-float extraction from #157, applied to the two 2-bit mat-vecs. PQ2_0 and Q2_0 share one codec at group sizes 128 and 64, so one templated dot serves both.

What

decode before decode after
PQ2_0, smaller dense 285.8 305.1 tok/s, +6.8%
PQ2_0, larger dense 80.2 83.1 tok/s, +3.6%
Q2_0, smaller dense 283.6 297.3 tok/s, +4.8%

M5 Pro, llama-bench -p 512 -n 128 -r 3, two interleaved passes. Prefill unchanged in all cases; the mat-mul path does not use this routine.

Why

The dot accumulated each 2-bit field through two ands, two bool tests, two selects and two adds per element, none of which this ISA can co-issue with the floating-point adds.

How

A byte is a base-4 fraction of 256. With u = b/256 and g_k = floor(4^k * u), exact in fp32, the floor chain peels the fields from the top down, because plain bit packing keeps field 0 in the low bits: g_1 is field 3, g_2 - 4*g_1 is field 2, g_3 - 4*g_2 is field 1 and b - 4*g_3 is field 0. Summing field times activation over a byte collapses to g_1*(y_3 - 4*y_2) + g_2*(y_2 - 4*y_1) + g_3*(y_1 - 4*y_0) + b*y_0, whose coefficients depend only on the activations, so the caller stages those in place of the raw y and reuses them across every row. Three floors and four fmas per byte, no integer work. The shared select-chain overload is left in place for its other callers.

The field order was checked against the packing over all 256 bytes before the kernel was touched. A first derivation that assumed the base-3 digit order was wrong and was caught by that check; the base-3 codec stores digit 0 as the leading fractional digit, which is why the same chain runs bottom-up there and top-down here.

What was tried and rejected

The same rewrite on Q1_0 is a measured regression of 5 to 7 percent on two dense models and is not included. With one bit per element the select is already a single op, so seven floors per byte cannot beat it and floor's lower throughput loses. The rule that falls out: the float-floor form pays only where the integer path costs two or more selects, or a multiply-shift chain, per element.

Where the 2-bit formats now sit

Against a weight-bandwidth ceiling of roughly 270 GB/s, PQ2_0 decode on the larger model is at about 75 percent of the ceiling after this change, so there is little ALU headroom left there. The base-3 formats sit lower (PTQ1_0 about 61 percent, TQ1_0 about 49 percent) and remain the place where kernel work still moves the number.

Correctness

test-backend-ops on Metal, rebuilt on this branch's base: MUL_MAT 45 cases pass for each of Q2_0 and PQ2_0 with exit code 0 out of 1719 total; MUL_MAT_ID 75 pass for each with exit code 0 out of 1022; Q1_0 is unchanged and passes the same. The collapse is exact; results are not bit-identical to the select chain because the coefficient staging reassociates the fp32 sum. A/B arms were separate build directories with the embedded shader confirmed distinct before each measurement.

The dot accumulated each 2-bit field through two ands, two bool tests, two
selects and two adds per element, none of which this ISA can co-issue with the
floating-point adds.

A byte is a base-4 fraction of 256. With u = b/256 and g_k = floor(4^k*u), exact
in fp32, the floor chain peels the fields from the top down because plain bit
packing keeps field 0 in the low bits: g_1 is field 3, g_2 - 4*g_1 is field 2,
g_3 - 4*g_2 is field 1 and b - 4*g_3 is field 0. Summing field*y over a byte
collapses to g_1*(y_3 - 4*y_2) + g_2*(y_2 - 4*y_1) + g_3*(y_1 - 4*y_0) + b*y_0,
whose coefficients depend only on the activations, so the caller stages those
in place of the raw y and reuses them across every row. Three floors and four
fmas per byte, no integer work. The shared select-chain overload is left in
place for its other callers.

The field order was checked against the packing over all 256 bytes before the
kernel was touched; a first derivation that assumed the base-3 digit order was
wrong and was caught by that check. The collapse is exact.

test-backend-ops on Metal: MUL_MAT 45 PQ2_0 cases pass, exit code 0 of 1719;
MUL_MAT_ID 75 pass, exit code 0 of 1022. Not bit-identical: coefficient staging
reassociates the fp32 sum.

Measured on an M5 Pro, llama-bench r=3, two interleaved passes, dense models:
  small  decode 285.8 -> 305.1 tok/s (+6.8%)
  large  decode  80.2 ->  83.1 tok/s (+3.6%)
Prefill unchanged.
Q2_0 and PQ2_0 use the same 2-bit codec at group sizes 64 and 128, and their
mat-vecs stage activations the same way, so the base-4 collapse from the
previous commit applies unchanged: the dot becomes a template over the block
type and the Q2_0 caller stages the same coefficients. Three floors and four
fmas per byte replace the select chain. Not bit-identical: coefficient staging
reassociates the fp32 sum.

The same idea was tried on Q1_0 and rejected on measurement: with one bit per
element the select is already a single op, so seven floors per byte lose to
it by 5 to 7 percent. Q1_0 keeps its select chain.

test-backend-ops on Metal: MUL_MAT 45 Q2_0 cases pass, exit code 0 of 1719;
MUL_MAT_ID 75 pass, exit code 0 of 1022.

Measured on an M5 Pro, llama-bench r=3, two interleaved passes, a small dense
model: decode 283.6 -> 297.3 tok/s (+4.8%). Prefill unchanged.
@khosravipasha
khosravipasha requested a balanced review from Copilot September 6, 2026 02:42
@khosravipasha
khosravipasha merged commit b984d5f into prism Sep 6, 2026
6 checks passed
@bri-prism
bri-prism removed the request for review from Copilot September 6, 2026 03:13
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