[Metrics][P/D] Add KV-fetch stage gauges for async KV loads (#58874)

Signed-off-by: Zijing Liu <liuzijing2014@gmail.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Zijing Liu committed Oct 1, 2026 at 18:24 UTC d58da79de76d2c3dfd6799f3355dec9eb8af9b19
5 files changed +116
tests/v1/kv_connector/unit/test_remote_prefill_lifecycle.py
+28
@@ -5,6 +5,7 @@ from unittest.mock import patch
5
6 import pytest
7
8 +from vllm.v1.metrics.stats import KV_FETCH_STAGES
9 from vllm.v1.outputs import (
10 EMPTY_MODEL_RUNNER_OUTPUT,
11 KVConnectorOutput,
@@ -28,6 +29,11 @@ def _num_waiting_requests(scheduler) -> int:
29 return len(scheduler.waiting) + len(scheduler.kv_holding_waiting)
30
31
32 +def _kv_fetch_stages(scheduler) -> tuple[int, ...]:
33 + """(waiting_to_start, in_progress, completed_waiting) request counts."""
34 + return tuple(scheduler._kv_fetch_counts[s] for s in KV_FETCH_STAGES)
35 +
36 +
37 def test_basic_lifecycle():
38 """Test lifecycle of a remote prefill."""
39 vllm_config = create_vllm_config()
@@ -50,10 +56,12 @@ def test_basic_lifecycle():
56
57 scheduler.add_request(request)
58 request_id = request.request_id
59 + assert _kv_fetch_stages(scheduler) == (1, 0, 0)
60
61 # STEP (1):
62 # (1a): schedule()
63 scheduler_output = scheduler.schedule()
64 + assert _kv_fetch_stages(scheduler) == (0, 1, 0)
65
66 # Nothing running and empty scheduler output.
67 assert len(scheduler.running) == 0
@@ -105,11 +113,13 @@ def test_basic_lifecycle():
113 )
114 assert _num_waiting_requests(scheduler) == 1
115 assert request_id in scheduler.finished_recving_kv_req_ids
116 + assert _kv_fetch_stages(scheduler) == (0, 0, 1)
117
118 # STEP (3):
119 # (3a): schedule(): this should actually schedule.
120 scheduler_output = scheduler.schedule()
121 assert len(scheduler.running) == 1
122 + assert _kv_fetch_stages(scheduler) == (0, 0, 0)
123
124 # Confirm the block are actually allocated.
125 num_hashed_blocks = 0
@@ -582,6 +592,24 @@ def test_cannot_recv():
592 assert_scheduler_empty(scheduler)
593
594
595 +def test_kv_fetch_stages_cleared_on_abort():
596 + vllm_config = create_vllm_config()
597 + scheduler = create_scheduler(vllm_config)
598 + BLOCK_SIZE = vllm_config.cache_config.block_size
599 + request = create_request(
600 + request_id=1,
601 + block_size=BLOCK_SIZE,
602 + num_tokens=int(BLOCK_SIZE * 2.5),
603 + do_remote_prefill=True,
604 + )
605 + scheduler.add_request(request)
606 + scheduler.schedule()
607 + assert _kv_fetch_stages(scheduler) == (0, 1, 0)
608 +
609 + scheduler.finish_requests(request.request_id, RequestStatus.FINISHED_ABORTED)
610 + assert _kv_fetch_stages(scheduler) == (0, 0, 0)
611 +
612 +
613 @patch(
614 "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_scheduler.current_platform"
615 )
tests/v1/kv_connector/unit/utils.py
+2
@@ -60,6 +60,8 @@ def assert_scheduler_empty(scheduler: Scheduler):
60 assert len(scheduler.finished_req_ids) == 0
61 assert len(scheduler.finished_recving_kv_req_ids) == 0
62 assert len(scheduler._inflight_prefills) == 0
63 + assert not scheduler._kv_fetch_stages
64 + assert not any(scheduler._kv_fetch_counts.values())
65
66 # EncoderCacheManager.
67 assert len(scheduler.encoder_cache_manager.freed) == 0
vllm/v1/core/sched/scheduler.py
+34
@@ -62,6 +62,10 @@ from vllm.v1.kv_cache_interface import (
62 )
63 from vllm.v1.metrics.perf import ModelMetrics, PerfStats
64 from vllm.v1.metrics.stats import (
65 + KV_FETCH_COMPLETED_WAITING,
66 + KV_FETCH_IN_PROGRESS,
67 + KV_FETCH_STAGES,
68 + KV_FETCH_WAITING_TO_START,
69 PrefixCacheStats,
70 RequestSpecDecodeMetrics,
71 SchedulerStats,
@@ -412,6 +416,13 @@ class Scheduler(SchedulerInterface):
416 # async KV loads). Their remaining-block reservation gates async loads.
417 self._inflight_prefills: set[Request] = set()
418
419 + # Requests with an async KV load, by stage (KV_FETCH_STAGES).
420 + # None when not tracked (stats disabled or no KV connector).
421 + self._kv_fetch_stages: dict[Request, str] | None = (
422 + {} if self.log_stats and self.connector is not None else None
423 + )
424 + self._kv_fetch_counts: dict[str, int] = dict.fromkeys(KV_FETCH_STAGES, 0)
425 +
426 def _mamba_block_aligned_split(
427 self,
428 request: Request,
@@ -1039,6 +1050,9 @@ class Scheduler(SchedulerInterface):
1050 num_local_cached_tokens=num_new_local_computed_tokens,
1051 num_external_cached_tokens=num_external_computed_tokens,
1052 )
1053 + self._set_kv_fetch_stage(
1054 + request, KV_FETCH_WAITING_TO_START if load_kv_async else None
1055 + )
1056 else:
1057 # KVTransfer: WAITING reqs have num_computed_tokens > 0
1058 # after async KV recvs are completed. A streaming-input
@@ -1276,6 +1290,7 @@ class Scheduler(SchedulerInterface):
1290 # only the successfully loaded tokens.
1291 request.num_computed_tokens = num_computed_tokens
1292 self._inflight_prefills.add(request)
1293 + self._set_kv_fetch_stage(request, KV_FETCH_IN_PROGRESS)
1294 if self.needs_kv_cache_zeroing:
1295 # Skip zeroing of the blocks the async load will
1296 # overwrite; the zeroing could race the write.
@@ -1316,6 +1331,7 @@ class Scheduler(SchedulerInterface):
1331 input_budget -= num_new_tokens + draft_slots
1332 request.status = RequestStatus.RUNNING
1333 request.num_computed_tokens = num_computed_tokens
1334 + self._set_kv_fetch_stage(request, None)
1335 if pad_spec_decode:
1336 assert num_new_tokens == 1 + self.num_spec_tokens
1337 scheduled_spec_decode_tokens[request_id] = [
@@ -2588,6 +2604,11 @@ class Scheduler(SchedulerInterface):
2604 )
2605 if self.connector is not None:
2606 self.connector.on_new_request(request)
2607 + kv_transfer_params = request.kv_transfer_params
2608 + if kv_transfer_params and kv_transfer_params.get("do_remote_prefill"):
2609 + # Count P/D requests on arrival: connector matching is
2610 + # skipped while all run slots are taken.
2611 + self._set_kv_fetch_stage(request, KV_FETCH_WAITING_TO_START)
2612 if self.log_stats:
2613 request.record_event(EngineCoreEventType.QUEUED)
2614
@@ -2664,6 +2685,7 @@ class Scheduler(SchedulerInterface):
2685 if self.aux_output_connector is not None:
2686 self.aux_output_connector.request_finished(request)
2687 self._inflight_prefills.discard(request)
2688 + self._set_kv_fetch_stage(request, None)
2689 connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request)
2690
2691 # EC Connector: mirror the KV hook. The contract requires firing
@@ -2889,6 +2911,7 @@ class Scheduler(SchedulerInterface):
2911 num_running_reqs=num_running,
2912 num_waiting_reqs=num_waiting - num_deferred,
2913 num_skipped_waiting_reqs=num_deferred,
2914 + num_kv_fetch_reqs_by_stage=self._kv_fetch_counts.copy(),
2915 kv_cache_usage=self.kv_cache_manager.usage,
2916 prefix_cache_stats=prefix_cache_stats,
2917 connector_prefix_cache_stats=connector_prefix_cache_stats,
@@ -3028,6 +3051,16 @@ class Scheduler(SchedulerInterface):
3051 1 + self.num_spec_tokens + self.num_lookahead_tokens, self.block_size
3052 )
3053
3054 + def _set_kv_fetch_stage(self, request: Request, stage: str | None) -> None:
3055 + kv_fetch_stages = self._kv_fetch_stages
3056 + if kv_fetch_stages is not None:
3057 + prev_stage = kv_fetch_stages.pop(request, None)
3058 + if prev_stage is not None:
3059 + self._kv_fetch_counts[prev_stage] -= 1
3060 + if stage is not None:
3061 + kv_fetch_stages[request] = stage
3062 + self._kv_fetch_counts[stage] += 1
3063 +
3064 def _inflight_prefill_reserved_blocks(self) -> int:
3065 """Num blocks in-flight prefills still need to finish (their reservation)."""
3066 return sum(
@@ -3148,6 +3181,7 @@ class Scheduler(SchedulerInterface):
3181 req = self.requests[req_id]
3182 if req.status == RequestStatus.WAITING_FOR_REMOTE_KVS:
3183 self.finished_recving_kv_req_ids.add(req_id)
3184 + self._set_kv_fetch_stage(req, KV_FETCH_COMPLETED_WAITING)
3185 else:
3186 assert RequestStatus.is_finished(req.status)
3187 self._free_blocks(self.requests[req_id])
vllm/v1/metrics/loggers.py
+40
@@ -26,6 +26,7 @@ from vllm.v1.metrics.buckets import histogram_buckets
26 from vllm.v1.metrics.perf import PerfMetricsLogging, PerfMetricsProm
27 from vllm.v1.metrics.prometheus import unregister_vllm_metrics
28 from vllm.v1.metrics.stats import (
29 + KV_FETCH_STAGES,
30 CachingMetrics,
31 IterationStats,
32 MultiModalCacheStats,
@@ -292,6 +293,13 @@ class LoggingStatLogger(StatLoggerBase):
293 log_parts.append("Deferred: %d reqs")
294 log_args.append(self.last_scheduler_stats.num_skipped_waiting_reqs)
295
296 + kv_fetch = self.last_scheduler_stats.num_kv_fetch_reqs_by_stage
297 + if any(kv_fetch.values()):
298 + log_parts.append(
299 + "KV fetch: %d waiting to start, %d in progress, %d completed waiting"
300 + )
301 + log_args.extend(kv_fetch.get(stage, 0) for stage in KV_FETCH_STAGES)
302 +
303 if self.num_preemptions > 0:
304 log_parts.append("Preemptions: %d")
305 log_args.append(self.num_preemptions)
@@ -393,6 +401,10 @@ class AggregatedLoggingStatLogger(LoggingStatLogger, AggregateStatLoggerBase):
401 self.last_scheduler_stats.num_skipped_waiting_reqs += (
402 last_scheduler_stats.num_skipped_waiting_reqs
403 )
404 + engine_kv_fetch = last_scheduler_stats.num_kv_fetch_reqs_by_stage
405 + total_kv_fetch = self.last_scheduler_stats.num_kv_fetch_reqs_by_stage
406 + for stage, num_reqs in engine_kv_fetch.items():
407 + total_kv_fetch[stage] = total_kv_fetch.get(stage, 0) + num_reqs
408 self.last_scheduler_stats.kv_cache_usage += (
409 last_scheduler_stats.kv_cache_usage
410 )
@@ -547,6 +559,30 @@ class PrometheusStatLogger(AggregateStatLoggerBase):
559 gauge_waiting_by_reason, per_engine_labelvalues_with_reason
560 )
561
562 + self.gauge_kv_fetch_by_stage: dict[str, dict[int, Gauge]] = {}
563 + kv_transfer_config = vllm_config.kv_transfer_config
564 + if kv_transfer_config is not None and kv_transfer_config.is_kv_consumer:
565 + gauge_kv_fetch_by_stage = self._gauge_cls(
566 + name="vllm:num_requests_kv_fetch_by_stage",
567 + documentation=(
568 + "Number of waiting requests by async KV load stage. "
569 + "Stage labels: 'waiting_to_start' = needs an async KV load "
570 + "that has not started; 'in_progress' = load started and not "
571 + "yet reported finished; 'completed_waiting' = load finished, "
572 + "request not running yet."
573 + ),
574 + multiprocess_mode="mostrecent",
575 + labelnames=labelnames + ["stage"],
576 + )
577 + for stage in KV_FETCH_STAGES:
578 + per_engine_labelvalues_with_stage = {
579 + idx: labelvalues + [stage]
580 + for idx, labelvalues in per_engine_labelvalues.items()
581 + }
582 + self.gauge_kv_fetch_by_stage[stage] = create_metric_per_engine(
583 + gauge_kv_fetch_by_stage, per_engine_labelvalues_with_stage
584 + )
585 +
586 gauge_engine_sleep_state = self._gauge_cls(
587 name="vllm:engine_sleep_state",
588 documentation=(
@@ -1062,6 +1098,10 @@ class PrometheusStatLogger(AggregateStatLoggerBase):
1098 self.gauge_waiting_by_reason[WAITING_REASON_DEFERRED][engine_idx].set(
1099 scheduler_stats.num_skipped_waiting_reqs
1100 )
1101 + for stage, gauges in self.gauge_kv_fetch_by_stage.items():
1102 + gauges[engine_idx].set(
1103 + scheduler_stats.num_kv_fetch_reqs_by_stage.get(stage, 0)
1104 + )
1105 self.gauge_kv_cache_usage[engine_idx].set(scheduler_stats.kv_cache_usage)
1106
1107 self.counter_prefix_cache_queries[engine_idx].inc(
vllm/v1/metrics/stats.py
+12
@@ -182,6 +182,16 @@ class SchedulerIterationDetails:
182 is_dummy: bool = False
183
184
185 +KV_FETCH_WAITING_TO_START = "waiting_to_start"
186 +KV_FETCH_IN_PROGRESS = "in_progress"
187 +KV_FETCH_COMPLETED_WAITING = "completed_waiting"
188 +KV_FETCH_STAGES = (
189 + KV_FETCH_WAITING_TO_START,
190 + KV_FETCH_IN_PROGRESS,
191 + KV_FETCH_COMPLETED_WAITING,
192 +)
193 +
194 +
195 @dataclass
196 class SchedulerStats:
197 """Stats associated with the scheduler."""
@@ -192,6 +202,8 @@ class SchedulerStats:
202 # waiting requests deferred by transient constraints or a blocked status
203 num_skipped_waiting_reqs: int = 0
204
205 + num_kv_fetch_reqs_by_stage: dict[str, int] = field(default_factory=dict)
206 +
207 # These are used for internal DP load-balancing.
208 step_counter: int = 0
209 current_wave: int = 0