Skip to content

[Performance][MLA] Use FP16 logits for sparse indexer - #52696

Open
WoosukKwon wants to merge 11 commits into
mainfrom
agent/fp16-indexer-logits
Open

WoosukKwon wants to merge 11 commits into
mainfrom
agent/fp16-indexer-logits

Conversation

@WoosukKwon

@WoosukKwon WoosukKwon commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • make the sparse-attention indexer request FP16 logits by default on CUDA,
    with an explicit float16/float32 engine override
  • keep DeepGEMM output, local DCP top-k, and every vLLM selector on native FP16
    without widening the materialized logits tensor
  • convert two packed FP16 score encodings to ordered keys at once, use exact
    native FP16 refinement, and retune persistent/decode crossover tiers
  • remove the experimental BF16 indexer/top-k surface and add rejection,
    correctness, engine-policy, DeepGEMM, DCP, and odd-length coverage
  • resolve the configured logits dtype during module construction so worker
    warm-up and inference do not depend on a thread-local config context
  • cover DeepSeek-V4 Flash's 64-head MXFP4 indexer in FP16 for both prefill and
    paged MTP decode with exact packed-cache-layout correctness tests

Why

GLM-5.2's sparse indexer materializes a large logits tensor before selecting
top-k indices. Keeping the tensor and selection native FP16 reduces memory
traffic. --attention-config.indexer_logits_dtype accepts auto, float16,
or float32; auto chooses FP16 on CUDA and FP32 elsewhere.

The final kernel transforms both packed halves with one 32-bit operation and
reuses the exact keys in histogram and scatter. This reduced the unified
FP16 kernel from 57 to 32 registers/thread at 1,024 rows and raised measured
occupancy from 49.7% to 94.9%.

The dtype is passed as a constant custom-op argument. Resolving it in the
operator body originally failed during real-model warm-up because the worker
callback does not run inside set_current_vllm_config; resolving it when the
SparseAttnIndexer module is constructed fixes eager warm-up and graph replay.

DeepSeek-V4 Flash's optional MXFP4 indexer cache uses the same FP16 logits path.
The model-specific tests exercise 64 indexer heads, the packed 68-byte cache
layout, and next_n=3 (MTP=2), comparing both prefill and paged decode against
dequantized MXFP4 references.

Performance

All selector measurements use captured real-model GLM logits, top-k 2,048,
warmed CUDA graphs, and the repository .venv. CUDA events bracket graph
replays and are not captured in the graph. Both the custom top-k and reference
torch.topk run on CUDA; CPU transfers occur only in separate pytest failure
diagnostics. Every result was checked against torch.topk before timing.

Production-style fixed 256K decode graph

The logits row stride and captured max_seq_len remain 256K while only the
live KV length changes, matching reuse of a maximum-length graph. Entries are
FP16 us / FP32 us (FP32 / FP16 speedup):

Rows 10K KV 50K KV 100K KV 200K KV 256K KV
1 4.242 / 4.609 (1.087x) 7.527 / 9.742 (1.294x) 8.144 / 10.485 (1.287x) 9.383 / 12.028 (1.282x) 9.820 / 12.383 (1.261x)
8 4.732 / 5.069 (1.071x) 7.628 / 10.008 (1.312x) 8.941 / 11.507 (1.287x) 11.562 / 14.032 (1.214x) 12.573 / 15.349 (1.221x)
32 4.838 / 5.242 (1.084x) 9.639 / 12.419 (1.288x) 13.138 / 16.068 (1.223x) 18.901 / 22.429 (1.187x) 21.295 / 25.228 (1.185x)
128 5.323 / 5.549 (1.042x) 17.500 / 19.292 (1.102x) 27.604 / 29.836 (1.081x) 47.513 / 66.983 (1.410x) 56.910 / 85.397 (1.501x)
1,024 23.967 / 33.269 (1.388x) 94.387 / 139.846 (1.482x) 153.163 / 221.120 (1.444x) 271.360 / 470.133 (1.733x) 334.933 / 574.720 (1.716x)

FP16 is faster in all 25 production-style shapes: 1.042x-1.733x.

Exact-stride matrix

FP16 speedup over FP32 after retuning the selector:

Rows 10K KV 50K KV 100K KV 200K KV
1 1.087x 1.276x 1.271x 1.270x
8 1.062x 1.300x 1.290x 1.214x
32 1.088x 1.294x 1.240x 1.191x
128 1.051x 1.084x 1.048x 1.387x
1,024 1.389x 1.472x 1.492x 1.763x
8,192 1.528x 1.627x 1.628x 1.839x
16,384 1.542x 1.638x 1.635x 1.832x

