A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 8, 20:15 UTC
[Spec Decode] Unified Parallel Drafting (#32887)
Signed-off-by: Benjamin Chislett <bchislett@nvidia.com>
Benjamin Chislett committed
Feb 5, 2026 at 12:37 UTC
af3162d3aaa559a738396baf5b5134c1ab0742f5
14 files changed
+1085
-392
examples/offline_inference/spec_decode.py
+3
@@ -75,6 +75,7 @@ def parse_args():
75
parser.add_argument("--gpu-memory-utilization", type=float, default=0.9)
76
parser.add_argument("--disable-padded-drafter-batch", action="store_true")
77
parser.add_argument("--max-num-seqs", type=int, default=None)
78
+ parser.add_argument("--parallel-drafting", action="store_true")
79
parser.add_argument("--allowed-local-media-path", type=str, default="")
80
return parser.parse_args()
81
@@ -121,6 +122,7 @@ def main(args):
122
"model": eagle_dir,
123
"num_speculative_tokens": args.num_spec_tokens,
124
"disable_padded_drafter_batch": args.disable_padded_drafter_batch,
125
+ "parallel_drafting": args.parallel_drafting,
126
}
127
elif args.method == "ngram":
128
speculative_config = {
@@ -137,6 +139,7 @@ def main(args):
139
"num_speculative_tokens": args.num_spec_tokens,
140
"enforce_eager": args.enforce_eager,
141
"max_model_len": args.max_model_len,
142
+ "parallel_drafting": args.parallel_drafting,
143
}
144
elif args.method == "mtp":
145
speculative_config = {
tests/v1/e2e/test_spec_decode.py
+32
-63
@@ -13,15 +13,12 @@ from vllm import LLM, SamplingParams
13
from vllm.assets.base import VLLM_S3_BUCKET_URL
14
from vllm.assets.image import VLM_IMAGES_DIR
15
from vllm.benchmarks.datasets import InstructCoderDataset
16
-from vllm.config.vllm import VllmConfig
16
+from vllm.config import VllmConfig
17
from vllm.distributed import cleanup_dist_env_and_memory
18
from vllm.engine.arg_utils import EngineArgs
19
from vllm.platforms import current_platform
20
from vllm.v1.metrics.reader import Metric
21
-from vllm.v1.spec_decode.draft_model import (
22
- create_vllm_config_for_draft_model,
23
- merge_toks_kernel,
24
-)
21
+from vllm.v1.spec_decode.utils import create_vllm_config_for_draft_model
22
23
MTP_SIMILARITY_RATE = 0.8
24
@@ -625,6 +622,8 @@ class ArgsTest:
622
expected_acceptance_rate: float
623
expected_acceptance_len: float
624
# Defaults
625
+ enforce_eager: bool = True
626
+ parallel_drafting: bool = False
627
target_tensor_parallel_size: int = 1
628
draft_tensor_parallel_size: int = 1
629
max_model_len: int = 1024
@@ -658,7 +657,8 @@ cases = [
657
@pytest.mark.parametrize("args", cases)
658
@pytest.mark.parametrize("enforce_eager", [True, False])
659
def test_draft_model_correctness(args: ArgsTest, enforce_eager: bool):
661
- assert_draft_model_correctness(args, enforce_eager)
660
+ args.enforce_eager = enforce_eager
661
+ assert_draft_model_correctness(args)
662
663
664
def test_draft_model_realistic_example():
@@ -668,11 +668,28 @@ def test_draft_model_realistic_example():
668
dataset="likaixin/InstructCoder",
669
num_speculative_tokens=3,
670
sampling_config=greedy_sampling(),
671
+ enforce_eager=False,
672
# values below are not derived, but just prevent a regression
673
expected_acceptance_len=2.8,
674
expected_acceptance_rate=0.55,
675
)
675
- assert_draft_model_correctness(args, enforce_eager=False)
676
+ assert_draft_model_correctness(args)
677
+
678
+
679
+def test_draft_model_parallel_drafting():
680
+ args = ArgsTest(
681
+ target_model="Qwen/Qwen3-1.7B",
682
+ draft_model="amd/PARD-Qwen3-0.6B",
683
+ dataset="likaixin/InstructCoder",
684
+ num_speculative_tokens=3,
685
+ sampling_config=greedy_sampling(),
686
+ parallel_drafting=True,
687
+ enforce_eager=False,
688
+ # values below are collected from a stable run, with ~5% tolerance
689
+ expected_acceptance_len=2.375,
690
+ expected_acceptance_rate=0.45,
691
+ )
692
+ assert_draft_model_correctness(args)
693
694
695
@pytest.mark.parametrize(
@@ -691,8 +708,9 @@ def test_draft_model_quantization(models: tuple[str, str], enforce_eager: bool):
708
target_model=tgt_model,
709
draft_model=draft_model,
710
**some_high_acceptance_metrics(),
711
+ enforce_eager=enforce_eager,
712
)
695
- assert_draft_model_correctness(sd_case, enforce_eager)
713
+ assert_draft_model_correctness(sd_case)
714
715
716
def test_draft_model_tensor_parallelism():
@@ -704,8 +722,9 @@ def test_draft_model_tensor_parallelism():
722
draft_model="Qwen/Qwen3-0.6B",
723
draft_tensor_parallel_size=2,
724
**some_high_acceptance_metrics(),
725
+ enforce_eager=False,
726
)
708
- assert_draft_model_correctness(sd_case, enforce_eager=False)
727
+ assert_draft_model_correctness(sd_case)
728
729
730
def test_draft_model_engine_args_tensor_parallelism():
@@ -750,7 +769,7 @@ def test_draft_model_engine_args_rejects_invalid_tp_argname():
769
engine_args.create_engine_config()
770
771
753
-def assert_draft_model_correctness(args: ArgsTest, enforce_eager: bool):
772
+def assert_draft_model_correctness(args: ArgsTest):
773
"""Compare the outputs using and not using speculative decoding.
774
In the greedy decoding case, the outputs must match EXACTLY."""
775
test_prompts: list[Messages] = get_messages(
@@ -764,14 +783,15 @@ def assert_draft_model_correctness(args: ArgsTest, enforce_eager: bool):
783
"method": "draft_model",
784
"num_speculative_tokens": args.num_speculative_tokens,
785
"max_model_len": args.max_model_len,
767
- "enforce_eager": enforce_eager,
786
+ "enforce_eager": args.enforce_eager,
787
"draft_tensor_parallel_size": args.draft_tensor_parallel_size,
788
+ "parallel_drafting": args.parallel_drafting,
789
},
790
max_num_seqs=100, # limit cudagraph capture runtime
791
max_model_len=args.max_model_len,
792
gpu_memory_utilization=args.gpu_memory_utilization,
793
tensor_parallel_size=args.target_tensor_parallel_size,
774
- enforce_eager=enforce_eager,
794
+ enforce_eager=args.enforce_eager,
795
disable_log_stats=False, # enables get_metrics()
796
)
797
# we don't check the outputs, only check the metrics
@@ -813,57 +833,6 @@ def some_high_acceptance_metrics() -> dict:
833
}
834
835
816
-def test_merge_toks_kernel():
817
- device = "cuda"
818
- merged_len = 5 + 2 # len(target_toks) = 5, batch_size = 2
819
- merged = torch.full((merged_len,), -100, device=device) # -100 is arbitrary
820
- is_rejected_tok = torch.full((merged_len,), True, device=device)
821
- grid = (2,)
822
- merge_toks_kernel[grid](
823
- target_toks_ptr=torch.tensor([0, 1, 2, 0, 1], device=device),
824
- next_toks_ptr=torch.tensor([3, 2], device=device),
825
- query_start_locs_ptr=torch.tensor([0, 3], device=device),
826
- query_end_locs_ptr=torch.tensor([2, 4], device=device),
827
- out_ptr_merged_toks=merged,
828
- out_ptr_is_rejected_tok=is_rejected_tok,
829
- target_toks_size=5,
830
- rejected_tok_fill=-1,
831
- )
832
- expected_merged = torch.tensor([0, 1, 2, 3, 0, 1, 2], device=device)
833
- assert torch.allclose(merged, expected_merged)
834
-
835
- expected_rejected_toks = torch.tensor([False] * merged_len, device=device)
836
- assert torch.allclose(is_rejected_tok, expected_rejected_toks)
837
-
838
-
839
-def test_merge_toks_kernel_with_rejected_tokens():
840
- device = "cuda"
841
- merged_size = 9 + 2 # len(target_toks) = 9, batch_size = 2
842
- merged = torch.full((merged_size,), -100, device=device)
843
- is_rejected_tok = torch.full((merged_size,), True, device=device)
844
- grid = (2,)
845
- merge_toks_kernel[grid](
846
- # rejected tokens
847
- # ↓ ↓ ↓ ↓
848
- target_toks_ptr=torch.tensor([0, 1, 2, 13, 14, 15, 0, 1, 22], device=device),
849
- next_toks_ptr=torch.tensor([3, 2], device=device),
850
- query_start_locs_ptr=torch.tensor([0, 6], device=device),
851
- query_end_locs_ptr=torch.tensor([2, 7], device=device),
852
- out_ptr_merged_toks=merged,
853
- out_ptr_is_rejected_tok=is_rejected_tok,
854
- target_toks_size=9,
855
- rejected_tok_fill=-1,
856
- )
857
- expected_merged = torch.tensor([0, 1, 2, 3, -1, -1, -1, 0, 1, 2, -1], device=device)
858
- assert torch.allclose(merged, expected_merged)
859
-
860
- expected_rejected_toks = torch.tensor(
861
- [False, False, False, False, True, True, True, False, False, False, True],
862
- device=device,
863
- )
864
- assert torch.allclose(is_rejected_tok, expected_rejected_toks)
865
-
866
-
836
def compute_acceptance_rate(metrics: list[Metric]) -> float:
837
name2metric = {metric.name: metric for metric in metrics}
838
n_draft_toks = name2metric["vllm:spec_decode_num_draft_tokens"].value # type: ignore
tests/v1/spec_decode/test_eagle.py
+408
-6
@@ -27,6 +27,7 @@ from vllm.config.load import LoadConfig
27
from vllm.model_executor.models.llama import LlamaForCausalLM
28
from vllm.platforms import current_platform
29
from vllm.v1.attention.backends.registry import AttentionBackendEnum
30
+from vllm.v1.spec_decode.draft_model import DraftModelProposer
31
from vllm.v1.spec_decode.eagle import EagleProposer
32
from vllm.v1.spec_decode.metadata import SpecDecodeMetadata
33
from vllm.v1.worker.gpu_input_batch import CachedRequestState, InputBatch
@@ -34,6 +35,7 @@ from vllm.v1.worker.gpu_input_batch import CachedRequestState, InputBatch
35
model_dir = "meta-llama/Llama-3.1-8B-Instruct"
36
eagle_dir = "yuhuili/EAGLE-LLaMA3.1-Instruct-8B"
37
eagle3_dir = "yuhuili/EAGLE3-LLaMA3.1-Instruct-8B"
38
+ar_draft_model_dir = "amd/PARD-Llama-3.2-1B" # Compatible with parallel and AR drafting
39
40
41
def _create_proposer(
@@ -41,11 +43,19 @@ def _create_proposer(
43
num_speculative_tokens: int,
44
attention_backend: str | None = None,
45
speculative_token_tree: list[tuple[int, ...]] | None = None,
46
+ parallel_drafting: bool = False,
47
) -> EagleProposer:
48
model_config = ModelConfig(model=model_dir, runner="generate", max_model_len=100)
49
47
- # Choose model directory based on method
48
- draft_model_dir = eagle_dir if method == "eagle" else eagle3_dir
50
+ # Method-dependent setup
51
+ if method == "eagle":
52
+ draft_model_dir = eagle_dir
53
+ elif method == "eagle3":
54
+ draft_model_dir = eagle3_dir
55
+ elif method == "draft_model":
56
+ draft_model_dir = ar_draft_model_dir
57
+ else:
58
+ raise ValueError(f"Unknown method: {method}")
59
60
spec_token_tree_str = None
61
if speculative_token_tree is not None:
@@ -59,13 +69,18 @@ def _create_proposer(
69
method=method,
70
num_speculative_tokens=num_speculative_tokens,
71
speculative_token_tree=spec_token_tree_str,
72
+ parallel_drafting=parallel_drafting,
73
)
74
+ if parallel_drafting:
75
+ # Overwrite pard_token to avoid crash during init
76
+ speculative_config.draft_model_config.hf_config.pard_token = 0
77
78
+ device = current_platform.device_type
79
vllm_config = VllmConfig(
80
model_config=model_config,
81
cache_config=CacheConfig(),
82
speculative_config=speculative_config,
68
- device_config=DeviceConfig(device=current_platform.device_type),
83
+ device_config=DeviceConfig(device=device),
84
parallel_config=ParallelConfig(),
85
load_config=LoadConfig(),
86
scheduler_config=SchedulerConfig(
@@ -75,7 +90,10 @@ def _create_proposer(
90
attention_config=AttentionConfig(backend=attention_backend),
91
)
92
78
- return EagleProposer(vllm_config=vllm_config, device=current_platform.device_type)
93
+ if "eagle" in method:
94
+ return EagleProposer(vllm_config=vllm_config, device=device)
95
+ else:
96
+ return DraftModelProposer(vllm_config=vllm_config, device=device)
97
98
99
def test_prepare_next_token_ids():
@@ -321,6 +339,390 @@ def test_prepare_inputs_padded():
339
assert torch.equal(token_indices_to_sample, expected_token_indices_to_sample)
340
341
342
+def test_set_inputs_first_pass_default_eagle():
343
+ """
344
+ Test for set_inputs_first_pass without extra input slots (default EAGLE).
345
+
346
+ This tests the path where needs_extra_input_slots=False, which is the
347
+ default EAGLE pathway. In this case:
348
+ - Input IDs are rotated (shifted by one)
349
+ - The next_token_ids are inserted at the last position of each request
350
+ - Positions are copied as-is
351
+ - Hidden states are copied as-is
352
+ - The CommonAttentionMetadata is returned unchanged
353
+
354
+ Setup:
355
+ - 3 requests with query_lens [3, 2, 4]
356
+ - Tokens: [a1, a2, a3, b1, b2, c1, c2, c3, c4]
357
+ - After rotation: [a2, a3, -, b2, -, c2, c3, c4, -]
358
+ - After inserting next_tokens [100, 200, 300]:
359
+ [a2, a3, 100, b2, 200, c2, c3, c4, 300]
360
+ """
361
+ device = torch.device(current_platform.device_type)
362
+
363
+ num_speculative_tokens = 3
364
+ proposer = _create_proposer("eagle", num_speculative_tokens)
365
+
366
+ # Setup batch with 3 requests
367
+ batch_spec = BatchSpec(
368
+ seq_lens=[10, 8, 12], # Arbitrary context lengths
369
+ query_lens=[3, 2, 4],
370
+ )
371
+
372
+ common_attn_metadata = create_common_attn_metadata(
373
+ batch_spec,
374
+ block_size=16,
375
+ device=device,
376
+ )
377
+
378
+ # Input tensors
379
+ # Request 0: tokens [10, 11, 12] at positions [7, 8, 9]
380
+ # Request 1: tokens [20, 21] at positions [6, 7]
381
+ # Request 2: tokens [30, 31, 32, 33] at positions [8, 9, 10, 11]
382
+ target_token_ids = torch.tensor(
383
+ [10, 11, 12, 20, 21, 30, 31, 32, 33], dtype=torch.int32, device=device
384
+ )
385
+ target_positions = torch.tensor(
386
+ [7, 8, 9, 6, 7, 8, 9, 10, 11], dtype=torch.int64, device=device
387
+ )
388
+ target_hidden_states = torch.randn(
389
+ 9, proposer.hidden_size, dtype=proposer.dtype, device=device
390
+ )
391
+ next_token_ids = torch.tensor([100, 200, 300], dtype=torch.int32, device=device)
392
+
393
+ num_tokens, token_indices_to_sample, output_cad = proposer.set_inputs_first_pass(
394
+ target_token_ids=target_token_ids,
395
+ next_token_ids=next_token_ids,
396
+ target_positions=target_positions,
397
+ target_hidden_states=target_hidden_states,
398
+ token_indices_to_sample=None,
399
+ cad=common_attn_metadata,
400
+ num_rejected_tokens_gpu=None,
401
+ )
402
+
403
+ assert num_tokens == 9 # Total tokens unchanged
404
+
405
+ expected_token_indices_to_sample = torch.tensor(
406
+ [2, 4, 8], dtype=torch.int32, device=device
407
+ )
408
+ assert torch.equal(token_indices_to_sample, expected_token_indices_to_sample)
409
+
410
+ assert output_cad is common_attn_metadata
411
+
412
+ # Verify input_ids are rotated and next_tokens inserted
413
+ # Original: [10, 11, 12, 20, 21, 30, 31, 32, 33]
414
+ # After shift by 1: [11, 12, 12, 21, 21, 31, 32, 33, 33]
415
+ # After inserting at last indices [2, 4, 8]: [11, 12, 100, 21, 200, 31, 32, 33, 300]
416
+ expected_input_ids = torch.tensor(
417
+ [11, 12, 100, 21, 200, 31, 32, 33, 300], dtype=torch.int32, device=device
418
+ )
419
+ assert torch.equal(proposer.input_ids[:num_tokens], expected_input_ids)
420
+
421
+ # Verify positions are copied as-is
422
+ assert torch.equal(proposer.positions[:num_tokens], target_positions)
423
+
424
+ # Verify hidden states are copied as-is
425
+ assert torch.equal(proposer.hidden_states[:num_tokens], target_hidden_states)
426
+
427
+
428
+def test_set_inputs_first_pass_draft_model():
429
+ """
430
+ Test for set_inputs_first_pass with a draft model (extra input slots,
431
+ no shift).
432
+
433
+ This tests the path where needs_extra_input_slots=True and
434
+ shift_input_ids=False (draft model case). In this case:
435
+ - Input IDs are NOT shifted
436
+ - Each request gets extra_slots_per_request (1) new slots
437
+ - The kernel handles copying tokens and inserting bonus/padding tokens
438
+ - A new CommonAttentionMetadata is returned with updated query_start_loc
439
+
440
+ Setup:
441
+ - 2 requests
442
+ - Request 0: tokens [10, 11, 12] at positions [0, 1, 2]
443
+ - Only tokens [10, 11] are "valid" (query_end_loc=1),
444
+ token 12 is a rejected token from previous speculation
445
+ - Request 1: tokens [20, 21] at positions [0, 1], both valid.
446
+ - Note: this is less than num_speculative_tokens (2) to ensure
447
+ we handle variable lengths correctly.
448
+ - next_token_ids: [100, 200] (bonus tokens)
449
+
450
+ With extra_slots_per_request=1 and shift=False:
451
+ Expected output layout:
452
+ Request 0 (indices 0-3):
453
+ - idx 0: token 10, pos 0
454
+ - idx 1: token 11, pos 1
455
+ - idx 2: token 100, pos 2 (bonus token)
456
+ - idx 3: padding_token_id, is_rejected=True
457
+ Request 1 (indices 4-6):
458
+ - idx 4: token 20, pos 0
459
+ - idx 5: token 21, pos 1
460
+ - idx 6: token 200, pos 2 (bonus token)
461
+ """
462
+ device = torch.device(current_platform.device_type)
463
+
464
+ num_speculative_tokens = 2
465
+ block_size = 16
466
+
467
+ # Create a proposer configured as a draft model (pass_hidden_states=False)
468
+ # We need to mock this since _create_proposer defaults to EAGLE
469
+ proposer = _create_proposer("draft_model", num_speculative_tokens)
470
+
471
+ proposer.parallel_drafting_token_id = 0
472
+ proposer.is_rejected_token_mask = torch.zeros(
473
+ proposer.max_num_tokens, dtype=torch.bool, device=device
474
+ )
475
+ proposer.is_masked_token_mask = torch.zeros(
476
+ proposer.max_num_tokens, dtype=torch.bool, device=device
477
+ )
478
+
479
+ # Mock the attn_metadata_builder to avoid needing the full model setup
480
+ mock_kv_cache_spec = mock.MagicMock()
481
+ mock_kv_cache_spec.block_size = block_size
482
+ mock_builder = mock.MagicMock()
483
+ mock_builder.kv_cache_spec = mock_kv_cache_spec
484
+ proposer.attn_metadata_builder = mock_builder
485
+
486
+ # Request 0: query_len=3 (but 1 rejected), Request 1: query_len=2
487
+ batch_spec = BatchSpec(
488
+ seq_lens=[3, 2],
489
+ query_lens=[3, 2],
490
+ )
491
+
492
+ common_attn_metadata = create_common_attn_metadata(
493
+ batch_spec,
494
+ block_size=block_size,
495
+ device=device,
496
+ arange_block_indices=True, # Use predictable block indices
497
+ )
498
+
499
+ # Input tensors
500
+ target_token_ids = torch.tensor(
501
+ [10, 11, 12, 20, 21], dtype=torch.int32, device=device
502
+ )
503
+ target_positions = torch.tensor([0, 1, 2, 0, 1], dtype=torch.int64, device=device)
504
+ target_hidden_states = torch.randn(
505
+ 5, proposer.hidden_size, dtype=proposer.dtype, device=device
506
+ )
507
+ next_token_ids = torch.tensor([100, 200], dtype=torch.int32, device=device)
508
+
509
+ num_rejected_tokens_gpu = torch.tensor([1, 0], dtype=torch.int32, device=device)
510
+
511
+ num_tokens, token_indices_to_sample, output_cad = proposer.set_inputs_first_pass(
512
+ target_token_ids=target_token_ids,
513
+ next_token_ids=next_token_ids,
514
+ target_positions=target_positions,
515
+ target_hidden_states=target_hidden_states,
516
+ token_indices_to_sample=None,
517
+ cad=common_attn_metadata,
518
+ num_rejected_tokens_gpu=num_rejected_tokens_gpu,
519
+ )
520
+
521
+ assert proposer.net_num_new_slots_per_request == 1
522
+ assert proposer.needs_extra_input_slots
523
+
524
+ # total_output_tokens = total_input_tokens + net_num_new_slots * batch_size
525
+ assert num_tokens == 7
526
+
527
+ # Request 0: [10, 11, 100, padding_token (0)]
528
+ # Request 1: [20, 21, 200]
529
+ # Combined: [10, 11, 100, 0, 20, 21, 200]
530
+ expected_input_ids = torch.tensor(
531
+ [10, 11, 100, 0, 20, 21, 200], dtype=torch.int32, device=device
532
+ )
533
+ assert torch.equal(proposer.input_ids[:num_tokens], expected_input_ids)
534
+
535
+ # Verify positions
536
+ # Request 0: [0, 1, 2, 0 (don't care)]
537
+ # Request 1: [0, 1, 2]
538
+ # Combined: [0, 1, 2, 0, 0, 1, 2]
539
+ expected_positions = torch.tensor(
540
+ [0, 1, 2, 0, 0, 1, 2], dtype=torch.int64, device=device
541
+ )
542
+ assert torch.equal(
543
+ proposer.positions[:num_tokens],
544
+ expected_positions,
545
+ )
546
+
547
+ # Verify rejection mask
548
+ expected_is_rejected = torch.zeros(7, dtype=torch.bool, device=device)
549
+ expected_is_rejected[3] = True # padding token at index 3
550
+ assert torch.equal(
551
+ proposer.is_rejected_token_mask[:num_tokens], expected_is_rejected
552
+ )
553
+
554
+ # Verify masked token mask (should all be False for non-parallel drafting)
555
+ expected_is_masked = torch.zeros(7, dtype=torch.bool, device=device)
556
+ assert torch.equal(proposer.is_masked_token_mask[:num_tokens], expected_is_masked)
557
+
558
+ # Verify token_indices_to_sample (bonus tokens at indices 2 and 6)
559
+ expected_token_indices_to_sample = torch.tensor(
560
+ [2, 6], dtype=torch.int32, device=device
561
+ )
562
+ assert torch.equal(token_indices_to_sample, expected_token_indices_to_sample)
563
+
564
+ # Verify the new CAD has updated query_start_loc
565
+ # Original: [0, 3, 5] -> New: [0, 4, 7] (each request gains 1 slot)
566
+ expected_query_start_loc = torch.tensor([0, 4, 7], dtype=torch.int32, device=device)
567
+ assert torch.equal(output_cad.query_start_loc, expected_query_start_loc)
568
+
569
+
570
+def test_set_inputs_first_pass_parallel_drafting():
571
+ """
572
+ Test for set_inputs_first_pass with parallel drafting (extra input slots,
573
+ with shift).
574
+
575
+ This tests the path where needs_extra_input_slots=True and
576
+ shift_input_ids=True (parallel drafting case). In this case:
577
+ - Input IDs ARE shifted (like default EAGLE)
578
+ - Each request gets extra_slots_per_request (3) new slots
579
+ - Parallel drafting tokens are inserted and marked as masked
580
+ - Hidden states are mapped correctly
581
+
582
+ Setup:
583
+ - 2 requests with query_lens [4, 4] (1 bonus + 3 spec tokens each)
584
+ - Request 0: tokens [10, 11, 12, 13] at positions [5, 6, 7, 8]
585
+ - Only tokens [10, 11, 12] are "valid", token 13 is rejected
586
+ - Request 1: tokens [20, 21, 22, 23] at positions [10, 11, 12, 13], all valid.
587
+ - next_token_ids: [100, 200] (bonus tokens)
588
+
589
+ With shift_input_ids=True, extra_slots_per_request=3:
590
+ Expected output layout:
591
+ Request 0 (6 output slots = 4 - 1 + 3):
592
+ - idx 0-2: shifted tokens [11, 12, 100]
593
+ - idx 3-4: parallel_drafting_tokens, is_masked=True
594
+ - idx 5: padding_token, is_rejected=True
595
+ Request 1 (6 output slots = 4 - 1 + 3):
596
+ - idx 6-8: shifted tokens [21, 22, 23]
597
+ - idx 9: bonus token 200
598
+ - idx 10-11: parallel_drafting_tokens, is_masked=True
599
+ """
600
+ device = torch.device(current_platform.device_type)
601
+
602
+ num_speculative_tokens = 3
603
+ block_size = 16
604
+
605
+ proposer = _create_proposer("eagle", num_speculative_tokens, parallel_drafting=True)
606
+
607
+ # Override to simulate parallel drafting behavior
608
+ proposer.parallel_drafting_token_id = -2
609
+ proposer.parallel_drafting_hidden_state_tensor = torch.zeros(
610
+ proposer.hidden_size, dtype=proposer.dtype, device=device
611
+ )
612
+ proposer.is_rejected_token_mask = torch.zeros(
613
+ proposer.max_num_tokens, dtype=torch.bool, device=device
614
+ )
615
+ proposer.is_masked_token_mask = torch.zeros(
616
+ proposer.max_num_tokens, dtype=torch.bool, device=device
617
+ )
618
+
619
+ # Mock the attn_metadata_builder
620
+ mock_kv_cache_spec = mock.MagicMock()
621
+ mock_kv_cache_spec.block_size = block_size
622
+ mock_builder = mock.MagicMock()
623
+ mock_builder.kv_cache_spec = mock_kv_cache_spec
624
+ proposer.attn_metadata_builder = mock_builder
625
+
626
+ # Request 0: query_len=4 (1 rejected), Request 1: query_len=4 (all valid)
627
+ batch_spec = BatchSpec(
628
+ seq_lens=[9, 14],
629
+ query_lens=[4, 4],
630
+ )
631
+
632
+ common_attn_metadata = create_common_attn_metadata(
633
+ batch_spec,
634
+ block_size=block_size,
635
+ device=device,
636
+ arange_block_indices=True,
637
+ )
638
+
639
+ # Input tensors
640
+ target_token_ids = torch.tensor(
641
+ [10, 11, 12, 13, 20, 21, 22, 23], dtype=torch.int32, device=device
642
+ )
643
+ target_positions = torch.tensor(
644
+ [5, 6, 7, 8, 10, 11, 12, 13], dtype=torch.int64, device=device
645
+ )
646
+ target_hidden_states = torch.arange(
647
+ 8 * proposer.hidden_size, dtype=proposer.dtype, device=device
648
+ ).view(8, proposer.hidden_size)
649
+ next_token_ids = torch.tensor([100, 200], dtype=torch.int32, device=device)
650
+
651
+ num_rejected_tokens_gpu = torch.tensor([1, 0], dtype=torch.int32, device=device)
652
+
653
+ num_tokens, token_indices_to_sample, output_cad = proposer.set_inputs_first_pass(
654
+ target_token_ids=target_token_ids,
655
+ next_token_ids=next_token_ids,
656
+ target_positions=target_positions,
657
+ target_hidden_states=target_hidden_states,
658
+ token_indices_to_sample=None,
659
+ cad=common_attn_metadata,
660
+ num_rejected_tokens_gpu=num_rejected_tokens_gpu,
661
+ )
662
+
663
+ # total_output_tokens = total_input_tokens + net_num_new_slots * batch_size
664
+ # = 8 + 2 * 2 = 12
665
+ assert num_tokens == 12
666
+
667
+ # Request 0: [11, 12, 100, -2, -2, 0(padding)]
668
+ # Request 1: [21, 22, 23, 200, -2, -2]
669
+ expected_input_ids = torch.tensor(
670
+ [11, 12, 100, -2, -2, 0, 21, 22, 23, 200, -2, -2],
671
+ dtype=torch.int32,
672
+ device=device,
673
+ )
674
+ assert torch.equal(proposer.input_ids[:num_tokens], expected_input_ids)
675
+
676
+ # Verify positions
677
+ # Request 0: [5, 6, 7, 8, 9, 0 (don't care)]
678
+ # Request 1: [10, 11, 12, 13, 14, 15]
679
+ expected_positions = torch.tensor(
680
+ [5, 6, 7, 8, 9, 0, 10, 11, 12, 13, 14, 15], dtype=torch.int64, device=device
681
+ )
682
+ assert torch.equal(
683
+ proposer.positions[:num_tokens],
684
+ expected_positions,
685
+ )
686
+
687
+ # Verify rejection mask
688
+ expected_is_rejected = torch.zeros(12, dtype=torch.bool, device=device)
689
+ expected_is_rejected[5] = True
690
+ assert torch.equal(
691
+ proposer.is_rejected_token_mask[:num_tokens], expected_is_rejected
692
+ )
693
+
694
+ # Verify masked token mask (parallel drafting slots should be masked)
695
+ expected_is_masked = torch.zeros(12, dtype=torch.bool, device=device)
696
+ expected_is_masked[3] = True
697
+ expected_is_masked[4] = True
698
+ expected_is_masked[10] = True
699
+ expected_is_masked[11] = True
700
+ assert torch.equal(proposer.is_masked_token_mask[:num_tokens], expected_is_masked)
701
+
702
+ # Verify token_indices_to_sample (bonus + parallel drafting tokens)
703
+ # Request 0: bonus at 2, parallel at 3, 4
704
+ # Request 1: bonus at 9, parallel at 10, 11
705
+ expected_token_indices_to_sample = torch.tensor(
706
+ [2, 3, 4, 9, 10, 11], dtype=torch.int32, device=device
707
+ )
708
+ assert torch.equal(token_indices_to_sample, expected_token_indices_to_sample)
709
+
710
+ # Verify the new CAD has updated query_start_loc
711
+ # Original query_lens: [4, 4] -> Output: [6, 6]
712
+ expected_query_start_loc = torch.tensor(
713
+ [0, 6, 12], dtype=torch.int32, device=device
714
+ )
715
+ assert torch.equal(output_cad.query_start_loc, expected_query_start_loc)
716
+
717
+ # Verify masked positions have the parallel drafting hidden state (zeros)
718
+ parallel_drafting_hs = proposer.parallel_drafting_hidden_state_tensor
719
+ for i in range(num_tokens):
720
+ if expected_is_masked[i]:
721
+ assert torch.equal(proposer.hidden_states[i], parallel_drafting_hs), (
722
+ f"Masked position {i} should have parallel drafting hidden state"
723
+ )
724
+
725
+
726
@pytest.mark.parametrize("method", ["eagle", "eagle3"])
727
@pytest.mark.parametrize("attn_backend", get_attn_backend_list_based_on_platform())
728
@pytest.mark.parametrize("pp_size", [1, 2])
@@ -579,7 +981,7 @@ def test_propose(method, attn_backend, num_speculative_tokens, monkeypatch):
981
target_positions=target_positions,
982
target_hidden_states=target_hidden_states,
983
next_token_ids=next_token_ids,
582
- last_token_indices=None,
984
+ token_indices_to_sample=None,
985
common_attn_metadata=common_attn_metadata,
986
sampling_metadata=sampling_metadata,
987
)
@@ -737,7 +1139,7 @@ def test_propose_tree(spec_token_tree):
1139
target_positions=target_positions,
1140
target_hidden_states=target_hidden_states,
1141
next_token_ids=next_token_ids,
740
- last_token_indices=None,
1142
+ token_indices_to_sample=None,
1143
common_attn_metadata=common_attn_metadata,
1144
sampling_metadata=sampling_metadata,
1145
)
tests/v1/spec_decode/test_mtp.py
+1
-1
@@ -204,7 +204,7 @@ def test_mtp_propose(num_speculative_tokens, monkeypatch):
204
target_positions=target_positions,
205
target_hidden_states=target_hidden_states,
206
next_token_ids=next_token_ids,
207
- last_token_indices=None,
207
+ token_indices_to_sample=None,
208
common_attn_metadata=common_attn_metadata,
209
sampling_metadata=sampling_metadata,
210
)
vllm/config/speculative.py
+7
@@ -116,9 +116,16 @@ class SpeculativeConfig:
116
"""Minimum size of ngram token window when using Ngram proposer, if
117
provided. Defaults to 1."""
118
119
+ # Alternative drafting strategies
120
speculative_token_tree: str | None = None
121
"""Specifies the tree structure for speculative token generation.
122
"""
123
+ parallel_drafting: bool = False
124
+ """Enable parallel drafting, where all speculative tokens are generated
125
+ in parallel rather than sequentially. This can improve performance but
126
+ requires the speculative model be trained to support parallel drafting.
127
+ Only compatible with EAGLE and draft model methods."""
128
+
129
# required configuration params passed from engine
130
target_model_config: SkipValidation[ModelConfig] = None # type: ignore
131
"""The configuration of the target model."""
vllm/config/vllm.py
+18
-10
@@ -604,10 +604,13 @@ class VllmConfig:
604
# Currently, async scheduling only support eagle speculative
605
# decoding.
606
if self.speculative_config is not None:
607
- if self.speculative_config.method not in get_args(EagleModelTypes):
607
+ if (
608
+ self.speculative_config.method not in get_args(EagleModelTypes)
609
+ and self.speculative_config.method != "draft_model"
610
+ ):
611
raise ValueError(
612
"Currently, async scheduling is only supported "
610
- "with EAGLE/MTP kind of speculative decoding."
613
+ "with EAGLE/MTP/Draft Model kind of speculative decoding."
614
)
615
if self.speculative_config.disable_padded_drafter_batch:
616
raise ValueError(
@@ -1298,16 +1301,21 @@ class VllmConfig:
1301
computed_compile_ranges_split_points = []
1302
1303
# The upper bound of the compile ranges is the max_num_batched_tokens.
1301
- # For speculative decoding with draft model, the compile range must be extended
1302
- # by 1 for each sequence.
1304
+ # For speculative decoding, the compile range must be extended
1305
+ # - Sequential: + 1 * max_num_seqs (one draft token per iteration)
1306
+ # - Parallel draft: + num_speculative_tokens * max_num_seqs
1307
compile_range_end = self.scheduler_config.max_num_batched_tokens
1308
if compile_range_end is not None:
1305
- do_extend: bool = (
1306
- self.speculative_config is not None
1307
- and self.speculative_config.uses_draft_model()
1308
- )
1309
- if do_extend:
1310
- compile_range_end += self.scheduler_config.max_num_seqs
1309
+ if self.speculative_config is not None and (
1310
+ self.speculative_config.uses_draft_model()
1311
+ or self.speculative_config.use_eagle()
1312
+ ):
1313
+ multiplier = (
1314
+ self.speculative_config.num_speculative_tokens
1315
+ if self.speculative_config.parallel_drafting
1316
+ else 1
1317
+ )
1318
+ compile_range_end += multiplier * self.scheduler_config.max_num_seqs
1319
1320
computed_compile_ranges_split_points.append(compile_range_end)
1321
vllm/model_executor/models/llama_eagle3.py
+39
-3
@@ -52,13 +52,16 @@ class LlamaDecoderLayer(LlamaDecoderLayer):
52
# Subsequent layers use hidden_size (only hidden_states, no embeds)
53
qkv_input_size = 2 * self.hidden_size if layer_idx == 0 else self.hidden_size
54
55
- # override qkv
55
+ # Parallel drafting checkpoints may have attention bias enabled
56
+ qkv_bias = getattr(config, "attention_bias", False)
57
+
58
+ # Override qkv_proj with correct input size and bias setting
59
self.self_attn.qkv_proj = QKVParallelLinear(
60
qkv_input_size,
61
self.self_attn.head_dim,
62
self.self_attn.total_num_heads,
63
self.self_attn.total_num_kv_heads,
61
- bias=False,
64
+ bias=qkv_bias,
65
quant_config=quant_config,
66
prefix=maybe_prefix(prefix, "qkv_proj"),
67
)
@@ -293,6 +296,19 @@ class Eagle3LlamaForCausalLM(LlamaForCausalLM):
296
requires_grad=False,
297
)
298
299
+ self.use_parallel_drafting = vllm_config.speculative_config.parallel_drafting
300
+
301
+ if self.use_parallel_drafting:
302
+ self.register_buffer(
303
+ "mask_hidden",
304
+ torch.zeros(
305
+ 1,
306
+ (3 if self.model.use_aux_hidden_state else 1)
307
+ * self.config.hidden_size,
308
+ ),
309
+ persistent=False,
310
+ )
311
+
312
def embed_input_ids(
313
self,
314
input_ids: torch.Tensor,
@@ -347,12 +363,25 @@ class Eagle3LlamaForCausalLM(LlamaForCausalLM):
363
model_weights = {}
364
includes_draft_id_mapping = False
365
includes_embed_tokens = False
366
+ includes_mask_hidden = False
367
for name, loaded_weight in weights:
368
if "t2d" in name:
369
continue
370
if "d2t" in name:
371
name = name.replace("d2t", "draft_id_to_target_id")
372
includes_draft_id_mapping = True
373
+ elif "mask_hidden" in name:
374
+ # Load mask_hidden directly into buffer
375
+ if not self.use_parallel_drafting:
376
+ logger.warning(
377
+ "mask_hidden found in weights but "
378
+ "model is not configured for parallel drafting. "
379
+ "Skipping loading mask_hidden."
380
+ )
381
+ continue
382
+ self.mask_hidden.copy_(loaded_weight.view(1, -1))
383
+ includes_mask_hidden = True
384
+ continue
385
elif "lm_head" not in name:
386
name = "model." + name
387
if "embed_tokens" in name:
@@ -360,7 +389,14 @@ class Eagle3LlamaForCausalLM(LlamaForCausalLM):
389
model_weights[name] = loaded_weight
390
process_eagle_weight(self, name)
391
363
- skip_substrs = []
392
+ if not includes_mask_hidden and self.use_parallel_drafting:
393
+ raise ValueError(
394
+ "mask_hidden not found in weights but "
395
+ "model is configured for parallel drafting. "
396
+ "Please provide mask_hidden in the weights."
397
+ )
398
+
399
+ skip_substrs = ["mask_hidden"]
400
if not includes_draft_id_mapping:
401
skip_substrs.append("draft_id_to_target_id")
402
if not includes_embed_tokens:
vllm/v1/attention/backend.py
+6
-1
@@ -480,9 +480,14 @@ class AttentionMetadataBuilder(ABC, Generic[M]):
480
speculative_config is not None
481
and speculative_config.num_speculative_tokens is not None
482
):
483
+ max_num_queries_for_spec = (
484
+ 1
485
+ + (2 if speculative_config.parallel_drafting else 1)
486
+ * speculative_config.num_speculative_tokens
487
+ )
488
self.reorder_batch_threshold = max(
489
self.reorder_batch_threshold,
485
- 1 + speculative_config.num_speculative_tokens,
490
+ max_num_queries_for_spec,
491
)
492
493
if (
vllm/v1/attention/backends/flashinfer.py
+30
-6
@@ -60,7 +60,7 @@ from vllm.v1.attention.backends.utils import (
60
)
61
from vllm.v1.attention.ops.common import cp_lse_ag_out_rs
62
from vllm.v1.attention.ops.merge_attn_states import merge_attn_states
63
-from vllm.v1.kv_cache_interface import AttentionSpec
63
+from vllm.v1.kv_cache_interface import AttentionSpec, UniformTypeKVCacheSpecs
64
from vllm.v1.utils import CpuGpuBuffer
65
66
FLASHINFER_WORKSPACE_BUFFER_SIZE_BATCH_INVARIANT = 2048 * 1024 * 1024
@@ -658,12 +658,36 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
658
vllm_config: VllmConfig,
659
kv_cache_spec: AttentionSpec,
660
) -> AttentionCGSupport:
661
- has_trtllm_support = can_use_trtllm_attention(
662
- num_qo_heads=vllm_config.model_config.get_num_attention_heads(
663
- vllm_config.parallel_config
664
- ),
665
- num_kv_heads=kv_cache_spec.num_kv_heads,
661
+ """Get the cudagraph support level for FlashInfer attention.
662
+
663
+ This depends on whether we can use TRTLLM attention for decodes, since we can
664
+ only do UNIFORM_SINGLE_TOKEN_DECODE if it is unavailable.
665
+ To check this, we must call can_use_trtllm_attention with the number of KV
666
+ heads from the kv_cache_spec. We check all available KV cache specs and
667
+ only return UNIFORM_BATCH if all of them support TRTLLM attention.
668
+ """
669
+ # For UniformTypeKVCacheSpecs, check all contained specs
670
+ kv_specs = (
671
+ kv_cache_spec.kv_cache_specs.values()
672
+ if isinstance(kv_cache_spec, UniformTypeKVCacheSpecs)
673
+ else [kv_cache_spec]
674
)
675
+ num_qo_heads = vllm_config.model_config.get_num_attention_heads(
676
+ vllm_config.parallel_config
677
+ )
678
+ has_trtllm_support: bool = len(kv_specs) > 0
679
+ for spec in kv_specs:
680
+ if not isinstance(spec, AttentionSpec):
681
+ # FlashInfer only applies to attention, so we don't consider other types
682
+ # of KV spec (e.g. Mamba) here. This is mostly for type checking.
683
+ continue
684
+ if not can_use_trtllm_attention(
685
+ num_qo_heads=num_qo_heads,
686
+ num_kv_heads=spec.num_kv_heads,
687
+ ):
688
+ has_trtllm_support = False
689
+ break
690
+
691
if has_trtllm_support:
692
return AttentionCGSupport.UNIFORM_BATCH
693
else:
vllm/v1/attention/backends/utils.py
-32
@@ -825,38 +825,6 @@ def get_dcp_local_seq_lens(
825
return dcp_local_seq_lens.squeeze(1)
826
827
828
-def extend_all_queries_by_1(
829
- common_attn_metadata: CommonAttentionMetadata,
830
- arange: torch.Tensor,
831
- new_slot_mapping: torch.Tensor,
832
-) -> CommonAttentionMetadata:
833
- """
834
- Creates a new CommonAttentionMetadata with all query lengths increased by 1.
835
- Also all seq lens are increased by 1.
836
- This is useful e.g. in speculative decoding with draft models, where we
837
- extend each sequence by 1 token.
838
- The slot mapping is computed externally, as it requires more information.
839
- """
840
- cad = common_attn_metadata
841
- # query start loc must be increased by [+0, +1, +2, ..., +batch_size]
842
- new_query_start_loc = cad.query_start_loc + arange[: len(cad.query_start_loc)]
843
- new_query_start_loc_cpu = cad.query_start_loc_cpu + torch.arange(
844
- len(cad.query_start_loc_cpu), dtype=torch.int32
845
- )
846
- new_cad = cad.replace(
847
- query_start_loc=new_query_start_loc,
848
- query_start_loc_cpu=new_query_start_loc_cpu,
849
- seq_lens=cad.seq_lens + 1,
850
- # each request is extended by 1 token -> batch_size tokens are added
851
- num_actual_tokens=cad.num_actual_tokens + cad.batch_size(),
852
- # All query lens increase by 1, so max query len increases by 1
853
- max_query_len=cad.max_query_len + 1,
854
- max_seq_len=cad.max_seq_len + 1,
855
- slot_mapping=new_slot_mapping,
856
- )
857
- return new_cad
858
-
859
-
828
def mamba_get_block_table_tensor(
829
block_table: torch.Tensor,
830
seq_lens: torch.Tensor,
vllm/v1/spec_decode/draft_model.py
+23
-222
@@ -1,19 +1,15 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from typing import Any
3
4
import torch
5
+import torch.nn as nn
6
+from typing_extensions import override
7
7
-from vllm.config import VllmConfig, get_layers_from_vllm_config, replace
8
+from vllm.config import VllmConfig
9
from vllm.logger import init_logger
9
-from vllm.model_executor.layers.attention import Attention
10
from vllm.model_executor.model_loader import get_model
11
-from vllm.triton_utils import tl, triton
12
-from vllm.v1.attention.backends.utils import (
13
- CommonAttentionMetadata,
14
- extend_all_queries_by_1,
15
-)
16
-from vllm.v1.spec_decode.eagle import PADDING_SLOT_ID, SpecDecodeBaseProposer
11
+from vllm.v1.spec_decode.eagle import SpecDecodeBaseProposer
12
+from vllm.v1.spec_decode.utils import create_vllm_config_for_draft_model
13
14
logger = init_logger(__name__)
15
@@ -31,37 +27,9 @@ class DraftModelProposer(SpecDecodeBaseProposer):
27
pass_hidden_states_to_model=False,
28
runner=runner,
29
)
34
- self._raise_if_multimodal()
35
- self._raise_if_mrope()
36
- self._raise_if_padded_drafter_batch_disabled()
30
self._raise_if_vocab_size_mismatch()
31
self._raise_if_draft_tp_mismatch()
32
40
- def _block_size(self) -> int:
41
- builder = self._get_attention_metadata_builder()
42
- return builder.kv_cache_spec.block_size
43
-
44
- def _raise_if_multimodal(self):
45
- if self.supports_mm_inputs:
46
- raise NotImplementedError(
47
- "Speculative Decoding with draft models "
48
- "does not support multimodal models yet"
49
- )
50
-
51
- def _raise_if_mrope(self):
52
- if self.draft_model_config.uses_mrope:
53
- raise NotImplementedError(
54
- "Speculative Decoding with draft models does not support M-RoPE yet"
55
- )
56
-
57
- def _raise_if_padded_drafter_batch_disabled(self):
58
- if self.speculative_config.disable_padded_drafter_batch:
59
- raise NotImplementedError(
60
- "Speculative Decoding with draft models only supports "
61
- "padded drafter batch. Please don't pass --disable-padded-drafter-batch"
62
- " in the speculative_config."
63
- )
64
-
33
def _raise_if_vocab_size_mismatch(self):
34
self.speculative_config.verify_equal_vocab_size_if_draft_model()
35
@@ -82,193 +50,26 @@ class DraftModelProposer(SpecDecodeBaseProposer):
50
"Please pass 'draft_tensor_parallel_size' in the speculative_config."
51
)
52
85
- def set_inputs_first_pass(
86
- self,
87
- target_token_ids: torch.Tensor,
88
- next_token_ids: torch.Tensor,
89
- target_positions: torch.Tensor,
90
- last_token_indices: torch.Tensor | None,
91
- cad: CommonAttentionMetadata,
92
- num_rejected_tokens_gpu: torch.Tensor | None,
93
- ) -> tuple[int, torch.Tensor, CommonAttentionMetadata]:
94
- batch_size = cad.batch_size()
95
- grid = (batch_size,)
96
- start_locs = cad.query_start_loc[:-1]
97
- end_locs = cad.query_start_loc[1:] - 1
98
- if num_rejected_tokens_gpu is not None:
99
- end_locs -= num_rejected_tokens_gpu
100
-
101
- num_tokens = target_token_ids.shape[0] + batch_size
102
- is_rejected_tok = torch.empty(
103
- (num_tokens,), device=self.input_ids.device, dtype=torch.bool
104
- )
105
- merge_toks_kernel[grid](
106
- target_toks_ptr=target_token_ids,
107
- next_toks_ptr=next_token_ids,
108
- query_start_locs_ptr=start_locs,
109
- query_end_locs_ptr=end_locs,
110
- out_ptr_merged_toks=self.input_ids,
111
- out_ptr_is_rejected_tok=is_rejected_tok,
112
- target_toks_size=target_token_ids.shape[0],
113
- # passing a negative rejected_tok_fill value will raise an error
114
- # when the value is used to index into embeddings.
115
- # Therefore, we pass a valid integer, e.g. 0.
116
- rejected_tok_fill=0,
117
- )
118
- merge_toks_kernel[grid](
119
- target_toks_ptr=target_positions,
120
- next_toks_ptr=target_positions[end_locs] + 1,
121
- query_start_locs_ptr=start_locs,
122
- query_end_locs_ptr=end_locs,
123
- out_ptr_merged_toks=self.positions,
124
- out_ptr_is_rejected_tok=is_rejected_tok,
125
- target_toks_size=target_positions.shape[0],
126
- rejected_tok_fill=0,
127
- )
128
-
129
- # recompute slot mapping
130
- new_slot_mapping = compute_new_slot_mapping(
131
- cad=cad,
132
- new_positions=self.positions[:num_tokens],
133
- is_rejected_token_mask=is_rejected_tok,
134
- block_size=self._block_size(),
135
- max_model_len=self.max_model_len,
136
- )
137
- # update common_attn_metadata
138
- new_cad: CommonAttentionMetadata = extend_all_queries_by_1(
139
- cad,
140
- arange=self.arange,
141
- new_slot_mapping=new_slot_mapping,
142
- )
143
-
144
- new_last_token_indices = new_cad.query_start_loc[1:] - 1
145
- if num_rejected_tokens_gpu is not None:
146
- new_last_token_indices -= num_rejected_tokens_gpu
147
-
148
- return num_tokens, new_last_token_indices, new_cad
149
-
150
- def load_model(self, target_model: Any) -> None:
151
- """Takes target_model to satisfy the type checker."""
152
-
153
- # This must be computed before loading the draft model
154
- # because that mutates the forward_context of the vllm_config
155
- target_attn_layer_names = set(
156
- get_layers_from_vllm_config(self.vllm_config, Attention).keys()
157
- )
158
-
53
+ @override
54
+ def _get_model(self) -> nn.Module:
55
+ # Draft models may be quantized or on different parallelism,
56
+ # so we load them with a modified vllm config
57
from vllm.compilation.backends import set_model_tag
58
161
- draft_vllm_config: VllmConfig = create_vllm_config_for_draft_model(
162
- target_model_vllm_config=self.vllm_config
163
- )
164
- logger.info(
165
- "Starting to load draft model %s. TP=%d, rank=%d",
166
- draft_vllm_config.model_config.model,
167
- draft_vllm_config.parallel_config.tensor_parallel_size,
168
- draft_vllm_config.parallel_config.rank,
169
- )
59
+ temp_vllm_config = create_vllm_config_for_draft_model(self.vllm_config)
60
with set_model_tag("draft_model"):
171
- self.model = get_model(vllm_config=draft_vllm_config, prefix="draft_model")
172
-
173
- # This must be computed after loading the draft model
174
- # because that mutates the forward_context of the vllm_config
175
- draft_attn_layer_names = (
176
- get_layers_from_vllm_config(self.vllm_config, Attention).keys()
177
- - target_attn_layer_names
178
- )
179
- self.attn_layer_names = list(draft_attn_layer_names)
180
-
181
-
182
-def create_vllm_config_for_draft_model(
183
- target_model_vllm_config: VllmConfig,
184
-) -> VllmConfig:
185
- """The vllm_config is configured for the target model, e.g.
186
- its quant_config and parallel_config. But the draft model is potentially
187
- quantized differently, and has potentially different tensor_parallel_size.
188
- This function creates a new vllm_config configured for the draft model.
189
- The vllm_config is useful when loading the draft model with get_model().
190
- """
191
- old = target_model_vllm_config
192
- assert old.speculative_config is not None, "speculative_config is not set"
193
- old_spec_config = old.speculative_config
194
- new_parallel_config = replace(
195
- old_spec_config.draft_parallel_config,
196
- rank=old.parallel_config.rank,
197
- )
198
- new: VllmConfig = replace(
199
- old,
200
- quant_config=None, # quant_config is recomputed in __init__()
201
- model_config=old_spec_config.draft_model_config,
202
- parallel_config=new_parallel_config,
203
- )
204
- return new
205
-
206
-
207
-def compute_new_slot_mapping(
208
- cad: CommonAttentionMetadata,
209
- new_positions: torch.Tensor,
210
- is_rejected_token_mask: torch.Tensor,
211
- block_size: int,
212
- max_model_len: int,
213
-):
214
- batch_size, n_blocks_per_req = cad.block_table_tensor.shape
215
- req_indices = torch.arange(batch_size, device=cad.query_start_loc.device)
216
- req_indices = torch.repeat_interleave(
217
- req_indices, cad.naive_query_lens() + 1, output_size=len(new_positions)
218
- )
219
- # Clamp the positions to prevent an out-of-bounds error when indexing
220
- # into block_table_tensor.
221
- clamped_positions = torch.clamp(new_positions, max=max_model_len - 1)
222
- block_table_indices = (
223
- req_indices * n_blocks_per_req + clamped_positions // block_size
224
- )
225
- block_nums = cad.block_table_tensor.view(-1)[block_table_indices]
226
- block_offsets = clamped_positions % block_size
227
- new_slot_mapping = block_nums * block_size + block_offsets
228
- # Mask out the position ids that exceed the max model length.
229
- exceeds_max_model_len = new_positions >= max_model_len
230
- new_slot_mapping.masked_fill_(exceeds_max_model_len, PADDING_SLOT_ID)
231
- # Mask out rejected tokens to prevent saves to the KV cache.
232
- new_slot_mapping.masked_fill_(is_rejected_token_mask, PADDING_SLOT_ID)
233
- return new_slot_mapping
234
-
61
+ model = get_model(
62
+ vllm_config=temp_vllm_config,
63
+ prefix="draft_model",
64
+ )
65
+ return model
66
236
-@triton.jit
237
-def merge_toks_kernel(
238
- target_toks_ptr,
239
- next_toks_ptr,
240
- query_start_locs_ptr,
241
- query_end_locs_ptr,
242
- out_ptr_merged_toks,
243
- out_ptr_is_rejected_tok,
244
- target_toks_size,
245
- rejected_tok_fill,
246
-):
247
- """
248
- Merges the `target_toks_ptr` and the `next_toks_ptr` into a new tensor
249
- called `out_ptr_merged_toks`. Rejected tokens are those after the
250
- `query_end_locs_ptr` and before the next `query_start_locs_ptr`. Fills the
251
- rejected tokens positions with the value `rejected_tok_fill`. Also fills a mask
252
- of the rejected tokens in `out_ptr_is_rejected_tok`.
253
- """
254
- pid = tl.program_id(0)
255
- start_loc = tl.load(query_start_locs_ptr + pid)
256
- is_last_program = pid == tl.num_programs(0) - 1
257
- if is_last_program:
258
- next_start_loc = target_toks_size.to(tl.int32)
259
- else:
260
- next_start_loc = tl.load(query_start_locs_ptr + pid + 1).to(tl.int32)
67
+ @override
68
+ def _maybe_share_embeddings(self, target_language_model: nn.Module) -> None:
69
+ # Draft models don't share embeddings with the target model
70
+ pass
71
262
- end_loc = tl.load(query_end_locs_ptr + pid)
263
- new_val = tl.load(next_toks_ptr + pid)
264
- for i in range(start_loc, next_start_loc + 1):
265
- if i <= end_loc: # copy existing tokens
266
- old_val = tl.load(target_toks_ptr + i)
267
- tl.store(out_ptr_merged_toks + pid + i, old_val)
268
- tl.store(out_ptr_is_rejected_tok + pid + i, False)
269
- elif i == end_loc + 1: # copy bonus token
270
- tl.store(out_ptr_merged_toks + pid + i, new_val)
271
- tl.store(out_ptr_is_rejected_tok + pid + i, False)
272
- else: # fill rejected tokens
273
- tl.store(out_ptr_merged_toks + pid + i, rejected_tok_fill)
274
- tl.store(out_ptr_is_rejected_tok + pid + i, True)
72
+ @override
73
+ def _maybe_share_lm_head(self, target_language_model: nn.Module) -> None:
74
+ # Draft models don't share lm_head with the target model
75
+ pass
vllm/v1/spec_decode/eagle.py
+269
-47
@@ -43,8 +43,12 @@ from vllm.v1.sample.metadata import SamplingMetadata
43
from vllm.v1.sample.sampler import _SAMPLING_EPS
44
from vllm.v1.spec_decode.metadata import SpecDecodeMetadata
45
from vllm.v1.spec_decode.utils import (
46
+ PADDING_SLOT_ID,
47
+ compute_new_slot_mapping,
48
+ copy_and_expand_eagle_inputs_kernel,
49
eagle_prepare_inputs_padded_kernel,
50
eagle_prepare_next_token_padded_kernel,
51
+ extend_all_queries_by_N,
52
)
53
from vllm.v1.utils import CpuGpuBuffer
54
from vllm.v1.worker.dp_utils import coordinate_batch_across_dp
@@ -52,8 +56,6 @@ from vllm.v1.worker.gpu_input_batch import CachedRequestState, InputBatch
56
57
logger = init_logger(__name__)
58
55
-PADDING_SLOT_ID = -1
56
-
59
60
class SpecDecodeBaseProposer:
61
def __init__(
@@ -76,18 +78,35 @@ class SpecDecodeBaseProposer:
78
self.max_model_len = vllm_config.model_config.max_model_len
79
self.dp_rank = vllm_config.parallel_config.data_parallel_rank
80
self.num_speculative_tokens = self.speculative_config.num_speculative_tokens
79
- # The drafter can get longer sequences than the target model.
80
- max_batch_size = vllm_config.scheduler_config.max_num_seqs
81
- self.max_num_tokens = (
82
- vllm_config.scheduler_config.max_num_batched_tokens + max_batch_size
83
- )
84
- self.token_arange_np = np.arange(self.max_num_tokens)
81
+
82
# We need to get the hidden size from the draft model config because
83
# the draft model's hidden size can be different from the target model's
84
# hidden size (e.g., Llama 3.3 70B).
85
self.hidden_size = self.draft_model_config.get_hidden_size()
86
self.inputs_embeds_size = self.draft_model_config.get_inputs_embeds_size()
87
88
+ # Unifying eagle, draft model, and parallel drafting support
89
+ self.parallel_drafting: bool = self.speculative_config.parallel_drafting
90
+ self.extra_slots_per_request = (
91
+ 1 if not self.parallel_drafting else self.num_speculative_tokens
92
+ )
93
+ self.net_num_new_slots_per_request = self.extra_slots_per_request - (
94
+ 1 if self.pass_hidden_states_to_model else 0
95
+ )
96
+ self.needs_extra_input_slots = self.net_num_new_slots_per_request > 0
97
+
98
+ self.parallel_drafting_token_id: int = 0
99
+ self.parallel_drafting_hidden_state_tensor: torch.Tensor | None = None
100
+ if self.parallel_drafting:
101
+ self._init_parallel_drafting_params()
102
+
103
+ # The drafter can get longer sequences than the target model.
104
+ max_batch_size = vllm_config.scheduler_config.max_num_seqs
105
+ self.max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens + (
106
+ self.net_num_new_slots_per_request * max_batch_size
107
+ )
108
+ self.token_arange_np = np.arange(self.max_num_tokens)
109
+
110
# Multi-modal data support
111
self.mm_registry = MULTIMODAL_REGISTRY
112
self.supports_mm_inputs = self.mm_registry.supports_multimodal_inputs(
@@ -155,6 +174,26 @@ class SpecDecodeBaseProposer:
174
max_num_slots_for_arange, device=device, dtype=torch.int32
175
)
176
177
+ if self.needs_extra_input_slots:
178
+ self._raise_if_padded_drafter_batch_disabled()
179
+ self._raise_if_multimodal()
180
+ self._raise_if_mrope()
181
+
182
+ self.is_rejected_token_mask: torch.Tensor | None = None
183
+ self.is_masked_token_mask: torch.Tensor | None = None
184
+ if self.needs_extra_input_slots:
185
+ # For draft models and parallel drafting, we need to keep track of
186
+ # which tokens are rejected to update the slot mapping with padding slots.
187
+ self.is_rejected_token_mask = torch.zeros(
188
+ (self.max_num_tokens,), dtype=torch.bool, device=device
189
+ )
190
+ # For parallel drafting, we also need to keep track of which tokens
191
+ # are parallel-padding tokens used to sample at later positions.
192
+ # We populate this tensor even when using draft models for simplicity.
193
+ self.is_masked_token_mask = torch.zeros(
194
+ (self.max_num_tokens,), dtype=torch.bool, device=device
195
+ )
196
+
197
self.inputs_embeds = torch.zeros(
198
(self.max_num_tokens, self.inputs_embeds_size),
199
dtype=self.dtype,
@@ -231,6 +270,49 @@ class SpecDecodeBaseProposer:
270
1, len(self.tree_choices) + 1, device=device, dtype=torch.int32
271
).repeat(max_batch_size, 1)
272
273
+ def _raise_if_padded_drafter_batch_disabled(self):
274
+ if self.speculative_config.disable_padded_drafter_batch:
275
+ raise NotImplementedError(
276
+ "Speculative Decoding with draft models or parallel drafting only "
277
+ "supports padded drafter batch. Please unset "
278
+ "disable_padded_drafter_batch in the speculative_config."
279
+ )
280
+
281
+ def _raise_if_multimodal(self):
282
+ if self.supports_mm_inputs:
283
+ raise NotImplementedError(
284
+ "Speculative Decoding with draft models or parallel drafting "
285
+ "does not support multimodal models yet"
286
+ )
287
+
288
+ def _raise_if_mrope(self):
289
+ if self.draft_model_config.uses_mrope:
290
+ raise NotImplementedError(
291
+ "Speculative Decoding with draft models or parallel drafting "
292
+ "does not support M-RoPE yet"
293
+ )
294
+
295
+ def _init_parallel_drafting_params(self):
296
+ # For parallel drafting, we need the token ID to use for masked slots
297
+ # And for EAGLE + parallel drafting, we need the hidden state tensor to use
298
+ # for those masked slots.
299
+
300
+ model_hf_config = self.draft_model_config.hf_config
301
+ if hasattr(model_hf_config, "pard_token"):
302
+ self.parallel_drafting_token_id = model_hf_config.pard_token
303
+ elif hasattr(model_hf_config, "ptd_token_id"):
304
+ self.parallel_drafting_token_id = model_hf_config.ptd_token_id
305
+ else:
306
+ raise ValueError(
307
+ "For parallel drafting, the draft model config must have "
308
+ "`pard_token` or `ptd_token_id` specified in its config.json."
309
+ )
310
+
311
+ if self.pass_hidden_states_to_model:
312
+ self.parallel_drafting_hidden_state_tensor = torch.empty(
313
+ self.hidden_size, dtype=self.dtype, device=self.device
314
+ )
315
+
316
def _get_positions(self, num_tokens: int):
317
if self.uses_mrope:
318
return self.mrope_positions[:, :num_tokens]
@@ -296,7 +378,7 @@ class SpecDecodeBaseProposer:
378
target_hidden_states: torch.Tensor,
379
# [batch_size]
380
next_token_ids: torch.Tensor,
299
- last_token_indices: torch.Tensor | None,
381
+ token_indices_to_sample: torch.Tensor | None,
382
common_attn_metadata: CommonAttentionMetadata,
383
sampling_metadata: SamplingMetadata,
384
mm_embed_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None,
@@ -314,12 +396,13 @@ class SpecDecodeBaseProposer:
396
)
397
assert target_hidden_states.shape[-1] == self.hidden_size
398
317
- num_tokens, last_token_indices, common_attn_metadata = (
399
+ num_tokens, token_indices_to_sample, common_attn_metadata = (
400
self.set_inputs_first_pass(
401
target_token_ids=target_token_ids,
402
next_token_ids=next_token_ids,
403
target_positions=target_positions,
322
- last_token_indices=last_token_indices,
404
+ target_hidden_states=target_hidden_states,
405
+ token_indices_to_sample=token_indices_to_sample,
406
cad=common_attn_metadata,
407
num_rejected_tokens_gpu=num_rejected_tokens_gpu,
408
)
@@ -366,11 +449,6 @@ class SpecDecodeBaseProposer:
449
if num_tokens_across_dp is not None:
450
num_tokens_across_dp[self.dp_rank] = num_input_tokens
451
369
- if self.pass_hidden_states_to_model:
370
- # target_hidden_states and self.hidden_states can have different
371
- # hidden dims. E.g. large target model and small draft model.
372
- self.hidden_states[:num_tokens] = target_hidden_states
373
-
452
if self.supports_mm_inputs:
453
mm_embeds, is_mm_embed = mm_embed_inputs or (None, None)
454
@@ -411,27 +489,27 @@ class SpecDecodeBaseProposer:
489
else:
490
last_hidden_states, hidden_states = ret_hidden_states
491
414
- sample_hidden_states = last_hidden_states[last_token_indices]
492
+ sample_hidden_states = last_hidden_states[token_indices_to_sample]
493
logits = self.model.compute_logits(sample_hidden_states)
494
495
# Early exit if there is only one draft token to be generated.
418
- if self.num_speculative_tokens == 1:
496
+ if self.num_speculative_tokens == 1 or self.parallel_drafting:
497
draft_token_ids = logits.argmax(dim=-1)
420
- return draft_token_ids.view(-1, 1)
498
+ return draft_token_ids.view(-1, self.num_speculative_tokens)
499
500
if self.uses_mrope:
423
- positions = self.mrope_positions[:, last_token_indices]
501
+ positions = self.mrope_positions[:, token_indices_to_sample]
502
else:
425
- positions = self.positions[last_token_indices]
503
+ positions = self.positions[token_indices_to_sample]
504
if self.method in (
505
"deepseek_mtp",
506
"ernie_mtp",
507
"longcat_flash_mtp",
508
"pangu_ultra_moe_mtp",
509
):
432
- hidden_states = self.hidden_states[last_token_indices]
510
+ hidden_states = self.hidden_states[token_indices_to_sample]
511
else:
434
- hidden_states = hidden_states[last_token_indices]
512
+ hidden_states = hidden_states[token_indices_to_sample]
513
514
if isinstance(attn_metadata, TreeAttentionMetadata):
515
# Draft using tree attention.
@@ -624,27 +702,139 @@ class SpecDecodeBaseProposer:
702
target_token_ids: torch.Tensor,
703
next_token_ids: torch.Tensor,
704
target_positions: torch.Tensor,
627
- last_token_indices: torch.Tensor | None,
705
+ target_hidden_states: torch.Tensor,
706
+ token_indices_to_sample: torch.Tensor | None,
707
cad: CommonAttentionMetadata,
708
num_rejected_tokens_gpu: torch.Tensor | None,
709
) -> tuple[int, torch.Tensor, CommonAttentionMetadata]:
631
- if last_token_indices is None:
632
- last_token_indices = cad.query_start_loc[1:] - 1
710
+ if not self.needs_extra_input_slots:
711
+ # Default EAGLE pathway: no reshaping of input tensors needed.
712
+ # Simply rotate the input ids and leave the positions unchanged,
713
+ # Inserting the next token ids at the last slot in each request.
714
+ if token_indices_to_sample is None:
715
+ token_indices_to_sample = cad.query_start_loc[1:] - 1
716
+
717
+ num_tokens = target_token_ids.shape[0]
718
+ # Shift the input ids by one token.
719
+ # E.g., [a1, b1, b2, c1, c2, c3] -> [b1, b2, c1, c2, c3, c3]
720
+ self.input_ids[: num_tokens - 1] = target_token_ids[1:]
721
+ # Replace the last token with the next token.
722
+ # E.g., [b1, b2, c1, c2, c3, c3] -> [a2, b2, b3, c2, c3, c4]
723
+ self.input_ids[token_indices_to_sample] = next_token_ids
724
+
725
+ # copy inputs to buffer for cudagraph
726
+ if self.uses_xdrope_dim > 0 and self.draft_uses_xdrope_dim == 0:
727
+ target_positions = target_positions[0]
728
+ self._set_positions(num_tokens, target_positions)
729
+
730
+ self.hidden_states[:num_tokens] = target_hidden_states
731
+
732
+ return num_tokens, token_indices_to_sample, cad
733
+ else:
734
+ assert self.is_rejected_token_mask is not None
735
+ assert self.is_masked_token_mask is not None
736
+ # 1.
737
+ # Call a custom triton kernel to copy input_ids and positions
738
+ # into the correct slots in the preallocated buffers self.input_ids,
739
+ # self.positions.
740
+ batch_size = cad.batch_size()
741
+ # Since we might have to copy a lot of data for prefills, we select the
742
+ # block size based on the max query length and limit to max 256 slots/block.
743
+ max_num_tokens_per_request = (
744
+ cad.max_query_len + self.net_num_new_slots_per_request
745
+ )
746
+ BLOCK_SIZE_TOKENS = min(
747
+ 256, triton.next_power_of_2(max_num_tokens_per_request)
748
+ )
749
+ num_blocks = (
750
+ max_num_tokens_per_request + BLOCK_SIZE_TOKENS - 1
751
+ ) // BLOCK_SIZE_TOKENS
752
+ total_num_input_tokens = target_token_ids.shape[0]
753
+ total_num_output_tokens = total_num_input_tokens + (
754
+ self.net_num_new_slots_per_request * batch_size
755
+ )
756
634
- num_tokens = target_token_ids.shape[0]
635
- # Shift the input ids by one token.
636
- # E.g., [a1, b1, b2, c1, c2, c3] -> [b1, b2, c1, c2, c3, c3]
637
- self.input_ids[: num_tokens - 1] = target_token_ids[1:]
638
- # Replace the last token with the next token.
639
- # E.g., [b1, b2, c1, c2, c3, c3] -> [a2, b2, b3, c2, c3, c4]
640
- self.input_ids[last_token_indices] = next_token_ids
757
+ token_indices_to_sample = torch.empty(
758
+ batch_size * self.extra_slots_per_request,
759
+ dtype=torch.int32,
760
+ device=self.device,
761
+ )
762
642
- # copy inputs to buffer for cudagraph
643
- if self.uses_xdrope_dim > 0 and self.draft_uses_xdrope_dim == 0:
644
- target_positions = target_positions[0]
645
- self._set_positions(num_tokens, target_positions)
763
+ # Destination indices to write target_hidden_states into drafting buffer.
764
+ out_hidden_state_mapping = torch.empty(
765
+ total_num_input_tokens, dtype=torch.int32, device=self.device
766
+ )
767
+
768
+ # Kernel grid: one program per request (row)
769
+ grid = (batch_size, num_blocks)
770
+ query_start_loc = cad.query_start_loc
771
+ query_end_loc = cad.query_start_loc[1:] - 1
772
+ if num_rejected_tokens_gpu is not None:
773
+ query_end_loc = query_end_loc - num_rejected_tokens_gpu
774
+ copy_and_expand_eagle_inputs_kernel[grid](
775
+ # (Padded) Inputs from the target model
776
+ target_token_ids_ptr=target_token_ids,
777
+ target_positions_ptr=target_positions,
778
+ next_token_ids_ptr=next_token_ids, # sampled tokens, one per request
779
+ # Outputs to the drafting buffers
780
+ out_input_ids_ptr=self.input_ids,
781
+ out_positions_ptr=self.positions, # Doesn't support mrope for now
782
+ out_is_rejected_token_mask_ptr=self.is_rejected_token_mask,
783
+ out_is_masked_token_mask_ptr=self.is_masked_token_mask,
784
+ out_new_token_indices_ptr=token_indices_to_sample,
785
+ out_hidden_state_mapping_ptr=out_hidden_state_mapping,
786
+ # Input metadata
787
+ query_start_loc_ptr=query_start_loc,
788
+ query_end_loc_ptr=query_end_loc,
789
+ padding_token_id=0,
790
+ parallel_drafting_token_id=self.parallel_drafting_token_id,
791
+ # Sizing info
792
+ # Note that we can deduce batch_size for free from the grid size
793
+ total_input_tokens=total_num_input_tokens,
794
+ num_padding_slots_per_request=self.extra_slots_per_request,
795
+ shift_input_ids=self.pass_hidden_states_to_model,
796
+ BLOCK_SIZE_TOKENS=BLOCK_SIZE_TOKENS,
797
+ )
798
+ if self.pass_hidden_states_to_model:
799
+ assert self.parallel_drafting_hidden_state_tensor is not None
800
+ self.hidden_states[out_hidden_state_mapping] = target_hidden_states
801
+ # Use torch.where to avoid DtoH sync from boolean indexing
802
+ mask = self.is_masked_token_mask[:total_num_output_tokens]
803
+ torch.where(
804
+ mask.unsqueeze(1),
805
+ self.parallel_drafting_hidden_state_tensor,
806
+ self.hidden_states[:total_num_output_tokens],
807
+ out=self.hidden_states[:total_num_output_tokens],
808
+ )
809
+
810
+ # 2.
811
+ # Recompute the slot mapping based on the new positions and
812
+ # rejection mask.
813
+ builder = (
814
+ self._get_attention_metadata_builder()
815
+ if self.attn_metadata_builder is None
816
+ else self.attn_metadata_builder
817
+ )
818
+ new_slot_mapping = compute_new_slot_mapping(
819
+ cad=cad,
820
+ new_positions=self.positions[:total_num_output_tokens],
821
+ is_rejected_token_mask=self.is_rejected_token_mask[
822
+ :total_num_output_tokens
823
+ ],
824
+ block_size=builder.kv_cache_spec.block_size,
825
+ num_new_tokens=self.net_num_new_slots_per_request,
826
+ max_model_len=self.max_model_len,
827
+ )
828
+
829
+ # 3. Update the common attention metadata with the new (meta)data
830
+ new_cad = extend_all_queries_by_N(
831
+ cad,
832
+ N=self.net_num_new_slots_per_request,
833
+ arange=self.arange,
834
+ new_slot_mapping=new_slot_mapping,
835
+ )
836
647
- return num_tokens, last_token_indices, cad
837
+ return total_num_output_tokens, token_indices_to_sample, new_cad
838
839
def model_returns_tuple(self) -> bool:
840
return self.method not in ("mtp", "draft_model")
@@ -1081,8 +1271,21 @@ class SpecDecodeBaseProposer:
1271
model = model.module
1272
return model.__class__.__name__
1273
1274
+ def _get_model(self) -> nn.Module:
1275
+ """
1276
+ Default method to call get_model(). Can be overridden by subclasses which
1277
+ need to customize model loading.
1278
+ """
1279
+ from vllm.compilation.backends import set_model_tag
1280
+
1281
+ with set_model_tag("eagle_head"):
1282
+ model = get_model(
1283
+ vllm_config=self.vllm_config,
1284
+ model_config=self.speculative_config.draft_model_config,
1285
+ )
1286
+ return model
1287
+
1288
def load_model(self, target_model: nn.Module) -> None:
1085
- draft_model_config = self.speculative_config.draft_model_config
1289
target_attn_layer_names = set(
1290
get_layers_from_vllm_config(
1291
self.vllm_config,
@@ -1096,12 +1299,7 @@ class SpecDecodeBaseProposer:
1299
).keys()
1300
)
1301
1099
- from vllm.compilation.backends import set_model_tag
1100
-
1101
- with set_model_tag("eagle_head"):
1102
- self.model = get_model(
1103
- vllm_config=self.vllm_config, model_config=draft_model_config
1104
- )
1302
+ self.model = self._get_model()
1303
1304
draft_attn_layer_names = (
1305
get_layers_from_vllm_config(
@@ -1170,7 +1368,26 @@ class SpecDecodeBaseProposer:
1368
else:
1369
target_language_model = target_model
1370
1173
- # share embed_tokens with the target model if needed
1371
+ self._maybe_share_embeddings(target_language_model)
1372
+ self._maybe_share_lm_head(target_language_model)
1373
+
1374
+ if self.parallel_drafting and self.pass_hidden_states_to_model:
1375
+ assert self.parallel_drafting_hidden_state_tensor is not None
1376
+ self.parallel_drafting_hidden_state_tensor.copy_(
1377
+ self.model.combine_hidden_states(
1378
+ self.model.mask_hidden.view(3 * self.hidden_size)
1379
+ )
1380
+ if self.eagle3_use_aux_hidden_state
1381
+ else self.model.mask_hidden.view(self.hidden_size)
1382
+ )
1383
+
1384
+ def _maybe_share_embeddings(self, target_language_model: nn.Module) -> None:
1385
+ """
1386
+ Some draft models may not have their own embedding layers, and some may
1387
+ have a duplicate copy of the target model's embedding layers. In these cases,
1388
+ we share the target model's embedding layers with the draft model to save
1389
+ memory.
1390
+ """
1391
if get_pp_group().world_size == 1:
1392
inner_model = getattr(target_language_model, "model", None)
1393
if inner_model is None:
@@ -1233,7 +1450,12 @@ class SpecDecodeBaseProposer:
1450
" from the target model."
1451
)
1452
1236
- # share lm_head with the target model if needed
1453
+ def _maybe_share_lm_head(self, target_language_model: nn.Module) -> None:
1454
+ """
1455
+ Some draft models may not have their own LM head, and some may have a
1456
+ duplicate copy of the target model's LM head. In these cases, we share
1457
+ the target model's LM head with the draft model to save memory.
1458
+ """
1459
share_lm_head = False
1460
if hasattr(self.model, "has_own_lm_head"):
1461
# EAGLE model
vllm/v1/spec_decode/utils.py
+248
@@ -1,6 +1,14 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import torch
4
+
5
+from vllm.config import VllmConfig, replace
6
from vllm.triton_utils import tl, triton
7
+from vllm.v1.attention.backends.utils import (
8
+ CommonAttentionMetadata,
9
+)
10
+
11
+PADDING_SLOT_ID = -1
12
13
14
@triton.jit
@@ -107,3 +115,243 @@ def eagle_prepare_next_token_padded_kernel(
115
tl.store(next_token_ids_ptr + req_idx, backup_token)
116
117
tl.store(valid_sampled_tokens_count_ptr + req_idx, valid_count)
118
+
119
+
120
+def compute_new_slot_mapping(
121
+ cad: CommonAttentionMetadata,
122
+ new_positions: torch.Tensor,
123
+ is_rejected_token_mask: torch.Tensor,
124
+ block_size: int,
125
+ num_new_tokens: int,
126
+ max_model_len: int,
127
+):
128
+ batch_size, n_blocks_per_req = cad.block_table_tensor.shape
129
+ req_indices = torch.arange(batch_size, device=cad.query_start_loc.device)
130
+ req_indices = torch.repeat_interleave(
131
+ req_indices,
132
+ cad.naive_query_lens() + num_new_tokens,
133
+ output_size=len(new_positions),
134
+ )
135
+ # Clamp the positions to prevent an out-of-bounds error when indexing
136
+ # into block_table_tensor.
137
+ clamped_positions = torch.clamp(new_positions, max=max_model_len - 1)
138
+ block_table_indices = (
139
+ req_indices * n_blocks_per_req + clamped_positions // block_size
140
+ )
141
+ block_nums = cad.block_table_tensor.view(-1)[block_table_indices]
142
+ block_offsets = clamped_positions % block_size
143
+ new_slot_mapping = block_nums * block_size + block_offsets
144
+ # Mask out the position ids that exceed the max model length.
145
+ exceeds_max_model_len = new_positions >= max_model_len
146
+ new_slot_mapping.masked_fill_(exceeds_max_model_len, PADDING_SLOT_ID)
147
+ # Mask out rejected tokens to prevent saves to the KV cache.
148
+ new_slot_mapping.masked_fill_(is_rejected_token_mask, PADDING_SLOT_ID)
149
+ return new_slot_mapping
150
+
151
+
152
+def create_vllm_config_for_draft_model(
153
+ target_model_vllm_config: VllmConfig,
154
+) -> VllmConfig:
155
+ """The vllm_config is configured for the target model, e.g.
156
+ its quant_config and parallel_config. But the draft model is potentially
157
+ quantized differently, and has potentially different tensor_parallel_size.
158
+ This function creates a new vllm_config configured for the drafter.
159
+ The vllm_config is useful when loading the draft model with get_model().
160
+ """
161
+ old = target_model_vllm_config
162
+ assert old.speculative_config is not None, "speculative_config is not set"
163
+ old_spec_config = old.speculative_config
164
+ new_parallel_config = replace(
165
+ old_spec_config.draft_parallel_config, rank=old.parallel_config.rank
166
+ )
167
+ new: VllmConfig = replace(
168
+ old,
169
+ quant_config=None,
170
+ parallel_config=new_parallel_config,
171
+ model_config=old_spec_config.draft_model_config,
172
+ )
173
+ return new
174
+
175
+
176
+def extend_all_queries_by_N(
177
+ common_attn_metadata: CommonAttentionMetadata,
178
+ N: int,
179
+ arange: torch.Tensor,
180
+ new_slot_mapping: torch.Tensor,
181
+) -> CommonAttentionMetadata:
182
+ """
183
+ Creates a new CommonAttentionMetadata with all query lengths increased by N.
184
+ Also all seq lens are increased by N.
185
+ This is useful e.g. in speculative decoding with parallel drafting, where we
186
+ extend each sequence by N tokens and predict all tokens in one pass.
187
+ The slot mapping is computed externally, as it requires more information.
188
+ """
189
+ cad = common_attn_metadata
190
+ # query start loc must be increased by [+0, +N, +2N, ..., +batch_size * N]
191
+ new_query_start_loc = cad.query_start_loc + N * arange[: len(cad.query_start_loc)]
192
+ new_query_start_loc_cpu = cad.query_start_loc_cpu + N * torch.arange(
193
+ len(cad.query_start_loc_cpu), dtype=torch.int32
194
+ )
195
+ new_cad = cad.replace(
196
+ query_start_loc=new_query_start_loc,
197
+ query_start_loc_cpu=new_query_start_loc_cpu,
198
+ seq_lens=cad.seq_lens + N,
199
+ # each request is extended by N tokens -> batch_size * N tokens are added
200
+ num_actual_tokens=cad.num_actual_tokens + cad.batch_size() * N,
201
+ # All query lens increase by N, so max query len increases by N
202
+ max_query_len=cad.max_query_len + N,
203
+ max_seq_len=cad.max_seq_len + N,
204
+ slot_mapping=new_slot_mapping,
205
+ )
206
+ return new_cad
207
+
208
+
209
+# Unified copy/expand kernel
210
+@triton.jit
211
+def copy_and_expand_eagle_inputs_kernel(
212
+ # (Padded) Inputs from the target model
213
+ target_token_ids_ptr, # [total_tokens_in_batch]
214
+ target_positions_ptr, # [total_tokens_in_batch]
215
+ next_token_ids_ptr, # [num_reqs]
216
+ # Outputs to the drafting buffers
217
+ out_input_ids_ptr, # [total_draft_tokens_in_batch] (output)
218
+ out_positions_ptr, # [total_draft_tokens_in_batch] (output)
219
+ out_is_rejected_token_mask_ptr, # [total_draft_tokens_in_batch] (output)
220
+ out_is_masked_token_mask_ptr, # [total_draft_tokens_in_batch] (output)
221
+ out_new_token_indices_ptr, # [num_padding_slots_per_request * num_reqs] (output)
222
+ out_hidden_state_mapping_ptr, # [total_tokens_in_batch]
223
+ # Input metadata
224
+ query_start_loc_ptr, # [num_reqs + 1], last value is the total num input tokens
225
+ query_end_loc_ptr, # [num_reqs]
226
+ padding_token_id, # tl.int32
227
+ parallel_drafting_token_id, # tl.int32
228
+ # Sizing info
229
+ total_input_tokens, # tl.int32
230
+ num_padding_slots_per_request, # tl.int32
231
+ shift_input_ids, # tl.bool
232
+ BLOCK_SIZE_TOKENS: tl.constexpr, # Blocks along token dim to handle prefills
233
+):
234
+ """
235
+ Copy and expand inputs from the target model to the drafting buffers for Eagle
236
+ speculative decoding. This kernel handles padding slots and parallel drafting
237
+ tokens, if enabled.
238
+ """
239
+ request_idx = tl.program_id(axis=0)
240
+ token_batch_idx = tl.program_id(axis=1)
241
+
242
+ # Load query locations
243
+ query_start_loc = tl.load(query_start_loc_ptr + request_idx)
244
+ next_query_start_loc = tl.load(query_start_loc_ptr + request_idx + 1)
245
+ query_end_loc = tl.load(query_end_loc_ptr + request_idx)
246
+
247
+ # Calculate number of valid tokens to copy and input offset
248
+ # With shift_input_ids=True, we skip the first token
249
+ # Output layout: each request gets (input_len + num_padding_slots_per_request) slots
250
+ # But with shift, we lose one token per request
251
+ if shift_input_ids:
252
+ num_valid_tokens = query_end_loc - query_start_loc
253
+ input_offset = 1
254
+ output_start = query_start_loc + request_idx * (
255
+ num_padding_slots_per_request - 1
256
+ )
257
+ else:
258
+ num_valid_tokens = query_end_loc - query_start_loc + 1
259
+ input_offset = 0
260
+ output_start = query_start_loc + request_idx * num_padding_slots_per_request
261
+
262
+ # Number of rejected tokens from previous speculation
263
+ num_rejected = next_query_start_loc - query_end_loc - 1
264
+
265
+ # Total output tokens for this request
266
+ total_output_tokens = (
267
+ num_valid_tokens + num_padding_slots_per_request + num_rejected
268
+ )
269
+
270
+ # Process tokens in this block
271
+ j = token_batch_idx * BLOCK_SIZE_TOKENS + tl.arange(0, BLOCK_SIZE_TOKENS)
272
+
273
+ # Compute masks for different output regions:
274
+ # [0, num_valid_tokens): valid tokens copied from input
275
+ # [num_valid_tokens]: bonus token from next_token_ids
276
+ # (num_valid_tokens, num_valid_tokens + num_padding_slots_per_request):
277
+ # parallel drafting slots
278
+ # [num_valid_tokens + num_padding_slots_per_request, total_output_tokens):
279
+ # rejected slots
280
+ in_bounds = j < total_output_tokens
281
+ is_valid_region = j < num_valid_tokens
282
+ is_bonus_region = j == num_valid_tokens
283
+ is_parallel_draft_region = (j > num_valid_tokens) & (
284
+ j < num_valid_tokens + num_padding_slots_per_request
285
+ )
286
+ is_rejected_region = j >= num_valid_tokens + num_padding_slots_per_request
287
+
288
+ # Compute output indices
289
+ out_idx = output_start + j
290
+
291
+ # For valid tokens, compute input index
292
+ in_idx = query_start_loc + input_offset + j
293
+ # Clamp to avoid out-of-bounds access (masked loads still need valid addresses)
294
+ in_idx_clamped = tl.minimum(in_idx, total_input_tokens - 1)
295
+
296
+ # Load input tokens (masked to valid region)
297
+ token_ids = tl.load(
298
+ target_token_ids_ptr + in_idx_clamped, mask=is_valid_region & in_bounds, other=0
299
+ )
300
+
301
+ # Load the starting position for this request (first position in the sequence)
302
+ start_pos = tl.load(target_positions_ptr + query_start_loc)
303
+
304
+ # Load bonus token for this request
305
+ bonus_token = tl.load(next_token_ids_ptr + request_idx)
306
+
307
+ # Build final token_ids based on region
308
+ token_ids = tl.where(is_bonus_region, bonus_token, token_ids)
309
+ token_ids = tl.where(
310
+ is_parallel_draft_region, parallel_drafting_token_id, token_ids
311
+ )
312
+ token_ids = tl.where(is_rejected_region, padding_token_id, token_ids)
313
+
314
+ # Build final positions:
315
+ # Positions are NOT shifted - they start from the first input position and increment
316
+ # Output position j gets start_pos + j
317
+ # (e.g., input positions [5,6,7] -> output [5,6,7,8,9,...])
318
+ positions = start_pos + j
319
+ # Rejected positions are don't-care, set to 0
320
+ positions = tl.where(is_rejected_region, 0, positions)
321
+
322
+ # Compute output masks
323
+ is_rejected_out = is_rejected_region & in_bounds
324
+ is_masked_out = is_parallel_draft_region & in_bounds
325
+
326
+ # Compute indices of new tokens (bonus + parallel drafting) for sampling
327
+ # New tokens are at positions
328
+ # [num_valid_tokens, num_valid_tokens + num_padding_slots_per_request)
329
+ is_new_token_region = (j >= num_valid_tokens) & (
330
+ j < num_valid_tokens + num_padding_slots_per_request
331
+ )
332
+ new_token_local_idx = (
333
+ j - num_valid_tokens
334
+ ) # 0 for bonus, 1, 2, ... for parallel drafting
335
+ new_token_out_idx = (
336
+ request_idx * num_padding_slots_per_request + new_token_local_idx
337
+ )
338
+
339
+ # Compute hidden state mapping (source index -> destination index)
340
+ # This maps each input position to its corresponding output position
341
+ # Hidden states don't get shifted, so we map all input tokens (including rejected)
342
+ if shift_input_ids:
343
+ num_input_tokens_this_request = next_query_start_loc - query_start_loc
344
+ is_input_region = j < num_input_tokens_this_request
345
+ src_idx = query_start_loc + j
346
+ tl.store(out_hidden_state_mapping_ptr + src_idx, out_idx, mask=is_input_region)
347
+
348
+ # Store outputs
349
+ tl.store(out_input_ids_ptr + out_idx, token_ids, mask=in_bounds)
350
+ tl.store(out_positions_ptr + out_idx, positions, mask=in_bounds)
351
+ tl.store(out_is_rejected_token_mask_ptr + out_idx, is_rejected_out, mask=in_bounds)
352
+ tl.store(out_is_masked_token_mask_ptr + out_idx, is_masked_out, mask=in_bounds)
353
+ tl.store(
354
+ out_new_token_indices_ptr + new_token_out_idx,
355
+ out_idx,
356
+ mask=is_new_token_region & in_bounds,
357
+ )
vllm/v1/worker/gpu_model_runner.py
+1
-1
@@ -4090,7 +4090,7 @@ class GPUModelRunner(
4090
target_positions=target_positions,
4091
target_hidden_states=target_hidden_states,
4092
next_token_ids=next_token_ids,
4093
- last_token_indices=token_indices_to_sample,
4093
+ token_indices_to_sample=token_indices_to_sample,
4094
sampling_metadata=sampling_metadata,
4095
common_attn_metadata=common_attn_metadata,
4096
mm_embed_inputs=mm_embed_inputs,