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 14, 05:30 UTC
[Metrics] [KVConnector] Add connector prefix cache hit rate stats (#26245)
Signed-off-by: tovam <tovam@pliops.com>
Tova Movshovitz committed
Oct 23, 2025 at 13:21 UTC
88afa1101069305974cf9c50319965ecc9107e76
5 files changed
+152
-11
tests/v1/core/test_scheduler.py
+60
@@ -1014,6 +1014,66 @@ def test_kv_connector_basic():
1014
)
1015
1016
1017
+def test_external_prefix_cache_metrics():
1018
+ """
1019
+ Verify connector prefix cache metrics are updated
1020
+ correctly when the scheduler processes requests with KV connector hits.
1021
+ """
1022
+
1023
+ # Setup Scheduler.
1024
+ scheduler = create_scheduler(
1025
+ enable_prefix_caching=False,
1026
+ use_kv_connector=True,
1027
+ )
1028
+
1029
+ # Mock connector to simulate a partial external cache hit
1030
+ NUM_MATCHED_NEW_TOKENS = 4
1031
+ scheduler.connector.get_num_new_matched_tokens = Mock(name="method")
1032
+ scheduler.connector.get_num_new_matched_tokens.return_value = (
1033
+ NUM_MATCHED_NEW_TOKENS,
1034
+ False,
1035
+ )
1036
+
1037
+ # --- Prepare simple requests ---
1038
+ NUM_REQUESTS = 2
1039
+ NUM_TOKENS = 8
1040
+ MAX_TOKENS = 2
1041
+ requests = create_requests(
1042
+ num_requests=NUM_REQUESTS,
1043
+ num_tokens=NUM_TOKENS,
1044
+ max_tokens=MAX_TOKENS,
1045
+ )
1046
+
1047
+ for req in requests:
1048
+ scheduler.add_request(req)
1049
+
1050
+ # --- Trigger scheduling and simulate model output ---
1051
+ output = scheduler.schedule()
1052
+ MODEL_RUNNER_OUTPUT = ModelRunnerOutput(
1053
+ req_ids=[r.request_id for r in requests],
1054
+ req_id_to_index={r.request_id: i for i, r in enumerate(requests)},
1055
+ sampled_token_ids=[[1000]] * NUM_REQUESTS,
1056
+ logprobs=None,
1057
+ prompt_logprobs_dict={},
1058
+ pooler_output=[],
1059
+ )
1060
+
1061
+ # Update scheduler stats
1062
+ ecos = scheduler.update_from_output(output, MODEL_RUNNER_OUTPUT)
1063
+
1064
+ # --- Assertions ---
1065
+ assert ecos is not None and len(ecos) > 0
1066
+ assert ecos[0].scheduler_stats is not None
1067
+
1068
+ external_stats = ecos[0].scheduler_stats.connector_prefix_cache_stats
1069
+ assert external_stats is not None
1070
+
1071
+ assert external_stats.queries == NUM_TOKENS * NUM_REQUESTS
1072
+ assert external_stats.hits == NUM_MATCHED_NEW_TOKENS * NUM_REQUESTS
1073
+ assert external_stats.requests == NUM_REQUESTS
1074
+ assert external_stats.preempted_requests == 0
1075
+
1076
+
1077
def test_kv_connector_unable_to_allocate():
1078
"""
1079
Test whether scheduler with KVConnector is able to handle
vllm/v1/core/kv_cache_manager.py
+5
-10
@@ -208,16 +208,11 @@ class KVCacheManager:
208
209
if self.log_stats:
210
assert self.prefix_cache_stats is not None
211
- if request.num_preemptions > 0:
212
- # Previously preempted request
213
- self.prefix_cache_stats.preempted_requests += 1
214
- self.prefix_cache_stats.preempted_queries += request.num_tokens
215
- self.prefix_cache_stats.preempted_hits += num_new_computed_tokens
216
- else:
217
- # New request
218
- self.prefix_cache_stats.requests += 1
219
- self.prefix_cache_stats.queries += request.num_tokens
220
- self.prefix_cache_stats.hits += num_new_computed_tokens
211
+ self.prefix_cache_stats.record(
212
+ num_tokens=request.num_tokens,
213
+ num_hits=num_new_computed_tokens,
214
+ preempted=request.num_preemptions > 0,
215
+ )
216
217
return self.create_kv_cache_blocks(computed_blocks), num_new_computed_tokens
218
vllm/v1/core/sched/scheduler.py
+28
-1
@@ -28,7 +28,7 @@ from vllm.v1.core.sched.request_queue import SchedulingPolicy, create_request_qu
28
from vllm.v1.core.sched.utils import check_stop, remove_all
29
from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs
30
from vllm.v1.kv_cache_interface import KVCacheConfig
31
-from vllm.v1.metrics.stats import SchedulerStats
31
+from vllm.v1.metrics.stats import PrefixCacheStats, SchedulerStats
32
from vllm.v1.outputs import DraftTokenIds, KVConnectorOutput, ModelRunnerOutput
33
from vllm.v1.request import Request, RequestStatus
34
from vllm.v1.spec_decode.metrics import SpecDecodingStats
@@ -84,6 +84,7 @@ class Scheduler(SchedulerInterface):
84
# will have a corresponding KVConnector with Role=WORKER.
85
# KV Connector pushes/pull of remote KVs for P/D and offloading.
86
self.connector = None
87
+ self.connector_prefix_cache_stats: PrefixCacheStats | None = None
88
if self.vllm_config.kv_transfer_config is not None:
89
assert len(self.kv_cache_config.kv_cache_groups) == 1, (
90
"Multiple KV cache groups are not currently supported "
@@ -95,6 +96,8 @@ class Scheduler(SchedulerInterface):
96
self.connector = KVConnectorFactory.create_connector(
97
config=self.vllm_config, role=KVConnectorRole.SCHEDULER
98
)
99
+ if self.log_stats:
100
+ self.connector_prefix_cache_stats = PrefixCacheStats()
101
102
self.kv_event_publisher = EventPublisherFactory.create(
103
self.kv_events_config,
@@ -526,6 +529,9 @@ class Scheduler(SchedulerInterface):
529
new_computed_blocks + new_blocks,
530
num_external_computed_tokens,
531
)
532
+ self._update_connector_prefix_cache_stats(
533
+ request, num_external_computed_tokens
534
+ )
535
536
# Request was already popped from self.waiting
537
# unless it was re-added above due to new_blocks being None.
@@ -1247,11 +1253,13 @@ class Scheduler(SchedulerInterface):
1253
return None
1254
prefix_cache_stats = self.kv_cache_manager.make_prefix_cache_stats()
1255
assert prefix_cache_stats is not None
1256
+ connector_prefix_cache_stats = self._make_connector_prefix_cache_stats()
1257
return SchedulerStats(
1258
num_running_reqs=len(self.running),
1259
num_waiting_reqs=len(self.waiting),
1260
kv_cache_usage=self.kv_cache_manager.usage,
1261
prefix_cache_stats=prefix_cache_stats,
1262
+ connector_prefix_cache_stats=connector_prefix_cache_stats,
1263
spec_decoding_stats=spec_decoding_stats,
1264
num_corrupted_reqs=sum(req.is_output_corrupted for req in self.running),
1265
kv_connector_stats=kv_connector_stats.data if kv_connector_stats else None,
@@ -1282,6 +1290,25 @@ class Scheduler(SchedulerInterface):
1290
# KV Connector Related Methods
1291
########################################################################
1292
1293
+ def _update_connector_prefix_cache_stats(
1294
+ self, request: Request, num_external_tokens: int
1295
+ ) -> None:
1296
+ if self.connector_prefix_cache_stats is None:
1297
+ return
1298
+
1299
+ self.connector_prefix_cache_stats.record(
1300
+ num_tokens=request.num_tokens,
1301
+ num_hits=num_external_tokens,
1302
+ preempted=request.num_preemptions > 0,
1303
+ )
1304
+
1305
+ def _make_connector_prefix_cache_stats(self) -> PrefixCacheStats | None:
1306
+ if self.connector_prefix_cache_stats is None:
1307
+ return None
1308
+ stats = self.connector_prefix_cache_stats
1309
+ self.connector_prefix_cache_stats = PrefixCacheStats()
1310
+ return stats
1311
+
1312
def get_kv_connector(self) -> KVConnectorBase_V1 | None:
1313
return self.connector
1314
vllm/v1/metrics/loggers.py
+45
@@ -93,6 +93,7 @@ class LoggingStatLogger(StatLoggerBase):
93
# Caching metrics. This cannot be reset.
94
# TODO: Make the interval configurable.
95
self.prefix_caching_metrics = CachingMetrics()
96
+ self.connector_prefix_caching_metrics = CachingMetrics()
97
self.mm_caching_metrics = CachingMetrics()
98
99
self.spec_decoding_logging = SpecDecodingLogging()
@@ -140,6 +141,11 @@ class LoggingStatLogger(StatLoggerBase):
141
if scheduler_stats is not None:
142
self.prefix_caching_metrics.observe(scheduler_stats.prefix_cache_stats)
143
144
+ if scheduler_stats.connector_prefix_cache_stats is not None:
145
+ self.connector_prefix_caching_metrics.observe(
146
+ scheduler_stats.connector_prefix_cache_stats
147
+ )
148
+
149
if scheduler_stats.spec_decoding_stats is not None:
150
self.spec_decoding_logging.observe(scheduler_stats.spec_decoding_stats)
151
if kv_connector_stats := scheduler_stats.kv_connector_stats:
@@ -192,6 +198,9 @@ class LoggingStatLogger(StatLoggerBase):
198
self.last_scheduler_stats.kv_cache_usage * 100,
199
self.prefix_caching_metrics.hit_rate * 100,
200
]
201
+ if not self.connector_prefix_caching_metrics.empty:
202
+ log_parts.append("External prefix cache hit rate: %.1f%%")
203
+ log_args.append(self.connector_prefix_caching_metrics.hit_rate * 100)
204
if not self.mm_caching_metrics.empty:
205
log_parts.append("MM cache hit rate: %.1f%%")
206
log_args.append(self.mm_caching_metrics.hit_rate * 100)
@@ -457,6 +466,34 @@ class PrometheusStatLogger(AggregateStatLoggerBase):
466
counter_prefix_cache_hits, engine_indexes, model_name
467
)
468
469
+ #
470
+ # External - KV connector prefix cache
471
+ #
472
+
473
+ counter_connector_prefix_cache_queries = self._counter_cls(
474
+ name="vllm:external_prefix_cache_queries",
475
+ documentation=(
476
+ "External prefix cache queries from KV connector "
477
+ "cross-instance cache sharing, in terms of number of queried tokens."
478
+ ),
479
+ labelnames=labelnames,
480
+ )
481
+ self.counter_connector_prefix_cache_queries = make_per_engine(
482
+ counter_connector_prefix_cache_queries, engine_indexes, model_name
483
+ )
484
+
485
+ counter_connector_prefix_cache_hits = self._counter_cls(
486
+ name="vllm:external_prefix_cache_hits",
487
+ documentation=(
488
+ "External prefix cache hits from KV connector "
489
+ "cross-instance cache sharing, in terms of number of cached tokens."
490
+ ),
491
+ labelnames=labelnames,
492
+ )
493
+ self.counter_connector_prefix_cache_hits = make_per_engine(
494
+ counter_connector_prefix_cache_hits, engine_indexes, model_name
495
+ )
496
+
497
#
498
# Multi-modal cache
499
#
@@ -883,6 +920,14 @@ class PrometheusStatLogger(AggregateStatLoggerBase):
920
scheduler_stats.prefix_cache_stats.hits
921
)
922
923
+ if scheduler_stats.connector_prefix_cache_stats is not None:
924
+ self.counter_connector_prefix_cache_queries[engine_idx].inc(
925
+ scheduler_stats.connector_prefix_cache_stats.queries
926
+ )
927
+ self.counter_connector_prefix_cache_hits[engine_idx].inc(
928
+ scheduler_stats.connector_prefix_cache_stats.hits
929
+ )
930
+
931
if scheduler_stats.spec_decoding_stats is not None:
932
self.spec_decoding_prom.observe(
933
scheduler_stats.spec_decoding_stats, engine_idx
vllm/v1/metrics/stats.py
+14
@@ -126,6 +126,19 @@ class PrefixCacheStats(BaseCacheStats):
126
preempted_hits: int = 0
127
"""The `hits` number for preempted requests."""
128
129
+ def record(self, num_tokens: int, num_hits: int, preempted: bool) -> None:
130
+ """Aggregate request information into the stats."""
131
+ if preempted:
132
+ # Previously preempted request
133
+ self.preempted_requests += 1
134
+ self.preempted_queries += num_tokens
135
+ self.preempted_hits += num_hits
136
+ else:
137
+ # New request
138
+ self.requests += 1
139
+ self.queries += num_tokens
140
+ self.hits += num_hits
141
+
142
143
@dataclass
144
class MultiModalCacheStats(BaseCacheStats):
@@ -151,6 +164,7 @@ class SchedulerStats:
164
kv_cache_usage: float = 0.0
165
166
prefix_cache_stats: PrefixCacheStats = field(default_factory=PrefixCacheStats)
167
+ connector_prefix_cache_stats: PrefixCacheStats | None = None
168
169
spec_decoding_stats: SpecDecodingStats | None = None
170
kv_connector_stats: dict[str, Any] | None = None