Skip to content

feat(kvcache): store the KV cache as fp8 e4m3 codes (--kv-cache-dtype… - #354

Open
ArqAlice wants to merge 1 commit into
FlashML-org:mainfrom
ArqAlice:feat/fp8-quantization
Open

feat(kvcache): store the KV cache as fp8 e4m3 codes (--kv-cache-dtype…#354
ArqAlice wants to merge 1 commit into
FlashML-org:mainfrom
ArqAlice:feat/fp8-quantization

Conversation

@ArqAlice

@ArqAlice ArqAlice commented Sep 2, 2026

Copy link
Copy Markdown

… fp8)

One (token, kv head) row of K and of V becomes head_dim e4m3 codes plus ONE fp32 symmetric scale, in a code buffer with exactly the geometry of the 16-bit KV buffer -- only the element type changes. That halves the bytes per cached token (the scale sidecar costs 4/head_dim of it back, ~3% at head_dim 128), and it is what lets Qwen3.8-Flash-Next serve a 1M-token context on this card.

Codes are kept in a plain uint8 buffer on EVERY architecture, and the fp8e4nv type never appears in a kernel signature. Both ways of choosing that per target failed on real hardware and are recorded here so nobody reopens them: the compile-time fp8-native probe (e4m3_compat.e4m3_native_cx) answers the question independently from the host that allocated the buffer and disagreed with it on sm_100, and branching on a pointer's element type is NOT statically pruned -- triton still type-checked the dead arm, whose int mask fill is illegal against an fp8 pointer ("cannot cast int32 to fp8e4nv", raised at CUDA graph capture). What remains is the software encode/decode that already runs wherever the fp8 type is unavailable and is bit-exact per e4m3_compat's header, so the cache holds the same bytes and produces the same numbers on every card (docs/cli.md).

  • server/args.py, engine/config.py: --kv-cache-dtype {auto,bf16,fp8}, refused at startup for the pools and backends that cannot apply the row scales (attention/init.py: BackendInfo.supports_fp8_kv) rather than ignored.
  • kernel/triton/kv_quant.py: fused quantize+scatter -- one launch under CUDA graph capture, where the slot ids arrive as a device tensor.
  • kvcache: unit_bytes() counts codes plus the scale sidecar, so ft ctl stats and cache --kv N follow the smaller footprint, and rebuild reallocates the scale buffers alongside the codes (mha, hybrid-SWA and QSA pools).
  • kvcache/base.py: pool.dtype is the COMPUTE dtype -- what store_kv receives and what a backend sizes its scratch with -- while pool.store_dtype is what the buffer holds. Reporting codes as dtype handed e4m3 to QSA's 16-bit indexer and died compiling qsa_mqa_paged; the contract is now asserted at backend init and in the kernel wrapper. QSA's block-selection keys stay 16-bit: only the selected K/V rows are read back as codes.

Tested on: sm_100, 148 SMs, Linux; 524,480 fp8 KV tokens = 6.47 GiB,
Qwen3.8-Flash-Next with: ft serve --kv-cache-dtype fp8 -> 1M-token context.
Covered by tests/kernels/test_kv_fp8.py, tests/kernels/test_qsa_fp8.py,
tests/kernels/test_triton_attention.py, tests/kernels/test_e4m3_compat.py,
tests/kvcache/test_mha_pool_fp8.py, tests/kvcache/test_qsa_pool_fp8.py and
tests/engine/test_kv_quant_config.py (CUDA-gated; not run on the Windows
development box, which has neither triton nor pytest installed).

Not included here, on purpose: unifying the two fp8-native probes (triton's cache-key walk rejects a constexpr function that defers to a host one, so warn_if_probes_disagree() reports the disagreement instead), and a hardware decode fast path on sm_89+ (that needs a constexpr flag threaded from the host plus the matching AOT variants, since testing the dtype does not prune).

… fp8)

One (token, kv head) row of K and of V becomes head_dim e4m3 codes plus ONE
fp32 symmetric scale, in a code buffer with exactly the geometry of the 16-bit
KV buffer -- only the element type changes. That halves the bytes per cached
token (the scale sidecar costs 4/head_dim of it back, ~3% at head_dim 128), and
it is what lets Qwen3.8-Flash-Next serve a 1M-token context on this card.

Codes are kept in a plain uint8 buffer on EVERY architecture, and the fp8e4nv
type never appears in a kernel signature. Both ways of choosing that per target
failed on real hardware and are recorded here so nobody reopens them: the
compile-time fp8-native probe (e4m3_compat.e4m3_native_cx) answers the question
independently from the host that allocated the buffer and disagreed with it on
sm_100, and branching on a pointer's element type is NOT statically pruned --
triton still type-checked the dead arm, whose int mask fill is illegal against
an fp8 pointer ("cannot cast int32 to fp8e4nv", raised at CUDA graph capture).
What remains is the software encode/decode that already runs wherever the fp8
type is unavailable and is bit-exact per e4m3_compat's header, so the cache
holds the same bytes and produces the same numbers on every card (docs/cli.md).

- server/args.py, engine/config.py: --kv-cache-dtype {auto,bf16,fp8}, refused at
  startup for the pools and backends that cannot apply the row scales
  (attention/__init__.py: BackendInfo.supports_fp8_kv) rather than ignored.
- kernel/triton/kv_quant.py: fused quantize+scatter -- one launch under CUDA
  graph capture, where the slot ids arrive as a device tensor.
- kvcache: unit_bytes() counts codes plus the scale sidecar, so ft ctl stats and
  cache --kv N follow the smaller footprint, and rebuild reallocates the scale
  buffers alongside the codes (mha, hybrid-SWA and QSA pools).
- kvcache/base.py: pool.dtype is the COMPUTE dtype -- what store_kv receives and
  what a backend sizes its scratch with -- while pool.store_dtype is what the
  buffer holds. Reporting codes as dtype handed e4m3 to QSA's 16-bit indexer and
  died compiling qsa_mqa_paged; the contract is now asserted at backend init and
  in the kernel wrapper. QSA's block-selection keys stay 16-bit: only the
  selected K/V rows are read back as codes.

Tested on: sm_100, 148 SMs, Linux; 524,480 fp8 KV tokens = 6.47 GiB,
  Qwen3.8-Flash-Next with: ft serve --kv-cache-dtype fp8  ->  1M-token context.
  Covered by tests/kernels/test_kv_fp8.py, tests/kernels/test_qsa_fp8.py,
  tests/kernels/test_triton_attention.py, tests/kernels/test_e4m3_compat.py,
  tests/kvcache/test_mha_pool_fp8.py, tests/kvcache/test_qsa_pool_fp8.py and
  tests/engine/test_kv_quant_config.py (CUDA-gated; not run on the Windows
  development box, which has neither triton nor pytest installed).

Not included here, on purpose: unifying the two fp8-native probes (triton's
cache-key walk rejects a constexpr function that defers to a host one, so
warn_if_probes_disagree() reports the disagreement instead), and a hardware
decode fast path on sm_89+ (that needs a constexpr flag threaded from the host
plus the matching AOT variants, since testing the dtype does not prune).
@MT-z

MT-z commented Sep 2, 2026

Copy link
Copy Markdown

Thanks for building this -- a smaller KV cache is the single thing that would help this box most,
so I pulled the branch and ran the suite on an Ada card. Nine tests fail that pass on main, and I
wanted to let you know before digging any deeper.

I could not tell from the description whether the suite has been run anywhere yet -- the note says

CUDA-gated; not run on the Windows development box, which has neither triton nor pytest installed

and the sm_100 figures look like they come from the serving run (ft serve --kv-cache-dtype fp8
reaching a 1M-token context). I mention it because four of the nine do not depend on the GPU at
all
: they are host-side Python errors that fire before any kernel launches, and one of them still
reproduces with the GPU hidden entirely (CUDA_VISIBLE_DEVICES=""). So I suspect those four are not
an Ada thing and would show up wherever you run them -- which is the main reason I am reporting now
rather than assuming it is my card.

Setup. RTX 4090 24 GB (sm_89, 128 SMs), i9-14900KF, driver 595.84 (CUDA 13.2), CUDA toolkit
13.3, triton from the repo's pin. This PR rebased onto main (6eca2d7): one commit, no conflicts.
Counts below are from running each test in its own process -- see item 3 for why that matters.


Group A -- fails without touching the GPU (4)

tests/kernels/test_triton_attention.py::test_triton_backend_stores_kv_and_matches_reference
tests/kernels/test_triton_attention.py::test_triton_backend_passes_attention_sinks_to_paged_kernel
  AttributeError: 'FakeKVCache' object has no attribute 'k_scale'   (attention/triton.py:160)

Both are upstream tests from 3af9d90 and pass on main. attention/triton.py:160 now calls
self.kvcache.k_scale(layer_id) unconditionally; FakeKVCache in the test is a plain CPU stub
with k_cache/v_cache/store_kv and no scale accessors, so it raises before any launch. The
second one still fails with CUDA_VISIBLE_DEVICES="" (the first skips on the CUDA gate). Any pool
object built by a caller that predates the scale sidecar hits the same line, and bf16 is still the
default, so a getattr/store_dtype guard there would cover both the tests and real callers.

tests/kvcache/test_mha_pool_fp8.py::test_layer_ids_remap_applies_to_scales_too
  ValueError: KV layer id 3 outside [0, 3)   (kvcache/mha_pool.py:67)

Reads like the remap indexes the scale buffers with the unmapped layer id.

tests/kernels/test_kv_fp8.py::test_encoder_inverts_the_grid_through_the_scale_one_path
  ValueError: Pointer argument (at 1) cannot be accessed from Triton (cpu tensor?)

The scale-one path reaches the kernel with a CPU tensor.

Group B -- the store kernel mis-reads a strided qkv slice (1, the interesting one)

tests/kernels/test_kv_fp8.py::test_codes_match_the_reference_quantizer_and_reconstruction_is_close
fails with 2684 code mismatches of 3072. Every mismatch is in V; K is byte-perfect. The test's own
comment names the condition -- "the row pitch is then wider than the row, which the store kernel
must honour" -- and that is exactly it. Same data, three ways (its own tokens=8, heads=3, dim=128,
seed 1):

