feat(kvcache): 8-bit KV cache (q8_0 / fp8_e4m3) behind --kv-cache-dtype - #103
feat(kvcache): 8-bit KV cache (q8_0 / fp8_e4m3) behind --kv-cache-dtype#103lucaspirola wants to merge 2 commits into
Conversation
Stores the KV cache in 8 bits plus an fp16 scale per 32 elements along head_dim
(1.0625 bytes/element vs 2), freeing VRAM for the MoE expert cache. Two schemes
share the scale tensor, store kernel and dequant path -- q8_0 (int8, s = max/127)
and fp8_e4m3 (s = max/448) -- so comparing them is a flag change, not a port.
- kvcache/quant.py: KVQuantSpec (storage dtype, block 32, torch reference
quantize/dequantize, effective bytes/element)
- kernel/triton/kv_quant.py: store kernel computing per-block max-abs and writing
the quantized buffer + scales
- kernel/triton/attention.py: dequant inside the four attention kernels behind a
QUANT constexpr (0 compiles the existing bf16 path unchanged); the scale varies
along head_dim, the reduction dim, so K/V dequantize to bf16 before the dot
- kvcache pools: parallel scale buffers, k_scale()/v_scale(), rebuild() realloc,
unit_bytes()/kv_cost() accounting on effective bytes
- server/args.py, engine: --kv-cache-dtype {auto,q8_0,fp8_e4m3} with gating
(triton backend only, head_dim % 32 == 0, supported pool families)
Tests: 53 new (round-trip vs torch reference, quantized attention vs the bf16
reference, pool sizing and hot rebuild, flag gating); the existing 33 triton
attention tests still pass.
Step 9 of tasks/todo.md is NOT done: no end-to-end validation on this host --
needle-in-246k, perplexity vs bf16, and the real expert-slot / tok-s gain are
unmeasured, so q8_0 vs fp8_e4m3 as the default is still an open question.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Je2rjENB9qJiiRGmNcnAct
|
Merged this into a deployment branch and ran it on 2x RTX 6000 Ada (sm_89), which is a different arch from the RTX 5080 you tested on. One bug, and the end-to-end numbers your "Not done" section asks for. A bug on sm_89: the fp8 store does not round to nearestTwo of your own tests fail here, and only the fp8 ones:
if IS_INT:
# Round half away from zero (what GGUF's Q8_0 does), then clamp -- the
# float->int cast truncates.
q = tl.where(q >= 0, tl.floor(q + 0.5), tl.ceil(q - 0.5))
q = tl.minimum(tl.maximum(q, -MAX_MAG), MAX_MAG)
tl.store(dst_ptr + ..., q.to(dst_ptr.dtype.element_ty))The int branch rounds because the cast truncates. The fp8 cast does not round to nearest either: triton lowers Fix is one line, mirroring what the int branch already does: else:
q = round_e4m3(tl.minimum(tl.maximum(q, -MAX_MAG), MAX_MAG))with End-to-end numbersQwen3.6-35B-A3B-FP8 (head_dim 256, GQA, 40 layers, 256 experts), TP=1,
The pool grows +88%, which is exactly the 2 / 1.0625 the format predicts, and aggregate throughput rises +27% because more requests fit without preemption. Single stream costs 5.8% for The expert-slot gain did not appear, for a reason specific to this model. 40 layers x 256 experts is 10,240 slots and
|
FlashML-org#113 and FlashML-org#103 both add --kv-cache-dtype and neither covers the other's pools, so the merge needs two resolutions git cannot make: - args.py auto-merged into TWO --kv-cache-dtype definitions. argparse rejects a duplicate option string at runtime, and git saw no textual conflict because the two landed in different parts of the file. Kept FlashML-org#103's, whose choices already cover both value sets (auto / q8_0 / fp8_e4m3); dropped mine. - _validate_kv_cache_dtype existed twice. Unified by routing on the pool family: a DSV4 checkpoint takes the DSV4 checks (fp8 only, head_dim % 32, native fp8), everything else falls through to FlashML-org#103's (triton backend, non-MLA, head_dim % BLOCK). q8_0 on DSV4 is now an explicit rejection rather than a wrong-dtype tensor reaching a kernel that only stores fp8. Deployment branch only.
…e flag string The unified gate short-circuited on kv_cache_dtype == 'auto' before reaching the non-DSV4 branch. FlashML-org#103's callers carry only the resolved kv_quant spec, so every one of its cases read as 'auto' and nothing was rejected -- its own gating tests caught it (9 failures, all DID NOT RAISE). Each branch now owns its early-out: DSV4 keys on the flag string (it also has to stamp dsv4_args for the cost model), everything else keys on the resolved spec. Deployment branch only.
The native fp32 -> float8e4nv downcast does not round to nearest everywhere: on sm_89 triton lowers it as a truncating fp32 -> fp16 -> e4m3 double-round, so values just above a grid midpoint collapse downward and disagree with the RNE torch reference (~0.4% of elements). Round explicitly with round_e4m3 before clamping, mirroring the int branch's rounding. Fixes the two fp8 reference-equality tests on sm_89. Co-Authored-By: Claude <noreply@anthropic.com>
|
Addressed the fp8 rounding bug — one-line fix mirroring the int branch's rounding, using else:
q = round_e4m3(tl.minimum(tl.maximum(q, -MAX_MAG), MAX_MAG))
Noted the two remaining open points — the "which regime" framing in the PR body and the #19 merge conflict (quant args inside the tile-ladder closures) — will address in follow-up. Co-Authored-By: Claude noreply@anthropic.com |
|
Ran this on a 16 GB card against a GGUF MoE, which is the case it helps most, and it is the SetupFreeToken 0.1.2 (PyPI wheel) with this PR and #185 applied to the installed tree, RTX 4080 The relevant property of this configuration: the routed experts total ~12.5 GiB against What it buys
2.24x decode at the same context, purely from handing 4.3 GiB back to the expert cache. CorrectnessThroughput numbers on a quantized KV are meaningless without a recall check, so: Exact recall at every depth at both sizes, including 95% depth in a 71k-token prompt — which Cost worth documentingPrefill is slower — the store-side quantize plus dequant-on-read is not free:
(The bf16 rows come from a 65536-token pool, so they also enjoyed a larger expert cache; NetOn this box the PR turns a 131072-token context from unusable (63 tok/s, worse than |
What
8-bit KV cache storage behind
--kv-cache-dtype {auto,q8_0,fp8_e4m3}, trading KVVRAM for the MoE expert cache.
Two schemes share one scale tensor, store kernel and dequant path —
q8_0(int8,
s = max/127) andfp8_e4m3(s = max/448, rounded to the e4m3 gridvia the RNE
round_e4m3helper before the native cast) — both 1.0625 bytes/elementvs 2 for bf16, an fp16 scale per 32 elements along
head_dim.kvcache/quant.py—KVQuantSpec(storage dtype, block 32, torch referencequantize/dequantize, effective bytes/element)
kernel/triton/kv_quant.py— store kernel (per-block max-abs → quantizedbuffer + scales)
kernel/triton/attention.py— dequant inside the four attention kernels behinda
QUANTconstexpr (0compiles the existing bf16 path unchanged); the scalevaries along
head_dim(the reduction dim), so K/V dequantize to bf16 before dotk_scale()/v_scale(),rebuild(),unit_bytes()/kv_cost()on effective bytes--kv-cache-dtypeflag with gating (triton backend only,head_dim % 32 == 0,supported pool families)
Tested on
NVIDIA RTX 5080 (16 GB), WSL2, driver 610.62, CUDA 13.0. Exercised in the same
run as the Laguna GGUF model (fp8 KV at 262144 tokens = 8.79 GiB vs 17.6 bf16,
which would not fit this card).
Results
tests/kernels/test_kv_quant.py,tests/kvcache/test_kv_quant_pool.py,tests/engine/test_kv_cache_dtype_gating.py), plus the 33 pre-existing tritonattention tests unchanged.
pool sizing/rebuild; flag gating.
What the win buys (and when it doesn't)
The KV shrink pays out in one of two ways, depending on whether the MoE expert
cache is already saturated:
(the original motivation).
more requests fit in the KV pool without preemption.
Community validation on 2x RTX 6000 Ada (sm_89), Qwen3.6-35B-A3B-FP8 (40 layers ×
256 experts = 10,240 slots), expert cache already saturated: the KV pool grew
+88% (305,730 → 575,493 slots = 2 / 1.0625) and 8-concurrent throughput rose
+27% (261.97 → 333.22 tok/s), at a 5.8% (fp8_e4m3) / 2.6% (q8_0) single-stream
cost. The expert-slot gain did not appear there — every expert was resident
before the KV shrank, so the freed VRAM had nothing to buy.
Not done
tasks/todo.md) is still open: needle-in-246k andperplexity vs bf16 are not yet captured here. Community numbers show greedy
divergence vs bf16 over five prompts of 3 lines (fp8_e4m3) vs 8 (q8_0), all
benign, so
q8_0vsfp8_e4m3as default remains a tie-break: fp8_e4m3 forclosest-to-bf16, q8_0 for raw speed.