ML SYSTEMS · GPU INFERENCE

Attention Kernel Portability Across GPU Architectures

Measuring whether an attention backend chosen on one GPU stays a good choice on another, for forward prefill and single-token KV-cache decode, under one software build.

Contents
  1. Overview
  2. Experimental setup
  3. Winner and transfer cost
  4. Main results
  5. Prefill
  6. Decode
  7. Limitations
  8. Reproduce
  9. Supplementary material
  10. Run outcomes
  11. Dispatch checks
  12. Correctness
  13. Code sources
54,213
Valid timed runs
Out of 73,230 attempts on 6 GPUs
27/64
Prefill winner changes
Separated comparisons across Ampere, Ada and Hopper GPU pairs
29/289
Decode winner changes
Same pairs and the same separation rule
1.61x
p95 cost of reusing a prefill choice
Median 1.002x. Decode is 1.32x at p95

Overview

If you pick the fastest attention backend on one GPU, is it still the fastest on another GPU, and what does it cost if it is not? I measured this for forward prefill and single-token KV-cache decode on six NVIDIA GPUs covering Ampere, Ada, Hopper and Blackwell, all under one software build.

Most configurations did not have a clearly fastest backend. Only 128 of 447 prefill and 409 of 1203 decode configurations had a winner that was at least 10% ahead of second place and statistically separated from it. Among those, the winner changed between GPUs in 27 of 64 prefill comparisons but only 29 of 289 decode comparisons. Across all GPU pairs, reusing the source GPU’s choice usually cost almost nothing (median 1.002x prefill, 1.000x decode). The tail was worse, at 1.61x for prefill and 1.32x for decode at the 95th percentile.

Experimental setup

GPUArchitectureCCSMsMemory (GB)L2 (MB)Memory bandwidth (GB/s)Repeats
A100Amperesm8010885.141.917335
A10Amperesm867223.76.34835
L40Adasm8914247.7100.76442
L40SAdasm8914247.7100.76445
H100Hoppersm9013285.052.429805
RTX 5090Blackwellsm12017033.7100.715185
The 6 GPUs. Memory bandwidth is measured on each GPU by timing a 512 MB device-to-device copy. Repeats is the median number of separate process launches per configuration. L40 had fewer repeats than the others, so its intervals are wider.

Prefill covers batch 1, 4, 16 and sequence lengths 256, 512, 1,024, 2,048, 4,096, 8,192. Decode appends one token to a KV cache of length 512, 1,024, 2,048, 4,096, 8,192, 16,384 at batch 1, 8, 32, 64. The full grid also varies dtype (bf16 and fp16), head dimension (64 and 128), causal masking and, for decode, eager execution against CUDA Graphs. The figures below show a bf16, head dimension 128 slice. I measured 16 implementation variants from seven backend families.

Winner and transfer cost

A configuration has a winner only if the second-fastest backend is at least 10% slower and the lower bound of a 95% bootstrap interval on that ratio is above 1. With Tg,c,bT_{g,c,b} the median latency on GPU gg, configuration cc and backend bb, and b1,b2b_1, b_2 the fastest and second-fastest backends,

Rg,c=Tg,c,b2Tg,c,b1Sep⁡(g,c)=1 ⁣[Rg,c≥1.10  ∧  L95(Rg,c)>1]R_{g,c} = \frac{T_{g,c,b_2}}{T_{g,c,b_1}} \qquad \operatorname{Sep}(g,c) = \mathbf{1}\!\left[R_{g,c} \ge 1.10 \;\wedge\; L_{95}(R_{g,c}) > 1\right]

Failing this test means the gap was too small or too noisy to call. It does not mean the backends are equally fast.

The transfer cost is the target GPU’s time for the source GPU’s pick divided by the fastest time measured on the target.

Cg→h, c=Th,c, bg,c∗min⁡b∈Bh,cTh,c,bC_{g \rightarrow h,\,c} = \frac{T_{h,c,\,b^{*}_{g,c}}}{\min_{b \in \mathcal{B}_{h,c}} T_{h,c,b}}

