An ~10-line PTX patch that issues an early L2 prefetch for the next KV block during PagedAttention compute — and why its L2 win is categorically outside the <0.65% passive-residency bound of "Worth the Bytes?"
Alibaba — github.com/alibaba/vllm_xformers_prefetch
Alibaba Group · experiments on H20 (Hopper) · analysed here as an adversarial L2-reuse case for our measurement study
During the PagedAttention decode kernel, each warp iterates over the sequence's KV blocks one at a time. When a warp finishes block $b$ and reaches for block $b{+}\text{NUM\_WARPS}$, the address of that next block is only resolved as the iteration begins, so the warp stalls on a long-latency global load before its compute can proceed (an exposed long-scoreboard stall). This paper adds a single PTX instruction — cp.async.bulk.prefetch.L2.global — that, while the current block is still being multiplied, issues an asynchronous bulk prefetch of the next block's key (and later value) tensor toward L2. Because the request is issued early, those bytes are moving toward L2 ahead of use, so when the warp reaches that block its long-latency load is hidden behind compute rather than serialized in front of it. (Caveat, measured: the baseline KV is already ~85% L2-resident from natural reuse — see Sections 6-7 — so the prefetch is hiding the exposed load latency, not removing traffic or changing what sits in L2.)
In memory-bound decode, the PagedAttention kernel reads KV blocks. The next block's address is resolved too late for the hardware to start its long-latency load early, so that load latency is exposed as a warp stall in front of compute — even though the block is often already in L2.
Compute the physical address of the block NUM_WARPS iterations ahead and issue cp.async.bulk.prefetch.L2.global for it — once for K, once for V — guarded against the last block. No new launch, no extra shared memory.
Measured on PACE H100 (job 9948442): e2e 1.80x at batch 1, 1.46x at batch 8, 0.99x at batch 32 — reproducing the paper's regime. ncu confirms the mechanism: long-scoreboard mem-stall drops 9.2%->8.5% while HBM read stays flat (2.01 MB). The baseline is already ~85% L2-resident from natural KV reuse, so the prefetch's marginal L2-hit gain is tiny (+0.2-0.4 pp); ~0% of the win is the passive activation-residency mechanism we bound.
Autoregressive decoding generates one token per step and, at each step, must read the entire key/value history of every active sequence. With batch size $B$, sequence length $L$, number of KV heads $H_{kv}$, and head dimension $d$ in two bytes, the per-step KV read is roughly $2 \cdot B \cdot L \cdot H_{kv} \cdot d \cdot 2$ bytes, while the matmul work per step is tiny — a single query vector against the cached keys. The arithmetic intensity of decode is therefore far below the GPU's compute-to-bandwidth ratio, so the attention kernel spends most of its time waiting on HBM. As context windows grow into the tens of thousands of tokens and agentic workloads accumulate ever-longer histories, the fraction of decode time spent moving KV bytes only increases.
PagedAttention stores the KV-cache in fixed-size physical blocks scattered across HBM, indexed through a per-sequence block table. The decode kernel walks these blocks in a strided loop — warp $w$ handles blocks $w, w{+}\text{NUM\_WARPS}, w{+}2\,\text{NUM\_WARPS}, \dots$ Each loop iteration must (1) translate the logical block to a physical block via the block table, (2) issue a global load for that block's keys, and (3) do the dot products. Because the load address is only known once the iteration begins, the kernel cannot issue that load early enough for the hardware to hide its long latency; the warp issues the load and then sits idle for hundreds of cycles waiting on it — what Nsight calls a long-scoreboard stall — before compute can resume. The stall is the exposed latency of a load that was issued too late, not a claim that the bytes had to travel from HBM (in steady state much of the KV is already L2-resident); the cost is that the warp waits in front of compute instead of behind it. Repeated across every block of every sequence, these stalls dominate the kernel's runtime.
Stock vLLM's PagedAttention is already a heavily-tuned kernel: it vectorizes the loads, uses shared memory for the running softmax, and overlaps thread-group work. What it does not do is look ahead in the block table. The hardware prefetcher cannot help, because the next block's address is a pointer-chase through the block table rather than a linear stride in virtual memory — the very indirection that makes paging flexible also defeats automatic prefetch. So the baseline issues each block's load only at the start of its iteration and pays that load's full latency in the open, even though the address of the next block is, in fact, computable several iterations early from the same block table the kernel already holds in registers. (The latency is exposed because the load is issued late, not because the bytes are necessarily cold in HBM — in steady state much of the KV is already L2-resident; what's missing is the early issue, not the data.)
One could double-buffer the next block into SRAM with cp.async. But the decode KV working set per warp is small and shared memory is already spent on the softmax reduction; the win is marginal and the occupancy cost is real. Crucially it changes the kernel's memory hierarchy and register pressure.
Larger paged blocks amortize the per-block setup but waste HBM and capacity, and a fully fused FlashAttention-style kernel is a major rewrite that the XFORMERS V0 path was not built around. Neither is a drop-in for an existing deployment.
As 2.3 explains, the block-table indirection defeats the stride-based hardware prefetcher. Doing nothing leaves the latency fully exposed. The address is known to software but not to the prefetcher — so software must volunteer it.
The obstacle the paper must clear is precision-without-disruption: it needs to issue the next block's load/prefetch early enough to hide exposed long-scoreboard latency before use, without consuming shared memory, without blocking the issuing warp, and without rewriting the kernel. Hopper (sm_90+) supplies exactly the right primitive — cp.async.bulk.prefetch.L2.global — a non-blocking bulk-prefetch hint that requests data be moved from global memory toward L2 and returns immediately, touching neither registers nor shared memory of the issuing warp. (It is a scalar L2-prefetch instruction in the same async-bulk-copy family as TMA, but unlike a full TMA tensor copy it needs no tensor-map descriptor and writes nothing into shared memory.) The paper's positioning is therefore narrow and honest: it is not a new attention algorithm; it is a minimal, additive latency-hiding patch that exploits a Hopper hardware feature to prefetch a tensor whose address the software already knows.
block_idx + NUM_WARPS), reusing data the kernel already touches.attention_kernels.cuh; no scheduler, model, or API changes. Measured ~2.15× kernel and up to ~1.97× e2e on H20.The whole idea is to convert serial "load-then-compute" into overlapped "compute-while-loading". In the baseline, each block iteration begins with an exposed late global-load (long-scoreboard) stall (the warp waits in front of compute), then computes. In the patched kernel, lane 0 fires a prefetch for the next block at the start of the current iteration; the bulk-prefetch engine issues that next block's load toward L2 in the background while the warp computes the current block. By the time the next iteration needs that block, its load is already in flight, so the long-latency global load is hidden behind compute rather than serialized in front of it. (Measured caveat: the baseline KV is already ~85% L2-resident, so the observable signature is a small drop in memory stalls, not a large L2-hit jump — see Sections 6-7.)
The orange dashed cells (baseline stalls) shrink once the next block's bytes have been pulled toward L2 ahead of use. The bottom lane is the non-blocking bulk-prefetch path through the cache subsystem, independent of the warp's compute, which is what makes the overlap free of register and shared-memory cost.
Because the block loop is strided by NUM_WARPS — warp $w$ processes $w, w{+}\text{NUM\_WARPS}, \dots$ — the block a given warp will touch next is exactly block_idx + NUM_WARPS, not block_idx + 1. The patch therefore looks ahead by the stride, which keeps each warp prefetching precisely the block it itself will consume. This is read straight from the block table the warp already holds, so the lookahead address costs one extra table read and a comparison.
// attention_kernels.cuh:258 — next physical block from the block table
const int64_t next_physical_block_number = (block_idx + NUM_WARPS) < end_block_idx ?
block_table[block_idx + NUM_WARPS] : 0;
Inside the per-token vector loop, exactly one lane (lane 0, first vector) issues the prefetch, guarded so the last block — whose next_physical_block_number is the sentinel 0 — is never prefetched. The address is masked to a 16-byte boundary (the low nibble cleared by & 0xFFFFFFFFFFFFFFF0) as the bulk-prefetch instruction requires an aligned global address, and the size is a fixed 4096 bytes — one paged block's worth of key data.
// attention_kernels.cuh:281-287 — Prefetch Key Cache
if (j == 0 && lane == 0) {
const cache_t* next_k_ptr = k_cache + next_physical_block_number * kv_block_stride +
kv_head_idx * kv_head_stride;
if (next_physical_block_number != 0)
asm volatile ("cp.async.bulk.prefetch.L2.global [%0], 4096;"
::"l"((unsigned long)next_k_ptr & 0xFFFFFFFFFFFFFFF0UL));
}
The value cache is read in a separate loop (the second pass that computes the weighted sum of values). The patch repeats the same pattern there: recompute next_physical_block_number for the value loop's iteration, and on the first row of lane 0 issue an identical bulk prefetch against the value pointer. The structure is intentionally a mirror of the key prefetch, which is why the diff is so small.
// attention_kernels.cuh:425-431 — Prefetch Value Cache
if (i == 0 && lane == 0) {
const cache_t* next_v_ptr = v_cache + next_physical_block_number * kv_block_stride +
kv_head_idx * kv_head_stride;
if (next_physical_block_number != 0)
asm volatile ("cp.async.bulk.prefetch.L2.global [%0], 4096;"
::"l"((unsigned long)next_v_ptr & 0xFFFFFFFFFFFFFFF0UL));
}
.L2.global: data is moved from global memory (HBM) into the L2 cache, not into shared memory and not into registers. That is exactly the cache level our "Worth the Bytes?" study is about. But note three things the code makes unambiguous: the prefetch is (1) issued by software (an explicit asm volatile), (2) intra-kernel (the same kernel prefetches its own future iteration), and (3) on the KV-cache (k_cache / v_cache), a long-lived high-reuse tensor. Our bound concerns the opposite on all three axes: passive, inter-kernel, activation residency.
The benefit per block iteration is the hidden latency $t_{hide} = \min(t_{load},\, t_{compute})$, where $t_{load} \approx t_{scoreboard}$ is the exposed global-load / cache-hierarchy latency of the late load — the prefetch can hide at most as much exposed global-load latency as there is compute to overlap with. The cost is one block-table read, one comparison, one address mask, and the launch of one asynchronous prefetch per block, all on a single lane. Net speedup appears when $t_{hide}$ accumulated over all blocks dominates the tiny issue overhead, which is exactly the memory-bound regime decode lives in. This is why the paper sees its largest wins on long sequences and decode-heavy workloads.
Although the patch is tiny, each token in the PTX is load-bearing, and getting any of them wrong would either crash the kernel or silently corrupt the prefetch. Three details are worth spelling out because they are exactly the kind of thing a reproduction must preserve byte-for-byte.
The address is ANDed with 0xFFFFFFFFFFFFFFF0 to clear its low four bits. The bulk-prefetch instruction requires a 16-byte-aligned global address; without the mask, an unaligned pointer would fault. Clearing the low nibble is safe here because it only rounds the prefetch start down to the enclosing aligned line.
When block_idx + NUM_WARPS reaches past end_block_idx, the lookahead is set to 0, and the if (next_physical_block_number != 0) guard suppresses the prefetch. This prevents reading a non-existent next block — both an out-of-bounds risk and a wasted memory transaction at every sequence boundary. (0 is a conservative no-next sentinel for the optimization, not a correctness invariant: if physical block 0 were ever a valid next block, the guard would merely skip a legal prefetch — a missed optimization, never a correctness break.)
The prefetch fires only on lane == 0 (and j == 0 / i == 0), so one instruction is issued per warp per block, not 32. Bulk prefetch is a per-region request; duplicating it across all lanes would multiply traffic and serialize on the same L2 line for no benefit.
The repository is striking in its minimalism. It contains no build system, no benchmark scripts, no Python — just a license, a short README, and one CUDA header. The entire intellectual contribution is shipped as a drop-in replacement for one vLLM file. We cloned it to the path below and confirmed its contents are exactly as described.
The header is a near-verbatim copy of vLLM's own csrc/attention/attention_kernels.cuh with the prefetch additions woven in. The additive change is small — two next-block computations, two PTX prefetch sites (one for K at line 286, one for V at line 430), each behind a last-block guard. The table below maps the paper's mechanism onto exact code locations we read in the cloned header.
| Mechanism | Location in attention_kernels.cuh | What it does |
|---|---|---|
| Strided block loop | :224-225, :389-390 |
warp w iterates blocks w, w+NUM_WARPS, ... (stock vLLM) |
| K next-block lookahead | :258-259 |
next_physical_block_number = block_table[block_idx + NUM_WARPS] or 0 |
| K prefetch (PTX) | :281-287 |
lane 0 issues cp.async.bulk.prefetch.L2.global for next K block, 4096 B |
| V next-block lookahead | :407-408 |
same lookahead recomputed for the value loop |
| V prefetch (PTX) | :425-431 |
lane 0 issues cp.async.bulk.prefetch.L2.global for next V block, 4096 B |
| Last-block guard | :285, :429 |
if (next_physical_block_number != 0) — 0 is a conservative no-next sentinel for the prefetch (skips, never corrupts) |
papers/kv-prefetch/repo/ contains exactly three files (LICENSE, README.md, attention_kernels.cuh) with a single git commit (0b804d0 "upload cuh and README"). The line numbers above are read from that file. Because the repo ships no setup.py and no benchmarks, our reproduction harness is reconstructed from vLLM's own benchmarks/benchmark_throughput.py — a deliberate, documented choice.
Each paper/README claim, checked against the cloned header. Performance claims are the paper's H20 numbers; our own measured H100 numbers are in Sections 6-7.
| Claim | Status | Code evidence |
|---|---|---|
| Adds cp.async.bulk.prefetch.L2.global for next K and V blocks | YES | Two identical asm volatile sites: K @ :286, V @ :430 |
| Prefetches the NEXT block during current-block compute | YES | lookahead = block_idx + NUM_WARPS (:258), issued inside the compute loop before the block is consumed |
| Minimal, additive patch over stock vLLM | YES | One header; the loops, softmax, and reductions are stock vLLM; only the lookahead + 2 prefetch sites are new (~10 lines) |
| Targets the XFORMERS backend's PagedAttention kernel | YES | File is csrc/attention/attention_kernels.cuh (paged_attention_v1/v2); README sets VLLM_ATTENTION_BACKEND=XFORMERS |
| ~2.15x attention-kernel speedup | MEASURED: ncu only ~1.01-1.02x | Paper's H20 number. Our H100 ncu isolated-kernel time is only 1.01-1.02x (45.4->44.8 us b1; 45.6->44.8 us b8); the big win is async overlap across the warm decode loop, which cold-cache replay under-represents (Section 7). |
| up to ~1.97x end-to-end throughput | MEASURED: up to 1.80x | Reproduced on H100: 1.80x at batch 1, 1.46x at batch 8, 0.99x at batch 32. Big at low batch / decode-heavy, vanishing to ~1.0x at batch 32 (compute-bound) (Section 6.4). |
| Requires Hopper (sm_90) | YES | cp.async.bulk.prefetch is a Hopper bulk-async-prefetch instruction requiring sm_90+; our build targeted 9.0a. A100/L40S/RTX6000 (sm_80/89) are excluded. |
The measured answer is concrete: ~0% of this paper's speedup is the passive inter-kernel activation residency we bound at <0.65% BOUND. The reasoning follows directly from the code and is confirmed by the counters. The carryover here is (1) software-issued — an explicit PTX instruction, not data left behind incidentally; (2) intra-kernel — the same kernel prefetches a later iteration of itself, so no second kernel is involved at all; and (3) on the KV-cache — a tensor that is read every decode step, not a transient activation. Each of those three properties places it outside the definition of the quantity our study measures. The measured signature matches at the kernel level: the cold-replay per-kernel delta (ncu, 1.01-1.02x) tracks a memory-stall reduction (long-scoreboard 9.2%->8.5%) with HBM read flat — not any cross-kernel cache hand-off. The headline 1.80x is the warm-loop e2e figure; the cold-replay-vs-warm-loop gap is reported separately (Section 7), not folded into the stall drop.
| ncu metric | Isolates | Measured base → patch (H100) |
|---|---|---|
lts__t_sector_op_read_hit_rate.pct |
L2 READ hit rate — the prefetch's direct fingerprint | 85.2->85.4% (b1), 84.6->85.0% (b8) — already L2-resident |
smsp__warp_issue_stalled_long_scoreboard_per_warp_active.pct |
Global-load memory stall — the actual speedup lever | 9.2% -> 8.5% (latency hidden) |
dram__bytes_read.sum |
HBM read traffic — separates this from a dedup/traffic-cut mechanism | 2.01 MB -> 2.01 MB (flat; same KV reads issued earlier, not removed) |
sm__throughput.avg.pct_of_peak_sustained_elapsed |
Compute utilization — rises as stalls are hidden | 33.9% -> ~35.4% (up) |
gpu__time_duration.sum |
Per-kernel time — the speedup numerator/denominator | 45.4->44.8 us (1.01x, b1); 45.6->44.8 us (1.02x, b8) |
We use the project's proven application-replay recipe (--replay-mode application --cache-control all --clock-control none --launch-count 40 --launch-skip 200, kernel-filtered to regex:paged_attention) to get cold-cache semantics without kernel-replay's host-RAM snapshot, which OOM'd this project at 67 GB on an H200.
Random dataset, input-len 512, output-len 512, tok/s base -> patch. This reproduces the paper's e2e regime (up to ~1.97x on Hopper): the win is big at low batch / decode-heavy and vanishes by batch 32 (compute-bound). We ran H100; the paper's headline was H20.
| Batch | Baseline (tok/s) | Patched (tok/s) | Speedup |
|---|---|---|---|
| 1 | 115.5 | 207.8 | 1.80× |
| 8 | 1159.8 | 1692.2 | 1.46× |
| 32 | 5057 | 4994 | 0.99× |
Nsight Compute on paged_attention_v1 (cold-cache application replay, --cache-control all, input-len 1024 output-len 256, mean over ~40 launches). The isolated kernel speedup is only ~1.01-1.02x — far below the e2e 1.80x — because the baseline is already ~85% L2-resident and cold-cache replay under-represents the warm-loop async overlap. The mechanism is visible as a memory-stall drop, not an L2-residency jump.
| Quantity | Batch 1 | Batch 8 | Tag |
|---|---|---|---|
| Kernel time base -> patch (speedup) | 45.4 -> 44.8 us (1.01×) | 45.6 -> 44.8 us (1.02×) | FACT |
| L2 read-hit rate | 85.2% -> 85.4% | 84.6% -> 85.0% | FACT |
| Long-scoreboard mem-stall | 9.2% -> 8.5% | 9.2% -> 8.5% | FACT |
| DRAM read | 2.01 MB (flat) | 2.01 MB (flat) | FACT |
| SM busy | 33.9% -> ~35.4% | 33.9% -> ~35.4% | FACT |
| Persisting-L2 access-policy window | 9.8 MB (both builds) | 9.8 MB (both builds) | FACT |
| Passive activation-residency share of gain | ~0% (outside our <0.65% bound) | BOUND | |
Profiling both builds on the same workload decides, from the hardware counters alone, which lever produces the speedup. There are four candidate explanations; the measured metric pattern distinguishes them. The H100 counters confirm lever (A): the per-kernel time delta tracks the long-scoreboard mem-stall drop (9.2%->8.5%), L2 read-hit barely moves (already ~85% resident), and HBM read is flat (2.01 MB) — so it is active latency-hiding of the prefetched KV, not residency or a traffic cut.
Signature: long-scoreboard stall DOWN, time-delta tracks the stall drop. MEASURED: yes — stall 9.2%->8.5%, kernel 1.01-1.02x, L2 hit already ~85% so it barely rises.
Would show as shared-mem traffic change. CODE: ruled out — the asm targets .L2.global and adds no smem allocation, so the kernel's shared-memory use is unchanged.
Would show as fewer launches. CODE: ruled out — the patch is intra-kernel and adds no new launch, so the launch count is unchanged between builds.
Would show as dram__bytes_read DOWN. MEASURED: ruled out — bytes flat (2.01 MB); same KV read, just earlier.
The decisive test was correlation, and the counters bear it out: the per-kernel time reduction tracks the drop in long-scoreboard stall percentage, so the speedup is latency-hiding of the prefetched KV — lever (A). HBM bytes staying flat (2.01 MB) prove the same KV reads were issued earlier / overlapped — the traffic was not removed, only its latency hidden; and by construction the patch adds no kernel launch and no shared-memory allocation, ruling out levers (C) and (B). One honest subtlety: the L2 read-hit rate barely rises (+0.2-0.4 pp) because the baseline KV is already ~85% L2-resident — so the prefetch's fingerprint is the stall drop, not a residency jump. This is the same separation logic our measurement study applies to bound passive residency — here it identifies an entirely different, active lever.
This is why the paper belongs in our study as a sharpening case rather than a counterexample. Our bound is about one specific quantity: the value of letting a prior kernel's output activation sit in L2 long enough that the next kernel reads it from L2 instead of HBM, with no software doing anything to make that happen. The prefetch patch differs on every axis that defines that quantity.
| Axis | Our bound (<0.65%) | This paper's prefetch |
|---|---|---|
| Agency | PASSIVE — data left in L2 incidentally | ACTIVE — explicit cp.async.bulk.prefetch.L2 PTX |
| Scope | INTER-kernel — kernel N -> kernel N+1 | INTRA-kernel — same kernel, future iteration |
| Tensor | ACTIVATION — transient, written then read once | KV-CACHE — long-lived, re-read every decode step |
| Payoff | Avoided HBM re-read of carried-over bytes | Latency-hiding: overlap fetch with current-block compute |
| HBM bytes moved | REDUCED (re-read avoided) | UNCHANGED (same reads issued earlier, not removed) |
The repo ships no build or benchmark scripts, so reproduction means: build stock and patched v0.10.2, then drive both with vLLM's own throughput benchmark and Nsight Compute. The two sbatch scripts below ran on PACE H100 (job 9948442); the build and the H100 run are complete and the numbers in Sections 6-7 are the result. (vLLM has since moved benchmark_throughput.py to `vllm bench throughput`.)
# 1. clone vLLM v0.10.2 (twice: baseline + patched)
git clone --depth=1 --branch v0.10.2 https://github.com/vllm-project/vllm.git baseline
git clone --depth=1 --branch v0.10.2 https://github.com/vllm-project/vllm.git patched
# 2. drop the repo header into the patched tree only
cp attention_kernels.cuh patched/csrc/attention/attention_kernels.cuh
# 3. build EACH tree into its OWN conda env (avoids two-tree .so drift)
export TORCH_CUDA_ARCH_LIST="9.0a" # 'a' variant exposes the bulk-prefetch instruction
# module load cuda/12.9.1 (NOT 12.6.1 — ptxas SIGSEGVs on FA3 Hopper kernels)
pip install --no-cache-dir --no-build-isolation -e .
pip install xformers
export VLLM_ATTENTION_BACKEND=XFORMERS
export VLLM_USE_V1=0 # exercise the legacy PagedAttention decode kernel
# end-to-end throughput (decode-heavy: long output_len)
python benchmarks/benchmark_throughput.py --model Llama-3.1-8B \
--dtype float16 --input-len 512 --output-len 512 --num-prompts 8 --output-json out.json
# kernel attribution (cold-cache, application-replay; NOT kernel-replay -> host-RAM OOM)
ncu --replay-mode application --cache-control all --clock-control none \
--target-processes all -k "regex:paged_attention" \
--launch-count 40 --launch-skip 200 --csv --page raw \
--metrics gpu__time_duration.sum,dram__bytes_read.sum,\
lts__t_sector_op_read_hit_rate.pct,\
smsp__warp_issue_stalled_long_scoreboard_per_warp_active.pct,\
sm__throughput.avg.pct_of_peak_sustained_elapsed \
python benchmarks/benchmark_throughput.py --model Llama-3.1-8B ...
| Artifact (our project tree) | Purpose |
|---|---|
pace/prefetch_build_cpu.sbatch | builds v0.10.2 baseline + patched, sm_90a, dumps torch pins + patch diff + sm-arch list |
pace/prefetch_run_h100.sbatch | e2e throughput + ncu attribution on H100; emits prefetch_attribution.json |
papers/kv-prefetch/repo/attention_kernels.cuh | the treatment header (drop-in over stock v0.10.2) |