Skip to content

perf(vulkan): decode GEMV reads four elements per load, and the row lever is dead - #184

Merged
mudler merged 1 commit into
mainfrom
row/BACKEND-VULKAN-GEMVROWS
Aug 9, 2026
Merged

perf(vulkan): decode GEMV reads four elements per load, and the row lever is dead#184
mudler merged 1 commit into
mainfrom
row/BACKEND-VULKAN-GEMVROWS

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

vt_matmul_vec is 89-90% of 27B decode GPU time and moves ~52 GB of bf16
weights per token, so it was the largest single line item left. Two structural
differences against llama.cpp's mul_mat_vec (pin 237ad9b96) were the
candidates. One is worth about 1%, the other is a measured LOSS on this
device, and the win is 7x smaller than the isolated sweep first said.
All
three of those are the result.

What shipped

VT_MM_PACK, a specialization constant selecting the LOAD WIDTH for a 16-bit
operand: one element per load (2 B, the previous behaviour), two through the
buffer's existing 32-bit view, or four through a new 64-bit view of the same
VkBuffer. Ported in intent from llama.cpp's data_b_v4 /
dequantize4_2aligned. Same bytes, same coalescing, so this cannot reduce
DRAM traffic; it reduces load instructions, four to one. Default 2, degrading a
width at a time when K or an operand byte offset is not aligned, and declining
for an f32 operand.

What did NOT work, with its number

VT_MM_ROWS, llama.cpp's NUM_ROWS (it sets this per vendor via rm_stdq),
computing 2 or 4 output elements per workgroup. Measured 0.966x at 2 rows and
0.968x at 4 — a loss in every pass at every load width.
It ships OFF. What it
was supposed to buy was never traffic (weights are read exactly once per token at
any row count) and the activation re-reads it saves were L2 hits; halving the
workgroup count halves the independent sequential read streams the memory
controller sees, which is the likely cost. The axis is kept, off by default,
because llama.cpp raises it exactly on the AMD GCN and Intel parts this backend's
portability case is aimed at. It is bit-identical to rows=1 at any value,
verified by full-output bit-hash rather than by argument.

Measured

Isolated sweep (benchmarks/vulkan_gemv_ab.cpp, 7 real 27B shapes, 9 arms x 4
rotated passes, each arm paired against the baseline in the same pass because
the box drifted 15.5% peak-to-peak between passes):

pack 0 pack 1 pack 2
rows 1 1.000x 1.025x 1.086x
rows 2 0.966x 1.017x 1.047x
rows 4 0.968x 1.014x 1.039x

Real 27B decode, the number of record — two-length GPU-timestamp diff (len 36
minus len 4, over 32 tokens) so prefill and one-time costs cancel, run twice with
arm order reversed:

per decoded token blk1 pack0 blk1 pack2 blk2 pack0 blk2 pack2
vt_matmul_vec ms 211.99 209.52 214.59 210.38
GB/s (52.1 GB/token) 245.8 248.7 242.8 247.6
% of 273 GB/s roof 90.0% 91.1% 88.9% 90.7%

1.012x and 1.020x, not 1.086x. The sweep was in the wrong regime: it re-reads
one 356 MB buffer 320 times, so its DRAM rows and TLB stay hot and instruction
issue becomes visible; decode streams 50 GB of distinct weights once per token,
where DRAM is the whole story. The campaign already has "a negative result is
regime-dependent" on record; this is the same lesson running the other way — a
win can be a regime artefact too.

e2e AB/BA: 14 pairs across two blocks; 6 pairs had both legs clean, pack=2 won
5 of 6, median paired 1.0064x, clean-leg medians 242.38 -> 240.54 ms =
4.126 -> 4.157 tok/s. Measured noise floor on clean legs 0.88% (baseline arm)
and 1.92% (pack arm), so 0.7% is at the edge of e2e resolvability and it is the
GPU-timestamp diff that resolves it. Stated plainly rather than dressed up.

The GEMV lever is essentially empty and now we know why. The binding
constraint is DRAM bandwidth on a ~50 GB working set streamed once per token —
not instruction issue, not latency, not activation re-reads.

The finding worth more than the 1%

The ~1.8x bimodal decode legs this campaign has repeatedly discarded as
environmental are vt_matmul — the lm_head — collapsing 19.5x: 12.35 ->
242.12 ms/token
, i.e. 205 GB/s -> 10.5 GB/s on 2.54 GB of weights.
vt_matmul_vec beside it moved 1.2% in the same legs; TTFT is unchanged across
the modes (6.21-6.55 s over all 28 legs), so it is neither load nor prefill. It
hit 8 of 28 legs, arm-symmetric. This is one kernel entering a slow state, not
the environment, and it is a bigger lever than every remaining Vulkan kernel
optimisation combined. Being chased separately.

