opus r23 quant: ksg parity signs, dq_f16 / DW / unroll (Round 23 Track A) #
Patch: /tmp/opus_r23_quant/opus_r23_quant.patch (git -C vendor/strata apply --check OK). Files touched:
metal/kernels/quant.metal (shipped kernel change), metal/tests/dense_mm_parity.cpp (new A/B mode).
Device: Apple M5 (24 GB). Scratch: /tmp/opus_r23_quant/{base,strata} (base = vendor at 296b59d + working tree).
TL;DR #
ksigns_iq2xs[i] == i | (popcount(i) & 1) << 7holds for all 128 entries (checked vs ggml-common.h). But computing it is not a uniform win. On M5 it depends on format and on where the lookup would otherwise go (constant vs threadgroup), soksgnow computes the sign byte only where it was measured faster:- IQ2_XS with
QUANT_CTAB(production decodemoe_gu, since window.cpp sets ctab for the routed gate/up): -2.5..-5.5% gate/up time across NE=1..4, ntok 1..8 (ratios 0.945..0.995, 2 runs) - IQ3_XXS with the threadgroup table (production prompt
mm_gu3_18_16_32_32_4, which runs with ctab off): -1.6..-3.9% (5 paired runs, quiet GPU; A/A control within +-0.1%). Its 128-byte sign copy is gone, andfmt<T_IQ3_XXS>::TABdrops from 288 to 256 words. - everywhere else it stays a lookup, because computing it measured slower: IQ2_XXS moe_gu ctab +2..+13%, IQ3_XXS moe_gu ctab +0..+6%, IQ2_XS mm_gu3 +1.5..2% (matches opus_r8/opus_r12, which rejected popcount for IQ2_XS mm).
- known trade-off: IQ3_XXS
moe_guwith ctab off (only reachable withSTRATA_METAL_CTAB=0) is +5..7% slower. The default decode path (ctab on) for IQ3_XXS is unchanged (0.998..1.006).
- IQ2_XS with
dq_f16is at the memory roofline. IQ4_XS 2560x10240 moves 67 MB (52 MB of fp16 out + 15 MB in) in ~509 us = ~132 GB/s on M5. None of the decode-side ideas helped, so none ship:- IQ4
DWwithout the table/barrier, using a 4-register packed codebook: slower, 1% on big shapes and 10-22% on small ones - IQ4
DWreadingkvalues_iq4nlstraight from constant memory: ~1% slower / neutral a.permguard (no integer division when perm == 0): 1.00x- 16-byte
uint4stores in place of thehalf4pairs: 1.00x
- IQ4
#pragma clang loop unroll(full)on the fixed-trip loops (load16,X::load, everyDec<F>,unit_combine, everydeq<F>loop,putloops): net loss on gemv. IQ4_XS 2560x10240 was +8..9% (best tile, 2 sweeps), Q6_K 2560x10240 +2.8%, Q4_K/Q3_K/Q5_K/Q4_0/BF16 within +-1..2%. Not shipped. The compiler already unrolls these where it pays; forcing it costs registers.
Bitwise verification #
Every output buffer bitwise: I added an instrumentation hook to the scratch base mtl_rt.mm (not in the patch):
with STRATA_ABHASH=file, after every Context::run/run_graph it hashes every buffer bound to the dispatches.
Each test binary runs twice, with STRATA_METAL_KERNELS=base/kernels and then =candidate/kernels, and the hash
logs are compared. A/A runs first confirmed the tests are deterministic. With the final patch, all 31 cases gave the
same hashes (OK) and the same exit codes (0):
dense_mm_parity(360 hashed buffer states),gemv_parity(26,316)expert_parity --gu {Q2_0,IQ3_XXS,IQ2_XXS,IQ2_XS,IQ2_S,IQ3_S,IQ4_XS,Q4_K,Q5_K,Q6_K,Q8_0} --ntok 3,--dn {Q2_0,IQ4_NL,Q5_1,Q8_0} --ntok 8moe_mm_parity --v3 --tile 16,32,32,4 --dtile 64,32,2 --T 48 --gu {Q2_0,IQ3_XXS,IQ2_XXS,IQ2_XS,IQ2_S,IQ3_S,IQ4_XS,Q4_K,Q8_0},--dn {IQ4_NL,Q5_1,Q8_0},--gu IQ2_XS --tile 16,64,64,2(mm_gu),--gu IQ3_XXS --dn IQ4_NL- for the ctab=on decode path, each
expert_parity --abrun (both ctab on and ctab off, IQ2_XS/IQ3_XXS/IQ2_XXS/IQ2_S) passed its in-process memcmp of plan/h/ye/y. Trick:quant.metalandunits.metalare empty stubs in the kernel dir, andmoe.metal/ the--abfile are each version's quant+units+moe concatenation, so A and B carry different quant.metal. moe_mm_parity --v3 --gu {IQ2_XS,IQ3_XXS}withQUANT_CTAB=1(mm_gu3 ctab on): same hashes toodense_mm_parity --dq-abwithDQ_AB_KERNELS: memcmp=0 for every shape tried (IQ4_XS, IQ4_NL, IQ3_S, Q8_0)
Measurements (paired, M5) #
mm_gu3 (prompt, moe_mm_parity --v3 --tile 16,32,32,4 --dtile 64,32,2 --gu-bench --ab KB, kernel dir = base, T=2048) #
diff = (cand - base) / base, med / min, %
| format | A/A control | final candidate |
|---|---|---|
| IQ3_XXS | -0.04/-0.07, -0.02/-0.08, +0.03/0.00 | -1.73/-2.88, -2.92/-2.24, -3.66/-1.58, -3.91/-2.29, -3.79/-2.95 |
| IQ2_XS | +0.03/+0.04, +0.09/-0.03, +0.08/+0.12 | +0.06/+0.14, +0.14/+0.06, +0.03/+0.17, +0.07/0.00, +0.02/+0.15 (same code path) |
| IQ2_XXS | -0.06/+0.07, -0.43/+0.12, +0.16/+1.34 | +0.03/+0.64, -0.26/+0.51, +0.22/+0.51, -0.02/+0.63, +0.13/+0.57 (same code path) |
| all-popcount (rejected), IQ2_XS | +1.53..+1.95 med, +1.65..+2.11 min |
IQ2_S: removing its dead sign copy / shrinking TAB came out +1..3% but within that run's noise (A/A spread up to 11%). Left unchanged.
moe_gu (decode, expert_parity --bench --ab, cand/base gate/up ratio over NE=1,2,3,4 same + ntok=1,2,4,8 sh=0.3) #
- IQ2_XS ctab: 0.953 0.995 0.965 0.975 0.945 0.954 0.971 0.968 (earlier run 0.947..0.993)
- IQ2_XS tab (unchanged path): 0.992..1.007
- IQ3_XXS ctab (unchanged path): 0.998..1.006, plus one 1.064 at ntok=8
- IQ3_XXS tab: 1.047..1.076 (non-default path, see trade-off above)
- IQ2_XXS ctab / tab (unchanged path): 0.993..1.006 / 0.996..1.018
dq_f16 (DQ_AB_KERNELS=base dense_mm_parity --dq-ab TYPE, CHUNK totals) #
- A/A: IQ4_XS 60.20 -> 60.39 ms, IQ3_S 55.14 -> 55.09
- IQ4 4-register LUT: IQ4_XS 60.06 -> 60.61 (2560x128: 0.78x, 2560x48: 0.85x), IQ4_NL 65.44 -> 65.45 (320x10240p4: 0.82x)
- IQ4 constant
kvalues_iq4nl: 56.53 -> 57.15 - perm guard: 56.55 -> 56.51
- 16-byte stores: IQ4_XS 56.75 -> 56.82, IQ3_S 55.18 -> 55.12, Q8_0 80.27 -> 76.45 (one 2560x10240 outlier; the other 12 shapes 1.00x)
gemv unroll pragmas (gemv_parity --one, 3 alternating runs per shape, min per tile) #
best tile base -> cand: IQ4_XS 2560x10240 104.6 -> 114.1 (+9.2%; first sweep +24%); Q6_K 2560x10240 160.0 -> 164.5 (+2.8%); IQ4_NL 640x2560 +3.3%; Q2_0 2560x640 +0.6%; Q3_K -2.2%; Q4_K -0.9%; Q5_K -0.7%; IQ3_S -0.3%; Q4_0 -0.1%; BF16 -0.1%. Rejected.
New test mode (in patch) #
DQ_AB_KERNELS=<dir> dense_mm_parity --dq-ab [type] [shape] compiles <dir>'s
quant/units/mma/dense_mm next to this build's. It runs both at dq_tg's threadgroup size, memcmps the fp16 output and
times them in paired rounds. The warm-up now covers both sides (before, the first shape was biased against A by ~14%).
Without the env var the mode is unchanged (64 threads vs dq_tg's).
Notes / caveats #
ksgpicks byat(the grid's word count: 256 IQ3_XXS, 512 IQ2_XXS, 1024 IQ2_XS). It is a compile-time constant at every call site (quant.metal and moe_mm.metal), so the branch folds away. moe_mm.metal's call sites didn't need changing.- The
moe_mm.metal:691kspointer (tab + 256) is unused, so the IQ3_XXS shrink is safe. The only other readers oftab + Ggo throughksg. - About the heartbeat's note: my moe_mm runs used kernel dir = base and
--ab= candidate, so A = base and B = candidate. Negative means the candidate is faster, as reported here (the A/A controls confirm the direction). - Not tested end to end (no full-model runs, per the rules). Expected effect: decode routed gate/up for the IQ2_XS K512 pack about -4%; prompt mm_gu3 for IQ3_XXS packs about -3%.