Skip to content

vulkan: dedicated mul_mat_vec shader for PTQ1_0 (~3x on the dequant path) - #252

Open
Preygle wants to merge 1 commit into
PrismML-Eng:prismfrom
Preygle:vulkan-ptq1_0-mul-mat-vec
Open

Preygle wants to merge 1 commit into
PrismML-Eng:prismfrom
Preygle:vulkan-ptq1_0-mul-mat-vec

Conversation

@Preygle

@Preygle Preygle commented Sep 23, 2026

Copy link
Copy Markdown

What

PTQ1_0 has no float mat-vec shader of its own, so it falls back to the generic
mul_mat_vec.comp, which reaches it through dequantize() / dequantize4() and calls
ptq1_0_trit() once per weight: an 8-bit load plus a loop of up to four multiplies to
skip the earlier trits packed in the same byte. Every packed byte is re-read and
re-decoded five times. A per-op profile (GGML_VK_PERF_LOGGER=1) on RDNA2 showed
MUL_MAT_VEC ptq1_0 at 93% of the time per token and ~130 GFLOPS, while plain f32
mat-vec in the same graph reached ~1700 GFLOPS.

Scope, after #238: the integer-dot shader (mul_mat_vecq_ptq1_0.comp) already
covers devices with VK_KHR_shader_integer_dot_product, and
ggml_vk_should_use_mmvq() selects it for k >= 2048 on AMD and NVIDIA, so on those
GPUs this shader is not on the hot path and the numbers there are unchanged. It
still matters for:

  • devices without integer dot support (where MMVQ can never be selected),
  • k < 2048,
  • mmvq_mode == -1 / GGML_VK_DISABLE_MMVQ=1.

So this is an improvement to the fallback path, not to the default path on recent
discrete GPUs.

How

  • Reads each 28-byte block as seven 32-bit words instead of 28 separate byte loads.
  • Peels the five trits off a byte with the base-3 recurrence two bytes at a time in
    16-bit lanes — 255*3 = 765 stays inside a lane, so lanes never interfere. 10
    multiplies per 20 weights instead of about 60.
  • Consecutive bytes of a word map to consecutive elements for a fixed trit, so each
    trit step consumes exactly one vec4 of activations.
  • weight = (trit - 1) * d is evaluated as d * (sum(trit*y) - sum(y)), so the
    row-independent sum(y) is gathered once per work item.
  • A work item is half a block (words 0..2 + the qh word, or words 3..5) so rows of
    5120 still spread across the workgroup.
  • Loop order is (word, row, column): a word is decoded once per row, each activation
    vector is loaded once per column, and the register footprint stays independent of
    NUM_COLS (which goes up to 8).

Routing matches how tq2_0 and the k-quants are handled: one condition in
vulkan-shaders-gen.cpp. mul_mm, mul_mat_vecq, get_rows, dequant and the
CPU/CUDA/Metal paths are untouched, and no other quant type is affected.

Benchmarks

AMD Radeon RX 6700M (RDNA2, gfx1031), Windows 11, Vulkan SDK 1.4.357, MSVC.
Ternary-Bonsai-2-27B-PTQ1_0, -ngl 99 -fa 1 -ctk q4_0 -ctv q4_0.
This branch vs an unmodified build of the parent commit, same toolchain, runs
alternated to cancel thermal drift.

With the dequant path forced (GGML_VK_DISABLE_MMVQ=1) — what this PR changes:

test unmodified this PR change
tg64 3.50 t/s 10.44 t/s 2.98x

Default settings, where MMVQ takes over on this GPU — unchanged, as expected:

test unmodified this PR
tg128 24.6 t/s 24.8 t/s
pp512 71.0 t/s 70.4 t/s

Both default-path differences are inside the run-to-run spread on this laptop.

Aside for Windows + AMD users reproducing this: a model that does not fit the
CPU-visible VRAM heap gets partly backed by system memory and loses 10-15x
regardless of this PR. GGML_VK_DISABLE_HOST_VISIBLE_VIDMEM=1 was set for every
measurement above, on both sides.

Correctness

  • test-backend-ops -b Vulkan0 -o MUL_MAT -p type_a=ptq1_0: 28/28 pass.
  • test-backend-ops -b Vulkan0 -o MUL_MAT_ID -p type_a=ptq1_0: 75/75 pass.
  • Full test-backend-ops -b Vulkan0: 17187/17190. The 3 failures are
    GATED_DELTA_NET(type=f32, ..., raw_gates=1) and are pre-existing — an unmodified
    build fails the identical 3 (-o GATED_DELTA_NET is 36/39 on both).
  • Perplexity over a fixed 512-token sample (-c 128 --chunks 4 -ub 1): 8.6924
    +/- 1.43177 on both
    the unmodified build and this branch. At -ub 4 on this
    branch: 8.6935.
  • Greedy generation matched an unmodified build word for word for the first 67 words
    of a 300-word prompt before diverging at a near-tie, as expected from a different
    summation order.