Gates, verified by the operator rather than taken on report

Re-run independently on a clean llvmpipe build:

  • test_vulkan_backend 30/30, 1823 assertions (was 29/1786; the new case and
    37 assertions are the variant-selection test)
  • test_backend_cross_device 11/11, 132 assertions
  • VLLM_CPP_DEVICE=vulkan test_opt_paged_engine 6/6 prompts token-exact
    (96/96 tokens), 0 declines
  • gen-vulkan-spirv.py --check under pinned glslang 16.5.0: committed SPIR-V is
    up to date

Agent-reported and not re-run by me: GB10 test_vulkan_backend 30/30 with 2366
assertions; all 9 (rows, pack) arms verified numerically; scratch mutation
(reverting kGemvPackDefault to 0) turns the selection test red 6/33 and green
on restore.

Known-red gates, named rather than papered over

check-pr-size counts the regenerated vulkan_spirv.cpp as reviewable product
code — the same checker gap every Vulkan shader PR hits. check-public-doc-tables
still fails on the docs/STATUS.md ratchet, which main already violates and this
branch leaves byte-neutral. The other preflight failures (undocumented
VT_GEMMA4_*/VT_ROCM_* vars, trailer commit 39da53c7) are pre-existing on the
base.

FOLLOWING_AGENTS_PROTOCOL

…ever is dead

vt_matmul_vec is 89-90% of 27B decode GPU time and moves ~52 GB of bf16 weights
per token, so it was the largest single line item left. Two structural
differences against llama.cpp's mul_mat_vec (pin 237ad9b96) were the candidates.
ONE is worth about 1%, the OTHER is a measured LOSS on this device, and the win
is 7x smaller than the isolated sweep first said. All three of those are the
result, and the third is the most useful.

WHAT SHIPPED. VT_MM_PACK, a specialization constant selecting the LOAD WIDTH for
a 16-bit operand: one element per load (2 B, the previous behaviour), two through
the buffer's existing 32-bit view, or four through a new 64-bit view of the same
VkBuffer. Ported in intent from llama.cpp's data_b_v4 / dequantize4_2aligned
(vulkan-shaders/dequant_funcs.glsl:54-61, which reads bf16 weights as two 32-bit
words). Same bytes, same coalescing -- a 32-lane subgroup still covers 128 or 256
CONSECUTIVE bytes -- so this CANNOT reduce DRAM traffic. It reduces LOAD
INSTRUCTIONS, four to one. Default 2, degrading a width at a time when K or an
operand byte offset is not aligned to it, and declining for an f32 operand.

WHAT DID NOT WORK, AND ITS NUMBER. VT_MM_ROWS, llama.cpp's NUM_ROWS
(mul_mat_vec_base.glsl:90; ggml-vulkan.cpp:4705-4719 sets it per vendor via
rm_stdq), computing 2 or 4 output elements per workgroup so one loaded activation
feeds several weight rows. MEASURED 0.966x at 2 rows and 0.968x at 4, in every
pass and at every load width. It ships OFF. What it was supposed to buy was never
traffic -- the weights are read exactly once per token at any row count -- and the
activation re-reads it saves were L2 hits. Halving the workgroup count halves the
independent sequential read streams the memory controller sees while each
surviving workgroup interleaves rows K*2 bytes apart, which is the likely cost.
The axis is KEPT, off by default, because llama.cpp raises it exactly on the AMD
GCN and Intel parts this backend's portability case is aimed at, so deleting it
would mean rediscovering it on the VK-I board. It is bit-identical to rows=1 at
any value, verified by a full-output bit-hash and not only by argument.

MEASURED, ISOLATED SWEEP, AND IT OVERSTATED THE WIN BY 7x.
benchmarks/vulkan_gemv_ab.cpp runs the GEMV over the seven (k, n) shapes
Qwen3.6-27B actually dispatches; 9 arms x 4 rotated passes, each arm paired
against the rows=1/pack=0 baseline measured IN THE SAME PASS (the box drifted
15.5% peak-to-peak between passes, so an unpaired ranking would have been noise):

              pack 0     pack 1     pack 2
  rows 1      1.000x     1.025x     1.086x
  rows 2      0.966x     1.017x     1.047x
  rows 4      0.968x     1.014x     1.039x