A value of 1.20 means the transferred choice is 20% slower than the best option on the target. When the source’s pick could not run on the target, I counted the case separately instead of giving it a ratio.

Main results

Figure 1
Configurations with a separated fastest backend, by GPU
Separated fastest backends by GPU
  • Only 28.6% of prefill and 34.0% of decode configurations have a clear winner. No GPU reaches half in both regimes.

Figure 2
Winner changes and the cost of reusing a backend choice on another GPU
Winner changes and transfer cost
  • Across the Ampere, Ada and Hopper GPUs, the prefill winner changed in 27 of 64 comparisons (42%) and the decode winner in 29 of 289 (10%).

  • Across all GPU pairs, the median cost of reusing a choice is close to 1x, but 5% of prefill transfers cost 1.61x or more. In 85 of 590 prefill and 287 of 1974 decode cases the chosen backend could not run on the target GPU at all.

Prefill

Figure 3
Prefill latency, throughput and peak memory against sequence length, batch 4, head_dim 128, bf16
  • On H100 at N=8,192, FlashAttention-3 takes 3,311 us. cuDNN SDPA takes 3,663 us, Triton 4,510 us and FlashAttention-2 6,634 us. FlashAttention-3 leads by 10.6%, just past the 10% threshold.

  • At N=256 the order flips. cuDNN SDPA takes 36.2 us against 84.13 us for FlashAttention-3.

  • On A10 at N=8,192, Triton (32,160 us) and cuDNN SDPA (32,270 us) are within 1% of each other, so neither counts as the winner.

  • The unfused PyTorch reference is limited by memory. On H100 at N=4,096 it uses 21,070 MB against 706 MB for FlashAttention-2, because it stores the full score matrix.

Figure 4
Prefill speedup over SDPA FlashAttention by batch and sequence length
Prefill speedup over SDPA FlashAttention by batch and sequence length
  • The largest speedup is cuDNN SDPA on L40S at batch 1, N=256, at 2.31x. In absolute terms that saves only 71.23 us per call.

  • FlashAttention-3 reaches 2.48x on H100 at batch 1, N=2,048. It runs only on H100 because the build rejects every other architecture in the set, so 3,204 of its 3,999 attempts were unsupported.

Decode

Decode does little arithmetic and reads the whole KV cache, so I report it in bytes. The KV footprint for one decode step is

bytes  =  2 B Hkv N D s\text{bytes} \;=\; 2 \, B \, H_{kv} \, N \, D \, s

where BB is batch, HkvH_{kv} the number of KV heads, NN the KV length, DD the head dimension and ss the bytes per element. The factor 2 counts K and V. Effective bandwidth is this byte count divided by measured time. It is a model and was not read from a profiler.

Decode was measured with and without CUDA Graphs. The figures use eager execution, and every comparison uses the same mode on both GPUs.

Figure 5
Decode step time and effective KV bandwidth against KV length, hkv=8, head_dim=128, bf16

Loading measurements…

  • At batch 1 on H100 the FlashAttention KV-cache kernel barely changes with cache length. It goes from 102.1 us at N=512 to 103.8 us at N=16,384, so fixed overhead dominates.

  • At batch 1, FlashInfer is fastest at N=512 (78.13 us) but reaches 859.7 us at N=16,384, which is 8.3 times the FlashAttention latency. The best decode backend on one GPU depends on context length.

  • At batch 32 and N=16,384 on H100, FlashAttention takes 863.7 us and reaches 2,486 GB/s of modelled KV traffic. That is 83% of the measured memory bandwidth.

Figure 6
Fastest decode implementation per batch and KV length, 6 GPUs, hkv=8, head_dim=128, bf16
  • Each cell shows the implementation with the lowest median latency at that batch and KV length. 3 implementations (SDPA, FlashAttention-2 KV cache, FlashInfer) win at least one of the 144 cells. Only 34.0% of the full decode grid has a clear winner, so a colour change between neighbouring cells is often not a real difference. Hover a cell for its latency.

