feat(kvcache): 8-bit DSV4 window/compressed KV behind --kv-cache-dtype - #113
Open
gdevenyi wants to merge 1 commit into
Open
feat(kvcache): 8-bit DSV4 window/compressed KV behind --kv-cache-dtype#113gdevenyi wants to merge 1 commit into
gdevenyi wants to merge 1 commit into
Conversation
DeepSeek-V4's attention KV is one MLA latent row per token, so unlike a
separate-K-and-V pool there is a single buffer to quantize and a single scale
array beside it. `--kv-cache-dtype fp8_e4m3` stores the window and compressed
pools as fp8 plus one fp16 scale per 32 elements along head_dim: 1.0625 bytes
per element against bf16's 2.
The precision cost is small because the model already rounds this KV onto the
e4m3 grid before it reaches the pool -- act_quant_fp8_inplace(kv[..., :-rd], 64)
runs immediately before every store_window and before the compressor's scatter.
Re-blocking 64 -> 32 moves values within the grid they already live on. The rope
tail is the exception: those dims are genuine bf16 and this rounds them.
Scope, and what is deliberately left alone:
- window_pool and cmp_pool are quantized together. The gather selects a pool
BASE pointer per column and issues one tl.load, so the two must share a row
layout -- they quantize together or not at all.
- idx_pool stays bf16. The indexer's K decides top-k *selection*, so an error
there perturbs routing rather than a value, and it is 3.7% of the pool.
- The compress-state rings stay fp32. They are accumulators, not storage, and
at 53% of the pool they are the larger target -- separately.
Both sparse-attention kernels dequantize the gathered tile before the dot (the
scale varies along head_dim, the reduction axis, so it cannot be folded in
after). QUANT=0 compiles the existing bf16 path unchanged.
Measured on an RTX 6000 Ada (sm_89), DeepSeek-V4-Flash, offload MoE, TP=1,
--memory-ratio 0.96, with --moe-cache-auto sizing the split:
num_pages expert slots single 8-concurrent
bf16 560 2551 18.81 34.88
fp8_e4m3 670 2571 18.31 30.18
+19.6% +20 -2.7% -13.5%
So it buys 19.6% more KV capacity for the same VRAM and costs throughput: the
dequant is on the critical path and there is little to amortise it against at
these batch sizes. Whether that trade is worth taking depends on whether a
deployment is context-bound or throughput-bound; it is off by default.
A code planted ~9000 tokens back was recalled under both dtypes.
The cost model has to agree with the pool: --moe-cache-auto divides the VRAM
budget by bytes-per-KV-element to pick the expert-cache size, and it resolves
before any pool exists, so kv_quant is stamped on dsv4_args at config
resolution rather than at pool construction. Getting that ordering wrong is
silent -- the pool shrinks and the saving never reaches the budget.
Gated at config time to a DeepSeek-V4 checkpoint, head_dim a multiple of 32,
and native fp8 (sm_89+, since a float8 pointer is illegal in triton below it).
17 tests: store kernel against a torch reference, quantized attention against
the bf16 reference on both the prefill and split-k decode kernels, masked
columns, cmp_counts bounds, saturation, and the already-on-grid regime.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
gdevenyi
added a commit
to gdevenyi/FreeToken
that referenced
this pull request
Aug 23, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
--kv-cache-dtype fp8_e4m3stores DeepSeek-V4's window and compressed KV pools as fp8 plus one fp16 scale per 32 elements alonghead_dim: 1.0625 bytes per element against bf16's 2.DSV4's attention KV is one MLA latent row per token — K and V are the same slab — so unlike a separate-K-and-V pool there is a single buffer to quantize and a single scale array beside it.
Why the precision cost is small
The model already rounds this KV onto the e4m3 grid before it reaches the pool.
act_quant_fp8_inplace(kv[..., :-rope_head_dim], 64)runs immediately before everystore_window, and the compressor does the same before its scatter. Re-blocking 64 → 32 moves values within the grid they already live on.The rope tail is the exception: those dims are genuine bf16 and this rounds them for the first time.
Scope, and what is deliberately left alone
window_poolandcmp_poolquantize together. The gather selects a pool base pointer per column and issues a singletl.load, so the two must share a row layout. They quantize together or not at all.idx_poolstays bf16. The indexer's K decides top-k selection, so an error there perturbs routing rather than a value — a disproportionate risk for 3.7% of the pool.That last row is why this caps out where it does: more than half of DSV4's KV memory is not KV.
Measurements
RTX 6000 Ada (sm_89), DeepSeek-V4-Flash,
--moe-backend offload, TP=1,--memory-ratio 0.96,--moe-cache-autosizing the KV/expert split. Single-stream cancels prefill by timing a 64-token and a 256-token generation and taking192/(t256-t64).Tensor parallelism decides whether this is a trade or a win, so both are here.
TP=1 — buys capacity, costs throughput
auto(bf16)fp8_e4m3TP=2 — wins on both axes
Two alternating pairs, order reversed between them:
auto(bf16)fp8_e4m3Both
fp8runs beat bothbf16runs on both axes, in both orderings.The likely reason it wins at TP=2 and loses at TP=1 is the expert cache: TP=2 holds 5746 slots against TP=1's 2551, so it takes far fewer expert misses. That leaves the dequantization a smaller share of the step, and lets the extra KV pages cut preemption at eight concurrent streams. That mechanism is plausible, not isolated — I have not run the experiment that would separate cache-hit-rate from KV headroom, so treat it as the explanation that fits rather than a demonstrated cause.
Practically: measure before enabling it on a single-GPU deployment. It stays off by default.
A code planted ~9,000 tokens back was recalled under both dtypes, at both TP settings.
One ordering trap worth knowing about
The cost model has to agree with the pool.
--moe-cache-autodivides the VRAM budget by bytes-per-KV-element to pick the expert-cache size, and it resolves before any pool exists, sokv_quantis stamped ondsv4_argsat config resolution rather than at pool construction.I got this wrong first: with the flag set at pool-build time the pools shrank correctly and the budget never noticed, so
num_pagesstayed at 560 and the saving went nowhere. The failure is silent — the only symptom is that the numbers do not move. Worth a look during review.Gating
Config-time, with a clear message rather than a wrong-dtype tensor reaching a kernel:
head_dima multiple of 32, the quantization blockTesting
17 tests in
tests/kvcache/test_dsv4_kv_quant.py: the store kernel against a torch reference, quantized attention against the bf16 reference on both the prefill kernel and the split-k decode kernel, masked (-1) columns,cmp_countsbounds, saturation, and the already-on-the-grid regime that matters in practice.On
mainwith this PR: 17 passed;tests/dsv4 tests/kvcache tests/engine359 passed, 1 skipped.Relationship to #103
Independent — this builds on
mainand shares no code with #103, whose machinery is shaped around separate K and V pools and which explicitly rejects the MLA/DSA/DSV4 families. The two do collide on the flag name--kv-cache-dtype, which I think is correct: it should be one flag, and whoever merges second can union the gating.Note also that #103's store kernel has the same fp8 rounding bug I avoided here — triton lowers
fp32 -> float8e4nvas a one-sided double-round, so the cast needsround_e4m3in front of it. I have commented there with the details.round_e4m3is already onmain, so neither PR needs #85.🤖 Generated with Claude Code
https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun