Skip to content

cuda: tile the lightning indexer over keys and tokens for 4 heads - #29901

Merged
ggerganov merged 4 commits into
ggml-org:masterfrom
ServeurpersoCom:cuda-lightning-indexer-tile-4-heads
Oct 5, 2026
Merged

ggerganov merged 4 commits into
ggml-org:masterfrom
ServeurpersoCom:cuda-lightning-indexer-tile-4-heads

Conversation

@ServeurpersoCom

Copy link
Copy Markdown
Contributor

Overview

Follow up to #29825, as suggested by @am17an: this ports the structure of the Vulkan shader by @0cc4m to CUDA for the 4 head case (qwen4exp), where the vector kernel reads every key again for each token.

A block scores 64 keys against 8 tokens with the keys staged once in half precision and half precision products accumulated in float, and batches smaller than a token tile keep the vector kernel so decoding is unchanged.

On an RTX PRO 6000 the kernel is 2.5x faster in prefill (6.4 vs 16.4 ms at kv 65536, nb 2048), and qwen4exp with my prod config goes from PP 3867 -> 4038 t/s @ 128k with TG unchanged, the indexer dropping from 9.6% to 4.0% of the GPU time in nsys.

Additional information

test-backend-ops passes and test-llama-archs passes on CUDA for every lightning indexer model (deepseek32, deepseek4, glm-dsa, dots3note, hy_v4, glm5-next, qwen4exp).

The dispatch follows the existing style of the file, one case per K type in each branch. Listing the types once in a macro could be an optional cleanup in a separate PR, since several dispatches across the codebase could be tidied the same way, out of scope here.

Older architectures will gain even more performance

The gap was found in the benchmark @0cc4m posted on #29825: at 4 heads the Vulkan shader runs at 9.32 TFLOPs on a 3090 against 2.34 for the CUDA vector kernel (4x), and 6.12 against 2.28 on a GB10. On my RTX PRO 6000 the vector kernel already reaches 8.5 TFLOPs, so the 2.5x measured here is the low end, and I expect a larger gain on Ampere and other older architectures. Numbers from someone with a 3090 would be welcome.

Requirements

With too few heads for a wmma tile, a block scores 64 keys against 8
tokens: the keys are staged once in half precision, the queries one
head at a time, and each thread owns one key for two tokens, so no dot
product needs a cross thread reduction. Batches smaller than a token
tile keep the vector kernel. test-backend-ops measures 4 heads.
@ServeurpersoCom
ServeurpersoCom requested review from a team and ggerganov as code owners October 3, 2026 10:20
@ServeurpersoCom
ServeurpersoCom requested a review from am17an October 3, 2026 10:21
@github-actions github-actions Bot added testing Everything test related ggml changes relating to the ggml tensor library for machine learning CUDA Related to the CUDA backend labels Oct 3, 2026
@am17an
am17an requested a review from fairydreaming October 3, 2026 16:21
@eapache

eapache commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

On my Frankensetup (a 3060 connected via only PCIe3x4, and 128GB of system RAM at 2400MT/s):

This branch:

ggml_cuda_init: found 1 CUDA devices (Total VRAM: 11878 MiB):
  Device 0: NVIDIA GeForce RTX 3060, compute capability 8.6, VMM: yes, VRAM: 11878 MiB
| model                          |       size |     params | backend    | ngl |  n_cpu_moe | n_ubatch |         lm | lazy_mode  |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | --: | ---------: | -------: | ---------: | ---------- | --------------: | -------------------: |
| qwen4exp A3B Q4_K - Medium     | 103.68 GiB |   176.94 B | CUDA       |  -1 |         99 |     2048 |       none | off        |          pp2048 |        138.10 ± 2.45 |
| qwen4exp A3B Q4_K - Medium     | 103.68 GiB |   176.94 B | CUDA       |  -1 |         99 |     2048 |       none | off        |           tg128 |         13.01 ± 0.04 |

Master (1537a0a):

ggml_cuda_init: found 1 CUDA devices (Total VRAM: 11878 MiB):
  Device 0: NVIDIA GeForce RTX 3060, compute capability 8.6, VMM: yes, VRAM: 11878 MiB