how K and V reach the store K mismatches V mismatches
qkv.split() views, V scaled by 0.01 (as the test does) 0 / 3072 2684 / 3072
the same tensors .contiguous().clone()d first 0 / 3072 0 / 3072
qkv.split() views, but K scaled by 0.01 and V by 5.0 0 / 3072 0 / 3072

Making the input contiguous fixes it, and swapping which slice carries the small values fixes it
(K is then the small one and stays correct), so it is neither the e4m3 encoding nor V's magnitude:
it is the third slice of the qkv buffer, at offset 2 * heads * dim, read with the wrong
stride. Supporting detail: the wrong bytes are not a permutation of the right ones
(got.sort() != exp.sort()), i.e. the kernel reads other rows rather than mis-rounding; no
subnormals are involved (0 elements with |x| < 2^-6 in the failing set); and the count scales with
the tensor (8x2x64: 894/2048, 4x2x64: 382/1024, 8x3x64: 1333/3072, 8x2x128: 1774/4096,
16x2x64: 1905/4096).

Worth knowing: the second assertion in that same test passes -- dequantised error is 0.035
against a 0.08 tolerance -- so a check that only looks at reconstruction quality does not catch
this. That may be why the serving run looked healthy.

This is the one I would guess could be arch-specific (block shape or vector width making the
addresses coincide on sm_100), but I would not assume it.