Fewer KV heads did not speed up every backend. At batch 1, N=16,384 on H100, going from 32 to 8 KV heads cuts the cache from 268 MB to 67 MB. FlashAttention improves from 129.5 to 103.8 us, SDPA stays about the same (135.9 to 142.3 us), and FlashInfer gets slower (280.3 to 859.7 us).

Limitations

Reproduce

The raw measurements are in release v1.2 with checksums. These commands rebuild everything on this page from them.

python -m akp.analysis ../akp-measurements-v1/raw --out results/processed
python paper/ledger.py
python -m akp.figures_portability results/processed --out site/public/figures
python -m akp.webdata results/processed site/public/data/web.json

Supplementary material

Show diagnostics

Run outcomes

Every attempt ends in one of five states. OK is a clean timed run. UNSUPPORTED means the library rejected the configuration. OOM_PREDICTED means the run was skipped because it would not fit in memory. OOM means it ran out of memory anyway. ERROR means it crashed at run time, and all 950 of these are FlashInfer on the RTX 5090.

ImplementationOKUNSUPPORTEDOOM_PREDICTEDOOMERRORClean (%)
PyTorch reference5,5300010099.8
torch.compile1945,3460003.5
SDPA5,535005099.9
FlashAttention-2 KV cache5,5150025099.5
FlashInfer4,565002595082.4
FlashAttention-2, q=15,5150025099.5
PyTorch reference3,148079061078.7
torch.compile3,20907900080.2
torch.compile, no pattern matching1213,8735003.0
torch.compile, torch.where mask1213,8735003.0
SDPA memory-efficient3,9693000099.2
SDPA FlashAttention3,9990000100.0
SDPA cuDNN3,9990000100.0
Triton tutorial kernel3,9990000100.0
FlashAttention-23,9990000100.0
FlashAttention-37953,20400019.9
Run outcomes by implementation, summed over all 6 GPUs.

Dispatch checks

For each run I recorded which kernel actually executed. Among probed runs, 0.449% ran a different kernel than the one requested. 33.5% of runs were not probed, mostly on the library paths. FlashAttention-2 has 3,067 unprobed runs and 932 confirmed as flash-attn kernels.

ImplementationObserved kernels (runs)Unprobed runs
PyTorch referenceunfused-gemm 5,509, unknown 210
torch.compiletriton 1877
SDPApytorch-flash 4,5251,010
FlashAttention-2 KV cacheflash-attn 4,516999
FlashInferflashinfer 3,4891,076
FlashAttention-2, q=1flash-attn 4,531984
PyTorch referenceunfused-gemm 2,792, unknown 41315
torch.compilepytorch-flash 30, triton 2,047, unfused-gemm 4, unknown 321,096
torch.compile, no pattern matchingtriton 8833
torch.compile, torch.where maskpytorch-flash 15, triton 7333
SDPA memory-efficientcutlass-fmha 1,645, unknown 1102,214
SDPA FlashAttentionpytorch-flash 1,6502,349
SDPA cuDNNcudnn 1,9882,011
Triton tutorial kerneltriton 1,5912,408
FlashAttention-2flash-attn 9323,067
FlashAttention-3flash-attn 238557
Observed kernel per implementation, in runs.
GPURuns probed (%)Mismatch (%)
A10067.30.077
A1056.30.285
L4056.50.462
L40S80.90.333
H10075.80.821
RTX 509052.50.599
Probe coverage and mismatch rate by GPU. Mismatch is a share of probed runs only.

TorchInductor’s attention-fusion counter read zero in all 3,645 runs where it was recorded, yet 45 profiled Inductor runs executed a flash-attention kernel. The counter alone is not a reliable signal of what ran.

Correctness

Every implementation was checked against the PyTorch reference on a sample of configurations, and none failed. Relative error is inflated where reference values are near zero, so absolute error and cosine similarity are the useful columns.

ImplementationChecked runsMax abs errMax rel errMin cosinevs baselineFailures
PyTorch reference3550.007474.41.0001.0000
torch.compile500.0010.0151.0000.4840
SDPA3600.001220.81.0000.7390
FlashAttention-2 KV cache3600.001220.81.0000.7390
FlashInfer3200.0010.0841.0000.5990
FlashAttention-2, q=13600.001220.81.0000.7390
PyTorch reference4150.0253,2041.0001.0000
torch.compile4150.0202,5171.0001.0810
torch.compile, no pattern matching490.0201,5711.0001.0690
torch.compile, torch.where mask490.0201,5711.0001.0690
SDPA memory-efficient4050.0122,0601.0000.7750
SDPA FlashAttention4150.0122,0601.0000.7750
SDPA cuDNN4150.0122,0601.0000.7750
Triton tutorial kernel4150.0122,0601.0000.7750
FlashAttention-24150.0122,0601.0000.7750
FlashAttention-3650.0117931.0000.6640
Numerical agreement with the PyTorch reference. The last-but-one column is the implementation's error divided by the reference path's own error.

Code sources

I wrote the PyTorch reference, its torch.compile variants and the SDPA call sites that force one backend each. The Triton kernel is the Triton fused-attention tutorial kernel with a small BF16 change. FlashAttention-2, FlashAttention-3 and FlashInfer are the unmodified flash-attn 2.8.3 and FlashInfer 0.6.18 libraries.

Figure 7
Winner change rate per GPU pair, all configurations against separated ones, 30 pairs
All configurationsConfigurations with a clear winner (at least 10% ahead and outside the bootstrap interval)

Prefill8 device pairs over 6 GPUs

Decode15 device pairs over 6 GPUs

The number at the end of each bar is how many configurations the rate uses. Pairs with fewer than 5 separated configurations are not drawn.

  • Counting all configurations makes decode look as unstable as prefill. A100 against L40 changes winner in 53.6% of 192 configurations, but in only 2.9% of the 35 with a clear winner.

  • Individual pairs get small once the rule is applied. A100 against H100 prefill keeps only 4 of 72 configurations, and L40 against L40S keeps 8. Pairs with a handful of separated configurations should not be read as rates.

RegimePairConfigsChange, all (%)SeparatedChange, separated (%)
decodeA100 vs A1019141.9405.0
decodeA100 vs L4019253.6352.9
decodeA100 vs L40S19268.8156.7
decodeA100 vs H10019226.66312.7
decodeA100 vs RTX 509019271.41866.7
decodeA10 vs L4019148.2360.0
decodeA10 vs L40S19165.4150.0
decodeA10 vs H10019149.72020.0
decodeA10 vs RTX 509019189.01963.2
decodeL40 vs L40S19232.3234.3
decodeL40 vs H10019263.52733.3
decodeL40 vs RTX 509019282.82572.0
decodeL40S vs H10019270.81520.0
decodeL40S vs RTX 509019278.6988.9
decodeH100 vs RTX 509019270.31492.9
prefillA100 vs A107272.2714.3
prefillA100 vs L407265.370.0
prefillA100 vs L40S7277.81100.0
prefillA100 vs H1007287.54100.0
prefillA100 vs RTX 50907251.4366.7
prefillA10 vs L407241.750.0
prefillA10 vs L40S7250.0250.0
prefillA10 vs H1007265.31030.0
prefillA10 vs RTX 50907261.1425.0
prefillL40 vs L40S7243.1812.5
prefillL40 vs H1007286.18100.0
prefillL40 vs RTX 50907269.4475.0
prefillL40S vs H1007270.81266.7
prefillL40S vs RTX 50907272.22100.0
prefillH100 vs RTX 50907275.01747.1
All 30 GPU pairs. "All" counts every shared configuration. "Separated" counts only those with a clear winner on both GPUs.