main
py 1,186 lines 41.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from typing import Any
4 from unittest.mock import Mock
5
6 import pytest
7 import torch
8 import torch.nn.functional as F
9
10 from tests.v1.sample.utils import create_allowed_token_ids
11 from vllm.platforms import current_platform
12 from vllm.v1.sample.logits_processor import LogitsProcessors
13 from vllm.v1.sample.metadata import SamplingMetadata
14 from vllm.v1.sample.rejection_sampler import (
15 PLACEHOLDER_TOKEN_ID,
16 RejectionSampler,
17 sample_recovered_tokens,
18 )
19 from vllm.v1.sample.sampler import Sampler, SamplerOutput
20 from vllm.v1.spec_decode.metadata import SpecDecodeMetadata
21
22 DEVICE_TYPE = current_platform.device_type
23
24
25 @pytest.fixture
26 def rejection_sampler():
27 mock_sampler = Mock(spec=Sampler)
28 mock_sampler.logprobs_mode = "raw_logprobs"
29 return RejectionSampler(mock_sampler)
30
31
32 def mock_sampler_output(
33 rejection_sampler: RejectionSampler, bonus_token_ids: torch.Tensor
34 ):
35 rejection_sampler.sampler.return_value = SamplerOutput(
36 sampled_token_ids=bonus_token_ids, logprobs_tensors=None
37 )
38
39
40 def create_spec_decode_metadata(
41 spec_tokens: list[list[int]], logits: torch.Tensor
42 ) -> SpecDecodeMetadata:
43 metadata = SpecDecodeMetadata.make_dummy(spec_tokens, device=logits.device)
44 metadata.target_logits_indices = torch.arange(logits.shape[0])
45 # Output bonus token ids are mocked, so the bonus logit indices should
46 # be empty.
47 metadata.bonus_logits_indices = torch.empty(0, dtype=torch.int32)
48 return metadata
49
50
51 def create_logits_tensor(
52 output_token_ids: list[list[int]],
53 vocab_size: int = 100,
54 token_idx_to_override: int | None = None,
55 ) -> torch.Tensor:
56 """Helper function to create logits tensor that
57 will produce desired token ids on argmax"""
58 token_ids = [tokens[:-1] for tokens in output_token_ids]
59 num_total_tokens = sum(len(tokens) for tokens in token_ids)
60 logits = torch.full((num_total_tokens, vocab_size), -100.0, device=DEVICE_TYPE)
61 start_loc = 0
62 for tokens in token_ids:
63 for j, token_id in enumerate(tokens):
64 logits[start_loc + j, token_id] = 100.0
65 start_loc += len(tokens)
66 if token_idx_to_override:
67 logits[:, token_idx_to_override] = 99.0
68 return logits
69
70
71 def create_sampling_metadata(
72 all_greedy: bool,
73 output_token_ids: list[list[int]] | None = None,
74 prompt_token_ids: torch.Tensor | None = None,
75 spec_token_ids: torch.Tensor | None = None,
76 temperature: torch.Tensor | None = None,
77 top_k: torch.Tensor | None = None,
78 top_p: torch.Tensor | None = None,
79 generators: dict[int, Any] | None = None,
80 frequency_penalties: list[float] | None = None,
81 presence_penalties: list[float] | None = None,
82 repetition_penalties: list[float] | None = None,
83 bad_words_token_ids: dict[int, list[list[int]]] | None = None,
84 allowed_token_ids_mask: torch.Tensor | None = None,
85 ) -> SamplingMetadata:
86 """Create a v1 sampling metadata object with all_greedy set
87 to the given value. Either all greedy or all random sampling
88 is used.
89 """
90 generators = generators or {}
91 if all_greedy:
92 temperature = None
93 else:
94 assert temperature is not None
95
96 if any([frequency_penalties, presence_penalties, repetition_penalties]):
97 no_penalties = False
98
99 assert output_token_ids
100 assert len(output_token_ids) > 0
101
102 frequency_penalties = torch.tensor(frequency_penalties, device=DEVICE_TYPE)
103 presence_penalties = torch.tensor(presence_penalties, device=DEVICE_TYPE)
104 repetition_penalties = torch.tensor(repetition_penalties, device=DEVICE_TYPE)
105 else:
106 no_penalties = True
107 frequency_penalties = torch.tensor([])
108 presence_penalties = torch.tensor([])
109 repetition_penalties = torch.tensor([])
110
111 return SamplingMetadata(
112 temperature=temperature,
113 all_greedy=all_greedy,
114 all_random=not all_greedy,
115 top_p=top_p,
116 top_k=top_k,
117 generators=generators,
118 max_num_logprobs=None,
119 no_penalties=no_penalties,
120 prompt_token_ids=prompt_token_ids,
121 frequency_penalties=frequency_penalties,
122 presence_penalties=presence_penalties,
123 repetition_penalties=repetition_penalties,
124 output_token_ids=[] if output_token_ids is None else output_token_ids,
125 spec_token_ids=[] if spec_token_ids is None else spec_token_ids,
126 allowed_token_ids_mask=allowed_token_ids_mask,
127 bad_words_token_ids={} if bad_words_token_ids is None else bad_words_token_ids,
128 logitsprocs=LogitsProcessors(),
129 )
130
131
132 ########################### Tests for Greedy Sampling ###################
133 def test_perfect_match(rejection_sampler):
134 """Test when output tokens perfectly match speculated tokens."""
135 spec_tokens = [[1, 2, 3]]
136 output_tokens = [[1, 2, 3, 4]] # 4 is the bonus token
137
138 metadata = create_sampling_metadata(all_greedy=True)
139 logits = create_logits_tensor(output_tokens)
140 bonus_token_tensor = torch.tensor([output_tokens[0][-1]], device=logits.device)
141 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
142
143 mock_sampler_output(rejection_sampler, bonus_token_tensor)
144 output = rejection_sampler(
145 spec_decode_metadata,
146 draft_probs=None,
147 logits=logits,
148 sampling_metadata=metadata,
149 )
150 expected = torch.tensor([[1, 2, 3, 4]], dtype=torch.int, device=logits.device)
151 assert torch.equal(output.sampled_token_ids, expected)
152
153
154 def test_early_mismatch(rejection_sampler):
155 """Test when there's an early mismatch in tokens."""
156 spec_tokens = [[1, 2, 3]]
157 output_tokens = [[1, 5, 3, 4]] # Mismatch at position 1
158
159 metadata = create_sampling_metadata(all_greedy=True)
160 logits = create_logits_tensor(output_tokens)
161 bonus_token_tensor = torch.tensor([output_tokens[0][-1]], device=logits.device)
162 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
163
164 mock_sampler_output(rejection_sampler, bonus_token_tensor)
165 output = rejection_sampler(
166 spec_decode_metadata,
167 draft_probs=None,
168 logits=logits,
169 sampling_metadata=metadata,
170 )
171 expected = torch.tensor(
172 [[1, 5, PLACEHOLDER_TOKEN_ID, PLACEHOLDER_TOKEN_ID]],
173 dtype=torch.int,
174 device=logits.device,
175 )
176 assert torch.equal(output.sampled_token_ids, expected)
177
178
179 def test_multiple_sequences(rejection_sampler):
180 """Test handling multiple sequences of speculated tokens."""
181 spec_tokens = [[1, 2], [3]]
182 output_tokens = [[1, 2, 5], [3, 4]] # Two sequences with bonus tokens 5 and 4
183
184 metadata = create_sampling_metadata(all_greedy=True)
185 logits = create_logits_tensor(output_tokens)
186 bonus_token_tensor = torch.tensor(
187 [output_tokens[0][-1], output_tokens[1][-1]], device=logits.device
188 )
189 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
190
191 mock_sampler_output(rejection_sampler, bonus_token_tensor)
192 output = rejection_sampler(
193 spec_decode_metadata,
194 draft_probs=None,
195 logits=logits,
196 sampling_metadata=metadata,
197 )
198 expected = torch.tensor(
199 [[1, 2, 5], [3, 4, PLACEHOLDER_TOKEN_ID]], dtype=torch.int, device=logits.device
200 )
201 assert torch.equal(output.sampled_token_ids, expected)
202
203
204 def test_single_token_sequence(rejection_sampler):
205 """Test handling sequences with single token."""
206 spec_tokens = [[1]]
207 output_tokens = [[1, 2]] # Single token with bonus token 2
208
209 metadata = create_sampling_metadata(all_greedy=True)
210 logits = create_logits_tensor(output_tokens)
211 bonus_token_tensor = torch.tensor([output_tokens[0][-1]], device=logits.device)
212 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
213
214 mock_sampler_output(rejection_sampler, bonus_token_tensor)
215 output = rejection_sampler(
216 spec_decode_metadata,
217 draft_probs=None,
218 logits=logits,
219 sampling_metadata=metadata,
220 )
221 expected = torch.tensor([[1, 2]], dtype=torch.int, device=logits.device)
222 assert torch.equal(output.sampled_token_ids, expected)
223
224
225 def test_empty_sequence(rejection_sampler):
226 """Test handling empty sequence of speculated tokens."""
227 spec_tokens: list[list[int]] = [[]]
228 output_tokens = [[5]] # Just the bonus token
229
230 metadata = create_sampling_metadata(all_greedy=True)
231 logits = create_logits_tensor(output_tokens)
232 bonus_token_tensor = torch.tensor([output_tokens[0][-1]], device=logits.device)
233 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
234
235 mock_sampler_output(rejection_sampler, bonus_token_tensor)
236 output = rejection_sampler(
237 spec_decode_metadata,
238 draft_probs=None,
239 logits=logits,
240 sampling_metadata=metadata,
241 )
242 expected = torch.tensor([[5]], dtype=torch.int, device=logits.device)
243 assert torch.equal(output.sampled_token_ids, expected)
244
245
246 def test_multiple_mismatches(rejection_sampler):
247 """Test handling multiple sequences with mismatches."""
248 spec_tokens = [[1, 2, 3], [4, 5, 6]]
249 output_tokens = [[1, 2, 7, 6], [4, 8, 6, 9]] # Mismatches in both sequences
250
251 metadata = create_sampling_metadata(all_greedy=True)
252 logits = create_logits_tensor(output_tokens)
253 bonus_token_tensor = torch.tensor(
254 [output_tokens[0][-1], output_tokens[1][-1]], device=logits.device
255 )
256 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
257
258 mock_sampler_output(rejection_sampler, bonus_token_tensor)
259 output = rejection_sampler(
260 spec_decode_metadata,
261 draft_probs=None,
262 logits=logits,
263 sampling_metadata=metadata,
264 )
265 expected = torch.tensor(
266 [
267 [1, 2, 7, PLACEHOLDER_TOKEN_ID],
268 [4, 8, PLACEHOLDER_TOKEN_ID, PLACEHOLDER_TOKEN_ID],
269 ],
270 dtype=torch.int,
271 device=logits.device,
272 )
273 assert torch.equal(output.sampled_token_ids, expected)
274
275
276 @pytest.mark.parametrize(
277 "spec_tokens,output_tokens,expected",
278 [
279 ([[1, 2]], [[1, 2, 3]], [[1, 2, 3]]), # Perfect match with bonus
280 ([[1]], [[2, 3]], [[2, PLACEHOLDER_TOKEN_ID]]), # First mismatch
281 (
282 [[1, 2], [3, 4]],
283 [[1, 5, 6], [3, 4, 7]],
284 [[1, 5, PLACEHOLDER_TOKEN_ID], [3, 4, 7]],
285 ), # Mixed matches
286 ],
287 )
288 def test_parametrized_cases(rejection_sampler, spec_tokens, output_tokens, expected):
289 """Parametrized test for various matching scenarios."""
290 metadata = create_sampling_metadata(all_greedy=True)
291 logits = create_logits_tensor(output_tokens)
292 bonus_token_tensor = torch.tensor(
293 [tokens[-1] for tokens in output_tokens], device=logits.device
294 )
295 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
296
297 mock_sampler_output(rejection_sampler, bonus_token_tensor)
298 output = rejection_sampler(
299 spec_decode_metadata,
300 draft_probs=None,
301 logits=logits,
302 sampling_metadata=metadata,
303 )
304 expected_tensor = torch.tensor(expected, dtype=torch.int, device=logits.device)
305 assert torch.equal(output.sampled_token_ids, expected_tensor)
306
307
308 ########################### Tests for Random Sampling ###################
309 @pytest.mark.parametrize("k", [1, 3, 5])
310 @pytest.mark.parametrize("vocab_size", [1000])
311 @pytest.mark.parametrize("batch_size", [1, 4, 8])
312 @pytest.mark.parametrize("frac_seeded", [0.0, 0.5])
313 @pytest.mark.parametrize("n_rep", [20])
314 def test_deterministic_when_seeded(
315 rejection_sampler,
316 k: int,
317 vocab_size: int,
318 batch_size: int,
319 frac_seeded: float,
320 n_rep: int,
321 ):
322 num_tokens = batch_size * k
323 draft_probs = torch.rand(
324 num_tokens,
325 vocab_size,
326 dtype=torch.float32,
327 device=DEVICE_TYPE,
328 )
329 draft_probs = F.softmax(draft_probs, dim=-1)
330 target_logits = torch.rand_like(draft_probs)
331 bonus_token_ids = torch.randint(
332 low=0,
333 high=vocab_size,
334 size=(batch_size, 1),
335 dtype=torch.int64,
336 device=DEVICE_TYPE,
337 )
338 draft_token_ids = torch.randint(
339 low=0,
340 high=vocab_size,
341 size=(batch_size, k),
342 dtype=torch.int64,
343 device=DEVICE_TYPE,
344 )
345
346 seeded_mask = torch.rand(batch_size, dtype=torch.float32) <= frac_seeded
347
348 results = []
349 for _ in range(n_rep):
350 seeded_seqs = {
351 i: torch.Generator(device=DEVICE_TYPE).manual_seed(i)
352 for i in range(batch_size)
353 if seeded_mask[i]
354 }
355
356 temperature = torch.ones(batch_size, dtype=torch.float32, device=DEVICE_TYPE)
357 sampling_metadata = create_sampling_metadata(
358 all_greedy=False, temperature=temperature, generators=seeded_seqs
359 )
360 spec_decode_metadata = create_spec_decode_metadata(
361 draft_token_ids.tolist(), target_logits
362 )
363
364 mock_sampler_output(rejection_sampler, bonus_token_ids)
365 rep_result = rejection_sampler(
366 spec_decode_metadata,
367 draft_probs=None,
368 logits=target_logits,
369 sampling_metadata=sampling_metadata,
370 )
371
372 results.append(rep_result.sampled_token_ids)
373
374 for i in range(batch_size):
375 if seeded_mask[i]:
376 for j in range(1, n_rep):
377 assert torch.equal(results[j][i], results[0][i])
378
379
380 def test_rejection_sampling_approximates_target_distribution():
381 """Verify rejection sampling approximates target distribution,
382 despite sampling from a potentially distinct draft distribution.
383
384 This is done by first creating a random target probability
385 distribution and a random draft probability distribution. We then
386 sample token ids from the rejection sampler using these draft
387 and target distributions. The samples are used to estimate
388 the output probability distribution, which we expect to approximate
389 the target distribution.
390
391 A basic distance metric is used to determine similarity between
392 distributions.
393
394 We expect that as we increase the number of samples,
395 the distance between the observed distribution and the target
396 distribution decreases. To measure this, we compare the distance
397 of the observed distribution against both the target distribution
398 and a uniform random distribution. We expect the distance between
399 the observed distribution and the target distribution to improve
400 much more than the distance improvement between the observed
401 distribution and the random distribution.
402 """
403 torch.set_default_device(DEVICE_TYPE)
404 vocab_size = 10
405 k = 2
406 num_reference_probs = 100
407
408 # Prepare draft, target, and reference probability distributions
409 draft_probs = F.softmax(torch.rand(vocab_size, dtype=torch.float32), dim=-1)
410 target_logits = torch.rand(vocab_size, dtype=torch.float32)
411 target_probs = F.softmax(target_logits, dim=-1)
412 reference_probs = F.softmax(
413 torch.rand(num_reference_probs, vocab_size, dtype=torch.float32),
414 dim=-1,
415 )
416
417 sample_sizes = [10, 100, 1_000, 10_000, 100_000]
418 distance_wrt_reference: list[float] = []
419 distance_wrt_target: list[float] = []
420
421 for num_samples in sample_sizes:
422 # Sample using rejection sampling.
423 rej_sample_probs = estimate_rejection_sampling_pdf(
424 draft_probs, target_logits, k, vocab_size, num_samples
425 )
426 rej_sample_probs = rej_sample_probs.to(DEVICE_TYPE)
427
428 # Average distance from reference probs.
429 reference_vs_rejsample_dist = (
430 torch.dist(reference_probs, rej_sample_probs).item()
431 / reference_probs.shape[0]
432 )
433 target_vs_rejsample_dist = torch.dist(target_probs, rej_sample_probs).item()
434
435 distance_wrt_reference.append(reference_vs_rejsample_dist)
436 distance_wrt_target.append(target_vs_rejsample_dist)
437
438 relative_change_in_distance_wrt_target = get_ratio_first_to_last(
439 distance_wrt_target
440 )
441 relative_change_in_distance_wrt_reference = get_ratio_first_to_last(
442 distance_wrt_reference
443 )
444
445 print(
446 f"{num_samples=} {target_vs_rejsample_dist=:.05f} "
447 f"{reference_vs_rejsample_dist=:.05f}"
448 )
449 print(
450 f"{num_samples=} {relative_change_in_distance_wrt_target=:.02f} "
451 f"{relative_change_in_distance_wrt_reference=:.02f}"
452 )
453
454 relative_change_in_distance_wrt_target = get_ratio_first_to_last(
455 distance_wrt_target
456 )
457 relative_change_in_distance_wrt_reference = get_ratio_first_to_last(
458 distance_wrt_reference
459 )
460
461 expected_improvement_multiplier = 20
462 assert (
463 relative_change_in_distance_wrt_target
464 > relative_change_in_distance_wrt_reference * expected_improvement_multiplier
465 )
466
467
468 def get_ratio_first_to_last(elements: list[float]) -> float:
469 return elements[0] / elements[-1]
470
471
472 def estimate_rejection_sampling_pdf(
473 draft_probs: torch.Tensor,
474 target_logits: torch.Tensor,
475 k: int,
476 vocab_size: int,
477 num_samples: int,
478 ) -> torch.Tensor:
479 """Estimate the probability distribution of the output tokens
480 using rejection sampling.
481
482 Args:
483 draft_probs: Draft probability distribution.
484 target_logits: Target logits.
485 num_samples: Number of samples to draw.
486
487 Returns:
488 Estimated probability distribution of the output tokens.
489
490 """
491 mock_sampler = Mock(spec=Sampler)
492 mock_sampler.logprobs_mode = "raw_logprobs"
493 rejection_sampler = RejectionSampler(mock_sampler)
494 num_tokens = num_samples * k
495 # Repeat draft probs num_samples * k times.
496 draft_probs = draft_probs.reshape(1, 1, vocab_size).repeat(num_samples, k, 1)
497
498 # Repeat target probs num_tokens times.
499 target_logits = target_logits.reshape(1, vocab_size).repeat(num_tokens, 1)
500
501 # Randomly sample draft token ids from draft probs.
502 draft_token_ids = torch.multinomial(
503 draft_probs[:, 0, :], num_samples=k, replacement=True
504 ).reshape(num_samples, k)
505 draft_probs = draft_probs.view(num_tokens, vocab_size)
506
507 # Bonus tokens not used but required.
508 bonus_token_ids = torch.zeros((1, 1), dtype=torch.int64, device=DEVICE_TYPE).repeat(
509 num_samples, 1
510 )
511
512 temperature = torch.ones(num_samples, dtype=torch.float32, device=DEVICE_TYPE)
513 sampling_metadata = create_sampling_metadata(
514 all_greedy=False, temperature=temperature
515 )
516 spec_decode_metadata = create_spec_decode_metadata(
517 draft_token_ids.tolist(), target_logits
518 )
519
520 mock_sampler_output(rejection_sampler, bonus_token_ids)
521 sampler_output = rejection_sampler(
522 spec_decode_metadata,
523 draft_probs=draft_probs,
524 logits=target_logits,
525 sampling_metadata=sampling_metadata,
526 )
527 output_token_ids = sampler_output.sampled_token_ids[:, :-1].flatten()
528
529 hist = torch.histogram(
530 output_token_ids.to(dtype=torch.float, device="cpu"),
531 bins=vocab_size,
532 range=(0, vocab_size),
533 density=True,
534 )
535
536 return hist.hist
537
538
539 def native_sample_recovered_tokens(
540 max_spec_len: int,
541 num_draft_tokens: list[int],
542 cu_num_draft_tokens: torch.Tensor, # [batch_size]
543 draft_token_ids: torch.Tensor, # [num_tokens]
544 draft_probs: torch.Tensor | None, # [num_tokens, vocab_size]
545 target_probs: torch.Tensor, # [num_tokens, vocab_size]
546 sampling_metadata: SamplingMetadata,
547 device: torch.device,
548 use_fp64_gumbel: bool = False,
549 ) -> torch.Tensor:
550 batch_size = len(num_draft_tokens)
551 vocab_size = target_probs.shape[-1]
552 q_dtype = torch.float64 if use_fp64_gumbel else torch.float32
553
554 q = torch.empty(
555 (batch_size, vocab_size),
556 dtype=q_dtype,
557 device=device,
558 )
559 q.exponential_()
560
561 states = {
562 i: generator.get_state()
563 for i, generator in sampling_metadata.generators.items()
564 }
565 for i, generator in sampling_metadata.generators.items():
566 # Do not generate random numbers for requests with no draft tokens.
567 # This can be important for reproducibility.
568 if num_draft_tokens[i] > 0:
569 q[i].exponential_(generator=generator)
570
571 # In order to generate the same exponential later, reset the CUDA RNG
572 # state because RNG state advances after each call.
573 generator.set_state(states[i])
574
575 inv_q = q.reciprocal()
576
577 out = torch.empty_like(draft_token_ids)
578
579 for req_idx in range(batch_size):
580 start_idx = 0 if req_idx == 0 else int(cu_num_draft_tokens[req_idx - 1].item())
581 end_idx = int(cu_num_draft_tokens[req_idx].item())
582 num_tokens = end_idx - start_idx
583
584 for pos in range(max_spec_len):
585 if pos >= num_tokens:
586 continue
587 token_idx = start_idx + pos
588
589 if draft_probs is None:
590 # prob is target_probs[token_idx] except draft_token_id is zeroed
591 prob = target_probs[token_idx].clone()
592 draft_token_id = draft_token_ids[token_idx]
593 prob[draft_token_id] = 0.0
594 else:
595 prob = (target_probs[token_idx] - draft_probs[token_idx]).clamp_min_(
596 0.0
597 )
598
599 score = prob * inv_q[req_idx]
600 recovered_id = torch.argmax(score, dim=-1)
601 out[token_idx] = recovered_id
602 return out
603
604
605 def _test_masked_logits(
606 rejection_sampler,
607 batch_size: int,
608 num_draft_tokens: int,
609 vocab_size: int,
610 target_logits: torch.Tensor,
611 unmasked_indices: torch.Tensor,
612 sampling_metadata: SamplingMetadata,
613 ):
614 # Set up test parameters
615 num_tokens = batch_size * num_draft_tokens
616
617 # Create random draft probabilities.
618 draft_probs = torch.rand(
619 (num_tokens, vocab_size), dtype=torch.float32, device=DEVICE_TYPE
620 )
621 draft_probs = F.softmax(draft_probs, dim=-1)
622
623 # Randomly sample draft token ids from draft probs
624 draft_token_ids = torch.multinomial(draft_probs, num_samples=1)
625 draft_token_ids = draft_token_ids.reshape(batch_size, num_draft_tokens)
626 draft_token_ids = draft_token_ids.tolist()
627
628 # Bonus tokens not used but required
629 bonus_token_ids = torch.zeros(
630 (batch_size, 1),
631 dtype=torch.int64,
632 device=DEVICE_TYPE,
633 )
634
635 # Create spec decode metadata
636 spec_decode_metadata = create_spec_decode_metadata(draft_token_ids, target_logits)
637
638 # Run rejection sampling
639 mock_sampler_output(rejection_sampler, bonus_token_ids)
640 output = rejection_sampler(
641 spec_decode_metadata,
642 draft_probs=draft_probs,
643 logits=target_logits,
644 sampling_metadata=sampling_metadata,
645 )
646
647 # Remove bonus tokens and reshape
648 output_token_ids = output.sampled_token_ids[:, :-1].flatten().tolist()
649
650 # Check that all sampled tokens are within the unmasked indices.
651 for i in range(num_tokens):
652 token_id = output_token_ids[i]
653 if token_id == PLACEHOLDER_TOKEN_ID:
654 continue
655 assert token_id in unmasked_indices[i]
656
657
658 @pytest.mark.parametrize("top_k", [1, 5, 99])
659 def test_top_k(rejection_sampler, top_k):
660 """Test rejection sampling with top-k sampling."""
661 vocab_size = 100
662 batch_size = 100
663 num_draft_tokens = 3
664 num_tokens = batch_size * num_draft_tokens
665
666 # Randomly create top-k indices.
667 top_k_indices = [
668 torch.randperm(vocab_size, device=DEVICE_TYPE)[:top_k]
669 for _ in range(num_tokens)
670 ]
671 top_k_indices = torch.stack(top_k_indices)
672
673 # Create logits with the uniform distribution.
674 target_logits = torch.zeros((num_tokens, vocab_size), device=DEVICE_TYPE)
675
676 # Increment the logits for top-k indices, a little bit more than the other
677 # ones. If the masking is effective, the non-topk indices will never be
678 # sampled despite the small difference in logits.
679 for i in range(num_tokens):
680 target_logits[i, top_k_indices[i]] += 0.1
681
682 # Create sampling metadata
683 temperature = torch.ones(batch_size, dtype=torch.float32, device=DEVICE_TYPE)
684 sampling_metadata = create_sampling_metadata(
685 all_greedy=False,
686 temperature=temperature,
687 top_k=torch.tensor([top_k] * batch_size, device=DEVICE_TYPE, dtype=torch.int64),
688 )
689
690 _test_masked_logits(
691 rejection_sampler,
692 batch_size=batch_size,
693 num_draft_tokens=num_draft_tokens,
694 vocab_size=vocab_size,
695 target_logits=target_logits,
696 unmasked_indices=top_k_indices,
697 sampling_metadata=sampling_metadata,
698 )
699
700
701 @pytest.mark.parametrize("top_p", [0.5, 0.9, 0.99])
702 def test_top_p(rejection_sampler, top_p):
703 """Test rejection sampling with top-p sampling."""
704 vocab_size = 100
705 batch_size = 100
706 num_draft_tokens = 3
707 num_tokens = batch_size * num_draft_tokens
708
709 # Create logits with the uniform distribution.
710 target_logits = torch.randn((num_tokens, vocab_size), device=DEVICE_TYPE)
711 temperature = torch.ones(batch_size, dtype=torch.float32, device=DEVICE_TYPE)
712 rescaled_logits = target_logits / temperature
713
714 logits_sort, logits_idx = rescaled_logits.sort(dim=-1, descending=False)
715 probs_sort = logits_sort.softmax(dim=-1)
716 probs_sum = probs_sort.cumsum(dim=-1)
717 top_p_mask = probs_sum <= 1 - top_p
718 # at least one
719 top_p_mask[:, -1] = False
720
721 # Get the top-p indices.
722 top_p_indices = []
723 for i in range(num_tokens):
724 top_p_indices.append(logits_idx[i][~top_p_mask[i]].tolist())
725
726 # Create sampling metadata
727 sampling_metadata = create_sampling_metadata(
728 all_greedy=False,
729 temperature=temperature,
730 top_p=torch.tensor(
731 [top_p] * batch_size,
732 device=DEVICE_TYPE,
733 dtype=torch.float32,
734 ),
735 )
736
737 _test_masked_logits(
738 rejection_sampler,
739 batch_size=batch_size,
740 num_draft_tokens=num_draft_tokens,
741 vocab_size=vocab_size,
742 target_logits=target_logits,
743 unmasked_indices=top_p_indices,
744 sampling_metadata=sampling_metadata,
745 )
746
747
748 ########################### Tests for Logit Processors ###################
749 def test_frequency_penalties(rejection_sampler):
750 """Test rejection sampling with frequency penalties."""
751 spec_tokens = [[1, 1, 1], [], [1, 1, 1]]
752 output_tokens = [[1, 1, 1, 1], [7], [1, 1, 1, 1]] # 1, 7 and 1 are the bonus tokens
753
754 num_requests = len(spec_tokens)
755 logits = create_logits_tensor(output_tokens, token_idx_to_override=15)
756 metadata = create_sampling_metadata(
757 all_greedy=True,
758 output_token_ids=[[2], [3], [4]],
759 spec_token_ids=spec_tokens,
760 prompt_token_ids=torch.tensor(
761 [[5, 6, 7], [6, 7, 8], [7, 8, 9]],
762 device=DEVICE_TYPE,
763 ),
764 frequency_penalties=[1.5, 1.5, 0.7],
765 presence_penalties=[0.0] * num_requests,
766 repetition_penalties=[1.0] * num_requests,
767 )
768 bonus_token_tensor = torch.tensor(
769 [output_tokens[i][-1] for i in range(len(output_tokens))], device=logits.device
770 )
771 spec_decode_metadata = SpecDecodeMetadata.make_dummy(
772 spec_tokens, device=logits.device
773 )
774 mock_sampler_output(rejection_sampler, bonus_token_tensor)
775 output = rejection_sampler(
776 spec_decode_metadata,
777 draft_probs=None,
778 logits=logits,
779 sampling_metadata=metadata,
780 )
781 expected = torch.tensor(
782 [[1, 15, -1, -1], [7, -1, -1, -1], [1, 1, 15, -1]],
783 dtype=torch.int,
784 device=logits.device,
785 )
786 assert torch.equal(output.sampled_token_ids, expected)
787
788
789 def test_bad_words(rejection_sampler):
790 """Test rejection sampling with bad words constraints.
791
792 This test applies bad words to non-consecutive requests (0 and 2, but not 1)
793 to verify correct logit indexing when iterating over requests with bad words.
794 """
795 spec_tokens = [[1, 2, 3], [1, 15, 3], [1, 2, 3]]
796 output_tokens = [[1, 2, 3, 4], [1, 15, 3, 4], [1, 2, 3, 4]]
797
798 logits = create_logits_tensor(output_tokens, token_idx_to_override=15)
799 metadata = create_sampling_metadata(
800 all_greedy=True,
801 output_token_ids=[[2], [3], [4]],
802 spec_token_ids=spec_tokens,
803 bad_words_token_ids={
804 0: [[2]],
805 # Request 1 has no bad words (to test non-consecutive request handling)
806 2: [[2]],
807 },
808 )
809 bonus_token_tensor = torch.tensor(
810 [output_tokens[i][-1] for i in range(len(output_tokens))], device=logits.device
811 )
812 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
813 mock_sampler_output(rejection_sampler, bonus_token_tensor)
814 output = rejection_sampler(
815 spec_decode_metadata,
816 draft_probs=None,
817 logits=logits,
818 sampling_metadata=metadata,
819 )
820
821 # Request 0: bad word [2] matches prefix, so token 2 is rejected -> 15
822 # Request 1: no bad words, all tokens match -> [1, 15, 3, 4]
823 # Request 2: bad word [2] matches prefix, so token 2 is rejected -> 15
824 expected = torch.tensor(
825 [[1, 15, -1, -1], [1, 15, 3, 4], [1, 15, -1, -1]],
826 dtype=torch.int,
827 device=logits.device,
828 )
829 assert torch.equal(output.sampled_token_ids, expected)
830
831
832 def test_allowed_token_ids(rejection_sampler):
833 """Test rejection sampling with allowed token ids."""
834 spec_tokens = [[1, 2, 10], [10, 5, 3], [7, 10, 12]]
835 output_tokens = [[1, 2, 10, 5], [10, 5, 10, 5], [7, 10, 12, 5]]
836 # Not allowed tokens:
837 # 0: 0-4
838 # 1: 1-5
839 # 2: 2-6
840 num_allowed_token_ids = 5
841
842 # Use the token 15 as the sampler choose if a token rejected
843 logits = create_logits_tensor(output_tokens, token_idx_to_override=15)
844
845 batch_size = len(output_tokens)
846 _, vocab_size = logits.size()
847 mask = create_allowed_token_ids(
848 batch_size=batch_size,
849 vocab_size=vocab_size,
850 num_allowed_token_ids=num_allowed_token_ids,
851 device=logits.device,
852 )
853 metadata = create_sampling_metadata(
854 all_greedy=True,
855 output_token_ids=[[], [], []],
856 spec_token_ids=spec_tokens,
857 allowed_token_ids_mask=mask,
858 )
859 bonus_token_tensor = torch.tensor(
860 [output_tokens[i][-1] for i in range(len(output_tokens))], device=logits.device
861 )
862 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
863 mock_sampler_output(rejection_sampler, bonus_token_tensor)
864 output = rejection_sampler(
865 spec_decode_metadata,
866 draft_probs=None,
867 logits=logits,
868 sampling_metadata=metadata,
869 )
870
871 expected = torch.tensor(
872 [[15, -1, -1, -1], [10, 5, 10, -1], [7, 10, 12, 5]],
873 dtype=torch.int,
874 device=logits.device,
875 )
876 assert torch.equal(output.sampled_token_ids, expected)
877
878
879 @pytest.mark.parametrize("batch_size", [1, 100])
880 @pytest.mark.parametrize("vocab_size", [100, 8192, 10000])
881 @pytest.mark.parametrize("max_spec_len", [1, 3])
882 @pytest.mark.parametrize("no_draft_probs", [True, False])
883 def test_sample_recovered_tokens(
884 batch_size: int, vocab_size: int, max_spec_len: int, no_draft_probs: bool
885 ):
886 num_tokens = batch_size * max_spec_len
887
888 # Create random draft probabilities.
889 draft_probs = torch.rand(
890 num_tokens,
891 vocab_size,
892 dtype=torch.float32,
893 device=DEVICE_TYPE,
894 )
895 draft_probs = F.softmax(draft_probs, dim=-1)
896
897 # Create random target probabilities.
898 target_logits = torch.rand(
899 num_tokens, vocab_size, dtype=torch.float32, device=DEVICE_TYPE
900 )
901 target_probs = F.softmax(target_logits, dim=-1)
902
903 # Randomly sample draft token ids from draft probs
904 draft_token_ids = torch.multinomial(draft_probs, num_samples=1).to(torch.int32)
905
906 temperature = torch.ones(batch_size, dtype=torch.float32, device=DEVICE_TYPE)
907 generators = {
908 i: torch.Generator(device=DEVICE_TYPE).manual_seed(i) for i in range(batch_size)
909 }
910 sampling_metadata = create_sampling_metadata(
911 all_greedy=False, temperature=temperature, generators=generators
912 )
913
914 spec_decode_metadata = create_spec_decode_metadata(
915 draft_token_ids.reshape(batch_size, max_spec_len).tolist(), target_logits
916 )
917
918 ref_recovered_token_ids = native_sample_recovered_tokens(
919 max_spec_len,
920 spec_decode_metadata.num_draft_tokens,
921 spec_decode_metadata.cu_num_draft_tokens,
922 draft_token_ids,
923 None if no_draft_probs else draft_probs,
924 target_probs,
925 sampling_metadata,
926 device=DEVICE_TYPE,
927 )
928 recovered_token_ids = sample_recovered_tokens(
929 max_spec_len,
930 spec_decode_metadata.num_draft_tokens,
931 spec_decode_metadata.cu_num_draft_tokens,
932 draft_token_ids,
933 None if no_draft_probs else draft_probs,
934 target_probs,
935 sampling_metadata,
936 device=DEVICE_TYPE,
937 )
938 assert torch.equal(recovered_token_ids, ref_recovered_token_ids)
939
940
941 def test_sample_recovered_tokens_uses_fp64_exponential_race_when_requested():
942 batch_size = 2
943 vocab_size = 64
944 max_spec_len = 2
945 num_tokens = batch_size * max_spec_len
946
947 draft_probs = torch.rand(
948 num_tokens,
949 vocab_size,
950 dtype=torch.float32,
951 device=DEVICE_TYPE,
952 )
953 draft_probs = F.softmax(draft_probs, dim=-1)
954 target_probs = torch.rand(
955 num_tokens,
956 vocab_size,
957 dtype=torch.float32,
958 device=DEVICE_TYPE,
959 )
960 target_probs = F.softmax(target_probs, dim=-1)
961 draft_token_ids = torch.multinomial(draft_probs, num_samples=1).to(torch.int32)
962
963 generators = {
964 i: torch.Generator(device=DEVICE_TYPE).manual_seed(i) for i in range(batch_size)
965 }
966 sampling_metadata = create_sampling_metadata(
967 all_greedy=False,
968 temperature=torch.ones(batch_size, dtype=torch.float32, device=DEVICE_TYPE),
969 generators=generators,
970 )
971 spec_decode_metadata = create_spec_decode_metadata(
972 draft_token_ids.reshape(batch_size, max_spec_len).tolist(),
973 target_probs.log(),
974 )
975
976 expected = native_sample_recovered_tokens(
977 max_spec_len,
978 spec_decode_metadata.num_draft_tokens,
979 spec_decode_metadata.cu_num_draft_tokens,
980 draft_token_ids,
981 draft_probs,
982 target_probs,
983 sampling_metadata,
984 device=torch.device(DEVICE_TYPE),
985 use_fp64_gumbel=True,
986 )
987 actual = sample_recovered_tokens(
988 max_spec_len,
989 spec_decode_metadata.num_draft_tokens,
990 spec_decode_metadata.cu_num_draft_tokens,
991 draft_token_ids,
992 draft_probs,
993 target_probs,
994 sampling_metadata,
995 device=torch.device(DEVICE_TYPE),
996 use_fp64_gumbel=True,
997 )
998
999 assert torch.equal(actual, expected)
1000
1001
1002 @pytest.mark.parametrize("no_draft_probs", [True, False])
1003 @pytest.mark.parametrize(
1004 "vocab_size",
1005 [
1006 100, # below BLOCK_SIZE: single partial tile with many padding entries
1007 8193, # BLOCK_SIZE + 1: only 1 valid entry in the last tile
1008 10000, # non-aligned, moderate tail
1009 151936, # real-world Qwen3 vocab size from the CVE report
1010 ],
1011 )
1012 def test_sample_recovered_tokens_vocab_boundary(vocab_size: int, no_draft_probs: bool):
1013 """Regression test for GHSA-8wr5-jm2h-8r4f.
1014
1015 When vocab_size is not a multiple of BLOCK_SIZE (8192), the last Triton
1016 tile extends beyond the vocabulary. If all valid entries in that tail tile
1017 have zero target probability, the out-of-range masked positions (score 0)
1018 could win the tl.max tie-break, producing recovered_id >= vocab_size.
1019 This test forces that scenario and asserts every recovered token is valid.
1020 """
1021 BLOCK_SIZE = 8192
1022 batch_size = 2
1023 max_spec_len = 3
1024 num_tokens = batch_size * max_spec_len
1025
1026 last_tile_start = (vocab_size // BLOCK_SIZE) * BLOCK_SIZE
1027
1028 target_probs = torch.rand(
1029 num_tokens, vocab_size, dtype=torch.float32, device=DEVICE_TYPE
1030 )
1031 if last_tile_start > 0:
1032 # Zero out valid entries in the last partial tile so the only
1033 # non-zero scores come from earlier, fully-covered tiles.
1034 target_probs[:, last_tile_start:] = 0.0
1035 else:
1036 # vocab_size < BLOCK_SIZE: single tile. Concentrate all mass on
1037 # entry 0 so the NO_DRAFT_PROBS path (which zeroes the draft
1038 # token entry) can drive all valid scores to zero.
1039 target_probs = torch.zeros_like(target_probs)
1040 target_probs[:, 0] = 1.0
1041 # Re-normalize so it's a valid distribution.
1042 target_probs = target_probs / target_probs.sum(dim=-1, keepdim=True)
1043
1044 draft_probs = torch.rand(
1045 num_tokens, vocab_size, dtype=torch.float32, device=DEVICE_TYPE
1046 )
1047 draft_probs = torch.nn.functional.softmax(draft_probs, dim=-1)
1048
1049 if last_tile_start == 0:
1050 # Force draft token to 0 so the NO_DRAFT_PROBS path zeroes the
1051 # only non-zero entry, leaving all valid scores at zero.
1052 draft_token_ids = torch.zeros(
1053 num_tokens, 1, dtype=torch.int32, device=DEVICE_TYPE
1054 )
1055 else:
1056 draft_token_ids = torch.randint(
1057 0, vocab_size, (num_tokens, 1), dtype=torch.int32, device=DEVICE_TYPE
1058 )
1059
1060 temperature = torch.ones(batch_size, dtype=torch.float32, device=DEVICE_TYPE)
1061 generators = {
1062 i: torch.Generator(device=DEVICE_TYPE).manual_seed(42 + i)
1063 for i in range(batch_size)
1064 }
1065 sampling_metadata = create_sampling_metadata(
1066 all_greedy=False, temperature=temperature, generators=generators
1067 )
1068
1069 spec_decode_metadata = create_spec_decode_metadata(
1070 draft_token_ids.reshape(batch_size, max_spec_len).tolist(),
1071 torch.rand(num_tokens, vocab_size, device=DEVICE_TYPE),
1072 )
1073
1074 recovered = sample_recovered_tokens(
1075 max_spec_len,
1076 spec_decode_metadata.num_draft_tokens,
1077 spec_decode_metadata.cu_num_draft_tokens,
1078 draft_token_ids.squeeze(-1),
1079 None if no_draft_probs else draft_probs,
1080 target_probs,
1081 sampling_metadata,
1082 device=DEVICE_TYPE,
1083 )
1084
1085 assert (recovered >= 0).all(), (
1086 f"Recovered token IDs contain negative values: "
1087 f"{recovered[recovered < 0].tolist()}"
1088 )
1089 assert (recovered < vocab_size).all(), (
1090 f"Recovered token IDs >= vocab_size ({vocab_size}): "
1091 f"{recovered[recovered >= vocab_size].tolist()}"
1092 )
1093
1094
1095 ########################### Tests for Synthetic Rejection Sampling #########
1096
1097
1098 def _make_synthetic_sampler(rates: list[float]) -> RejectionSampler:
1099 mock_sampler = Mock(spec=Sampler)
1100 mock_sampler.logprobs_mode = "raw_logprobs"
1101 spec_config = Mock()
1102 spec_config.rejection_sample_method = "synthetic"
1103 spec_config.synthetic_acceptance_rates = rates
1104 return RejectionSampler(mock_sampler, spec_config, torch.device(DEVICE_TYPE))
1105
1106
1107 def _make_sampling_metadata(all_greedy: bool) -> SamplingMetadata:
1108 temperature = None if all_greedy else torch.tensor([1.0, 1.0], device=DEVICE_TYPE)
1109 return create_sampling_metadata(all_greedy=all_greedy, temperature=temperature)
1110
1111
1112 @pytest.mark.parametrize("all_greedy", [True, False])
1113 def test_synthetic_all_accepted(all_greedy: bool):
1114 """With all rates=1.0, every draft token is accepted."""
1115 sampler = _make_synthetic_sampler([1.0, 1.0])
1116 spec_tokens = [[1, 2], [3]]
1117 output_tokens = [[10, 20, 50], [30, 40]]
1118
1119 metadata = _make_sampling_metadata(all_greedy)
1120 logits = create_logits_tensor(output_tokens)
1121 bonus = torch.tensor([50, 40], device=DEVICE_TYPE)
1122 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
1123
1124 mock_sampler_output(sampler, bonus)
1125 output = sampler(spec_decode_metadata, None, logits, metadata)
1126 expected = torch.tensor(
1127 [[1, 2, 50], [3, 40, PLACEHOLDER_TOKEN_ID]],
1128 dtype=torch.int,
1129 device=DEVICE_TYPE,
1130 )
1131 assert torch.equal(output.sampled_token_ids, expected)
1132
1133
1134 @pytest.mark.parametrize("all_greedy", [True, False])
1135 def test_synthetic_all_rejected(all_greedy: bool):
1136 """With all rates=0.0, the first token is always rejected."""
1137 sampler = _make_synthetic_sampler([0.0, 0.0])
1138 spec_tokens = [[1, 2], [3]]
1139 output_tokens = [[10, 20, 50], [30, 40]]
1140
1141 metadata = _make_sampling_metadata(all_greedy)
1142 logits = create_logits_tensor(output_tokens)
1143 bonus = torch.tensor([50, 40], device=DEVICE_TYPE)
1144 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
1145
1146 mock_sampler_output(sampler, bonus)
1147 output = sampler(spec_decode_metadata, None, logits, metadata)
1148 result = output.sampled_token_ids
1149 # Exactly one token emitted per sequence (the rejection fallback),
1150 # followed by placeholders.
1151 for row in result:
1152 assert row[0] != PLACEHOLDER_TOKEN_ID
1153 assert (row[1:] == PLACEHOLDER_TOKEN_ID).all()
1154
1155
1156 def test_placeholder_draft_token_rejected_random(rejection_sampler):
1157 """A placeholder draft id (-1) must be rejected in non-greedy sampling
1158 without indexing the probability tensors by the invalid id.
1159 """
1160 vocab_size = 100
1161 spec_tokens = [[1, vocab_size - 1, PLACEHOLDER_TOKEN_ID]]
1162 output_tokens = [[1, vocab_size - 1, 7, 9]]
1163
1164 temperature = torch.ones(1, dtype=torch.float32, device=DEVICE_TYPE)
1165 metadata = create_sampling_metadata(
1166 all_greedy=False,
1167 temperature=temperature,
1168 generators={0: torch.Generator(device=DEVICE_TYPE).manual_seed(0)},
1169 )
1170 logits = create_logits_tensor(output_tokens, vocab_size=vocab_size)
1171 bonus_token_tensor = torch.tensor([output_tokens[0][-1]], device=logits.device)
1172 spec_decode_metadata = create_spec_decode_metadata(spec_tokens, logits)
1173
1174 mock_sampler_output(rejection_sampler, bonus_token_tensor)
1175 output = rejection_sampler(
1176 spec_decode_metadata,
1177 draft_probs=None,
1178 logits=logits,
1179 sampling_metadata=metadata,
1180 )
1181 sampled = output.sampled_token_ids
1182
1183 assert sampled[0, 0].item() == 1
1184 assert sampled[0, 1].item() == vocab_size - 1
1185 recovered = sampled[0, 2].item()
1186 assert 0 <= recovered < vocab_size