main
py 1,090 lines 42.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import pytest
4 import torch
5 from torch import Generator
6
7 from tests.utils import large_gpu_mark
8 from vllm.platforms import current_platform
9 from vllm.triton_utils import HAS_TRITON
10 from vllm.utils.torch_utils import set_random_seed
11 from vllm.v1.sample.ops.topk_topp_sampler import (
12 apply_top_k_top_p_pytorch,
13 random_sample,
14 )
15 from vllm.v1.sample.sampler import Sampler
16
17 DEVICE_TYPE = current_platform.device_type
18
19 BATCH_SIZE = 1024
20 VOCAB_SIZE = 128 * 1024
21
22
23 def _flashinfer_topk_topp_supported() -> bool:
24 """True iff the FlashInfer top-k/top-p sampler is usable on this host.
25
26 Mirrors the gate in `TopKTopPSampler.__init__`: CUDA + flashinfer
27 importable + GPU compute capability supported by the FlashInfer
28 backend + more than 16 SMs.
29 """
30 if not current_platform.is_cuda():
31 return False
32 try:
33 import flashinfer # noqa: F401
34
35 from vllm.v1.attention.backends.flashinfer import FlashInferBackend
36 except ImportError:
37 return False
38 capability = current_platform.get_device_capability()
39 if capability is None:
40 return False
41 return FlashInferBackend.supports_compute_capability(capability) and (
42 current_platform.num_compute_units(torch.accelerator.current_device_index())
43 > 16
44 )
45
46
47 FLASHINFER_TOPK_TOPP_SUPPORTED = _flashinfer_topk_topp_supported()
48
49
50 def _seed_default_generator(seed: int) -> None:
51 set_random_seed(seed)
52
53
54 @pytest.fixture(autouse=True)
55 def reset_default_device():
56 """Explicitly set the default device, which can affect subsequent tests.
57 Adding this fixture helps avoid this problem.
58 """
59 original_device = torch.get_default_device()
60 yield
61 torch.set_default_device(original_device)
62
63
64 def test_sampler_threads_fp64_gumbel_to_topk_topp_sampler():
65 sampler = Sampler(use_fp64_gumbel=True)
66
67 assert sampler.topk_topp_sampler.use_fp64_gumbel
68
69
70 @pytest.mark.skipif(
71 not current_platform.is_rocm(),
72 reason="ROCm aiter sampler test only runs on ROCm",
73 )
74 def test_rocm_aiter_sampler_defers_import_when_generators_force_native(
75 monkeypatch: pytest.MonkeyPatch,
76 ):
77 from vllm.v1.sample.ops import topk_topp_sampler
78
79 class MockPlatform:
80 @staticmethod
81 def is_cuda():
82 return False
83
84 @staticmethod
85 def is_cuda_alike():
86 return True
87
88 @staticmethod
89 def is_cpu():
90 return False
91
92 @staticmethod
93 def is_xpu():
94 return False
95
96 class MockRocmAiterOps:
97 @staticmethod
98 def is_enabled():
99 return True
100
101 real_import = __import__
102
103 def guard_aiter_sampling_import(name, *args, **kwargs):
104 if name == "aiter.ops.sampling":
105 raise AssertionError("aiter sampling import should be deferred")
106 return real_import(name, *args, **kwargs)
107
108 monkeypatch.setattr(topk_topp_sampler, "current_platform", MockPlatform())
109 monkeypatch.setattr(topk_topp_sampler, "rocm_aiter_ops", MockRocmAiterOps())
110 monkeypatch.setattr("builtins.__import__", guard_aiter_sampling_import)
111
112 sampler = topk_topp_sampler.TopKTopPSampler()
113 logits = torch.randn(2, 8)
114 k = torch.full((2,), 2, dtype=torch.int32)
115 generators = {0: torch.Generator(device=logits.device).manual_seed(0)}
116
117 token_ids, logits_to_return = sampler(logits, generators, k, None)
118
119 assert token_ids.shape == (2,)
120 assert logits_to_return is None
121
122
123 def test_random_sample_uses_fp64_exponential_race_when_requested():
124 torch.set_default_device(DEVICE_TYPE)
125 probs = torch.tensor(
126 [
127 [0.70, 0.20, 0.10],
128 [0.05, 0.15, 0.80],
129 [0.25, 0.25, 0.50],
130 ],
131 dtype=torch.float32,
132 device=DEVICE_TYPE,
133 )
134
135 _seed_default_generator(12345)
136 q = torch.empty(probs.shape, dtype=torch.float64, device=probs.device)
137 q.exponential_()
138 expected = q.reciprocal_().mul_(probs).argmax(dim=-1).view(-1)
139
140 _seed_default_generator(12345)
141 actual = random_sample(probs.clone(), {}, use_fp64_gumbel=True)
142
143 assert torch.equal(actual, expected)
144
145
146 def test_topk_impl_equivalence():
147 torch.set_default_device(DEVICE_TYPE)
148 generator = Generator(device=DEVICE_TYPE).manual_seed(33)
149
150 logits = torch.rand((BATCH_SIZE, VOCAB_SIZE), generator=generator)
151
152 # Random top-k values between 1 and 9.
153 k = torch.randint(1, 10, (BATCH_SIZE,), generator=generator)
154
155 # Set k=vocab_size for ~50% of requests in the batch (top-k disabled).
156 k.masked_fill_(
157 torch.randint(0, 2, (BATCH_SIZE,), generator=generator, dtype=bool), VOCAB_SIZE
158 )
159
160 # Top-k only implementation
161 result1 = apply_top_k_top_p_pytorch(logits=logits.clone(), k=k, p=None)
162
163 # Top-p + top-k
164 no_op_top_p = torch.tensor([1.0])
165 result2 = apply_top_k_top_p_pytorch(logits=logits.clone(), k=k, p=no_op_top_p)
166
167 assert torch.allclose(result1, result2)
168
169
170 @pytest.mark.skipif(
171 not FLASHINFER_TOPK_TOPP_SUPPORTED,
172 reason="Requires CUDA and flashinfer",
173 )
174 def test_flashinfer_sampler_fallback_when_jit_cannot_target_gpu(monkeypatch):
175 """FlashInfer swallows arch-detection errors (e.g. SM 12.x with a CUDA
176 toolkit older than 12.9), leaving an empty target-arch set that makes
177 every JIT spec fail with a misleading "requires sm75 or higher" error at
178 first use, killing the engine during startup profiling (issue #42393).
179 The sampler gate must detect this and fall back to native sampling, and
180 surface the real reason when the user explicitly opted in.
181 """
182 import flashinfer.jit.core
183 from flashinfer.compilation_context import CompilationContext
184
185 from vllm.v1.sample.ops.topk_topp_sampler import flashinfer_sampler_supported
186
187 def fake_check_cuda_arch():
188 raise RuntimeError("FlashInfer requires GPUs with sm75 or higher")
189
190 def fake_normalize_cuda_arch(major, minor):
191 raise RuntimeError("SM 12.x requires CUDA >= 12.9")
192
193 monkeypatch.setattr(flashinfer.jit.core, "check_cuda_arch", fake_check_cuda_arch)
194 monkeypatch.setattr(
195 CompilationContext,
196 "_normalize_cuda_arch",
197 staticmethod(fake_normalize_cuda_arch),
198 )
199
200 monkeypatch.delenv("VLLM_USE_FLASHINFER_SAMPLER", raising=False)
201 assert flashinfer_sampler_supported() is False
202
203 monkeypatch.setenv("VLLM_USE_FLASHINFER_SAMPLER", "1")
204 with pytest.raises(RuntimeError, match="SM 12.x requires CUDA >= 12.9"):
205 flashinfer_sampler_supported()
206
207
208 # =============================================================================
209 # Triton kernel tests
210 # =============================================================================
211
212
213 @pytest.mark.skipif(not HAS_TRITON, reason="Triton not available on this platform")
214 class TestTritonTopkTopp:
215 """Tests for the Triton top-k/top-p kernel."""
216
217 @pytest.fixture(autouse=True)
218 def setup(self):
219 """Set up test fixtures."""
220 torch.set_default_device(DEVICE_TYPE)
221 self.generator = Generator(device=DEVICE_TYPE).manual_seed(42)
222
223 def _compare_results(
224 self,
225 logits: torch.Tensor,
226 k: torch.Tensor | None,
227 p: torch.Tensor | None,
228 ):
229 """Compare Triton kernel results with PyTorch sorting implementation.
230
231 For top-k only, we expect exact match.
232 For top-p (with or without top-k), we allow small differences due to
233 floating-point precision in probability sum calculations.
234 """
235 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
236
237 # Clone logits for both implementations
238 logits_pytorch = logits.clone()
239 logits_triton = logits.clone().to(torch.float32)
240
241 # Apply PyTorch sorting implementation
242 result_pytorch = apply_top_k_top_p_pytorch(logits_pytorch, k, p)
243
244 # Apply Triton kernel
245 k_i32 = k.to(torch.int32) if k is not None else None
246 p_f32 = p.to(torch.float32) if p is not None else None
247 result_triton = apply_top_k_top_p_triton(logits_triton, k_i32, p_f32)
248
249 # Compare kept counts per row
250 pytorch_kept = (result_pytorch != float("-inf")).sum(dim=-1)
251 triton_kept = (result_triton != float("-inf")).sum(dim=-1)
252
253 if p is None:
254 # Top-k only: expect exact match
255 assert torch.equal(pytorch_kept, triton_kept), (
256 f"Top-k mask mismatch: PyTorch kept {pytorch_kept.tolist()}, "
257 f"Triton kept {triton_kept.tolist()}"
258 )
259 else:
260 # Top-p involved: allow small differences
261 # Either < 1% of kept values OR < 5 values absolute
262 max_diff = (pytorch_kept - triton_kept).abs().max().item()
263 max_kept = pytorch_kept.max().item()
264 if max_kept > 0 and max_diff > 3:
265 diff_pct = max_diff / max_kept * 100
266 assert diff_pct < 0.5, (
267 f"Top-p mask difference too large: {diff_pct:.2f}% "
268 f"(max diff {max_diff} values out of {max_kept})"
269 )
270
271 @pytest.mark.parametrize("batch_size", [1, 8, 32, 128, 512, 1024])
272 @pytest.mark.parametrize("vocab_size", [1024, 32000, 128256])
273 def test_topk_only(self, batch_size: int, vocab_size: int):
274 """Test top-k only (p=None)."""
275 logits = torch.randn(
276 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
277 )
278 k = torch.randint(
279 1, min(100, vocab_size), (batch_size,), generator=self.generator
280 )
281 # Randomly disable top-k for some rows (~25%)
282 disable_mask = torch.randint(0, 4, (batch_size,), generator=self.generator) == 0
283 k.masked_fill_(disable_mask, vocab_size)
284
285 self._compare_results(logits, k, p=None)
286
287 @pytest.mark.parametrize("batch_size", [1, 8, 32, 128, 512, 1024])
288 @pytest.mark.parametrize("vocab_size", [1024, 32000, 128256])
289 def test_topp_only(self, batch_size: int, vocab_size: int):
290 """Test top-p only (k=None)."""
291 logits = torch.randn(
292 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
293 )
294 p = torch.rand(batch_size, generator=self.generator) * 0.9 + 0.1 # [0.1, 1.0]
295 # Randomly disable top-p for some rows (~25%)
296 disable_mask = torch.randint(0, 4, (batch_size,), generator=self.generator) == 0
297 p.masked_fill_(disable_mask, 1.0)
298
299 self._compare_results(logits, k=None, p=p)
300
301 @pytest.mark.parametrize("batch_size", [1, 8, 32, 128, 512, 1024])
302 @pytest.mark.parametrize("vocab_size", [1024, 32000, 128256])
303 def test_topk_and_topp(self, batch_size: int, vocab_size: int):
304 """Test combined top-k and top-p."""
305 logits = torch.randn(
306 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
307 )
308 k = torch.randint(
309 1, min(100, vocab_size), (batch_size,), generator=self.generator
310 )
311 p = torch.rand(batch_size, generator=self.generator) * 0.9 + 0.1 # [0.1, 1.0]
312
313 # Randomly disable top-k for some rows (~25%)
314 disable_k = torch.randint(0, 4, (batch_size,), generator=self.generator) == 0
315 k.masked_fill_(disable_k, vocab_size)
316 # Randomly disable top-p for some rows (~25%)
317 disable_p = torch.randint(0, 4, (batch_size,), generator=self.generator) == 0
318 p.masked_fill_(disable_p, 1.0)
319
320 self._compare_results(logits, k, p)
321
322 def test_both_disabled(self):
323 """Test when both k and p are None (should be no-op)."""
324 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
325
326 logits = torch.randn(32, 1024, generator=self.generator, dtype=torch.float32)
327 logits_clone = logits.clone()
328
329 result = apply_top_k_top_p_triton(logits_clone, k=None, p=None)
330
331 assert torch.equal(result, logits), "Should be no-op when both k and p are None"
332
333 def test_extreme_k_values(self):
334 """Test edge cases for k values."""
335 batch_size, vocab_size = 16, 1024
336 logits = torch.randn(
337 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
338 )
339
340 # k=1 (keep only top 1)
341 k = torch.ones(batch_size, dtype=torch.int32)
342 self._compare_results(logits.clone(), k, p=None)
343
344 # k=vocab_size (keep all)
345 k = torch.full((batch_size,), vocab_size, dtype=torch.int32)
346 self._compare_results(logits.clone(), k, p=None)
347
348 # Mixed extreme values
349 k = torch.tensor([1, vocab_size, 2, vocab_size - 1] * 4, dtype=torch.int32)
350 self._compare_results(logits.clone(), k, p=None)
351
352 def test_extreme_p_values(self):
353 """Test edge cases for p values."""
354 batch_size, vocab_size = 16, 1024
355 logits = torch.randn(
356 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
357 )
358
359 # p close to 0 (very restrictive)
360 p = torch.full((batch_size,), 0.01, dtype=torch.float32)
361 self._compare_results(logits.clone(), k=None, p=p)
362
363 # p=1.0 (keep all)
364 p = torch.ones(batch_size, dtype=torch.float32)
365 self._compare_results(logits.clone(), k=None, p=p)
366
367 # Mixed values
368 p = torch.tensor([0.1, 0.5, 0.9, 1.0] * 4, dtype=torch.float32)
369 self._compare_results(logits.clone(), k=None, p=p)
370
371 def test_large_batch(self):
372 """Test with a large batch size."""
373 batch_size, vocab_size = 512, 32000
374 logits = torch.randn(
375 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
376 )
377 k = torch.randint(1, 50, (batch_size,), generator=self.generator)
378 p = torch.rand(batch_size, generator=self.generator) * 0.5 + 0.5
379
380 self._compare_results(logits, k, p)
381
382 @large_gpu_mark(min_gb=24)
383 def test_large_batch_int64_row_offset(self):
384 """Regression: per-row offset (row * vocab_size) must not overflow int32.
385
386 Speculative decoding expands the logits batch (e.g. DFlash drafts K
387 tokens per request), so batch_size * vocab_size can exceed 2**31. With
388 int32 offset arithmetic the per-row pointer wraps to a negative address
389 and the kernel hits a CUDA illegal memory access. Use a batch where
390 batch_size * vocab_size > 2**31 and give the highest-offset row the same
391 logits as row 0: an overflow there would read a different row and change
392 the kept set.
393 """
394 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
395
396 if not current_platform.is_cuda():
397 pytest.skip("int32 row-offset overflow is a CUDA kernel issue")
398 vocab_size = 131072
399 batch_size = 2**31 // vocab_size + 64 # batch_size * vocab_size > 2**31
400 # logits is modified in place; the only extra device memory is the
401 # per-SM scratch buffer (~num_sm * vocab), so allow ~1 GB of headroom.
402 required_bytes = batch_size * vocab_size * 4 + (1 << 30)
403 if torch.accelerator.get_memory_info()[0] < required_bytes:
404 pytest.skip(f"needs ~{required_bytes / 1e9:.0f} GB of free GPU memory")
405
406 logits = torch.randn(
407 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
408 )
409 logits[batch_size - 1] = logits[0]
410 k = torch.full((batch_size,), 5, dtype=torch.int32)
411 result = apply_top_k_top_p_triton(logits, k, None)
412 torch.accelerator.synchronize() # surface any async illegal memory access
413 kept_first = (result[0] > float("-inf")).nonzero(as_tuple=True)[0]
414 kept_last = (result[batch_size - 1] > float("-inf")).nonzero(as_tuple=True)[0]
415 assert kept_first.numel() == 5, f"row 0 kept {kept_first.numel()}, expected 5"
416 assert torch.equal(kept_first, kept_last), (
417 "highest-offset row produced a different top-k mask than the "
418 "identical row 0 (int32 row-offset overflow)"
419 )
420
421 @pytest.mark.parametrize(
422 "mode",
423 ["topk_only", "topp_only", "topk_and_topp"],
424 )
425 def test_noncontiguous_logits_match_contiguous(self, mode: str):
426 """Non-contiguous logits views should behave like contiguous inputs."""
427 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
428
429 device = torch.device(DEVICE_TYPE)
430 batch_size, vocab_size, pad = 16, 4096, 8
431 backing = torch.full(
432 (batch_size, vocab_size + pad),
433 -1000.0,
434 device=device,
435 dtype=torch.float32,
436 )
437 base = torch.linspace(
438 10.0, -10.0, vocab_size, device=device, dtype=torch.float32
439 )
440 source = base[None, :] + (
441 torch.arange(batch_size, device=device, dtype=torch.float32)[:, None]
442 / 1000.0
443 )
444
445 logits = backing[:, :vocab_size]
446 logits.copy_(source)
447 contig_logits = source.clone()
448 pytorch_logits = source.clone()
449
450 assert logits.shape == (batch_size, vocab_size)
451 assert logits.stride() == (vocab_size + pad, 1)
452 assert not logits.is_contiguous()
453
454 k: torch.Tensor | None = None
455 p: torch.Tensor | None = None
456 if mode in ("topk_only", "topk_and_topp"):
457 k = torch.full((batch_size,), 154, device=device, dtype=torch.int32)
458 if mode in ("topp_only", "topk_and_topp"):
459 p = torch.full((batch_size,), 0.95, device=device, dtype=torch.float32)
460
461 noncontig_out = apply_top_k_top_p_triton(logits, k, p)
462 contig_out = apply_top_k_top_p_triton(contig_logits, k, p)
463 pytorch_out = apply_top_k_top_p_pytorch(pytorch_logits, k, p)
464
465 assert noncontig_out.data_ptr() == logits.data_ptr()
466 assert not noncontig_out.is_contiguous()
467 assert torch.equal(logits, noncontig_out)
468 assert torch.equal(torch.isfinite(noncontig_out), torch.isfinite(contig_out))
469 assert torch.equal(torch.isfinite(noncontig_out), torch.isfinite(pytorch_out))
470
471 # -----------------------------------------------------------------
472 # Tests for -inf logits (e.g. from grammar / structured output masks)
473 # -----------------------------------------------------------------
474
475 @pytest.mark.parametrize("inf_fraction", [0.5, 0.9, 0.99])
476 def test_topk_with_neginf_logits(self, inf_fraction: float):
477 """Top-k with many -inf logits (simulating grammar bitmask).
478
479 The kernel must not produce NaN when most logits are -inf, which
480 can happen when structured-output grammar masks are applied before
481 sampling.
482 """
483 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
484
485 batch_size, vocab_size = 32, 128256
486 logits = torch.randn(
487 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
488 )
489 # Mask a fraction of logits to -inf.
490 mask = (
491 torch.rand(batch_size, vocab_size, generator=self.generator) < inf_fraction
492 )
493 logits[mask] = float("-inf")
494
495 k = torch.randint(
496 1, 50, (batch_size,), generator=self.generator, dtype=torch.int32
497 )
498 result = apply_top_k_top_p_triton(logits.clone(), k, None)
499
500 assert not result.isnan().any(), "NaN found in top-k result with -inf logits"
501 for i in range(batch_size):
502 kept = (result[i] > float("-inf")).sum().item()
503 assert kept <= k[i].item(), f"Row {i}: kept {kept} > k={k[i].item()}"
504 # At least one value should survive unless the row was all -inf.
505 finite_in = (logits[i] > float("-inf")).sum().item()
506 if finite_in > 0:
507 assert kept > 0, f"Row {i}: no tokens kept despite finite input"
508
509 @pytest.mark.parametrize("inf_fraction", [0.5, 0.9, 0.99])
510 def test_topp_with_neginf_logits(self, inf_fraction: float):
511 """Top-p with many -inf logits."""
512 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
513
514 batch_size, vocab_size = 32, 128256
515 logits = torch.randn(
516 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
517 )
518 mask = (
519 torch.rand(batch_size, vocab_size, generator=self.generator) < inf_fraction
520 )
521 logits[mask] = float("-inf")
522
523 p = (
524 torch.rand(batch_size, generator=self.generator, dtype=torch.float32) * 0.9
525 + 0.1
526 )
527 result = apply_top_k_top_p_triton(logits.clone(), None, p)
528
529 assert not result.isnan().any(), "NaN found in top-p result with -inf logits"
530 for i in range(batch_size):
531 finite_in = (logits[i] > float("-inf")).sum().item()
532 kept = (result[i] > float("-inf")).sum().item()
533 if finite_in > 0:
534 assert kept > 0, f"Row {i}: no tokens kept despite finite input"
535
536 @pytest.mark.parametrize("inf_fraction", [0.5, 0.9, 0.99])
537 def test_topk_topp_with_neginf_logits(self, inf_fraction: float):
538 """Combined top-k + top-p with many -inf logits."""
539 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
540
541 batch_size, vocab_size = 32, 128256
542 logits = torch.randn(
543 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
544 )
545 mask = (
546 torch.rand(batch_size, vocab_size, generator=self.generator) < inf_fraction
547 )
548 logits[mask] = float("-inf")
549
550 k = torch.randint(
551 1, 50, (batch_size,), generator=self.generator, dtype=torch.int32
552 )
553 p = (
554 torch.rand(batch_size, generator=self.generator, dtype=torch.float32) * 0.9
555 + 0.1
556 )
557 result = apply_top_k_top_p_triton(logits.clone(), k, p)
558
559 assert not result.isnan().any(), (
560 "NaN found in top-k+top-p result with -inf logits"
561 )
562 for i in range(batch_size):
563 kept = (result[i] > float("-inf")).sum().item()
564 assert kept <= k[i].item(), f"Row {i}: kept {kept} > k={k[i].item()}"
565
566 def test_all_neginf_logits(self):
567 """All logits are -inf (fully masked). Kernel should be a no-op."""
568 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
569
570 batch_size, vocab_size = 16, 128256
571 logits = torch.full(
572 (batch_size, vocab_size), float("-inf"), dtype=torch.float32
573 )
574
575 k = torch.randint(
576 1, 50, (batch_size,), generator=self.generator, dtype=torch.int32
577 )
578 p = torch.full((batch_size,), 0.9, dtype=torch.float32)
579
580 # top-k only
581 result = apply_top_k_top_p_triton(logits.clone(), k, None)
582 assert not result.isnan().any(), "NaN from all-inf top-k"
583 assert (result == float("-inf")).all(), "Expected all -inf unchanged"
584
585 # top-p only
586 result = apply_top_k_top_p_triton(logits.clone(), None, p)
587 assert not result.isnan().any(), "NaN from all-inf top-p"
588 assert (result == float("-inf")).all(), "Expected all -inf unchanged"
589
590 # top-k + top-p
591 result = apply_top_k_top_p_triton(logits.clone(), k, p)
592 assert not result.isnan().any(), "NaN from all-inf top-k+top-p"
593 assert (result == float("-inf")).all(), "Expected all -inf unchanged"
594
595 def test_few_valid_tokens_with_neginf(self):
596 """Only a handful of tokens are finite per row (strict grammar)."""
597 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
598
599 batch_size, vocab_size = 32, 128256
600 logits = torch.full(
601 (batch_size, vocab_size), float("-inf"), dtype=torch.float32
602 )
603 # Allow only 5 random tokens per row to be finite.
604 for i in range(batch_size):
605 indices = torch.randperm(vocab_size, generator=self.generator)[:5]
606 logits[i, indices] = torch.randn(
607 5, generator=self.generator, dtype=torch.float32
608 )
609
610 k = torch.full((batch_size,), 50, dtype=torch.int32)
611 p = torch.full((batch_size,), 0.9, dtype=torch.float32)
612
613 # top-k only (k=50 but only 5 finite → keep all 5)
614 result = apply_top_k_top_p_triton(logits.clone(), k, None)
615 assert not result.isnan().any()
616 for i in range(batch_size):
617 kept = (result[i] > float("-inf")).sum().item()
618 assert kept == 5, f"Row {i}: expected 5 kept, got {kept}"
619
620 # top-k with k < num_finite
621 k_small = torch.full((batch_size,), 3, dtype=torch.int32)
622 result = apply_top_k_top_p_triton(logits.clone(), k_small, None)
623 assert not result.isnan().any()
624 for i in range(batch_size):
625 kept = (result[i] > float("-inf")).sum().item()
626 assert kept <= 3, f"Row {i}: expected <=3 kept, got {kept}"
627
628 # top-p only
629 result = apply_top_k_top_p_triton(logits.clone(), None, p)
630 assert not result.isnan().any()
631 for i in range(batch_size):
632 kept = (result[i] > float("-inf")).sum().item()
633 assert kept > 0, f"Row {i}: no tokens kept"
634
635 @pytest.mark.parametrize("num_valid", [1, 2, 5, 10, 50])
636 @pytest.mark.parametrize(
637 "mode",
638 ["topk_only", "topp_only", "topk_and_topp"],
639 )
640 def test_equal_logits_few_valid(self, num_valid: int, mode: str):
641 """Few valid tokens all sharing the same logit value.
642
643 This is the pattern produced by grammar bitmask filtering when
644 the model assigns similar scores to the few allowed tokens.
645 The ternary search can converge to a pivot equal to max_logit,
646 causing the strict `>` keep_mask to exclude everything.
647 Regression test for the `final_pivot >= max_logit` guard.
648 """
649 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
650
651 batch_size, vocab_size = 32, 128256
652 logits = torch.full(
653 (batch_size, vocab_size), float("-inf"), dtype=torch.float32
654 )
655 # Set exactly `num_valid` tokens per row to the SAME finite value.
656 for i in range(batch_size):
657 indices = torch.randperm(vocab_size, generator=self.generator)[:num_valid]
658 logits[i, indices] = 1.0 # all equal
659
660 k: torch.Tensor | None = None
661 p: torch.Tensor | None = None
662 if mode in ("topk_only", "topk_and_topp"):
663 k = torch.full((batch_size,), max(1, num_valid - 1), dtype=torch.int32)
664 if mode in ("topp_only", "topk_and_topp"):
665 p = torch.full((batch_size,), 0.95, dtype=torch.float32)
666
667 result = apply_top_k_top_p_triton(logits.clone(), k, p)
668
669 assert not result.isnan().any(), "NaN in equal-logit result"
670 for i in range(batch_size):
671 kept = (result[i] > float("-inf")).sum().item()
672 # The key invariant: at least one token must survive.
673 # With all-equal logits the pivot search can't differentiate
674 # tokens, so the guard may keep more than k — that is the
675 # intended safe fallback.
676 assert kept > 0, (
677 f"Row {i}: all tokens masked with {num_valid} equal-valued "
678 f"finite logits ({mode})"
679 )
680
681 @pytest.mark.parametrize("num_valid", [2, 5, 10])
682 def test_nearly_equal_logits_topp(self, num_valid: int):
683 """Few valid tokens with very similar (but not identical) logits.
684
685 Ensures the kernel handles near-degenerate probability
686 distributions where the ternary search range collapses.
687 """
688 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
689
690 batch_size, vocab_size = 32, 128256
691 logits = torch.full(
692 (batch_size, vocab_size), float("-inf"), dtype=torch.float32
693 )
694 for i in range(batch_size):
695 indices = torch.randperm(vocab_size, generator=self.generator)[:num_valid]
696 # Tiny spread: values in [1.0, 1.0 + 1e-6]
697 logits[i, indices] = (
698 1.0
699 + torch.rand(num_valid, generator=self.generator, dtype=torch.float32)
700 * 1e-6
701 )
702
703 p = torch.full((batch_size,), 0.95, dtype=torch.float32)
704 result = apply_top_k_top_p_triton(logits.clone(), None, p)
705
706 assert not result.isnan().any(), "NaN in nearly-equal-logit result"
707 for i in range(batch_size):
708 kept = (result[i] > float("-inf")).sum().item()
709 assert kept > 0, (
710 f"Row {i}: all tokens masked with {num_valid} "
711 f"nearly-equal finite logits"
712 )
713
714 def test_mixed_neginf_and_normal_rows(self):
715 """Batch with a mix of normal rows and heavily-masked rows."""
716 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
717
718 batch_size, vocab_size = 32, 32000
719 logits = torch.randn(
720 batch_size, vocab_size, generator=self.generator, dtype=torch.float32
721 )
722 # Mask even rows heavily (99% -inf), leave odd rows normal.
723 for i in range(0, batch_size, 2):
724 mask = torch.rand(vocab_size, generator=self.generator) < 0.99
725 logits[i][mask] = float("-inf")
726
727 k = torch.randint(
728 1, 50, (batch_size,), generator=self.generator, dtype=torch.int32
729 )
730 p = (
731 torch.rand(batch_size, generator=self.generator, dtype=torch.float32) * 0.9
732 + 0.1
733 )
734
735 result = apply_top_k_top_p_triton(logits.clone(), k, p)
736 assert not result.isnan().any(), "NaN in mixed normal/-inf batch"
737 for i in range(batch_size):
738 kept = (result[i] > float("-inf")).sum().item()
739 assert kept <= k[i].item()
740 finite_in = (logits[i] > float("-inf")).sum().item()
741 if finite_in > 0:
742 assert kept > 0, f"Row {i}: no tokens kept"
743
744 def test_mixed_batch_grammar_rows_get_topp(self):
745 """Grammar-masked rows in a mixed top-k batch must still get top-p.
746
747 Regression: with the split top-p pipeline active (batch <= 64), a
748 row with k < vocab but <= k finite logits was covered by neither the
749 monolithic kernel (standalone top-p disabled) nor the split pipeline
750 (skips rows with k < vocab), so its top-p was silently dropped.
751 """
752 batch_size, vocab_size = 8, 32000
753 logits = torch.full((batch_size, vocab_size), float("-inf"))
754 # Rows 0-2: grammar rows (30 finite tokens); k=50 is a no-op for them.
755 for i in range(3):
756 idx = torch.randperm(vocab_size, generator=self.generator)[:30]
757 logits[i, idx] = torch.randn(30, generator=self.generator)
758 # Rows 3-4: p-only rows (k = vocab); rows 5-7: normal top-k+top-p.
759 logits[3:5] = torch.randn(2, vocab_size, generator=self.generator)
760 logits[5:] = torch.randn(3, vocab_size, generator=self.generator)
761 k = torch.tensor(
762 [50, 50, 50, vocab_size, vocab_size, 50, 50, 50], dtype=torch.int32
763 )
764 p = torch.tensor([0.5, 0.5, 0.5, 0.9, 0.9, 0.9, 0.9, 0.9])
765
766 self._compare_results(logits, k, p)
767
768 # Top-p must actually trim the grammar rows beyond the (no-op) top-k.
769 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
770
771 kept_topk_only = (
772 apply_top_k_top_p_triton(logits.clone(), k, None)[:3] != float("-inf")
773 ).sum(-1)
774 kept_topk_topp = (
775 apply_top_k_top_p_triton(logits.clone(), k, p)[:3] != float("-inf")
776 ).sum(-1)
777 assert (kept_topk_topp < kept_topk_only).all(), (
778 f"top-p did not trim grammar rows: {kept_topk_only.tolist()} -> "
779 f"{kept_topk_topp.tolist()}"
780 )
781
782 def test_topp_tie_break_deterministic(self):
783 """Boundary-duplicate trimming must be deterministic across calls.
784
785 Regression: the split top-p mask kernel handed out the tie budget in
786 cross-program atomic order, so which copies of a tied boundary value
787 survived could vary between identical calls.
788 """
789 from vllm.v1.sample.ops.topk_topp_triton import apply_top_k_top_p_triton
790
791 batch_size, vocab_size = 4, 128256
792 # 1000 tied top tokens; the p=0.5 boundary lands inside the tie
793 # group (each tied token has mass e/(1000e+1000) ~ 0.073%).
794 logits = torch.full((batch_size, vocab_size), float("-inf"))
795 logits[:, :1000] = 1.0
796 logits[:, 1000:2000] = 0.0
797 p = torch.full((batch_size,), 0.5, dtype=torch.float32)
798
799 results = [apply_top_k_top_p_triton(logits.clone(), None, p) for _ in range(5)]
800 for r in results[1:]:
801 assert torch.equal(results[0], r), "non-deterministic tie break"
802
803 # The split pipeline now keeps the first `numkeep` copies in index
804 # order, exactly like the monolithic kernel and the PyTorch impl.
805 kept_idx = (results[0][0] != float("-inf")).nonzero().flatten()
806 assert kept_idx.min().item() == 0
807 assert kept_idx.max().item() == len(kept_idx) - 1
808 pytorch_kept = (
809 (apply_top_k_top_p_pytorch(logits.clone(), None, p)[0] != float("-inf"))
810 .sum()
811 .item()
812 )
813 assert len(kept_idx) == pytorch_kept
814
815
816 # =============================================================================
817 # FlashInfer top-k/top-p robustness tests
818 # =============================================================================
819
820
821 @pytest.mark.skipif(
822 not FLASHINFER_TOPK_TOPP_SUPPORTED,
823 reason="FlashInfer top-k/top-p sampler requires CUDA "
824 "and a GPU with FlashInfer support.",
825 )
826 class TestFlashInferTopkToppRobustness:
827 """Robustness of FlashInfer top-k / top-p sampling to NaN / Inf logits.
828
829 The FlashInfer sampler is enabled by default on supported GPUs. A
830 single poisoned request (NaN / +Inf / -Inf in row 0) must not:
831
832 1. crash or hang the process;
833 2. produce out-of-range token ids (anything outside ``[0, vocab)``);
834 3. corrupt other batch rows — neighbours of a poisoned row must
835 still receive valid token ids (regression for cross-row
836 corruption in a DP batch where one bad request would otherwise
837 poison its peers).
838
839 The reference is "no crash + valid token ids", not bit-exact equality
840 against the PyTorch-native path.
841 """
842
843 BATCH = 8
844 VOCAB = 32768
845 TOPK = 50
846 TOPP = 0.9
847
848 @pytest.fixture(autouse=True)
849 def setup(self):
850 torch.set_default_device(DEVICE_TYPE)
851 self.generator = Generator(device=DEVICE_TYPE).manual_seed(1234)
852
853 def _make_logits(self, pattern: str) -> torch.Tensor:
854 """Build (BATCH, VOCAB) logits with `pattern` applied to row 0
855 (rows 1..B-1 stay clean so we can detect cross-row corruption)."""
856 logits = (
857 torch.randn(
858 self.BATCH,
859 self.VOCAB,
860 generator=self.generator,
861 dtype=torch.float32,
862 )
863 * 5.0
864 )
865 if pattern == "clean":
866 return logits
867 if pattern == "nan_one_row":
868 logits[0, :] = float("nan")
869 elif pattern == "nan_few":
870 # Scatter 16 NaNs across row 0, keep the rest finite.
871 idx = torch.randperm(self.VOCAB, generator=self.generator)[:16]
872 logits[0, idx] = float("nan")
873 elif pattern == "nan_at_top":
874 # Poison the top-32 highest-scoring positions of row 0 — worst
875 # case for top-k since these are exactly the tokens that would
876 # otherwise be selected. Use argsort instead of topk to avoid
877 # a known compute-sanitizer false positive in mbtopk.
878 top_idx = logits[0].argsort(descending=True)[:32]
879 logits[0, top_idx] = float("nan")
880 elif pattern == "nan_all_rows":
881 logits[:, :] = float("nan")
882 elif pattern == "pos_inf_one_row":
883 logits[0, :] = float("inf")
884 elif pattern == "neg_inf_one_row":
885 logits[0, :] = float("-inf")
886 elif pattern == "mixed_inf_nan":
887 assert self.BATCH >= 3
888 logits[0, :] = float("nan")
889 logits[1, :] = float("inf")
890 logits[2, :] = float("-inf")
891 elif pattern == "degenerate_flat":
892 logits[:, :] = 1.0
893 else:
894 raise ValueError(f"unknown pattern: {pattern}")
895 return logits
896
897 def _check_tokens(self, tokens: torch.Tensor, ctx: str):
898 assert tokens.dim() == 1, f"{ctx}: expected 1-D output, got {tokens.shape}"
899 assert tokens.shape[0] == self.BATCH, (
900 f"{ctx}: expected batch size {self.BATCH}, got {tokens.shape[0]}"
901 )
902 ids = tokens.tolist()
903 min_id, max_id = min(ids), max(ids)
904 assert 0 <= min_id < self.VOCAB and 0 <= max_id < self.VOCAB, (
905 f"{ctx}: token id(s) outside [0, {self.VOCAB}): min={min_id}, max={max_id}"
906 )
907
908 @pytest.mark.parametrize(
909 "pattern",
910 [
911 "clean",
912 "nan_one_row",
913 "nan_few",
914 "nan_at_top",
915 "nan_all_rows",
916 "pos_inf_one_row",
917 "neg_inf_one_row",
918 "mixed_inf_nan",
919 "degenerate_flat",
920 ],
921 )
922 @pytest.mark.parametrize("path", ["topk_only", "topp_only", "topk_topp"])
923 def test_flashinfer_handles_pathological_logits(self, pattern: str, path: str):
924 """flashinfer_sample must return valid ids even on poisoned logits.
925
926 Direct call into ``flashinfer_sample`` — exactly the code path
927 ``TopKTopPSampler.forward_cuda`` takes when FI is enabled.
928 """
929 from vllm.v1.sample.ops.topk_topp_sampler import flashinfer_sample
930
931 logits = self._make_logits(pattern)
932 k = (
933 torch.full(
934 (self.BATCH,),
935 self.TOPK,
936 device=DEVICE_TYPE,
937 dtype=torch.int32,
938 )
939 if path in ("topk_only", "topk_topp")
940 else None
941 )
942 p = (
943 torch.full(
944 (self.BATCH,),
945 self.TOPP,
946 device=DEVICE_TYPE,
947 dtype=torch.float32,
948 )
949 if path in ("topp_only", "topk_topp")
950 else None
951 )
952
953 # flashinfer_sample may mutate its input in-place; pass a clone so
954 # the parametrize iterations stay independent.
955 tokens = flashinfer_sample(logits.clone().contiguous(), k, p, {})
956 # Surface any async CUDA error synchronously (e.g. illegal memory
957 # access from a malformed FlashInfer call) so it's attributed to
958 # this test rather than a later, unrelated GPU op.
959 torch.accelerator.synchronize()
960 self._check_tokens(tokens, ctx=f"pattern={pattern}, path={path}")
961
962
963 # =============================================================================
964 # FlashInfer top-k/top-p distribution-match tests
965 # =============================================================================
966
967
968 @pytest.mark.skipif(
969 not FLASHINFER_TOPK_TOPP_SUPPORTED,
970 reason="FlashInfer top-k/top-p sampler requires CUDA "
971 "and a GPU with FlashInfer support.",
972 )
973 class TestFlashInferDistributionMatch:
974 """Chi-square goodness-of-fit: FlashInfer and PyTorch-native samplers
975 both reproduce the expected token distribution after top-k / top-p.
976
977 Regression guard against historical FlashInfer distribution-shift.
978 Each impl is compared to the theoretical distribution (softmax of
979 filtered logits); if both pass they are statistically equivalent
980 to each other by transitivity.
981 """
982
983 VOCAB = 32
984 N_SAMPLES = 50_000
985 ALPHA = 1e-6
986 SEED = 0
987
988 @pytest.mark.parametrize(
989 "topk,topp",
990 [
991 (8, None),
992 (16, None),
993 (None, 0.5),
994 (None, 0.7),
995 (None, 0.99),
996 (8, 0.9),
997 (4, 0.5),
998 ],
999 )
1000 def test_distribution_matches_theoretical(self, topk, topp):
1001 from scipy.stats import chisquare
1002
1003 from vllm.v1.sample.ops.topk_topp_sampler import (
1004 apply_top_k_top_p,
1005 flashinfer_sample,
1006 random_sample,
1007 )
1008
1009 torch.set_default_device(DEVICE_TYPE)
1010 torch.manual_seed(self.SEED)
1011
1012 # Same logits row used for both impls so the comparison is fair.
1013 logits_one = (
1014 torch.randn(
1015 (1, self.VOCAB),
1016 dtype=torch.float32,
1017 )
1018 * 2.0
1019 )
1020
1021 # Theoretical expected distribution from PyTorch-native filter.
1022 k_one = torch.tensor([topk], dtype=torch.int32) if topk is not None else None
1023 p_one = torch.tensor([topp], dtype=torch.float32) if topp is not None else None
1024 masked = apply_top_k_top_p_pytorch(logits_one.clone(), k_one, p_one)
1025 expected_probs = masked.softmax(dim=-1).flatten().cpu().numpy()
1026 expected_counts = expected_probs * self.N_SAMPLES
1027
1028 # Build a batch of N identical rows for both impls.
1029 batch = logits_one.expand(self.N_SAMPLES, self.VOCAB).contiguous()
1030 k_batch = (
1031 torch.full((self.N_SAMPLES,), topk, dtype=torch.int32)
1032 if topk is not None
1033 else None
1034 )
1035 p_batch = (
1036 torch.full((self.N_SAMPLES,), topp, dtype=torch.float32)
1037 if topp is not None
1038 else None
1039 )
1040
1041 # FlashInfer dispatch path.
1042 fi_tokens = flashinfer_sample(batch.contiguous(), k_batch, p_batch, {})
1043 fi_counts = torch.bincount(fi_tokens, minlength=self.VOCAB).cpu().numpy()
1044 self._chi2_check(
1045 fi_counts,
1046 expected_counts,
1047 chisquare,
1048 label=f"flashinfer top-k={topk} top-p={topp}",
1049 )
1050
1051 # PyTorch-native dispatch path (Triton-routed filter + Gumbel sample).
1052 processed = apply_top_k_top_p(batch.clone(), k_batch, p_batch)
1053 probs = processed.softmax(dim=-1, dtype=torch.float32)
1054 pt_tokens = random_sample(probs, {})
1055 pt_counts = torch.bincount(pt_tokens, minlength=self.VOCAB).cpu().numpy()
1056 self._chi2_check(
1057 pt_counts,
1058 expected_counts,
1059 chisquare,
1060 label=f"native top-k={topk} top-p={topp}",
1061 )
1062
1063 def _chi2_check(self, empirical, expected, chisquare_fn, *, label):
1064 import numpy as np
1065
1066 # Hard check: the sampler must never produce a token outside the
1067 # expected support (zero theoretical probability).
1068 outside = (expected == 0) & (empirical > 0)
1069 assert not outside.any(), (
1070 f"{label}: sampled out-of-support tokens "
1071 f"(zero expected prob): indices={outside.nonzero()[0].tolist()}"
1072 )
1073 # Skip chi-square in the degenerate case where the support
1074 # collapses to a single token (e.g. very restrictive joint
1075 # top-k + top-p): all samples must land there and the hard
1076 # check above already verified they do.
1077 in_support = expected > 0
1078 if int(in_support.sum()) <= 1:
1079 return
1080 # Soft check: chi-square goodness-of-fit on in-support tokens.
1081 # Cast to float64 so the rescaling step below stays within
1082 # scipy.chisquare's strict 1.5e-8 sum-equality tolerance.
1083 emp = empirical[in_support].astype(np.float64)
1084 exp = expected[in_support].astype(np.float64)
1085 exp = exp * (emp.sum() / exp.sum())
1086 chi2, p_value = chisquare_fn(emp, exp)
1087 assert p_value > self.ALPHA, (
1088 f"{label}: distribution differs from theoretical: "
1089 f"chi2={chi2:.2f} p_value={p_value:.2e} alpha={self.ALPHA}"
1090 )