| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """CPU speculative-decoding end-to-end correctness.""" |
| 4 | |
| 5 | import os |
| 6 | |
| 7 | import pytest |
| 8 | |
| 9 | from tests.models.utils import check_logprobs_close |
| 10 | from tests.v1.e2e.spec_decode.utils import compute_acceptance_len |
| 11 | from vllm import LLM, SamplingParams |
| 12 | from vllm.platforms import current_platform |
| 13 | from vllm.triton_utils import HAS_TRITON |
| 14 | |
| 15 | pytestmark = pytest.mark.cpu_model |
| 16 | |
| 17 | if not current_platform.is_cpu(): |
| 18 | pytest.skip("skipping CPU-only tests", allow_module_level=True) |
| 19 | |
| 20 | # Bound the KV cache so the run does not scale with host memory. |
| 21 | os.environ.setdefault("VLLM_CPU_KVCACHE_SPACE", "1") |
| 22 | |
| 23 | MODEL = "Qwen/Qwen3-0.6B" |
| 24 | NUM_LOGPROBS = 5 |
| 25 | SP = SamplingParams(max_tokens=48, temperature=0, logprobs=NUM_LOGPROBS) |
| 26 | |
| 27 | # Repetitive prompts so the ngram proposer finds matches and actually drafts; |
| 28 | # without hits the run is indistinguishable from plain decoding. |
| 29 | PROMPTS = [ |
| 30 | "The capital of France is Paris. The capital of Italy is Rome. " |
| 31 | "The capital of Spain is Madrid. The capital of France is", |
| 32 | "def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n\n" |
| 33 | "def mul(a, b):\n return", |
| 34 | "one two three four five one two three four five one two three four", |
| 35 | ] |
| 36 | |
| 37 | |
| 38 | def _make_llm(**overrides) -> LLM: |
| 39 | base = dict( |
| 40 | model=MODEL, |
| 41 | dtype="bfloat16", |
| 42 | max_model_len=1024, |
| 43 | disable_log_stats=False, |
| 44 | ) |
| 45 | base.update(overrides) |
| 46 | return LLM(**base) |
| 47 | |
| 48 | |
| 49 | def _tuples(outputs) -> list[tuple[list[int], str, object]]: |
| 50 | return [ |
| 51 | (list(o.outputs[0].token_ids), o.outputs[0].text, o.outputs[0].logprobs) |
| 52 | for o in outputs |
| 53 | ] |
| 54 | |
| 55 | |
| 56 | @pytest.fixture(scope="module") |
| 57 | def baseline_refs(): |
| 58 | llm = _make_llm() |
| 59 | refs = _tuples(llm.generate(PROMPTS, SP)) |
| 60 | del llm |
| 61 | return refs |
| 62 | |
| 63 | |
| 64 | def _spec_metric(llm: LLM, name: str) -> float: |
| 65 | for metric in llm.get_metrics(): |
| 66 | if metric.name == name: |
| 67 | return float(getattr(metric, "value", 0.0)) |
| 68 | return 0.0 |
| 69 | |
| 70 | |
| 71 | def test_ngram_spec_decode_matches_baseline(baseline_refs): |
| 72 | """Greedy output must be unchanged by speculative decoding. |
| 73 | |
| 74 | The drafted-token check keeps the test from passing vacuously when the |
| 75 | proposer silently produces nothing. |
| 76 | """ |
| 77 | llm = _make_llm( |
| 78 | speculative_config={ |
| 79 | "method": "ngram", |
| 80 | "prompt_lookup_max": 5, |
| 81 | "prompt_lookup_min": 3, |
| 82 | "num_speculative_tokens": 3, |
| 83 | }, |
| 84 | ) |
| 85 | got = _tuples(llm.generate(PROMPTS, SP)) |
| 86 | drafted = _spec_metric(llm, "vllm:spec_decode_num_draft_tokens") |
| 87 | accepted = _spec_metric(llm, "vllm:spec_decode_num_accepted_tokens") |
| 88 | del llm |
| 89 | |
| 90 | assert drafted > 0, "ngram proposer drafted no tokens; the test would be vacuous" |
| 91 | assert accepted > 0, f"no draft tokens accepted out of {drafted} drafted" |
| 92 | |
| 93 | check_logprobs_close( |
| 94 | outputs_0_lst=baseline_refs, |
| 95 | outputs_1_lst=got, |
| 96 | name_0="no_spec_decode", |
| 97 | name_1="ngram_spec_decode", |
| 98 | ) |
| 99 | |
| 100 | |
| 101 | SMOKE_PROMPTS = [ |
| 102 | "The capital of France is", |
| 103 | "2 + 2 equals", |
| 104 | "In one word, the color of the sky is", |
| 105 | "Q: If a train travels 60 miles in 1.5 hours, what is its average speed?\nA:", |
| 106 | ] |
| 107 | SMOKE_SP = SamplingParams(temperature=0.0, max_tokens=32, ignore_eos=True) |
| 108 | |
| 109 | # Methods routed to the V2 runner, using the same target/draft pairs as the GPU |
| 110 | # suite. MTP needs no separate draft checkpoint: its speculator layer is |
| 111 | # trained jointly with, and shipped inside, the target model's own weights. |
| 112 | SMOKE_CONFIGS = [ |
| 113 | pytest.param( |
| 114 | "meta-llama/Llama-3.2-1B-Instruct", |
| 115 | { |
| 116 | "method": "eagle3", |
| 117 | "model": "nm-testing/Llama3_2_1B_speculator.eagle3", |
| 118 | "num_speculative_tokens": 3, |
| 119 | }, |
| 120 | id="eagle3", |
| 121 | ), |
| 122 | pytest.param( |
| 123 | "Qwen/Qwen3-4B", |
| 124 | { |
| 125 | "method": "dflash", |
| 126 | "model": "z-lab/Qwen3-4B-DFlash-b16", |
| 127 | # The checkpoint allows 16, but accepted tokens plateau around 64 |
| 128 | # regardless, so a larger draft only burns CPU on rejected tokens. |
| 129 | "num_speculative_tokens": 4, |
| 130 | }, |
| 131 | id="dflash", |
| 132 | ), |
| 133 | pytest.param( |
| 134 | "Qwen/Qwen3.5-0.8B", |
| 135 | { |
| 136 | "method": "mtp", |
| 137 | "num_speculative_tokens": 3, |
| 138 | }, |
| 139 | id="mtp", |
| 140 | ), |
| 141 | ] |
| 142 | |
| 143 | # Measured 1.85 (eagle3), 1.99 (dflash), 2.29 (mtp) on CPU; this floor leaves |
| 144 | # room for numeric drift while still failing if speculation stops paying off. |
| 145 | MIN_ACCEPTANCE_LEN = 1.5 |
| 146 | |
| 147 | |
| 148 | @pytest.mark.skipif( |
| 149 | not HAS_TRITON, reason="the V2 runner needs triton-cpu, which CI builds" |
| 150 | ) |
| 151 | @pytest.mark.parametrize("model,speculative_config", SMOKE_CONFIGS) |
| 152 | def test_v2_speculator_smoke(model: str, speculative_config: dict): |
| 153 | """Gates that drafts are accepted often enough for speculation to pay off.""" |
| 154 | llm = _make_llm(model=model, speculative_config=speculative_config) |
| 155 | try: |
| 156 | assert llm.llm_engine.vllm_config.use_v2_model_runner, ( |
| 157 | f"{speculative_config['method']} fell back to the V1 runner; " |
| 158 | "this test is meant to cover the V2 path" |
| 159 | ) |
| 160 | llm.generate(SMOKE_PROMPTS, SMOKE_SP) |
| 161 | acceptance = compute_acceptance_len(llm.get_metrics()) |
| 162 | assert acceptance >= MIN_ACCEPTANCE_LEN, ( |
| 163 | f"acceptance length {acceptance:.3f} below {MIN_ACCEPTANCE_LEN}; " |
| 164 | "drafts are being rejected far more than expected" |
| 165 | ) |
| 166 | finally: |
| 167 | del llm |