FP16 is faster in all 28 exact-stride shapes: 1.048x-1.839x. The packed-key
change invalidated the old decode crossover: persistent now wins through 32K,
and measured row thresholds are 768 for 32K-64K, 512 for 64K-100K, and 1,024
for 100K-131K.

Accuracy

End-to-end long-context recall

The final PR code was evaluated with the real nvidia/GLM-5.2-NVFP4
checkpoint and the standard Paul Graham NIAH corpus. The paired runs used TP4,
greedy decoding, FP8 E4M3 KV cache, 2,048-token chunked prefill, one sequence
at a time, eager execution, disabled prefix caching, disabled FlashInfer
autotuning, and no GVR. The only changed engine setting was
--attention-config.indexer_logits_dtype=float32 versus float16.

Each context used needle depths 10%, 50%, and 90%, with two independently
generated seven-digit keys per cell:

Context FP32 exact FP16 exact Paired identical responses
50K 6/6 6/6 6/6
100K 6/6 6/6 6/6
200K 6/6 6/6 6/6
250K 6/6 6/6 6/6
Total 24/24 24/24 24/24

Server-reported prompt lengths were 50,000-50,002, 100,001-100,002,
200,002, and 250,001-250,002 tokens. This is deterministic single-needle
retrieval coverage, not a claim that every downstream task is unchanged.

DeepSeek-V4 Flash FP4 indexer-cache NIAH

The real deepseek-ai/DeepSeek-V4-Flash checkpoint was evaluated with TP4,
MTP=2, FP8 main KV cache, FP16 indexer logits, eager execution, disabled
prefix caching, and disabled FlashInfer autotuning. Both arms used the same
ordinary fused-MoE backend because the local mega-MoE extension has a stale
ABI. The only paired change was an FP8 indexer cache versus
use_fp4_indexer_cache=true, which startup resolved to mxfp4 and
confirmed with Using MXFP4 indexer cache for Lightning Indexer.

The standard Paul Graham NIAH corpus used one deterministic seven-digit key
per cell and greedy decoding:

Context FP8 indexer exact FP4 indexer exact Paired identical responses
50K 3/3 3/3 3/3
100K 3/3 3/3 3/3
200K 3/3 3/3 3/3
250K 3/3 3/3 3/3
Total 12/12 12/12 12/12

Needle depths were 10%, 50%, and 90%. Server prompt-token counts matched
between paired arms for all 12 cells and ranged from 49,983 to 249,984, so no
prompt was truncated. This is deterministic single-needle retrieval coverage,
not a claim that every downstream task is unchanged. Request latency is not
reported because the control arm absorbed first-use JIT compilation.

Real-input top-k set recall

To isolate selector-set changes, FP32 logits captured from real GLM decode
for BEAM documents were rounded to FP16 and exact top-2,048 was recomputed.
Recall is |topk(FP16) intersect topk(FP32)| / 2048.

Two layer-74 snapshots with 1,024 real rows each and 199,401-199,481 live KV
tokens produced:

Rows Mean recall Median 1st percentile Worst row Mean / max changed indices
2,048 99.9313% 99.9512% 99.8047% 99.7070% 1.41 / 6

A separate context sweep used two selector positions and 64 real rows per
length:

Live KV Mean recall Worst row Mean changed indices
10K 100.0000% 100.0000% 0.00
50K 99.9496% 99.9023% 1.03
100K 99.9733% 99.9023% 0.55
200K 99.9428% 99.8535% 1.17
Overall 99.9664% 99.8535% 0.69

This recall measurement isolates FP16 score rounding on real distributions;
the end-to-end NIAH table above measures the actual native FP16 DeepGEMM and
top-k execution path.

Validation

  • cmake --build build/gvr_native_fp16 --target _C_stable_libtorch -j16
  • 65 workspace/backend correctness cases passed, including FP16 odd lengths
  • 20 indexer dtype and retuned-selector policy cases passed
  • real TP4 GLM launch and warm-up passed for explicit FP32 and FP16 engine
    settings after moving dtype resolution to module construction
  • full 1,024-row FP16 equality with CUDA torch.topk at 10K, 50K, 100K,
    200K, and 256K with a fixed 256K stride
  • all 56 exact-stride and all 50 fixed-stride dtype/shape cells passed
    correctness before CUDA-graph timing
  • packed transform exhaustively matched the scalar ordered-key transform for
    all 65,536 FP16 encodings
  • real DSV4 TP4 NIAH passed 12/12 with the FP8 indexer cache and 12/12
    with use_fp4_indexer_cache=true; all 12 paired responses were identical
  • all 12 DeepGEMM attention cases passed on GB200, including 64-head DSV4
    MXFP4-to-FP16 prefill and paged next_n=3 decode against dequantized
    references; 2 full DCP FP16/FP32 decode-parity cases passed earlier
  • SWEEP_MAX_CONCURRENCY=1,8,32,256 SWEEP_NUM_PROMPTS_FACTOR=3 bash ../glm_bench.sh and a matched concurrency-1,024 factor-1 run completed
    1,915 requests per dtype with no failures
  • all applicable pre-commit hooks on the PR changes passed

uv pip install -e . --torch-backend=auto was attempted. The full editable
build is currently blocked on unmodified current-main QUTLASS code including
ATen extension headers while TORCH_TARGET_VERSION is defined; both changed
CUDA targets build successfully.

Duplicate-work check

This does not duplicate #52149, which fixes persistent top-k candidate
overflow, or #48726, which adds an opt-in fused DSA indexer/top-k path. This PR
changes dtype production and native selection for the existing materialized-
logits path, including DeepGEMM and DCP.

AI assistance

AI assistance was used to implement and benchmark this change. Before this
draft is marked ready, the submitting human must review every changed line,
reproduce the relevant validation, and be prepared to explain and defend the
change end-to-end.

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
@mergify mergify Bot added the ci/build label Aug 18, 2026
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
@mergify mergify Bot added the performance Performance-related issues label Aug 18, 2026
Add forced backend selection to the CUDA-graph benchmark so dispatch boundaries can be measured directly on captured model logits.

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
Process packed FP16 score bits directly, refine only the exact remaining key bits, and benchmark fixed-stride CUDA graph captures separately from live KV length.

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
@LopezCastroRoberto

Copy link
Copy Markdown
Contributor

I see the duplicate-work check didn't catch this (i closed it a while ago), so I just wanted to reference the PR here just in case there’s anything useful you can get from it: #40811

@WoosukKwon

Copy link
Copy Markdown
Collaborator Author

@LopezCastroRoberto Oh thanks. I didn't know you tried the same idea. Here, I used FP16 instead of BF16 since I though precision matters more than range. Could you please let me know why you closed the previous PR?

@LopezCastroRoberto

LopezCastroRoberto commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

@WoosukKwon

Here, I used FP16 instead of BF16 since I though precision matters more than range

I used BF16 because DSv4 QAT apparently quantized the indexer scores to BF16, so I went with it for consistency:

"We further quantize the index scores 𝐼:,: from FP32 to BF16 during this QAT process. This optimization achieves a 2× speedup for the top-k selector, while preserving a 99.7% recall rate of KV entries." Source: https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro/blob/main/DeepSeek_V4.pdf

Could you please let me know why you closed the previous PR?

When I created the cooperative topK version (#43008), I wasn't getting significant speedups from the index scores quantization as I did in #40811 with topk v1 (persistent topK). So I just decided to close it and maybe revisit in the future. Great to see there was a way to do it :) I always trusted this was a nice optimization to apply, or at least to have as a configurable parameter

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
@WoosukKwon

Copy link
Copy Markdown
Collaborator Author

@LopezCastroRoberto Got it. Thanks for the explanation! Using FP16 gives me higher recall (99.96% median) than BF16. Given the low probability of having more than 2,048 logits exceed FP16_MAX (65,536), I think FP16 is the better choice here.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@WoosukKwon WoosukKwon added the ready ONLY add when PR is ready to merge/full CI is needed label Aug 18, 2026
@WoosukKwon

Copy link
Copy Markdown
Collaborator Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #84371 for commit a47dda42d5c2.

@WoosukKwon

Copy link
Copy Markdown
Collaborator Author

@LopezCastroRoberto Can you please review this PR?

Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
@mergify

mergify Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Hi @WoosukKwon, the pre-commit checks have failed. Please run:

uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-files

Then, commit the changes and push to your branch.

For future commits, pre-commit will run automatically on changed files before each commit.

@WoosukKwon

Copy link
Copy Markdown
Collaborator Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #84421 for commit 10c21f254993.

@mergify

mergify Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @WoosukKwon.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Aug 18, 2026
Resolve the sparse indexer test import conflict by retaining both FP16 dtype coverage and upstream MQA-only coverage.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
@WoosukKwon

Copy link
Copy Markdown
Collaborator Author

/ci run

@WoosukKwon

Copy link
Copy Markdown
Collaborator Author

@LopezCastroRoberto Can you please review?

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #84751 for commit 25a86009a947.

@mergify mergify Bot removed the needs-rebase label Aug 20, 2026

@LopezCastroRoberto LopezCastroRoberto left a comment

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.

@WoosukKwon I feel like we should verify at least:

(1) FP32 performance-regression coverage for every modified path, i.e., top_k_per_row_prefill, top_k_per_row_prefill, cooperative_topk, persistent_topk, and filtered_topk. Just to make sure the code changes do not introduce any unexpected overhead in the FP32 existing path.

(2) FP16-versus-FP32 prefill benchmarks. The reported performance tables cover decode topk, but it is unclear to me if the FP16 prefill code has been benchmarked and what's the speedup obtained.

(3) Since the PR makes FP16 logits the default, I think we should make sure this doesn't affect real accuracy numbers. This is probably correct for DSv4, since they claimed to have quantized the logits during QAT to BF16. But it is unclear to me if this is safe for other models using DSA, such as DSv3.2. MRCR accy evals for different context lengths can be good for this this, imo. See EleutherAI/lm-evaluation-harness#3754.

(4, maybe optional) Matched end-to-end FP32-versus-FP16 serving benchmarks. The current e2e results validate retrieval accuracy, but do not demonstrate an e2e performance benefit. I think it is good to check both TTFT and TPOT look healthy.

Comment on lines +160 to +165
def _benchmark_graph(
launch: Callable[[], None],
graph_calls: int,
replays: int,
samples: int,
) -> float:

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.

nit: I generally think we should use triton.testing.do_bench_cudagraph instead of CUDAGraph+events to measure kernel-wise performance, e.g., this. Not only because it is better for time measurement, but it also reduces the LoC significantly.

} else {
launch_cooperative_cluster<TopK, 4>(params, ct::kSmemSize4, stream);
launch_cooperative_cluster<InputType, TopK, 4>(
params, ct::kSmemSize4<InputType>, stream);

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.

I wonder if this heuristic holds for BF16 input scores, i.e., CS=16 if num_rows <=4, CS=8 if num_rows<=8, otherwise CS=4.

Have you checked this? Maybe leave a TODO if you think this is out of the scope of this PR.

Comment thread csrc/libtorch_stable/topk.cu Outdated
const bool is_half =
logits.scalar_type() == torch::headeronly::ScalarType::Half;
if (is_half) {
STD_TORCH_CHECK(max_smem_per_block >= 128 * 1024,

@LopezCastroRoberto LopezCastroRoberto Aug 20, 2026

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.

I think this is too strict for the half path? Seems like half of this workspace is needed, right?

constexpr int kBuffers =
    FilteredTopKTraits<DType>::NUM_REFINE_ROUNDS == 1 ? 1 : 2;

return sizeof(int) * kBuffers * 16 * 1024;

i.e., 4 bytes × 1 buffer × 16,384 entries = 65,536 bytes = 64 KiB

Comment thread csrc/libtorch_stable/topk.cu Outdated
"128KB of shared memory per block");
}

if ((num_rows > 32 || is_half) && max_smem_per_block >= 128 * 1024) {

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.

Why is this changing the logic? This always skips persistent_topk. Just making sure this is on purpose.

I feel that the logic to select cooperative/persistent/filtered/top_k_per_row_decode is a bit difficult to follow now.

Comment on lines +56 to +64
if logits_dtype != torch.float16:
return False
if max_seq_len <= 32768:
return False
if max_seq_len <= 65536:
return num_rows >= 768
if max_seq_len <= 100000:
return num_rows >= 512
return max_seq_len <= 131072 and num_rows >= 1024

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.

Should we re-run this on Hopper to refine this heuristic? I think we should at least refer to this PR in a comment to justify where this numbers come from.

and topk_tokens in (512, 1024, 2048)
and num_rows <= 32
and logits.stride(0) % 4 == 0 # TMA 16-byte alignment
and logits.stride(0) % (16 // logits.element_size()) == 0

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.

keep comment?

parser.add_argument("--samples", type=int, default=7)
parser.add_argument(
"--backend",
choices=("auto", "cooperative", "persistent", "decode"),

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.

Should we add top_k_per_row_prefill to this script? It also supports FP16 now.

Co-authored-by: OpenAI Codex <codex@openai.com>
@mergify

mergify Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @WoosukKwon.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/build needs-rebase performance Performance-related issues ready ONLY add when PR is ready to merge/full CI is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants