Skip to content

Commit 631d876

Browse files
committed
feat: add request-level spec decode acceptance rate metric
Track speculative decoding acceptance at the finished-request level by carrying per-request draft and accepted token counts through EngineCoreOutput into IterationStats. Expose vllm:spec_decode_request_acceptance_rate as a Prometheus histogram that observes each finished request's accepted/draft token ratio, while preserving the existing aggregate spec decoding counters. Add tests for request-level acceptance-rate observation and per-request spec decode stat accumulation.
1 parent 6124a98 commit 631d876

7 files changed

Lines changed: 199 additions & 10 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
4+
from unittest.mock import MagicMock
5+
6+
from vllm.config import SpeculativeConfig
7+
from vllm.v1.spec_decode.metrics import SpecDecodingProm
8+
9+
10+
class MockCounter:
11+
def __init__(self, *args, **kwargs):
12+
self.children = {}
13+
14+
def labels(self, *labelvalues):
15+
child = MagicMock()
16+
self.children[labelvalues] = child
17+
return child
18+
19+
20+
class MockHistogram(MockCounter):
21+
pass
22+
23+
24+
def _make_prom(spec_enabled: bool = True) -> SpecDecodingProm:
25+
class MockSpecDecodingProm(SpecDecodingProm):
26+
_counter_cls = MockCounter
27+
_histogram_cls = MockHistogram
28+
29+
return MockSpecDecodingProm(
30+
speculative_config=(
31+
SpeculativeConfig(
32+
method="ngram",
33+
num_speculative_tokens=2,
34+
)
35+
if spec_enabled
36+
else None
37+
),
38+
labelnames=["model_name", "engine"],
39+
per_engine_labelvalues={0: ["test-model", "0"]},
40+
)
41+
42+
43+
def test_spec_decode_request_acceptance_rate_not_registered_without_spec_decode():
44+
prom = _make_prom(spec_enabled=False)
45+
46+
prom.observe_finished_request(
47+
num_draft_tokens=200,
48+
num_accepted_tokens=10,
49+
)
50+
51+
assert not hasattr(prom, "histogram_spec_decode_request_acceptance_rate")
52+
53+
54+
def test_spec_decode_request_acceptance_rate_observed_for_finished_request():
55+
prom = _make_prom()
56+
57+
prom.observe_finished_request(
58+
num_draft_tokens=200,
59+
num_accepted_tokens=10,
60+
)
61+
62+
prom.histogram_spec_decode_request_acceptance_rate[
63+
0
64+
].observe.assert_called_once_with(0.05)
65+
66+
67+
def test_spec_decode_request_acceptance_rate_skips_request_without_draft_tokens():
68+
prom = _make_prom()
69+
70+
prom.observe_finished_request(
71+
num_draft_tokens=0,
72+
num_accepted_tokens=0,
73+
)
74+
75+
prom.histogram_spec_decode_request_acceptance_rate[0].observe.assert_not_called()

