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 5, 04:45 UTC
[Core][Metrics][BugFix] Replace num_cached_tokens/num_external_computed_tokens with PrefillStats (#37460)
Related to `Counters can only be incremented by non-negative amounts` error with the `vllm:prompt_tokens_by_source_total` metric. Signed-off-by: Mark McLoughlin <markmc@redhat.com> Co-authored-by: Or Ozeri <or@ozery.com>
Mark McLoughlin committed
Apr 14, 2026 at 09:00 UTC
d3af8c18317c0dc008d42e4367fbb9045cfb7bf6
9 files changed
+187
-102
tests/v1/core/test_async_scheduler.py
+18
-11
@@ -153,7 +153,6 @@ def test_prefix_caching_for_prefill_dedup():
153
same_prompt=True,
154
block_size=BLOCK_SIZE,
155
)
156
- requests_copy = requests.copy()
156
157
# Two requests with the same prompt.
158
req0 = requests.pop(0)
@@ -167,26 +166,31 @@ def test_prefix_caching_for_prefill_dedup():
166
# Make sure prefix caching de-duplicates the prompts in the same step,
167
# so all the blocks except the last are shared between the two requests.
168
assert len(sched_output.num_scheduled_tokens) == 2
170
- num_blocks = num_prompt_tokens // BLOCK_SIZE
171
- assert req0.num_cached_tokens == 0
172
- assert req1.num_cached_tokens >= num_blocks * BLOCK_SIZE
169
+ assert sched_output.num_scheduled_tokens[req0.request_id] == num_prompt_tokens
170
+ assert (
171
+ sched_output.num_scheduled_tokens[req1.request_id]
172
+ == num_prompt_tokens % BLOCK_SIZE
173
+ )
174
175
sched_outputs.append(scheduler.schedule())
176
while sched_outputs:
177
+ added_req = None
178
if requests:
177
- scheduler.add_request(requests.pop(0))
179
+ added_req = requests.pop(0)
180
+ scheduler.add_request(added_req)
181
sched_output = sched_outputs.popleft()
182
model_runner_output = _make_model_runner_output(sched_output)
183
scheduler.update_from_output(sched_output, model_runner_output)
184
sched_output = scheduler.schedule()
185
if sched_output.num_scheduled_tokens:
186
sched_outputs.append(sched_output)
187
+ if added_req:
188
+ assert (
189
+ sched_output.num_scheduled_tokens[added_req.request_id]
190
+ == num_prompt_tokens % BLOCK_SIZE
191
+ )
192
185
- # Other requests scheduled after the two requests should also get
186
- # prefix cache hit.
193
assert scheduler.get_num_unfinished_requests() == 0
188
- for req in requests_copy[1:]:
189
- assert req.num_cached_tokens >= num_blocks * BLOCK_SIZE
194
195
196
def test_prefix_caching_for_multi_turn():
@@ -243,12 +247,15 @@ def test_prefix_caching_for_multi_turn():
247
# Schedule the next-turn requests.
248
for req in next_turn_requests:
249
scheduler.add_request(req)
246
- sched_outputs.append(scheduler.schedule())
250
+ sched_output = scheduler.schedule()
251
+ sched_outputs.append(sched_output)
252
253
# Make sure the next-turn requests get prefix cache hit by the previous
254
# requests.
255
for req in next_turn_requests:
251
- assert req.num_cached_tokens == req.num_prompt_tokens // BLOCK_SIZE * BLOCK_SIZE
256
+ assert sched_output.num_scheduled_tokens[req.request_id] == (
257
+ req.num_prompt_tokens % BLOCK_SIZE
258
+ )
259
260
261
def test_abort_request_when_structured_output_fsm_cannot_advance():
tests/v1/engine/test_output_processor.py
+10
-4
@@ -84,6 +84,7 @@ def test_incremental_detokenization(
84
85
engine_core = MockEngineCore(
86
tokens_list=dummy_test_vectors.generation_tokens,
87
+ prompts_list=dummy_test_vectors.prompt_tokens,
88
request_ids=[req.request_id for req in requests],
89
)
90
@@ -506,6 +507,7 @@ def test_logprobs_processor(
507
508
engine_core = MockEngineCore(
509
tokens_list=dummy_test_vectors.generation_tokens,
510
+ prompts_list=dummy_test_vectors.prompt_tokens,
511
generated_logprobs_raw=None
512
if num_sample_logprobs is None
513
else dummy_test_vectors.generation_logprobs,
@@ -691,6 +693,7 @@ def test_stop_token(
693
694
engine_core = MockEngineCore(
695
tokens_list=[generation_tokens],
696
+ prompts_list=dummy_test_vectors.prompt_tokens,
697
generated_logprobs_raw=[generation_logprobs] if do_logprobs else None,
698
prompt_logprobs_raw=None,
699
eos_token_id=sampling_params.eos_token_id,
@@ -794,6 +797,7 @@ def test_stop_string(
797
798
engine_core = MockEngineCore(
799
tokens_list=dummy_test_vectors.generation_tokens,
800
+ prompts_list=dummy_test_vectors.prompt_tokens,
801
generated_logprobs_raw=dummy_test_vectors.generation_logprobs
802
if num_sample_logprobs
803
else None,
@@ -917,6 +921,7 @@ def test_iteration_stats(dummy_test_vectors):
921
922
engine_core = MockEngineCore(
923
dummy_test_vectors.generation_tokens,
924
+ dummy_test_vectors.prompt_tokens,
925
request_ids=[req.request_id for req in requests],
926
)
927
@@ -927,7 +932,7 @@ def test_iteration_stats(dummy_test_vectors):
932
inactive_request = requests[num_active]
933
934
# First iteration has 2 prefills.
930
- outputs = engine_core.get_outputs()[:num_active]
935
+ outputs = engine_core.get_outputs(num_active)
936
iteration_stats = IterationStats()
937
output_processor.process_outputs(outputs, engine_core_timestamp, iteration_stats)
938
total_prompt_tokens = sum(
@@ -941,7 +946,7 @@ def test_iteration_stats(dummy_test_vectors):
946
assert iteration_stats.num_generation_tokens == num_active
947
948
# Just decodes in this step.
944
- outputs = engine_core.get_outputs()[:num_active]
949
+ outputs = engine_core.get_outputs(num_active)
950
iteration_stats = IterationStats()
951
output_processor.process_outputs(outputs, engine_core_timestamp, iteration_stats)
952
@@ -951,7 +956,7 @@ def test_iteration_stats(dummy_test_vectors):
956
# Add a new request - prefill and 2 decodes in this step.
957
output_processor.add_request(inactive_request, None)
958
num_active += 1
954
- outputs = engine_core.get_outputs()[:num_active]
959
+ outputs = engine_core.get_outputs(num_active)
960
iteration_stats = IterationStats()
961
output_processor.process_outputs(outputs, engine_core_timestamp, iteration_stats)
962
total_prompt_tokens = len(dummy_test_vectors.prompt_tokens[num_active - 1])
@@ -960,7 +965,7 @@ def test_iteration_stats(dummy_test_vectors):
965
assert iteration_stats.num_generation_tokens == num_active
966
967
# Just decodes in this step.
963
- outputs = engine_core.get_outputs()[:num_active]
968
+ outputs = engine_core.get_outputs(num_active)
969
iteration_stats = IterationStats()
970
output_processor.process_outputs(outputs, engine_core_timestamp, iteration_stats)
971
@@ -1003,6 +1008,7 @@ def test_lora_request_tracking(log_stats: bool, dummy_test_vectors):
1008
1009
engine_core = MockEngineCore(
1010
dummy_test_vectors.generation_tokens,
1011
+ dummy_test_vectors.prompt_tokens,
1012
request_ids=[req.request_id for req in requests],
1013
)
1014
tests/v1/engine/utils.py
+27
-6
@@ -11,6 +11,7 @@ from transformers import PreTrainedTokenizer, PreTrainedTokenizerFast
11
12
from vllm.engine.arg_utils import EngineArgs
13
from vllm.v1.engine import EngineCoreOutput, FinishReason
14
+from vllm.v1.metrics.stats import PrefillStats
15
from vllm.v1.outputs import LogprobsLists, LogprobsTensors
16
17
GeneralTokenizerType: TypeAlias = PreTrainedTokenizer | PreTrainedTokenizerFast
@@ -330,6 +331,7 @@ class MockEngineCore:
331
def __init__(
332
self,
333
tokens_list: list[list[int]],
334
+ prompts_list: list[list[int]],
335
# For each request, for each sampled token offset,
336
# a tuple of
337
# (list of topk token ids, list of sample logprob vals, rank)
@@ -346,12 +348,13 @@ class MockEngineCore:
348
) -> None:
349
self.num_requests = len(tokens_list)
350
self.tokens_list = tokens_list
349
- self.current_idx = 0
351
+ self.prompts_list = prompts_list
352
self.generated_logprobs_raw = generated_logprobs_raw
353
self.do_logprobs = generated_logprobs_raw is not None
354
self.prompt_logprobs_raw = prompt_logprobs_raw
355
self.do_prompt_logprobs = prompt_logprobs_raw is not None
356
self.request_finished = [False for _ in range(self.num_requests)]
357
+ self.request_token_idx = [0 for _ in range(self.num_requests)]
358
self.eos_token_id = eos_token_id
359
self.stop_token_ids = stop_token_ids
360
self.request_ids = (
@@ -360,14 +363,18 @@ class MockEngineCore:
363
else [f"request-{i}" for i in range(self.num_requests)]
364
)
365
363
- def get_outputs(self) -> list[EngineCoreOutput]:
366
+ def get_outputs(self, num_active: int = -1) -> list[EngineCoreOutput]:
367
do_logprobs = self.do_logprobs
368
do_prompt_logprobs = self.do_prompt_logprobs
366
- token_idx = self.current_idx
369
370
outputs = []
369
- for req_idx, token_ids in enumerate(self.tokens_list):
371
+ for req_idx, (token_ids, prompt_token_ids) in enumerate(
372
+ zip(self.tokens_list, self.prompts_list)
373
+ ):
374
+ if num_active != -1 and req_idx >= num_active:
375
+ break
376
if not self.request_finished[req_idx]:
377
+ token_idx = self.request_token_idx[req_idx]
378
if do_logprobs:
379
assert self.generated_logprobs_raw is not None
380
(logprobs_token_ids_, logprobs_, sampled_token_ranks_) = (
@@ -381,19 +388,32 @@ class MockEngineCore:
388
else:
389
logprobs = None
390
if do_prompt_logprobs:
384
- if self.current_idx == 0:
391
+ if token_idx == 0:
392
assert self.prompt_logprobs_raw is not None
393
prompt_logprobs = self.prompt_logprobs_raw[req_idx]
394
else:
395
prompt_logprobs = None
396
else:
397
prompt_logprobs = None
398
+
399
+ # Add prefill_stats on first output (prefill) for this request
400
+ if token_idx == 0:
401
+ prefill_stats = PrefillStats()
402
+ prefill_stats.set(
403
+ num_prompt_tokens=len(prompt_token_ids),
404
+ num_local_cached_tokens=0,
405
+ num_external_cached_tokens=0,
406
+ )
407
+ else:
408
+ prefill_stats = None
409
+
410
new_token_id = token_ids[token_idx]
411
output = EngineCoreOutput(
412
request_id=self.request_ids[req_idx],
413
new_token_ids=[new_token_id],
414
new_logprobs=logprobs,
415
new_prompt_logprobs_tensors=prompt_logprobs,
416
+ prefill_stats=prefill_stats,
417
)
418
if token_idx == len(token_ids) - 1:
419
output.finish_reason = FinishReason.LENGTH
@@ -407,5 +427,6 @@ class MockEngineCore:
427
self.request_finished[req_idx] = True
428
outputs.append(output)
429
410
- self.current_idx += 1
430
+ self.request_token_idx[req_idx] += 1
431
+
432
return outputs
tests/v1/metrics/test_stats.py
+58
-29
@@ -1,7 +1,12 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from vllm.v1.engine import FinishReason
4
-from vllm.v1.metrics.stats import IterationStats, PromptTokenStats, RequestStateStats
4
+from vllm.v1.metrics.stats import (
5
+ IterationStats,
6
+ PrefillStats,
7
+ PromptTokenStats,
8
+ RequestStateStats,
9
+)
10
11
12
def test_iteration_stats_repr():
@@ -114,15 +119,18 @@ def test_prompt_token_stats_all_computed():
119
stats = PromptTokenStats()
120
121
# Case 1: No caching (All tokens computed locally)
117
- stats.update_from_output(
118
- num_cached_tokens=0,
119
- num_external_computed_tokens=0,
120
- prompt_len=1000,
122
+ prefill_stats = PrefillStats()
123
+ prefill_stats.set(
124
+ num_prompt_tokens=1000,
125
+ num_local_cached_tokens=0,
126
+ num_external_cached_tokens=0,
127
)
128
+ stats.update_from_output(prefill_stats)
129
130
assert stats.computed == 1000
131
assert stats.local_cache_hit == 0
132
assert stats.external_kv_transfer == 0
133
+ assert stats.cached_tokens == 0
134
assert stats.total == 1000
135
136
@@ -131,15 +139,19 @@ def test_prompt_token_stats_partial_local_cache():
139
stats = PromptTokenStats()
140
141
# Case 2: Partial local cache
134
- stats.update_from_output(
135
- num_cached_tokens=300,
136
- num_external_computed_tokens=0,
137
- prompt_len=1000,
142
+ prefill_stats = PrefillStats()
143
+ prefill_stats.set(
144
+ num_prompt_tokens=1000,
145
+ num_local_cached_tokens=300,
146
+ num_external_cached_tokens=0,
147
)
148
+ stats.update_from_output(prefill_stats)
149
150
assert stats.computed == 700
151
assert stats.local_cache_hit == 300
152
assert stats.external_kv_transfer == 0
153
+ assert stats.cached_tokens == 300
154
+ assert stats.total == 1000
155
156
157
def test_prompt_token_stats_partial_external_transfer():
@@ -147,15 +159,19 @@ def test_prompt_token_stats_partial_external_transfer():
159
stats = PromptTokenStats()
160
161
# Case 3: Partial external transfer
150
- stats.update_from_output(
151
- num_cached_tokens=500,
152
- num_external_computed_tokens=500,
153
- prompt_len=1000,
162
+ prefill_stats = PrefillStats()
163
+ prefill_stats.set(
164
+ num_prompt_tokens=1000,
165
+ num_local_cached_tokens=0,
166
+ num_external_cached_tokens=500,
167
)
168
+ stats.update_from_output(prefill_stats)
169
170
assert stats.computed == 500
171
assert stats.local_cache_hit == 0
172
assert stats.external_kv_transfer == 500
173
+ assert stats.cached_tokens == 500
174
+ assert stats.total == 1000
175
176
177
def test_prompt_token_stats_mixed_sources():
@@ -163,47 +179,60 @@ def test_prompt_token_stats_mixed_sources():
179
stats = PromptTokenStats()
180
181
# Case 4: Mixed sources
166
- stats.update_from_output(
167
- num_cached_tokens=600,
168
- num_external_computed_tokens=200,
169
- prompt_len=1000,
182
+ prefill_stats = PrefillStats()
183
+ prefill_stats.set(
184
+ num_prompt_tokens=1000,
185
+ num_local_cached_tokens=400,
186
+ num_external_cached_tokens=200,
187
)
188
+ stats.update_from_output(prefill_stats)
189
190
assert stats.computed == 400
191
assert stats.local_cache_hit == 400
192
assert stats.external_kv_transfer == 200
193
+ assert stats.cached_tokens == 600
194
+ assert stats.total == 1000
195
196
197
def test_prompt_token_stats_full_local_cache_recompute():
198
"""Test full local cache triggers last token recomputation.
199
180
- When all tokens are cached, the scheduler reduces num_cached_tokens by 1
181
- to force the model to recompute the last token.
200
+ When all tokens are cached, the scheduler forces the model to recompute
201
+ the last token (num_computed_tokens=1), with the rest from cache.
202
"""
203
stats = PromptTokenStats()
204
185
- # Case 5: Full local cache (999 cached after reduction, 1 recomputed)
186
- stats.update_from_output(
187
- num_cached_tokens=999,
188
- num_external_computed_tokens=0,
189
- prompt_len=1000,
205
+ # Case 5: Full local cache (999 cached, 1 recomputed)
206
+ prefill_stats = PrefillStats()
207
+ prefill_stats.set(
208
+ num_prompt_tokens=1000,
209
+ num_local_cached_tokens=999,
210
+ num_external_cached_tokens=0,
211
)
212
+ stats.update_from_output(prefill_stats)
213
214
assert stats.computed == 1
215
assert stats.local_cache_hit == 999
216
+ assert stats.external_kv_transfer == 0
217
+ assert stats.cached_tokens == 999
218
+ assert stats.total == 1000
219
220
221
def test_prompt_token_stats_full_external_transfer_recompute():
222
"""Test full external transfer triggers last token recomputation."""
223
stats = PromptTokenStats()
224
200
- # Case 6: Full external transfer (999 cached after reduction, 1 recomputed)
201
- stats.update_from_output(
202
- num_cached_tokens=999,
203
- num_external_computed_tokens=999,
204
- prompt_len=1000,
225
+ # Case 6: Full external transfer (999 from external, 1 recomputed)
226
+ prefill_stats = PrefillStats()
227
+ prefill_stats.set(
228
+ num_prompt_tokens=1000,
229
+ num_local_cached_tokens=0,
230
+ num_external_cached_tokens=999,
231
)
232
+ stats.update_from_output(prefill_stats)
233
234
assert stats.computed == 1
235
assert stats.local_cache_hit == 0
236
assert stats.external_kv_transfer == 999
237
+ assert stats.cached_tokens == 999
238
+ assert stats.total == 1000
vllm/v1/core/sched/scheduler.py
+11
-12
@@ -629,7 +629,6 @@ class Scheduler(SchedulerInterface):
629
step_skipped_waiting.prepend_request(request)
630
continue
631
632
- request.num_external_computed_tokens = ext_tokens
632
num_external_computed_tokens = ext_tokens
633
634
connector_prefix_cache_queries = (
@@ -642,6 +641,15 @@ class Scheduler(SchedulerInterface):
641
num_new_local_computed_tokens + num_external_computed_tokens
642
)
643
assert num_computed_tokens <= request.num_tokens
644
+
645
+ # Track first scheduled prefill, not post-preemption repeat prefills
646
+ if request.prefill_stats is not None:
647
+ assert num_computed_tokens <= request.num_prompt_tokens
648
+ request.prefill_stats.set(
649
+ num_prompt_tokens=request.num_prompt_tokens,
650
+ num_local_cached_tokens=num_new_local_computed_tokens,
651
+ num_external_cached_tokens=num_external_computed_tokens,
652
+ )
653
else:
654
# KVTransfer: WAITING reqs have num_computed_tokens > 0
655
# after async KV recvs are completed.
@@ -826,9 +834,6 @@ class Scheduler(SchedulerInterface):
834
token_budget -= num_new_tokens
835
request.status = RequestStatus.RUNNING
836
request.num_computed_tokens = num_computed_tokens
829
- # Count the number of prefix cached tokens.
830
- if request.num_cached_tokens < 0:
831
- request.num_cached_tokens = num_computed_tokens
837
# Encoder-related.
838
if encoder_inputs_to_schedule:
839
scheduled_encoder_inputs[request_id] = encoder_inputs_to_schedule
@@ -1466,10 +1471,9 @@ class Scheduler(SchedulerInterface):
1471
pooling_output=pooler_output,
1472
stop_reason=request.stop_reason,
1473
events=request.take_events(),
1474
+ prefill_stats=request.take_prefill_stats(),
1475
kv_transfer_params=kv_transfer_params,
1476
trace_headers=request.trace_headers,
1471
- num_cached_tokens=request.num_cached_tokens,
1472
- num_external_computed_tokens=request.num_external_computed_tokens,
1477
routed_experts=routed_experts,
1478
num_nans_in_logits=request.num_nans_in_logits,
1479
)
@@ -1496,7 +1500,6 @@ class Scheduler(SchedulerInterface):
1500
finish_reason=request.get_finished_reason(),
1501
events=request.take_events(),
1502
trace_headers=request.trace_headers,
1499
- num_cached_tokens=request.num_cached_tokens,
1503
)
1504
)
1505
@@ -2071,10 +2074,6 @@ class Scheduler(SchedulerInterface):
2074
if request.num_computed_tokens == request.num_tokens:
2075
request.num_computed_tokens = request.num_tokens - 1
2076
2074
- # Count the number of prefix cached tokens.
2075
- if request.num_cached_tokens < 0:
2076
- request.num_cached_tokens = request.num_computed_tokens
2077
-
2077
self.finished_recving_kv_req_ids.remove(request.request_id)
2078
2079
def _try_promote_blocked_waiting_request(self, request: Request) -> bool:
@@ -2221,7 +2220,7 @@ class Scheduler(SchedulerInterface):
2220
req_num_computed_tokens - request.num_computed_tokens
2221
)
2222
total_affected_tokens += num_affected_tokens
2224
- request.num_external_computed_tokens -= num_affected_tokens
2223
+
2224
# collect invalid block and all downstream dependent blocks
2225
if evict_blocks:
2226
blocks_to_evict.update(req_block_ids[idx:])
vllm/v1/engine/__init__.py
+4
-5
@@ -15,7 +15,7 @@ from vllm.lora.request import LoRARequest
15
from vllm.multimodal.inputs import MultiModalFeatureSpec
16
from vllm.pooling_params import PoolingParams
17
from vllm.sampling_params import SamplingParams
18
-from vllm.v1.metrics.stats import SchedulerStats
18
+from vllm.v1.metrics.stats import PrefillStats, SchedulerStats
19
from vllm.v1.outputs import LogprobsLists, LogprobsTensors
20
from vllm.v1.serial_utils import UtilityResult
21
@@ -171,10 +171,9 @@ class EngineCoreOutput(
171
kv_transfer_params: dict[str, Any] | None = None
172
173
trace_headers: Mapping[str, str] | None = None
174
- # The number of tokens with prefix cache hits (local + external).
175
- num_cached_tokens: int = 0
176
- # The number of tokens computed remotely (original count from connector).
177
- num_external_computed_tokens: int = 0
174
+
175
+ prefill_stats: PrefillStats | None = None
176
+
177
routed_experts: np.ndarray | None = None
178
# The number of NaNs in logits.
179
# A value greater than 0 indicates that the output is corrupted.
vllm/v1/engine/output_processor.py
+7
-3
@@ -617,8 +617,13 @@ class OutputProcessor:
617
stop_reason = engine_core_output.stop_reason
618
kv_transfer_params = engine_core_output.kv_transfer_params
619
routed_experts = engine_core_output.routed_experts
620
- req_state.num_cached_tokens = engine_core_output.num_cached_tokens
621
- req_state.is_prefilling = False
620
+
621
+ if req_state.is_prefilling:
622
+ if engine_core_output.prefill_stats is not None:
623
+ req_state.num_cached_tokens = (
624
+ engine_core_output.prefill_stats.num_cached_tokens
625
+ )
626
+ req_state.is_prefilling = False
627
628
if pooling_output is None:
629
assert req_state.detokenizer is not None
@@ -776,7 +781,6 @@ class OutputProcessor:
781
engine_core_output,
782
engine_core_timestamp,
783
req_state.is_prefilling,
779
- req_state.prompt_len,
784
req_state.stats,
785
self.lora_states,
786
req_state.lora_name,
vllm/v1/metrics/stats.py
+43
-27
@@ -239,6 +239,40 @@ class FinishedRequestStats:
239
num_cached_tokens: int = 0
240
241
242
+@dataclass
243
+class PrefillStats:
244
+ """Breakdown of a scheduled prefill computation.
245
+
246
+ Fields:
247
+ num_prompt_tokens: Total number of tokens to be prefilled.
248
+ num_computed_tokens: Tokens to be prefilled locally (actual compute work).
249
+ num_cached_tokens: Tokens to be prefilled without actual compute work.
250
+ num_local_cached_tokens: Tokens to be prefilled from local prefix cache.
251
+ num_external_cached_tokens: Tokens to be prefilled from external KV transfer.
252
+ """
253
+
254
+ num_prompt_tokens: int = 0
255
+ num_computed_tokens: int = 0
256
+ num_cached_tokens: int = 0
257
+ num_local_cached_tokens: int = 0
258
+ num_external_cached_tokens: int = 0
259
+
260
+ def set(
261
+ self,
262
+ num_prompt_tokens: int,
263
+ num_local_cached_tokens: int,
264
+ num_external_cached_tokens: int,
265
+ ):
266
+ num_cached_tokens = num_local_cached_tokens + num_external_cached_tokens
267
+ assert num_cached_tokens <= num_prompt_tokens
268
+
269
+ self.num_prompt_tokens = num_prompt_tokens
270
+ self.num_computed_tokens = num_prompt_tokens - num_cached_tokens
271
+ self.num_cached_tokens = num_cached_tokens
272
+ self.num_local_cached_tokens = num_local_cached_tokens
273
+ self.num_external_cached_tokens = num_external_cached_tokens
274
+
275
+
276
@dataclass
277
class PromptTokenStats:
278
"""Breakdown of prompt tokens by source.
@@ -267,28 +301,14 @@ class PromptTokenStats:
301
cached_tokens: int = 0
302
total: int = 0
303
270
- def update_from_output(
271
- self,
272
- num_cached_tokens: int,
273
- num_external_computed_tokens: int,
274
- prompt_len: int,
275
- ) -> None:
304
+ def update_from_output(self, prefill_stats: PrefillStats) -> None:
305
"""Update stats from a prefill output."""
277
- self.computed += prompt_len - num_cached_tokens
278
- self.external_kv_transfer += num_external_computed_tokens
279
- # FIXME(yifan): local_cache_hit can go negative after preemption.
280
- # num_cached_tokens is a one-time snapshot from first scheduling and
281
- # is never reset on preemption, while num_external_computed_tokens is
282
- # overwritten on re-scheduling. If CPU offload finds more tokens on
283
- # the second pass than the original total, the subtraction underflows.
284
- # A fundamental fix is to track the first-time num_external_computed_tokens
285
- # as a separate metric rather than reusing num_external_computed_tokens
286
- # for metric directly.
287
- self.local_cache_hit += max(
288
- 0, (num_cached_tokens - num_external_computed_tokens)
289
- )
290
- self.cached_tokens += num_cached_tokens
291
- self.total += prompt_len
306
+ self.computed += prefill_stats.num_computed_tokens
307
+ self.cached_tokens += prefill_stats.num_cached_tokens
308
+ self.total += prefill_stats.num_prompt_tokens
309
+
310
+ self.local_cache_hit += prefill_stats.num_local_cached_tokens
311
+ self.external_kv_transfer += prefill_stats.num_external_cached_tokens
312
313
def get_by_source(self, source: str) -> int:
314
"""Get token count by source label."""
@@ -335,7 +355,6 @@ class IterationStats:
355
output: "EngineCoreOutput",
356
engine_core_timestamp: float,
357
is_prefilling: bool,
338
- prompt_len: int,
358
req_stats: RequestStateStats,
359
lora_states: "LoRARequestStates",
360
lora_name: str | None,
@@ -344,11 +363,8 @@ class IterationStats:
363
364
self.num_generation_tokens += num_new_generation_tokens
365
if is_prefilling:
347
- self.prompt_token_stats.update_from_output(
348
- num_cached_tokens=output.num_cached_tokens,
349
- num_external_computed_tokens=output.num_external_computed_tokens,
350
- prompt_len=prompt_len,
351
- )
366
+ if output.prefill_stats is not None:
367
+ self.prompt_token_stats.update_from_output(output.prefill_stats)
368
369
first_token_latency = self._time_since(req_stats.arrival_time)
370
self.time_to_first_tokens_iter.append(first_token_latency)
vllm/v1/request.py
+9
-5
@@ -20,6 +20,7 @@ from vllm.v1.engine import (
20
EngineCoreRequest,
21
FinishReason,
22
)
23
+from vllm.v1.metrics.stats import PrefillStats
24
from vllm.v1.structured_output.request import StructuredOutputRequest
25
from vllm.v1.utils import ConstantList
26
@@ -145,9 +146,6 @@ class Request:
146
self.all_token_ids = ConstantList(self._all_token_ids)
147
# trace_headers
148
self.trace_headers = trace_headers
148
- # State
149
- # The number of tokens with prefix cache hits.
150
- self.num_cached_tokens = -1
149
150
# True if this request is scheduled as a non-final prefill chunk.
151
self.is_prefill_chunk = False
@@ -159,8 +157,7 @@ class Request:
157
# The number of times this request has been preempted by the scheduler.
158
self.num_preemptions = 0
159
162
- # The number of tokens that have been computed remotely.
163
- self.num_external_computed_tokens = 0
160
+ self.prefill_stats: PrefillStats | None = PrefillStats()
161
162
self.block_hashes: list[BlockHash] = []
163
# Store the block hasher without binding self to avoid creating a
@@ -278,6 +275,13 @@ class Request:
275
events, self.events = self.events, []
276
return events
277
278
+ def take_prefill_stats(self) -> PrefillStats | None:
279
+ if self.prefill_stats is None:
280
+ return None
281
+ prefill_stats = self.prefill_stats
282
+ self.prefill_stats = None
283
+ return prefill_stats
284
+
285
def __lt__(self, other: "Request") -> bool:
286
"""
287
Compare two requests based on priority, arrival time, and request ID.