main
py 1,273 lines 45.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import math
5 from types import SimpleNamespace
6
7 import pytest
8 import torch
9
10 from vllm.v1.watermarking import GumbelWatermarker
11 from vllm.v1.watermarking.spec_decode import watermarked_rejection_sample
12 from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
13 from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_sample
14 from vllm.v1.worker.gpu.spec_decode.rejection_sampler import (
15 RejectionSampler,
16 gather_draft_sampled,
17 )
18 from vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils import (
19 rejection_sample,
20 )
21
22 VOCAB_SIZE = 4096
23
24 # Skip if no CUDA - Triton kernel requires GPU
25 pytest.importorskip("triton")
26 if not torch.cuda.is_available():
27 pytest.skip("CUDA required for rejection sampler tests", allow_module_level=True)
28
29
30 def _build_rejection_sample_inputs(
31 target_logits_1d: torch.Tensor,
32 draft_logits_1d: torch.Tensor,
33 num_speculative_steps: int,
34 temperature: float,
35 num_trials: int,
36 ) -> dict:
37 """Build rejection_sample kwargs from a fixed target and draft distribution.
38
39 target_logits_1d must already have temperature applied (the sampler applies
40 sampling params before verification), whereas draft_logits_1d must not:
41 rejection_sample divides the draft logits by the temperature on load.
42 """
43 device = target_logits_1d.device
44 vocab_size = target_logits_1d.shape[0]
45 K = num_speculative_steps
46 num_logits = num_trials * (K + 1)
47
48 target_logits = target_logits_1d.unsqueeze(0).expand(num_logits, -1).contiguous()
49 draft_logits = (
50 draft_logits_1d.view(1, 1, vocab_size).expand(num_trials, K, -1).contiguous()
51 )
52
53 scaled_draft_logits_1d = draft_logits_1d.float()
54 if temperature > 0:
55 scaled_draft_logits_1d = scaled_draft_logits_1d / temperature
56 draft_probs = torch.softmax(scaled_draft_logits_1d, dim=0)
57 draft_tokens = torch.multinomial(
58 draft_probs.expand(num_trials, -1), K, replacement=True
59 )
60 draft_sampled_2d = torch.zeros(num_trials, K + 1, dtype=torch.int64, device=device)
61 draft_sampled_2d[:, 1:] = draft_tokens
62 draft_sampled = draft_sampled_2d.reshape(-1)
63
64 cu_num_logits = torch.arange(num_trials + 1, dtype=torch.int32, device=device) * (
65 K + 1
66 )
67 pos = torch.arange(num_logits, dtype=torch.int32, device=device)
68 idx_mapping = torch.arange(num_trials, dtype=torch.int32, device=device)
69 expanded_idx_mapping = torch.arange(
70 num_trials, dtype=torch.int32, device=device
71 ).repeat_interleave(K + 1)
72 expanded_local_pos = torch.arange(K + 1, dtype=torch.int32, device=device).repeat(
73 num_trials
74 )
75 temp_tensor = torch.full(
76 (num_trials,), temperature, dtype=torch.float32, device=device
77 )
78 seed = torch.arange(num_trials, dtype=torch.int64, device=device)
79
80 return dict(
81 target_logits=target_logits,
82 draft_logits=draft_logits,
83 draft_sampled=draft_sampled,
84 cu_num_logits=cu_num_logits,
85 pos=pos,
86 idx_mapping=idx_mapping,
87 expanded_idx_mapping=expanded_idx_mapping,
88 expanded_local_pos=expanded_local_pos,
89 temperature=temp_tensor,
90 seed=seed,
91 )
92
93
94 def _assert_distribution_match(
95 sampled_tokens: torch.Tensor,
96 target_probs: torch.Tensor,
97 device: str,
98 label: str = "",
99 min_expected: float = 5.0,
100 ):
101 """Assert sampled tokens match the target distribution via a
102 chi-squared goodness-of-fit test. This is done by computing
103 observed vs expected token counts (target_probs * num_samples),
104 then checking that the chi-squared statistic is below a conservative
105 threshold. The threshold is set at df + 10*sqrt(2*df), which
106 corresponds to ~10 sigma under the chi-squared distribution's
107 normal approximation, effectively disallowing false positives.
108
109 NOTE: Tokens with expected count < min_expected are merged into
110 a single "other" bin to minimize chi-squared noise.
111 """
112 num_samples = sampled_tokens.shape[0]
113 vocab_size = target_probs.shape[0]
114
115 observed = torch.zeros(vocab_size, device=device, dtype=torch.float32)
116 observed.scatter_add_(0, sampled_tokens, torch.ones(num_samples, device=device))
117 expected = target_probs * num_samples
118
119 sufficient = expected >= min_expected
120 obs_main = observed[sufficient]
121 exp_main = expected[sufficient]
122
123 obs_other = observed[~sufficient].sum().unsqueeze(0)
124 exp_other = expected[~sufficient].sum().unsqueeze(0)
125
126 if exp_other.item() >= min_expected:
127 obs_all = torch.cat([obs_main, obs_other])
128 exp_all = torch.cat([exp_main, exp_other])
129 else:
130 obs_all = obs_main
131 exp_all = exp_main
132
133 chi2 = ((obs_all - exp_all) ** 2 / exp_all).sum().item()
134 df = obs_all.shape[0] - 1
135 if df < 1:
136 # All samples were merged into < 2 bins, which is too
137 # few to evaluate.
138 return
139
140 threshold = df + 10 * math.sqrt(2 * df)
141 prefix = f"[{label}] " if label else ""
142 assert chi2 < threshold, (
143 f"{prefix}Chi-squared test failed: chi2={chi2:.1f}, "
144 f"df={df}, threshold={threshold:.1f}. "
145 f"Output distribution does not match target distribution."
146 )
147
148
149 def test_watermarked_bonus_uses_target_key():
150 target_logits = torch.randn(16, device="cuda")
151 inputs = _build_rejection_sample_inputs(
152 target_logits,
153 target_logits,
154 num_speculative_steps=1,
155 temperature=1.0,
156 num_trials=1,
157 )
158 draft_token = inputs["draft_sampled"][1]
159 contexts = torch.tensor(
160 [[10, 11], [11, draft_token.item()]], dtype=torch.int64, device="cuda"
161 )
162
163 sampled, num_sampled = watermarked_rejection_sample(
164 **inputs,
165 num_speculative_steps=1,
166 contexts=contexts,
167 watermarking=torch.tensor([True], device="cuda"),
168 watermarker=GumbelWatermarker(key=42, context_width=2),
169 )
170 expected = GumbelWatermarker(key=42, context_width=2).sample(
171 target_logits.unsqueeze(0), contexts[1:]
172 )
173
174 assert num_sampled.item() == 2
175 assert sampled[0, 1] == expected.token_ids[0]
176
177
178 def test_watermarked_recovery_uses_target_key():
179 target_logits = torch.tensor([float("-inf"), 0.0, 1.0, 2.0], device="cuda")
180 draft_logits = torch.tensor([100.0, -100.0, -100.0, -100.0], device="cuda")
181 inputs = _build_rejection_sample_inputs(
182 target_logits,
183 draft_logits,
184 num_speculative_steps=1,
185 temperature=1.0,
186 num_trials=1,
187 )
188 inputs["draft_sampled"][1] = 0
189 contexts = torch.tensor([[10, 11], [11, 0]], dtype=torch.int64, device="cuda")
190
191 sampled, num_sampled = watermarked_rejection_sample(
192 **inputs,
193 num_speculative_steps=1,
194 contexts=contexts,
195 watermarking=torch.tensor([True], device="cuda"),
196 watermarker=GumbelWatermarker(key=42, context_width=2),
197 )
198 expected = GumbelWatermarker(key=42, context_width=2).sample(
199 target_logits.unsqueeze(0), contexts[:1]
200 )
201
202 assert num_sampled.item() == 1
203 assert sampled[0, 0] == expected.token_ids[0]
204
205
206 def test_repeated_draft_context_does_not_change_acceptance():
207 torch.manual_seed(0)
208 num_trials = 8
209 # A draft distribution of its own, so the drafts are not accepted outright.
210 target_logits = torch.randn(64, device="cuda")
211 draft_logits = torch.randn(64, device="cuda")
212 inputs = _build_rejection_sample_inputs(
213 target_logits,
214 draft_logits,
215 num_speculative_steps=1,
216 temperature=1.0,
217 num_trials=num_trials,
218 )
219 expected, expected_num_sampled = rejection_sample(**inputs, num_speculative_steps=1)
220
221 sampled, num_sampled = watermarked_rejection_sample(
222 **inputs,
223 num_speculative_steps=1,
224 contexts=torch.tensor([[10, 11], [11, 12]], device="cuda").repeat(
225 num_trials, 1
226 ),
227 watermarking=torch.ones(num_trials, dtype=torch.bool, device="cuda"),
228 watermarking_skip_mask=torch.tensor([True, False], device="cuda").repeat(
229 num_trials
230 ),
231 watermarker=GumbelWatermarker(key=42, context_width=2),
232 )
233
234 # Some drafts must actually be rejected, or the ordinary-residual branch
235 # the skip mask selects is never reached.
236 assert (expected_num_sampled == 1).any()
237 torch.testing.assert_close(num_sampled, expected_num_sampled, rtol=0, atol=0)
238 torch.testing.assert_close(sampled[:, :1], expected[:, :1], rtol=0, atol=0)
239
240
241 def test_repeated_context_skip_mask_is_read_per_row_in_a_block():
242 """A block of three drafts, accepted at row 0 and rejected at row 1.
243
244 The skip mask selects the sampler per row, so the row the target recovers
245 at decides which key draws the recovery token.
246 """
247 num_speculative_steps = 3
248 target_logits = torch.tensor([float("-inf"), 0.0, 1.0, 2.0], device="cuda")
249 draft_logits = torch.tensor([30.0, -30.0, -30.0, -30.0], device="cuda")
250 inputs = _build_rejection_sample_inputs(
251 target_logits,
252 draft_logits,
253 num_speculative_steps=num_speculative_steps,
254 temperature=1.0,
255 num_trials=1,
256 )
257 # Row 0 drafts a token the target gives 0.67 and the draft e**-60, so it is
258 # accepted; row 1 drafts a token the target gives no mass, so it is not.
259 inputs["draft_sampled"][1] = 3
260 inputs["draft_sampled"][2] = 0
261 contexts = torch.tensor(
262 [[10, 11], [11, 3], [3, 0], [0, 1]], dtype=torch.int64, device="cuda"
263 )
264 expected, expected_num_sampled = rejection_sample(
265 **inputs, num_speculative_steps=num_speculative_steps
266 )
267 assert expected_num_sampled.item() == 2
268 assert expected[0, 0].item() == 3
269
270 watermarker = GumbelWatermarker(key=42, context_width=2)
271 skipped, skipped_num_sampled = watermarked_rejection_sample(
272 **inputs,
273 num_speculative_steps=num_speculative_steps,
274 contexts=contexts,
275 watermarking=torch.tensor([True], device="cuda"),
276 watermarking_skip_mask=torch.tensor([False, True, False, False], device="cuda"),
277 watermarker=watermarker,
278 )
279
280 assert skipped_num_sampled.item() == 2
281 torch.testing.assert_close(skipped[:, :2], expected[:, :2], rtol=0, atol=0)
282
283 watermarked, watermarked_num_sampled = watermarked_rejection_sample(
284 **inputs,
285 num_speculative_steps=num_speculative_steps,
286 contexts=contexts,
287 watermarking=torch.tensor([True], device="cuda"),
288 watermarking_skip_mask=torch.zeros(
289 num_speculative_steps + 1, dtype=torch.bool, device="cuda"
290 ),
291 watermarker=watermarker,
292 )
293 # The residual equals the target to within e**-60, so the recovery token is
294 # the target key's draw on the rejected row's context.
295 recovery = GumbelWatermarker(key=42, context_width=2).sample(
296 target_logits.unsqueeze(0), contexts[1:2], lambda _: None
297 )
298
299 assert watermarked_num_sampled.item() == 2
300 assert watermarked[0, 0].item() == 3
301 assert watermarked[0, 1] == recovery.token_ids[0]
302
303
304 def test_repeated_recovery_context_uses_ordinary_residual_sample():
305 target_logits = torch.tensor([float("-inf"), 0.0, 1.0, 2.0], device="cuda")
306 draft_logits = torch.tensor([100.0, -100.0, -100.0, -100.0], device="cuda")
307 inputs = _build_rejection_sample_inputs(
308 target_logits,
309 draft_logits,
310 num_speculative_steps=1,
311 temperature=1.0,
312 num_trials=1,
313 )
314 inputs["draft_sampled"][1] = 0
315 expected, expected_num_sampled = rejection_sample(**inputs, num_speculative_steps=1)
316
317 sampled, num_sampled = watermarked_rejection_sample(
318 **inputs,
319 num_speculative_steps=1,
320 contexts=torch.tensor([[10, 11], [11, 0]], device="cuda"),
321 watermarking=torch.tensor([True], device="cuda"),
322 watermarking_skip_mask=torch.tensor([True, False], device="cuda"),
323 watermarker=GumbelWatermarker(key=42, context_width=2),
324 )
325
326 torch.testing.assert_close(num_sampled, expected_num_sampled, rtol=0, atol=0)
327 torch.testing.assert_close(sampled[:, :1], expected[:, :1], rtol=0, atol=0)
328
329
330 def test_repeated_bonus_context_uses_ordinary_target_sample():
331 target_logits = torch.randn(64, device="cuda")
332 inputs = _build_rejection_sample_inputs(
333 target_logits,
334 target_logits,
335 num_speculative_steps=1,
336 temperature=1.0,
337 num_trials=1,
338 )
339 expected, expected_num_sampled = rejection_sample(**inputs, num_speculative_steps=1)
340
341 sampled, num_sampled = watermarked_rejection_sample(
342 **inputs,
343 num_speculative_steps=1,
344 contexts=torch.tensor([[10, 11], [11, 12]], device="cuda"),
345 watermarking=torch.tensor([True], device="cuda"),
346 watermarking_skip_mask=torch.tensor([False, True], device="cuda"),
347 watermarker=GumbelWatermarker(key=42, context_width=2),
348 )
349
350 torch.testing.assert_close(num_sampled, expected_num_sampled, rtol=0, atol=0)
351 torch.testing.assert_close(sampled[:, :2], expected[:, :2], rtol=0, atol=0)
352
353
354 def test_repeated_context_does_not_change_unwatermarked_or_greedy_rows():
355 target_logits = torch.randn(64, device="cuda")
356 inputs = _build_rejection_sample_inputs(
357 target_logits,
358 target_logits,
359 num_speculative_steps=1,
360 temperature=1.0,
361 num_trials=2,
362 )
363 inputs["temperature"][1] = 0
364 expected, expected_num_sampled = rejection_sample(**inputs, num_speculative_steps=1)
365
366 sampled, num_sampled = watermarked_rejection_sample(
367 **inputs,
368 num_speculative_steps=1,
369 contexts=torch.tensor([[10, 11], [11, 12], [20, 21], [21, 22]], device="cuda"),
370 watermarking=torch.tensor([False, True], device="cuda"),
371 watermarking_skip_mask=torch.ones(4, dtype=torch.bool, device="cuda"),
372 watermarker=GumbelWatermarker(key=42, context_width=2),
373 )
374
375 torch.testing.assert_close(num_sampled, expected_num_sampled, rtol=0, atol=0)
376 for row, length in enumerate(num_sampled):
377 torch.testing.assert_close(
378 sampled[row, : int(length)], expected[row, : int(length)], rtol=0, atol=0
379 )
380
381
382 def test_watermarked_recovery_supports_smaller_draft_vocabulary():
383 target_logits = torch.tensor([float("-inf"), 0.0, 1.0, 2.0, 100.0], device="cuda")
384 draft_logits = torch.tensor([100.0, -100.0, -100.0, -100.0], device="cuda")
385 inputs = _build_rejection_sample_inputs(
386 target_logits[:4],
387 draft_logits,
388 num_speculative_steps=1,
389 temperature=1.0,
390 num_trials=1,
391 )
392 inputs["target_logits"] = target_logits.unsqueeze(0).expand(2, -1).contiguous()
393 inputs["draft_sampled"][1] = 0
394 contexts = torch.tensor([[10, 11], [11, 0]], dtype=torch.int64, device="cuda")
395 watermarker = GumbelWatermarker(key=42, context_width=2)
396
397 sampled, num_sampled = watermarked_rejection_sample(
398 **inputs,
399 num_speculative_steps=1,
400 contexts=contexts,
401 watermarking=torch.tensor([True], device="cuda"),
402 watermarker=watermarker,
403 )
404 expected = watermarker.sample(target_logits[:4].unsqueeze(0), contexts[:1])
405
406 assert num_sampled.item() == 1
407 assert sampled[0, 0] == expected.token_ids[0]
408
409
410 @pytest.mark.parametrize(
411 "num_speculative_steps,temperature",
412 [
413 (1, 0.6),
414 (3, 0.6),
415 (1, 1.0),
416 (3, 1.0),
417 ],
418 )
419 @pytest.mark.parametrize("draft_logits_dtype", [torch.float32, torch.bfloat16])
420 def test_stochastic_rejection_sample(
421 num_speculative_steps: int, temperature: float, draft_logits_dtype: torch.dtype
422 ):
423 """Verify that rejection sampling produces the target distribution.
424 This is done by simulating many independent trials of speculative
425 decoding (from a fixed target and draft distribution). We then
426 run rejection sample on all of the trials (requests), and verify
427 that the sampled tokens at every position follow the target
428 distribution p(x).
429
430 Parametrized over the draft-logits dtype: storing them in the draft head's
431 dtype must not bias the output distribution.
432 """
433 torch.manual_seed(42)
434 device = "cuda"
435 num_trials = 10 * VOCAB_SIZE
436
437 target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
438 draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32).to(
439 draft_logits_dtype
440 )
441
442 if temperature > 0:
443 target_logits_1d /= temperature
444
445 inputs = _build_rejection_sample_inputs(
446 target_logits_1d,
447 draft_logits_1d,
448 num_speculative_steps,
449 temperature=temperature,
450 num_trials=num_trials,
451 )
452
453 sampled, num_sampled = rejection_sample(
454 **inputs, num_speculative_steps=num_speculative_steps
455 )
456
457 target_probs = torch.softmax(target_logits_1d, dim=0)
458 for pos in range(num_speculative_steps + 1):
459 accepted_mask = num_sampled >= pos + 1
460 _assert_distribution_match(
461 sampled[accepted_mask, pos], target_probs, device, label=f"position {pos}"
462 )
463
464
465 # The test above spreads its samples too thin to resolve a small distributional
466 # bias: VOCAB_SIZE bins over 10 * VOCAB_SIZE trials is ~10 samples per bin.
467 # Sixteen bins over 200K trials is ~12K per bin, which resolves a few percent.
468 NARROW_VOCAB_SIZE = 16
469 NARROW_NUM_TRIALS = 200_000
470
471
472 def _gumbel_drafted_tokens(
473 inputs: dict,
474 draft_logits_1d: torch.Tensor,
475 num_trials: int,
476 num_speculative_steps: int,
477 ) -> torch.Tensor:
478 """Proposals drawn with gumbel_sample, shaped like inputs["draft_sampled"].
479
480 _build_rejection_sample_inputs draws them with torch.multinomial, which is
481 independent of the resample noise by construction. Production drafts come
482 from gumbel_sample keyed by pos[t * (K + 1) + i] for step i of trial t --
483 the same entry _rejection_kernel and _resample_kernel read for that token --
484 so the draft and the residual compete for one noise stream.
485 """
486 k = num_speculative_steps
487 vocab_size = draft_logits_1d.shape[0]
488 device = draft_logits_1d.device
489 draft_tokens = gumbel_sample(
490 draft_logits_1d.unsqueeze(0).expand(num_trials * k, vocab_size).float(),
491 inputs["expanded_idx_mapping"]
492 .view(num_trials, k + 1)[:, :k]
493 .reshape(-1)
494 .contiguous(),
495 inputs["temperature"],
496 inputs["seed"],
497 inputs["pos"].view(num_trials, k + 1)[:, :k].reshape(-1).contiguous(),
498 apply_temperature=True,
499 is_drafting=True,
500 )
501 draft_sampled = torch.zeros(num_trials * (k + 1), dtype=torch.int64, device=device)
502 draft_sampled.view(num_trials, k + 1)[:, 1:] = draft_tokens.view(num_trials, k)
503 return draft_sampled
504
505
506 @pytest.mark.parametrize("num_speculative_steps", [1, 3])
507 def test_gumbel_drafted_rejection_sample_is_unbiased(num_speculative_steps: int):
508 """The proposal and the residual resample must not share a noise vector.
509
510 Draws proposals on the same (seed, pos) stream the sampler verifies and
511 resamples with, then checks the output still follows the target. Conditioned
512 on a proposal winning the argmax, every other token's Gumbel is truncated
513 below that max -- most tightly for the tokens the draft ranked highest -- so
514 a shared stream makes the residual under-weight exactly those tokens.
515
516 Runs narrow because the wide-vocab test above cannot resolve this: dropping
517 `is_drafting=True` in _gumbel_drafted_tokens takes position 0 from chi2 ~12 to
518 ~1500 against a threshold of ~70 here, while leaving that test passing.
519 """
520 torch.manual_seed(42)
521 device = "cuda"
522
523 # A draft that ranks tokens in exactly the opposite order rejects ~73% of
524 # proposals, so most trials reach the residual resample where the bias
525 # lives. The disagreement has to be constructed rather than sampled: two
526 # independent randn draws land close together often enough that the signal
527 # swings between chi2 ~14 and ~1900 depending on the seed. At temperature
528 # 1.0 the target needs no scaling before being passed in.
529 target_logits_1d = torch.randn(
530 NARROW_VOCAB_SIZE, device=device, dtype=torch.float32
531 )
532 draft_logits_1d = -target_logits_1d
533
534 inputs = _build_rejection_sample_inputs(
535 target_logits_1d,
536 draft_logits_1d,
537 num_speculative_steps,
538 temperature=1.0,
539 num_trials=NARROW_NUM_TRIALS,
540 )
541 inputs["draft_sampled"] = _gumbel_drafted_tokens(
542 inputs, draft_logits_1d, NARROW_NUM_TRIALS, num_speculative_steps
543 )
544
545 sampled, num_sampled = rejection_sample(
546 **inputs, num_speculative_steps=num_speculative_steps
547 )
548
549 # Position 0 carries the power: every trial reaches it, while later
550 # positions are only reached on acceptance, which is rare by construction.
551 assert (num_sampled >= 1).all()
552 target_probs = torch.softmax(target_logits_1d, dim=0)
553 for pos in range(num_speculative_steps + 1):
554 accepted_mask = num_sampled >= pos + 1
555 _assert_distribution_match(
556 sampled[accepted_mask, pos], target_probs, device, label=f"position {pos}"
557 )
558
559
560 @pytest.mark.parametrize("num_speculative_steps", [1, 3])
561 def test_greedy_rejection_sample(num_speculative_steps: int):
562 """Verify that greedy (temperature=0) always outputs the target argmax
563 at every accepted position.
564 """
565 torch.manual_seed(42)
566 device = "cuda"
567 num_trials = 10 * VOCAB_SIZE
568
569 target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
570 draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
571
572 inputs = _build_rejection_sample_inputs(
573 target_logits_1d,
574 draft_logits_1d,
575 num_speculative_steps,
576 temperature=0.0,
577 num_trials=num_trials,
578 )
579
580 sampled, num_sampled = rejection_sample(
581 **inputs, num_speculative_steps=num_speculative_steps
582 )
583
584 target_argmax = target_logits_1d.argmax().item()
585
586 steps = torch.arange(num_speculative_steps + 1, device=device).unsqueeze(0)
587 accepted_mask = steps < num_sampled.unsqueeze(1)
588
589 assert (sampled[accepted_mask] == target_argmax).all(), (
590 "Greedy sampling produced tokens that are not the target argmax"
591 )
592
593
594 @pytest.mark.parametrize(
595 "num_speculative_steps,temperature,unconditional_rates",
596 [
597 (3, 1.0, [0.9, 0.5, 0.2]),
598 (3, 0.0, [0.9, 0.5, 0.2]),
599 (3, 1.0, [1.0, 1.0, 1.0]),
600 (3, 0.0, [1.0, 1.0, 1.0]),
601 (3, 1.0, [0.0, 0.0, 0.0]),
602 (3, 0.0, [0.0, 0.0, 0.0]),
603 (1, 1.0, [0.7]),
604 (1, 0.0, [0.7]),
605 ],
606 )
607 def test_synthetic_rejection_sample(
608 num_speculative_steps: int,
609 temperature: float,
610 unconditional_rates: list[float],
611 ):
612 """Verify that synthetic rejection sampling produces the expected
613 per-position acceptance rates. The unconditional rate at position i
614 is P(all draft steps 0..i accepted) = product(conditional_rates[0:i+1]).
615 This is approximately mean(num accepted >= i + 1) over many trials.
616 """
617 from vllm.v1.spec_decode.utils import unconditional_to_conditional_rates
618
619 torch.manual_seed(42)
620 device = "cuda"
621 num_trials = 10 * VOCAB_SIZE
622 deviation_tol = 1e-2
623
624 target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
625 draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
626
627 if temperature > 0:
628 target_logits_1d /= temperature
629
630 inputs = _build_rejection_sample_inputs(
631 target_logits_1d,
632 draft_logits_1d,
633 num_speculative_steps,
634 temperature=temperature,
635 num_trials=num_trials,
636 )
637
638 conditional_rates = unconditional_to_conditional_rates(unconditional_rates)
639 synthetic_conditional_rates = torch.tensor(
640 conditional_rates, dtype=torch.float32, device=device
641 )
642
643 _, num_sampled = rejection_sample(
644 **inputs,
645 num_speculative_steps=num_speculative_steps,
646 synthetic_conditional_rates=synthetic_conditional_rates,
647 )
648
649 # num_sampled includes the resampled/bonus token.
650 num_accepted = num_sampled - 1
651 for i, expected_rate in enumerate(unconditional_rates):
652 observed_rate = (num_accepted >= i + 1).float().mean().item()
653 assert abs(observed_rate - expected_rate) < deviation_tol, (
654 f"Step {i}: observed rate {observed_rate:.4f} deviates from "
655 f"expected rate {expected_rate:.4f} by more than {deviation_tol}."
656 )
657
658
659 @pytest.mark.parametrize("temperature", [0.0, 1.0])
660 def test_all_nan_target_logits_in_range(temperature: float):
661 """Regression test for NaN breaking tl.argmax index bounds.
662
663 An all-NaN target logits row makes every per-block local max NaN.
664 tl.argmax over such a block returns an index into the padded region
665 (>= num_blocks), causing an OOB read of the local-argmax tensors in
666 _compute_global_target_argmax (greedy) and _insert_resampled_kernel
667 (stochastic). The vocab size below gives a non-power-of-2 block count
668 (3 blocks of 8192, padded to 4) so the padded region exists. Post-fix,
669 NaN is mapped to -inf, so the kernels must complete without error and
670 emit in-range token ids.
671 """
672 torch.manual_seed(0)
673 device = "cuda"
674 num_trials = 4
675 K = 1
676 vocab_size = 20000 # 3 vocab blocks of 8192, padded to 4
677
678 target_logits_1d = torch.full(
679 (vocab_size,), float("nan"), device=device, dtype=torch.float32
680 )
681 draft_logits_1d = torch.randn(vocab_size, device=device, dtype=torch.float32)
682
683 inputs = _build_rejection_sample_inputs(
684 target_logits_1d,
685 draft_logits_1d,
686 K,
687 temperature=temperature,
688 num_trials=num_trials,
689 )
690
691 sampled, num_sampled = rejection_sample(**inputs, num_speculative_steps=K)
692
693 assert ((num_sampled >= 1) & (num_sampled <= K + 1)).all()
694 steps = torch.arange(K + 1, device=device).unsqueeze(0)
695 valid = steps < num_sampled.unsqueeze(1)
696 assert (sampled[valid] >= 0).all()
697 assert (sampled[valid] < vocab_size).all()
698
699
700 def test_placeholder_draft_token_rejected():
701 """A placeholder draft id (-1) must be rejected without reading the logit
702 tensors out of bounds, for any sampling method.
703 """
704 torch.manual_seed(0)
705 device = "cuda"
706 num_trials = 64
707 K = 1
708 temperature = 0.6
709
710 target_logits_1d = torch.randn(VOCAB_SIZE, device=device) / temperature
711 draft_logits_1d = torch.randn(VOCAB_SIZE, device=device)
712
713 inputs = _build_rejection_sample_inputs(
714 target_logits_1d,
715 draft_logits_1d,
716 K,
717 temperature=temperature,
718 num_trials=num_trials,
719 )
720 inputs["draft_sampled"].view(num_trials, K + 1)[:, 1:] = -1
721
722 sampled, num_sampled = rejection_sample(**inputs, num_speculative_steps=K)
723
724 assert torch.equal(num_sampled, torch.ones_like(num_sampled))
725 recovered = sampled[:, 0]
726 assert (recovered >= 0).all() and (recovered < VOCAB_SIZE).all()
727
728
729 @pytest.mark.parametrize("has_draft_logits", [True, False])
730 @pytest.mark.parametrize("num_placeholders", [1, 2])
731 def test_block_verification_placeholder_truncates_block(
732 has_draft_logits: bool, num_placeholders: int
733 ):
734 """Trailing placeholder (-1) draft ids end the block early. The last real
735 draft token must then be verified against the closed-form threshold rather
736 than a residual mass derived from the placeholder position, whose logits
737 describe a token that was never drafted.
738 """
739 torch.manual_seed(42)
740 device = "cuda"
741 num_trials = 20 * VOCAB_SIZE
742 K = 3
743 temperature = 1.0
744
745 target_logits_1d = torch.randn(VOCAB_SIZE, device=device) / temperature
746 draft_logits_1d = torch.randn(VOCAB_SIZE, device=device)
747
748 inputs = _build_rejection_sample_inputs(
749 target_logits_1d,
750 draft_logits_1d,
751 K,
752 temperature=temperature,
753 num_trials=num_trials,
754 )
755 if not has_draft_logits:
756 inputs["draft_logits"] = None
757 inputs["draft_sampled"].view(num_trials, K + 1)[:, K + 1 - num_placeholders :] = -1
758
759 sampled, num_sampled = rejection_sample(
760 **inputs, num_speculative_steps=K, use_block_verification=True
761 )
762
763 num_real = K - num_placeholders
764 assert (num_sampled <= num_real + 1).all(), (
765 "Accepted a draft token at or after a placeholder."
766 )
767 target_probs = torch.softmax(target_logits_1d, dim=0)
768 for pos in range(num_real + 1):
769 accepted_mask = num_sampled >= pos + 1
770 _assert_distribution_match(
771 sampled[accepted_mask, pos], target_probs, device, label=f"position {pos}"
772 )
773
774
775 def test_greedy_placeholder_emits_target_argmax():
776 """Greedy sampling skips resampling and relies on the rejection kernel
777 storing the target argmax at the rejected position. A placeholder must not
778 bypass that store, or the output slot is returned uninitialized.
779 """
780 torch.manual_seed(0)
781 device = "cuda"
782 num_trials = 512
783 K = 3
784
785 target_logits_1d = torch.randn(VOCAB_SIZE, device=device)
786 draft_logits_1d = torch.randn(VOCAB_SIZE, device=device)
787
788 inputs = _build_rejection_sample_inputs(
789 target_logits_1d,
790 draft_logits_1d,
791 K,
792 temperature=0.0,
793 num_trials=num_trials,
794 )
795 target_argmax = int(target_logits_1d.argmax())
796 draft_sampled = inputs["draft_sampled"].view(num_trials, K + 1)
797 # Accept the first draft so that the rejection lands on the placeholder.
798 draft_sampled[:, 1] = target_argmax
799 draft_sampled[:, 2:] = -1
800
801 sampled, num_sampled = rejection_sample(**inputs, num_speculative_steps=K)
802
803 assert torch.equal(num_sampled, torch.full_like(num_sampled, 2))
804 steps = torch.arange(K + 1, device=device).unsqueeze(0)
805 emitted = sampled[steps < num_sampled.unsqueeze(1)]
806 assert (emitted == target_argmax).all(), (
807 "Greedy sampling emitted a token that is not the target argmax"
808 )
809
810
811 @pytest.mark.parametrize("use_block_verification", [False, True])
812 def test_placeholder_blocks_later_draft_tokens(use_block_verification: bool):
813 """A placeholder is not necessarily the final draft. Nothing at or after
814 one may be accepted, even when a valid draft follows it.
815 """
816 torch.manual_seed(0)
817 device = "cuda"
818 num_trials = 4 * VOCAB_SIZE
819 K = 3
820 temperature = 1.0
821
822 # An identical draft is accepted with probability ~1, so any token after
823 # the placeholder would be accepted unless it is explicitly blocked. The
824 # draft logits are stored pre-temperature, so pass the unscaled base.
825 draft_logits_1d = torch.randn(VOCAB_SIZE, device=device)
826 target_logits_1d = draft_logits_1d / temperature
827
828 inputs = _build_rejection_sample_inputs(
829 target_logits_1d,
830 draft_logits_1d,
831 K,
832 temperature=temperature,
833 num_trials=num_trials,
834 )
835 inputs["draft_sampled"].view(num_trials, K + 1)[:, 2] = -1
836
837 _, num_sampled = rejection_sample(
838 **inputs,
839 num_speculative_steps=K,
840 use_block_verification=use_block_verification,
841 )
842
843 # Only the first draft is verifiable, plus one resampled token.
844 assert (num_sampled <= 2).all(), "Accepted a draft token past a placeholder."
845 assert (num_sampled == 2).float().mean().item() > 0.9, (
846 "The first draft was rarely accepted; the test is not exercising "
847 "acceptance past the placeholder."
848 )
849
850
851 def test_verify_rejects_unproposed_drafts():
852 """Zero drafts padded onto a step starting in the prefill must be rejected."""
853 torch.manual_seed(0)
854 device = "cuda"
855 num_trials = 256
856 K = 8
857
858 # Row 0: token 0 is very unlikely. Rows >= 1 follow the zero inputs and
859 # predict token 0. The never-written draft logits row is uniform.
860 target_logits_1d = torch.randn(VOCAB_SIZE, device=device)
861 target_logits_1d[0] = -20.0
862 inputs = _build_rejection_sample_inputs(
863 target_logits_1d,
864 torch.zeros(VOCAB_SIZE, device=device),
865 K,
866 temperature=1.0,
867 num_trials=num_trials,
868 )
869 target_rest = torch.randn(VOCAB_SIZE, device=device)
870 target_rest[0] = 20.0
871 inputs["target_logits"].view(num_trials, K + 1, VOCAB_SIZE)[:, 1:] = target_rest
872 inputs["draft_sampled"].view(num_trials, K + 1)[:, 1:] = 0
873
874 # Each request's first logit is the last prefill token, as in the first
875 # decode step after a P/D remote KV load.
876 first_pos = inputs["pos"].view(num_trials, K + 1)[:, 0]
877 prefill_len = first_pos + 1
878 rejection_sampler = object.__new__(RejectionSampler)
879 rejection_sampler.sampler = SimpleNamespace(
880 apply_sampling_params=lambda logits, *args: logits,
881 sampling_states=SimpleNamespace(
882 temperature=SimpleNamespace(gpu=inputs["temperature"]),
883 seeds=SimpleNamespace(gpu=inputs["seed"]),
884 ),
885 use_fp64_gumbel=False,
886 )
887 rejection_sampler.num_speculative_steps = K
888 rejection_sampler.synthetic_conditional_rates = None
889 rejection_sampler.use_block_verification = True
890 rejection_sampler.watermark_key = None
891
892 def verify() -> tuple[torch.Tensor, torch.Tensor]:
893 draft_sampled, pos = gather_draft_sampled(
894 inputs["draft_sampled"],
895 inputs["pos"],
896 torch.arange(num_trials * (K + 1), device=device),
897 inputs["expanded_idx_mapping"],
898 inputs["expanded_local_pos"],
899 prefill_len,
900 )
901 _, sampled, num_sampled = rejection_sampler._verify(
902 inputs["target_logits"],
903 inputs["draft_logits"],
904 draft_sampled,
905 pos,
906 inputs["cu_num_logits"],
907 inputs["idx_mapping"],
908 None,
909 inputs["expanded_idx_mapping"],
910 inputs["expanded_local_pos"],
911 None,
912 draft_sampled,
913 )
914 return sampled, num_sampled
915
916 sampled, num_sampled = verify()
917 assert (num_sampled == 1).all()
918 # Resampled from the target at row 0, not from a residual.
919 assert (sampled[:, 0] != 0).all()
920
921 # Once past the prefill, block verification accepts the same zero drafts.
922 prefill_len.copy_(first_pos)
923 _, num_sampled = verify()
924 assert (num_sampled == K + 1).all()
925
926
927 @pytest.mark.parametrize(
928 "num_speculative_steps,temperature",
929 [
930 (1, 0.6),
931 (3, 0.6),
932 (1, 1.0),
933 (3, 1.0),
934 (5, 1.0),
935 ],
936 )
937 @pytest.mark.parametrize("has_draft_logits", [True, False])
938 def test_block_verification_rejection_sample(
939 num_speculative_steps: int, temperature: float, has_draft_logits: bool
940 ):
941 """Verify that block verification (Sun et al.) preserves the target
942 distribution at every accepted position, for both the full draft-logits
943 case and the one-hot (no draft logits) case. Block verification changes
944 *which* prefix is accepted, but the marginal of every output position must
945 still match the target distribution p(x).
946 """
947 torch.manual_seed(42)
948 device = "cuda"
949 num_trials = 10 * VOCAB_SIZE
950
951 target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
952 draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
953
954 if temperature > 0:
955 target_logits_1d /= temperature
956
957 inputs = _build_rejection_sample_inputs(
958 target_logits_1d,
959 draft_logits_1d,
960 num_speculative_steps,
961 temperature=temperature,
962 num_trials=num_trials,
963 )
964 if not has_draft_logits:
965 inputs["draft_logits"] = None
966
967 sampled, num_sampled = rejection_sample(
968 **inputs,
969 num_speculative_steps=num_speculative_steps,
970 use_block_verification=True,
971 )
972
973 target_probs = torch.softmax(target_logits_1d, dim=0)
974 for pos in range(num_speculative_steps + 1):
975 accepted_mask = num_sampled >= pos + 1
976 _assert_distribution_match(
977 sampled[accepted_mask, pos], target_probs, device, label=f"position {pos}"
978 )
979
980
981 @pytest.mark.parametrize("num_speculative_steps", [3, 5])
982 def test_block_verification_accepts_at_least_as_many(num_speculative_steps: int):
983 """Block verification is designed to accept at least as long a prefix as
984 token verification in expectation. Verify the mean accepted length is no
985 worse than the standard method on the same inputs.
986 """
987 torch.manual_seed(0)
988 device = "cuda"
989 num_trials = 20 * VOCAB_SIZE
990 temperature = 1.0
991
992 target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
993 # A draft close to the target gives block verification room to recover
994 # prefixes that token verification would have truncated.
995 draft_logits_1d = target_logits_1d + 0.5 * torch.randn(
996 VOCAB_SIZE, device=device, dtype=torch.float32
997 )
998
999 inputs = _build_rejection_sample_inputs(
1000 target_logits_1d,
1001 draft_logits_1d,
1002 num_speculative_steps,
1003 temperature=temperature,
1004 num_trials=num_trials,
1005 )
1006
1007 _, num_sampled_standard = rejection_sample(
1008 **inputs, num_speculative_steps=num_speculative_steps
1009 )
1010 _, num_sampled_block = rejection_sample(
1011 **inputs,
1012 num_speculative_steps=num_speculative_steps,
1013 use_block_verification=True,
1014 )
1015
1016 mean_standard = (num_sampled_standard - 1).float().mean().item()
1017 mean_block = (num_sampled_block - 1).float().mean().item()
1018 # Allow a small slack for sampling noise.
1019 assert mean_block >= mean_standard - 1e-2, (
1020 f"Block verification mean accepted length {mean_block:.4f} is worse "
1021 f"than standard {mean_standard:.4f}."
1022 )
1023
1024
1025 @pytest.mark.parametrize("has_draft_logits", [True, False])
1026 def test_chunked_requests_match_full_batch(has_draft_logits: bool):
1027 torch.manual_seed(7)
1028 device = "cuda"
1029 num_reqs = 5
1030 num_speculative_steps = 3
1031 vocab_size = 257
1032
1033 target_logits = torch.randn(vocab_size, device=device)
1034 draft_logits = torch.randn(vocab_size, device=device)
1035 inputs = _build_rejection_sample_inputs(
1036 target_logits,
1037 draft_logits,
1038 num_speculative_steps,
1039 temperature=0.6,
1040 num_trials=num_reqs,
1041 )
1042 padded_target_logits = torch.empty(
1043 inputs["target_logits"].shape[0], vocab_size + 3, device=device
1044 )
1045 padded_target_logits[:, :vocab_size].copy_(inputs["target_logits"])
1046 inputs["target_logits"] = padded_target_logits[:, :vocab_size]
1047 assert inputs["target_logits"].stride(-1) == 1
1048 assert not inputs["target_logits"].is_contiguous()
1049 if not has_draft_logits:
1050 inputs["draft_logits"] = None
1051
1052 sampled, num_sampled = rejection_sample(
1053 **inputs, num_speculative_steps=num_speculative_steps
1054 )
1055
1056 sampled_chunks = []
1057 num_sampled_chunks = []
1058 for start, end in ((0, 2), (2, 5)):
1059 lo = start * (num_speculative_steps + 1)
1060 hi = end * (num_speculative_steps + 1)
1061 chunk_inputs = dict(inputs)
1062 for name in (
1063 "target_logits",
1064 "draft_sampled",
1065 "pos",
1066 "expanded_idx_mapping",
1067 "expanded_local_pos",
1068 ):
1069 chunk_inputs[name] = inputs[name][lo:hi]
1070 chunk_inputs["cu_num_logits"] = inputs["cu_num_logits"][start : end + 1] - lo
1071 chunk_inputs["idx_mapping"] = inputs["idx_mapping"][start:end]
1072
1073 chunk_sampled, chunk_num_sampled = rejection_sample(
1074 **chunk_inputs, num_speculative_steps=num_speculative_steps
1075 )
1076 sampled_chunks.append(chunk_sampled)
1077 num_sampled_chunks.append(chunk_num_sampled)
1078
1079 chunked_sampled = torch.cat(sampled_chunks)
1080 chunked_num_sampled = torch.cat(num_sampled_chunks)
1081 assert torch.equal(chunked_num_sampled, num_sampled)
1082 steps = torch.arange(num_speculative_steps + 1, device=device)
1083 valid = steps.unsqueeze(0) < num_sampled.unsqueeze(1)
1084 assert torch.equal(chunked_sampled[valid], sampled[valid])
1085
1086
1087 def _residual_recovery_logits(
1088 inputs: dict,
1089 num_sampled: torch.Tensor,
1090 vocab_size: int,
1091 ) -> tuple[torch.Tensor, torch.Tensor]:
1092 target_logits = inputs["target_logits"]
1093 draft_logits = inputs["draft_logits"]
1094 cu_num_logits = inputs["cu_num_logits"]
1095 resample_rows = cu_num_logits[:-1].to(torch.int64) + num_sampled.to(torch.int64) - 1
1096 req_state_indices = inputs["expanded_idx_mapping"][resample_rows].to(torch.int64)
1097 temperatures = inputs["temperature"][req_state_indices]
1098
1099 is_bonus = resample_rows == cu_num_logits[1:] - 1
1100 next_rows = (resample_rows + 1).clamp_max(inputs["draft_sampled"].shape[0] - 1)
1101 needs_residual = ~is_bonus & (inputs["draft_sampled"][next_rows] >= 0)
1102
1103 target_rows = target_logits[resample_rows][:, :vocab_size].float()
1104 target_rows = torch.where(target_rows.isnan(), float("-inf"), target_rows)
1105 if draft_logits is None:
1106 rejected = inputs["draft_sampled"][next_rows].clamp_min(0)
1107 one_hot = torch.arange(vocab_size, device=target_rows.device).unsqueeze(
1108 0
1109 ) == rejected.unsqueeze(-1)
1110 residual_logits = torch.where(one_hot, float("-inf"), target_rows)
1111 else:
1112 draft_steps = (num_sampled.to(torch.int64) - 1).clamp_max(
1113 draft_logits.shape[1] - 1
1114 )
1115 draft_rows = draft_logits[req_state_indices, draft_steps][
1116 :, :vocab_size
1117 ].float()
1118 draft_rows = torch.where(draft_rows.isnan(), float("-inf"), draft_rows)
1119 draft_rows = draft_rows / temperatures.unsqueeze(-1)
1120 target_log_probs = torch.log_softmax(target_rows, dim=-1)
1121 draft_log_probs = torch.log_softmax(draft_rows, dim=-1)
1122 ratio = torch.exp(draft_log_probs - target_log_probs)
1123 residual_logits = torch.where(
1124 ratio < 1,
1125 target_log_probs + torch.log1p(-ratio.clamp_max(1)),
1126 float("-inf"),
1127 )
1128 recovery = torch.where(needs_residual.unsqueeze(-1), residual_logits, target_rows)
1129 return recovery, resample_rows
1130
1131
1132 def _watermark_inputs(
1133 num_trials: int,
1134 num_speculative_steps: int,
1135 context_width: int,
1136 vocab_size: int,
1137 one_hot_draft: bool,
1138 temperature: float = 1.0,
1139 ) -> tuple[dict, torch.Tensor]:
1140 device = "cuda"
1141 target_logits_1d = torch.randn(vocab_size, device=device) / temperature
1142 draft_logits_1d = torch.randn(vocab_size, device=device)
1143 inputs = _build_rejection_sample_inputs(
1144 target_logits_1d,
1145 draft_logits_1d,
1146 num_speculative_steps,
1147 temperature=temperature,
1148 num_trials=num_trials,
1149 )
1150 if one_hot_draft:
1151 inputs["draft_logits"] = None
1152 inputs["draft_sampled"].view(num_trials, num_speculative_steps + 1)[::5, 1] = -1
1153 num_logits = inputs["target_logits"].shape[0]
1154 contexts = torch.randint(
1155 0, vocab_size, (num_logits, context_width), dtype=torch.int64, device=device
1156 )
1157 contexts[::7, 0] = -1
1158 return inputs, contexts
1159
1160
1161 @pytest.mark.parametrize("one_hot_draft", [False, True])
1162 # Cover one full block and a partial multi-block vocabulary.
1163 @pytest.mark.parametrize("vocab_size", [1024, 3500])
1164 def test_watermarked_recovery_matches_philox_gumbel_sample(
1165 one_hot_draft: bool, vocab_size: int
1166 ):
1167 torch.manual_seed(7)
1168 key = 0xDA39A3EE5E6B4B0D
1169 context_width = 4
1170 inputs, contexts = _watermark_inputs(
1171 num_trials=128,
1172 num_speculative_steps=2,
1173 context_width=context_width,
1174 vocab_size=vocab_size,
1175 one_hot_draft=one_hot_draft,
1176 )
1177 watermarker = GumbelWatermarker(key=key, context_width=context_width)
1178
1179 sampled, num_sampled = watermarked_rejection_sample(
1180 **inputs,
1181 num_speculative_steps=2,
1182 contexts=contexts,
1183 watermarking=torch.ones(128, dtype=torch.bool, device="cuda"),
1184 watermarker=watermarker,
1185 )
1186
1187 recovery, resample_rows = _residual_recovery_logits(inputs, num_sampled, vocab_size)
1188 expected = philox_gumbel_sample(recovery, contexts[resample_rows], key)
1189 recovered = sampled[torch.arange(128, device="cuda"), num_sampled.long() - 1]
1190 torch.testing.assert_close(recovered, expected, rtol=0, atol=0)
1191
1192 is_bonus = resample_rows == inputs["cu_num_logits"][1:] - 1
1193 next_rows = (resample_rows + 1).clamp_max(inputs["draft_sampled"].shape[0] - 1)
1194 is_placeholder = ~is_bonus & (inputs["draft_sampled"][next_rows] < 0)
1195 assert int((~is_bonus & ~is_placeholder).sum()) > 0
1196 assert int(is_placeholder.sum()) > 0
1197 if not one_hot_draft:
1198 # One-hot drafts almost never reach the bonus row.
1199 assert int(is_bonus.sum()) > 0
1200
1201
1202 def test_watermarked_recovery_leaves_disabled_rows_on_the_stock_draw():
1203 torch.manual_seed(11)
1204 num_trials = 96
1205 vocab_size = 512
1206 inputs, contexts = _watermark_inputs(
1207 num_trials=num_trials,
1208 num_speculative_steps=3,
1209 context_width=2,
1210 vocab_size=vocab_size,
1211 one_hot_draft=False,
1212 )
1213 watermarking = torch.zeros(num_trials, dtype=torch.bool, device="cuda")
1214 watermarking[::2] = True
1215 inputs["temperature"][:8] = 0.0
1216
1217 stock_sampled, stock_num_sampled = rejection_sample(
1218 **inputs, num_speculative_steps=3
1219 )
1220 sampled, num_sampled = watermarked_rejection_sample(
1221 **inputs,
1222 num_speculative_steps=3,
1223 contexts=contexts,
1224 watermarking=watermarking,
1225 watermarker=GumbelWatermarker(key=99, context_width=2),
1226 )
1227
1228 torch.testing.assert_close(num_sampled, stock_num_sampled, rtol=0, atol=0)
1229 rows = torch.arange(num_trials, device="cuda")
1230 positions = num_sampled.long() - 1
1231 disabled = ~watermarking | (inputs["temperature"] == 0)
1232 torch.testing.assert_close(
1233 sampled[rows[disabled], positions[disabled]],
1234 stock_sampled[rows[disabled], positions[disabled]],
1235 rtol=0,
1236 atol=0,
1237 )
1238 for req in range(num_trials):
1239 n = int(num_sampled[req]) - 1
1240 torch.testing.assert_close(
1241 sampled[req, :n], stock_sampled[req, :n], rtol=0, atol=0
1242 )
1243 assert int(disabled.sum()) > 0 and int((~disabled).sum()) > 0
1244
1245
1246 def test_watermarked_recovery_is_unchanged_by_fp64_gumbel():
1247 torch.manual_seed(13)
1248 inputs, contexts = _watermark_inputs(
1249 num_trials=64,
1250 num_speculative_steps=2,
1251 context_width=4,
1252 vocab_size=2048,
1253 one_hot_draft=False,
1254 )
1255 watermarker = GumbelWatermarker(key=2**63 + 12345, context_width=4)
1256 kwargs = dict(
1257 num_speculative_steps=2,
1258 contexts=contexts,
1259 watermarking=torch.ones(64, dtype=torch.bool, device="cuda"),
1260 watermarker=watermarker,
1261 )
1262 sampled32, num_sampled32 = watermarked_rejection_sample(**inputs, **kwargs)
1263 sampled64, num_sampled64 = watermarked_rejection_sample(
1264 **inputs, **kwargs, use_fp64=True
1265 )
1266 rows = torch.arange(64, device="cuda")
1267 torch.testing.assert_close(num_sampled64, num_sampled32, rtol=0, atol=0)
1268 torch.testing.assert_close(
1269 sampled64[rows, num_sampled64.long() - 1],
1270 sampled32[rows, num_sampled32.long() - 1],
1271 rtol=0,
1272 atol=0,
1273 )