Repository navigation
[rdna2] INT8 KV cache for the RDNA2 backend: map the path, the measured read path, and int8 + FP16 block scales - #71
BlivionIaG wants to merge 4 commits into
Conversation
Rewrites the proposal around the measured winner and records what it costs. The proposal becomes asymmetric INT4 with the 4-bit zero-point packed into the low mantissa bits of the FP16 block scale - the trick int4_per_token_head already plays with an FP32 scale, at block granularity. Measured 20.302 dB on outlier rows at 4.500 bits/value, against 18.326 dB for the symmetric format at the same cost and 14.570 dB for the shipped one. Adds the sections the measurements forced: - What we lose: the trade against INT8 + E8M0 and INT8 + FP16 in dB and in memory, plus the two costs that do not show up in an SNR table (arithmetic per byte, and the second byte of scale). - The E8M0 question: how it is stored, why its power-of-two step cannot be repaired by a second level or by a finer block, and why E4M3 is the better one-byte buy. The OCP floor rule's clipping is measured, and the finding that round-trip SNR prefers the clipping rule is why H1 now has to be measured on attention output error. - How it would run on gfx1030: the scale is applied per element rather than per partial dot, because the MX idiom is a tensor-core idiom and the accumulator traffic costs head_size^2/k against head_size here; the zero-point correction is hoisted out of the element loop on both the QK and the PV side. - Prior work: PR #71 and the humanwork-legacy INT8 dispatch at 3baecdb, which is this proposal's most likely host and its clearest benchmark, and the reason H3 is re-scoped. Every number in the document is checked against the committed script. Signed-off-by: BlivionIaG <kev29lt@gmail.com> Assisted-by: Mavis <kev29lt@gmail.com> Co-authored-by: Mavis <kev29lt@gmail.com>
|
Mapped this against Good news: the kernels are already here
So the legacy What is missing to make it run
The defect to fix first
k_dst[HEAD_DIM * block_size + slot_in_block + d] = ...Under the declared layout —
Consequences: 17 of the 19 distinct addresses in a page's scale region are written by more than one thread, and planes k_dst[(HEAD_DIM + d) * block_size + slot_in_block] = ...The same expression appears for the value cache at :4630. The reader is not the inverse of the writerThe legacy Python bridge in k_scale_per_slot = kv_cache[0, :, :, _hs:_hs + 4, :].view(torch.float32).squeeze(2)
Three different scale index conventionsWorth settling in one place before writing code, because the three parties disagree:
Physical and logical indexing differ whenever a block is partially filled, which is the normal case with chunked prefill. The port has to pick one and make the writer, the reader and the page spec agree on it. The evidence gap
That is a throughput measurement. It cannot distinguish a correct scale from a corrupted one, and with the writer bug above the scales are corrupted — a dequantised K of ~0 still produces plausible tokens per second. No test in the legacy tree exercises the writer-to-reader path, so the numerical correctness of the legacy INT8 path was never established. The port therefore needs a correctness test that has never existed, not just a re-run of the throughput bench. Concretely: write K/V through Smaller items
Suggested order of work
Worth keeping the two apart: this PR is the host for a quantized KV cache in the RDNA2 backend, and the format question is a separate one. Landing INT8 first — and measuring it properly — is the cheaper step, and it gives the INT4 work a real baseline to beat. |
Tracking branch for a backlog item; the plan is in the draft PR.
… path The FA-RDNA2 int8 kernels are already in the tree but unreachable: the two read ops are not registered, the RDNA2 backend rejects every quantised KV dtype, and the writer puts the scale bytes at the wrong address so the scales it writes race with each other and two of the four scale planes are never written at all. The writer also assumes a cache geometry that vLLM does not allocate for this dtype. This documents the mapping against the current base, the measured ISA verdict on how to read an int8 cache on gfx1030 (the v3 packed-load + VGPR dequant + fdot2 pattern is the implementation choice; sdot4 was measured and rejected for the QK dot at production occupancy; the PV product was never measured), and the int8 + FP16 block-scale format that goes with it - which at k=64 costs exactly what the shipped per-token-head scale costs and buys +3.01 dB on the K side on the reference codec's synthetic rows. No code in the serving path changes; the V620 cells in TESTPLAN.md are empty. Co-authored-by: Mavis <kev29lt@gmail.com> Signed-off-by: BlivionIaG <kev29lt@gmail.com>
3e21bd1 to
25e1293
Compare
|
The draft body is now a full proposal rather than a six-line backlog stub, and the branch carries Corrections and additions to my earlier mapping comment on this PR:
Two things worth flagging for whoever reviews this:
The |
|
Prior-work note added: KVarN (Huawei CSL). The body now answers KVarN explicitly: It is not a duplicate of this PR — it is a different method at a different It is worth recording because it reaches the same conclusion this PR does, Nothing in the proposal changes. |
…36bb9 The doc was written against 30d6e82 (2026-10-21). Since then int4_fp16_block shipped on RDNA_ATTN (PR 138), the int8 prefill op was registered for a workspace test, and every fa_rdna2.cu line moved. Re-read each cited line, write every citation as a full path:line so check_citations.py resolves all 62, and rewrite the statements the INT4 landing made stale: the backend gate and supported dtypes, the cache-geometry choice (INT4 took option a and shows the page-spec and view pieces), the V620 evidence, the PR 134 native-INT8 wording that PR 134 itself corrected, and the codec that is now on this branch. TESTPLAN: fix the writer cite, the codec location and the commands (bench throughput has no --concurrency, a duplicated --kv-cache-dtype, a .venv path that does not exist). Signed-off-by: BlivionIaG <kev29lt@gmail.com>
Refresh 2026-11-11 (merged
origin/rdna_extras1136bb952binto the branch, no force)The doc was written against
30d6e82dcd(2026-10-21). The branch was 2,166 commits behind, the merge was clean, and the doc was not current. The citation checker reported "21 citations, 0 missing" on the old text, but it only proves that a line exists, not that it says what the sentence claims. In the old text, nearly everyfa_rdna2.cucite had drifted by 13 to 250 lines. I re-read each cited line (new commitd98dfffc22). The body below is updated to match.What was stale and is now rewritten in the README (and TESTPLAN where noted):
int4_fp16_blockpremise. The README said RDNA_ATTN "rejects every quantised KV dtype", listedsupported_kv_cache_dtypesasauto/float16/bfloat16, and called PR [explore] INT4 + FP16 block scales for the RDNA2 KV cache (NVFP4-inspired) #134 an unmerged proposal whose codec "is not on this branch". PR [explore] INT4 + FP16 block scales for the RDNA2 KV cache (NVFP4-inspired) #134 (explore doc) and PR feat(rdna2): INT4 KV cache with FP16 block scales, natively read on the QSA path #138 (implementation,8dec4520e4) are merged:rdna_attn.py:764-769listsint4_fp16_block, and its own branch in__init__,forwardand the writer runs before the FP16-only gate (rdna_attn.py:329-330), which every other quantised dtype still hits.KVQuantMode.INT4_FP16_BLOCKis atkv_cache_interface.py:61; the codec is atdocs/explore/kv-int4-fp16-scales/reference_codec.py. The V620 numbers for INT4 are in the commit message of8dec4520e4(3.24x tokens, 16k/1k c=1 decode +11.2 %, c=8 aggregate decode -12.4 %, PPL 4.98 vs 4.94), so "FP8 is the only dtype measured on V620" is also gone.int4_fp16_blockchose option (a) and shows what an int8 port needs: acustomize_specbranch that reserves the scale bytes (rdna_attn.py:861-876; the generic branch at:877-888sizes content with no scale bytes) and backend-owned cache views (int4_fp16_kv_layout.py:44,:72). The sizing statement for int8 is marked[INFERENCE]; nothing has allocated an int8 cache on this backend.a948ee01de(2026-11-04) registeredfa_rdna2_prefill_paged_varlen_int8for a workspace test (torch_bindings.cpp:464-476).fa_rdna2_decode_paged_int8still has no schema orops.hdeclaration;reshape_and_cache_int8_rdna2is registered and unused. The shimfa_rdna2_backend.pyhas only the two INT4 wrappers.fa_rdna2.cu:4905/:4907(was:4681/:4683). The int32slot_mappingcheck moved to:4933, the duplicateops.hdeclarations to:505/:815.1e-6and rounds half away from zero before the int8 store; clamp[-128, 127]. The doc said "clamped to[127, -128]".997e37aa1a) and the merged README says what this doc says.int4_load_halfintosK/sV,fa_rdna2.cu:513-519) as the shipped INT8 branch, so an N1 rewrite would apply to both dtypes.bench throughputhas no--concurrency,--kv-cache-dtypewas passed twice,.venv/bin/pythondoes not exist). They now usebench serve --max-concurrency 1and one--kv-cache-dtype.path:line, socheck_citations.pynow covers 62 citations (was 21; the bare:4457-style cites were invisible to it). It reports 62 checked, 0 missing, and I read each one against its sentence.Re-run on 2026-11-11: the README's Reproducing the numbers snippet against the in-tree codec gives all eight SNR rows and bit counts exactly as published (33.415 / 44.022 at 8.250 bits for the shipped format; 33.415 / 44.027 at 8.125; 36.425 / 44.764 at 8.250; 39.475 / 45.594 at 8.500). pre-commit passes on the touched files. No GPU was used and no hardware measurement exists for INT8; the V620 cells in TESTPLAN stay empty.
Still a draft: this is a design document whose own text says a human has to defend every claim before it leaves draft. The branch also carries an empty
wip: feat(rdna2): FA-RDNA2 int8/fp8 KV cachecommit (44616b40d2) that I did not touch (no history rewrite).The summary below is the original text (against
30d6e82dcd). Its line references and its points 1, 2 and 4 are superseded by this section and by the README.Status: draft. This is a design document. Nothing in the serving path changes, no dtype is added, and every V620 cell in the test plan is empty on purpose. The long form is
docs/explore/kv-int8-native/README.md; this body is the summary.Why
INT8 KV cache is not a new capability in vLLM.
int8_per_token_headships (vllm/config/cache.py:54), the Triton backend serves it (vllm/v1/attention/backends/triton_attn.py:321), and the writer and its scale-cache views are tested (tests/quantization/test_per_token_kv_cache.py).What does not exist is a way to serve that dtype from the RDNA2 / FA-RDNA2 backend. The INT8 FA-RDNA2 kernels are already in this tree — and they are unwired, unreachable, and their writer is defective.
This PR replaces a six-line backlog stub with the mapping, the measured answer to "what is the cheapest way to read an INT8 cache on gfx1030", and the format that should go with it.
What is already in the tree
origin/rdna_extras@30d6e82dcd)vllm/config/cache.py:54,vllm/v1/kv_cache_interface.py:48,vllm/v1/attention/backends/triton_attn.py:321absmax/127per token-head)vllm/v1/attention/ops/triton_reshape_and_cache_flash.py:272[K(hs) | K_scale(4 B) | V(hs) | V_scale(4 B)]per (head, slot)vllm/v1/attention/backends/triton_attn.py:405-427csrc/rocm/fa_rdna2.cu:4457,:4270,:4699v_dot4c_i32_i8)csrc/rocm/w4a8_sdot4_rdna2.cuh:125-128(PR #93)What is missing or broken
csrc/rocm/torch_bindings.cpp:543-546, and the comment above it says why.vllm/v1/attention/backends/rdna_attn.py:283and:601-605.csrc/rocm/fa_rdna2.cu:4681/:4683put bytedof slotsinto planeHEAD_DIM, slots+d, instead of planeHEAD_DIM+d, slots. 17 of the 19 distinct scale addresses in a page are written by more than one thread, and planesHEAD_DIM+1..+3are never written at all.[2, num_blocks, H_kv, D+4, block_size]; vLLM allocates one packed tensor with the scale inline.split_kv_cache(vllm/v1/attention/ops/paged_attn.py:25-29) only resolves when the content dim is exactlyhead_size. This is the largest unknown in the port and it needs an explicit decision, not a patch.:443), the legacy bridge by physical(block, slot), vLLM by physical(block, slot, head). The fix is local: the decode kernel already has the physical block and slot at the load point..view(torch.float32)reinterprets the slot dimension as bytes.Tensorwhile every otherfa_rdna2op writes intoout; the writer wants an int32slot_mapping;csrc/rocm/ops.hdeclaresreshape_and_cache_int8_rdna2twice (:458,:763).The native INT8 read path
The ISA study in the parent project already measured this (
.omo/plans/rdna2-int8-isa-benchmark/isa-report.md, V620), so this PR does not guess:fp16_refv1v_dot2v2v_dot4c_i32_i8(sdot4)v3v_dot2Three conclusions:
v3, not the native int8 dot.sdot4was implemented and measured, and rejected for the QK dot at production occupancy ("we lose SDOT4's single-thread advantage when we parallelize across threads, which production does with 128 thr/CTA"). It is recorded here so it is not re-litigated.IS_INT8decode branch (csrc/rocm/fa_rdna2.cu:443) is a per-element scalar dequantise into an FP16 shared tile — thev1/v0family. The study: "The currentfa_rdna2_decode_paged_int8is leaving 30%+ on the table at the typical Qwen3.x head dim (128)."v_dot4c_i32_i8for PV. The study says so twice — "The PV (output) update is scalar in all variants… not measured here."Note that none of the FA-RDNA2 int8 kernels is a native INT8 dot today: the cache is INT8, the arithmetic is FP16 (
fdot2against an FP16 Q at:472-477). PR #134's description of this work as "native INT8 prefill and decode kernels" is not what the code does, and this PR says so explicitly.The format: INT8 + FP16 block scale
The INT4 sibling of this idea is PR #134. Extended to INT8 it becomes:
kvalues along the head dimension8 + 16/k→ 8.250 atk=64, 8.500 atk=32Measured with PR #134's
reference_codec.py(synthetic rows, not V620 measurements):int8_per_token_head(shipped: one FP32 scale per row)int8_fp16_blockk=128 (same granularity, FP16 scale)int8_fp16_blockk=64int8_fp16_blockk=32k=64the block scale pays for itself: the same 8.250 bits/value as the shipped format, +3.01 dB on the K side.Order of work
int8_per_token_headagainst FP16 on the V620 — NIAH, PPL, KV capacity, 16k/1k and 32k cells, with output-token parity against the FP16 baseline, not just throughput.v3_128pattern into the decode kernel and re-measure.int8_fp16_blockformat.Why this is not a duplicate
v_dot4c_i32_i8) on a different tensor (activations for a weight GEMM).arXiv:2606.03458, reference
implementation at huawei-csl/KVarN)
is the strongest published KV-cache method and is not a duplicate of this
PR, because it is a different method at a different budget: a Hadamard
rotation and an iterative dual-axis variance normalisation in front of an
asymmetric round-to-nearest step, with two scales and a zero-point per
group of 128 elements, at 2.375 bits/value for the 2-bit configuration and
3.375 for the released 4-bit-key preset. This PR is about the native
INT8 read path on RDNA2 - whether an integer dot can reach the KV cache
at all, given an FP16 query - plus an INT8 + FP16 block scale at 8.250
bits/value. The two share the observation that a KV quantiser's cost is
set by its read path, not by its bit width: measured on the same synthetic
tensor, KVarN's 2-bit configuration reads back at 12.1 instructions per
element against INT4's 8.1, because it applies two scales and a zero-point
to every element, while its rotation is free (it rotates the query in and
un-rotates the output). That is the same argument this PR makes about the
INT8 path, arrived at from the published method's own code.
Tests run
No serving-path code changes, so no model evaluation applies. What was run:
python3 docs/explore/kv-int8-native/check_citations.py # 21 citations checked against origin/rdna_extras, 0 missingcheck_citations.pyresolves everyfile:linereference in the README against the base commit and prints the cited source, so a stale reference is visible; it exits non-zero if one does not resolve. The fork's pre-commit hooks (ruff, markdownlint, SPDX, forbidden imports) pass.The reference-codec numbers come from PR #134's committed codec, reproduced by the snippet in the README's Reproducing the numbers section.
AI assistance
This draft was written with AI assistance. Every
file:linereference was checked against the base tree by a script that is committed here, and the numbers come from the committed codec rather than from the author. No hardware measurement was taken for this PR — the V620 cells inTESTPLAN.mdare empty on purpose, and a human has to review and defend every claim above before this leaves draft.