Repository navigation
Conversation
Decode reads E4M3 codes as fp16 bit patterns (value/256, exact) pinned to the load layout, folds 256 into the row scales, and uses 16-key single-stage tiles with shorter/more splits. Prefill dequantizes the batch rows to bf16 once and reuses the bf16 extend kernel instead of re-decoding every key tile per query block.
This branch has not been deployed
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.
Follow-up to #897 (gfx1201 packed FP8 KV reads); related to #894 and #893.
#897 accelerates FP8 KV on RDNA4 only. On RDNA3 (gfx11),
fp8_per_token_headKV is functionally supported but is much slower than BF16 KV: on a Radeon PRO W7900D (gfx1100) servingQwen/Qwen3.5-0.8B, FP8 KV decode was 15% slower at 512 tokens and 55% slower at 64K, and 64K TTFT was 4.8x worse. This PR lists what RDNA3 needs and implements it: FP8 KV reaches TTFT parity with BF16 KV and decodes faster at long context (+20% at 64K), while keeping FP8's KV capacity (70K tokens: 0.41 GiB vs 0.80 GiB).Scope: gfx11 +
fp8_per_token_headonly. RDNA4 (including #897's packed reader), CUDA, BF16 KV and INT8 KV keep their current code; evidence below.Why the current FP8 path is slow on RDNA3
e4m3_native_cx()is false on HIP, sokv_tilerunse4m3_u8_to_f32and then.to(tl.bfloat16), which LLVM lowers to an integer RNE sequence (v_add3_u32,v_cmp_o_f32,v_cndmask). The generated D256 FP8 decode stage-1 contains 3846v_and, 3716v_lshr, 1313v_add3_u32and 1312v_cmp_o_f32instructions, against 80 WMMA instructions.kv_tile_stagedpins the decode on NVIDIA;_stageis a no-op on HIP).Changes (top commit only)
kv_f16_reader(fmt, device_index)inkv_rows.py:fp8_per_token_head,gcnArchNamestarts withgfx11, andFREETOKEN_FORCE_E4M3_EMUnot set._decode_grouped_stage1_kernel, new constexprF16_KV):kv_tile_f16turns each E4M3FN code into the fp16 bit pattern of value/256:((c & 0x80) << 8) | ((c & 0x7F) << 7). This is exact for every finite code (E4M3 subnormals become fp16 subnormals) and uses integer ops only. The 256 is folded into the row scales; Q is cast to fp16, so QK and PV run asv_wmma_f32_16x16x16_f16with FP32 accumulation.v_mov_b32inline-asm pin keeps the decode in the load layout, so each byte is decoded once rather than per operand replica. The pipeliner cannot predicate this op, so the path uses one stage.decode_kv_split_captakes an optionalkv_format, and the Triton backend sizes its decode scratch with the KV cache's format so the cap and the scratch agree, including Graph capture.extend_paged_attention): on gfx11 FP8,dequant_kv_rowsdecodes the batch's KV rows once into a transient BF16 buffer (row i = slotkv_indices[i]), then the unchanged BF16 extend kernel runs on identity indices. Each element is decoded once, not once per query block.kv_indices.numel() x kv_heads x head_dim x 4bytes (K+V BF16), about 134 MB per layer call for Qwen3.5-0.8B at 65,792 tokens. It is released after the call and does not change the KV pool size.kv_tile_f16inside_extend_attend_tile): 48.3 ms vs 35.1 ms per 2048-token chunk at a 62K prefix, so it was not kept.test_rdna3_kv_f16_reader_preserves_every_finite_fp8_codecompares all 256 codes bit-for-bit againsttorch.float8_e4m3fn/ 256 in fp16, including signed zero and subnormals.test_rdna3_fp8_kv_paths_match_bf16_kernels_on_dequantized_rowschecks:dequant_kv_rowsbit-for-bit against a PyTorch dequantization;One existing backend test's
FakeKVCachenow subclasses the file's_PlainKVStoragebecause the backend readskv_formatwhen sizing decode scratch; production pools already define it (kv_format: KVFormat = NONE).Numerics
code x scaleto BF16 (relative error at most 2^-9, far below E4M3's 2^-4 step).p x scale_v x 256is rounded to fp16. Greedy outputs therefore diverge from the previous FP8 path at some contexts (hash table below). Task-quality evaluation has not been run.Isolation: RDNA4, CUDA and other formats
kv_f16_readeris false off gfx11 (torch.version.hip is Noneshort-circuits on CUDA), so host-side launch parameters, split caps and the prefill route are unchanged there, andF16_KV=Falseremoves the new kernel branches at compile time.01f3b47(perf(attention): add packed fp8 kv reads on gfx1201 #897) and oncf44001, for D128 GQA4. The instruction streams are identical in all eight comparisons:v_cvt_pk_f32_fp8KV_PACK=4, #897 path)861efefedf9f2fdfb5de1d190d4f935f1be8768c41784f68none)9eb09bc2e2ab7ebfTest environment
0000:A3:00.0(one GPU visible viaROCR_VISIBLE_DEVICES;torch.cuda.get_device_properties(0)reports gfx1100,multi_processor_count=48).6.8.0-146-generic; amdgpu6.16.13.sha256:31cc953da2ee8345774f92c8f288dded6a19cbcd59c1ce96051970f48d627d18: ROCm 10.1.0, HIP7.16.26385, Python 3.13.13, Torch2.14.0+rocm10.1.0, Triton3.8.0, Transformers5.18.0.Qwen/Qwen3.5-0.8B(BF16;model.safetensorsSHA-25604b1c301231dd422b8860db31311ab2721511346a32cb1e079c4c4e5f1fe4696). 24 layers, 6 full-attention layers; head_dim 256; 8 query / 2 KV heads (GQA4).Tests (all on
cf44001)pytest tests/kernels/test_triton_attention.py: 113 passed, 4 skipped. The four skips aretest_rdna4_kv_decode_preserves_every_finite_fp8_code[*], which require gfx1201.pytest tests/engine/test_attention_backend_arch.py tests/kvcache/test_kv_cache_rebuild.py tests/kvcache/test_pool_sizing_surface.py tests/attention/test_capture_rows_per_req.py: 62 passed, 4 skipped (skips:sgl_kernel/flashinfernot installed).FREETOKEN_FORCE_E4M3_EMU=1 pytest tests/kernels/test_triton_attention.py -k test_quantized_kv_attention_matches_dequantized_referencewith a separate Triton cache: 48 passed (the gfx11 path is disabled under forced emulation).End-to-end A/B
Same GPU, same image, one service at a time, 1 warmup + 3 measured requests per point (medians), 128 output tokens,
temperature=0,ignore_eos=true,--cache-type naive. Every request was checked for exact prompt tokens, 128 completion tokens andcached_tokens == 0. Prompts are exact-length synthetic documents (<|im_start|>user ... <|im_start|>assistant). TTFT is request start to first text event; decode tok/s = 127 / (finish event - first text event).Baselines are
01f3b47(#897 head =main+ #897). #897 only touches decode stage-1 and its wrapper; for these gfx1100 D256 shapes the stage-1 instruction streams ofmainand01f3b47are identical for FP8, INT8 and BF16 (cross-compiled, SHA-256 prefixes397ac1fad1cd6b35/1d0826d1a4170012/fe517d008a142427), and the extend path is untouched, so these columns also representmain.01f3b47/ FP8cf4400101f3b47/ FP8cf4400101f3b47/ FP8cf44001cf44001vs BF16 (TTFT / decode / E2E)The 512-token TTFT difference is about 4 ms and within that point's noise. KV allocation for 70,000 tokens: BF16 0.80 GiB, FP8 0.41 GiB (service log); service-reported VRAM BF16 2.719 GiB, FP8 2.318 GiB (
01f3b47) / 2.316 GiB (cf44001).Greedy output hashes (identical across the three repeats in every run):
cf44001matches BF16 at 512, matches the previous FP8 path at 8,192, and matches neither at 32,768 / 65,536.Kernel microbenchmark
Qwen3.5-0.8B full-attention shapes (8 Q heads, 2 KV heads, D256), contiguous slots, per-layer time in us, median of 7 HIP-Graph replay batches. Decode uses the backend's split cap; prefill is one 2048-token chunk after the given prefix with
k_extend/v_extend.01f3b4701f3b47cf44001cf44001max abs err vs BF16-on-dequantizedOpen questions for reviewers
gfx11*(gfx1100/1101/1102, RDNA3.5 gfx1150/1151), which share the missing conversions, but only gfx1100 was measured. Should it be narrowed to measured targets?Scripts
bench_service.py (end-to-end SSE client)
bench_kernels.py (kernel microbenchmark)
xcompile_gfx12.py (RDNA4 cross-compile check)