fp8 KV cache in the in-tree prefill kernel - #368
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Remaining dtype-validation gaps can permit unsupported mixed operands or silently reinterpret decode cache data.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds FNUZ FP8 KV-cache support to ROCm’s in-tree prefill kernel while providing explicit failures for unsupported dtype combinations.
Changes:
- Dequantizes FP8 KV data into 2-byte LDS tiles for prefill and tensor-core decode.
- Adds dtype validation, AITER FP8 paged-prefill routing, and regression tests.
- Updates capability documentation and benchmark dtype/byte accounting.
File summaries
| File | Description |
|---|---|
include/flashinfer/rocm/attention/prefill.cuh |
Implements FP8 KV loading and LDS conversion. |
flashinfer/jit/rocm/modules.py |
Adds ROCm attention dtype guards. |
flashinfer/rocm/prefill.py |
Adds routing, validation, descales, and output handling. |
flashinfer/jit/utils.py |
Maps E5M2FNUZ for JIT generation. |
tests/rocm/test_fp8_kv_prefill.py |
Tests in-tree FP8 KV behavior and refusals. |
tests/rocm/test_fp8_paged_prefill.py |
Tests AITER FP8 paged prefill. |
tests/rocm/test_prefill_decode_dispatch.py |
Updates native page-size expectations. |
tests/rocm/test_batch_prefill_kernels.py |
Updates AITER routing tests. |
include/flashinfer/rocm/attention/aiter/batch_prefill.cuh |
Passes FP8 descales to AITER. |
include/flashinfer/rocm/attention/aiter/aiter_loader.h |
Extends AITER variant keys for FP8. |
csrc/rocm/batch_prefill_paged_aiter.cu |
Validates and launches FP8 paged prefill. |
csrc/rocm/batch_prefill_paged_aiter_jit_pybind.cu |
Extends the paged binding signature. |
csrc/rocm/batch_prefill_aiter_customize_config.jinja |
Adds required HIP dtype headers. |
csrc/rocm/aiter_loader.cc |
Resolves scaled FP8 AITER variants. |
flashinfer/rocm/arch_caps.py |
Updates advertised capabilities. |
docs/rocm/backends.md |
Documents FP8 support and limitations. |
README.md |
Regenerates the backend capability table. |
benchmarks/rocm/bench_fa2_prefill.py |
Adds FP8 KV benchmarking and byte accounting. |
benchmarks/rocm/bench_aiter_prefill.py |
Aligns benchmark page sizes with routing. |
Review details
Suppressed comments (1)
flashinfer/jit/rocm/modules.py:463
- The exported
flashinfer.jit.gen_customize_batch_decode_modulebypasses this guard: its implementation atmodules.py:841-919feeds the supplied dtype directly intodtype_map_hip. Thus OCP fp8 remains reachable through a supported public JIT API and is silently interpreted as FNUZ. Apply the check in the custom batch generator, letting this wrapper inherit it.
_check_fa2_fp8_dtypes("batch decode", dtype_q, dtype_kv, dtype_o)
- Files reviewed: 19/19 changed files
- Comments generated: 3
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
be15012 to
42bdd81
Compare
|
Suppressed comment (review of 2026-09-12T23:22Z), |
There was a problem hiding this comment.
🟡 Changes recommended
Auto backend selection remains sticky across replans, and the new scale API conflicts with existing signatures and documented scaling parameters.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 23/23 changed files
- Comments generated: 4
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
|
Disposition log for the bot reviews on this PR, at Correction to an earlier verdict. I previously declined the suppressed comment on Both are in Suppressed comments, per review:
Standing declines, so later rounds re-raising them can be read against this:
Follow-ups for Note this branch is maintained with fixups and autosquash, so |
42bdd81 to
d557492
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Sparse wrappers can still silently reinterpret runtime KV tensors, and some unsupported dtypes bypass the intended validation.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
flashinfer/sparse.py:1631
- The K/V equality check still permits both tensors to differ from the dtype used to build the cached module. Planning this wrapper for fp16 and running it with two fp8 tensors therefore passes here and lets
paged_runreinterpret both buffers as fp16. Persist the planned Q/KV dtypes and validate runtime inputs against them before reshaping and dispatch.
if k.dtype != v.dtype:
raise ValueError(
f"k has dtype {k.dtype} but v has {v.dtype}; block-sparse "
"attention compiles a single KV dtype and would reinterpret v."
)
- Files reviewed: 13/13 changed files
- Comments generated: 2
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
d557492 to
7049384
Compare
There was a problem hiding this comment.
🟡 Changes recommended
POD validation still permits cross-phase dtype mismatches that can reinterpret query and KV tensor bytes.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 6
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
7049384 to
33460c0
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The FP8 conversion has an alignment hazard, and one unsupported dtype combination still raises a bare assertion.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 2
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
fp16 query with an fp8 KV cache failed at JIT build on every fa2 prefill entry point, with a ninja log rather than an error. Measured on gfx942 at 6dc0b0f: 54 compiler error lines from BatchDecode(use_tensor_cores=True) and from BatchPrefillWithPagedKVCacheWrapper, 27 from single_prefill_with_kv_cache. Every one traces to the blanket static_assert at prefill.cuh:108, which kills SharedStorage; the two static_assert(false) arms in compute_qk/compute_sfm_v never fire because the kernel body is never instantiated. KV now arrives from HBM as fp8 and is dequantized into a 2-byte LDS tile (DTypeKVSmem) in the global->smem produce path. This keeps swizzle, fragment addressing and the smem budget bit-identical to bf16, so permuted_smem.cuh is untouched and the fp16/bf16 path emits the instructions it did before -- produce_kv_unit forwards verbatim when the cache is already 2-byte. Measured on gfx942, fp16 single prefill before and after, llama3-8b shapes: 0.074/0.240/0.744/2.263/8.701 ms against 0.074/0.239/0.744/2.277/8.708. The fp8 win is HBM bandwidth and cache capacity, both properties of the HBM type. kv_smem_type_t keys on the fp8 types, not on sizeof()==1: an int8/uint8 cache has no float interpretation, and widening it would send it through vec_cast's primary template, which reads 0..255 as a value. Keying on size would have turned the old blanket static_assert from a refusal into silent garbage. Dequant goes through vec_cast<float, fp8>, not vec_cast<half, fp8>: the latter's convert_e4m3x2_to_f16x2 decodes fnuz with the OCP bias (-7 where the format's bias is 8) and is 2x wrong. It stays unreachable. It is also built from raw arrays rather than vec_t, because on gfx950 HIP_FP8_TYPE_FNUZ=0 leaves vec_t<fp8,N> with a host-only default constructor -- identical source compiled clean on gfx942 and failed on gfx950 with `no matching constructor` at vec_dtypes.h:853. The granule is staged through one 4-byte register load because vec_cast is a per-element loop and converting straight off gptr can emit four global_load_ubyte where the 2-byte path emits one global_load_dwordx2. This measured no difference on gfx942 (tensor-core decode identical to three decimal places), so it is kept for not depending on the compiler coalescing, not for speed. The dead sizeof(DTypeKV)==1 arms are deleted rather than repaired: they carry upstream's 8-half CUDA fragment geometry where a CDNA fragment is 4 halves, and they call advance_offset_by_column without the k_col_idx that k128B_16Row requires. The two IsInvalid() fp8 clauses go with them -- at CTA_TILE_Q=128/head_dim=128 the budget gives NUM_MMA_KV=1, so 1*2 % 4 != 0 would have failed a working config at runtime. Not chosen: an fp8 smem tile with in-loop fragment dequant. NUM_MMA_KV is register-capped (ELEMS_PER_FRAGMENT = 16*16/64 = 4), so halving the KV smem type widens exactly one cell of the tile table, and there is no async copy to forfeit -- commit_group/wait_group are no-ops and load_64b is a plain assignment, so every KV byte already passes through VGPRs. The seam back is the one conditional_t in kv_smem_type_t. Co-Authored-By: Claude <noreply@anthropic.com>
33460c0 to
b3e3c0e
Compare
There was a problem hiding this comment.
🟡 Changes recommended
AITER dtype validation remains incomplete and can occur only after expensive bootstrap work or URI construction.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
flashinfer/jit/rocm/modules.py:990
- Equality is not a sufficient AITER dtype check here. For example,
dtype_q=dtype_kv=dtype_o=torch.float32passes this branch, whilegen_batch_prefill_moduleconstructs the URI first and raises a bareKeyErrorbecause float32 is absent fromfilename_safe_dtype_map; mapped but unsupported types such as int8 can proceed even further. Add an AITER-specific allowlist before URI construction so unsupported equal-dtype requests also receive the promised typed refusal.
if backend == "aiter" and dtype_q != dtype_kv:
raise NotImplementedError(
f"batch prefill: AITER requires equal query and KV dtypes; got "
f"{dtype_q} and {dtype_kv}."
)
- Files reviewed: 18/18 changed files
- Comments generated: 1
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
Guards at module-build time rather than in IsInvalid(), which fires
host-side only after a multi-minute JIT.
fp8 query and output: the MFMA path is f16f16f32, so both must be 2-byte.
KernelTraits asserts sizeof(DTypeQ)==2 to keep the C++ side honest.
The OCP encodings: dtype_map_hip sends torch.float8_e4m3fn and
torch.float8_e5m2 onto the *fnuz* C++ types, whose exponent bias is 8
against OCP's 7, so a caller passing one would get a silently 2x-wrong
result. Refused for prefill and for decode, which shares that map --
without the decode half, the claim in backends.md and arch_caps that the
OCP spellings are refused would have been false for plain decode.
The guard sits at gen_customize_{single,batch}_prefill_module, not at the
outer generators: both delegate there, and so does the jit_args custom
variant path (rocm/prefill.py:2110, 3397), which the outer guards missed
entirely. It is keyed on backend != "aiter" rather than == "fa2" because
ROCm logs and ignores an explicit "fa3" and routes it to the same in-tree
kernel -- keying on "fa2" let backend="fa3" past. AITER keeps its own fp8
dtypes: it compiles kernels against aiter.dtypes.fp8, which is OCP
e4m3fn on gfx950.
fp8 KV on POD: pod.cuh and batch_pod.cuh carry upstream's CUDA register
constant (8, against CDNA's ELEMS_PER_FRAGMENT/NUM_MMA_Q = 4) and size
the smem budget off the query dtype, so they can instantiate KernelTraits
at a NUM_MMA_KV the prefill dispatchers never produce. Before this series
the blanket static_assert caught that; deleting it would have let POD
compile fp8 at a geometry nothing has tested. The POD-wide refusal is
raised before the dtype one, or the user is told to re-quantize into
something POD also rejects.
Mismatched k/v dtypes were never checked: the module is built from
k.dtype alone and v is read with it. Only 2-byte KV used to reach the
kernel so the 8-bit static_assert caught it; with an fp8 cache it
returned NaN silently. Measured on gfx942, k=e4m3fnuz with v=fp16:
"NO ERROR -- out dtype=torch.float16 finite=False".
torch.float8_e5m2fnuz was absent from both filename_safe_dtype_map and
dtype_map_hip, so it raised KeyError from the URI builder rather than
anything legible -- hit while running the oracle.
The fp8_enabled raise in the customize generators keeps its behaviour but
loses its message: it gates upstream's fa3-only sm90 fp8-*query*
templates, and read as a blanket refusal of what this series implements.
BatchPrefillWithRaggedKVCacheWrapper.run() silently cast q, k AND v to
f16 on an fp8 query, which would now discard a caller's fp8 KV cache.
That branch is deleted rather than converted to a raise: plan() refuses
an fp8 q_data_type, and _check_cached_qkv_data_type rejects a runtime q
that disagrees with the plan, so it is unreachable.
Four gaps the PR review found, all reproduced before accepting:
The allowlist admitted mixed 2-byte operands. The MFMA is instantiated
from DTypeQ, so a bf16 cache under an fp16 query is read as fp16; aot.py
already skips that pair and DISPATCH_PYTORCH_QKV_DTYPE_TO_CTYPE admits
only kv == q or wide-q + fp8-kv. A 2-byte KV must now equal the query.
The decode guard sat on gen_{single,batch}_decode_module, but both
delegate to the customize generators, which flashinfer/jit/__init__.py
and jit/rocm/api.py re-export -- so the public JIT API could still
compile OCP fp8 as fnuz. Moved down, as the prefill guards already were.
Mismatched k/v reached both decode entry points too: decode specializes
on k.dtype and casts both pointers to it. Measured on gfx942, paged
decode with k=e4m3fnuz and v=fp16: "NO ERROR -- finite=False". The
prefill helper is now shared through the import edge decode.py already
has on prefill.py, rather than a new module.
Two more from later rounds.
The allowlist ran after the URI was built, and the URI indexes
filename_safe_dtype_map -- so a dtype absent from that map (float32 is
the reachable one) still raised a bare KeyError instead of the refusal
the docs advertise. The check moved ahead of get_*_uri in all four
prefill/decode loaders, keyed on backend != "aiter" for prefill so
The k/v check was added to flashinfer/sparse.py, which has no prior AMD
commit -- "git log origin/amd-integration -- flashinfer/sparse.py"
returns upstream commits only -- so it would have carried a conflict hunk
into every upstream sync. It is now CHECK_KV_DTYPES_MATCH at the six
C++ entry points instead, and sparse.py is untouched. That covers strictly
more: both block-sparse wrappers call paged_run, and POD has no shadow
module under flashinfer/rocm/, so neither could be reached from Python
without editing an upstream file.
The Python check stays as well, for a different job: it refuses before
the URI and before any AITER bootstrap, so a mismatch costs no JIT build.
A/B, with the sparse.py check deleted: block-sparse with k=fp16, v=bf16
raises "RuntimeError: paged_k_cache has dtype Half but paged_v_cache has
BFloat16" -- only the C++ can produce that. The same probe found
block-sparse fp8 KV is bitwise identical to the dequantized reference for
both fnuz dtypes.
Co-Authored-By: Claude <noreply@anthropic.com>
56 cases on gfx942, all green. The equivalence oracle is that fp8 -> fp16 is lossless, so an fp8 cache must give what the trusted 2-byte kernel gives when torch does the dequant. Both runs pick the same tile geometry -- the LDS tile is 2-byte either way -- so the bar is bit-equality, and every case meets it rather than merely landing inside 1e-3. test_tensor_core_decode_fp8_kv covers the reported defect. Its float16 parametrization is not padding: no test anywhere planned a tensor-core decode on the fa2 path with any dtype, which is why an fp8 cache there reached a user as a ninja log. pos_encoding_mode is NONE throughout. The fa2 RoPE path returns a different answer on every call for *any* KV dtype -- measured on an unmodified prefill.cuh at 6dc0b0f, spread 0.2-1.4 over repeated identical calls, 6 runs giving 6 distinct results, at kv_len=16 where there is a single KV tile. It is pre-existing and orthogonal to fp8, but it means RoPE cannot serve as a reference here. test_fp8_kv_quantization_quality is deliberately separate from the oracle: it measures quantization error against fp32, so a failure there means the cache is too coarse rather than that the kernel disagrees with its fp16 twin. It uses naive_attention rather than F.scaled_dot_product_attention -- torch anchors is_causal top-left and FlashInfer bottom-right, which at qo_len=64/kv_len=256 mismatches 93% of elements and looks exactly like a kernel bug. Co-Authored-By: Claude <noreply@anthropic.com>
The batch_decode hip row already advertised "fp8 KV-cache (E4M3FNUZ)", which was true of the plain path and false of use_tensor_cores=True. It now covers both, plus E5M2FNUZ. single_prefill and batch_prefill gain the capability; pod records that it refuses fp8 KV and why. backends.md's "fp8 on the HIP path" section was two bullets; it now states the four constraints a caller actually hits -- 2-byte query and output, fnuz spellings only, no POD, JIT-only. Its AITER section claimed the in-tree kernel "rejects 8-bit types in a static_assert", which this series makes false for KV. README regenerated with scripts/gen_arch_support_matrix.py. Co-Authored-By: Claude <noreply@anthropic.com>
_bytes() hardcoded 2 bytes per element for q, o, k and v alike, so every fp8 achieved-bandwidth number would have read ~2x high. The q read and o write stay 2-byte; k and v now follow --kv-dtype. Both batch wrappers also had to declare q_data_type/kv_data_type on plan(), which otherwise default the cache to the query dtype. Co-Authored-By: Claude <noreply@anthropic.com>
b3e3c0e to
4fd9e14
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The AITER allowlist admits output and single-prefill FP8 combinations that the generated launchers cannot execute.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
flashinfer/jit/rocm/modules.py:774
- This uses the batch-paged dtype policy for single prefill and therefore admits e4m3 fp8 Q/KV. However, the generated
single_prefill_aiter.culauncher explicitly accepts only fp16/bf16 at lines 81-84, so both this public custom generator and the regular generator that delegates here can create a module that can never execute. Reject_AITER_FP8_DTYPESat the single-prefill seam; fp8 AITER support is paged batch only, and update the test that currently marks these single-generator cases as allowed.
if backend == "aiter":
_check_aiter_dtypes("single prefill", dtype_q, dtype_kv, dtype_o)
- Files reviewed: 19/19 changed files
- Comments generated: 1
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
There was a problem hiding this comment.
🟡 Changes recommended
The new single-decode dtype guard lacks a direct regression test.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 19/19 changed files
- Comments generated: 1
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
The allowlist admitted any 2-byte output, but the generated launchers pin it exactly: single_prefill_aiter.cu:85 and batch_ragged_prefill_aiter.cu:56 require o == q_dtype, and batch_prefill_paged_aiter.cu:67 requires bf16 for an fp8 query. fp16 q/kv with a bf16 output therefore passed the check, paid a cold JIT build, and only then failed in TORCH_CHECK -- the outcome the allowlist exists to replace. Matches how the callers already derive it (prefill.py:2663, :3955): FP8_PREFILL_OUT_DTYPE for an fp8 query, else q_data_type. Co-Authored-By: Claude <noreply@anthropic.com>
test_decode_mismatched_k_v_dtypes_are_refused drives the batch wrapper, which reaches BatchDecodeWithPagedKVCacheRun -- so neither single-decode seam was covered: the Python pre-check that saves a cold JIT, nor single_decode.cu's CHECK_KV_DTYPES_MATCH. Removing either would not have failed a test. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The exported decode JIT builders can still raise opaque KeyError exceptions before the new dtype validation runs.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
flashinfer/jit/rocm/modules.py:938
- The exported
flashinfer.jit.gen_batch_decode_modulestill callsget_batch_decode_uribefore this validation (modules.py:519-530). Consequently an unmapped unsupported dtype such astorch.float32raisesKeyErrorfrom that public builder rather than the promised actionable refusal. Add the same check at the beginning ofgen_batch_decode_module, before URI construction, and keep this customize-level backstop.
_check_fa2_fp8_dtypes(
"batch decode", dtype_q, dtype_kv, dtype_o, kv_must_match_q=False
)
- Files reviewed: 19/19 changed files
- Comments generated: 1
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
… of them An audit of every gen_* in modules.py that takes dtype_q/dtype_kv found the guard placed after URI construction on the two decode builders, and absent entirely from gen_batch_decode_aiter_module -- all three publicly re-exported through flashinfer/jit/__init__.py. Measured before the fix: gen_single_decode_module(float32, ...) -> KeyError: torch.float32 gen_batch_decode_module(float32, ...) -> KeyError: torch.float32 gen_batch_decode_aiter_module(int8, ...) -> NO RAISE, spec built gen_batch_decode_aiter_module(e4m3fnuz, ...) -> NO RAISE, spec built The AITER decode pair is the worse half: batch_decode_aiter.cu:109 serves fp16/bf16 only, so an fp8 or int8 module built and could only fail at run time. _check_aiter_decode_dtypes is separate from the prefill allowlist because the decode kernel has no fp8 arm at all. The new test sweeps all six builders rather than spot-checking, so the next entry point that indexes filename_safe_dtype_map before validating fails a test instead of a review round. Co-Authored-By: Claude <noreply@anthropic.com>
… construct The strict-aliasing fix in b3e3c0e staged the global load in a `DTypeKV packed[GRANULE]`. gfx950 builds with HIP_FP8_TYPE_FNUZ=0, which leaves __hip_fp8_e4m3_fnuz without a default constructor, so an array of it does not compile: prefill.cuh:326:41: error: no matching constructor for initialization of 'DTypeKV[4]' (aka '__hip_fp8_e4m3_fnuz[4]') gfx942 has the constructor and compiled it, so no gfx942 run could catch this; every commit from b3e3c0e on was broken on CDNA4, and neither conformance nor pre-commit compiles kernels. Staging in a uint32_t keeps both properties: no fp8 array, so gfx950 compiles, and the load is still a __builtin_memcpy, so it stays aliasing-clean under -O3. Only the vec_cast read-back casts, as it did before b3e3c0e. Verified on gfx950 (MI350X, job 67920205, rocm 7.15): builds, and the fp8 kernel tests pass. gfx942 (MI300X) re-run green. Co-Authored-By: Claude <noreply@anthropic.com>
…re the import Two defects in the guard layer, both surfaced by the first gfx950 run. `_check_aiter_dtypes` admitted e4m3 fp8 at every AITER prefill seam, but only batch_prefill_paged_aiter.cu has an fp8 arm. single_prefill_aiter.cu and batch_ragged_prefill_aiter.cu are fp16/bf16 only, so a single-prefill fp8 module built and could never execute: single_prefill aiter fp8: NO RAISE -> spec built (before) single_prefill aiter fp8: NotImplementedError: ... (after) batch_prefill aiter fp8: NO RAISE -> spec built (both; paged serves it) single_prefill aiter fp16: NO RAISE -> spec built (both; no over-refusal) `gen_batch_decode_aiter_module` imported aiter as its first statement, ahead of the dtype check the previous commit had just moved up. The refusal was therefore reachable only where aiter is installed; the gfx950 image is py3.13 and the wheel is cp312-only, so it raised ModuleNotFoundError instead. With the import blocked: batch_decode_aiter f32 (no aiter): NotImplementedError: ... (after) The tests carried the same assumption: the allowed AITER cases call a generator that imports aiter unconditionally, so they failed rather than skipped on a box without it. Guarded with importorskip; the refusal cases need no guard, which is the point of checking first. gfx950 (MI350X, job 67920205) and gfx942 (MI300X) both green. Co-Authored-By: Claude <noreply@anthropic.com>
gen_pod_module and gen_batch_pod_module refuse an fp8 KV cache, but the two customize entry points reached the kernel without passing through either. They built a spec the POD kernel's static_assert then rejected as a ninja log -- the failure _check_pod_fp8_dtypes exists to replace -- and an unmapped dtype still raised a bare KeyError: gen_pod_module fp8kv: NotImplementedError (before and after) gen_customize_pod_module fp8kv: NO RAISE -> spec built -> NotImplementedError gen_customize_batch_pod_module fp8kv: NO RAISE -> spec built -> NotImplementedError gen_customize_pod_module f32: KeyError: torch.float32 -> NotImplementedError Both delegate to _gen_customize_pod_like_module, so one guard there covers them, matching how CHECK_KV_DTYPES_MATCH is a single C++ seam rather than a check per launcher. The sweep test grew the four customize prefill/decode generators plus gen_batch_pod_module and both customize POD ones, and now asserts its own completeness against dir(modules) -- the gap is what let this through, since the sweep was billed as covering every public generator and listed 6 of 13. gfx942: 119 passed, 16 skipped, 0 failed. Co-Authored-By: Claude <noreply@anthropic.com>
|
Suppressed comments, reviews through
Also fixed from an internal review of the same push: |
There was a problem hiding this comment.
🔵 Needs a closer look
Low-level LDS layout, pointer arithmetic, and cross-architecture FP8 behavior require final human GPU validation.
Review details
- Files reviewed: 19/19 changed files
- Comments generated: 0 new
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
|
Closing the review loop: the review on Deliberately not in this PR, each recorded under Known limitations in the description rather than carried silently: the RoPE prefill non-determinism (pre-existing and dtype-independent), the flat prefill throughput (fp8 halves bytes moved but not load instructions — a wider per-lane load is its own change), and AOT packaging of an fp8 KV axis. Later rounds re-raising these should be read against this comment. |
Summary
An fp16 query with an fp8 KV cache failed at JIT build on every in-tree prefill entry point — including
BatchDecodeWithPagedKVCacheWrapper(use_tensor_cores=True), which the README advertises as supporting an fp8 KV cache. The failure was a ninja log, not an error. This makes the kernel serve fp8 KV, and makes every combination it still cannot serve fail with a sentence.#366 (merged) covers fp8 on the AITER paged route, where q and kv must both be fp8. This covers the other shape — a 2-byte query against an fp8 cache, which is what an fp8 KV cache actually is — and it has no AITER route at all. Rebased onto
amd-integrationafter #366 landed, so these commits stand alone.What changed
Kernel
include/flashinfer/rocm/attention/prefill.cuh—KernelTraitsgainsDTypeKVSmem. KV stays fp8 in HBM and is dequantized into a 2-byte LDS tile in the global→smem produce path, soDTypeKVis now the HBM type only.permuted_smem.cuhis untouched.Refusals
flashinfer/jit/rocm/modules.py— a dtype allowlist at thegen_customize_*seam for prefill and decode. q and o must be float16/bfloat16; KV may additionally be fnuz fp8. On prefill and POD a 2-byte KV must also equal the query; decode is exempt (see below). An explicitbackend="aiter"with unequal q/kv dtypes is refused too. Every guard runs before its generator builds a URI or importsaiter, so an unmapped dtype gets the sentence rather than aKeyError, and the refusal still fires on an interpreter with no AITER wheel. The AITER allowlists are per-launcher: fp8 is admitted at the batch-prefill seam, whichbatch_prefill_paged_aiter.cuserves, and refused at the single-prefill and paged-decode seams, which are fp16/bf16 only.flashinfer/rocm/decode.py— both decode entry points validatek.dtype == v.dtype, sharing the prefill helper through the import edge decode already has.csrc/rocm/pytorch_extension_utils.h—CHECK_KV_DTYPES_MATCH, called from every attention run entry point (single_prefill.cu, bothbatch_prefill.curuns,single_decode.cu,batch_decode.cu,pod.cu,batch_pod.cu). This is where the invariant lives: it covers the routes Python cannot reach without editing an upstream file, since both block-sparse wrappers callpaged_runand POD is not shadowed underflashinfer/rocm/.include/flashinfer/rocm/attention/{pod,batch_pod}.cuh— astatic_assert(!is_fp8_kv_v<...>). Both instantiate prefill'sKernelTraitsdirectly, and the deleted blanket assert was their only compile-time tripwire. The Python refusal sits in_gen_customize_pod_like_module, the one body both POD customize entry points delegate to, so it cannot be bypassed by calling the customize generator directly.flashinfer/rocm/prefill.py—_check_kv_dtypes_matchon the two non-wrapper entry points, and the removal of a silent fp8-query downcast in the ragged wrapper.flashinfer/jit/utils.py—torch.float8_e5m2fnuzwas missing from both dtype maps, so it raisedKeyErrorfrom the URI builder.Tests, docs, benchmark
tests/rocm/test_fp8_kv_prefill.py— new, 99 cases.flashinfer/rocm/arch_caps.py,docs/rocm/backends.md,README.md(regenerated).benchmarks/rocm/bench_fa2_prefill.py— a--kv-dtypeaxis;_bytes()hardcoded 2 bytes for q, o, k and v alike, so every fp8 bandwidth number would have read ~2x high.Architecture / design notes
Dequantize on load, not an fp8 LDS tile. Upstream keeps fp8 in LDS and dequantizes per fragment. Three measured facts say that costs a rewrite of the fragment-addressing layer and buys nothing here:
NUM_MMA_KVis register-capped (ELEMS_PER_FRAGMENT = 16*16/64 = 4), so halving the KV smem type widens exactly one cell of the tile table; there is no async copy to forfeit, sincecommit_group/wait_groupare no-ops andload_64bis a plain assignment, so every KV byte already passes through VGPRs; and the existing deadsizeof(DTypeKV)==1branches are wrong, not merely unimplemented — they carry upstream's 8-half CUDA fragment geometry where a CDNA fragment is 4 halves. The seam back to an fp8 tile is the oneconditional_tinkv_smem_type_t.The "2-byte KV must equal the query" rule is prefill-only. Prefill instantiates the MFMA from
DTypeQand reads KV fragments as that type, so a bf16 cache under an fp16 query is misread. Decodecast_loads q and k intovec_t<float>separately and accumulates in float, so mixed 2-byte dtypes are correct there — measured: fp16-q with bf16-kv decode agrees with bf16/bf16 to 0.0024. Applying the rule to decode would have refused a working combination.The k/v-match invariant is enforced in C++, with two Python calls that buy something extra.
CHECK_KV_DTYPES_MATCHat every run entry point is the definition: block-sparse and POD reach the same kernels without passing through a shadowed module, and the alternative was editingflashinfer/sparse.py, which has no prior AMD commit and would carry a conflict hunk into every upstream sync. Python keeps the check only insingle_prefill_with_kv_cacheandsingle_decode_with_kv_cache, where it runs before module resolution and so refuses without paying a cold JIT. The three wrapperrun()copies were dropped:plan()has already built the module there, so they added 335 ns and a second spelling of the same rule. Mismatches from a wrapper therefore surface asRuntimeError, notValueError.kv_smem_type_tkeys on the fp8 types, not onsizeof()==1. An int8/uint8 cache has no float interpretation; widening it would route throughvec_cast's primary template, which reads 0..255 as a value. Keying on size would have converted the old blanketstatic_assertfrom a refusal into silent garbage.Dequant goes through
vec_cast<float, fp8>.vec_cast<half, fp8>atvec_size >= 2decodes fnuz with the OCP bias (- 7where the format's bias is 8) and mishandles denormals — a latent 2x error in shared code. It stays unreachable, and the float route is what working decode already exercises. Left unfixed deliberately: nothing undertests/rocm/can callvec_casttoday, so a conformance test needs a new.cu, a JIT generator and a binding.The OCP spellings are refused rather than mapped.
dtype_map_hipsendstorch.float8_e4m3fnandtorch.float8_e5m2onto the fnuz C++ types, whose exponent bias is one greater (E4M3 7 against 8, E5M2 15 against 16). The refusal is keyed onbackend != "aiter", not== "fa2", because ROCm logs and ignores an explicit"fa3"and routes it to the same in-tree kernel. AITER keeps its own fp8 dtypes — it compiles againstaiter.dtypes.fp8, which is OCPe4m3fnon gfx950.fp8 KV stays JIT-only.
rocm/aot.pydrawsdtype_kvfrom the f16 list; adding a KV-dtype axis multiplies the prefill+decode matrix and is a wheel-size decision deserving its own measurement. First use pays a cold build.Benchmark results
Prefill: fp8 is neutral, and that is the honest result. These shapes are compute-bound — arithmetic intensity 205–3277 FLOPs/B against MI300X's ~247 ridge point — so halving KV bytes buys nothing. gfx942, llama3-8b (GQA 32/8, hd 128), causal, median ms:
No fp16 regression, which is the point of
produce_kv_unitforwarding verbatim for a 2-byte cache. Same shapes, kernel reverted vs applied: 0.074 / 0.240 / 0.744 / 2.263 / 8.701 → 0.074 / 0.239 / 0.744 / 2.277 / 8.708 ms, inside run-to-run noise.Decode is where an fp8 cache pays, and the comparison that matters is against the only fp8 decode route that previously worked. gfx942, GQA 32/8, hd 128, page 16, median ms:
Against tensor-core fp16 the same rows are only 1.02–1.17x (mean 1.09x): at 1.2 TB/s of MI300X's 5.3 the path is latency-bound, not bandwidth-bound. And plain decode with fp16 remains the fastest route overall (0.041–0.106 ms) — an fp8 KV cache is a memory-capacity trade, not a speed win against fp16. Two things worth flagging for follow-up, neither touched here: plain decode with fp8 is 2–3.6x slower than plain decode with fp16, and the tensor-core decode path leaves most of the memory system idle.
Test plan
torch.equal, no tolerance): single / paged / ragged / tensor-core decode x 2 fp8 dtypes x head_dim 64,128, on gfx942 and gfx950.k_scale/v_scaleon an fp8 cache — asserted as the two folds the implementation performs (k_scaleintosm_scale,v_scalelinear on the output). A/B: disablingsm_scale *= k_scalefails all four cases.float32, mismatchedk/von prefill, decode and block-sparse, POD + fp8, and the AITER generator allowlist (10 dtype combinations x 2 generators, each seam asserted separately since fp8 is legal at one and not the other).gen_*_moduleentry points, with the list derived fromdir(modules)so the test fails if a new generator is added without one. The hand-written list previously covered 6 of 13, which is how the two customize-POD generators kept building fp8 specs the POD kernel then rejected in ninja.k=fp16, v=bf16raisesRuntimeError: paged_k_cache has dtype Half but paged_v_cache has BFloat16.6dc0b0fc0first — 54 / 54 / 27 compiler error lines from the three entry points — and thek/vhazard:k=e4m3fnuz, v=fp16returnedfinite=Falsewith no error.test_{single,batch}_prefill_kernels,bf16_custom_mask,sliding_window,pod,batch_pod,customize_prefill_*,prefill_decode_dispatch,fp8_paged_prefill) — 8,627 passed, 3,563 skipped, 0 failed.test_fp8_paged_prefill(fp8 paged prefill on the AITER route, at 1.22-1.66x over bf16 #366's) +test_batch_prefill_kernels— 5,615 passed, 2,136 skipped, 0 failed.9be6d0960):test_fp8_kv_prefill— 119 passed, 16 skipped, 0 failed;test_block_sparse,test_pod,test_batch_pod,test_batch_decode_kernelsgreen on the preceding commit.test_fp8_kv_prefill,test_fp8_paged_prefill,test_batch_decode_kernels,test_single_prefill_kernels. Two failures, both pre-existing —test_gated_architecture_really_is_defective[17-2048]and[512-512]fail identically on the base6dc0b0fc0, same GPU and image.DTypeKV[GRANULE]does not compile whereHIP_FP8_TYPE_FNUZ=0leaves the fnuz types without a default constructor. Fixed in54cdcaecfby staging in auint32_t, which keeps the load aliasing-clean; re-verified on MI350X, then re-run green on gfx942. Neitherconformancenorpre-commitcompiles kernels, so only an on-device build surfaces this class of defect.amd-integrationafter fp8 paged prefill on the AITER route, at 1.22-1.66x over bf16 #366, Correct the asm prefill speedup, which the old benchmark overstated #370, Let the soft-cap gate test reach the kernel it measures #371 and Record why ragged prefill is ungated, and what would have to be measured to gate it #372 merged. The benchmark numbers above predate that rebase; none of those merges touch the fa2 prefill kernel.pre-commit run -a.Known limitations
pos_encoding_mode="ROPE_LLAMA"on the fa2 prefill kernel returns a different answer on each call from identical inputs — measured on an unmodified kernel with plain fp16 KV (spread 0.2-1.4 over repeats, 6 runs giving 6 distinct results, already at a single KV tile). Pre-existing and dtype-independent, so this series does not gate fp8 on it: that would imply the fp16 path is sound. No prefill test covers RoPE, which is how it survived. Recorded indocs/rocm/backends.md; fixing it needs its own change.rocm/aot.pydrawsdtype_kvfrom the f16 list; adding a KV-dtype axis multiplies the prefill+decode matrix and is a wheel-size decision deserving its own measurement. First use pays a cold build.