perf(rocm): remove per-call KV-cache copies from the MLA and paged-append paths - #292
Open
demandal25 wants to merge 6 commits into
Open
perf(rocm): remove per-call KV-cache copies from the MLA and paged-append paths#292demandal25 wants to merge 6 commits into
demandal25 wants to merge 6 commits into
Conversation
AITER's MLA kernels read one combined [pages, page_size, 1, ckv+kpe]
buffer, so BatchMLAPagedAttentionWrapper.run() concatenated ckv_cache and
kpe_cache on every call. torch.cat allocates unconditionally, and it
covers the *entire allocated page pool* rather than the live pages, so
decode cost tracked cache capacity instead of the working set.
Measured on MI300X (gfx942), active set pinned at b=8/kv=1024 (9 MB)
while only the pool grows:
pool before after
0.28 GB 0.281 ms 0.071 ms
1.12 GB 1.076 ms 0.072 ms
4.50 GB 2.799 ms 0.071 ms
Production pools fill HBM, so this scaled well past anything usable.
run() now detects when ckv_cache and kpe_cache are adjacent halves of a
single allocation and passes a view, copying nothing. That is already how
vLLM (kv_c_and_k_pe_cache) and SGLang (K_Buffer) store MLA caches -- both
slice with torch.split -- so those callers get the fast path with no code
change. Separate allocations still work, falling back to the copy with a
one-time warning.
At standard shapes this is 4.1x at heads=16 (b=32/kv=32K: 1.424 -> 0.349
ms) and 2.4x at heads=128 (1.806 -> 0.756 ms).
The previous docstring told callers to pre-allocate a combined buffer and
"pass it as both arguments (sliced)", which did nothing -- torch.cat
copies regardless. Replaced with the layout that is now actually honored.
Also adds benchmarks/rocm_benchmarks/bench_mla_hip.py (attn/cat/plan/pool
modes, --separate for the pre-fix layout), and ignores *_meta.json, which
the rocm_profiler ignore block intended to cover but missed.
Co-Authored-By: Claude <noreply@anthropic.com>
plan() reached device memory twice: once inside
_kv_lens_to_last_page_len_cpu (copying kv_indptr and kv_len_arr to derive
last-page lengths) and again for qo_lens.max().item() to derive
max_seqlen_q. On gfx942 a device->host sync costs ~70 us regardless of
payload, and these tensors are only batch+1 int32 elements, so the number
of round-trips was the entire cost.
Concatenating the three index tensors into a single copy makes it one
sync. Measured on MI300X: 120-130 us -> 87-92 us, flat in batch size
(1..256). Inputs already resident on the host now skip the sync
altogether rather than being copied per-tensor.
_kv_lens_to_last_page_len_cpu is unchanged -- it is simply handed tensors
that are already on the host, where its internal .to("cpu") is a no-op.
Every validation check and error message is preserved verbatim.
torch.cat rejects tensors on different devices, and mixed placement was
previously accepted (each tensor was moved independently), so a
per-tensor fallback keeps that working. Covered by a new parametrized
test over cuda / cpu / mixed placement.
Not addressed: the remaining ~70 us is the H2D copy of the computed
last_page_len, since non_blocking=True on pageable memory is still a
blocking copy. Removing it means computing on-device while validating on
host, which needs kv_len_arr on the device and turns circular when it is
not -- not worth a second code path for ~35 us.
Co-Authored-By: Claude <noreply@anthropic.com>
append_paged_kv_cache's AITER path went through aiter.ops.cache, which
meant AITER's per-call @compile_ops dispatch plus seven Python-level
tensor ops to translate FlashInfer's (batch_indices, positions) + page
table into the absolute slot indices AITER wants.
Move it to a csrc_rocm shim on the same pattern as norm/rope/activation:
link the symbol-visible AITER module and call reshape_and_cache_flash
directly, with the slot-mapping arithmetic folded into one kernel.
Measured on MI300X (gfx942), append into a 1024-page cache:
nnz python shim
32 102.9 us 11.0 us 9.3x
256 101.2 us 11.2 us 9.0x
4096 101.8 us 12.1 us 8.4x
The existing aiter-vs-native tests already assert bit-exact equality
across dtypes, page sizes and head configs, so they cover the new
kernel; 50 pass.
Targets amd-aiter 0.1.10, where the entry point takes torch::Tensor& in
namespace aiter and reads its stream from at::hip::getCurrentHIPStream().
Newer AITER (>=0.1.19) de-torched this to aiter_tensor_t and moved to a
thread-local stream set from Python -- under that version the shim would
silently run on the null stream, and since torch's default stream *is*
stream 0, every existing test would still pass. Added an explicit
side-stream test so that upgrade fails loudly instead.
Two smaller fixes while here: _auto_select_kv_append_backend now probes
via is_aiter_available rather than a bare try/except import, and an
explicit backend="aiter" goes through require_aiter so an unsupported
device raises ValueError instead of a loader ImportError -- both
matching what norm/rope/activation already do.
Note this does not remove `import aiter` from the process: the
backend-availability probe still imports the package. What it removes is
the per-call dispatch and the lazy build-on-first-call.
Co-Authored-By: Claude <noreply@anthropic.com>
A review of the three preceding commits turned up one real regression and several validation gaps. Fixes, most severe first. **backend="auto" could raise instead of falling back.** ab55de6 replaced `try: _aiter_cache_module() except Exception: return "native"` with `is_aiter_available()`, which only probes `import aiter`. But what can now fail is the JIT shim build -- no hipcc, an unwritable cache dir, or an AITER whose reshape_and_cache_flash signature no longer matches the forward declaration -- and that happened later, unguarded. Every default-path caller was exposed, including ones that never opted into AITER (tests/attention/test_page.py, test_shared_prefix_kernels.py, test_torch_compile_hip.py, bench_append_paged_kv_cache.py). Routed through a new _try_get_page_aiter_module() that returns None and logs on failure. The None is cached deliberately: functools.cache does not memoize exceptions, so a raising getter retried the whole AITER rebuild on every call. **Explicit backend="aiter" skipped the layout and dtype gates.** Those live in _auto_select_kv_append_backend, which is bypassed when backend != "auto", and require_aiter checks only arch and importability. The shim reads page_size from paged_k_cache.size(1) -- num_kv_heads under HND -- so an HND cache scattered every token to the wrong slot silently. Extracted the constraints into _aiter_kv_append_supported() so both paths share them. **The shim was less validated than its native sibling.** csrc_rocm/page.cu does CHECK_EQ(device) on all seven tensors, checks batch_indices.size(0) against nnz, and TORCH_CHECKs the launch status; page_aiter.cu did none of these. It dereferences the index tensors as raw device pointers, so a host-resident kv_indptr faulted instead of erroring, a short append_key was read past its end, and a failed launch left slot_mapping uninitialized while the scatter proceeded into garbage slots. All three now checked. **4-D ckv/kpe produced a warning saying the opposite of the truth.** _combined_kv_view rejects dim() != 3, but the docstring 8f14acb deleted told callers to allocate [num_pages, page_size, 1, ckv+kpe] and pass 4-D slices. Those callers were told their (genuinely adjacent) buffer was not adjacent, then got a 5-D tensor AITER rejects. Now an explicit ValueError naming the 3-D form. **Two docstrings asserted things the code did not do.** _gather_plan_inputs_on_host claimed host-resident inputs were "a no-op, no sync at all" -- but torch.cat on CPU is a real allocation, ~1.9 us, where the old path copied nothing. Host inputs now pass through untouched. The bench docstring described pre-fix behaviour in the present tense and cited line numbers eb3ebda and 8f14acb had already invalidated. Seven new tests, one per fix. Performance is unchanged: append 10.3-12.4 us, MLA run 0.337 ms at b=32/kv=32K, plan 90 us. 74 tests pass. Not addressed, noted for the PR: build_slot_mapping_kernel does unchecked page-table indexing where the replaced torch index_select was bounds-checked -- this is parity with the native kernel, which is equally unchecked, so it is a regression only against the old AITER path. Co-Authored-By: Claude <noreply@anthropic.com>
The full rocm_tests suite failed one test that the targeted runs did not
cover: test_torch_compile_hip.py::test_torch_compile_with_custom_ops, with
Cannot call numel() on tensor with symbolic sizes/strides
in append_paged_kv_cache_aiter(...)
Registering the shim through TORCH_LIBRARY_FRAGMENT made it visible to
Dynamo, which places it in the FX graph and fake-runs it during tracing --
and the shim calls numel()/size() on those symbolic-shaped FakeTensors. The
Python path it replaced was not a registered torch op, so Dynamo graph-broke
on it instead and the problem did not exist.
The native kernel already handles this: _append_paged_kv_cache_kernel pairs
@register_custom_op with a @register_fake_op no-op. The AITER path now does
the same, which keeps the C++ off the tracing path entirely.
Worth noting for the other AITER shims: norm/rope/activation call their
compiled modules directly from the public entry points without a custom-op
wrapper, so they have the same exposure if anyone puts them under
torch.compile. Not changed here -- out of scope, and untested.
Co-Authored-By: Claude <noreply@anthropic.com>
A second review pass over 8e6cf89..c60f45b (the fixes themselves were not yet reviewed) found six issues. **`except Exception` swallowed MissingJITCacheError.** That is the sentinel for "JIT disabled and this module is absent from the prebuilt cache"; tests/conftest.py turns it into a skip and records the module so it gets added to the cache. Downgrading it to a warning would have made test_append_paged_kv_cache_aiter_auto_routes_on_nhd_fp16 hard-fail under FLASHINFER_DISABLE_JIT=1, and page_aiter would never join the jit-cache. Now re-raised. **The 4-D error's remedy did not work.** It told callers to run `cache.split([ckv, kpe], dim=-1)`, but on a 4-D buffer that returns 4-D tensors and re-raises the identical error. Now names `cache.squeeze(2)` first, so copy-pasting the message actually resolves it. **The shim's parity claim was false.** csrc_rocm/page.cu enforces append_key.dim()==3, size(1)==num_kv_heads and size(2)==head_dim; the shim checked none of them while its comment said "page.cu checks the same". A GQA caller passing num_q_heads would have had AITER copy the wrong element count per slot, silently. Added, along with k_scale/v_scale to the device check -- they are dereferenced too, and the op is directly callable. **hipGetLastError attribution.** It returns and clears the last error for the thread, not just this launch, so an unrelated earlier failure would be reported as ours. Drained before the launch, and the comment no longer claims to catch in-kernel faults, which surface at the next sync. **append_paged_mla_kv_cache had no fake op** -- and c60f45b exported it on ROCm, newly exposing a torch.compile-unsafe op: exactly the failure class that commit set out to fix. Added. **Two weak tests.** The fallback test asserted isfinite() on a zero-filled cache, which holds however badly the fallback misbehaves; it now compares against a native reference and asserts something was written. The layout/dtype test matched on "NHD|float16", which both parametrizations satisfy, so the fp32 case did not distinguish itself; each case now matches its own token. Also un-skipped test_torch_compile_without_custom_ops_fails. Its skip reason -- "AITER routes append_paged_kv_cache away from the custom-op path" -- was made obsolete by c60f45b, and it was being skipped on exactly the hardware that now needs it. Verified it passes. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves ROCm MLA and paged KV-cache append performance by removing unnecessary copies and host synchronizations, while adding compiled AITER dispatch.
Changes:
- Adds zero-copy MLA cache handling and batched planning transfers.
- Adds validated, fallback-capable AITER append shims with
torch.compilesupport. - Adds tests, benchmarks, ROCm export coverage, and profiler metadata ignores.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Reviewed changes | Final review comments |
|---|---|---|
tests/rocm_tests/test_torch_compile_hip.py |
Updates compile-path coverage. | — |
tests/rocm_tests/test_mla_aiter_hip.py |
Tests MLA views, planning, and validation. | — |
tests/rocm_tests/test_append_paged_kv_cache_aiter_hip.py |
Tests append correctness, validation, fallback, and streams. | Nit (3 votes): Replace device-wide synchronization with an inter-stream event/dependency check. |
flashinfer/page.py |
Adds AITER dispatch, fallback, validation, and fake-op integration. | Critical (1 vote): Isolate module caching by architecture/device or reject cross-architecture reuse. Moderate (2 votes): Normalize accepted int64 metadata or support both dtypes in the shim. |
flashinfer/mla_rocm.py |
Adds zero-copy cache detection and batched planning. | Nit (2 votes): Add an explicit one-time guard for the fallback warning. |
flashinfer/jit/page.py |
Defines the AITER page JIT specification. | — |
flashinfer/csrc_rocm/page_aiter.cu |
Implements slot mapping and AITER append dispatch. | Critical (1 vote): Validate V-cache rank, shape, strides, and K/V stride compatibility. Critical (1 vote): Validate kv_indptr rank and length before slot mapping. |
flashinfer/csrc_rocm/page_aiter_jit_pybind.cu |
Registers the compiled append shim. | — |
flashinfer/__init__.py |
Exports the ROCm MLA append API. | — |
benchmarks/rocm_benchmarks/bench_mla_hip.py |
Adds MLA performance benchmarks. | — |
.gitignore |
Ignores profiler metadata outputs. | — |
Suppressed comments (5)
benchmarks/rocm_benchmarks/bench_mla_hip.py:326
_alloc_kv()returns adjacent views by default, sorun()takes the zero-copy path and performs no pool-sizedtorch.cat; that copy exists only with--separate. ThisKernelConfignevertheless always reports2 * pool_pages * ...bytes, so the default--mode poolroofline/arithmetic-intensity output attributes GBs of nonexistent traffic and incorrectly grows with pool capacity. Make the byte count conditional on_bench_args.separateand include the active attention traffic.
# The pool-sized cat dominates: read ckv+kpe, write the combined buffer.
theoretical_bytes=2 * pool_pages * _QK_HEAD_DIM * 2,
flashinfer/csrc_rocm/page_aiter.cu:72
- The new shim rejects int64 index tensors, but the public API currently accepts them: the native path coerces
batch_indices,positions,kv_indices, andkv_indptrwith.int(), and the previous AITER path converted them with.long(). A caller using defaulttorch.arange/int64 tensors therefore succeeds withbackend='native'but now gets this error on the AITER auto/explicit path. Preserve the existing API by conditionally converting these inputs before the custom op or dispatching the shim for both integer widths.
TORCH_CHECK(batch_indices.scalar_type() == at::kInt && positions.scalar_type() == at::kInt &&
kv_indices.scalar_type() == at::kInt && kv_indptr.scalar_type() == at::kInt,
"batch_indices/positions/kv_indices/kv_indptr must be int32");
TORCH_CHECK(batch_indices.numel() == positions.numel(),
"batch_indices and positions must have the same length, got ", batch_indices.numel(),
" vs ", positions.numel());
flashinfer/jit/page.py:34
aiter_jitspec_flags()eagerly callsensure_aiter_lib()before returning, so this generator builds the external AITER module even whenFLASHINFER_DISABLE_JIT=1. That bypasses the intendedJitSpec.build()MissingJITCacheErrorpath: an uncached or failed AITER dependency raises a generic build error (explicit AITER tests fail instead of being skipped/recorded, while auto fallback can hide the missingpage_aitercache). Defer resolving/building these flags until JIT is allowed, or handle the disabled-JIT path before callingaiter_jitspec_flags().
extra_include_paths, extra_ldflags = aiter_jitspec_flags("module_cache")
flashinfer/jit/page.py:42
- The
page_aiterspec has a fixed name, whileaiter_jitspec_flags()points at a version/architecture-specific cached AITER library. Oncepage_aiter/build.ninjaexists,JitSpec.build()does not rewrite it, so upgrading AITER or changing the selected architecture leaves the shim linked/rpathed to the old library (or silently keeps using it). Include the AITER dependency identity in the spec/cache key or invalidate/rewrite the ninja file when these flags change.
extra_include_paths, extra_ldflags = aiter_jitspec_flags("module_cache")
return gen_jit_spec(
"page_aiter",
[
jit_env.FLASHINFER_CSRC_DIR / "page_aiter.cu",
jit_env.FLASHINFER_CSRC_DIR / "page_aiter_jit_pybind.cu",
],
extra_include_paths=extra_include_paths,
extra_ldflags=extra_ldflags,
flashinfer/page.py:247
- This fake implementation is the fix for
torch.compileon the newly exported MLA append op, but the compile test exercises onlyappend_paged_kv_cache; no test invokesappend_paged_mla_kv_cachewith custom ops enabled undertorch.compile. A registration or signature regression here would therefore pass the suite. Add a minimal compiled MLA append case.
@register_fake_op("flashinfer::append_paged_mla_kv_cache")
def _fake_append_paged_mla_kv_cache_kernel(
append_ckv: torch.Tensor,
append_kpe: torch.Tensor,
batch_indices: torch.Tensor,
positions: torch.Tensor,
ckv_cache: Optional[torch.Tensor],
kpe_cache: Optional[torch.Tensor],
kv_indices: torch.Tensor,
kv_indptr: torch.Tensor,
kv_last_page_len: torch.Tensor,
) -> None:
pass
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+64
to
+66
| TORCH_CHECK(paged_k_cache.dim() == 4, | ||
| "paged_k_cache must be [num_pages, page_size, num_kv_heads, head_dim] (NHD), got ", | ||
| paged_k_cache.dim(), " dims"); |
Comment on lines
+67
to
+72
| TORCH_CHECK(batch_indices.scalar_type() == at::kInt && positions.scalar_type() == at::kInt && | ||
| kv_indices.scalar_type() == at::kInt && kv_indptr.scalar_type() == at::kInt, | ||
| "batch_indices/positions/kv_indices/kv_indptr must be int32"); | ||
| TORCH_CHECK(batch_indices.numel() == positions.numel(), | ||
| "batch_indices and positions must have the same length, got ", batch_indices.numel(), | ||
| " vs ", positions.numel()); |
Comment on lines
+381
to
+390
| warnings.warn( | ||
| "MLA: ckv_cache and kpe_cache are not adjacent halves of a single " | ||
| "allocation, so run() must concatenate them on every call. That " | ||
| "copy covers the whole allocated page pool, not just the live " | ||
| "pages, so it scales with cache capacity. Allocate one " | ||
| "[num_pages, page_size, head_dim_ckv + head_dim_kpe] buffer and " | ||
| "pass torch.split(buf, [head_dim_ckv, head_dim_kpe], dim=-1) to " | ||
| "run() for the zero-copy path.", | ||
| UserWarning, | ||
| stacklevel=2, |
Comment on lines
+134
to
+139
| batch_indices, | ||
| positions, | ||
| paged_k_cache, | ||
| paged_v_cache, | ||
| slot_mapping, | ||
| "auto", | ||
| kv_indices, | ||
| kv_indptr, |
Comment on lines
36
to
+40
| @functools.cache | ||
| def _aiter_cache_module(): | ||
| from aiter.ops import cache as aiter_cache | ||
| def get_page_aiter_module(): | ||
| from .jit.page import gen_page_aiter_module | ||
|
|
||
| return aiter_cache | ||
| return gen_page_aiter_module().build_and_load() |
Comment on lines
+206
to
+208
| if stream is not None: | ||
| torch.cuda.current_stream().wait_stream(stream) | ||
| torch.cuda.synchronize() |
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.
Summary
BatchMLAPagedAttentionWrapper.run()concatenatedckv_cacheandkpe_cacheon every call to build the single buffer AITER's MLA kernels read.torch.catcopies the whole tensor it is handed, so the cost tracked the allocated page pool rather than the live pages — at a 4.5 GB pool with a 9 MB working set, 2.799 ms per decode step, per layer. This makes that copy a view when the two halves are adjacent, which is how vLLM and SGLang already allocate MLA caches.Also collapses
plan()'s two device→host syncs into one, and moves the AITER paged-KV append off AITER's per-call Python dispatch into a compiled shim.What changed
flashinfer/mla_rocm.py—_combined_kv_view()detectsckv/kpeas adjacent halves of one allocation and returns a zero-copyas_stridedview;run()falls back to the copy with a one-time warning otherwise, and rejects 4-D caches outright._gather_plan_inputs_on_host()fetches the three index tensors in a single host round-trip.flashinfer/csrc_rocm/page_aiter.cu— new shim callingaiter::reshape_and_cache_flash, with the FlashInfer→vLLM slot-index translation folded into one kernel (it was seven Python tensor ops). Validation matchescsrc_rocm/page.cu: device, rank, head/dim, nnz, launch status.flashinfer/csrc_rocm/page_aiter_jit_pybind.cu,flashinfer/jit/page.py—TORCH_LIBRARY_FRAGMENTregistration andgen_page_aiter_module(), following thenorm_aiterpattern.flashinfer/page.py— routes the AITER append through the shim behindregister_custom_op+register_fake_op;_try_get_page_aiter_module()keepsbackend="auto"from raising when the shim cannot build; explicitbackend="aiter"now enforces the same NHD/dtype gatesautoapplies. Adds the missing fake op forappend_paged_mla_kv_cache.flashinfer/__init__.py— exportappend_paged_mla_kv_cacheon HIP; it was CUDA-only.benchmarks/rocm_benchmarks/bench_mla_hip.py— new, four modes (attn/cat/plan/pool) so results stay attributable;--separatereproduces the pre-fix allocation layout.Architecture / design notes
Detection rather than a new parameter, because production already has the layout. vLLM stores
kv_c_and_k_pe_cacheand SGLang storesK_Buffer, both sliced withtorch.split— exactly the adjacency this detects — so those callers get the fast path with no code change and the signature stays identical to the CUDA wrapper. The rejected alternative, caching the concatenated buffer across calls, is silently wrong: the append op mutates the cache between decode steps, so a cached copy serves stale KV.The old docstring told callers to pre-allocate a combined buffer and "pass it as both arguments (sliced)". That never worked —
torch.catcopies regardless of how its inputs were carved — and the shape it named (4-D, with a size-1 axis) would then be unsqueezed to 5-D, which AITER rejects. Replaced with the 3-D form the code now honors; 4-D input raises instead of warning that a genuinely adjacent buffer is not adjacent.plan()is bounded by round-trip count, not payload. A device→host sync costs ~70 µs on gfx942 regardless of size, and these tensors arebatch+1int32 elements, so batching the three into one copy is the whole win. The remaining ~70 µs is the H2D of the computedlast_page_len; removing it means computing on-device while validating on host, which needskv_len_arron the device and turns circular when it is not.The shim needs a fake op. Registering it through
TORCH_LIBRARY_FRAGMENTmakes it visible to Dynamo, which places it in the FX graph and fake-runs it during tracing — and the shim callsnumel()/size(), which throw on symbolic shapes. The Python path it replaced was not a registered torch op, so Dynamo graph-broke on it instead. The norm/rope/activation AITER shims call their modules directly from the public entry points with no custom-op wrapper, so they carry the same exposure; not changed here.backend="auto"must never raise. The shim links AITER's C++ symbols, so it can fail for reasons an import probe cannot see (no hipcc, unwritable cache dir, AITER signature drift). TheNoneis cached deliberately —functools.cachedoes not memoize exceptions, so a raising getter would retry the whole AITER rebuild on every call.MissingJITCacheErroris re-raised rather than caught, sinceconftest.pyturns it into a skip and records the module for the prebuilt cache.Benchmark results
MI300X (gfx942, 304 CU), bf16, page_size 1, torch 2.9.1+rocm7.2, amd-aiter 0.1.10.
MLA decode
run()— active set fixed at b=8 / kv=1024 (9 MB), only the pool grows:Flat across a 450× range — cost now depends on the working set. Production pools fill HBM, well past the largest row here.
MLA decode, standard shapes:
heads=16 is TP8, heads=128 is DP-attention + EP; AMD recommends the former below ~256 concurrent requests and the latter above ~512, so both are load-bearing.
plan(): 120–130 µs → 87–92 µs, flat in batch size (1..256).AITER paged-KV append: 102.9 → 11.0 µs at nnz=32, 101.2 → 11.2 at 256, 101.8 → 12.1 at 4096 (~9×). Also removes the lazy build-on-first-call that otherwise stalls the first append in a server process.
Measured, but deliberately not changed
autoroutes the KV append to AITER, which is slower than the in-tree kernel — 7.4 vs 11.6 µs at nnz=4096, 593 vs 751 µs at 262144, converging to ~0.79×. The gap is AITER'sreshape_and_cache_flashitself (2.86 vs 3.62 TB/s on identical work), not the shim's slot-mapping kernel, which is free at scale. This predates the PR and only gfx942 was measured, so the routing is left alone.num_kv_splitsheuristic is also already optimal (best 1.02× across ten shapes against fixed values).AppendPagedKVMlaCachehardcodesvec_size = 2(4-byte accesses for bf16, where the MHA path computes 16-byte) andAppendPagedKVCachederives it partly fromHEAD_DIM / 32, a CUDA warp-size constant. Coalescing recovers most of it (68% of peak at nnz=65536 vs 75% for a plaincopy_), so the impact is moderate.build_slot_mapping_kerneldoes unchecked page-table indexing where thetorch.index_selectit replaced was bounds-checked. This is parity with the native kernel, which is equally unchecked, so it should be fixed in both together rather than here.CDNA3 impact
This is the CDNA3 path. Everything above was measured on gfx942. gfx950 is unmeasured and no behaviour is arch-gated, so CDNA4 gets the same code with unverified numbers.
Test plan
Verified on 8× MI300X (gfx942, ROCm 7.2.0, torch 2.9.1+rocm7.2.0, amd-aiter 0.1.10):
tests/rocm_testssuite,-n auto --reruns 2 -m "not slow": 27,535 passed, 3,584 skipped, 0 failed, 0 errors.torch.equal, not a tolerance) at three shapes including heads=128.torch.splitviews and layer-indexed slices; rejects separate allocations, reversed halves, padded row strides, mismatched dtypes — each of which would otherwise alias the wrong memory.at::hip::getCurrentHIPStream(), so this passes today; newer AITER moved these ops to a thread-local stream, which would silently put the append on the null stream — and since torch's default stream is stream 0, every other test would still pass. This is the one that would not.test_torch_compile_without_custom_ops_failsun-skipped: its reason ("AITER routesappend_paged_kv_cacheaway from the custom-op path") is obsolete now that the shim goes throughregister_custom_op, and it was being skipped on exactly the hardware that exercises it.pre-commit run -a