Group C -- a device-side assert, and why the raw failure count misleads (1)

tests/kvcache/test_qsa_pool_fp8.py::test_store_kv_writes_the_slot_the_attend_kernel_will_read
trips vectorized_gather_kernel: Assertion 'ind >= 0 && ind < ind_dim_size' -- an out-of-range
gather index. That is sticky: every later CUDA call in the same process fails with
device-side assert triggered, so it drags unrelated tests down with it.

run failures in tests/models/qwen4_exp/test_qsa_backend.py
that file alone 3 (the same 3 that fail on main here)
immediately after test_qsa_pool_fp8.py 10
all changed test files in one pytest run 21 total, vs 9 real

The extra 7 are collateral. A host-side bounds check on that index would turn this into a readable
Python error instead of a poisoned context -- and would keep a single bad index from making the
suite look far worse than it is.

Group D -- remaining (3)

  • tests/kernels/test_qsa_fp8.py::test_fp8_codes_match_the_bf16_cache_bit_for_bit[16-2-64]:
    fp8 QSA attend diverged from the same data in a bf16 cache (max diff 1.953e-03).
  • tests/kernels/test_triton_attention.py::test_extend_paged_attention_decodes_fp8_scales[True|False]
    (new in this PR). I did not dig past Group B, since a store writing wrong V codes would explain
    a decode mismatch.

And the good news: tests/engine/test_kv_quant_config.py is 15/15 green. The flag, the config
plumbing and the per-backend refusals all behave; what is broken is under them.

Not your bug

tests/kernels/test_e4m3_compat.py::test_forced_emu_matches_native also fails, but it fails on
main on this box too (one of 7 standing failures here), so I have left it out of the counts. And
the item you deliberately left out of this PR is not what is biting: both native probes agree here
(e4m3_native() and e4m3_native_cx() are both True on sm_89, warn_if_probes_disagree() silent).


I have not measured throughput or context length -- with V codes wrong there is nothing worth
benchmarking yet. Happy to re-run anything, bisect further, or test a fix; an Ada box is the one
thing I can usefully offer here. For context on why I am keen: on this card the KV/expert trade-off
is steep (Qwen3.8-Flash-Next at 262144 KV tokens leaves 829 expert slots and 2.73 tok/s; at 131072
it leaves 2465 slots and 6.92 tok/s), so halving KV bytes buys real throughput.

Written with AI assistance; every number above was measured on my hardware and I can
reproduce it.

@Kaempferia

Copy link
Copy Markdown

Environment

Result on PR's own tests

42 passed, 5 failed (the 5 that actually execute the new Triton kernels):

FAILED tests/kernels/test_kv_fp8.py::test_codes_match_the_reference_quantizer_and_reconstruction_is_close
FAILED tests/kernels/test_kv_fp8.py::test_encoder_inverts_the_grid_through_the_scale_one_path
FAILED tests/kvcache/test_mha_pool_fp8.py::test_layer_ids_remap_applies_to_scales_too
FAILED tests/kvcache/test_qsa_pool_fp8.py::test_store_kv_writes_the_slot_the_attend_kernel_will_read
FAILED tests/kvcache/test_qsa_pool_fp8.py::test_factory_threads_kv_quant_into_the_qsa_pool

Clearing ~/.triton/cache and re-running does not change anything.

Narrowed it down (not a precision problem — stores are lost)

Driving quantize_kv_to_cache directly with a 2×2 matrix of
{contiguous vs wide-pitch (qkv slice) source} × {V normal vs V ×0.01 subnormal},
T=8, H=3, D=128, out_loc = arange(T), counting code slots left at 0x00:

source V normal V ×0.01
contiguous (stride(0) == H*D) K ok, V ok K ok, V ok
wide pitch (stride(0) == 1152 != H*D) K ok, V: 5 of 8 slots never written K ok, V: 3 of 8 slots never written

Key observations:

  1. The K path is bit-exact against a torch-side RNE reference in all four
    cases
    (kernel == (x/scale).clamp(±448).to(float8_e4m3fn), 0/3072 code
    mismatches), so the calling convention, the environment and the quantization
    math are fine.
  2. Only the V store loses slots, and only with a wide source pitch — the
    exact layout real attention backends hand over (_store in
    tests/kernels/test_kv_fp8.py exercises precisely this).
  3. The number of dropped slots varies with the input data (3 vs 5 for two
    inputs through the identical kernel), which smells like a race / undefined
    ordering rather than a deterministic indexing bug.
  4. The original test failure signature matches: V codes are 0x00 from some
    token onward while the oracle expects real codes (got[-8:] = [0]*8,
    expected[-8:] = [94, 112, 233, 220, 234, 118, 113, 248]).