tests/v1/metrics/test_stats.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# SPDX-License-Identifier: Apache-2.0
22
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3-
from vllm.v1.engine import FinishReason
3+
from unittest.mock import MagicMock
4+
5+
from vllm.v1.engine import EngineCoreOutput, FinishReason
46
from vllm.v1.metrics.stats import (
57
IterationStats,
68
PrefillStats,
@@ -122,6 +124,49 @@ def test_prefill_kv_computed_edge_cases():
122124
assert finished_req2.request_id == "test-req-004"
123125

124126

127+
def test_finished_request_includes_spec_decode_stats():
128+
iteration_stats = IterationStats()
129+
req_stats = RequestStateStats(arrival_time=0.0)
130+
req_stats.scheduled_ts = 0.1
131+
req_stats.first_token_ts = 0.5
132+
req_stats.last_token_ts = 1.0
133+
134+
for output in (
135+
EngineCoreOutput(
136+
request_id="test-req-005",
137+
new_token_ids=[1, 2],
138+
num_draft_tokens=4,
139+
num_accepted_tokens=1,
140+
),
141+
EngineCoreOutput(
142+
request_id="test-req-005",
143+
new_token_ids=[3, 4, 5],
144+
num_draft_tokens=4,
145+
num_accepted_tokens=2,
146+
),
147+
):
148+
iteration_stats.update_from_output(
149+
output=output,
150+
engine_core_timestamp=2.0,
151+
is_prefilling=False,
152+
req_stats=req_stats,
153+
lora_states=MagicMock(),
154+
lora_name=None,
155+
)
156+
157+
iteration_stats.update_from_finished_request(
158+
finish_reason=FinishReason.STOP,
159+
request_id="test-req-005",
160+
num_prompt_tokens=2000,
161+
max_tokens_param=300,
162+
req_stats=req_stats,
163+
)
164+
165+
finished_req = iteration_stats.finished_requests[0]
166+
assert finished_req.num_draft_tokens == 8
167+
assert finished_req.num_accepted_tokens == 3
168+
169+
125170
def test_prompt_token_stats_all_computed():
126171
"""Test all tokens computed locally, no caching."""
127172
stats = PromptTokenStats()

vllm/v1/core/sched/scheduler.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,26 +1414,33 @@ def update_from_output(
14141414
scheduled_spec_token_ids = (
14151415
scheduler_output.scheduled_spec_decode_tokens.get(req_id)
14161416
)
1417+
num_draft_tokens = 0
1418+
num_accepted_tokens = 0
1419+
num_invalid_spec_tokens = 0
14171420
if scheduled_spec_token_ids and generated_token_ids:
14181421
num_draft_tokens = len(scheduled_spec_token_ids)
1419-
num_accepted = len(generated_token_ids) - 1
1420-
num_rejected = num_draft_tokens - num_accepted
1422+
num_accepted_tokens = len(generated_token_ids) - 1
1423+
num_rejected_tokens = num_draft_tokens - num_accepted_tokens
1424+
if scheduler_output.num_invalid_spec_tokens:
1425+
num_invalid_spec_tokens = (
1426+
scheduler_output.num_invalid_spec_tokens.get(req_id, 0)
1427+
)
14211428
# num_computed_tokens represents the number of tokens
14221429
# processed in the current step, considering scheduled
14231430
# tokens and rejections. If some tokens are rejected,
14241431
# num_computed_tokens is decreased by the number of rejected
14251432
# tokens.
14261433
if request.num_computed_tokens > 0:
1427-
request.num_computed_tokens -= num_rejected
1434+
request.num_computed_tokens -= num_rejected_tokens
14281435
# If async scheduling, num_output_placeholders also includes
14291436
# the scheduled spec tokens count and so is similarly adjusted.
14301437
if request.num_output_placeholders > 0:
1431-
request.num_output_placeholders -= num_rejected
1438+
request.num_output_placeholders -= num_rejected_tokens
14321439
spec_decoding_stats = self.make_spec_decoding_stats(
14331440
spec_decoding_stats,
14341441
num_draft_tokens=num_draft_tokens,
1435-
num_accepted_tokens=num_accepted,
1436-
num_invalid_spec_tokens=scheduler_output.num_invalid_spec_tokens,
1442+
num_accepted_tokens=num_accepted_tokens,
1443+
num_invalid_spec_tokens=num_invalid_spec_tokens,
14371444
request_id=req_id,
14381445
)
14391446

@@ -1565,6 +1572,9 @@ def update_from_output(
15651572
trace_headers=request.trace_headers,
15661573
routed_experts=routed_experts,
15671574
num_nans_in_logits=request.num_nans_in_logits,
1575+
num_draft_tokens=num_draft_tokens,
1576+
num_accepted_tokens=num_accepted_tokens,
1577+
num_invalid_spec_tokens=num_invalid_spec_tokens,
15681578
)
15691579
)
15701580
else:
@@ -2063,15 +2073,14 @@ def make_spec_decoding_stats(
20632073
spec_decoding_stats: SpecDecodingStats | None,
20642074
num_draft_tokens: int,
20652075
num_accepted_tokens: int,
2066-
num_invalid_spec_tokens: dict[str, int] | None,
2076+
num_invalid_spec_tokens: int,
20672077
request_id: str,
20682078
) -> SpecDecodingStats | None:
20692079
if not self.log_stats or not num_draft_tokens:
20702080
return None
20712081
if spec_decoding_stats is None:
20722082
spec_decoding_stats = SpecDecodingStats.new(self.num_spec_tokens)
2073-
if num_invalid_spec_tokens:
2074-
num_draft_tokens -= num_invalid_spec_tokens.get(request_id, 0)
2083+
num_draft_tokens -= num_invalid_spec_tokens
20752084
spec_decoding_stats.observe_draft(
20762085
num_draft_tokens=num_draft_tokens, num_accepted_tokens=num_accepted_tokens
20772086
)

vllm/v1/engine/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ class EngineCoreOutput(
194194
# The number of NaNs in logits.
195195
# A value greater than 0 indicates that the output is corrupted.
196196
num_nans_in_logits: int = 0
197+
num_draft_tokens: int = 0
198+
num_accepted_tokens: int = 0
199+
num_invalid_spec_tokens: int = 0
197200

198201
@property
199202
def finished(self) -> bool:

vllm/v1/metrics/loggers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,12 @@ def record(
12161216
finished_request.max_tokens_param
12171217
)
12181218

1219+
self.spec_decoding_prom.observe_finished_request(
1220+
num_draft_tokens=finished_request.num_draft_tokens,
1221+
num_accepted_tokens=finished_request.num_accepted_tokens,
1222+
engine_idx=engine_idx,
1223+
)
1224+
12191225
def record_sleep_state(self, sleep: int = 0, level: int = 0):
12201226
awake = 1
12211227
discard_all = 0

vllm/v1/metrics/stats.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ class RequestStateStats:
218218

219219
# Track if this request is corrupted (NaNs in logits)
220220
is_corrupted: bool = False
221+
num_draft_tokens: int = 0
222+
num_accepted_tokens: int = 0
221223

222224

223225
@dataclass
@@ -237,6 +239,8 @@ class FinishedRequestStats:
237239
mean_time_per_output_token: float = 0.0
238240
is_corrupted: bool = False
239241
num_cached_tokens: int = 0
242+
num_draft_tokens: int = 0
243+
num_accepted_tokens: int = 0
240244

241245

242246
@dataclass
@@ -371,6 +375,10 @@ def update_from_output(
371375
req_stats.first_token_latency = first_token_latency
372376

373377
req_stats.num_generation_tokens += num_new_generation_tokens
378+
req_stats.num_draft_tokens += max(
379+
0, output.num_draft_tokens - output.num_invalid_spec_tokens
380+
)
381+
req_stats.num_accepted_tokens += output.num_accepted_tokens
374382

375383
# Track if this request is corrupted (only check once per request)
376384
# Early exit if already marked as corrupted to avoid redundant checks
@@ -472,6 +480,8 @@ def update_from_finished_request(
472480
mean_time_per_output_token=mean_time_per_output_token,
473481
is_corrupted=req_stats.is_corrupted,
474482
num_cached_tokens=num_cached_tokens,
483+
num_draft_tokens=req_stats.num_draft_tokens,
484+
num_accepted_tokens=req_stats.num_accepted_tokens,
475485
)
476486
self.finished_requests.append(finished_req)
477487

vllm/v1/spec_decode/metrics.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ class SpecDecodingProm:
140140
"""
141141

142142
_counter_cls = prometheus_client.Counter
143+
_histogram_cls = prometheus_client.Histogram
143144

144145
def __init__(
145146
self,
@@ -148,9 +149,35 @@ def __init__(
148149
per_engine_labelvalues: dict[int, list[object]],
149150
):
150151
self.spec_decoding_enabled = speculative_config is not None
152+
151153
if not self.spec_decoding_enabled:
152154
return
153155

156+
histogram_request_acceptance_rate = self._histogram_cls(
157+
name="vllm:spec_decode_request_acceptance_rate",
158+
documentation=(
159+
"Histogram of request-level speculative decoding draft "
160+
"acceptance rates for finished requests."
161+
),
162+
buckets=[
163+
0.0,
164+
0.1,
165+
0.2,
166+
0.3,
167+
0.4,
168+
0.5,
169+
0.6,
170+
0.7,
171+
0.8,
172+
0.9,
173+
1.0,
174+
],
175+
labelnames=labelnames,
176+
)
177+
self.histogram_spec_decode_request_acceptance_rate = create_metric_per_engine(
178+
histogram_request_acceptance_rate, per_engine_labelvalues
179+
)
180+
154181
counter_drafts = self._counter_cls(
155182
name="vllm:spec_decode_num_drafts",
156183
documentation="Number of spec decoding drafts.",
@@ -213,3 +240,17 @@ def observe(self, spec_decoding_stats: SpecDecodingStats, engine_idx: int = 0):
213240
self.counter_spec_decode_num_accepted_tokens_per_pos[engine_idx]
214241
):
215242
counter.inc(spec_decoding_stats.num_accepted_tokens_per_pos[pos])
243+
244+
def observe_finished_request(
245+
self,
246+
num_draft_tokens: int,
247+
num_accepted_tokens: int,
248+
engine_idx: int = 0,
249+
) -> None:
250+
if not self.spec_decoding_enabled or num_draft_tokens <= 0:
251+
return
252+
253+
acceptance_rate = num_accepted_tokens / num_draft_tokens
254+
self.histogram_spec_decode_request_acceptance_rate[engine_idx].observe(
255+
acceptance_rate
256+
)

0 commit comments

Comments
 (0)