[Model Runner V2] Fix -1 placeholder draft token ids in rejection sam… (#50939)
Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai> Co-authored-by: Nick Hill <nickhill123@gmail.com>
Giancarlo Delfin committed
Aug 6, 2026 at 16:32 UTC
27930df9c2bd14047be35ff2a986ca72fc65631a
2 files changed
+229
-74
tests/v1/spec_decode/test_rejection_sampler_utils.py
+122
@@ -367,6 +367,128 @@ def test_placeholder_draft_token_rejected():
367
assert (recovered >= 0).all() and (recovered < VOCAB_SIZE).all()
368
369
370
+@pytest.mark.parametrize("has_draft_logits", [True, False])
371
+@pytest.mark.parametrize("num_placeholders", [1, 2])
372
+def test_block_verification_placeholder_truncates_block(
373
+ has_draft_logits: bool, num_placeholders: int
374
+):
375
+ """Trailing placeholder (-1) draft ids end the block early. The last real
376
+ draft token must then be verified against the closed-form threshold rather
377
+ than a residual mass derived from the placeholder position, whose logits
378
+ describe a token that was never drafted.
379
+ """
380
+ torch.manual_seed(42)
381
+ device = "cuda"
382
+ num_trials = 20 * VOCAB_SIZE
383
+ K = 3
384
+ temperature = 1.0
385
+
386
+ target_logits_1d = torch.randn(VOCAB_SIZE, device=device) / temperature
387
+ draft_logits_1d = torch.randn(VOCAB_SIZE, device=device)
388
+
389
+ inputs = _build_rejection_sample_inputs(
390
+ target_logits_1d,
391
+ draft_logits_1d,
392
+ K,
393
+ temperature=temperature,
394
+ num_trials=num_trials,
395
+ )
396
+ if not has_draft_logits:
397
+ inputs["draft_logits"] = None
398
+ inputs["draft_sampled"].view(num_trials, K + 1)[:, K + 1 - num_placeholders :] = -1
399
+
400
+ sampled, num_sampled = rejection_sample(
401
+ **inputs, num_speculative_steps=K, use_block_verification=True
402
+ )
403
+
404
+ num_real = K - num_placeholders
405
+ assert (num_sampled <= num_real + 1).all(), (
406
+ "Accepted a draft token at or after a placeholder."
407
+ )
408
+ target_probs = torch.softmax(target_logits_1d, dim=0)
409
+ for pos in range(num_real + 1):
410
+ accepted_mask = num_sampled >= pos + 1
411
+ _assert_distribution_match(
412
+ sampled[accepted_mask, pos], target_probs, device, label=f"position {pos}"
413
+ )
414
+
415
+
416
+def test_greedy_placeholder_emits_target_argmax():
417
+ """Greedy sampling skips resampling and relies on the rejection kernel
418
+ storing the target argmax at the rejected position. A placeholder must not
419
+ bypass that store, or the output slot is returned uninitialized.
420
+ """
421
+ torch.manual_seed(0)
422
+ device = "cuda"
423
+ num_trials = 512
424
+ K = 3
425
+
426
+ target_logits_1d = torch.randn(VOCAB_SIZE, device=device)
427
+ draft_logits_1d = torch.randn(VOCAB_SIZE, device=device)
428
+
429
+ inputs = _build_rejection_sample_inputs(
430
+ target_logits_1d,
431
+ draft_logits_1d,
432
+ K,
433
+ temperature=0.0,
434
+ num_trials=num_trials,
435
+ )
436
+ target_argmax = int(target_logits_1d.argmax())
437
+ draft_sampled = inputs["draft_sampled"].view(num_trials, K + 1)
438
+ # Accept the first draft so that the rejection lands on the placeholder.
439
+ draft_sampled[:, 1] = target_argmax
440
+ draft_sampled[:, 2:] = -1
441
+
442
+ sampled, num_sampled = rejection_sample(**inputs, num_speculative_steps=K)
443
+
444
+ assert torch.equal(num_sampled, torch.full_like(num_sampled, 2))
445
+ steps = torch.arange(K + 1, device=device).unsqueeze(0)
446
+ emitted = sampled[steps < num_sampled.unsqueeze(1)]
447
+ assert (emitted == target_argmax).all(), (
448
+ "Greedy sampling emitted a token that is not the target argmax"
449
+ )
450
+
451
+
452
+@pytest.mark.parametrize("use_block_verification", [False, True])
453
+def test_placeholder_blocks_later_draft_tokens(use_block_verification: bool):
454
+ """A placeholder is not necessarily the final draft. Nothing at or after
455
+ one may be accepted, even when a valid draft follows it.
456
+ """
457
+ torch.manual_seed(0)
458
+ device = "cuda"
459
+ num_trials = 4 * VOCAB_SIZE
460
+ K = 3
461
+ temperature = 1.0
462
+
463
+ # An identical draft is accepted with probability ~1, so any token after
464
+ # the placeholder would be accepted unless it is explicitly blocked. The
465
+ # draft logits are stored pre-temperature, so pass the unscaled base.
466
+ draft_logits_1d = torch.randn(VOCAB_SIZE, device=device)
467
+ target_logits_1d = draft_logits_1d / temperature
468
+
469
+ inputs = _build_rejection_sample_inputs(
470
+ target_logits_1d,
471
+ draft_logits_1d,
472
+ K,
473
+ temperature=temperature,
474
+ num_trials=num_trials,
475
+ )
476
+ inputs["draft_sampled"].view(num_trials, K + 1)[:, 2] = -1
477
+
478
+ _, num_sampled = rejection_sample(
479
+ **inputs,
480
+ num_speculative_steps=K,
481
+ use_block_verification=use_block_verification,
482
+ )
483
+
484
+ # Only the first draft is verifiable, plus one resampled token.
485
+ assert (num_sampled <= 2).all(), "Accepted a draft token past a placeholder."
486
+ assert (num_sampled == 2).float().mean().item() > 0.9, (
487
+ "The first draft was rarely accepted; the test is not exercising "
488
+ "acceptance past the placeholder."
489
+ )
490
+
491
+
492
@pytest.mark.parametrize(
493
"num_speculative_steps,temperature",
494
[
vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py
+107
-74
@@ -55,7 +55,7 @@ def _compute_global_residual_mass(
55
target_local_max_stride,
56
target_local_sumexp_ptr,
57
target_local_sumexp_stride,
58
- draft_sampled_ptr,
58
+ draft_token,
59
logit_idx,
60
vocab_num_blocks,
61
PADDED_VOCAB_NUM_BLOCKS: tl.constexpr,
@@ -74,7 +74,6 @@ def _compute_global_residual_mass(
74
# One-hot draft. M_s is a point mass at this draft token
75
# so the residual mass reduces to the closed form:
76
# p * (1 - M_b(draft_token)).
77
- draft_token = tl.load(draft_sampled_ptr + logit_idx + 1).to(tl.int64)
77
target_lse = _compute_global_logsumexp(
78
target_local_max_ptr,
79
target_local_max_stride,
@@ -347,35 +346,40 @@ def _compute_cumulative_log_p_kernel(
346
if temp == 0.0:
347
return
348
350
- log_p = 0.0
349
+ log_p = tl.zeros((), tl.float32)
350
for step in range(num_draft_tokens):
351
logit_idx = start_idx + step
352
draft_token = tl.load(draft_sampled_ptr + logit_idx + 1).to(tl.int64)
354
- target_logprob, draft_logprob, _, _ = _compute_global_logprobs_and_logsumexp(
355
- draft_token,
356
- True, # mask
357
- logit_idx,
358
- req_state_idx,
359
- step,
360
- temp,
361
- target_logits_ptr,
362
- target_logits_stride,
363
- target_local_max_ptr,
364
- target_local_max_stride,
365
- target_local_sumexp_ptr,
366
- target_local_sumexp_stride,
367
- draft_logits_ptr,
368
- draft_logits_stride_0,
369
- draft_logits_stride_1,
370
- draft_local_max_ptr,
371
- draft_local_max_stride,
372
- draft_local_sumexp_ptr,
373
- draft_local_sumexp_stride,
374
- vocab_num_blocks,
375
- PADDED_VOCAB_NUM_BLOCKS,
376
- HAS_DRAFT_LOGITS,
377
- )
378
- log_p = tl.minimum(log_p + (target_logprob - draft_logprob), 0.0)
353
+ # -1 placeholder tokens can never be accepted. Skip their reductions
354
+ # and carry the last valid cumulative value.
355
+ if draft_token >= 0:
356
+ target_logprob, draft_logprob, _, _ = (
357
+ _compute_global_logprobs_and_logsumexp(
358
+ draft_token,
359
+ True, # mask
360
+ logit_idx,
361
+ req_state_idx,
362
+ step,
363
+ temp,
364
+ target_logits_ptr,
365
+ target_logits_stride,
366
+ target_local_max_ptr,
367
+ target_local_max_stride,
368
+ target_local_sumexp_ptr,
369
+ target_local_sumexp_stride,
370
+ draft_logits_ptr,
371
+ draft_logits_stride_0,
372
+ draft_logits_stride_1,
373
+ draft_local_max_ptr,
374
+ draft_local_max_stride,
375
+ draft_local_sumexp_ptr,
376
+ draft_local_sumexp_stride,
377
+ vocab_num_blocks,
378
+ PADDED_VOCAB_NUM_BLOCKS,
379
+ HAS_DRAFT_LOGITS,
380
+ )
381
+ )
382
+ log_p = tl.minimum(log_p + (target_logprob - draft_logprob), 0.0)
383
tl.store(cumulative_log_p_ptr + logit_idx, log_p)
384
385
@@ -406,6 +410,8 @@ def _compute_local_residual_mass_kernel(
410
draft_local_sumexp_ptr,
411
draft_local_sumexp_stride,
412
# [num_logits]
413
+ draft_sampled_ptr,
414
+ # [num_logits]
415
expanded_idx_mapping_ptr,
416
# [num_logits]
417
expanded_local_pos_ptr,
@@ -425,6 +431,11 @@ def _compute_local_residual_mass_kernel(
431
# first and last (bonus) positions aren't needed for this computation.
432
return
433
434
+ if tl.load(draft_sampled_ptr + logit_idx + 1) < 0:
435
+ # -1 placeholder token. The rejection kernel treats the preceding token
436
+ # as the end of the block, so this position's residual mass is unused.
437
+ return
438
+
439
req_state_idx = tl.load(expanded_idx_mapping_ptr + logit_idx).to(tl.int64)
440
temp = tl.load(temp_ptr + req_state_idx).to(tl.float32)
441
if temp == 0.0:
@@ -539,41 +550,23 @@ def _rejection_kernel(
550
accepted_length = tl.zeros((), tl.int64)
551
target_lse = 0.0
552
draft_lse = 0.0
542
- accepted = True
553
+ verifying = True
554
for i in range(num_draft_tokens):
555
logit_idx = start_idx + i
556
draft_sampled = tl.load(draft_sampled_ptr + logit_idx + 1).to(tl.int64)
546
- pos = tl.load(pos_ptr + logit_idx)
547
- u = tl_rand32(seed, pos, includes_zero=False)
548
- if USE_BLOCK_VERIFICATION and not is_greedy:
549
- # Block verification (Sun et al., 2024): https://arxiv.org/abs/2403.10444
550
- prefix_joint_ratio = tl.exp(
551
- tl.load(cumulative_log_p_ptr + logit_idx).to(tl.float32)
552
- )
553
- if i < num_draft_tokens - 1:
554
- residual_mass = _compute_global_residual_mass(
555
- local_residual_mass_ptr,
556
- local_residual_mass_stride,
557
- prefix_joint_ratio,
558
- target_logits_ptr,
559
- target_logits_stride,
560
- target_local_max_ptr,
561
- target_local_max_stride,
562
- target_local_sumexp_ptr,
563
- target_local_sumexp_stride,
564
- draft_sampled_ptr,
565
- logit_idx + 1,
566
- vocab_num_blocks,
567
- PADDED_VOCAB_NUM_BLOCKS,
568
- HAS_DRAFT_LOGITS,
569
- )
570
- denom = residual_mass + 1.0 - prefix_joint_ratio
571
- h = tl.where(denom > 0.0, residual_mass / denom, 1.0)
572
- else:
573
- h = prefix_joint_ratio
574
- accepted_length = tl.where(u <= h, i + 1, accepted_length)
575
- tl.store(sampled_ptr + req_idx * sampled_stride + i, draft_sampled)
576
- elif accepted:
557
+ # -1 is used for placeholder draft token ids that should be rejected.
558
+ is_valid_draft = draft_sampled >= 0
559
+ # Avoid possible OOB ptr access.
560
+ draft_sampled = tl.maximum(0, draft_sampled)
561
+ if not is_greedy:
562
+ # A -1 placeholder ends verification. Greedy is excluded because it
563
+ # stores the target argmax upon first rejection, so it rejects the
564
+ # placeholder via `accepted` instead.
565
+ verifying &= is_valid_draft
566
+
567
+ if verifying:
568
+ pos = tl.load(pos_ptr + logit_idx)
569
+ u = tl_rand32(seed, pos, includes_zero=False)
570
if is_greedy:
571
# Greedy sampling. Accept IFF draft matches target argmax.
572
# NOTE: Target argmax is stored directly so that resampling
@@ -589,20 +582,51 @@ def _rejection_kernel(
582
)
583
if SYNTHETIC_MODE:
584
rate = tl.load(synthetic_conditional_rates_ptr + i)
592
- # -1 is used for padded draft token ids that should be rejected.
593
- accepted &= (u < rate) & (draft_sampled >= 0)
585
+ accepted = u < rate
586
else:
595
- accepted &= target_argmax == draft_sampled
587
+ accepted = target_argmax == draft_sampled
588
+ accepted &= is_valid_draft
589
+ verifying = accepted
590
+ accepted_length += accepted
591
tl.store(
592
sampled_ptr + req_idx * sampled_stride + i,
593
draft_sampled if accepted else target_argmax,
594
)
595
+ elif USE_BLOCK_VERIFICATION:
596
+ # Block verification (Sun et al., 2024): https://arxiv.org/abs/2403.10444
597
+ prefix_joint_ratio = tl.exp(
598
+ tl.load(cumulative_log_p_ptr + logit_idx).to(tl.float32)
599
+ )
600
+ next_draft_token = tl.load(
601
+ draft_sampled_ptr + logit_idx + 2,
602
+ mask=i < num_draft_tokens - 1,
603
+ other=-1,
604
+ ).to(tl.int64)
605
+ if next_draft_token >= 0:
606
+ residual_mass = _compute_global_residual_mass(
607
+ local_residual_mass_ptr,
608
+ local_residual_mass_stride,
609
+ prefix_joint_ratio,
610
+ target_logits_ptr,
611
+ target_logits_stride,
612
+ target_local_max_ptr,
613
+ target_local_max_stride,
614
+ target_local_sumexp_ptr,
615
+ target_local_sumexp_stride,
616
+ next_draft_token,
617
+ logit_idx + 1,
618
+ vocab_num_blocks,
619
+ PADDED_VOCAB_NUM_BLOCKS,
620
+ HAS_DRAFT_LOGITS,
621
+ )
622
+ denom = residual_mass + 1.0 - prefix_joint_ratio
623
+ h = tl.where(denom > 0.0, residual_mass / denom, 1.0)
624
+ else:
625
+ h = prefix_joint_ratio
626
+ accepted_length = tl.where(u <= h, i + 1, accepted_length)
627
+ tl.store(sampled_ptr + req_idx * sampled_stride + i, draft_sampled)
628
else:
629
# Speculative decoding (Leviathan et al., 2023): https://arxiv.org/abs/2211.17192
602
- # -1 is used for padded draft token ids that should be rejected.
603
- is_valid_draft = draft_sampled >= 0
604
- # Avoid possible OOB ptr access.
605
- draft_sampled = tl.maximum(0, draft_sampled)
630
target_logprob, draft_logprob, target_lse, draft_lse = (
631
_compute_global_logprobs_and_logsumexp(
632
draft_sampled,
@@ -631,14 +655,15 @@ def _rejection_kernel(
655
)
656
if SYNTHETIC_MODE:
657
rate = tl.load(synthetic_conditional_rates_ptr + i)
634
- accepted &= u < rate
658
+ accepted = u < rate
659
else:
660
# Probability ratio test: p(x) > u * q(x)
661
# Equivalent log form: log_p(x) > log(u) + log_q(x)
638
- accepted &= target_logprob > tl.log(u) + draft_logprob
639
- accepted &= is_valid_draft
662
+ accepted = target_logprob > tl.log(u) + draft_logprob
663
+ verifying = accepted
664
+ accepted_length += accepted
665
tl.store(sampled_ptr + req_idx * sampled_stride + i, draft_sampled)
641
- accepted_length += accepted
666
+
667
tl.store(rejected_steps_ptr + req_idx, accepted_length)
668
if USE_BLOCK_VERIFICATION and not is_greedy and accepted_length < num_draft_tokens:
669
# Compute the target and draft log exponential sums for the
@@ -722,6 +747,13 @@ def _resample_kernel(
747
# the target argmax is already in the sampled tensor.
748
return
749
750
+ rejected_draft_token = tl.load(
751
+ draft_sampled_ptr + resample_token_idx + 1,
752
+ mask=not is_bonus,
753
+ other=0,
754
+ )
755
+ is_valid_rejected_draft = rejected_draft_token >= 0
756
+
757
block_idx = tl.program_id(1)
758
block = block_idx * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
759
mask = block < vocab_size
@@ -732,8 +764,9 @@ def _resample_kernel(
764
).to(tl.float32)
765
766
# Compute the residual logits to resample the rejected token from.
735
- if is_bonus:
736
- # Bonus token (no rejections). Directly use the target logits.
767
+ if is_bonus or not is_valid_rejected_draft:
768
+ # Bonus token (no rejections) or -1 placeholder token. In either case,
769
+ # directly use the target logits.
770
residual_logits = target_logits
771
elif HAS_DRAFT_LOGITS:
772
# draft_logits is stored pre-temperature, so apply scale first.
@@ -783,7 +816,6 @@ def _resample_kernel(
816
# p_tau * M_b(x) / Z otherwise
817
# Therefore p_tau is a constant that cancels under normalization,
818
# and does not need to be applied.
786
- rejected_draft_token = tl.load(draft_sampled_ptr + resample_token_idx + 1)
819
residual_logits = tl.where(
820
block != rejected_draft_token,
821
target_logits,
@@ -1036,6 +1068,7 @@ def rejection_sample(
1068
draft_local_max.stride(0),
1069
draft_local_sumexp,
1070
draft_local_sumexp.stride(0),
1071
+ draft_sampled,
1072
expanded_idx_mapping,
1073
expanded_local_pos,
1074
temperature,