The minimal driver (matrix above) is ~30 lines around quantize_kv_to_cache
with alloc_codes((T,H,D)) and an arange out_loc — happy to share it, or
to test a fix; the box is set up and each run takes seconds.

@MT-z

MT-z commented Sep 3, 2026

Copy link
Copy Markdown

Independent confirmation from the other end of the hardware range: I see the same thing on
sm_89 (RTX 4090, 128 SMs, driver 595.84 / CUDA 13.2, nvcc 13.3.73, torch 2.11.0+cu130 and
triton 3.6.0 -- the same versions you ran
, so the toolkit and the GPU generation are the only
differences between our two boxes; this PR rebased onto main 6eca2d7). Same shape of result -- K byte-exact, V wrong, and only when the source has a wide row
pitch -- so this is not Ada-specific and not Blackwell-specific. That also retires the caveat I
put in my own report, where I had left open the possibility that it was an Ada gap.

Where our two runs agree, with the test's own parameters (tokens=8, heads=3, dim=128, seed 1):

how K and V reach the store K mismatches V mismatches
qkv.split() views, V scaled by 0.01 0 / 3072 2684 / 3072
the same tensors .contiguous().clone()d first 0 / 3072 0 / 3072
qkv.split() views, K scaled by 0.01 and V by 5.0 0 / 3072 0 / 3072

The third row is the one I would add to your 2x2: swapping which slice carries the small values
also fixes it. K is the second slice of the qkv buffer and V the third, so with the magnitudes
swapped K becomes the "V-like" small tensor and stays correct. Combined with your finding that the
count varies with the data, that points away from "the small-magnitude tensor is handled wrong"
and toward the third slice's addressing specifically.

Two more data points from my side, one of which does not obviously fit the dropped-writes model:

  1. It is non-deterministic even with identical input, and there are two failure modes at
    once. Running the exact same build (same seed, same tensors, five times in one process):

    run V mismatches left 0x00 non-zero junk fully-zero (token,head) slots
    1 2684 / 3072 1920 764 15 / 24
    2 2684 / 3072 1920 764 15 / 24
    3 2671 / 3072 1152 1519 9 / 24
    4 2671 / 3072 1152 1519 9 / 24
    5 2655 / 3072 16 2639 0 / 24

    K was 0/3072 in all five. So it is not only that stores are lost: codes that were never
    expected are also written (got.sort() != exp.sort() every run), and the balance between the
    two shifts run to run -- by run 5 almost nothing was left at zero and nearly every mismatch was
    junk. That strengthens your read: your "the count varies with the data" holds even with the
    data held fixed, so the ordering is what varies, not the input.

  2. The count scales with the tensor, so it is not an edge lane or a tail block:
    8x2x64: 894/2048, 4x2x64: 382/1024, 8x3x64: 1333/3072, 8x2x128: 1774/4096,
    16x2x64: 1905/4096 (K is 0 in every one of these).

One thing worth flagging for whoever picks this up: the second assertion in
test_codes_match_the_reference_quantizer_and_reconstruction_is_close passes even while the codes
are wrong -- dequantised error came out at 0.035 against its 0.08 tolerance on my box. A check that
only looks at reconstruction quality will not catch this, which may be why the serving run in the
PR description looked healthy.

Happy to run your minimal driver here for a second architecture, or to test a fix -- an Ada box is
what I can offer.

Written with AI assistance; every number above was measured on my hardware and I can
reproduce it.

@Kaempferia

Copy link
Copy Markdown

Thank you for the detailed follow-up, and for taking the time to verify this independently — between the two machines this now spans sm_89 and sm_120, which is exactly what whoever fixes it needs.

Two things in your data move this forward. The magnitude-swap row (K small / V large on the same views → clean) is a sharper probe than my original 2x2: it helps rule out a magnitude-dependent path and narrows things toward the third slice's addressing — since with the swap K becomes the "V-like" tensor and stays correct. And the five-run table — the zero-slot count drifting 15 → 9 → 0 while junk codes grow, with the input held fixed — is what rules out a data-dependent cause and points at undefined ordering, which fits the fingerprint we saw on our side too.

One point I would ask to be carried into any fix, because it plausibly explains how the serving run in the PR description could look healthy: the reconstruction assertion only checks dequantised error (0.035 < 0.08 here) and passes while the codes are wrong. Validation of a fix needs to happen at the codes level, not by perplexity-style error.

A corroborating observation from our side, offered only in case it shortens the search — a hypothesis rather than a verified mechanism: we see the same split in environment, not just run-to-run. Inside the pytest process the test fails consistently (2684/3072, K clean), while an identical standalone script — same build, same seed, same tensors, codes compared against a torch-side RNE reference — ran clean in 10/10 repeats (0/3072 each). With the kernel and inputs byte-identical, the trigger appears to depend on process memory layout (allocator state when v_cache is allocated), which would also explain the within-process drift in your five-run table.

Both machines remain available to test a fix — ours covers the Blackwell end (sm_120, RTX 5090 D), yours the Ada end. We would be glad to run any candidate patch through the PR's own tests plus a service-level smoke check on our side.


About this reply: like your own note, drafted with AI assistance. Every number above was measured on our hardware (RTX 5090 D, sm_120) — please take the measurements over the phrasing, as AI-assisted wording can misrepresent the intended meaning across languages.

@MT-z

MT-z commented Sep 3, 2026

Copy link
Copy Markdown

Thanks — and likewise for the careful write-up. It is genuinely useful having a Blackwell box on
the other end of this; between the two of us the fix has somewhere to be checked before it lands.
Your note about taking the measurements over the phrasing landed with me too: same situation here,
and it is a good habit to state.

Your allocator-state hypothesis is worth pinning down, because on sm_89 it comes out the other way
round — and the disagreement is itself informative.

My five-run table was not from pytest. It was a standalone script: importlib the test module,
call _store directly, compare against the same torch-side RNE reference. So on this box the
standalone path fails too, and it is the one that drifts:

sm_120 (yours) sm_89 (mine)
inside pytest fails, consistent 2684/3072 fails, 2684/3072 in all three runs
standalone script 10/10 clean (0/3072) fails: 2684, 2671, 2655

(I just re-ran the pytest side three times to be sure of that row: 2684 every time, no drift.)

So the stable-vs-drifting halves are swapped between our two machines. That does not contradict
"depends on process memory layout" — it is what that hypothesis predicts if the layout that happens
to be safe differs per allocator history. What it does rule out is a simpler reading someone might
take from your result alone: that the bug needs pytest, or that a standalone reproducer is a clean
baseline to validate a fix against. On Ada it is neither. A candidate patch that only clears the
standalone script here would still be broken.

If it helps narrow it: my standalone run allocates the code buffers through the test module's own
alloc_codes immediately before the store, with nothing else on the device except the CUDA context
(no server, ~490 MiB used, one process). That is about as quiet as the allocator gets on this box,
and it still drifts run to run inside a single process — the 15 → 9 → 0 zero-slot progression came
from five consecutive _store calls with the tensors rebuilt identically each time. Whatever the
ordering depends on, it moves within a process here, not just between processes.

Fully agreed on validating at the codes level. For whoever picks this up, the concrete check that
catches it and that the current test does not: compare got.sort() against exp.sort() as well as
elementwise. Every failing run here had the multiset differ, which is strictly stronger than
counting mismatches — it says values that were never expected got written, so a fix that merely
reduces the mismatch count has not necessarily fixed anything.

Ada box stays available for any candidate patch, on the PR's own tests plus the standalone matrix.

Written with AI assistance; every number above was measured on my hardware (RTX 4090, sm_89)
and I can reproduce it.

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.

3 participants