[Frontend][Core][Spec Decode] Per-request acceptance stats in OpenAI API responses (#48915)

Signed-off-by: Matthew Kotila <7692737+matthewkotila@users.noreply.github.com> Co-authored-by: Reed Meyerson <31574681+reed-meyerson@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Matthew Kotila committed Aug 20, 2026 at 15:42 UTC 7cfb97e33791a348cd5d7b622cca521d82d8399f
25 files changed +798 -76
docs/features/per_request_metrics.md
+9
@@ -125,3 +125,12 @@ a single prompt's generation.
125 The `metrics` response field provides per-request values for a single request.
126 The `/metrics` Prometheus endpoint exposes server-level histograms (e.g.
127 `vllm:time_to_first_token_seconds`) that aggregate across all requests.
128 +
129 +## Speculative Decoding Acceptance
130 +
131 +When speculative decoding is enabled, per-request acceptance metrics
132 +(mean acceptance length and the accepted-draft-length distribution) can be
133 +returned via `--per-request-spec-decode-metrics`. They share this `metrics`
134 +object as `metrics.speculative_decoding`, and — like the timing fields — are
135 +reported only for single-sequence (`n == 1`) requests. See
136 +[Per-Request Acceptance Metrics](speculative_decoding/acceptance_metrics.md).
docs/features/speculative_decoding/README.md
+1
@@ -19,6 +19,7 @@ vLLM supports a variety of methods of speculative decoding. Model-based methods
19 - [Custom Proposer Backend (Experimental)](#custom-proposer-backend-experimental)
20 - [Dynamic Speculative Decoding](dynamic_speculative_decoding.md)
21 - [Adaptive Verification](adaptive_verification.md)
22 +- [Per-Request Acceptance Metrics](acceptance_metrics.md)
23
24 ## Method Selection at a Glance
25
docs/features/speculative_decoding/acceptance_metrics.md new
+100
@@ -0,0 +1,100 @@
1 +# Per-Request Acceptance Metrics
2 +
3 +When speculative decoding is enabled, vLLM can report per-request acceptance
4 +metrics in the response, under `metrics.speculative_decoding`. This lets a
5 +client compute the mean acceptance length and the accepted-draft-length
6 +distribution for an individual request, as a complement to the server-aggregated
7 +spec-decode metrics exposed at `/metrics`.
8 +
9 +!!! warning "Experimental"
10 + `metrics.speculative_decoding` is experimental and its shape may change in a
11 + future release. Pin to a vLLM version if you depend on it.
12 +
13 +## Enabling
14 +
15 +Start the server with `--per-request-spec-decode-metrics` set to `summary` or
16 +`detailed` (default `none`):
17 +
18 +```bash
19 +vllm serve <target-model> \
20 + --speculative-config '{"method": "ngram", "num_speculative_tokens": 3, "prompt_lookup_min": 1, "prompt_lookup_max": 3}' \
21 + --per-request-spec-decode-metrics summary
22 +```
23 +
24 +| Level | Behavior |
25 +| --- | --- |
26 +| `none` (default) | No collection; responses are unchanged. |
27 +| `summary` | Acceptance metrics per request. |
28 +| `detailed` | `summary` plus ordered per-step arrays. |
29 +
30 +Collection is gated at the source: with `none`, nothing is accumulated.
31 +
32 +## Response Format
33 +
34 +Acceptance metrics share the top-level `metrics` object with the timing
35 +[per-request metrics](../per_request_metrics.md) — `metrics.speculative_decoding`
36 +sits alongside the timing fields. Like timing, they describe a single generation
37 +stream, so they are reported only for single-sequence requests and are `null`
38 +for `n > 1`.
39 +
40 +A `summary` response's `metrics` looks like:
41 +
42 +```json
43 +{
44 + "choices": [ ... ],
45 + "usage": { ... },
46 + "metrics": {
47 + "speculative_decoding": {
48 + "mean_acceptance_length": 1.2325581395348837,
49 + "draft_acceptance_rate": 0.07751937984496124,
50 + "acceptance_histogram": [39, 1, 0, 3],
51 + "num_spec_steps": 43,
52 + "num_accepted_draft_tokens": 10,
53 + "num_draft_tokens": 129,
54 + "num_spec_tokens": 3
55 + }
56 + }
57 +}
58 +```
59 +
60 +| Field | Description |
61 +| --- | --- |
62 +| `mean_acceptance_length` | Mean tokens emitted per verification step, including the bonus token: `1 + num_accepted_draft_tokens / num_spec_steps`. Ranges from `1.0` (nothing accepted) to `num_spec_tokens + 1`. |
63 +| `draft_acceptance_rate` | Fraction of proposed draft tokens accepted: `num_accepted_draft_tokens / num_draft_tokens`. |
64 +| `acceptance_histogram` | Dense list of length `num_spec_tokens + 1`; index `j` is the number of steps that accepted exactly `j` draft tokens. Excludes the always-accepted bonus token. |
65 +| `num_spec_steps` | Number of verification steps for this request (the sum of the histogram). |
66 +| `num_accepted_draft_tokens` | Total accepted draft tokens, excluding bonus tokens. |
67 +| `num_draft_tokens` | Total proposed draft tokens, after subtracting drafts invalidated by structured-output constraints. |
68 +| `num_spec_tokens` | Configured `num_speculative_tokens` (`k`), i.e. the maximum draft length per step. |
69 +
70 +With `detailed`, two ordered arrays are added, one entry per verification step:
71 +
72 +| Field | Description |
73 +| --- | --- |
74 +| `per_step_accepted` | Accepted draft count at each step. |
75 +| `per_step_drafted` | Proposed draft count at each step. Records the effective proposal length per step, so variable-length drafting (e.g. adaptive speculation) is represented without a schema change. |
76 +
77 +`metrics.speculative_decoding` is present whenever `--per-request-spec-decode-metrics`
78 +is `summary`/`detailed`, speculative decoding is enabled, and `n == 1` (with an
79 +all-zero histogram if the request drafted nothing). It is `null` otherwise.
80 +
81 +## Streaming
82 +
83 +In streaming responses, `metrics` (including `speculative_decoding`) rides the
84 +final usage chunk, which is only emitted when usage reporting is enabled — set
85 +`stream_options.include_usage: true` or start the server with
86 +`--enable-force-include-usage`.
87 +
88 +## Relationship to Prometheus metrics
89 +
90 +The per-request fields are the individual-request counterpart of the
91 +server-aggregated spec-decode counters at `/metrics`. Summed across the
92 +single-sequence requests that report them, they reconcile with the aggregate
93 +counters (which also count `n > 1` requests, so the totals match only for
94 +all-`n == 1` workloads):
95 +
96 +| Per-request field (summed) | Prometheus counter |
97 +| --- | --- |
98 +| `num_spec_steps` | `vllm:spec_decode_num_drafts_total` |
99 +| `num_draft_tokens` | `vllm:spec_decode_num_draft_tokens_total` |
100 +| `num_accepted_draft_tokens` | `vllm:spec_decode_num_accepted_tokens_total` |
rust/src/engine-core-client/src/protocol/output.rs
+6
@@ -125,6 +125,11 @@ pub struct EngineCoreOutput {
125 pub mm_cache_miss_hashes: Option<Vec<String>>,
126 #[serde(default)]
127 pub new_sampling_mask: Option<OpaqueValue>,
128 + /// Per-request speculative-decoding acceptance metrics, set on the final
129 + /// output when `--per-request-spec-decode-metrics` is enabled. Opaque here;
130 + /// the Rust frontend does not yet surface it in responses.
131 + #[serde(default)]
132 + pub spec_decode_metrics: Option<OpaqueValue>,
133 }
134
135 impl EngineCoreOutput {
@@ -443,6 +448,7 @@ mod tests {
448 num_nans_in_logits: 0,
449 mm_cache_miss_hashes: None,
450 new_sampling_mask: None,
451 + spec_decode_metrics: None,
452 },
453 ],
454 scheduler_stats: None,
rust/src/engine-core-client/src/tests/client.rs
+1
@@ -2743,6 +2743,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2743 num_nans_in_logits: 0,
2744 mm_cache_miss_hashes: None,
2745 new_sampling_mask: None,
2746 + spec_decode_metrics: None,
2747 },
2748 ],
2749 scheduler_stats: None,
tests/entrypoints/openai/completion/test_completion_error.py
+109 -1
@@ -23,7 +23,7 @@ from vllm.renderers.hf import HfRenderer
23 from vllm.renderers.online_renderer import OnlineRenderer
24 from vllm.tokenizers.registry import cached_tokenizer_from_config
25 from vllm.v1.engine.async_llm import AsyncLLM
26 -from vllm.v1.metrics.stats import RequestStateStats
26 +from vllm.v1.metrics.stats import RequestSpecDecodeMetrics, RequestStateStats
27
28 MODEL_NAME = "openai-community/gpt2"
29 MODEL_NAME_SHORT = "gpt2"
@@ -196,6 +196,114 @@ def test_completion_per_request_metrics_suppressed_for_multiple_prompts():
196 assert response.metrics is None
197
198
199 +def _spec_decode_metrics() -> RequestSpecDecodeMetrics:
200 + # Two verify steps: accept 3 drafts, then 1 -> histogram [0, 1, 0, 1].
201 + m = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
202 + m.observe(num_draft_tokens=3, num_accepted=3)
203 + m.observe(num_draft_tokens=3, num_accepted=1)
204 + return m
205 +
206 +
207 +def _make_spec_decode_request_output(
208 + num_seqs: int = 1, with_metrics: bool = True
209 +) -> RequestOutput:
210 + outputs = [
211 + CompletionOutput(
212 + index=i,
213 + text="Hello",
214 + token_ids=[100, 101],
215 + cumulative_logprob=None,
216 + logprobs=None,
217 + finish_reason="stop",
218 + spec_decode_metrics=_spec_decode_metrics() if with_metrics else None,
219 + )
220 + for i in range(num_seqs)
221 + ]
222 + return RequestOutput(
223 + request_id="test-id",
224 + prompt="Test prompt",
225 + prompt_token_ids=[1, 2, 3],
226 + prompt_logprobs=None,
227 + outputs=outputs,
228 + finished=True,
229 + metrics=None,
230 + )
231 +
232 +
233 +def _completion_response(serving, request, request_output):
234 + return serving.request_output_to_completion_response(
235 + [request_output],
236 + request,
237 + "cmpl-test-id",
238 + 0,
239 + MODEL_NAME,
240 + None,
241 + RequestResponseMetadata(request_id="cmpl-test-id"),
242 + )
243 +
244 +
245 +def test_completion_spec_decode_metrics_present_for_single_sequence():
246 + # Timing off, but the sequence carries acceptance metrics -> the metrics
247 + # object is created just to hold metrics.speculative_decoding.
248 + serving = _build_minimal_metrics_serving_completion(
249 + enable_per_request_metrics=False
250 + )
251 + response = _completion_response(
252 + serving,
253 + CompletionRequest(model=MODEL_NAME, prompt="Test prompt", max_tokens=10),
254 + _make_spec_decode_request_output(num_seqs=1),
255 + )
256 + assert response.metrics is not None
257 + assert response.metrics.time_to_first_token_ms is None # timing not requested
258 + spec = response.metrics.speculative_decoding
259 + assert spec is not None
260 + assert spec.acceptance_histogram == [0, 1, 0, 1] # dense, index j
261 + assert spec.num_spec_steps == 2
262 + assert spec.num_spec_tokens == 3
263 + assert spec.mean_acceptance_length == pytest.approx(3.0) # 1 + (3 + 1) / 2
264 +
265 +
266 +def test_completion_spec_decode_metrics_suppressed_for_n_gt_1():
267 + # Per-request metrics can't be attributed to one of the n sequences.
268 + serving = _build_minimal_metrics_serving_completion(
269 + enable_per_request_metrics=False
270 + )
271 + response = _completion_response(
272 + serving,
273 + CompletionRequest(model=MODEL_NAME, prompt="Test prompt", n=2, max_tokens=10),
274 + _make_spec_decode_request_output(num_seqs=2),
275 + )
276 + assert response.metrics is None
277 +
278 +
279 +def test_completion_spec_decode_metrics_absent_when_not_collected():
280 + # Flag off -> the sequence carries no acceptance metrics -> no metrics object.
281 + serving = _build_minimal_metrics_serving_completion(
282 + enable_per_request_metrics=False
283 + )
284 + response = _completion_response(
285 + serving,
286 + CompletionRequest(model=MODEL_NAME, prompt="Test prompt", max_tokens=10),
287 + _make_spec_decode_request_output(num_seqs=1, with_metrics=False),
288 + )
289 + assert response.metrics is None
290 +
291 +
292 +def test_completion_metrics_carries_both_timing_and_spec_decode():
293 + serving = _build_minimal_metrics_serving_completion(enable_per_request_metrics=True)
294 + request_output = _make_spec_decode_request_output(num_seqs=1)
295 + request_output.metrics = _PER_REQUEST_STATS # timing source
296 + response = _completion_response(
297 + serving,
298 + CompletionRequest(model=MODEL_NAME, prompt="Test prompt", max_tokens=10),
299 + request_output,
300 + )
301 + assert response.metrics is not None
302 + assert response.metrics.time_to_first_token_ms == pytest.approx(500.0)
303 + assert response.metrics.speculative_decoding is not None
304 + assert response.metrics.speculative_decoding.num_spec_steps == 2
305 +
306 +
307 @pytest.mark.asyncio
308 async def test_completion_error_non_stream():
309 """test finish_reason='error' returns 500 InternalServerError (non-streaming)"""
tests/test_config.py
+14
@@ -20,6 +20,7 @@ from vllm.config import (
20 CompilationConfig,
21 KernelConfig,
22 ModelConfig,
23 + ObservabilityConfig,
24 ParallelConfig,
25 PoolerConfig,
26 SchedulerConfig,
@@ -85,6 +86,19 @@ def test_kda_recoverssm_derivation_is_revalidated():
86 VllmConfig.validate_mamba_cached_kernel(config)
87
88
89 +def test_per_request_spec_decode_metrics_requires_spec_decode():
90 + # The flag only makes sense with speculative decoding configured; enabling
91 + # it without --speculative-config should fail fast rather than silently
92 + # produce no metrics.
93 + for level in ("summary", "detailed"):
94 + with pytest.raises(ValueError, match="speculative"):
95 + VllmConfig(
96 + observability_config=ObservabilityConfig(
97 + per_request_spec_decode_metrics=level
98 + )
99 + )
100 +
101 +
102 def test_compile_config_repr_succeeds():
103 # setup: VllmBackend mutates the config object
104 config = VllmConfig()
tests/v1/core/test_scheduler.py
+113 -9
@@ -1306,29 +1306,42 @@ def test_draft_slots_budgeted_per_scheduled_request(tmp_path, monkeypatch):
1306
1307 # Note - these test cases mirror some of those in test_rejection_sampler.py
1308 @pytest.mark.parametrize(
1309 - "spec_tokens,output_tokens,expected",
1309 + "spec_tokens,output_tokens,expected,expected_per_req",
1310 [
1311 - ([[1, 2, 3]], [[1, 2, 3, 4]], (1, 3, 3, [1, 1, 1])), # perfect match
1312 - ([[1, 2, 3]], [[1, 5]], (1, 3, 1, [1, 0, 0])), # early mismatch
1313 - ([[1, 2], [3]], [[1, 2, 5], [3, 4]], (2, 3, 3, [2, 1])), # multiple sequences
1314 - ([[1]], [[1, 2]], (1, 1, 1, [1])), # single token sequence
1315 - ([[]], [[5]], (0, 0, 0, [0])), # empty sequence
1311 + ([[1, 2, 3]], [[1, 2, 3, 4]], (1, 3, 3, [1, 1, 1]), [[0, 0, 0, 1]]), # perfect
1312 + ([[1, 2, 3]], [[1, 5]], (1, 3, 1, [1, 0, 0]), [[0, 1, 0, 0]]), # early mismatch
1313 + (
1314 + [[1, 2], [3]],
1315 + [[1, 2, 5], [3, 4]],
1316 + (2, 3, 3, [2, 1]),
1317 + [[0, 0, 1], [0, 1, 0]],
1318 + ), # multiple sequences
1319 + ([[1]], [[1, 2]], (1, 1, 1, [1]), [[0, 1]]), # single token sequence
1320 + ([[]], [[5]], (0, 0, 0, [0]), [[0, 0]]), # empty sequence -> empty accumulator
1321 (
1322 [[1, 2, 3], [4, 5, 6]],
1323 [[1, 2, 7], [4, 8]],
1324 (2, 6, 3, [2, 1, 0]),
1325 + [[0, 0, 1, 0], [0, 1, 0, 0]],
1326 ), # multiple mismatches
1327 ],
1328 )
1323 -def test_schedule_spec_decoding_stats(spec_tokens, output_tokens, expected):
1329 +def test_schedule_spec_decoding_stats(
1330 + spec_tokens, output_tokens, expected, expected_per_req
1331 +):
1332 """Test scheduling behavior with speculative decoding.
1333
1334 This test verifies that:
1335 1. Speculated tokens get scheduled correctly
1328 - 2. Spec decoding stats properly count number of draft and accepted tokens
1336 + 2. The aggregate SpecDecodingStats count draft and accepted tokens
1337 + 3. The per-request accumulator (enabled via per_request_spec_decode_metrics)
1338 + buckets the same acceptance by accepted draft count (j)
1339 """
1340 num_spec_tokens = max(1, max(len(t) for t in spec_tokens))
1331 - scheduler = create_scheduler(num_speculative_tokens=num_spec_tokens)
1341 + scheduler = create_scheduler(
1342 + num_speculative_tokens=num_spec_tokens,
1343 + per_request_spec_decode_metrics="summary",
1344 + )
1345 requests = create_requests(num_requests=len(spec_tokens), num_tokens=1)
1346 req_ids = []
1347 req_to_index = {}
@@ -1414,6 +1427,97 @@ def test_schedule_spec_decoding_stats(spec_tokens, output_tokens, expected):
1427 assert stats.num_accepted_tokens == expected[2]
1428 assert stats.num_accepted_tokens_per_pos == expected[3]
1429
1430 + # Per-request accumulator: the same acceptance, bucketed by accepted draft
1431 + # count (j) on each request rather than summed across the batch. The
1432 + # accumulator is created eagerly on add_request, so every request has one
1433 + # (an empty histogram when it drafted nothing).
1434 + for i, req_id in enumerate(req_ids):
1435 + payload = scheduler.requests[req_id].spec_decode_metrics.to_dict()
1436 + assert payload["acceptance_histogram"] == expected_per_req[i]
1437 + assert payload["num_draft_tokens"] == len(spec_tokens[i])
1438 + assert "per_step_accepted" not in payload # summary level
1439 +
1440 +
1441 +def _run_spec_verify_steps(scheduler, rounds, num_invalid_per_round=None):
1442 + """Drive prefill + one draft/verify step per round for a single request.
1443 +
1444 + ``rounds`` is a list of ``(spec_token_ids, output_token_ids)`` -- one verify
1445 + step each. ``num_invalid_per_round`` optionally injects the grammar-invalid
1446 + draft count structured-output decoding would set on the verify output.
1447 + Returns the request.
1448 + """
1449 + [req] = create_requests(num_requests=1, num_tokens=1)
1450 + scheduler.add_request(req)
1451 + rid = req.request_id
1452 + req_to_index = {rid: 0}
1453 +
1454 + def _mk_output(sampled):
1455 + return ModelRunnerOutput(
1456 + req_ids=[rid],
1457 + req_id_to_index=req_to_index,
1458 + sampled_token_ids=sampled,
1459 + logprobs=None,
1460 + prompt_logprobs_dict={},
1461 + pooler_output=[],
1462 + )
1463 +
1464 + # Prefill: sample one token.
1465 + scheduler.update_from_output(scheduler.schedule(), _mk_output([[0]]))
1466 + for i, (spec, out) in enumerate(rounds):
1467 + scheduler.update_draft_token_ids(DraftTokenIds([rid], [spec]))
1468 + output = scheduler.schedule()
1469 + if num_invalid_per_round is not None and num_invalid_per_round[i]:
1470 + output.num_invalid_spec_tokens = {rid: num_invalid_per_round[i]}
1471 + scheduler.update_from_output(output, _mk_output([out]))
1472 + return req
1473 +
1474 +
1475 +def test_per_request_spec_decode_detailed_records_per_step():
1476 + scheduler = create_scheduler(
1477 + num_speculative_tokens=3,
1478 + per_request_spec_decode_metrics="detailed",
1479 + )
1480 + # Three verify steps for one request; num_accepted = len(output) - 1, so the
1481 + # outputs below accept 3, 1, then 0 drafts across the steps.
1482 + req = _run_spec_verify_steps(
1483 + scheduler,
1484 + [
1485 + ([1, 2, 3], [1, 2, 3, 4]), # accept 3
1486 + ([5, 6, 7], [5, 8]), # accept 1
1487 + ([9, 10, 11], [12]), # accept 0
1488 + ],
1489 + )
1490 + payload = scheduler.requests[req.request_id].spec_decode_metrics.to_dict()
1491 + assert payload["per_step_accepted"] == [3, 1, 0]
1492 + assert payload["per_step_drafted"] == [3, 3, 3]
1493 + assert payload["num_spec_steps"] == 3
1494 +
1495 +
1496 +def test_per_request_spec_decode_subtracts_invalid_drafts():
1497 + # Grammar-invalidated drafts (num_invalid_spec_tokens, set by structured
1498 + # output) are excluded from the proposed count, mirroring the aggregate.
1499 + scheduler = create_scheduler(
1500 + num_speculative_tokens=3,
1501 + per_request_spec_decode_metrics="summary",
1502 + )
1503 + # One verify step: 3 drafted, 1 grammar-invalid, output accepts 2.
1504 + req = _run_spec_verify_steps(
1505 + scheduler,
1506 + [([1, 2, 3], [1, 2, 5])],
1507 + num_invalid_per_round=[1],
1508 + )
1509 + payload = scheduler.requests[req.request_id].spec_decode_metrics.to_dict()
1510 + assert payload["num_draft_tokens"] == 2 # 3 drafted - 1 invalid
1511 + assert payload["num_accepted_draft_tokens"] == 2 # len([1,2,5]) - 1
1512 + assert payload["acceptance_histogram"] == [0, 0, 1, 0]
1513 +
1514 +
1515 +def test_per_request_spec_decode_acceptance_disabled_by_default():
1516 + scheduler = create_scheduler(num_speculative_tokens=3)
1517 + assert scheduler.spec_decode_metrics_level == "none"
1518 + req = _run_spec_verify_steps(scheduler, [([1, 2, 3], [1, 2, 3, 4])])
1519 + assert scheduler.requests[req.request_id].spec_decode_metrics is None
1520 +
1521
1522 def test_spec_decoding_stats_empty_output():
1523 """Test that spec decoding stats handle empty output tokens gracefully.
tests/v1/core/utils.py
+5
@@ -10,6 +10,7 @@ from vllm.config import (
10 ECTransferConfig,
11 KVTransferConfig,
12 ModelConfig,
13 + ObservabilityConfig,
14 ParallelConfig,
15 SchedulerConfig,
16 SpeculativeConfig,
@@ -71,6 +72,7 @@ def create_scheduler(
72 ec_role: str | None = None,
73 use_v2_model_runner: bool | None = None,
74 kv_cache_spec: KVCacheSpec | None = None,
75 + per_request_spec_decode_metrics: str = "none",
76 ) -> Scheduler | AsyncScheduler:
77 """Create scheduler under test.
78
@@ -175,6 +177,9 @@ def create_scheduler(
177 kv_transfer_config=kv_transfer_config,
178 speculative_config=speculative_config,
179 ec_transfer_config=ec_transfer_config,
180 + observability_config=ObservabilityConfig(
181 + per_request_spec_decode_metrics=per_request_spec_decode_metrics,
182 + ),
183 )
184 if kv_cache_spec is None:
185 kv_cache_spec = FullAttentionSpec(
tests/v1/spec_decode/test_request_acceptance.py new
+150
@@ -0,0 +1,150 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for per-request speculative-decoding metrics accumulation.
4 +
5 +These cover the pure histogram/per-step math (no GPU / no model): the engine-core
6 +accumulator ``RequestSpecDecodeMetrics`` and its ``to_dict`` payload surfaced in the
7 +response as ``metrics.speculative_decoding``.
8 +"""
9 +
10 +import msgspec
11 +import pytest
12 +
13 +from vllm.outputs import CompletionOutput
14 +from vllm.v1.engine import EngineCoreOutput
15 +from vllm.v1.metrics.stats import RequestSpecDecodeMetrics
16 +
17 +
18 +def _metrics(pairs, num_spec_tokens=3, detailed=False):
19 + s = RequestSpecDecodeMetrics.new(num_spec_tokens)
20 + for k, j in pairs:
21 + s.observe(num_draft_tokens=k, num_accepted=j, detailed=detailed)
22 + return s
23 +
24 +
25 +def test_new_allocates_dense_histogram_of_k_plus_one():
26 + s = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
27 + assert s.num_spec_tokens == 3
28 + assert s.histogram == [0, 0, 0, 0]
29 + assert s.num_draft_tokens == 0
30 + assert s.per_step_accepted == []
31 +
32 +
33 +def test_observe_buckets_by_accepted_draft_count():
34 + s = _metrics([(3, 0), (3, 3), (3, 2), (3, 3), (3, 1)])
35 + # j=0 ->1, j=1 ->1, j=2 ->1, j=3 ->2
36 + assert s.histogram == [1, 1, 1, 2]
37 + assert s.num_draft_tokens == 15
38 + # summary level does not record the ordered per-step arrays
39 + assert s.per_step_accepted == []
40 + assert s.per_step_drafted == []
41 +
42 +
43 +def test_observe_detailed_records_ordered_per_step_arrays():
44 + # Distinct step count (3), max draft length (k=4), and per-step drafted
45 + # counts (4, 2, 4) so no accidental "everything is 3" pattern is implied.
46 + s = _metrics([(4, 3), (2, 2), (4, 0)], num_spec_tokens=4, detailed=True)
47 + assert s.per_step_accepted == [3, 2, 0]
48 + assert s.per_step_drafted == [4, 2, 4]
49 + # histogram (indexed by accepted j, length k+1=5) is still maintained
50 + assert s.histogram == [1, 0, 1, 1, 0]
51 +
52 +
53 +def test_to_dict_summary_omits_per_step_arrays():
54 + d = _metrics([(3, 0), (3, 3), (3, 2), (3, 3), (3, 1)]).to_dict()
55 + assert d == {
56 + "mean_acceptance_length": pytest.approx(1 + 9 / 5), # j+1
57 + "draft_acceptance_rate": pytest.approx(9 / 15),
58 + "acceptance_histogram": [1, 1, 1, 2], # dense, index j = step count
59 + "num_spec_steps": 5,
60 + "num_accepted_draft_tokens": 9,
61 + "num_draft_tokens": 15,
62 + "num_spec_tokens": 3,
63 + }
64 +
65 +
66 +def test_to_dict_detailed_appends_per_step_arrays():
67 + d = _metrics([(3, 3), (3, 2), (3, 0)], detailed=True).to_dict()
68 + assert d["per_step_accepted"] == [3, 2, 0]
69 + assert d["per_step_drafted"] == [3, 3, 3]
70 + # summary fields still present in detailed mode
71 + assert d["num_spec_steps"] == 3
72 + assert d["num_accepted_draft_tokens"] == 5
73 +
74 +
75 +def test_to_dict_histogram_is_dense_list_indexed_by_j():
76 + # length k+1, index j holds the step count that accepted exactly j drafts
77 + d = _metrics([(3, 0), (3, 0), (3, 3)]).to_dict()
78 + assert d["acceptance_histogram"] == [2, 0, 0, 1]
79 +
80 +
81 +def test_all_rejected_gives_mean_one_and_rate_zero():
82 + d = _metrics([(2, 0), (2, 0), (2, 0), (2, 0)], num_spec_tokens=2).to_dict()
83 + assert d["num_spec_steps"] == 4
84 + assert d["num_accepted_draft_tokens"] == 0
85 + assert d["acceptance_histogram"] == [4, 0, 0]
86 + assert d["mean_acceptance_length"] == pytest.approx(1.0)
87 + assert d["draft_acceptance_rate"] == pytest.approx(0.0)
88 +
89 +
90 +def test_empty_metrics_do_not_divide_by_zero():
91 + d = RequestSpecDecodeMetrics.new(3).to_dict()
92 + assert d["num_spec_steps"] == 0
93 + assert d["num_draft_tokens"] == 0
94 + assert d["draft_acceptance_rate"] == 0.0
95 + assert d["mean_acceptance_length"] == 1.0
96 + assert "per_step_accepted" not in d
97 +
98 +
99 +def test_observe_records_proposed_and_accepted_independently():
100 + # observe() takes proposed and accepted as independent inputs: the histogram
101 + # is keyed by accepted, num_draft_tokens sums the proposed as given. (The
102 + # grammar-invalidated-draft subtraction happens in the scheduler before
103 + # observe() -- see test_per_request_spec_decode_subtracts_invalid_drafts.)
104 + s = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
105 + s.observe(num_draft_tokens=2, num_accepted=1)
106 + s.observe(num_draft_tokens=3, num_accepted=1)
107 + d = s.to_dict()
108 + assert d["acceptance_histogram"] == [0, 2, 0, 0] # both steps accepted 1
109 + assert d["num_draft_tokens"] == 5 # proposed summed independently: 2 + 3
110 +
111 +
112 +def test_engine_core_output_round_trips_spec_decode_metrics():
113 + # The accumulator rides EngineCoreOutput (msgspec, array_like) to the
114 + # frontend; verify it serializes (incl. per-step arrays) and is omitted
115 + # when absent. ``new_token_ids`` is EngineCoreOutput's required "tokens
116 + # generated this step" field -- a dummy value here since we only exercise
117 + # spec_decode_metrics.
118 + metrics = _metrics([(3, 0), (3, 3), (3, 2)], detailed=True)
119 + out = EngineCoreOutput(
120 + request_id="r1", new_token_ids=[1, 2], spec_decode_metrics=metrics
121 + )
122 + decoder = msgspec.msgpack.Decoder(EngineCoreOutput)
123 + decoded = decoder.decode(msgspec.msgpack.encode(out))
124 + assert decoded.spec_decode_metrics.histogram == [1, 0, 1, 1]
125 + assert decoded.spec_decode_metrics.num_draft_tokens == 9
126 + assert decoded.spec_decode_metrics.per_step_accepted == [0, 3, 2]
127 +
128 + without = EngineCoreOutput(request_id="r2", new_token_ids=[1])
129 + decoded_without = decoder.decode(msgspec.msgpack.encode(without))
130 + assert decoded_without.spec_decode_metrics is None
131 +
132 +
133 +def _completion_output(**kwargs):
134 + return CompletionOutput(
135 + index=0,
136 + text="",
137 + token_ids=[],
138 + cumulative_logprob=None,
139 + logprobs=None,
140 + **kwargs,
141 + )
142 +
143 +
144 +def test_completion_output_carries_spec_decode_metrics():
145 + # Metrics are per output sequence, so they ride the CompletionOutput
146 + # (=> choices[i]), not the request-level RequestOutput.
147 + metrics = _metrics([(3, 3), (3, 1)])
148 + out = _completion_output(spec_decode_metrics=metrics)
149 + assert out.spec_decode_metrics is metrics
150 + assert _completion_output().spec_decode_metrics is None
vllm/config/observability.py
+12
@@ -45,6 +45,18 @@ class ObservabilityConfig:
45 Note that collecting detailed timing information for each request can be
46 expensive."""
47
48 + per_request_spec_decode_metrics: Literal["none", "summary", "detailed"] = "none"
49 + """Include per-request speculative-decoding acceptance metrics in the
50 + response under `metrics.speculative_decoding`. `none` disables; `summary` adds mean
51 + acceptance length, draft acceptance rate, and the step-by-draft-length
52 + histogram; `detailed` additionally records the ordered per-step
53 + accepted/proposed arrays (one entry per verify step). Only reported for
54 + single-sequence requests (`n == 1`), mirroring the timing metrics. No effect
55 + unless speculative decoding is enabled. Independent of `--disable-log-stats`.
56 + This is the per-request response-body counterpart of the aggregate
57 + `vllm:spec_decode_*` Prometheus metrics. The response field is experimental
58 + and its shape may change in a future release."""
59 +
60 kv_cache_metrics: bool = False
61 """Enable KV cache residency metrics (lifetime, idle time, reuse gaps).
62 Uses sampling to minimize overhead.
vllm/config/vllm.py
+9
@@ -1331,6 +1331,15 @@ class VllmConfig:
1331 )
1332 self.model_config.disable_cascade_attn = True
1333
1334 + if (
1335 + self.observability_config.per_request_spec_decode_metrics != "none"
1336 + and self.speculative_config is None
1337 + ):
1338 + raise ValueError(
1339 + "--per-request-spec-decode-metrics requires speculative decoding "
1340 + "to be enabled (via --speculative-config)."
1341 + )
1342 +
1343 if (
1344 self.model_config is not None
1345 and self.model_config.multimodal_config is not None
vllm/engine/arg_utils.py
+8
@@ -656,6 +656,9 @@ class EngineArgs:
656 collect_detailed_traces: list[DetailedTraceModules] | None = (
657 ObservabilityConfig.collect_detailed_traces
658 )
659 + per_request_spec_decode_metrics: Literal["none", "summary", "detailed"] = (
660 + ObservabilityConfig.per_request_spec_decode_metrics
661 + )
662 kv_cache_metrics: bool = ObservabilityConfig.kv_cache_metrics
663 kv_cache_metrics_sample: float = get_field(
664 ObservabilityConfig, "kv_cache_metrics_sample"
@@ -1476,6 +1479,10 @@ class EngineArgs:
1479 "--collect-detailed-traces",
1480 **observability_kwargs["collect_detailed_traces"],
1481 )
1482 + observability_group.add_argument(
1483 + "--per-request-spec-decode-metrics",
1484 + **observability_kwargs["per_request_spec_decode_metrics"],
1485 + )
1486 observability_group.add_argument(
1487 "--kv-cache-metrics", **observability_kwargs["kv_cache_metrics"]
1488 )
@@ -1934,6 +1941,7 @@ class EngineArgs:
1941 show_hidden_metrics_for_version=self.show_hidden_metrics_for_version,
1942 otlp_traces_endpoint=self.otlp_traces_endpoint,
1943 collect_detailed_traces=self.collect_detailed_traces,
1944 + per_request_spec_decode_metrics=self.per_request_spec_decode_metrics,
1945 kv_cache_metrics=self.kv_cache_metrics,
1946 kv_cache_metrics_sample=self.kv_cache_metrics_sample,
1947 cudagraph_metrics=self.cudagraph_metrics,
vllm/entrypoints/generate/base/serving.py
+26 -5
@@ -5,7 +5,7 @@ import time
5 from collections.abc import Awaitable, Mapping
6 from dataclasses import dataclass, field
7 from http import HTTPStatus
8 -from typing import ClassVar, Generic, TypeVar
8 +from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar
9
10 from fastapi import Request
11 from pydantic import ConfigDict
@@ -18,7 +18,8 @@ from vllm.entrypoints.openai.completion.protocol import CompletionRequest
18 from vllm.entrypoints.openai.engine.protocol import (
19 ErrorResponse,
20 GenerationError,
21 - PerRequestTimingMetrics,
21 + PerRequestMetrics,
22 + SpeculativeDecodingMetrics,
23 )
24 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
25 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
@@ -37,6 +38,9 @@ from vllm.tracing import (
38 )
39 from vllm.v1.metrics.stats import RequestStateStats
40
41 +if TYPE_CHECKING:
42 + from vllm.outputs import RequestOutput
43 +
44 logger = init_logger(__name__)
45
46 RequestT = TypeVar("RequestT", bound=AnyRequest)
@@ -48,7 +52,7 @@ PRIORITY_HEADER = "X-Vllm-Priority"
52 def build_per_request_timing_metrics(
53 metrics: RequestStateStats | None,
54 num_generation_tokens: int,
51 -) -> PerRequestTimingMetrics:
55 +) -> PerRequestMetrics:
56 """Build per-request timing metrics from ``RequestStateStats``.
57
58 ``generation_time_ms`` is the decode interval only (first output token to
@@ -60,7 +64,7 @@ def build_per_request_timing_metrics(
64 unavailable.
65 """
66 if metrics is None:
63 - return PerRequestTimingMetrics()
67 + return PerRequestMetrics()
68
69 queued_ts = metrics.queued_ts
70 scheduled_ts = metrics.scheduled_ts
@@ -91,7 +95,7 @@ def build_per_request_timing_metrics(
95 if inference_time_ms > 0:
96 tokens_per_second = num_generation_tokens / inference_time_ms * 1000
97
94 - return PerRequestTimingMetrics(
98 + return PerRequestMetrics(
99 time_to_first_token_ms=time_to_first_token_ms,
100 generation_time_ms=generation_time_ms,
101 queue_time_ms=queue_time_ms,
@@ -100,6 +104,23 @@ def build_per_request_timing_metrics(
104 )
105
106
107 +def build_spec_decoding_metrics(
108 + final_res: "RequestOutput | None",
109 +) -> SpeculativeDecodingMetrics | None:
110 + """Build per-request spec-decode acceptance metrics from the single output
111 + sequence, or ``None`` when unavailable (metrics disabled, or no sequence
112 + yet).
113 +
114 + Only meaningful for single-sequence requests; callers suppress it for n>1.
115 + """
116 + if final_res is None or not final_res.outputs:
117 + return None
118 + metrics = final_res.outputs[0].spec_decode_metrics
119 + if metrics is None:
120 + return None
121 + return SpeculativeDecodingMetrics(**metrics.to_dict())
122 +
123 +
124 @dataclass(kw_only=True)
125 class ServeContext(Generic[RequestT]):
126 request: RequestT
vllm/entrypoints/openai/chat_completion/protocol.py
+3 -3
@@ -29,7 +29,7 @@ from vllm.entrypoints.openai.engine.protocol import (
29 FunctionCall,
30 FunctionDefinition,
31 OpenAIBaseModel,
32 - PerRequestTimingMetrics,
32 + PerRequestMetrics,
33 StopParam,
34 StreamOptions,
35 ToolCall,
@@ -146,7 +146,7 @@ class ChatCompletionResponse(OpenAIBaseModel):
146 ec_transfer_params: dict[str, Any] | None = Field(
147 default=None, description="ECTransfer parameters."
148 )
149 - metrics: PerRequestTimingMetrics | None = None
149 + metrics: PerRequestMetrics | None = None
150
151
152 class ChatCompletionResponseStreamChoice(OpenAIBaseModel):
@@ -178,7 +178,7 @@ class ChatCompletionStreamResponse(OpenAIBaseModel):
178 # Rendered prompt text from chat templating (only set when
179 # ``return_prompt_text=True`` on the request); only sent on the first chunk.
180 prompt_text: str | None = None
181 - metrics: PerRequestTimingMetrics | None = None
181 + metrics: PerRequestMetrics | None = None
182
183
184 class ChatCompletionToolsParam(OpenAIBaseModel):
vllm/entrypoints/openai/chat_completion/serving.py
+31 -22
@@ -20,6 +20,7 @@ from vllm.entrypoints.generate.base.serving import (
20 GenerateBaseServing,
21 GenerationError,
22 build_per_request_timing_metrics,
23 + build_spec_decoding_metrics,
24 clamp_prompt_logprobs,
25 format_token_id_placeholder,
26 )
@@ -40,7 +41,7 @@ from vllm.entrypoints.openai.engine.protocol import (
41 DeltaMessage,
42 ErrorResponse,
43 FunctionCall,
43 - PerRequestTimingMetrics,
44 + PerRequestMetrics,
45 PromptTokenUsageInfo,
46 RequestResponseMetadata,
47 ToolCall,
@@ -819,16 +820,21 @@ class OpenAIServingChat(GenerateBaseServing):
820 # only emitted when usage reporting is enabled (i.e.
821 # ``stream_options.include_usage=true`` or
822 # ``--enable-force-include-usage``).
822 - stream_per_request_metrics: PerRequestTimingMetrics | None = None
823 - if (
824 - self.enable_per_request_metrics
825 - # See note in chat_completion_full_generator: suppress for n>1.
826 - and (request.n or 1) == 1
827 - ):
828 - last_metrics = last_res.metrics if last_res is not None else None
829 - stream_per_request_metrics = build_per_request_timing_metrics(
830 - last_metrics, completion_tokens
831 - )
823 + stream_per_request_metrics: PerRequestMetrics | None = None
824 + # See note in chat_completion_full_generator: suppress for n>1.
825 + if (request.n or 1) == 1:
826 + if self.enable_per_request_metrics:
827 + last_metrics = (
828 + last_res.metrics if last_res is not None else None
829 + )
830 + stream_per_request_metrics = build_per_request_timing_metrics(
831 + last_metrics, completion_tokens
832 + )
833 + spec_stats = build_spec_decoding_metrics(last_res)
834 + if spec_stats is not None:
835 + if stream_per_request_metrics is None:
836 + stream_per_request_metrics = PerRequestMetrics()
837 + stream_per_request_metrics.speculative_decoding = spec_stats
838
839 final_usage_chunk = ChatCompletionStreamResponse(
840 id=request_id,
@@ -1118,17 +1124,20 @@ class OpenAIServingChat(GenerateBaseServing):
1124
1125 request_metadata.final_usage_info = usage
1126
1121 - per_request_metrics: PerRequestTimingMetrics | None = None
1122 - if (
1123 - self.enable_per_request_metrics
1124 - # Timing metrics describe a single generation stream. For n>1 the
1125 - # returned stats belong to only one of the n sequences, so they
1126 - # cannot be accurately attributed to the request; suppress instead.
1127 - and (request.n or 1) == 1
1128 - ):
1129 - per_request_metrics = build_per_request_timing_metrics(
1130 - final_res.metrics, num_generated_tokens
1131 - )
1127 + per_request_metrics: PerRequestMetrics | None = None
1128 + # Per-request metrics (timing + spec-decode acceptance) describe a single
1129 + # generation stream. For n>1 the stats belong to only one of the n
1130 + # sequences, so they cannot be attributed to the request; suppress.
1131 + if (request.n or 1) == 1:
1132 + if self.enable_per_request_metrics:
1133 + per_request_metrics = build_per_request_timing_metrics(
1134 + final_res.metrics, num_generated_tokens
1135 + )
1136 + spec_stats = build_spec_decoding_metrics(final_res)
1137 + if spec_stats is not None:
1138 + if per_request_metrics is None:
1139 + per_request_metrics = PerRequestMetrics()
1140 + per_request_metrics.speculative_decoding = spec_stats
1141
1142 # ``final_res.prompt`` is the rendered chat-templated prompt text
1143 prompt_text = final_res.prompt if request.return_prompt_text else None
vllm/entrypoints/openai/completion/protocol.py
+3 -3
@@ -13,7 +13,7 @@ from vllm.config import ModelConfig
13 from vllm.entrypoints.openai.engine.protocol import (
14 AnyResponseFormat,
15 OpenAIBaseModel,
16 - PerRequestTimingMetrics,
16 + PerRequestMetrics,
17 StopParam,
18 StreamOptions,
19 UsageInfo,
@@ -660,7 +660,7 @@ class CompletionResponse(OpenAIBaseModel):
660 ec_transfer_params: dict[str, Any] | None = Field(
661 default=None, description="ECTransfer parameters."
662 )
663 - metrics: PerRequestTimingMetrics | None = None
663 + metrics: PerRequestMetrics | None = None
664
665
666 class CompletionResponseStreamChoice(OpenAIBaseModel):
@@ -692,4 +692,4 @@ class CompletionStreamResponse(OpenAIBaseModel):
692 # Set only on the final chunk of a stream to mirror non-streaming responses
693 # without the per-chunk serialization overhead.
694 system_fingerprint: str | None = None
695 - metrics: PerRequestTimingMetrics | None = None
695 + metrics: PerRequestMetrics | None = None
vllm/entrypoints/openai/completion/serving.py
+36 -28
@@ -14,6 +14,7 @@ from vllm.entrypoints.generate.base.serving import (
14 GenerateBaseServing,
15 GenerationError,
16 build_per_request_timing_metrics,
17 + build_spec_decoding_metrics,
18 clamp_prompt_logprobs,
19 format_token_id_placeholder,
20 )
@@ -27,7 +28,7 @@ from vllm.entrypoints.openai.completion.protocol import (
28 )
29 from vllm.entrypoints.openai.engine.protocol import (
30 ErrorResponse,
30 - PerRequestTimingMetrics,
31 + PerRequestMetrics,
32 PromptTokenUsageInfo,
33 RequestResponseMetadata,
34 UsageInfo,
@@ -460,18 +461,22 @@ class OpenAIServingCompletion(GenerateBaseServing):
461 # only emitted when usage reporting is enabled (i.e.
462 # ``stream_options.include_usage=true`` or
463 # ``--enable-force-include-usage``).
463 - stream_per_request_metrics: PerRequestTimingMetrics | None = None
464 - if (
465 - self.enable_per_request_metrics
466 - # See note in request_output_to_completion_response: suppress
467 - # when not attributable to one stream (multi-prompt or n>1).
468 - and num_prompts == 1
469 - and (request.n or 1) == 1
470 - ):
471 - last_metrics = last_res.metrics if last_res is not None else None
472 - stream_per_request_metrics = build_per_request_timing_metrics(
473 - last_metrics, total_completion_tokens
474 - )
464 + stream_per_request_metrics: PerRequestMetrics | None = None
465 + # See note in request_output_to_completion_response: suppress when
466 + # not attributable to one stream (multi-prompt or n>1).
467 + if num_prompts == 1 and (request.n or 1) == 1:
468 + if self.enable_per_request_metrics:
469 + last_metrics = (
470 + last_res.metrics if last_res is not None else None
471 + )
472 + stream_per_request_metrics = build_per_request_timing_metrics(
473 + last_metrics, total_completion_tokens
474 + )
475 + spec_stats = build_spec_decoding_metrics(last_res)
476 + if spec_stats is not None:
477 + if stream_per_request_metrics is None:
478 + stream_per_request_metrics = PerRequestMetrics()
479 + stream_per_request_metrics.speculative_decoding = spec_stats
480
481 final_usage_chunk = CompletionStreamResponse(
482 id=request_id,
@@ -612,21 +617,24 @@ class OpenAIServingCompletion(GenerateBaseServing):
617
618 request_metadata.final_usage_info = usage
619
615 - per_request_metrics: PerRequestTimingMetrics | None = None
616 - if (
617 - self.enable_per_request_metrics
618 - # Metrics describe a single generation stream, so suppress them when
619 - # they cannot be attributed to one: multiple prompts (timestamps
620 - # span prompts) or n>1 (stats belong to one of the n sequences).
621 - and len(final_res_batch) == 1
622 - and (request.n or 1) == 1
623 - ):
624 - last_metrics = (
625 - last_final_res.metrics if last_final_res is not None else None
626 - )
627 - per_request_metrics = build_per_request_timing_metrics(
628 - last_metrics, num_generated_tokens
629 - )
620 + per_request_metrics: PerRequestMetrics | None = None
621 + # Per-request metrics (timing + spec-decode acceptance) describe a single
622 + # generation stream, so suppress them when they cannot be attributed to
623 + # one: multiple prompts (timestamps span prompts) or n>1 (stats belong to
624 + # one of the n sequences).
625 + if len(final_res_batch) == 1 and (request.n or 1) == 1:
626 + if self.enable_per_request_metrics:
627 + last_metrics = (
628 + last_final_res.metrics if last_final_res is not None else None
629 + )
630 + per_request_metrics = build_per_request_timing_metrics(
631 + last_metrics, num_generated_tokens
632 + )
633 + spec_stats = build_spec_decoding_metrics(last_final_res)
634 + if spec_stats is not None:
635 + if per_request_metrics is None:
636 + per_request_metrics = PerRequestMetrics()
637 + per_request_metrics.speculative_decoding = spec_stats
638
639 if final_res_batch:
640 kv_transfer_params = final_res_batch[0].kv_transfer_params
vllm/entrypoints/openai/engine/protocol.py
+25 -1
@@ -129,12 +129,36 @@ class UsageInfo(OpenAIBaseModel):
129 completion_tokens_details: CompletionTokenUsageInfo | None = None
130
131
132 -class PerRequestTimingMetrics(OpenAIBaseModel):
132 +class SpeculativeDecodingMetrics(OpenAIBaseModel):
133 + """Per-request speculative-decoding acceptance metrics.
134 +
135 + Experimental, subject to change. Only populated for single-sequence requests
136 + (`n == 1`); `null` for `n > 1`, mirroring the timing metrics.
137 + """
138 +
139 + mean_acceptance_length: float
140 + draft_acceptance_rate: float
141 + # Dense histogram: index j holds the number of verify steps that accepted
142 + # exactly j draft tokens (length num_spec_tokens + 1). Excludes the
143 + # always-accepted bonus token.
144 + acceptance_histogram: list[int]
145 + num_spec_steps: int
146 + num_accepted_draft_tokens: int
147 + num_draft_tokens: int
148 + num_spec_tokens: int
149 + # Ordered per-verify-step arrays; populated only at the `detailed` level.
150 + per_step_accepted: list[int] | None = None
151 + per_step_drafted: list[int] | None = None
152 +
153 +
154 +class PerRequestMetrics(OpenAIBaseModel):
155 time_to_first_token_ms: float | None = None
156 generation_time_ms: float | None = None
157 queue_time_ms: float | None = None
158 mean_itl_ms: float | None = None
159 tokens_per_second: float | None = None
160 + # Experimental, subject to change.
161 + speculative_decoding: SpeculativeDecodingMetrics | None = None
162
163
164 class RequestResponseMetadata(BaseModel):
vllm/outputs.py
+7 -1
@@ -13,7 +13,7 @@ from typing_extensions import TypeVar
13 from vllm.logger import init_logger
14 from vllm.logprobs import PromptLogprobs, SampleLogprobs
15 from vllm.lora.request import LoRARequest
16 -from vllm.v1.metrics.stats import RequestStateStats
16 +from vllm.v1.metrics.stats import RequestSpecDecodeMetrics, RequestStateStats
17
18 logger = init_logger(__name__)
19
@@ -48,6 +48,11 @@ class CompletionOutput:
48 to stop, None if the completion finished for some other reason
49 including encountering the EOS token.
50 lora_request: The LoRA request that was used to generate the output.
51 + spec_decode_metrics: Per-sequence speculative-decoding acceptance metrics,
52 + populated on finish when speculative decoding ran and
53 + ``--per-request-spec-decode-metrics`` is enabled; None otherwise.
54 + Surfaced in the response as ``metrics.speculative_decoding`` for
55 + single-sequence (``n == 1``) requests.
56 """
57
58 index: int
@@ -60,6 +65,7 @@ class CompletionOutput:
65 stop_reason: int | str | None = None
66 lora_request: LoRARequest | None = None
67 sampling_mask: SamplingMask | None = None
68 + spec_decode_metrics: RequestSpecDecodeMetrics | None = None
69
70 def finished(self) -> bool:
71 return self.finish_reason is not None
vllm/v1/core/sched/scheduler.py
+31 -1
@@ -55,7 +55,11 @@ from vllm.v1.core.sched.utils import check_stop, remove_all
55 from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs
56 from vllm.v1.kv_cache_interface import KVCacheConfig
57 from vllm.v1.metrics.perf import ModelMetrics, PerfStats
58 -from vllm.v1.metrics.stats import PrefixCacheStats, SchedulerStats
58 +from vllm.v1.metrics.stats import (
59 + PrefixCacheStats,
60 + RequestSpecDecodeMetrics,
61 + SchedulerStats,
62 +)
63 from vllm.v1.outputs import DraftTokenIds, KVConnectorOutput, ModelRunnerOutput
64 from vllm.v1.request import Request, RequestStatus, StreamingUpdate
65 from vllm.v1.spec_decode.dynamic.utils import build_dynamic_sd_schedule_lookup
@@ -88,6 +92,9 @@ class Scheduler(SchedulerInterface):
92 self.parallel_config = vllm_config.parallel_config
93 self.log_stats = log_stats
94 self.observability_config = vllm_config.observability_config
95 + self.spec_decode_metrics_level = (
96 + self.observability_config.per_request_spec_decode_metrics
97 + )
98 self.kv_metrics_collector: KVCacheMetricsCollector | None = None
99 if self.observability_config.kv_cache_metrics:
100 self.kv_metrics_collector = KVCacheMetricsCollector(
@@ -1857,6 +1864,20 @@ class Scheduler(SchedulerInterface):
1864 num_invalid_spec_tokens=scheduler_output.num_invalid_spec_tokens,
1865 request_id=req_id,
1866 )
1867 + if request.spec_decode_metrics is not None:
1868 + # Exclude grammar-invalidated drafts from the proposed
1869 + # count, mirroring make_spec_decoding_stats; the accepted
1870 + # bucket (j) is unaffected.
1871 + adj_draft_tokens = num_draft_tokens
1872 + if scheduler_output.num_invalid_spec_tokens:
1873 + adj_draft_tokens -= (
1874 + scheduler_output.num_invalid_spec_tokens.get(req_id, 0)
1875 + )
1876 + request.spec_decode_metrics.observe(
1877 + num_draft_tokens=adj_draft_tokens,
1878 + num_accepted=num_accepted,
1879 + detailed=self.spec_decode_metrics_level == "detailed",
1880 + )
1881
1882 # Free encoder inputs only after the step has actually executed.
1883 if request.has_encoder_inputs:
@@ -2020,6 +2041,11 @@ class Scheduler(SchedulerInterface):
2041 stop_reason=request.stop_reason,
2042 events=request.take_events(),
2043 prefill_stats=prefill_stats,
2044 + spec_decode_metrics=(
2045 + request.spec_decode_metrics
2046 + if finish_reason is not None
2047 + else None
2048 + ),
2049 kv_transfer_params=kv_transfer_params,
2050 ec_transfer_params=ec_transfer_params,
2051 trace_headers=request.trace_headers,
@@ -2321,6 +2347,10 @@ class Scheduler(SchedulerInterface):
2347 request.streaming_queue = deque()
2348 self._enqueue_waiting_request(request)
2349 self.requests[request.request_id] = request
2350 + if self.spec_decode_metrics_level != "none":
2351 + request.spec_decode_metrics = RequestSpecDecodeMetrics.new(
2352 + self.num_spec_tokens
2353 + )
2354 if self.connector is not None:
2355 self.connector.on_new_request(request)
2356 if self.log_stats:
vllm/v1/engine/__init__.py
+9 -1
@@ -16,7 +16,11 @@ from vllm.lora.request import LoRARequest
16 from vllm.multimodal.inputs import MultiModalFeatureSpec
17 from vllm.pooling_params import PoolingParams
18 from vllm.sampling_params import SamplingParams
19 -from vllm.v1.metrics.stats import PrefillStats, SchedulerStats
19 +from vllm.v1.metrics.stats import (
20 + PrefillStats,
21 + RequestSpecDecodeMetrics,
22 + SchedulerStats,
23 +)
24 from vllm.v1.outputs import LogprobsLists, LogprobsTensors, SamplingMaskLists
25 from vllm.v1.serial_utils import UtilityResult
26
@@ -225,6 +229,10 @@ class EngineCoreOutput(
229
230 new_sampling_mask: SamplingMaskLists | None = None
231
232 + # Per-request spec-decode acceptance; attached only on the final output.
233 + # Appended last so `array_like` positional serialization stays compatible.
234 + spec_decode_metrics: RequestSpecDecodeMetrics | None = None
235 +
236 @property
237 def finished(self) -> bool:
238 return self.finish_reason is not None
vllm/v1/engine/output_processor.py
+8
@@ -35,6 +35,7 @@ from vllm.v1.engine.parallel_sampling import ParentRequest
35 from vllm.v1.metrics.stats import (
36 IterationStats,
37 LoRARequestStates,
38 + RequestSpecDecodeMetrics,
39 RequestStateStats,
40 SchedulerStats,
41 )
@@ -175,6 +176,9 @@ class RequestState:
176 self.queue = queue
177 self.num_cached_tokens = 0
178 self.num_cache_creation_tokens = 0
179 + # Per-sequence spec-decode accumulator; arrives once (on finish) via
180 + # EngineCoreOutput, then attached to this sequence's CompletionOutput.
181 + self.spec_decode_metrics: RequestSpecDecodeMetrics | None = None
182
183 self.stats = RequestStateStats(arrival_time=arrival_time) if log_stats else None
184
@@ -429,6 +433,7 @@ class RequestState:
433 cumulative_logprob=self.logprobs_processor.cumulative_logprob,
434 finish_reason=str(finish_reason) if finished else None,
435 stop_reason=stop_reason if finished else None,
436 + spec_decode_metrics=self.spec_decode_metrics if finished else None,
437 )
438
439 def _new_pooling_output(self, pooling_output: torch.Tensor) -> PoolingOutput:
@@ -658,6 +663,9 @@ class OutputProcessor:
663 )
664 req_state.is_prefilling = False
665
666 + if engine_core_output.spec_decode_metrics is not None:
667 + req_state.spec_decode_metrics = engine_core_output.spec_decode_metrics
668 +
669 if pooling_output is None:
670 assert req_state.detokenizer is not None
671 assert req_state.logprobs_processor is not None
vllm/v1/metrics/stats.py
+76
@@ -297,6 +297,82 @@ class PrefillStats:
297 )
298
299
300 +@dataclass
301 +class RequestSpecDecodeMetrics:
302 + """Per-output-sequence speculative-decoding statistics accumulator.
303 +
304 + Accumulates, over one sequence's verify steps, a histogram of accepted
305 + draft-token counts (``j``, draft-only) and the total number of proposed
306 + draft tokens. When ``detailed`` is requested it also records the ordered
307 + per-step accepted/proposed sequences (``summary`` omits them). Tracked per
308 + engine ``Request`` (one per sampled sequence, so ``n > 1`` yields one per
309 + child), surfaced via ``EngineCoreOutput`` and the response
310 + ``metrics.speculative_decoding`` for single-sequence requests (see
311 + ``to_dict``).
312 +
313 + Fields:
314 + num_spec_tokens: Configured ``num_speculative_tokens`` (the max ``k``);
315 + also the histogram's upper bound.
316 + histogram: Dense counts indexed by accepted draft tokens ``j``
317 + (length ``num_spec_tokens + 1``).
318 + num_draft_tokens: Total proposed draft tokens, after the
319 + grammar-invalidated (``num_invalid_spec_tokens``) adjustment.
320 + per_step_accepted: Ordered accepted-draft count per verify step
321 + (``detailed`` only; empty otherwise).
322 + per_step_drafted: Ordered proposed-draft count per verify step
323 + (``detailed`` only; empty otherwise).
324 + """
325 +
326 + num_spec_tokens: int
327 + histogram: list[int] = field(default_factory=list)
328 + num_draft_tokens: int = 0
329 + per_step_accepted: list[int] = field(default_factory=list)
330 + per_step_drafted: list[int] = field(default_factory=list)
331 +
332 + @classmethod
333 + def new(cls, num_spec_tokens: int) -> "RequestSpecDecodeMetrics":
334 + return cls(
335 + num_spec_tokens=num_spec_tokens,
336 + histogram=[0] * (num_spec_tokens + 1),
337 + )
338 +
339 + def observe(
340 + self, num_draft_tokens: int, num_accepted: int, detailed: bool = False
341 + ) -> None:
342 + self.histogram[num_accepted] += 1
343 + self.num_draft_tokens += num_draft_tokens
344 + if detailed:
345 + self.per_step_accepted.append(num_accepted)
346 + self.per_step_drafted.append(num_draft_tokens)
347 +
348 + def to_dict(self) -> dict[str, Any]:
349 + """Payload matching ``SpeculativeDecodingMetrics`` for the response.
350 +
351 + ``acceptance_histogram`` is a dense list indexed by accepted draft count
352 + ``j`` (length ``num_spec_tokens + 1``). ``mean_acceptance_length``
353 + includes the bonus token (``j + 1``); ``draft_acceptance_rate`` is
354 + draft-only, full precision. Per-step arrays are included only when
355 + populated (``detailed`` level).
356 + """
357 + num_spec_steps = sum(self.histogram)
358 + num_accepted = sum(j * count for j, count in enumerate(self.histogram))
359 + mean_al = 1.0 + num_accepted / num_spec_steps if num_spec_steps else 1.0
360 + rate = num_accepted / self.num_draft_tokens if self.num_draft_tokens else 0.0
361 + result: dict[str, Any] = {
362 + "mean_acceptance_length": mean_al,
363 + "draft_acceptance_rate": rate,
364 + "acceptance_histogram": list(self.histogram),
365 + "num_spec_steps": num_spec_steps,
366 + "num_accepted_draft_tokens": num_accepted,
367 + "num_draft_tokens": self.num_draft_tokens,
368 + "num_spec_tokens": self.num_spec_tokens,
369 + }
370 + if self.per_step_accepted:
371 + result["per_step_accepted"] = self.per_step_accepted
372 + result["per_step_drafted"] = self.per_step_drafted
373 + return result
374 +
375 +
376 @dataclass
377 class PromptTokenStats:
378 """Breakdown of prompt tokens by source.
vllm/v1/request.py
+6 -1
@@ -20,7 +20,7 @@ from vllm.v1.engine import (
20 EngineCoreRequest,
21 FinishReason,
22 )
23 -from vllm.v1.metrics.stats import PrefillStats
23 +from vllm.v1.metrics.stats import PrefillStats, RequestSpecDecodeMetrics
24 from vllm.v1.structured_output.request import StructuredOutputRequest
25 from vllm.v1.utils import ConstantList
26
@@ -211,6 +211,11 @@ class Request:
211
212 self.prefill_stats: PrefillStats | None = PrefillStats()
213
214 + # Per-request speculative-decoding acceptance accumulator. Populated by
215 + # the scheduler when --per-request-spec-decode-metrics is set (eagerly on
216 + # add_request, then observed each verify step); stays None otherwise.
217 + self.spec_decode_metrics: RequestSpecDecodeMetrics | None = None
218 +
219 self.block_hashes: list[BlockHash] = []
220 # Store the block hasher without binding self to avoid creating a
221 # reference cycle (Request -> partial -> Request) that prevents