MEASURED, REAL 27B DECODE, which is the number of record. Two-length GPU-timestamp
diff, output-len 36 minus output-len 4 over 32 decode tokens, so prefill and every
one-time cost cancels. Run twice, arm order reversed the second time:

  per decoded token        blk1 pack0  blk1 pack2  blk2 pack0  blk2 pack2
  vt_matmul_vec ms           211.99      209.52      214.59      210.38
  GB/s (52.1 GB/token)        245.8       248.7       242.8       247.6
  % of the 273 GB/s roof      90.0%       91.1%       88.9%       90.7%
  speedup                                 1.012x                  1.020x

1.012x and 1.020x, not 1.086x. THE SWEEP WAS IN THE WRONG REGIME: it re-reads ONE
356 MB weight buffer 320 times, so its DRAM rows and TLB entries stay hot and
instruction issue becomes visible as a secondary constraint. Decode streams 50 GB
of distinct weights once per token, where DRAM is the whole story. The campaign
already has "a negative result is regime-dependent" on record from the GEMV
unroll; this is the same lesson running the other way, and an isolated harness
must be shown to reproduce the e2e operating point (this one does: 89.3% of roof
against the e2e diff's 90.0%) before its RANKING is believed.

END TO END. Order-alternated AB/BA, page cache dropped before every leg, two
blocks of 6 and 8 pairs, one binary with VT_VULKAN_GEMV_PACK as the only variable
(=0 reproduces the pre-row kernel bit for bit). Eight of the 28 legs landed in the
lm_head slow mode described below, 5 baseline and 3 pack=2, so arm-symmetric; they
are reported rather than dropped. Of the six pairs where BOTH legs were clean,
pack=2 wins 5, median paired ratio 1.0064x. Clean-leg medians 242.38 -> 240.54 ms,
decode 4.126 -> 4.157 tok/s against llama.cpp Vulkan's 4.35. The measured noise
floor on the clean legs is 0.88% peak-to-peak on the baseline arm (9 legs) and
1.92% on the other (11), so a 0.7% effect sits AT the edge of what an e2e wall
clock resolves here; the GPU-timestamp diff is the instrument that resolves it,
and the two agree in sign and magnitude.

SO: IS ANYTHING LEFT IN THE GEMV? ABOUT ONE PERCENT. The kernel was already at
90.0% of the GB10 bandwidth roof and is at 91.1%. llama.cpp Vulkan's 4.35 tok/s is
229.9 ms/token wall against our 240.5; the GEMV alone is 210 ms of that, so even a
PERFECT GEMV leaves ~20 ms and the rest of the gap is the ~30 ms/token of non-GEMV
cost. The binding constraint on this kernel is DRAM bandwidth on a 50 GB working
set streamed once per token, and it is 9% from the roof.

SIDE FINDING, WORTH MORE THAN THIS ROW'S 1%. The ~1.8x bimodal decode legs this
box produces, which the previous Vulkan row discarded as environmental, are
vt_matmul -- the lm_head, NN orientation, ONE call per token -- collapsing by
19.5x: 12.35 ms/token in the fast mode and 242.12 ms/token in the slow one, in the
same two-length diff, while vt_matmul_vec beside it moved 1.2%. At 2.54 GB of
lm_head weights that is 205 GB/s versus 10.5 GB/s. Mean TTFT is unchanged across
the modes (6.21 to 6.55 s over all 28 legs), so it is neither the load nor
prefill. Whatever selects that state is a bigger lever than every remaining
Vulkan kernel optimisation combined.

CORRECTNESS. The load width repartitions K across lanes, so it changes the
answer's low bits exactly as the tactic itself does and is gated on token
exactness, not on an NMSE bound. New case "vt_matmul_vec selects its load width
and row count from the shape" asserts the SPECIALIZATION VALUES the dispatch
built, not the numbers: every value of both axes computes the same dot product to
within this kernel's tier, so a tolerance test cannot see the optimisation at all
and would still pass if the predicate silently stopped selecting it. It pins the
shipped default and the whole degradation ladder (K = 2 mod 4 halves the width,
odd K falls to one element, an f32 activation declines), and carries numeric
checks including the LAST output row, where a wide-load or row-count mistake at
the end of a dispatch leaves every earlier row right. Scratch mutation: reverting
kGemvPackDefault to 0 turns it red (6 of 33 assertions), restoring it green.

GATES, all on GB10 unless noted. test_vulkan_backend 30/30 (2366 assertions) on
GB10 and 30/30 (1823) on llvmpipe -- one case and 37 assertions more than the
previous 29/1786 because of the new case. test_backend_cross_device 11/11 (132).
test_opt_paged_engine with VLLM_CPP_DEVICE=vulkan 6/6 prompts token-exact (96/96
tokens), 0 declines. gen-vulkan-spirv.py --check clean at pinned glslang 16.5.0.
All nine (rows, pack) arms verified numerically on llvmpipe. check-public-doc-
tables and check-env-doc still report the same three and twelve pre-existing
findings they report on 93852c2, none of them in this change.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude-Code:claude-opus-5 [Claude Code]
@mudler
mudler merged commit a3ead66 into main Aug 9, 2026
rohitpaul pushed a commit to rohitpaul/vllm.cpp that referenced this pull request Aug 9, 2026
Each of mudler#184, mudler#185 and mudler#186 was measured against `93852c28` in isolation, so none
of their numbers described a tree carrying all three. STATUS.md was deliberately
left at the CONSERVATIVE 4.24 -- the largest single lever -- rather than a sum,
because adding independently-measured deltas would have been inventing a number.
This is the measurement that replaces it.

METHOD. `git archive` of merged main `81ea01f0` to dgx, `vt_matmul.comp` md5
verified identical on both sides. Fresh Release configure. 8 wall-clock legs with
the page cache dropped before each and `flock $HOME/gpu.lock` held, then a
two-length GPU-timestamp diff (output-len 36 minus 4, over 32 decode tokens) so
prefill and one-time costs cancel. Qwen3.6-27B bf16, 1 prompt, 32-in, c1.

MEASURED, 8 legs, ALL CLEAN: TPOT 232.18 to 234.07 ms, decode 4.27 to 4.31 tok/s,
MEDIAN 4.285, spread 0.8%. Zero bimodal collapses.

MEASURED, per decode token:

  shader                                before   merged   delta
  vt_matmul_vec                          214.1    210.1    -4.0
  vt_matmul (lm_head)                    12.43    11.57   -0.86
  vt_rms_norm -> vt_rms_norm_wide         7.97     1.57   -6.40
  TOTAL GPU                              240.3    227.7   -12.6

Wall 233.0 ms, so host is 5.3 ms/token. Every lever reproduced its own claim on the
merged tree: vt_rms_norm_wide at 0.0123 ms/call is exactly what its row reported,
and the other two deltas match theirs inside the leg spread.

A FIRST ATTEMPT WAS INVALID AND IS RECORDED, because the failure is reusable. It
read 0.75-1.27 tok/s. VLLM_CPP_VULKAN defaults to AUTO, which resolves to OFF --
Vulkan is opt-in so it cannot register into gate builds -- and the fresh configure
omitted -DVLLM_CPP_VULKAN=ON. The options had been copied from the reference
build's CMakeCache.txt through `grep | head -15`, and the alphabetical list ended
at TRITON_TARGET, exactly one line before VULKAN. My own truncation hid the flag.
Three tells were already in the output and are the cheap check: no `[vt vulkan]`
lines despite VT_VULKAN_DISPATCH_STATS=1, no `[vt reference-tier]` lines, and
"Asynchronous scheduling is ENABLED" where every valid Vulkan run reports it
disabled. Same family as this campaign's stale-binary false greens, in the opposite
direction -- a false catastrophe rather than a false pass.

A CORRECTION TO THIS CAMPAIGN'S ROOF ARITHMETIC. mudler#186 established that GB10 does
not reach its theoretical 273 GB/s, by running a known-good streaming kernel on the
identical byte count: 230.3 GB/s. That correctly retires the 9.3 ms lm_head floor.
It does NOT invalidate the layer-GEMV percentages, which MEASURE 243-248 GB/s --
above 230.3 -- so 230.3 is a ceiling for THAT SHAPE (k=5120, n=248320, one 2.54 GB
buffer), not a device ceiling. Why one shape's ceiling sits ~7% below the same
kernel's on layer weights is UNEXPLAINED, and it is the same lone buffer the 20x
bimodal collapse attaches to.

WHERE THE REMAINING 3.1 ms IS. llama.cpp Vulkan is 4.35 tok/s = 229.9 ms/token on
the same 50.89 GiB weights on this box. vt_matmul_vec is now 92% of our GPU time at
248.0 GB/s, and its lever is CLOSED with the binding constraint identified as DRAM
bandwidth on a ~50 GB working set streamed once per token. The named residuals are
lm_head (219.8 GB/s, 95.4% of its own shape's measured ceiling, worth ~1.3 ms if it
could reach 248) and host at 5.3 ms.

Also refreshes .agents/NOW.md in the same change and appends the dated checkpoint
below the enforced marker in .agents/state.md, per the handoff contract.

KNOWN RED, unchanged and pre-existing: docs/STATUS.md remains over its shrink-only
ratchet (this change shrinks it by 3 more chars).

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude-Code:claude-opus-5 [Claude Code]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants