Skip to content

opencl: quant lm_head / decode GEMV and medium-batch GEMM optimizations (speculative decoding/MTP) - #26477

Draft
wanghqc wants to merge 14 commits into
ggml-org:masterfrom
qualcomm:hq/quant-gemv-opt-intel-ready-r0730
Draft

opencl: quant lm_head / decode GEMV and medium-batch GEMM optimizations (speculative decoding/MTP)#26477
wanghqc wants to merge 14 commits into
ggml-org:masterfrom
qualcomm:hq/quant-gemv-opt-intel-ready-r0730

Conversation

@wanghqc

@wanghqc wanghqc commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR has comprehensive decode-side optimizations on K-quant dense models for Adreno GPUs, including optimization for skinny GEMM that are essential for speculative decoding and MTP (multi-token prediction):

  • A tiled lm_head/embed GEMV layout.
  • A cooperative-K medium-batch q4_K/q6_K GEMM to lower register pressure
  • A multi-row f16 decode GEMV (attention projections + lm_head)
  • A q4_K MUL_MAT+GLU decode fusion.
  • Every default is gated on measured, per-device data, and each piece has an env override.

Fleet defaults vs upstream, Adreno 840 (A8X), Qwen3-4B-Q4_K_M:

delta pairs order bias
tg128 +16.99% (16.91 vs 14.45 t/s) 4/4 +0.03%
pp512 −0.00% (neutral) 3/4 +0.01%

Per switch, against the route each replaces:

switch A8X (840) X2E (X2-90)
tiled lm_head/embed GEMV +11.9% tg128 (6/6 pairs) +6.9% tg128 (4/4 pairs)
cooperative-K medium-batch GEMM +85.8 / +84.4 / +83.6% at pp2 / pp4 / pp8 (12/12)

Note the wins live in the decode GEMV and the medium-batch GEMM, not long prefill.

  • This is a K-quant dense-decode win
  • MXFP4/MoE models and Q8_0 builds are structurally untouched.

Additional information

Gating:

  • The tiled defaults are on for X2E and A8X specifically;
  • The A7X regresses on the tiled layout and is excluded. Env forces either way.
  • The q4_K split-K GEMV is X2E-only:
    • ungated it measured 840 −1.3% / 850 −20.0%.
    • With the gate, both parts keep their existing path.
  • The flat-GEMV large-m escape stays opt-in
    • it is slower than the multi-quad route that now covers the same long-vocab shapes (A8X −10.3%, X2E −3.7%).

Which models see the gain.

Gated on a q4_K/q6_K lm_head/embed (tiled additionally needs vocab ≥ 32768, ne00 ≥ 512, and for q6_K vocab % 128 == 0; the cooperative-K GEMM needs a q4_K GEMM at n_q ∈ [2..8]).

Scanned over a 108-GGUF local model set: 65 eligible, 43 not.

  • Benefits:
    • Q4_0/Q4_K_M quants of Qwen3 (1.7B/4B), Qwen3.5-4B (incl. MTP),
    • gemma-4 (E2B/E4B/12b/26B-A4B), gemma-3n-E4B,
    • Llama-3/3.2, DeepSeek-R1-Distill-Qwen — the mainstream dense-decode set.
  • Does not benefit:
    • every -Q8_0 build (24 — the lm_head stays Q8_0);
    • F16/BF16/Q4_0/F32 lm_heads (7, including both gpt-oss-20b MXFP4 builds — gpt-oss gets none of this, it contains zero q4_K/q6_K tensors);
    • q6_K vocab not a multiple of 128 (6, e.g. gemma-3-4b at 262208);
    • vocab < 32768 (3); ne00 < 512 (2).

Intel NEO GPU

  • The rms+rope fusion precondition also fixed a previously-undetected garbage path on Intel NEO: RMS_NORM_MUL_ROPE 8/72 → 72/72.
  • This is not an Adreno-only cleanup.

Requirements

wanghqc added 13 commits July 6, 2026 12:56
Adreno decode- and verify-path matmul optimizations for K-quant weights, plus
two fusions and a multi-row f16 GEMV.

lm_head / long-vocab GEMV: four-output-per-work-item q4_K and q6_K variants
that share one activation read across four output rows, and a tiled-wide
q4_K/q6_K decode GEMV for the long-vocab lm_head/embed, including a GPU path
for the q6_K lm_head that previously fell back to the CPU.

Spec/MTP verify: multi-column (N=2..4) verify GEMVs for q4_K/q6_K, so the
draft-token verify lm_head runs one dispatch instead of N separate GEMVs.

Decode GEMV bandwidth: split-K-across-workgroups q4_K GEMV for the small-M
attention projections, with the K-split as a launch parameter capped by the
kernel's real max workgroup size. At the original 4-wave split the reduction
order is unchanged, so results are byte-identical.

Medium-batch GEMM: a cooperative-K q4_K/q6_K GEMM for the verify batch regime
that stages the activation in local memory.

Fusions (default on, opt-out env): FFN gate+up q4_K GEMV with the GLU in one
decode dispatch, and rms_norm + mul(weight) + add(residual) in one kernel. Both
are byte-identical to the unfused path on the shapes they fire.

Multi-row f16 decode GEMV: register-blocks several output rows per subgroup for
the f16 attention projections and lm_head, cutting activation re-reads.

Each optimization has a GGML_OPENCL_* opt-out or opt-in. test-backend-ops
MUL_MAT and MUL_MAT_ID report 0 FAIL on the affected quant types.
…Adreno build

The kernel_convert_block_q{4,6}_k_tiled_ns members are declared under
GGML_OPENCL_USE_ADRENO_KERNELS but were registered unconditionally, so the
non-Adreno build failed to compile. Guard the registration to match the
declaration and the use sites.
ggml_cl_mul_mat_q4_k_glu_fused is Adreno-only and aborts on the non-Adreno
path, but the fusion dispatch was ungated, so on other drivers the FFN
{MUL_MAT, MUL_MAT, GLU} subgraph fired and aborted at the first decode token.
Gate the dispatch with GGML_OPENCL_USE_ADRENO_KERNELS to match the executor.
kernel_gemv_noshuffle_q4_k_f32_glu reads the noshuffle image layout, which is
only produced at set_tensor time when use_adreno_kernels() accepts the weight
(ne0 >= 512 && ne1 >= 512). Below that threshold the weight stays in the plain
q4_K layout and the fused kernel misreads it. Require use_adreno_kernels() on
both projections, mirroring the existing use_q4k_tiled() carve-out.

Latent rather than shipping corruption: real FFN gate/up weights are far above
512x512. test-backend-ops MUL_MAT_VEC_FUSION 140/140 on Adreno X2-90 and X1-85.
… row stride

The multi-row f16 decode GEMV casts the src0 row pointer to half4, and to half8
in the register-blocked variants. The dispatch gate only requires ne00 % 4 == 0,
which constrains the elements in a row, not the byte stride between rows:
nb01/nb02/nb03 and the view offset of a strided src0 are unconstrained, so a row
can land 2-byte aligned and the vector load is then undefined behaviour.

kernel_mul_mat_f16_f32_mrow now checks its own row pointer and falls back to the
scalar loop. The register-blocked variants have no scalar path, so the host
selects them only when the offset and every row stride are 8- or 16-byte
aligned.

No functional or performance change on any shape dispatched today: every mrow
shape in test-backend-ops is already aligned, so the guard is a no-op there.
MUL_MAT 925/0 on the Adreno X2-90, unchanged.
…r call sites

Upstream added a stride parameter to get_scale_min_k4 so it can read a
row-interleaved scales layout. The kernels this branch adds use a row-major
contiguous layout, so they pass stride = 1, which is identical arithmetic to
the previous 7-argument form.

Without this the OpenCL program fails to build at runtime ("too few arguments
to function call, expected 8, have 7") and the whole backend drops out. The
host build cannot catch it: embedded .cl sources are compiled by the driver on
device, so only test-backend-ops on real hardware surfaces it.

Adreno 840: 8189/8189, 0 FAIL, where the backend previously failed to
initialise.
… to win

Split-K buys GPU time by spending an extra kernel launch: every eligible GEMV
also enqueues a reduce over the per-slice partials. Launch cost is a per-device
property, and the +3.36% the gate was built on does not travel. Measured tg32:

  Adreno X2-90   +3.36%   gemma-4 E4B
  Adreno 840      -1.3%   Qwen3.5-4B-Q4_K_M
  Adreno 850     -20.0%   Qwen3-1.7B-Q4_K_M

The kernel is not at fault. On the 850 split-K makes the GPU strictly faster -
this GEMV drops from 43.7 to 34.0 us/call - and still costs a fifth of decode,
because each added reduce dispatch runs ~550 us of host round-trip against
2.7 us of GPU work.

Default on for X2E only, where it is measured; X1E is unmeasured and therefore
excluded. GGML_OPENCL_Q4K_GEMV_SPLITK forces either way. test-backend-ops:
Adreno 840 8189/8189 and Adreno 850 MUL_MAT 926/926, both 0 FAIL.
…med)

The previous commit excluded X1E from the q4_K split-K default as unmeasured
and invited widening once a datapoint existed. It now exists and it says do not
widen: Adreno X1-85, Qwen3.5-4B-Q4_K_M, tg32, -0.7% - neutral to slightly
negative, the same direction as the Adreno 840.

Comment only, no behaviour change. Recorded in the source because the previous
wording actively invited someone to widen the gate.
The 64-row tiled q4_K/q6_K lm_head layout regresses the A7X hard, so the
default now keys on the generations with measured evidence instead of being
unconditional. GGML_OPENCL_{Q4K,Q6K}_GEMV_TILED forces either way; the convert
and every dispatch site read the same predicate.
The set_tensor convert stores q4_K scales row-interleaved and the base
GEMV/GEMM kernels read that layout, but the splitk, mc3, glu-fused, cok, r1,
kimg and o4 variants still addressed scales as row-major contiguous with
stride 1. Each computed wrong values on any multi-superblock K, on every
device, whenever it was dispatched; the op-test suite never reaches these
variants, so only end-to-end decode showed it.

Address scales as sb*12*M + row with stride M in all seven kernels.
The large-m escape routes vocab-scale q4_K/q6_K lm_head weights to the flat
GEMV instead of the noshuffle GEMV. Its rationale, that gemv_noshuffle drops
for large M, predates the o4 kernel added earlier in this series, which now
serves the same long-vocab shapes and is faster. Measured on Qwen3-4B-Q4_K_M,
q6_K lm_head 151936x2560, tg128, flat vs o4:

    Adreno 840     -10.3%
    Adreno X2-90    -3.7%

Park the escape behind GGML_OPENCL_FLAT_LARGE_M=1 for both types and let
large-m weights take the noshuffle GEMV, which is upstream's routing for these
shapes.
The o4 lm_head/embed GEMVs dispatch ceil(ne01/4 / 64)*64 quads, so any vocab
with ne01 % 256 == 128 spawned 32 tail quads whose unguarded vstore4 wrote 128
rows past dst - test-backend-ops trips its dst sentinel wherever the o4 path is
the default (ne01 >= 32768). Guard the store; the tail lanes still run the loop
so the subgroup broadcasts stay uniform.
get_tensor has a restore path for every other Adreno weight layout (noshuffle,
trans4_ns) but none for the tiled one, so reading a tiled weight back returned
tiled bytes reinterpreted as block_q4_K/q6_K. test-backend-ops builds its CPU
reference by copying the weights out of the backend, so it compared a correct
GPU result against a reference computed from garbage, and the tiled GEMVs
looked like they miscomputed at multi-superblock K.

Measured on the Adreno 840 with the tiled kernels on, before this change: the
convert writes exactly the layout it documents, the GEMV reproduces the ggml
reference on all 32768 rows, and dst is correct at end of graph, while a
read-back of the weight differs from the source in 9254238 of 9437184 bytes.

Add the inverse pack for both types. Also require use_adreno_kernels in
use_q4k_tiled/use_q6k_tiled: the convert gate only tested ne01, so a weight the
GEMV would decline could still be converted to the tiled layout and then read
by the generic kernel as plain SOA.

With the read-back correct, the X2E/A8X default set earlier in this series
stands on its own measurements. Qwen3-4B-Q4_K_M (q6_K lm_head 151936x2560),
tg128, tiled vs o4:

    Adreno 840     +11.9%   (16.93 vs 15.14 tok/s)
    Adreno X2-90    +6.9%   (35.24 vs 32.87 tok/s)

Measure this one on a cold device: these kernels are far more clock-sensitive
than the o4 route they replace, and on a heat-soaked Adreno 840 the ranking
inverts to a 4-5% loss. The A7X regresses on this layout and is unaffected.

test-backend-ops -o MUL_MAT, this commit against the same binary with the
tiled kernels forced off: Adreno 840 919/919 both ways, Adreno X2-90 918 OK /
0 FAIL both ways. Greedy output byte-identical to the tiled-off arm on both.
@github-actions github-actions Bot added ggml changes relating to the ggml tensor library for machine learning OpenCL Issues specific to the OpenCL backend labels Aug 2, 2026
kernel_gemv_noshuffle_q8_0_f32 puts one output row per lane and splits K
only across the subgroups of a single workgroup, so M is the sole source
of workgroup parallelism. gpt-oss-20b's K and V projections are M=512 =
8 workgroups on a 16-CU X2: half the device idle, and the kernel
measures 48 GB/s where the M=2880/4096 projections in the same decode
graph reach 122-123.

Add a split-K variant mirroring the q4_0/q4_K ones already here -- each
(kslice, subgroup) pair reduces a disjoint set of K-blocks into a
per-slice partial, reusing kernel_gemv_splitk_reduce_f32 to sum them.
Gated to M<=1024 (larger M already fills the compute units and the extra
reduce dispatch only costs).

Enabled where it is measured to win, matching the q4_K split-K gate
directly above: X2-90 +2.8% tg32 @D4096 on gpt-oss; Adreno 840 (12 CU)
NEUTRAL on Llama-3.2-3B-Q8_0 (+0.5% @d0, 0.0% @D4096 -- its K/V
projection is M=1024 = 16 workgroups, which already fills 12 CUs, so
there is no under-fill to recover). Unmeasured on X1E/A7X/A6X, and the
q4_K split-K measured -0.7% on X1E, so the default is not widened on
absence of evidence. GGML_OPENCL_Q8_GEMV_SPLITK forces either way.

The new kernel is placed BEFORE the base kernel in the .cl: on A6X no
kernel may be defined after one that uses a subgroup builtin.

Verified: test-backend-ops MUL_MAT 918 OK / 0 FAIL, greedy output
byte-identical with base-twice and fix-twice controls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ggml changes relating to the ggml tensor library for machine learning OpenCL Issues specific to the OpenCL backend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant