Skip to content

[Perf][Prefill] Combine opt for rubin + prefill q8kv8 indexer opt - #36

Merged
zyongye merged 3 commits into
vllm-project:devfrom
caitlinw-nvidia:rubin_combine
Oct 13, 2026
Merged

zyongye merged 3 commits into
vllm-project:devfrom
caitlinw-nvidia:rubin_combine

Conversation

@caitlinw-nvidia

Copy link
Copy Markdown

Overview

This PR makes two changes to the Q8KV8 paged prefill indexer (python/fmha_sm100/cute/):

  1. Rubin (SM107) optimizations for Q8KV8PrefillIndexerSm100, ported from perf: optimize prefill q8kv8 indexer for rubin MiniMax-AI/MSA#17 (nv_dev). It includes the follow-up bc5f83a from nv_dev and unroll = 1 optimization.
  2. A caller-owned score buffer for BatchPrefillIndexerQ8KV8Wrapper: plan(..., score_out=...) and a public run_scores(). [MiniMax-M3 Performance] Prefer CuTe for MiniMax M3 FP8 indexer prefill vllm#60930 uses these to run this scorer inside vLLM's shared-score-buffer / native-top-k indexer.

Changes

Kernel and planner (q8kv8_indexer_prefill.py, q8kv8_indexer_prefill_plan.py, q8_indexer_interface.py). SM107 changes only.

  • SM107MmaFP8Op (K=64) for the FP8 UMMA instead of the generic F8F6F4 op (K=32).
  • Two Q stages, so the next task's Q tile loads while the current pages are still being consumed.
  • 168 registers for every warp role, instead of 192/32 for score warps vs. the rest.
  • The K loader prefetches the next page-table entry while the current TMA is issued.
  • TMEM load-and-reduce for the per-page max.
  • Adaptive page chunk: the planner caps the page chunk (8–128 pages) so that short-query batches fill the clusters in one wave. This fixes the short-query, moderate-context shapes (e.g. Q=128 at 32K context) where a fixed 64-page chunk produced only a few tasks.

On all architectures:

  • The four persistent task-bucket loops run as runtime loops (cutlass.range(..., unroll=1)) instead of being unrolled at trace time, which shrinks the generated code.
  • physical_page is defined before the dynamic K-load loop on the non-SM107 path (port of bc5f83a) (to avoid hanging).

Wrapper API (q8_indexer_interface.py, README):

  • plan(..., score_out=None) takes an optional caller-owned fp32 buffer of shape [total_q * num_heads, >= max_pages], with contiguous pages per row. A wider row pitch is accepted, so a slice of a larger buffer works. The kernel layout is already dynamic, so there's no recompile and no kernel change. The buffer is validated on the host: dtype, device, shape, strides, alignment.
  • run_scores(q, k_cache) is a public scores-only call (no top-k). run() is unchanged.

Validation

Correctness:

  • test_q8_indexer.py -k "prefill or topk_select", with fresh caches and a GPU idle check each run:
    • SM107 (VR200): 41 passed on this PR's head, including 4 new score_out tests.
    • SM100 (B200): 37 passed on the port before the loop-rolling change. Not yet rerun on this PR's head.
  • New tests check that:
    • score_out at an 8,192-page pitch is bitwise identical to the wrapper-owned scores and leaves other entries untouched;
    • run_scores runs no top-k, and run()'s top-k is unchanged;
    • invalid buffers are rejected.

Kernel performance (Rubin SM107; 128-case prefill-indexer suite; score + top-k per call; CUDA-graph replay, 5 warmup + 20 timed; versus dev e441976):

H=1 (attention TP4) H=2 (attention TP2)
Weighted mean, port vs. dev 54.71 → 49.34 µs (1.109×) 88.04 → 82.15 µs (1.072×)
Short queries (< 512), median −22.2% −14.3%
Medium (512–4095), median −6.8% −5.1%
Long (≥ 4096), median −6.3% −5.3%
Loop rolling, additional 1.016× 1.006×
Top-k vs. dev bitwise identical, 128/128 bitwise identical, 128/128

End to end: vLLM nightly 81198e9 with vllm-project/vllm#60684 and #60930 overlaid; MiniMax-M3-MXFP8, TP4, Rubin; --kv-cache-dtype fp8, indexer_kv_dtype: "fp8", minimax_m3_indexer_prefill_backend: "cute". Both arms logged prefill scorer: CuTe.

Workload After vs. before
Prefill only: ISL 8192, OSL 1, concurrency 32, seeds 45–47, prefix caching off +0.94% input tok/s (96,542 vs 95,644; per seed +0.89 to +0.97%), −0.9% TTFT

The end-to-end gain is small because fresh 8K prompts are the kernel's weakest case (long queries, short context), and the indexer is a small share of each prefill step. Short-chunk, long-context traffic (chunked prefill, prefix-cached multi-turn) is where the kernel gains concentrate.

Accuracy (NeMo-Skills, same configuration as the end-to-end runs; temperature 0, top-p 1, 32,768-token generation cap):

Before After Paired exact test
GPQA Diamond 178/198 (89.90%) 173/198 (87.37%) p = 0.33
MMLU 91.05% 90.78% p = 0.14

No significant difference. The kernel produces bitwise-identical top-k to dev, so the differences are consistent with temperature-0 serving not being bit-reproducible.

