dsv4.1: candidate indexer library (#39671)

Co-authored-by: DarkSharpness <76582120+DarkSharpness@users.noreply.github.com>
Co-authored-by: Xiaoyu Zhang <1182563586@qq.com>
This commit is contained in:
Liangsheng Yin
2026-09-16 15:15:18 -07:00
committed by GitHub
co-authored by DarkSharpness Xiaoyu Zhang
parent 13d593b6cf
commit 35b7589e1a
6 changed files with 447 additions and 218 deletions
@@ -1,4 +1,4 @@
"""Candidate-block scores and visibility masking for paged indexer logits."""
"""Per-row candidate block counts and sparse-row lengths for the paged indexer."""
import torch
import triton
@@ -7,149 +7,6 @@ import triton.language as tl
from sglang.kernels.jit.utils import is_arch_support_pdl
@triton.jit
def _maximum_with_nan(a, b):
return tl.maximum(a, b, propagate_nan=tl.PropagateNan.ALL)
@triton.jit
def _candidate_scores_kernel(
X,
LENS,
OUT,
SCORES,
WIDTH: tl.constexpr,
STRIDE: tl.constexpr,
BLOCKS: tl.constexpr,
GROUP: tl.constexpr,
GROUP_PAD: tl.constexpr,
TILE: tl.constexpr,
):
row = tl.program_id(0).to(tl.int64)
blocks = tl.program_id(1) * TILE + tl.arange(0, TILE)
offsets = tl.arange(0, GROUP_PAD)
cols = blocks[:, None] * GROUP + offsets[None, :]
length = tl.load(LENS + row)
in_bounds = (cols < WIDTH) & (offsets[None, :] < GROUP)
values = tl.load(
X + row * STRIDE + cols, in_bounds & (cols < length), other=-float("inf")
).to(tl.float32)
tl.store(OUT + row * WIDTH + cols, values, in_bounds)
scores = tl.reduce(values, axis=1, combine_fn=_maximum_with_nan)
scores = tl.where(
(length > 0) & (blocks == (length - 1) // GROUP), float("inf"), scores
)
tl.store(SCORES + row * BLOCKS + blocks, scores, blocks < BLOCKS)
@triton.jit
def _candidate_mask_kernel(
X,
LENS,
KEEP,
OUT,
WIDTH: tl.constexpr,
STRIDE: tl.constexpr,
KEEP_STRIDE: tl.constexpr,
KEEP_COL_STRIDE: tl.constexpr,
TILE: tl.constexpr,
):
row = tl.program_id(0).to(tl.int64)
cols = tl.program_id(1) * TILE + tl.arange(0, TILE)
visible = (cols < WIDTH) & (cols < tl.load(LENS + row))
keep = tl.load(KEEP + row * KEEP_STRIDE + cols * KEEP_COL_STRIDE, visible, other=0)
values = tl.load(X + row * STRIDE + cols, visible & keep, other=-float("inf")).to(
tl.float32
)
tl.store(OUT + row * WIDTH + cols, values, cols < WIDTH)
@triton.jit
def _publish_candidate_mask_kernel(
INDICES,
VALUES,
KEEP,
WIDTH: tl.constexpr,
GROUP: tl.constexpr,
TOPK: tl.constexpr,
TILE: tl.constexpr,
):
row = tl.program_id(0).to(tl.int64)
i = tl.program_id(1) * TILE + tl.arange(0, TILE)
selected = tl.load(INDICES + row * TOPK + i // GROUP, i < TOPK * GROUP, 0)
score = tl.load(VALUES + row * TOPK + i // GROUP, i < TOPK * GROUP, -float("inf"))
cols = selected * GROUP + i % GROUP
# torch.topk returns unique block indices: each output position has one writer.
tl.store(
KEEP + row * WIDTH + cols,
score > -float("inf"),
(i < TOPK * GROUP) & (cols < WIDTH),
)
def candidate_block_logits(
logits: torch.Tensor,
seq_lens: torch.Tensor,
*,
topk_blocks: int,
block_size: int,
published: torch.Tensor | None,
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""Keep torch.topk's block selection, including its tie behavior.
Without ``published`` (a source) mask the unread tail while scoring blocks;
with it (a consumer) apply visibility and the published mask in one pass.
"""
rows, width = logits.shape
output = torch.empty((rows, width), dtype=torch.float32, device=logits.device)
if published is not None:
_candidate_mask_kernel[(rows, triton.cdiv(width, 4096))](
logits,
seq_lens,
published,
output,
width,
logits.stride(0),
published.stride(0),
published.stride(1),
4096,
)
return output, None
blocks = triton.cdiv(width, block_size)
scores = torch.empty((rows, blocks), dtype=torch.float32, device=logits.device)
group_pad = triton.next_power_of_2(block_size)
tile = max(1, 1024 // group_pad)
_candidate_scores_kernel[(rows, triton.cdiv(blocks, tile))](
logits,
seq_lens,
output,
scores,
width,
logits.stride(0),
blocks,
block_size,
group_pad,
tile,
)
# Publication only needs membership; sorting the selected pairs is unused.
top = scores.topk(min(topk_blocks, blocks), dim=-1, sorted=False)
keep = torch.zeros((rows, width), dtype=torch.bool, device=logits.device)
_publish_candidate_mask_kernel[
(rows, triton.cdiv(top.indices.shape[1] * block_size, 256))
](
top.indices,
top.values,
keep,
width,
block_size,
top.indices.shape[1],
256,
num_warps=4,
)
return output, keep
@triton.jit
def _candidate_row_lens_kernel(
LENS,
@@ -1,74 +0,0 @@
"""Filter selected indexer scores and map logical positions to KV slots."""
import torch
import triton
import triton.language as tl
@triton.jit
def _filter_topk_pages(
SCORES,
INDICES,
PAGES,
OUT,
RAW,
WIDTH: tl.constexpr,
TOPK: tl.constexpr,
PAGE_SIZE: tl.constexpr,
SS: tl.constexpr,
SI: tl.constexpr,
SP: tl.constexpr,
SO: tl.constexpr,
SR: tl.constexpr,
WRITE_RAW: tl.constexpr,
BLOCK: tl.constexpr,
):
row = tl.program_id(0).to(tl.int64)
col = tl.program_id(1) * BLOCK + tl.arange(0, BLOCK)
index = tl.load(INDICES + row * SI + col, col < TOPK, -1).to(tl.int64)
in_bounds = (index >= 0) & (index < WIDTH) & (col < TOPK)
score = tl.load(SCORES + row * SS + index, in_bounds, -float("inf"))
# This comparison also rejects NaN; +inf remains a valid score.
valid = in_bounds & (score > -float("inf"))
page = tl.load(PAGES + row * SP + index // PAGE_SIZE, valid, 0)
slot = (page * PAGE_SIZE).to(tl.int64) + index % PAGE_SIZE
tl.store(OUT + row * SO + col, tl.where(valid, slot, -1), col < TOPK)
if WRITE_RAW:
tl.store(RAW + row * SR + col, tl.where(valid, index, -1), col < TOPK)
def filter_topk_pages(
scores: torch.Tensor,
indices: torch.Tensor,
page_table: torch.Tensor,
page_indices: torch.Tensor,
page_size: int,
raw_indices: torch.Tensor | None = None,
) -> None:
"""Preserve top-k order, write -1 for invalid scores, and map valid slots."""
rows, topk = indices.shape
assert scores.ndim == page_table.ndim == page_indices.ndim == 2
assert scores.shape[0] == page_table.shape[0] == page_indices.shape[0] == rows
assert page_indices.shape[1] == topk and scores.shape[1] > 0
assert page_table.shape[1] * page_size >= scores.shape[1]
assert all(t.stride(1) == 1 for t in (scores, indices, page_table, page_indices))
if raw_indices is not None:
assert raw_indices.shape == indices.shape and raw_indices.stride(1) == 1
_filter_topk_pages[(rows, triton.cdiv(topk, 256))](
scores,
indices,
page_table,
page_indices,
raw_indices,
scores.shape[1],
topk,
page_size,
scores.stride(0),
indices.stride(0),
page_table.stride(0),
page_indices.stride(0),
raw_indices.stride(0) if raw_indices is not None else 0,
raw_indices is not None,
256,
num_warps=4,
)
@@ -0,0 +1,118 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Union
import torch
import torch.nn.functional as F
from sglang.srt.layers.attention.dsv4.metadata import PagedIndexerMetadata
from sglang.srt.runtime_context import get_platform
if TYPE_CHECKING:
from sglang.srt.layers.attention.dsv4.candidate_indexer_deep_gemm import (
DeepGemmCandidateIndexer,
)
class CandidateMetadata:
"""Base of an implementation's published state on
``DSV4Metadata.candidate_metadata``."""
@dataclass(frozen=True)
class IndexerInputs:
"""One index-source layer's operands on the paged fp4 decode path (one query
row per request, or per draft token under verify)."""
q_fp4: torch.Tensor # [rows, 1, heads, 64] int8, packed fp4
q_sf: torch.Tensor # [rows, 1, heads] int32, packed ue8m0
k_cache: torch.Tensor # [pages, page_size, 1, 68] uint8, the layer's index-K pool
weights: torch.Tensor # [rows, heads] bf16/fp32 head weights
metadata: PagedIndexerMetadata # this ratio's lengths, page table and plans
# [rows] int, one request id per query row, the rows of one request
# consecutive (verify: its draft tokens); None = every row its own request
request_ids: Optional[torch.Tensor] = None
@property
def num_rows(self) -> int:
return self.q_fp4.shape[0]
def make_candidate_indexer(
topk_blocks: int, block_size: int
) -> Optional[DeepGemmCandidateIndexer]:
"""The paged fp4 decode path's two-level indexer; None on Hopper, whose decode
indexer selects through masks inline."""
if topk_blocks <= 0 or get_platform().device_sm < 100:
return None
from sglang.srt.layers.deep_gemm_wrapper.configurer import (
DEEPGEMM_PAGED_SPARSE_MQA_LOGITS,
)
if not DEEPGEMM_PAGED_SPARSE_MQA_LOGITS:
raise RuntimeError(
"the candidate indexer needs DeepGEMM's paged sparse MQA logits "
"(sgl-deep-gemm >= 0.2.0 with SGLANG_ENABLE_JIT_DEEPGEMM on)"
)
from sglang.srt.layers.attention.dsv4.candidate_indexer_deep_gemm import (
DeepGemmCandidateIndexer,
)
return DeepGemmCandidateIndexer(topk_blocks, block_size)
# TODO(candidate): Hopper decode and prefill still select through these masks
# inline in the backend; move them behind the protocol as publish/select_prefill.
@dataclass
class CandidateMasks(CandidateMetadata):
mask: Optional[torch.Tensor] = None # decode: [rows, width] bool
request_masks: Optional[List[torch.Tensor]] = None # prefill: [rows_b, lc_b] each
def published_masks(candidate) -> CandidateMasks:
assert isinstance(candidate, CandidateMasks), "candidate masks missing"
return candidate
def mask_topk_scores(
scores: torch.Tensor,
indices: torch.Tensor,
offsets: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Keep masked indexer scores out of attention even when top-k underfills."""
columns = indices.to(torch.int64)
if offsets is not None:
columns = columns - offsets[:, None]
selected_scores = scores.gather(1, columns.clamp(0, scores.shape[1] - 1))
valid = (
(columns >= 0) & (columns < scores.shape[1]) & (selected_scores > -torch.inf)
)
return indices.masked_fill(~valid, -1)
def select_candidate_blocks(
logits: torch.Tensor,
compress_lens: Union[torch.Tensor, int],
topk_blocks: int,
block_size: int,
) -> torch.Tensor:
"""Level one of the two-level top-k: a bool mask over positions keeping the
topk_blocks best-scoring blocks per query. Unreachable positions are already -inf
in logits, so an all -inf block means not reachable yet; the block holding the
query's newest position is always kept."""
width = logits.size(-1)
scores = F.pad(logits, (0, -width % block_size), value=-torch.inf)
scores = scores.unflatten(-1, (-1, block_size)).amax(dim=-1)
num_blocks = scores.size(-1)
last = (compress_lens - 1) // block_size
scores = scores.masked_fill(
torch.arange(num_blocks, device=logits.device) == last, torch.inf
)
top = scores.topk(min(topk_blocks, num_blocks), dim=-1)
keep = torch.zeros_like(scores, dtype=torch.bool).scatter_(
-1, top.indices, top.values > -torch.inf
)
return keep.repeat_interleave(block_size, dim=-1)[..., :width]
@@ -0,0 +1,255 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import torch
from sglang.kernels.ops.attention.dsv4.candidate_blocks import candidate_row_lens
from sglang.kernels.ops.attention.dsv4.candidate_table import (
amax8_varlen,
sort_candidate_blocks,
)
from sglang.kernels.ops.attention.dsv4.topk import (
plan_topk_v2,
topk_transform_bf16_small,
topk_transform_paged_v2,
)
from sglang.srt.layers.attention.dsv4.candidate_indexer import (
CandidateMetadata,
IndexerInputs,
)
from sglang.srt.layers.attention.dsv4.indexer import (
deep_gemm_fp4_paged_mqa_logits,
)
CANDIDATE_BLOCK_SIZE = 8 # positions per block; DeepGEMM accepts 8 or 16
@dataclass
class SparseBlockTable(CandidateMetadata):
# [rows, topk_blocks] int32: ascending logical block ids, valid for the first
# min(topk_blocks, ceil(seq_len / 8)) entries of a row; DeepGEMM reads only those
blocks: torch.Tensor
# DeepGEMM's schedule metadata (uint8) for them
schedule: torch.Tensor
# [rows, topk_blocks] int32: the same blocks as pool slots / 8, so a consumer's
# top-k maps column j of the sparse row to slot phys_blocks[b, j // 8] * 8 + j % 8
# with the plain page-table transform at page size 8
phys_blocks: torch.Tensor
# [rows] int32: length of each row of the sparse logits: the published blocks
# laid out block by block, the newest possibly partial (`candidate_row_lens`)
valid_lens: torch.Tensor
# recorded on the side stream once the fields above are complete
ready: torch.cuda.Event
def amax_topk_blocks(
logits: torch.Tensor,
seq_lens: torch.Tensor,
nblocks: torch.Tensor,
topk_blocks: int,
max_seq_len: Optional[int] = None,
) -> torch.Tensor:
"""Per row the ``topk_blocks`` blocks of 8 positions with the largest block
maximum among its first ``seq_lens[b]`` positions, the newest block always
included: block ids in no particular order, ``-1`` past the row's count.
``nblocks`` is ``ceil(seq_lens / 8)`` as int32."""
rows = logits.shape[0]
block = CANDIDATE_BLOCK_SIZE
if max_seq_len is None:
max_seq_len = logits.shape[1]
# NOTE: plan cannot be the previous kernel of topk_transform_paged_v2
plan = plan_topk_v2(nblocks)
# block maxima, the newest block +inf; the top-k reads each row up to nblocks
# only, so nothing past a row's keys is initialised (v2 needs stride % 4 == 0)
keys = logits.new_empty(rows, -(-max_seq_len // (4 * block)) * 4)
amax8_varlen(logits, seq_lens, out=keys)
blocks = torch.empty(rows, topk_blocks, dtype=torch.int32, device=logits.device)
topk_transform_paged_v2(keys, nblocks, None, blocks, 1, plan)
return blocks
def build_sparse_indexer_schedule(
blocks: torch.Tensor,
seq_lens: torch.Tensor,
page_table: torch.Tensor,
page_size: int,
q_dtype: torch.dtype,
request_ids: torch.Tensor,
) -> torch.Tensor:
"""DeepGEMM's schedule for the published blocks: ``seq_lens`` ``[rows]``
int32, ``page_table`` ``[rows, pages]`` int32 at the index pool's page size.
``request_ids`` ``[rows]`` int32 lets DeepGEMM pair two rows of a request on
one KV pass; each row keeps its own block list and output layout, and paired
rows must share their page-table row."""
import deep_gemm
return deep_gemm.get_paged_sparse_mqa_logits_metadata(
seq_lens.contiguous(),
page_table,
request_ids,
page_size,
blocks,
q_dtype,
CANDIDATE_BLOCK_SIZE,
)
def sparse_logits(
q_fp4: torch.Tensor,
q_sf: torch.Tensor,
k_cache: torch.Tensor,
weights: torch.Tensor,
table: SparseBlockTable,
) -> torch.Tensor:
"""bf16 logits ``[rows, topk_blocks * 8]`` of the published blocks: ``q_fp4``
``[rows, 1, heads, 64]`` int8 with ``q_sf`` ``[rows, 1, heads]`` int32 (packed
ue8m0), ``k_cache`` ``[pages, page_size, 1, 68]`` uint8 whose page stride is
a multiple of 512 bytes, ``weights`` ``[rows, heads]`` bf16."""
import deep_gemm
return deep_gemm.fp8_fp4_paged_sparse_mqa_logits(
(q_fp4, q_sf),
k_cache,
weights,
table.schedule,
table.blocks.shape[1],
CANDIDATE_BLOCK_SIZE,
)
def topk_transform_sparse(
logits: torch.Tensor,
valid_lens: torch.Tensor,
table: SparseBlockTable,
page_indices: torch.Tensor,
) -> None:
"""Top-``k`` (``k = page_indices.shape[1]``) of every row of the sparse
``logits`` (bf16 ``[rows, topk_blocks * 8]``) within its first ``valid_lens[b]``
columns, written as pool slots, ``-1`` where a row has fewer than ``k`` valid
columns, in no particular order."""
topk_transform_bf16_small(
logits, valid_lens, table.phys_blocks, page_indices, CANDIDATE_BLOCK_SIZE
)
# TODO(dark): support publish prefill/select prefill
# TODO(dark): support fusion of publish + topk of publish layer
class DeepGemmCandidateIndexer:
def __init__(self, topk_blocks: int, block_size: int):
assert block_size == CANDIDATE_BLOCK_SIZE, block_size
self.topk_blocks = topk_blocks
self.block_size = block_size
self.alt_stream = torch.cuda.Stream()
self._row_ids: Optional[torch.Tensor] = None
self._retired_row_ids: list = [] # captured graphs keep reading the buffers they saw
def _request_ids(
self, request_ids: Optional[torch.Tensor], rows: int, device: torch.device
) -> torch.Tensor:
"""int32 ``[rows]``; None means every row is its own request, served from a
cached ``arange`` (grown in steps of 8192) so that case costs no launch."""
if request_ids is not None:
# the scheduler keeps request indices as int64; one small cast per publish
return request_ids[:rows].to(torch.int32).contiguous()
buf = self._row_ids
if buf is None or buf.numel() < rows:
assert not torch.cuda.is_current_stream_capturing(), (
f"row-id buffer grows to {rows} rows inside a CUDA graph capture; "
"warm up with the largest row count first"
)
if buf is not None:
self._retired_row_ids.append(buf)
size = max(8192, -(-rows // 8192) * 8192)
buf = self._row_ids = torch.arange(size, dtype=torch.int32, device=device)
return buf[:rows]
def publish_decode(
self,
inputs: IndexerInputs,
page_indices: torch.Tensor,
raw_indices: Optional[torch.Tensor] = None,
) -> SparseBlockTable:
"""The publishing layer's own plain top-k into ``page_indices``, plus the
block table for the consumers; the backend stores it on the forward
metadata."""
metadata = inputs.metadata
seq_lens = metadata.compressed_seq_lens.reshape(-1)
logits = deep_gemm_fp4_paged_mqa_logits(
(inputs.q_fp4, inputs.q_sf),
inputs.k_cache,
inputs.weights,
metadata.compressed_seq_lens,
metadata.page_table,
metadata.deep_gemm_metadata,
metadata.max_compressed_seq_len,
)
main_stream = torch.cuda.current_stream()
self.alt_stream.wait_stream(main_stream)
# The block-selection chain reads logits after the main stream moves on.
logits.record_stream(self.alt_stream)
# TODO(candidate): one kernel for both selections below (dense logits read once)
topk_transform_paged_v2(
logits,
seq_lens,
metadata.page_table,
page_indices,
metadata.compressed_page_size,
metadata.topk_metadata,
out_raw_indices=raw_indices,
)
# per row: block count for the block top-k, sparse-row length for the consumers
with torch.cuda.stream(self.alt_stream):
nblocks, row_valid_lens = candidate_row_lens(seq_lens, self.topk_blocks)
blocks = amax_topk_blocks(logits, seq_lens, nblocks, self.topk_blocks)
# in place: ascending, INT32_MAX padded, plus the blocks as pool slots / 8
phys_blocks = sort_candidate_blocks(
blocks,
seq_lens,
metadata.page_table,
metadata.compressed_page_size,
)
schedule = build_sparse_indexer_schedule(
blocks,
seq_lens,
metadata.page_table,
metadata.compressed_page_size,
inputs.q_fp4.dtype,
self._request_ids(inputs.request_ids, inputs.num_rows, blocks.device),
)
# select_decode reads these on the main stream
for t in (blocks, schedule, phys_blocks, row_valid_lens):
t.record_stream(main_stream)
ready = torch.cuda.Event()
ready.record(self.alt_stream)
return SparseBlockTable(
blocks=blocks,
schedule=schedule,
phys_blocks=phys_blocks,
valid_lens=row_valid_lens,
ready=ready,
)
def _scores(self, table: SparseBlockTable, inputs: IndexerInputs) -> torch.Tensor:
return sparse_logits(
inputs.q_fp4,
inputs.q_sf,
inputs.k_cache,
inputs.weights.to(torch.bfloat16),
table,
)
def select_decode(
self,
candidate_metadata: SparseBlockTable,
inputs: IndexerInputs,
page_indices: torch.Tensor,
raw_indices: Optional[torch.Tensor] = None,
) -> None:
assert raw_indices is None
table = candidate_metadata
torch.cuda.current_stream().wait_event(table.ready)
logits = self._scores(table, inputs)
# decode carries no raw_indices; the kernel writes slots only
topk_transform_sparse(logits, table.valid_lens, table, page_indices)
@@ -439,6 +439,62 @@ def topk_transform_flashinfer_fused(
)
def deep_gemm_fp4_paged_mqa_logits(
q_fp4: Tuple[torch.Tensor, torch.Tensor],
k_cache: torch.Tensor,
weights: torch.Tensor,
seq_lens: torch.Tensor,
page_table: torch.Tensor,
deep_gemm_metadata,
max_seq_len: int,
) -> torch.Tensor:
"""DeepGEMM paged fp4 logits; no hadamard, the reference does not apply one."""
from deep_gemm import fp8_fp4_paged_mqa_logits
sl = seq_lens.to(torch.int32)
if sl.dim() == 1:
sl = sl.unsqueeze(-1)
return fp8_fp4_paged_mqa_logits(
q_fp4,
k_cache,
weights,
sl,
page_table,
deep_gemm_metadata,
max_seq_len,
False,
)
def topk_transform_paged_from_metadata(
logits: torch.Tensor,
metadata,
page_indices: torch.Tensor,
raw_indices: Optional[torch.Tensor] = None,
) -> None:
"""Pool slots into ``page_indices`` (``-1`` past the valid count) and, when given,
positions into ``raw_indices``; ``metadata`` is a ``PagedIndexerMetadata``."""
if metadata.use_topk_v2:
topk_transform_paged_v2(
logits,
metadata.compressed_seq_lens,
metadata.page_table,
page_indices,
metadata.compressed_page_size,
metadata.topk_metadata,
raw_indices,
)
else:
topk_transform_paged(
logits,
metadata.compressed_seq_lens,
metadata.page_table,
page_indices,
metadata.compressed_page_size,
raw_indices,
)
class C4IndexerBackendMixin:
def __init__(self):
super().__init__()
@@ -43,3 +43,20 @@ DEEPGEMM_SCALE_UE8M0 = ENABLE_JIT_DEEPGEMM and (
get_platform().is_sm100 or get_device_sm() == 120
)
DEEPGEMM_NEED_TMA_ALIGNED_SCALES = not (DEEPGEMM_SCALE_UE8M0 or _is_musa)
def _supports_paged_sparse_mqa_logits() -> bool:
if not DEEPGEMM_BLACKWELL:
return False
import deep_gemm
return all(
callable(getattr(deep_gemm, name, None))
for name in (
"get_paged_sparse_mqa_logits_metadata",
"fp8_fp4_paged_sparse_mqa_logits",
)
)
DEEPGEMM_PAGED_SPARSE_MQA_LOGITS = _supports_paged_sparse_mqa_logits()