[Perf][Sampler] Speed up the small-batch split top-p pipeline (#61996)

Signed-off-by: Bowen Wang <bowen@inferact.ai>
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Nick Hill <nickhill123@gmail.com>
Co-authored-by: vllm-agent <claw@inferact.ai>
This commit is contained in:
Bowen Wang
2026-10-16 21:22:11 -07:00
committed by GitHub
co-authored by Claude Opus 5.5 Nick Hill vllm-agent
parent 556ac15eb9
commit 7b6310c109
9 changed files with 148 additions and 24 deletions
+52 -2
View File
@@ -293,7 +293,7 @@ class TestTritonTopkTopp:
self._compare_results(logits, k, p=None)
@pytest.mark.parametrize("batch_size", [1, 8, 32, 128, 512, 1024])
@pytest.mark.parametrize("batch_size", [1, 8, 32, 48, 128, 512, 1024])
@pytest.mark.parametrize("vocab_size", [1024, 32000, 128256])
def test_topp_only(self, batch_size: int, vocab_size: int):
"""Test top-p only (k=None)."""
@@ -307,7 +307,7 @@ class TestTritonTopkTopp:
self._compare_results(logits, k=None, p=p)
@pytest.mark.parametrize("batch_size", [1, 8, 32, 128, 512, 1024])
@pytest.mark.parametrize("batch_size", [1, 8, 32, 48, 128, 512, 1024])
@pytest.mark.parametrize("vocab_size", [1024, 32000, 128256])
def test_topk_and_topp(self, batch_size: int, vocab_size: int):
"""Test combined top-k and top-p."""
@@ -328,6 +328,24 @@ class TestTritonTopkTopp:
self._compare_results(logits, k, p)
@pytest.mark.parametrize("batch_size", [8, 48, 64])
@pytest.mark.parametrize("vocab_size", [151936, 200064, 262144])
def test_topk_and_topp_large_vocab(self, batch_size: int, vocab_size: int):
"""Mixed batches at vocab sizes where the split top-p pipeline uses
its largest split counts (up to _SPLIT_MAX_SPLITS)."""
logits = torch.randn(
batch_size, vocab_size, generator=self.generator, dtype=torch.float32
)
k = torch.randint(1, 100, (batch_size,), generator=self.generator)
p = torch.rand(batch_size, generator=self.generator) * 0.9 + 0.1
# Rows cycle through top-k + top-p, top-p only, top-k only, neither.
k[1::4] = vocab_size
p[2::4] = 1.0
k[3::4] = vocab_size
p[3::4] = 1.0
self._compare_results(logits, k, p)
def test_both_disabled(self):
"""Test when both k and p are None (should be no-op)."""
from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
@@ -788,6 +806,38 @@ class TestTritonTopkTopp:
f"{kept_topk_topp.tolist()}"
)
@pytest.mark.parametrize("batch_size", [8, 48])
def test_skip_split_without_topp_only_rows(
self, batch_size: int, monkeypatch: pytest.MonkeyPatch
):
"""has_topp_only_rows=False skips the split pipeline; the result must
not change when every top-p row also has a top-k, grammar rows
(k >= #finite logits) included."""
import vllm.v1.sample.ops.topk_topp_triton as tt
vocab_size = 32000
logits = torch.randn(
batch_size, vocab_size, generator=self.generator, dtype=torch.float32
)
logits[1] = float("-inf")
idx = torch.randperm(vocab_size, generator=self.generator)[:30]
logits[1, idx] = torch.randn(30, generator=self.generator)
k = torch.randint(
50, 100, (batch_size,), generator=self.generator, dtype=torch.int32
)
p = torch.rand(batch_size, generator=self.generator) * 0.9 + 0.1
k[::4] = vocab_size
p[::4] = 1.0
expected = tt.apply_top_k_top_p_triton(logits.clone(), k, p)
monkeypatch.setattr(
tt, "_apply_topp_split", lambda *args: pytest.fail("split launched")
)
actual = tt.apply_top_k_top_p_triton(
logits.clone(), k, p, has_topp_only_rows=False
)
assert torch.equal(actual, expected)
def test_topp_tie_break_deterministic(self):
"""Boundary-duplicate trimming must be deterministic across calls.
+24
View File
@@ -109,6 +109,30 @@ def test_custom_processor_add_request_gates_pipeline_flag(admitted: bool):
assert sampler.needs_logits_processing[3] == admitted
def test_has_topp_only_rows():
"""A row is top-p-only when it has top-p and no effective top-k; top_k
of 0 (the default) or at least the logits width disables top-k."""
sampler = _make_sampler()
sampler.add_request(0, SamplingParams(top_k=10, top_p=0.9))
sampler.add_request(1, SamplingParams(top_k=10))
sampler.add_request(2, SamplingParams(top_p=0.9))
sampler.add_request(3, SamplingParams(top_k=VOCAB_SIZE + 1, top_p=0.9))
states = sampler.sampling_states
states.apply_staged_writes()
def has_topp_only_rows(idx: list[int], vocab_size: int = VOCAB_SIZE) -> bool:
idx_np = np.array(idx, dtype=np.int32)
idx_gpu = torch.from_numpy(idx_np).to(DEVICE)
return states.get_top_k_top_p(idx_gpu, idx_np, vocab_size)[2]
assert not has_topp_only_rows([0, 1])
assert has_topp_only_rows([0, 2])
assert has_topp_only_rows([3])
# The kernel compares top_k against the logits width, so a top_k at or
# above a narrower logits width counts as no top-k.
assert has_topp_only_rows([0], vocab_size=10)
def test_logits_processing_cache_only_checks_active_requests():
sampler = _make_sampler()
sampler.add_request(0, SamplingParams(temperature=0.0))
+7 -1
View File
@@ -368,6 +368,7 @@ def test_gpu_sampler_respects_mixed_request_watermarking(
None,
None,
False,
False,
)
assert torch.equal(sampled, torch.tensor([3, 7]))
@@ -383,7 +384,7 @@ def test_gpu_sampler_filters_top_k_top_p_before_watermarking(
# reference path; the assertion is about the call order, not the kernel.
monkeypatch.setattr(
"vllm.v1.watermarking.gpu_sampler.apply_top_k_top_p",
lambda logits, k, p: apply_top_k_top_p_pytorch(
lambda logits, k, p, **kwargs: apply_top_k_top_p_pytorch(
logits, k, p, allow_cpu_sync=True
),
)
@@ -415,6 +416,7 @@ def test_gpu_sampler_filters_top_k_top_p_before_watermarking(
torch.tensor([2]),
torch.tensor([0.8]),
False,
False,
)
assert watermarker.captured_logits is not None
@@ -482,6 +484,7 @@ def test_gpu_sampler_skips_watermarking_for_repeated_contexts(monkeypatch):
None,
None,
False,
False,
)
assert torch.equal(sampled, torch.tensor([3, 7]))
@@ -517,6 +520,7 @@ def test_gpu_sampler_can_disable_context_deduplication(monkeypatch):
None,
None,
False,
False,
)
assert torch.equal(sampled, torch.tensor([7, 7]))
@@ -956,6 +960,7 @@ def test_gpu_sampler_uses_fused_gumbel_for_repeated_contexts():
None,
None,
False,
False,
)
assert torch.equal(repeated, torch.tensor([True, False], device=device))
@@ -992,6 +997,7 @@ def test_gpu_sampler_skips_watermarking_for_greedy_batch(monkeypatch):
None,
None,
False,
False,
)
assert actual is expected
@@ -1404,14 +1404,17 @@ class DiffusionSampler:
# unaffected; only the canvas exploration is constrained. Applied
# before canvas padding so phantom positions stay uniform.
if num_decode > 0:
top_k, top_p = self.sampling_states.get_top_k_top_p(
top_k, top_p, has_topp_only_rows = self.sampling_states.get_top_k_top_p(
decode_slots.repeat_interleave(
valid_canvas_len, output_size=int(valid_canvas_len_np.sum())
),
decode_slots_np,
logits.shape[-1],
)
if top_k is not None or top_p is not None:
logits = apply_top_k_top_p(logits.float(), top_k, top_p)
logits = apply_top_k_top_p(
logits.float(), top_k, top_p, has_topp_only_rows=has_topp_only_rows
)
# Where each decode request's rows start in the flat logits. Tiles
# below gather and pad a request to the tile's width.
+12 -2
View File
@@ -484,13 +484,23 @@ class TopKTopPSampler(nn.Module):
def apply_top_k_top_p(
logits: torch.Tensor, k: torch.Tensor | None, p: torch.Tensor | None
logits: torch.Tensor,
k: torch.Tensor | None,
p: torch.Tensor | None,
has_topp_only_rows: bool = True,
) -> torch.Tensor:
"""Apply top-k and/or top-p masking to logits.
has_topp_only_rows must be True if any row has p < 1 and
k >= logits.shape[-1] (no active top-k).
"""
if p is None and k is None:
return logits
if HAS_TRITON:
return apply_top_k_top_p_triton(logits, k, p)
return apply_top_k_top_p_triton(
logits, k, p, has_topp_only_rows=has_topp_only_rows
)
is_cpu = current_platform.is_cpu()
return apply_top_k_top_p_pytorch(logits, k, p, allow_cpu_sync=is_cpu)
+16 -6
View File
@@ -987,13 +987,19 @@ _SPLIT_MAX_BATCH = 64
_SPLIT_MAX_SPLITS = 32 # power of two
_SPLIT_FANOUT = 8
_SPLIT_ROUNDS = 5
# The step kernels are latency-bound at one program per SM, so keep splitting
# past that while slices span at least this many entries; shorter slices no
# longer amortize each program's row stats and combine replay.
_SPLIT_MIN_SLICE = 7168
_TRITON_SPLIT_CACHE: dict[torch.device, dict[str, torch.Tensor]] = {}
def _topp_split_count(batch_size: int, num_sm: int) -> int:
def _topp_split_count(batch_size: int, vocab_size: int, num_sm: int) -> int:
splits = 1
while splits < _SPLIT_MAX_SPLITS and splits * 2 * batch_size <= num_sm:
while splits < _SPLIT_MAX_SPLITS and (
splits * 2 * batch_size <= num_sm or splits * 2 * _SPLIT_MIN_SLICE <= vocab_size
):
splits *= 2
return splits
@@ -1079,7 +1085,7 @@ def _topp_split_stats(
num_sm: int,
) -> DispatchSpec:
batch_size, vocab_size = logits.shape
splits = _topp_split_count(batch_size, num_sm)
splits = _topp_split_count(batch_size, vocab_size, num_sm)
return (logits.shape[0] * splits,), dict(
K=k if k is not None else logits,
HAS_K=k is not None,
@@ -1373,7 +1379,7 @@ def _topp_split_step(
num_sm: int,
) -> DispatchSpec:
batch_size, vocab_size = logits.shape
splits = _topp_split_count(batch_size, num_sm)
splits = _topp_split_count(batch_size, vocab_size, num_sm)
return (logits.shape[0] * splits,), dict(
K=k if k is not None else logits,
HAS_K=k is not None,
@@ -1555,7 +1561,7 @@ def _topp_split_mask(
num_sm: int,
) -> DispatchSpec:
batch_size, vocab_size = logits.shape
splits = _topp_split_count(batch_size, num_sm)
splits = _topp_split_count(batch_size, vocab_size, num_sm)
return (logits.shape[0] * splits,), dict(
K=k if k is not None else logits,
HAS_K=k is not None,
@@ -1619,6 +1625,7 @@ def apply_top_k_top_p_triton(
k: torch.Tensor | None,
p: torch.Tensor | None,
mask_value: float = float("-inf"),
has_topp_only_rows: bool = True,
) -> torch.Tensor:
"""Apply combined top-k and top-p masking using Triton.
@@ -1633,6 +1640,9 @@ def apply_top_k_top_p_triton(
p: [batch_size] float32 tensor of top-p values per row (0 to 1),
or None to disable top-p
mask_value: Value for masked positions (default: -inf)
has_topp_only_rows: Whether any row has p < 1 and k >= vocab_size.
False lets small batches skip the split top-p pipeline; pass it
only if no such row exists, as such a row would get no top-p.
Returns:
The masked logits tensor. It may or may not be modified in-place.
@@ -1715,7 +1725,7 @@ def apply_top_k_top_p_triton(
mask_value,
num_sm,
)
if use_split:
if use_split and has_topp_only_rows:
_apply_topp_split(logits, k_ptr, p_ptr, mask_value, num_sm)
return logits
+5 -1
View File
@@ -58,6 +58,7 @@ class GPUWatermarkSampler(Sampler):
pos: torch.Tensor,
top_k: torch.Tensor | None,
top_p: torch.Tensor | None,
has_topp_only_rows: bool,
use_fused_sampler: bool,
) -> tuple[torch.Tensor, torch.Tensor]:
enabled = self.watermarking.np[idx_mapping_np] & (
@@ -71,10 +72,13 @@ class GPUWatermarkSampler(Sampler):
pos,
top_k,
top_p,
has_topp_only_rows,
use_fused_sampler,
)
processed_logits = apply_top_k_top_p(processed_logits, top_k, top_p)
processed_logits = apply_top_k_top_p(
processed_logits, top_k, top_p, has_topp_only_rows=has_topp_only_rows
)
contexts = self._get_contexts(expanded_idx_mapping)
repeated_contexts = None
if self.deduplicate_contexts != "none":
+7 -3
View File
@@ -298,8 +298,8 @@ class Sampler:
seq_lens_upper_bound_np,
skip_top_k_top_p=True,
)
top_k, top_p = self.sampling_states.get_top_k_top_p(
expanded_idx_mapping, idx_mapping_np
top_k, top_p, has_topp_only_rows = self.sampling_states.get_top_k_top_p(
expanded_idx_mapping, idx_mapping_np, logits.shape[-1]
)
# Don't use a fused sampler if no requests use top_k/top_p, if there are
# any greedy requests or per-request seeds, or if post-processed
@@ -321,6 +321,7 @@ class Sampler:
pos,
top_k,
top_p,
has_topp_only_rows,
use_fused_sampler,
)
@@ -332,6 +333,7 @@ class Sampler:
pos: torch.Tensor,
top_k: torch.Tensor | None,
top_p: torch.Tensor | None,
has_topp_only_rows: bool,
use_fused_sampler: bool,
) -> tuple[torch.Tensor, torch.Tensor]:
if use_fused_sampler:
@@ -342,7 +344,9 @@ class Sampler:
else: # Use XPU sampler
sampled, _ = xpu_sample(processed_logits, top_k, top_p)
else:
processed_logits = apply_top_k_top_p(processed_logits, top_k, top_p)
processed_logits = apply_top_k_top_p(
processed_logits, top_k, top_p, has_topp_only_rows=has_topp_only_rows
)
sampled = gumbel_sample(
processed_logits,
expanded_idx_mapping,
+20 -7
View File
@@ -103,13 +103,22 @@ class SamplingStates:
apply_min_p(logits, expanded_idx_mapping, self.min_p.gpu)
def get_top_k_top_p(
self, expanded_idx_mapping: torch.Tensor, idx_mapping_np: np.ndarray
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
do_top_k = np.any(self.top_k.np[idx_mapping_np] != self.vocab_size)
do_top_p = np.any(self.top_p.np[idx_mapping_np] != 1.0)
self,
expanded_idx_mapping: torch.Tensor,
idx_mapping_np: np.ndarray,
vocab_size: int,
) -> tuple[torch.Tensor | None, torch.Tensor | None, bool]:
"""Returns (top_k, top_p, has_topp_only_rows)."""
top_k_np = self.top_k.np[idx_mapping_np]
top_p_np = self.top_p.np[idx_mapping_np]
do_top_k = np.any(top_k_np != self.vocab_size)
do_top_p = np.any(top_p_np != 1.0)
top_k = self.top_k.gpu[expanded_idx_mapping] if do_top_k else None
top_p = self.top_p.gpu[expanded_idx_mapping] if do_top_p else None
return top_k, top_p
has_topp_only_rows = bool(
do_top_p and np.any((top_k_np >= vocab_size) & (top_p_np < 1.0))
)
return top_k, top_p, has_topp_only_rows
def apply_top_k_top_p(
self,
@@ -117,10 +126,14 @@ class SamplingStates:
expanded_idx_mapping: torch.Tensor,
idx_mapping_np: np.ndarray,
) -> torch.Tensor:
top_k, top_p = self.get_top_k_top_p(expanded_idx_mapping, idx_mapping_np)
top_k, top_p, has_topp_only_rows = self.get_top_k_top_p(
expanded_idx_mapping, idx_mapping_np, logits.shape[-1]
)
if top_k is None and top_p is None:
return logits
return apply_top_k_top_p(logits, top_k, top_p)
return apply_top_k_top_p(
logits, top_k, top_p, has_topp_only_rows=has_topp_only_rows
)
def any_greedy(self, idx_mapping_np: np.ndarray) -> bool:
return bool(np.any(self.temperature.np[idx_mapping_np] == 0.0))