caitlinw-nvidia and others added 3 commits October 10, 2026 21:30
Fold the nv_dev v1 combine (src/blackwell_prefill/combine.py) into
src/sm100/fwd/combine.py and make it the public combine() for all callers:
the Blackwell port route, the original FP8/BF16 path, the NVFP4 KV path and
the Q8KV4 prefill.

src/sm100/fwd/combine.py now has two sections, one per kernel:
- Original combine: SparseAttentionForwardCombineF32F8 and its launcher
  combine_f32f8, unchanged apart from the names. It implements the FP32 and
  FP8 E4M3 STG.128 fake layouts as well as BF16/FP16.
- v1 combine: SparseAttentionForwardCombine (moved verbatim) and its launcher
  combine(), the default. It takes BF16/FP16 partials only: the v1 kernel
  implements just that STG.128 fake layout.

interface.py picks the launcher by partial dtype (_select_combine) on the two
paths that accept a caller-chosen partial_dtype, the original FP8/BF16 path and
the NVFP4 KV path. The Blackwell port route and Q8KV4 always write BF16
partials and call combine() directly. vLLM always writes BF16 partials, so
production runs the v1 kernel.

Other changes:
- v1 launch: min_blocks_per_mp=0 (was 3 with output_scale + PDL), and a
  3-stage O_partial ring on SM107 (2 elsewhere).
- The Blackwell port route now accepts output_scale and passes it to the
  combine instead of falling back to the original path.
- Delete src/blackwell_prefill/combine.py; drop the per-caller combine
  selection in interface.py and prefill_q8kv4/interface.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…x-AI#17)

SM107-specific changes:
- SM107MmaFP8Op (K=64) for the FP8 UMMA
- TMEM load-and-reduce epilogue on SM107
- 168 registers for every warp role
- K loader prefetches the next page-table entry
- two Q stages
- planner caps the page chunk (8..128) to fill the clusters

Changes on all architectures:
- define the physical page index before the dynamic K-load loop on the
  non-SM107 path (port of MiniMax-AI/MSA bc5f83a)
- keep the four persistent task-bucket loops rolled
  (cutlass.range(..., unroll=1)) instead of unrolling them at trace
  time, which mainly shrinks the generated code

Rubin (SM107), full 128-case prefill-indexer suite, score + top-k per
call, weighted mean versus dev e441976:
- H=1: 54.71 -> 49.34 us (about 1.11x); short queries (<512) about -22%
- H=2: 88.04 -> 82.15 us (about 1.07x)
Top-k output is bitwise identical in all 128 cases. The prefill and
top-k tests in test_q8_indexer.py pass on SM107; on SM100 (B200) they
passed before the loop-rolling change.

Suggested-by: Fan Shi <fans@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Caitlin Wang <caitlinw@nvidia.com>
BatchPrefillIndexerQ8KV8Wrapper always allocated its own score buffer and
its only public call ran the TopK as well. A caller that selects pages
itself (vLLM's shared TopK over decode and prefill rows) needs the scores
written into its own buffer, without the wrapper's TopK.

- plan(..., score_out=None): optional caller-owned fp32 buffer of shape
  [total_q * num_heads, >= max_pages] with contiguous pages per row; a
  wider row pitch is accepted, so a slice of a larger buffer works. The
  kernel layout is already dynamic, so no recompile and no kernel change.
- run_scores(q, k_cache): public scores-only call; run() is unchanged.

Tests: score_out matches the wrapper-owned scores bitwise at an 8192-page
pitch, leaves other entries untouched, runs no TopK, and run() TopK is
unchanged; invalid score_out buffers are rejected. Prefill and TopK tests
in test_q8_indexer.py pass on SM107 (41 passed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Caitlin Wang <caitlinw@nvidia.com>
fans-nv added a commit to fans-nv/vllm that referenced this pull request Oct 13, 2026
Score FP8 indexer prefill with MSA's score-only CuTe scorer instead of
FMHA OnlyScore. One plan per batch writes into the unified score buffer
shared by all indexer layers; native Top16 and decode dispatch are
unchanged.

Add attention_config.minimax_m3_indexer_prefill_backend (auto/fmha/cute).
"auto" keeps FMHA for BF16, unsupported shapes, or an MSA without
run_scores; "cute" raises on unsupported configurations.

Requires the MSA plan(score_out=)/run_scores() API (vllm-project/MSA#36);
until the MSA pin is bumped, "auto" falls back to FMHA.

Co-authored-by: OpenAI Codex
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fan Shi <fans@nvidia.com>
@zyongye
zyongye merged commit 01f07ea into vllm-project:dev Oct 13, 2026
1 check failed
pgera pushed a commit to pgera/MSA that referenced this pull request Oct 14, 2026
dev (vllm-project#36) folded the v1 combine into src/sm100/fwd/combine.py next to the
original FP32 / FP8 E4M3 combine (combine_f32f8) and deleted
src/blackwell_prefill/combine.py, while this branch had replaced the v1
combine with the TMA-ring combine in the Blackwell-lineage file.

Keep dev's layout: the TMA-ring combine takes the v1 section of
src/sm100/fwd/combine.py as the default combine(); combine_f32f8 and the
partial-dtype selector stay, so FP32 and FP8 E4M3 partials remain
supported. The selector also sends query head counts that are not a multiple of 4
to combine_f32f8, since the TMA-ring combine's LSE tensor map needs the
flat (query, head) rows of every split 16-byte aligned; it binds cu_seqlens
for combine_f32f8, which the TMA-ring combine does not take. The Blackwell
port route goes through the same selector and passes output_scale as dev
now does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants