Harshith Kantamneni
Kernels

GQA in NVIDIA cuDNN

What removing K/V head copies did on an A100, why that code closed, and the tests that merged.

Tests merged Oct 6, 2026. Prototype closed Sep 27, 2026

K/V copies, at one shape on an A100
faster with them gone, at batch 1
NVIDIA's SM80 tests missed, mine caught

On an A100 at 64/8 heads and a 128K KV cache, K/V head copies in NVIDIA's cuDNN frontend took 7.28 ms per call against a 6.0-6.7 ms attention kernel. That is one shape. Removing the copies made 6 batched rows 2.6-3.3× faster, and 10 more stopped running out of memory. Those rows are really 3 configs and 5 configs. Multi-token steps at batch 1 ran 1.15-2.19× faster at that cache size. Clocks weren't locked, and the cuDNN backend stayed faster on 27 of 31 multi-token rows.

The merged code is tests, not a kernel. Those tests catch 3 injected bugs that NVIDIA's SM80 tests missed. I closed my prototype after NVIDIA shipped native GQA in #1239, and I didn't measure #1239.

What I built

In grouped-query attention (GQA), several query heads share one KV head. The SM80 adapter copied K and V up to the query head count on every call. The kernels already map each query head to its KV head, with h // (H_q // H_kv).

The prototype changed the adapter and no kernel file. That removed an expansion of 4 GiB per call at 64/8 heads. After #1239, I closed it. On issue #377, one option I offered was a tests-only PR, and that's what was suggested back.

That PR adds 80 tests and changes no library code. Of those, 66 cases check that native GQA gives bit-for-bit the same O and LSE as the same inputs with K/V copied up. NVIDIA's GQA tests used 4/2 and 16/4 heads. At those ratios, query heads per KV head equals KV heads, so a kernel that mixes up the two still passes. The new tests add 8/4 and 8/2.

What I measured

Timing

I used one rented A100-SXM4-80GB and bf16. Each timing is the median of 20 samples of kernel device time, which on the develop branch includes the copies. Develop and the prototype alternated in one pod. Rows with identical code on both sides had a median ratio of 1.000, and I only claimed rows outside their control band.

At batch 1 with a 2-token query (S_q=2), the gain ranges from 1.15× to 2.19×, and the backend stayed faster on all 5 shapes. conditions
Heads and dDevelop (ms)Prototype (ms)Faster by (×)cuDNN backend (ms)
64/8 d12814.576.662.193.30
64/8 d648.234.231.954.09
32/8 d1289.916.661.493.30
32/2 d25617.1512.551.379.96
8/1 d1287.596.631.153.30

I read the time saved as following the copy's size. At d128, the expansion is 8x smaller at 8/1 heads than at 64/8 heads, and the kernel takes about the same time.

Develop ran out of memory on 10 of the 16 batched rows, because it needed a 64-512 GiB expanded K/V workspace. The prototype ran all 10.

The 6 batched rows are 3 configs and the 10 out-of-memory rows are 5 configs, shown here at S_q=8. conditions
Heads, d and batchDevelop (ms)Prototype (ms)Faster bycuDNN backend (ms)
64/8 d64, batch 32155.947.13.31×53.5
8/1 d128, batch 128160.354.32.95×21.7
8/1 d128, batch 3242.416.32.60×8.5
64/8 d64, batch 128Out of memory179.0n/a212.7
64/8 d128, batch 32Out of memory103.8n/a43.0
32/8 d128, batch 32Out of memory54.5n/a22.1
32/2 d256, batch 32Out of memory102.8n/a93.7
32/2 d256, batch 128Out of memory391.2n/a367.8

The backend stayed faster on 27 of 31 multi-token rows. The other 4 are 64/8 d64 at batch 32 and 128.

Planted bugs

I planted bugs one at a time on an A100, restoring the kernel after each.

The first 3 bugs pass NVIDIA's SM80 tests and fail the new ones, and the control fails both. conditions
Planted bugNew tests failingNVIDIA's SM80 tests
LPT_L2 short last block split by the full group count4 of 80All 411 pass
KV head (h // H_kv) % H_kv70 of 80All 411 pass
KV head h // H_kv, d256 kernel only37 of 80All 60 d256 cases pass
KV head h % H_kv (control)70 of 80Caught, 30 fail

I read the control as showing that NVIDIA's suite does test head mapping, just not at a ratio that separates the two.

What it doesn't show

  • Correct output at a 128K KV cache. The timings don't check it.
  • It doesn't show a fast kernel. At 64/8 d128 the prototype takes 6.66 ms against the backend's 3.30 ms.
  • It doesn't explain that gap. I named a non-split KV walk and a mostly empty Q tile as other causes, but never timed either alone.
  • The 7.28 ms is 64/8 d128 at S_q=4, and develop's run there was bimodal. At 8/1 d128 the gain is only 1.15×.
  • Pass-to-pass spread ran up to 20% on some GQA rows, more than their control bands. The batched rows ran once per side.
  • Bitwise equality is fp16 only. The timings are bf16, which went through tolerance checks and an order-blind checksum.
  • Some shapes aren't servable. K and V take 0.5 GiB per layer at 64/8 d128, so batch 32 wouldn't fit an A100 across a whole model. The rows measure the kernel, not a deployment.
  • I didn't measure #1239, so I can't compare it with my prototype.