Not covered

  • One GPU only (RX 6700M / RDNA2) and one PTQ1_0 model. I have no NVIDIA, Intel or
    GCN hardware to verify on, though the decode work removed is vendor-independent.
  • PQ2_0 still uses the generic path; the same approach should apply, but I do not
    have that model locally to measure.
  • Multi-column variants still cost more per token than a single column (batch 2 is
    ~1.8x batch 1 here). An earlier variant that hoisted the decode differently was
    slower on single tokens, so the faster of the two measured forms is kept.

@bri-prism

Copy link
Copy Markdown

Tested on an Intel Arc B390 (Panther Lake Xe3 iGPU, UMA; the Vulkan backend treats it as Xe2-class, so #238's integer-dot path is its default). Windows 11, MSYS2 GCC 16.2, -DGGML_VULKAN=ON. 76798f98d merged onto prism @ 0324c6652, compared against that tip. Shipped v5 Bonsai 2 27B PTQ1_0 and 2B PTQ1_0, 0 other llama processes during the runs.

Heads-up for anyone reproducing: GGML_VK_DISABLE_MMVQ is checked with getenv(...) != NULL, so GGML_VK_DISABLE_MMVQ=0 also disables MMVQ. To get the default path you have to unset it. I got that wrong on my first pass.

Correctness: test-backend-ops test -b Vulkan0 -o MUL_MAT -p ptq1_0 gives 76/76 on both tip and PR, with MMVQ both enabled and disabled. With MMVQ disabled, that exercises this PR's shader.

Kernel, GGML_VK_PERF_LOGGER=1, 27B decode, MUL_MAT_VEC ptq1_0 m=6144 n=1 k=5120:

path tip PR
default (integer-dot MMVQ) 72.5 µs (868 GFLOPS) 81.1 µs (776 GFLOPS) (single sample; same shader, noise)
MMVQ disabled (float mat-vec) 711.2 µs (88 GFLOPS) 77.6 µs (810 GFLOPS)

That's ~9x on the fallback shader, which lands it level with the integer-dot path on this GPU.

End-to-end, llama-bench -ngl 99 -p 512 -n 128 -r 3, two rounds with the order reversed (t/s):

model path tip tg128 PR tg128 tip pp512 PR pp512
27B PTQ1_0 default 13.26 / 12.90 12.90 / 12.92 214.8* / 159.9 161.7 / 162.3
27B PTQ1_0 MMVQ disabled 1.49 / 1.54 12.28 / 11.93 (~8x) 207.8* / 161.5 161.6 / 156.8
2B PTQ1_0 default 56.63 / 55.35 56.53 / 56.31 2391 / 2410 2404 / 2384
2B PTQ1_0 MMVQ disabled 15.08 / 16.52 55.58 / 52.52 (~3.4x) 2300 / 2347 2375 / 2381

* The first tip run's 27B pp512 was high in round 1 only (~215 vs ~160 in every other run); I read it as a first-run outlier. Prefill goes through mul_mm, which this PR doesn't touch.

This matches the scope in the description: no change on the default path for an integer-dot device, and a large win on the fallback, which becomes roughly as fast as MMVQ here. That should matter on Intel/other GPUs without integer dot product, and for the k < 2048 matrices.

Tested with Claude Code.

@Preygle

Preygle commented Sep 24, 2026

Copy link
Copy Markdown
Author

Thanks for testing this — Arc B390 is exactly the hardware I couldn't cover, and the 2B model is a case I never ran.

Good catch on GGML_VK_DISABLE_MMVQ being checked with getenv(...) != NULL. My numbers used =1, so they're unaffected, but it's an easy trap. Happy to add a note about it to the description if you'd like it recorded there.

For a second data point, same methodology (paired runs alternated against an unmodified build of the parent commit, same toolchain, to cancel thermal drift on a laptop) — RX 6700M, RDNA2/gfx1031, Windows 11, Vulkan SDK 1.4.357, MSVC, Bonsai 2 27B PTQ1_0, -ngl 99 -fa 1 -ctk q4_0 -ctv q4_0:

path tip PR
MMVQ disabled (this PR's shader) 3.50 t/s tg 10.44 t/s tg (~2.98x)
default (integer-dot MMVQ) 24.6 t/s tg128 24.8 t/s tg128
default, prefill 68.9 t/s pp512 67.0 t/s pp512

Perplexity over a fixed 512-token sample was identical on both builds (8.6924 +/- 1.43177), and test-backend-ops -b Vulkan0 was 17187/17190 with the 3 GATED_DELTA_NET(raw_gates=1) failures also failing on an unmodified build of the same commit.

One difference worth noting between our GPUs: on RDNA2 the integer-dot path is still about 2.4x faster than this float shader (24.6 vs 10.4 t/s), whereas on your Xe3 they come out level. So on AMD this remains strictly a fallback-path improvement, as the description says, rather than a candidate for the default path.

Known limitation, in case it matters for review: the multi-column variants still re-decode the weights per column, so batch 2 costs ~1.8x batch 1 here. I tried hoisting the decode above the column loop and it regressed single-token throughput (register pressure), so the simpler form is what's in the PR.

Also, only labeler has run on this PR — the build workflows appear to need a maintainer to approve them for a fork PR, so let me know if you'd like anything rebased or changed before that.

Written with Claude Code.

@voxlo-dev

Copy link
Copy Markdown

Tested on a card without integer dot, which is the case this PR is for: AMD RX 570 (Polaris10 / gfx803), Linux, RADV Mesa 26.1.2, int dot: 0, RADV_PERFTEST=nogttspill. Ternary-Bonsai-2-27B-PTQ1_0. This PR merged onto prism @ 842b188, compared against 842b188 itself, same build flags.

llama-bench -ngl 99 -fa 1 -ctk q8_0 -ctv q4_0 -p 512 -n 128 -r 3:

build pp512 tg128
842b188 42.62 ± 0.00 1.58 ± 0.00
842b188 + #252 42.42 ± 0.04 7.15 ± 0.00

llama-server -c 16384, 40 generated tokens: 633.4 → 141.6 ms/token.

test-backend-ops -b Vulkan0 -p ptq1_0: MUL_MAT 140/140, MUL_MAT_ID 83/83, GET_ROWS 4/4, on both builds.

So 4.5x on generation on GCN, prompt unchanged as expected. For comparison, the table-decode patch I linked in #185 gets 6.99 tg128 on the same card, so this is slightly ahead and makes the mat-vec half of mine unnecessary. Mine also changes the mul_mm loader (pp512 42.6 → 59.1); stacked on top of this PR it gives 7.15 / 59.1 with the same tests passing, so the two don't conflict.

Builds and measurements were AI-assisted.

PTQ1_0 has no float mat-vec shader of its own, so it falls back to the generic
mul_mat_vec.comp, which reaches it through dequantize()/dequantize4() and calls
ptq1_0_trit() once per weight: an 8-bit load plus a loop of up to four
multiplies to skip the earlier trits in the same byte. Every packed byte is
re-read and re-decoded five times.

Since PrismML-Eng#238 an integer-dot mat-vec (mul_mat_vecq_ptq1_0.comp) covers devices
with VK_KHR_shader_integer_dot_product, and ggml_vk_should_use_mmvq() picks it
for k >= 2048 on AMD and NVIDIA, so on those GPUs this shader is not on the hot
path. It still matters for devices without integer dot support, for k < 2048,
and whenever MMVQ is declined or disabled.

mul_mat_vec_ptq1_0.comp reads each 28-byte block as seven 32-bit words and peels
the five trits off a byte with the base-3 recurrence two bytes at a time in
16-bit lanes (255*3 = 765 stays inside a lane), which is 10 multiplies per 20
weights instead of about 60. Consecutive bytes of a word map to consecutive
elements for a fixed trit, so each trit step consumes one vec4 of activations.
(trit - 1) is split into sum(trit*y) - sum(y) so the row-independent term is
gathered once per work item. A work item is half a block so rows of 5120 still
spread across the workgroup, and the loop order (word, row, column) keeps the
register footprint independent of NUM_COLS.

Measured on an RX 6700M (RDNA2, Windows, Vulkan 1.4.357) with
Ternary-Bonsai-2-27B-PTQ1_0, -ngl 99 -fa 1 -ctk q4_0 -ctv q4_0, against an
unmodified build of the parent commit with the same toolchain, runs alternated
to cancel thermal drift:

  GGML_VK_DISABLE_MMVQ=1 (this shader on the hot path)
    tg64    3.50 -> 10.44 t/s   (2.98x)
  default (MMVQ active, this shader off the hot path)
    tg128   24.6 -> 24.8 t/s    (unchanged, within noise)
    pp512   71.0 -> 70.4 t/s    (mul_mm untouched, within noise)

test-backend-ops -b Vulkan0: MUL_MAT 28/28 and MUL_MAT_ID 75/75 pass for
type_a=ptq1_0; the full suite is 17187/17190 and the 3 GATED_DELTA_NET failures
also fail on an unmodified build (36/39 both). Perplexity over a fixed
512-token sample is identical to the unmodified build: 8.6924 +/- 1.43177.
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.

3 participants