| model                          |       size |     params | backend    | ngl |  n_cpu_moe | n_ubatch |         lm | lazy_mode  |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | --: | ---------: | -------: | ---------: | ---------- | --------------: | -------------------: |
| qwen4exp A3B Q4_K - Medium     | 103.68 GiB |   176.94 B | CUDA       |  -1 |         99 |     2048 |       none | off        |          pp2048 |        137.88 ± 2.51 |
| qwen4exp A3B Q4_K - Medium     | 103.68 GiB |   176.94 B | CUDA       |  -1 |         99 |     2048 |       none | off        |           tg128 |         12.93 ± 0.27 |

So this branch is plausibly a bit faster but well within the margin of error. nvtop shows the 3060 running at full tilt during PP so I wouldn't have thought that the bottleneck is feeding it the weights, but maybe that is a confounding factor. Certainly not the large gain on an Ampere card I was expecting.

@eapache

eapache commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

On the other hand the microbenchmark shows a huge improvement. From

158854.29 us/run - 138.65 GFLOP/run - 872.79 GFLOPS

to

48764.67 us/run - 138.65 GFLOP/run - 2.84 TFLOPS

raw benchmark output
(cuda-lightning-indexer-tile-4-heads)$ ./build/bin/test-backend-ops perf -b CUDA1 -o LIGHTNING_INDEXER -p 'nh=4,kv=65536,nb=2048,ns=1,nm=1,type_K=f16'
ggml_cuda_init: found 2 CUDA devices (Total VRAM: 44005 MiB):
  Device 0: NVIDIA RTX PRO 4500 Blackwell, compute capability 12.0, VMM: yes, VRAM: 32126 MiB
  Device 1: NVIDIA GeForce RTX 3060, compute capability 8.6, VMM: yes, VRAM: 11878 MiB
Testing 3 devices

Backend 1/3: CUDA0
  Skipping
Backend 2/3: CUDA1
  Device description: NVIDIA GeForce RTX 3060
  Device memory: 11878 MB (11060 MB free)

ggml_backend_cuda_graph_compute: CUDA graph warmup complete
  LIGHTNING_INDEXER(hsk=128,nh=4,kv=65536,nb=2048,ns=1,nm=1,type_K=f16):                  21 runs - 48764.67 us/run - 138.65 GFLOP/run -   2.84 TFLOPS
  Backend CUDA1: OK

vs

(master)$ ./build/bin/test-backend-ops perf -b CUDA1 -o LIGHTNING_INDEXER -p 'nh=4,kv=65536,nb=2048,ns=1,nm=1,type_K=f16'
ggml_cuda_init: found 2 CUDA devices (Total VRAM: 44005 MiB):
  Device 0: NVIDIA RTX PRO 4500 Blackwell, compute capability 12.0, VMM: yes, VRAM: 32126 MiB
  Device 1: NVIDIA GeForce RTX 3060, compute capability 8.6, VMM: yes, VRAM: 11878 MiB
Testing 3 devices

Backend 1/3: CUDA0
  Skipping
Backend 2/3: CUDA1
  Device description: NVIDIA GeForce RTX 3060
  Device memory: 11878 MB (11060 MB free)

ggml_backend_cuda_graph_compute: CUDA graph warmup complete
  LIGHTNING_INDEXER(hsk=128,nh=4,kv=65536,nb=2048,ns=1,nm=1,type_K=f16):                   7 runs - 158854.29 us/run - 138.65 GFLOP/run - 872.79 GFLOPS
  Backend CUDA1: OK

@ServeurpersoCom

Copy link
Copy Markdown
Contributor Author

Thanks for testing! With the experts on the CPU the run is bound by system RAM and PCIe, so an e2e number can't show a kernel that only speeds up the GPU side, while your microbench shows exactly that: 3.3x on the 3060.

@eapache

eapache commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Also I didn't give llama-bench a depth arg, and at depth 0 the lightning indexer isn't really relevant anyway 🤦

At depth 65536 (otherwise all the same config) this branch speeds up pp2048 from:

122.71 ± 1.18

to

128.51 ± 1.09

Which is much more significant.

@LucaAmigoni

Copy link
Copy Markdown

Quite interesting results on 2x RTX PRO 6000 Blackwell Max-Q:

Prefill cb7934c PR 29901 Gain
pp512 3990 3973 -0,43%
pp2048 5030 5053 0,46%
pp8192 4582 4637 1,20%
pp32768 4020 4187 4,15%
pp131072 3098 3503 13,07%

