Skip to content

[DSV4.1] Enable the two-level candidate indexer on DeepGEMM's paged sparse MQA logits - #38944

Merged
DarkSharpness merged 6 commits into
dsv4.1from
dsv4.1-candidate-indexer-deep-gemm
Sep 11, 2026
Merged

DarkSharpness merged 6 commits into
dsv4.1from
dsv4.1-candidate-indexer-deep-gemm

Conversation

@DarkSharpness

@DarkSharpness DarkSharpness commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

DeepSeek-V4.1's low-ratio indexer is two-level: the candidate-source layer (layer 20) scores every visible compressed position and publishes, per query row, the candidate_topk_blocks best candidate_block_size-position blocks; the index-source layers after it (24/28/32/36) select their top-k only inside those blocks. Until now the consumers still computed dense logits over the whole context and masked everything outside the candidates. This PR routes the consumers through DeepGEMM's new fp8_fp4_paged_sparse_mqa_logits, which scores only the published blocks.

What this enables

  • SGLANG_DSV41_DEEP_GEMM_CANDIDATE_INDEXER=1 (default off) switches the paged fp4 decode path (SM100, decode and target-verify) to the DeepGEMM sparse indexer: layer 20 runs its own top-k and publishes an ascending logical block table plus DeepGEMM's schedule for it; layers 24/28/32/36 score the published blocks only and select inside that row. Requires a DeepGEMM that provides get_paged_sparse_mqa_logits_metadata / fp8_fp4_paged_sparse_mqa_logits (upstream 66081d4 or later); the user is responsible for that when turning the flag on.
  • With the flag on, the SM100 low-ratio index-K pool pages at 128 slots instead of 64 (128 x 68 B is a multiple of 512 B, the sparse kernel's page-stride requirement); the JIT paged-logits metadata builder accepts both page sizes.
  • Adds topk_bf16_small, a JIT bf16 top-k transform for rows of at most 16384 scores with a fused page-table transform, used to select inside the sparse row.

Structure

  • dsv4/candidate_indexer.py: the interface (CandidateMetadata, IndexerInputs, make_candidate_indexer, chosen once at backend init).
  • dsv4/candidate_torch.py: the model code's algorithm (bool masks over positions), the default. two_level_decode_logits moves here from the backend, keeping the Triton candidate_block_logits fast path.
  • dsv4/candidate_deep_gemm.py: the DeepGEMM implementation (SparseBlockTable: block table + schedule + physical blocks). The level-one block selection is still a torch composition (amax_topk_blocks, marked TODO).
  • DSV4Metadata.candidate_metadata: one slot for whatever the source published, in the implementation's own type; written by layer 20, read by the layers after it, never copied from the host. The former backend-level candidate_masks state moves there.
  • The backend's _low_ratio_index_topk_decode becomes: consumer -> select_decode; source -> publish_decode; everything else the plain top-k as before. The short-context CUDA-graph variants keep selecting every position and never touch the candidate path.

Unchanged: prefill and the Hopper decode path keep the existing mask-based selection (same results), only their state now lives on the forward metadata.

Tests

  • test/registered/kernels/ops/attention/test_topk_bf16.py: the bf16 top-k against torch.topk across row lengths, ties, page tables and padded outputs.
  • test/registered/attention/unittests/dsv4/test_dsv41_sparse_indexer.py: level one against the model code's select_candidate_blocks; with a DeepGEMM that has the sparse kernel, the sparse logits against DeepGEMM's dense bf16 logits at the published positions (bitwise) and the consumer's selection against a torch reference.
  • Existing dsv4 prefill / fused-compress tests unchanged and passing.

Follow-ups

Level-one kernel (block amax + top-k) instead of the torch composition; prefill through the same implementations; end-to-end validation once the DeepGEMM dependency carries the sparse kernels.

🤖 Generated with Claude Code


CI States

Latest PR Test (Base): ❌ Run #34632077104
Latest PR Test (Extra): ❌ Run #34632076753
Latest PR Test (AMD ROCm 10): ❌ Run #34632077042

DarkSharpness and others added 5 commits September 11, 2026 07:23
…parse MQA logits

- SGLANG_DSV41_DEEP_GEMM_CANDIDATE_INDEXER (default off) routes the paged fp4
  decode path through DeepGEMM's sparse indexer: the candidate-source layer
  publishes an ascending logical block table plus DeepGEMM's schedule, the
  index-source layers after it score the published blocks only with
  fp8_fp4_paged_sparse_mqa_logits and select inside that row.
- dsv4/candidate_indexer.py (interface, chosen once at backend init),
  candidate_torch.py (the model code's mask algorithm, default),
  candidate_deep_gemm.py (the DeepGEMM implementation; level one still torch).
- DSV4Metadata.candidate_metadata: one slot for what the source published,
  in the implementation's own type; replaces the backend-level candidate_masks.
- Under the flag the SM100 low-ratio index-K pool pages at 128 slots (512-byte
  page stride); the JIT paged-logits metadata builder takes 64 or 128.
- topk_bf16_small: JIT bf16 top-k transform with a fused page-table transform,
  used to select inside the sparse row.
- Tests: the bf16 top-k against torch.topk; level one against the model code's
  select_candidate_blocks; the sparse chain against DeepGEMM's dense bf16 logits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the torch pieces of the source layer's block selection with two JIT
kernels, so publish_decode goes from 25 launches / 95 us to 11 / 58 us at
bs=8, 128K on B200:

* amax8_varlen (deepseek_v4/amax_copy.cuh): the block maximum of every 8
  consecutive fp32 scores for the first ceil(seq_len / 8) blocks of a row,
  the newest block written as +inf so it is always selected, nothing written
  past the count; rows with at most `topk` blocks may be skipped. 32-byte
  vectors on Blackwell, PDL.
* sort_candidate_blocks (deepseek_v4/sort_idx.cuh): the selected block ids
  (any order, -1 padded) become, in place, the ascending INT32_MAX-padded
  table DeepGEMM's sparse schedule reads, plus the same blocks as pool
  slots / 8 through the row's page table. Counting sort over a 16 KiB
  bitmap: single-bit words emitted by their owner, denser words drained from
  a block-wide queue one word per warp step. Rows with at most k blocks get
  the identity table. transform_candidate_blocks is the page transform alone,
  for a block top-k that already emits ascending ids.

amax_topk_blocks now returns the top-k's unsorted output and publish_decode
sorts it in place; physical_blocks() is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, plan skip

* `indexer.fp32_jit_paged_topk`: the three "plain top-k of the dense logits"
  sites (backend decode, both implementations' publish) chose between the v2
  kernel and the v1 fallback on `use_topk_v2 and raw_indices is None`; with
  v2 emitting raw indices the choice is `use_topk_v2` alone, in one helper.
  The torch consumer takes its raw positions from the same call.
* `candidate_row_lens` (Triton, PDL): per row the block count and the sparse
  row length in one launch; publish_decode computes them once and stores the
  lengths on `SparseBlockTable.valid_lens`, so the consumers stop recomputing
  them (10 elementwise launches per layer).
* `TopKKernel::plan` skips its launch when no route of `transform_paged` reads
  the plan: only the persistent-cluster route does, and it is taken for
  batches above the pool size only.

publish_decode at bs=8, 128K on B200: 11 launches / 58 us -> 8 / 52 us;
select_decode 13 / 31 us -> 3 / 16 us.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
publish_decode forks a second stream after the dense logits: the source
layer's own top-k stays on the main stream while the block-max, block top-k,
block-table sort and DeepGEMM schedule run on the side stream, so they overlap
the main stream's top-k and the following layers. `logits` is recorded on the
side stream so the allocator does not recycle it under those reads; the first
consumer of the forward joins the side stream before its sparse-logits kernel
reads the published table. `SGLANG_DSV41_DEEP_GEMM_CANDIDATE_OVERLAP=0` turns
the side stream off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@DarkSharpness
DarkSharpness force-pushed the dsv4.1-candidate-indexer-deep-gemm branch from 12c2190 to 5bd0687 Compare September 11, 2026 17:15
…e schedule

DeepGEMM's sparse metadata pairs two consecutive query rows of one request on
one KV pass (each row keeps its own block list and output layout; paired rows
must share their page-table row). Under speculative verify every draft token
is a row of the same request, so pass the per-row request ids from the
dispatcher (IndexerInputs.request_ids) instead of numbering rows: verify rows
pair, the sparse-logits kernel reads K once per pair. Decode has one row per
request and passes none; that case takes a cached int32 arange, no launch.

Test: verify-shaped rows (6 per request, lengths L..L+5, shared page-table
row) give bitwise-identical sparse logits paired and unpaired, and equal the
dense bf16 logits at the published positions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@DarkSharpness

Copy link
Copy Markdown
Collaborator Author

End-to-end validation of this branch (head 6862d39) on 4x GB300 (TP4/EP4), official DeepGEMM 2.8.0 (66081d4, built from source) and the open deepseek-ai/DeepSeek-V4.1-Flash weights. "Flag on" = SGLANG_DSV41_DEEP_GEMM_CANDIDATE_INDEXER=1 (this branch's DeepGEMM sparse candidate indexer), "flag off" = the torch candidate path on the same tree, "base" = the dsv4.1 commit the branch started from.

gsm8k (sgl-eval run gsm8k, 1319 questions, zero-shot chat, greedy, thinking off)

configuration passes score
flag on 5 0.968 / 0.969 / 0.968 / 0.970 / 0.970
flag off 2 0.969 / 0.970
base 2 0.969 / 0.971

No errors or truncations except two greedy runaways in one flag-on pass (a request looping until the context limit); the same runaway reproduces with the flag off and depends only on batch composition, and the affected prompts answer correctly when sent alone. Pairwise, any two passes agree on ~68% of outputs regardless of configuration, i.e. the differences between configurations are the same as between two passes of one configuration.

Long context (the candidate path is only active above 16384 tokens): needle retrieval at 12K / 24K / 48K / 100K / 200K, three needle positions each, greedy 64 tokens: 15/15 retrieved with both flags, generated text byte-identical between flag on and flag off in every case; first-token logprobs agree to 1e-4. Mixed batches of 8 concurrent requests from 4K to 64K (some below and some above the 16384 threshold): 8/8 retrieved, text identical between flags.

Speculative decoding (--speculative-algorithm DSPARK, gamma=5, six verify rows per request): smoke and the same needle prompts give byte-identical text across non-speculative flag-on, speculative flag-on and speculative flag-off; the verify batches run through the candidate publish/select path with one row per draft token.

Module-level equivalence (synthetic inputs, torch vs DeepGEMM implementation, 61 rows from 16K to 1M tokens, batches 1 and 8 including rows below the threshold): the source layer's top-512 and the published candidate blocks are identical for all 61 rows; the consumers' top-512 selections have Jaccard 0.947-1.0 per row, and 717 of the 718 differing slots are within 2 bf16 ulps of the 512th score (the DeepGEMM consumer scores are bf16, the torch path keeps fp32). Every pick lies inside its own candidate set.

Unit tests (test_dsv41_sparse_indexer.py, test_topk_bf16.py, test_amax_copy.py, test_sort_idx.py, test_dsv4_indexer_postprocess.py, test_topk_v2.py): all passing on B200 (sm_100a), B300 (sm_100a, x86) and GB300 (sm_103a, aarch64).

CUDA-graph capture of the candidate variants succeeds with the flag on; no scheduler or CUDA errors in any server log across the runs.

@DarkSharpness
DarkSharpness merged commit 93ca767 into dsv4.1 Sep 11, 2026
81 of 91 checks passed
@DarkSharpness
DarkSharpness deleted the dsv4.1-candidate-indexer-deep-gemm branch September 11, 2026 18:24
Leoyzen added a commit to Leoyzen/sglang that referenced this pull request Sep 12, 2026
…iton (port of sgl-project#39086)

V4.1 prefill on Hopper currently reaches the `_low_ratio_index_topk_torch`
fallback (per-request python loop, per-request dequant, [rows, heads, lc] bf16
scores). Add an SM90 Triton FP8 path and route ragged extend batches to it:

- `unpack_fp4_index_keys_to_fp8`: decode block-scaled E2M1 index-K directly to E4M3.
- `quantize_bf16_index_queries_fp8`: cast each bf16 query head to E4M3.
- `fp8_index_logits_prefill`: E4M3 dot with FP32 accumulation, fused relu /
  head weighting / head reduction, fp32 output padded to 4 for the ragged top-k.
- `_low_ratio_index_topk_sm90_extend`: per-request chunked scoring that reuses
  each converted K row across the chunk's queries, then feeds the existing
  ragged top-k v2 kernel.

Adapted onto the post-sgl-project#38944 candidate-indexer API (`published_masks` /
`CandidateMasks`) and gated by the same `SGLANG_DSV41_TORCH_PREFILL_INDEXER`
kill-switch as the dense path. Decode path unchanged.

Reported cold prefill on 4xH200 (2K..256K): +14.5%..+71.1%; gsm8k 0.900.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant