Repository navigation
Exp 2026 09 03 KV Cache Quantization
Verdict: quantising the KV cache costs ~3.6× on the attention slope on this platform, and a
mismatched (type_k, type_v) pair costs a further ~1.6-1.8× on top. The cost is dequantisation inside
the flash-attention inner loop — it is not memory bandwidth (halving the bytes made it slower) and
not Mali-specific (q8_0 with Vulkan disabled shows the same penalty as TurboQuant on the GPU).
TurboQuant remains a capacity feature, not a performance one.
Filed as llama.cpp-rockchip#7 and oRKLLM#1.
Attention had been observed at 82-87% of prefill time at 4-6k tokens, with an arithmetic intensity of 2.0 FLOP/byte before query-block reuse against an RK3588 machine balance of ~15 — so the working hypothesis was memory-bound. Prediction: cutting KV bytes 2× (f16 → q8_0) should cut the attention term roughly in half, making KV quantisation a prefill speedup, not just a memory saving.
Measured: it went 4.2× the other way. The hypothesis is dead. FA tiles, so the no-reuse traffic estimate (~6.8 GB/s at n≈6k) was an upper bound the real kernel never approaches; there was no bandwidth wall to hit, and the dequant ALU cost dominates.
Radxa ROCK 5B (RK3588, 10.3.0.236), b10739-ork, ork-driver 1.0.105, governor performance
(DDR 2112 MHz / A76 2.4 GHz), cores 4-7, warm.
Model: Qwen3-1.7B-UD-Q4_K_XL int4 .orkpack (196 weights), ORK_QUANT=4 ORK_NO_STUB=1.
llama-bench -m <gguf> -p 400,2000,4000 -n 0 -r 3 -t 4 -b 512 -ub 512 \
-ngl 99 -dev ORK -fa on -ctk <K> -ctv <V>
env: ORK_QUANT=4 ORK_NO_STUB=1 GGML_DISABLE_VULKAN=1
Deviation from the standard config, stated explicitly: this uses -p 400,2000,4000 -n 0 rather than
the reference -p 512 -n 128, because the quantity under test is the attention slope — the per-token
cost of one extra token of context — which needs multiple lengths and does not need decode. Decode was
not measured at all (see Open below). No librkllmrt row: the closed runtime exposes no KV-type control
and does not consume .orkpack, so it is not an applicable arm here.
slope below = (ms/token at 2000 − ms/token at 400) / 1600.
| type_k | type_v | pp400 | pp2000 | pp4000 | slope | vs f16 |
|---|---|---|---|---|---|---|
| f16 | f16 | 227.07 ± 1.18 | 130.19 ± 0.12 | 84.56 ± 0.04 | 0.00205 | 1.0× |
| q8_0 | q8_0 | 167.20 ± 0.17 | 56.27 ± 0.04 | 26.77 ± 0.03 | 0.00737 | 3.6× |
| q8_0 | f16 | 161.25 ± 0.08 | 40.36 ± 0.02 | — | 0.01161 | 5.7× |
| f16 | q8_0 | 145.72 ± 0.22 | 35.55 ± 0.08 | — | 0.01329 | 6.5× |
Two penalties that stack:
- Quantising at all: 3.6× (matched q8_0 vs matched f16). Dequant in the FA inner loop.
-
Mismatching the pair: a further ~1.6-1.8×. Both mixed pairs are worse than both matched pairs —
including worse than quantising both sides. Signature of falling off a specialised
(type_k, type_v)FA kernel onto a generic path.
At 4000 tokens f16 → q8_0 is 84.56 → 26.77 tok/s, −68%.
turbo needs ggml_vk_set_mode() before model load, which only oRKLLM's addon calls, so llama-bench cannot
reach it. Run on a throwaway oRKLLM instance (fresh DB, n_ctx=4096), KV types verified live via
[CPAR-DBG] rather than assumed:
| tokens |
q8_0 K + turbo3 V (type_k=8 type_v=43) |
turbo3/turbo3 (43/43) |
f16 |
|---|---|---|---|
| 402 | 91.60 | 69.57 (−24%) | 244.66 |
| 1975 | 41.10 | 40.80 | 136.30 |
| 3943 | 24.48 | 24.96 | 85.59 |
- The mixed-pair penalty does NOT transfer to turbo. turbo-on-K is much worse at short prompts and a wash at long ones — the turbo codec (inverse WHT + polar dequant) is far heavier than a q8_0 scale-multiply, and K is read in QKᵀ. A 2-point fit projected an 11% matched win at 4000; measured 2%, inside noise. Projection recorded as wrong.
-
oRKLLM's
never lead with turbo Kpolicy is therefore right on speed as well as quality and should not be "fixed" on the assumption that matched pairs always win. - turbo3 vs f16 served:
7.67 + 0.00842·nvs3.23 + 0.00214·nms/token — 2.4× constant, 3.9× slope. - CPU load average during the two: 1.3-2.0 (turbo3) vs 3.6 (f16) — the CPU idles while the Mali works.
| control | result |
|---|---|
n_ctx |
(q8_0,turbo3) 91.60/41.10 at 4096 vs 90.47/41.04 at 32768 — allocation size is irrelevant
|
[BATCH-DBG] logging overhead |
same config with/without ORKLLM_LOGIT_DEBUG: 91.60 vs 90.47 — ≤1% |
| flash attention actually on |
flash_attn_type AUTO, resolves to enabled (llama-context.cpp:519-523; the device-mismatch disable branch is commented out) |
| threads |
cpar.n_threads = n_threads_batch = 4, matching the pinned big cluster |
| GPU involvement |
q8_0 arm runs GGML_DISABLE_VULKAN=1 — same penalty, so not the GPU
|
llama.cpp-rockchip#6 reported oRKLLM's worker
running the same i8 chain ~2.6× slower than a bare process, with a large exclusion table. That gap was
this KV setting — the served path was on (q8_0, turbo3) while every probe ran f16/f16; the exclusion
table varied n_ctx but never the KV dtypes. With KV matched:
| tokens | served f16 | llama-bench f16 |
|---|---|---|
| ~400 | 244.66 | 227.07 |
| ~2000 | 136.30 | 130.19 |
| ~4000 | 85.59 | 84.56 |
Served is equal or marginally faster; the attention slopes match (0.00214 vs 0.00206). There is no
serving overhead. #6 closed. Every mechanism its table excluded (orkd, op routing, addon binary, runtime
version, affinity, priority, cgroups, fork+IPC, stdio, stub, node hosting, reload, shape re-warm) was
correctly excluded — the variable nobody checked was oRKLLM's own saved per-model KV setting, which is
invisible in the logs. Read [CPAR-DBG] before comparing two processes.
ggml-ork's supports_op comment cites a ~365 µs/matmul NPU submit floor and reasons from it that M=1
decode is a net loss ("~197 submits/token × 365 µs ≈ 72 ms before any compute benefit"). That figure — and
Exp-2026-06-12 Submit-Floor Profiling, which measured ~205-496
µs/matmul and cross-checked it against the closed RKNN kernel — describes the blocking mcworker submit
path (run_i8).
It does not describe the non-blocking doorbell (ork_submit_async / ork_dyn_begin_mc,
ork_mm_run_sliced, run_i4_bchain_db). This wiki already carried the correct figures — they were on
record before this experiment and I failed to cite them:
| record | measurement |
|---|---|
Exp-2026-06-21-Mixture-of-Experts-PC-Chaining-and-Scheduler-Programming (tools/chain_probe, RK3588, S=8 int8 256×64, 200 iters) |
8 separate submits 58.1 µs/matmul → 1 chained submit 5.2 µs/matmul, 11.21×, "zero CPU intervention" |
| Exp-2026-07-16-Dynamic-Submit-API-and-Doorbell | NONBLOCK doorbell progress overhead is "a few µs/op"; the ~167 µs linger default is the NONBLOCK submit, not a per-op cost |
ggml-ork.cpp:398 |
chained doorbell at M=1 measured at 1.18× the per-channel cost |
i.e. ~5 µs per op inside a chain, and no per-op submit cost at all while the doorbell is spinning — against 58.1 µs/matmul unchained and the ~365-496 µs blocking-path floor.
Consequence: any argument that multiplies 365 µs by a submit count is measuring the wrong path — which
includes the justification for ORK_MINM and for routing M=1 decode to CPU. Both are calibrated against a
cost the doorbell does not pay, and the crossover is worth re-deriving with the doorbell forced.
ggml-ork.cpp:6062 already anticipates this ("the thread-free doorbell should overlap for free"; the
blocking rendezvous "lost net (profiled)").
It also strengthens the heterogeneous-overlap case (no dispatch tax while spinning, so pipelining layer i's FFN on the NPU against layer i−1's KV work on the GPU costs nothing per op; the blocker is that ggml's scheduler assigns whole ops to backends with a sync at each boundary and has no multi-stream model). It does not change the decomposition case: flash attention cannot be split across processors because its efficiency comes from never materialising the n×n intermediate, and the dequant cost lives inside that fused loop where no dispatch scheme reaches it.
| what | where |
|---|---|
| KV type selection |
pool.js — kv_cache_quant → kv_type_k/kv_type_v; forces kv_type_k=q8_0 whenever V is turbo/q8_0 ("never lead with turbo K") |
| KV types applied |
orkllm_llama_napi.cpp init_model — kvTypeFromStr (f16=1, q4_0=2, q5_1=7, q8_0=8, turbo2/3/4=42/43/44) → llama_context_params.type_k/type_v
|
| turbo GPU scoping |
pool.js sets vk_mode:'turboquant' + n_gpu_layers:0; addon calls ggml_vk_set_mode before model load (nullptr-guarded) |
| verification knob |
ORKLLM_LOGIT_DEBUG=1 → [CPAR-DBG] prints resolved n_ctx/type_k/type_v/flash_attn/n_threads
|
| attention op gate |
ggml-ork.cpp:9749 ork_supports_op_inner — FLASH_ATTN_EXT declined unless ORK_ATTN; batched MUL_MAT (ne[2]>1) and SOFT_MAX likewise |
| harness |
scripts/bench-prefill-chunking.mjs (oRKLLM) + llama-bench
|
Nothing was reverted; every arm is reachable by setting kv_cache_quant / kv_type_k / kv_type_v.
- Decode is unmeasured. Decode is bandwidth-bound on KV reads, so a 3× smaller cache could be a decode win even while it is a prefill loss. Until measured, "KV compression is slow" is a prefill-only claim.
-
Is the CPU q8_0 FA path missing a NEON specialisation? 4× is high; x86/AVX-512 typically pays 10-30%.
May belong upstream in
ggml-org/llama.cpprather than the fork. -
ORK_ATTN(attention on the NPU) unmeasured. Attention is ~70% of prefill at 4000 tokens even on f16, at ~56 GFLOP/s (~36% of the A76 cluster's fp32 NEON peak). This is the largest remaining prefill lever, well ahead of anything KV-side.