And with PR-29901\test-backend-ops perf -b CUDA0 -o LIGHTNING_INDEXER -p 'nh=4,kv=65536,nb=2048,ns=1,nm=1,type_K=f16':
ggml_backend_cuda_graph_compute: CUDA graph warmup complete LIGHTNING_INDEXER(hsk=128,nh=4,kv=65536,nb=2048,ns=1,nm=1,type_K=f16): 124 runs - 8107.77 us/run - 138.65 GFLOP/run - 17.10 TFLOPS

Comment thread ggml/src/ggml-cuda/lightning-indexer.cu Outdated
const half2 k_val = k_shared[kl][c];
#pragma unroll
for (int j = 0; j < TOKENS_PER_THREAD; ++j) {
ggml_cuda_mad(qk[j], k_val, q_shared[tl + j*TOKEN_GROUPS][c]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from what I understand this will do f16 product, so is |q * k| > f16_max it will overflow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, fixed: the queries now stay in float and every product is computed in float from the widened keys, which also made the kernel 10% faster on the RTX PRO 6000!

Address review from am17an: the half2 products overflow once a single
q * k exceeds the f16 range. The queries stay in float in shared memory
and each half2 of keys is widened once for both tokens, so every
product and sum is computed in float.
@am17an

am17an commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

@ServeurpersoCom I did a bot review and here are the findings. Please ignore the ones that don't make sense

Possible speedups:
Each key value is now converted from f16 to f32 four times, once per head.
Keeping k_shared in f32 would remove those repeated conversions and also fix the F32/BF16 range problem. It needs about 37 KB of shared memory per block, which is under the 48 KB limit, at some cost in how many blocks fit on each SM. It's worth benchmarking.
F16 K is also still converted f16 -> f32 -> f16 when loaded.

@ServeurpersoCom

ServeurpersoCom commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks! I'll go through the bot findings one by one and benchmark the kernel for each of them to see which ones actually pay off, every pass on this kernel speeds up Qwen3.8 Flash Next prefill a bit more.

The first finding led to a good one: staging every head at once so each key is widened only once gives +17-20% on the kernel and +1.8% PP @ 128k e2e. The rest didn't pay off: the f32 key tile is 5-9% slower because the occupancy drops, and the direct F16 copy gains nothing measurable, I only kept it since it's free.

The tile kernel stages the queries and weights of every head at once,
so each key element is widened from half once and feeds all heads,
with a single barrier. F16 keys are copied into the tile without a
float round trip. Keeping the keys in float in shared memory measures
slower, the occupancy drops.
@eapache

eapache commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Microbenchmark on the 3060 now hits 36038.39 us/run - 138.65 GFLOP/run - 3.85 TFLOPS , which is up another 35% from the previous state of this PR and over 4x better than master.

@ServeurpersoCom

Copy link
Copy Markdown
Contributor Author

I think the kernel is in a good spot now: 4.76 ms vs 16.4 ms for the vector kernel on the RTX PRO 6000 (3.4x), and the indexer went from 9.6% to around 3% of the GPU time at 128k. The last real lever would be tensor cores, running the token x head rows against the key tile through mma like the wmma kernel does for 32/64 heads, but with the indexer that small in the e2e it isn't worth the extra complexity here.

@am17an

am17an commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

can you ask check PPL master vs this branch before merging? Probably something at slightly larger context.

@ServeurpersoCom

ServeurpersoCom commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Sure, running it now on wikitext-2 with my prod config at 32k and 64k context, master vs this branch:

llama-perplexity -m Qwen3.8-Flash-Next-UD-Q4_K_XL-00001-of-00004.gguf -f wiki.test.raw -c 32768 -b 4096 -ub 2048 -fa on -ctk q8_0 -ctv q8_0 -ngl 999 -lm dio -lzm off -ot per_layer_token_embd=CPU

(+ same with -c 65536)

PPL is unchanged, wikitext-2 with my prod config:

context master PR
32k 4.3858 +/- 0.02556 4.3882 +/- 0.02557
64k 4.2693 +/- 0.02642 4.2683 +/- 0.02641

@ServeurpersoCom

Copy link
Copy Markdown
Contributor Author

I'm testing HIP on a ROCm machine because the CI is struggling.

Each thread of the tile kernel now scores two keys for a single token,
so a warp shares its token and the query reads are broadcasts: six
shared reads per element pair instead of nine for the same products.
The inner loop is unrolled by 8, which keeps gfx908 at 63 VGPRs with no
spill where the fully unrolled loop needed over a thousand, and makes
the kernel 36x faster on an R9700 and slightly faster on CUDA.
@ServeurpersoCom

Copy link
Copy Markdown
Contributor Author

The hip-quality-check failure was real: fully unrolled, the tile kernel needed 1110 VGPRs on gfx908 and spilled the same way on RDNA4, 610 ms vs 17 ms on an R9700 at kv 65536, nb 2048. Each thread now owns two keys for one token so the query reads are broadcasts, with the inner loop unrolled by 8: 63 VGPRs on gfx908 with no spill, 36x faster on the R9700, and slightly faster on CUDA too (+2.5-5% on the op).

@am17an am17an added the merge ready A maintainer can use this label to indicate that they consider the changes final and ready to merge. label Oct 5, 2026
@ggerganov
ggerganov merged commit 1b43d31 into ggml-org:master Oct 5, 2026
23 of 25 checks passed
@CISC

CISC commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

I'm not sure why the normal MUSA build succeeds, but this broke MUSA Docker build:
https://github.com/ggml-org/llama.cpp/actions/runs/37278908271/job/111667334035#step:10:6484

Edit: Ah, normal build only does arch 31, this fails on 21.

cc/ @yeahdongcn

@ServeurpersoCom

Copy link
Copy Markdown
Contributor Author

I'm not sure why the normal MUSA build succeeds, but this broke MUSA Docker build: https://github.com/ggml-org/llama.cpp/actions/runs/37278908271/job/111667334035#step:10:6484

Edit: Ah, normal build only does arch 31, this fails on 21.

cc/ @yeahdongcn

This should fix it: the tile kernel now stages the queries in head passes, two heads per pass on MUSA (25 KB of shared memory under the 28 KB cap of archs 21/22) and a single pass elsewhere that folds to the merged kernel. Validated on an R9700 with the MUSA path forced on (192/192, CUDA and ROCm unchanged): 5068f0b @yeahdongcn a run on MUSA hardware would confirm it. I can follow-up

@yeahdongcn

Copy link
Copy Markdown
Collaborator

I'm not sure why the normal MUSA build succeeds, but this broke MUSA Docker build: https://github.com/ggml-org/llama.cpp/actions/runs/37278908271/job/111667334035#step:10:6484
Edit: Ah, normal build only does arch 31, this fails on 21.
cc/ @yeahdongcn

This should fix it: the tile kernel now stages the queries in head passes, two heads per pass on MUSA (25 KB of shared memory under the 28 KB cap of archs 21/22) and a single pass elsewhere that folds to the merged kernel. Validated on an R9700 with the MUSA path forced on (192/192, CUDA and ROCm unchanged): 5068f0b @yeahdongcn a run on MUSA hardware would confirm it. I can follow-up

Sure. I can test it on an MTT GPU when I'm back at work on October 8.

thelittlefireman pushed a commit to thelittlefireman/llama.cpp that referenced this pull request Oct 12, 2026
…ml-org#29901)

* cuda: tile the lightning indexer over keys and tokens for 4 heads

With too few heads for a wmma tile, a block scores 64 keys against 8
tokens: the keys are staged once in half precision, the queries one
head at a time, and each thread owns one key for two tokens, so no dot
product needs a cross thread reduction. Batches smaller than a token
tile keep the vector kernel. test-backend-ops measures 4 heads.

* cuda: multiply the lightning indexer tile in float

Address review from am17an: the half2 products overflow once a single
q * k exceeds the f16 range. The queries stay in float in shared memory
and each half2 of keys is widened once for both tokens, so every
product and sum is computed in float.

* cuda: widen each lightning indexer key once for all heads

The tile kernel stages the queries and weights of every head at once,
so each key element is widened from half once and feeds all heads,
with a single barrier. F16 keys are copied into the tile without a
float round trip. Keeping the keys in float in shared memory measures
slower, the occupancy drops.

* cuda: stop the lightning indexer tile from spilling registers on ROCm

Each thread of the tile kernel now scores two keys for a single token,
so a warp shares its token and the query reads are broadcasts: six
shared reads per element pair instead of nine for the same products.
The inner loop is unrolled by 8, which keeps gfx908 at 63 VGPRs with no
spill where the fully unrolled loop needed over a thousand, and makes
the kernel 36x faster on an R9700 and slightly faster on CUDA.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CUDA Related to the CUDA backend ggml changes relating to the ggml tensor library for machine learning merge ready A maintainer can use this label to indicate that they consider the changes final and ready to merge. testing Everything test related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants