[Rust Frontend] Cache metric handles for scheduler & request stats (#47444)

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Jul 6, 2026 at 21:02 UTC 8f4c69b22293424690ea5956d536c20c3d4ec393
9 files changed +343 -237
rust/src/engine-core-client/src/client.rs
+1
@@ -512,6 +512,7 @@ impl EngineCoreClient {
512
513 Ok(EngineCoreOutputStream::new(
514 request_id,
515 + engine_id.engine_index().unwrap_or(0),
516 self.abort_tx.clone(),
517 rx,
518 ))
rust/src/engine-core-client/src/client/imp.rs
+6 -7
@@ -15,7 +15,7 @@ use crate::client::state::{OutputReceiver, RequestRegistry, UtilityReceiver, Uti
15 use crate::client::stream::EngineCoreStreamOutput;
16 use crate::client::{AbortCause, AbortRequest};
17 use crate::error::{client_closed, dispatcher_closed, unexpected_dispatcher_output};
18 -use crate::metrics::{LoraInfoExporter, record_scheduler_stats};
18 +use crate::metrics::{LoraInfoExporter, SchedulerStatsRecorder};
19 use crate::protocol::encode_msgpack;
20 use crate::protocol::output::{EngineCoreOutput, EngineCoreOutputs};
21 use crate::protocol::request::EngineCoreRequestType;
@@ -29,6 +29,7 @@ pub(crate) struct ClientInner {
29 /// The runtime handle used for sending messages to the engine.
30 handle: Handle,
31 model_name: String,
32 + scheduler_stats_recorder: SchedulerStatsRecorder,
33 request_reg: Mutex<RequestRegistry>,
34 utility_reg: Mutex<UtilityRegistry>,
35 health_error: ArcSwapOption<Error>,
@@ -43,10 +44,13 @@ impl ClientInner {
44 model_name: String,
45 engines: &[ConnectedEngine],
46 ) -> Self {
47 + let scheduler_stats_recorder =
48 + SchedulerStatsRecorder::new(&METRICS.scheduler, &model_name, engines);
49 Self {
50 input_send,
51 handle,
52 model_name,
53 + scheduler_stats_recorder,
54 request_reg: Mutex::new(RequestRegistry::new(engines)),
55 utility_reg: Mutex::new(UtilityRegistry::default()),
56 health_error: ArcSwapOption::empty(),
@@ -389,12 +393,7 @@ pub(crate) async fn run_output_dispatcher_loop(
393 "dropping scheduler stats for unknown engine"
394 );
395 }
392 - record_scheduler_stats(
393 - &METRICS.scheduler,
394 - inner.model_name(),
395 - batch.engine_index,
396 - scheduler_stats,
397 - );
396 + inner.scheduler_stats_recorder.record(batch.engine_index, scheduler_stats);
397 }
398
399 // The engine's scheduler stats never carry adapter names;
rust/src/engine-core-client/src/client/stream.rs
+8
@@ -45,6 +45,7 @@ impl Deref for EngineCoreStreamOutput {
45 /// `finish_reason` is non-`None`.
46 pub struct EngineCoreOutputStream {
47 request_id: String,
48 + engine_index: u32,
49 abort_tx: mpsc::UnboundedSender<AbortRequest>,
50 state: State,
51 rx: OutputReceiver,
@@ -53,11 +54,13 @@ pub struct EngineCoreOutputStream {
54 impl EngineCoreOutputStream {
55 pub(crate) fn new(
56 request_id: String,
57 + engine_index: u32,
58 abort_tx: mpsc::UnboundedSender<AbortRequest>,
59 rx: OutputReceiver,
60 ) -> Self {
61 Self {
62 request_id,
63 + engine_index,
64 abort_tx,
65 state: State::Running,
66 rx,
@@ -68,6 +71,11 @@ impl EngineCoreOutputStream {
71 pub fn request_id(&self) -> &str {
72 &self.request_id
73 }
74 +
75 + /// Return the index of the engine that owns this request.
76 + pub fn engine_index(&self) -> u32 {
77 + self.engine_index
78 + }
79 }
80
81 impl Stream for EngineCoreOutputStream {
rust/src/engine-core-client/src/metrics.rs
+160 -74
@@ -1,90 +1,190 @@
1 +use std::collections::BTreeMap;
2 use std::collections::BTreeSet;
3 use std::time::{SystemTime, UNIX_EPOCH};
4
5 use vllm_metrics::{
5 - EngineLabels, EnginePositionLabels, LoraAdapterNames, LoraInfoLabels, SchedulerMetrics,
6 + EngineLabels, EnginePositionLabels, F64Gauge, Family, HistogramMetric, LoraAdapterNames,
7 + LoraInfoLabels, SchedulerLogStatsAccumulator, SchedulerMetrics, U64Counter, U64Gauge,
8 WaitingReasonLabels,
9 };
10
11 use crate::protocol::stats::SchedulerStats;
12 +use crate::transport::ConnectedEngine;
13
14 const WAITING_REASON_CAPACITY: &str = "capacity";
15 const WAITING_REASON_DEFERRED: &str = "deferred";
16
14 -/// Record the scheduler-stats-backed metrics for one engine at one point in
15 -/// time.
16 -pub(crate) fn record_scheduler_stats(
17 +/// Cached scheduler-stats metric handles for all engines connected to one
18 +/// frontend client.
19 +pub(crate) struct SchedulerStatsRecorder {
20 + engines: BTreeMap<u32, SchedulerStatsHandles>,
21 +}
22 +
23 +/// Per-engine cached metric handles used while recording `SchedulerStats`.
24 +struct SchedulerStatsHandles {
25 + // Base labels reused for dynamic child labels.
26 + labels: EngineLabels,
27 +
28 + // Scheduler state gauges.
29 + scheduler_running: U64Gauge,
30 + scheduler_waiting: U64Gauge,
31 + scheduler_waiting_capacity: U64Gauge,
32 + scheduler_waiting_deferred: U64Gauge,
33 + kv_cache_usage: F64Gauge,
34 +
35 + // Prefix-cache counters, including the connector-backed external cache path.
36 + prefix_cache_queries: U64Counter,
37 + prefix_cache_hits: U64Counter,
38 + external_prefix_cache_queries: U64Counter,
39 + external_prefix_cache_hits: U64Counter,
40 +
41 + // Speculative decoding counters.
42 + spec_decode_num_drafts: U64Counter,
43 + spec_decode_num_draft_tokens: U64Counter,
44 + spec_decode_num_accepted_tokens: U64Counter,
45 + spec_decode_num_accepted_tokens_per_pos: Family<EnginePositionLabels, U64Counter>,
46 +
47 + // Per-engine performance / MFU counters.
48 + estimated_flops_per_gpu: U64Counter,
49 + estimated_read_bytes_per_gpu: U64Counter,
50 + estimated_write_bytes_per_gpu: U64Counter,
51 +
52 + // Sampled KV-cache residency histograms.
53 + kv_block_lifetime_seconds: HistogramMetric,
54 + kv_block_idle_before_evict_seconds: HistogramMetric,
55 + kv_block_reuse_gap_seconds: HistogramMetric,
56 +
57 + // Non-Prometheus interval accumulator for periodic text-log helpers.
58 + log_stats: SchedulerLogStatsAccumulator,
59 +}
60 +
61 +impl SchedulerStatsRecorder {
62 + /// Resolve the fixed-label metric handles for the connected engines.
63 + pub(crate) fn new(
64 + metrics: &SchedulerMetrics,
65 + model_name: &str,
66 + engines: &[ConnectedEngine],
67 + ) -> Self {
68 + let engines = engines
69 + .iter()
70 + .filter_map(|engine| {
71 + let engine = engine.engine_id.engine_index()?;
72 + Some((
73 + engine,
74 + resolve_scheduler_stats_handles(metrics, model_name, engine),
75 + ))
76 + })
77 + .collect();
78 +
79 + Self { engines }
80 + }
81 +
82 + /// Record one scheduler-stats payload for the given engine index.
83 + pub(crate) fn record(&self, engine_index: u32, stats: &SchedulerStats) {
84 + if let Some(handles) = self.engines.get(&engine_index) {
85 + record_scheduler_stats_with_handles(handles, stats);
86 + }
87 + }
88 +}
89 +
90 +/// Resolve all fixed-label scheduler metrics for one engine.
91 +fn resolve_scheduler_stats_handles(
92 metrics: &SchedulerMetrics,
18 - model_name: impl Into<String>,
93 + model_name: &str,
94 engine: u32,
20 - stats: &SchedulerStats,
21 -) {
22 - let model_name = model_name.into();
95 +) -> SchedulerStatsHandles {
96 let labels = EngineLabels {
24 - model_name: model_name.clone(),
97 + model_name: model_name.to_string(),
98 + engine,
99 + };
100 + let capacity = WaitingReasonLabels {
101 + model_name: model_name.to_string(),
102 + engine,
103 + reason: WAITING_REASON_CAPACITY,
104 + };
105 + let deferred = WaitingReasonLabels {
106 + model_name: model_name.to_string(),
107 engine,
108 + reason: WAITING_REASON_DEFERRED,
109 };
110
111 + SchedulerStatsHandles {
112 + scheduler_running: metrics.scheduler_running.get_or_create_owned(&labels),
113 + scheduler_waiting: metrics.scheduler_waiting.get_or_create_owned(&labels),
114 + scheduler_waiting_capacity: metrics
115 + .scheduler_waiting_by_reason
116 + .get_or_create_owned(&capacity),
117 + scheduler_waiting_deferred: metrics
118 + .scheduler_waiting_by_reason
119 + .get_or_create_owned(&deferred),
120 + kv_cache_usage: metrics.kv_cache_usage.get_or_create_owned(&labels),
121 + prefix_cache_queries: metrics.prefix_cache_queries.get_or_create_owned(&labels),
122 + prefix_cache_hits: metrics.prefix_cache_hits.get_or_create_owned(&labels),
123 + external_prefix_cache_queries: metrics
124 + .external_prefix_cache_queries
125 + .get_or_create_owned(&labels),
126 + external_prefix_cache_hits: metrics.external_prefix_cache_hits.get_or_create_owned(&labels),
127 + spec_decode_num_drafts: metrics.spec_decode_num_drafts.get_or_create_owned(&labels),
128 + spec_decode_num_draft_tokens: metrics
129 + .spec_decode_num_draft_tokens
130 + .get_or_create_owned(&labels),
131 + spec_decode_num_accepted_tokens: metrics
132 + .spec_decode_num_accepted_tokens
133 + .get_or_create_owned(&labels),
134 + spec_decode_num_accepted_tokens_per_pos: metrics
135 + .spec_decode_num_accepted_tokens_per_pos
136 + .clone(),
137 + log_stats: metrics.log_stats.get_or_create_owned(&labels),
138 + estimated_flops_per_gpu: metrics.estimated_flops_per_gpu.get_or_create_owned(&labels),
139 + estimated_read_bytes_per_gpu: metrics
140 + .estimated_read_bytes_per_gpu
141 + .get_or_create_owned(&labels),
142 + estimated_write_bytes_per_gpu: metrics
143 + .estimated_write_bytes_per_gpu
144 + .get_or_create_owned(&labels),
145 + kv_block_lifetime_seconds: metrics.kv_block_lifetime_seconds.get_or_create_owned(&labels),
146 + kv_block_idle_before_evict_seconds: metrics
147 + .kv_block_idle_before_evict_seconds
148 + .get_or_create_owned(&labels),
149 + kv_block_reuse_gap_seconds: metrics.kv_block_reuse_gap_seconds.get_or_create_owned(&labels),
150 + labels,
151 + }
152 +}
153 +
154 +/// Record scheduler-stats values through pre-resolved metric handles.
155 +fn record_scheduler_stats_with_handles(handles: &SchedulerStatsHandles, stats: &SchedulerStats) {
156 // Scheduler state gauges.
29 - metrics.scheduler_running.get_or_create(&labels).set(stats.num_running_reqs);
30 - metrics
157 + handles.scheduler_running.set(stats.num_running_reqs);
158 + handles
159 .scheduler_waiting
32 - .get_or_create(&labels)
160 .set(stats.num_waiting_reqs + stats.num_skipped_waiting_reqs);
34 - metrics
35 - .scheduler_waiting_by_reason
36 - .get_or_create(&WaitingReasonLabels {
37 - model_name: model_name.clone(),
38 - engine,
39 - reason: WAITING_REASON_CAPACITY,
40 - })
41 - .set(stats.num_waiting_reqs);
42 - metrics
43 - .scheduler_waiting_by_reason
44 - .get_or_create(&WaitingReasonLabels {
45 - model_name: model_name.clone(),
46 - engine,
47 - reason: WAITING_REASON_DEFERRED,
48 - })
49 - .set(stats.num_skipped_waiting_reqs);
50 - metrics.kv_cache_usage.get_or_create(&labels).set(stats.kv_cache_usage);
161 + handles.scheduler_waiting_capacity.set(stats.num_waiting_reqs);
162 + handles.scheduler_waiting_deferred.set(stats.num_skipped_waiting_reqs);
163 + handles.kv_cache_usage.set(stats.kv_cache_usage);
164
165 // Prefix-cache counters, including the connector-backed external cache path.
53 - metrics
54 - .prefix_cache_queries
55 - .get_or_create(&labels)
56 - .inc_by(stats.prefix_cache_stats.base.queries);
57 - metrics
58 - .prefix_cache_hits
59 - .get_or_create(&labels)
60 - .inc_by(stats.prefix_cache_stats.base.hits);
166 + handles.prefix_cache_queries.inc_by(stats.prefix_cache_stats.base.queries);
167 + handles.prefix_cache_hits.inc_by(stats.prefix_cache_stats.base.hits);
168
169 if let Some(connector_prefix_cache_stats) = &stats.connector_prefix_cache_stats {
63 - metrics
170 + handles
171 .external_prefix_cache_queries
65 - .get_or_create(&labels)
172 .inc_by(connector_prefix_cache_stats.base.queries);
67 - metrics
173 + handles
174 .external_prefix_cache_hits
69 - .get_or_create(&labels)
175 .inc_by(connector_prefix_cache_stats.base.hits);
176 }
177
178 // Speculative decoding counters.
179 if let Some(spec_decoding_stats) = &stats.spec_decoding_stats {
75 - metrics
76 - .spec_decode_num_drafts
77 - .get_or_create(&labels)
78 - .inc_by(spec_decoding_stats.num_drafts);
79 - metrics
180 + handles.spec_decode_num_drafts.inc_by(spec_decoding_stats.num_drafts);
181 + handles
182 .spec_decode_num_draft_tokens
81 - .get_or_create(&labels)
183 .inc_by(spec_decoding_stats.num_draft_tokens);
83 - metrics
184 + handles
185 .spec_decode_num_accepted_tokens
85 - .get_or_create(&labels)
186 .inc_by(spec_decoding_stats.num_accepted_tokens);
87 - metrics.log_stats.get_or_create(&labels).observe_spec_decode(
187 + handles.log_stats.observe_spec_decode(
188 spec_decoding_stats.num_drafts,
189 &spec_decoding_stats.num_accepted_tokens_per_pos,
190 );
@@ -92,11 +192,11 @@ pub(crate) fn record_scheduler_stats(
192 for (position, accepted_tokens) in
193 spec_decoding_stats.num_accepted_tokens_per_pos.iter().copied().enumerate()
194 {
95 - metrics
195 + handles
196 .spec_decode_num_accepted_tokens_per_pos
197 .get_or_create(&EnginePositionLabels {
98 - model_name: model_name.clone(),
99 - engine,
198 + model_name: handles.labels.model_name.clone(),
199 + engine: handles.labels.engine,
200 position: position as u32,
201 })
202 .inc_by(accepted_tokens);
@@ -109,22 +209,13 @@ pub(crate) fn record_scheduler_stats(
209 || perf_stats.num_read_bytes_per_gpu != 0
210 || perf_stats.num_write_bytes_per_gpu != 0)
211 {
112 - metrics
113 - .estimated_flops_per_gpu
114 - .get_or_create(&labels)
115 - .inc_by(perf_stats.num_flops_per_gpu);
116 - metrics
117 - .estimated_read_bytes_per_gpu
118 - .get_or_create(&labels)
119 - .inc_by(perf_stats.num_read_bytes_per_gpu);
120 - metrics
121 - .estimated_write_bytes_per_gpu
122 - .get_or_create(&labels)
123 - .inc_by(perf_stats.num_write_bytes_per_gpu);
212 + handles.estimated_flops_per_gpu.inc_by(perf_stats.num_flops_per_gpu);
213 + handles.estimated_read_bytes_per_gpu.inc_by(perf_stats.num_read_bytes_per_gpu);
214 + handles.estimated_write_bytes_per_gpu.inc_by(perf_stats.num_write_bytes_per_gpu);
215 }
216
217 if let Some(cudagraph_stats) = &stats.cudagraph_stats {
127 - metrics.log_stats.get_or_create(&labels).observe_cudagraph(
218 + handles.log_stats.observe_cudagraph(
219 cudagraph_stats.num_unpadded_tokens,
220 cudagraph_stats.num_padded_tokens,
221 cudagraph_stats.num_paddings,
@@ -134,16 +225,11 @@ pub(crate) fn record_scheduler_stats(
225
226 // Sampled KV-cache residency histograms.
227 if !stats.kv_cache_eviction_events.is_empty() {
137 - let kv_block_lifetime_seconds = metrics.kv_block_lifetime_seconds.get_or_create(&labels);
138 - let kv_block_idle_before_evict_seconds =
139 - metrics.kv_block_idle_before_evict_seconds.get_or_create(&labels);
140 - let kv_block_reuse_gap_seconds = metrics.kv_block_reuse_gap_seconds.get_or_create(&labels);
141 -
228 for event in &stats.kv_cache_eviction_events {
143 - kv_block_lifetime_seconds.observe(event.lifetime_seconds);
144 - kv_block_idle_before_evict_seconds.observe(event.idle_seconds);
229 + handles.kv_block_lifetime_seconds.observe(event.lifetime_seconds);
230 + handles.kv_block_idle_before_evict_seconds.observe(event.idle_seconds);
231 for reuse_gap_seconds in &event.reuse_gaps_seconds {
146 - kv_block_reuse_gap_seconds.observe(*reuse_gap_seconds);
232 + handles.kv_block_reuse_gap_seconds.observe(*reuse_gap_seconds);
233 }
234 }
235 }
rust/src/llm/src/lib.rs
+11 -4
@@ -88,14 +88,21 @@ impl Llm {
88 // Record internal engine-core request ID in the current tracing span.
89 Span::current().record("engine_request_id", &internal_request_id);
90
91 + let arrival_time = prepared.engine_request.arrival_time;
92 + let max_tokens_param =
93 + (prepared.engine_request.sampling_params.as_ref()).map(|p| p.max_tokens);
94 + let prompt_len = prepared.prompt_token_ids().len() as u32;
95 +
96 + let stream = self.client.call(prepared.engine_request).await?;
97 +
98 let request_metrics = RequestMetricsTracker::new(
99 self.client.model_name().to_string(),
93 - prepared.engine_request.arrival_time,
94 - prepared.prompt_token_ids().len() as u32,
95 - (prepared.engine_request.sampling_params.as_ref()).map(|p| p.max_tokens),
100 + stream.engine_index(),
101 + arrival_time,
102 + prompt_len,
103 + max_tokens_param,
104 1,
105 );
98 - let stream = self.client.call(prepared.engine_request).await?;
106 let guard = self.inflight.track(external_request_id, internal_request_id);
107
108 Ok(GenerateOutputStream::new(
rust/src/llm/src/output.rs
+1 -6
@@ -248,12 +248,7 @@ impl Stream for GenerateOutputStream {
248 };
249
250 let received_at = current_unix_timestamp_secs();
251 - self.request_metrics.observe_output(
252 - raw.engine_index,
253 - raw.timestamp,
254 - received_at,
255 - &raw.output,
256 - );
251 + self.request_metrics.observe_output(raw.timestamp, received_at, &raw.output);
252
253 let raw = raw.output;
254
rust/src/llm/src/request_metrics.rs
+150 -142
@@ -5,15 +5,12 @@ use vllm_engine_core_client::protocol::output::{
5 };
6 use vllm_engine_core_client::protocol::stats::PrefillStats;
7 use vllm_metrics::{
8 - EngineLabels, FinishedReasonLabels, METRICS, PromptTokenSourceLabels, RequestMetrics,
8 + EngineLabels, Family, FinishedReasonLabels, HistogramMetric, METRICS, PromptTokenSourceLabels,
9 + U64Counter,
10 };
11
12 use crate::FinishReason;
13
13 -fn metrics() -> &'static RequestMetrics {
14 - &METRICS.request
15 -}
16 -
14 const PROMPT_TOKEN_SOURCE_LOCAL_COMPUTE: &str = "local_compute";
15 const PROMPT_TOKEN_SOURCE_LOCAL_CACHE_HIT: &str = "local_cache_hit";
16 const PROMPT_TOKEN_SOURCE_EXTERNAL_KV_TRANSFER: &str = "external_kv_transfer";
@@ -29,9 +26,11 @@ const PROMPT_TOKEN_SOURCE_EXTERNAL_KV_TRANSFER: &str = "external_kv_transfer";
26 ///
27 /// Original Python update flow:
28 /// <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/v1/engine/output_processor.py#L600-L677>
32 -#[derive(Debug, Clone)]
29 +#[derive(Clone)]
30 pub(crate) struct RequestMetricsTracker {
34 - model_name: String,
31 + /// Cached request metric handles for this request's model and engine index.
32 + handles: RequestMetricHandles,
33 +
34 arrival_time: f64,
35 prompt_len: u32,
36 max_tokens_param: Option<u32>,
@@ -44,7 +43,38 @@ pub(crate) struct RequestMetricsTracker {
43 first_token_latency: f64,
44 num_generation_tokens: u32,
45 latest_num_cached_tokens: u32,
47 - last_seen_engine_index: u32,
46 +}
47 +
48 +/// Cached request metric handles for one model and engine index.
49 +#[derive(Clone)]
50 +struct RequestMetricHandles {
51 + labels: EngineLabels,
52 +
53 + // Request-derived counters.
54 + num_preemptions: U64Counter,
55 + prompt_tokens: U64Counter,
56 + prompt_tokens_local_compute: U64Counter,
57 + prompt_tokens_local_cache_hit: U64Counter,
58 + prompt_tokens_external_kv_transfer: U64Counter,
59 + prompt_tokens_cached: U64Counter,
60 + generation_tokens: U64Counter,
61 +
62 + // Request lifecycle counters and histograms.
63 + request_success: Family<FinishedReasonLabels, U64Counter>,
64 + request_prompt_tokens: HistogramMetric,
65 + request_generation_tokens: HistogramMetric,
66 + request_max_num_generation_tokens: HistogramMetric,
67 + request_params_max_tokens: HistogramMetric,
68 + request_params_n: HistogramMetric,
69 + request_prefill_kv_computed_tokens: HistogramMetric,
70 + time_to_first_token_seconds: HistogramMetric,
71 + inter_token_latency_seconds: HistogramMetric,
72 + e2e_request_latency_seconds: HistogramMetric,
73 + request_queue_time_seconds: HistogramMetric,
74 + request_prefill_time_seconds: HistogramMetric,
75 + request_decode_time_seconds: HistogramMetric,
76 + request_inference_time_seconds: HistogramMetric,
77 + request_time_per_output_token_seconds: HistogramMetric,
78 }
79
80 impl RequestMetricsTracker {
@@ -52,13 +82,14 @@ impl RequestMetricsTracker {
82 /// context.
83 pub(crate) fn new(
84 model_name: String,
85 + engine_index: u32,
86 arrival_time: f64,
87 prompt_len: u32,
88 max_tokens_param: Option<u32>,
89 n_param: u32,
90 ) -> Self {
91 Self {
61 - model_name,
92 + handles: resolve_request_metric_handles(&model_name, engine_index),
93 arrival_time,
94 prompt_len,
95 max_tokens_param,
@@ -71,7 +102,6 @@ impl RequestMetricsTracker {
102 first_token_latency: 0.0,
103 num_generation_tokens: 0,
104 latest_num_cached_tokens: 0,
74 - last_seen_engine_index: 0,
105 }
106 }
107
@@ -81,23 +111,18 @@ impl RequestMetricsTracker {
111 /// <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/v1/metrics/stats.py#L331-L384>
112 pub(crate) fn observe_output(
113 &mut self,
84 - engine_index: u32,
114 batch_timestamp: f64,
115 received_at: f64,
116 output: &EngineCoreOutput,
117 ) {
89 - self.last_seen_engine_index = engine_index;
118 if let Some(prefill_stats) = &output.prefill_stats {
119 self.latest_num_cached_tokens = prefill_stats.num_cached_tokens;
120 }
121 self.num_generation_tokens += output.new_token_ids.len() as u32;
94 - metrics()
95 - .generation_tokens
96 - .get_or_create(&engine_labels(&self.model_name, engine_index))
97 - .inc_by(output.new_token_ids.len() as u64);
122 + self.handles.generation_tokens.inc_by(output.new_token_ids.len() as u64);
123
124 if let Some(events) = &output.events {
100 - self.observe_events(engine_index, events);
125 + self.observe_events(events);
126 }
127
128 // Only outputs that actually carry tokens drive token-timing metrics.
@@ -107,22 +132,16 @@ impl RequestMetricsTracker {
132 if !output.new_token_ids.is_empty() {
133 if self.is_prefilling {
134 if let Some(prefill_stats) = &output.prefill_stats {
110 - record_prompt_tokens(&self.model_name, engine_index, prefill_stats);
135 + self.record_prompt_tokens(prefill_stats);
136 }
137 self.first_token_latency = received_at - self.arrival_time;
113 - observe_time_to_first_token_seconds(
114 - &self.model_name,
115 - engine_index,
116 - self.first_token_latency,
117 - );
138 + self.handles.time_to_first_token_seconds.observe(self.first_token_latency);
139 self.first_token_ts = batch_timestamp;
140 self.is_prefilling = false;
141 } else if self.last_token_ts > 0.0 {
121 - observe_inter_token_latency_seconds(
122 - &self.model_name,
123 - engine_index,
124 - batch_timestamp - self.last_token_ts,
125 - );
142 + self.handles
143 + .inter_token_latency_seconds
144 + .observe(batch_timestamp - self.last_token_ts);
145 }
146
147 self.last_token_ts = batch_timestamp;
@@ -135,7 +154,6 @@ impl RequestMetricsTracker {
154 /// Original Python finished-request stats:
155 /// <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/v1/metrics/stats.py#L222-L237>
156 pub(crate) fn record_finished(&self, received_at: f64, finish_reason: FinishReason) {
138 - let labels = engine_labels(&self.model_name, self.last_seen_engine_index);
157 let prefill_kv_computed_tokens =
158 self.prompt_len.saturating_sub(self.latest_num_cached_tokens);
159 let e2e_latency_seconds = received_at - self.arrival_time;
@@ -150,57 +168,47 @@ impl RequestMetricsTracker {
168 0.0
169 };
170
153 - record_request_success(&self.model_name, self.last_seen_engine_index, finish_reason);
154 - metrics()
155 - .request_prompt_tokens
156 - .get_or_create(&labels)
157 - .observe(self.prompt_len as f64);
158 - metrics()
171 + self.record_request_success(finish_reason);
172 +
173 + self.handles.request_prompt_tokens.observe(self.prompt_len as f64);
174 + self.handles
175 .request_generation_tokens
160 - .get_or_create(&labels)
176 .observe(self.num_generation_tokens as f64);
162 - metrics()
177 + self.handles
178 .request_max_num_generation_tokens
164 - .get_or_create(&labels)
179 .observe(self.num_generation_tokens as f64);
180 if let Some(max_tokens_param) = self.max_tokens_param {
167 - metrics()
168 - .request_params_max_tokens
169 - .get_or_create(&labels)
170 - .observe(max_tokens_param as f64);
181 + self.handles.request_params_max_tokens.observe(max_tokens_param as f64);
182 }
172 - metrics().request_params_n.get_or_create(&labels).observe(self.n_param as f64);
173 - metrics()
183 + self.handles.request_params_n.observe(self.n_param as f64);
184 + self.handles
185 .request_prefill_kv_computed_tokens
175 - .get_or_create(&labels)
186 .observe(prefill_kv_computed_tokens as f64);
177 - metrics()
178 - .e2e_request_latency_seconds
179 - .get_or_create(&labels)
180 - .observe(e2e_latency_seconds);
181 - metrics()
182 - .request_queue_time_seconds
183 - .get_or_create(&labels)
184 - .observe(queue_time_seconds);
185 - metrics()
186 - .request_prefill_time_seconds
187 - .get_or_create(&labels)
188 - .observe(prefill_time_seconds);
189 - metrics()
190 - .request_decode_time_seconds
191 - .get_or_create(&labels)
192 - .observe(decode_time_seconds);
193 - metrics()
194 - .request_inference_time_seconds
195 - .get_or_create(&labels)
196 - .observe(inference_time_seconds);
197 - metrics()
187 + self.handles.e2e_request_latency_seconds.observe(e2e_latency_seconds);
188 + self.handles.request_queue_time_seconds.observe(queue_time_seconds);
189 + self.handles.request_prefill_time_seconds.observe(prefill_time_seconds);
190 + self.handles.request_decode_time_seconds.observe(decode_time_seconds);
191 + self.handles.request_inference_time_seconds.observe(inference_time_seconds);
192 + self.handles
193 .request_time_per_output_token_seconds
199 - .get_or_create(&labels)
194 .observe(time_per_output_token_seconds);
195 }
196
203 - fn observe_events(&mut self, engine_index: u32, events: &[EngineCoreEvent]) {
197 + /// Record prompt token counters through cached metric handles.
198 + fn record_prompt_tokens(&self, prefill_stats: &PrefillStats) {
199 + let computed = prefill_stats.num_computed_tokens as u64;
200 + let local_cache_hit = prefill_stats.num_local_cached_tokens as u64;
201 + let external_kv_transfer = prefill_stats.num_external_cached_tokens as u64;
202 +
203 + self.handles.prompt_tokens.inc_by(prefill_stats.num_prompt_tokens as u64);
204 + self.handles.prompt_tokens_local_compute.inc_by(computed);
205 + self.handles.prompt_tokens_local_cache_hit.inc_by(local_cache_hit);
206 + self.handles.prompt_tokens_external_kv_transfer.inc_by(external_kv_transfer);
207 + self.handles.prompt_tokens_cached.inc_by(prefill_stats.num_cached_tokens as u64);
208 + }
209 +
210 + /// Record request event counters through cached metric handles.
211 + fn observe_events(&mut self, events: &[EngineCoreEvent]) {
212 for event in events {
213 match event.r#type {
214 EngineCoreEventType::Queued => {
@@ -212,46 +220,86 @@ impl RequestMetricsTracker {
220 }
221 }
222 EngineCoreEventType::Preempted => {
215 - metrics()
216 - .num_preemptions
217 - .get_or_create(&engine_labels(&self.model_name, engine_index))
218 - .inc();
223 + self.handles.num_preemptions.inc();
224 }
225 }
226 }
227 }
223 -}
228
225 -fn engine_labels(model_name: &str, engine: u32) -> EngineLabels {
226 - EngineLabels {
227 - model_name: model_name.to_string(),
228 - engine,
229 + /// Increment the request-success counter for the terminal finish reason.
230 + fn record_request_success(&self, finish_reason: FinishReason) {
231 + self.handles
232 + .request_success
233 + .get_or_create(&FinishedReasonLabels {
234 + model_name: self.handles.labels.model_name.clone(),
235 + engine: self.handles.labels.engine,
236 + finished_reason: finish_reason.as_str(),
237 + })
238 + .inc();
239 }
240 }
241
232 -fn observe_time_to_first_token_seconds(model_name: &str, engine: u32, seconds: f64) {
233 - metrics()
234 - .time_to_first_token_seconds
235 - .get_or_create(&engine_labels(model_name, engine))
236 - .observe(seconds);
237 -}
238 -
239 -fn observe_inter_token_latency_seconds(model_name: &str, engine: u32, seconds: f64) {
240 - metrics()
241 - .inter_token_latency_seconds
242 - .get_or_create(&engine_labels(model_name, engine))
243 - .observe(seconds);
244 -}
242 +/// Resolve fixed request metric handles for one model and engine index.
243 +fn resolve_request_metric_handles(model_name: &str, engine: u32) -> RequestMetricHandles {
244 + let metrics = &METRICS.request;
245 + let labels = EngineLabels {
246 + model_name: model_name.to_string(),
247 + engine,
248 + };
249
246 -fn record_request_success(model_name: &str, engine: u32, finish_reason: FinishReason) {
247 - metrics()
248 - .request_success
249 - .get_or_create(&FinishedReasonLabels {
250 - model_name: model_name.to_string(),
251 - engine,
252 - finished_reason: finish_reason.as_str(),
253 - })
254 - .inc();
250 + RequestMetricHandles {
251 + num_preemptions: metrics.num_preemptions.get_or_create_owned(&labels),
252 + prompt_tokens: metrics.prompt_tokens.get_or_create_owned(&labels),
253 + prompt_tokens_local_compute: metrics.prompt_tokens_by_source.get_or_create_owned(
254 + &prompt_token_source_labels(model_name, engine, PROMPT_TOKEN_SOURCE_LOCAL_COMPUTE),
255 + ),
256 + prompt_tokens_local_cache_hit: metrics.prompt_tokens_by_source.get_or_create_owned(
257 + &prompt_token_source_labels(model_name, engine, PROMPT_TOKEN_SOURCE_LOCAL_CACHE_HIT),
258 + ),
259 + prompt_tokens_external_kv_transfer: metrics.prompt_tokens_by_source.get_or_create_owned(
260 + &prompt_token_source_labels(
261 + model_name,
262 + engine,
263 + PROMPT_TOKEN_SOURCE_EXTERNAL_KV_TRANSFER,
264 + ),
265 + ),
266 + prompt_tokens_cached: metrics.prompt_tokens_cached.get_or_create_owned(&labels),
267 + generation_tokens: metrics.generation_tokens.get_or_create_owned(&labels),
268 + request_success: metrics.request_success.clone(),
269 + request_prompt_tokens: metrics.request_prompt_tokens.get_or_create_owned(&labels),
270 + request_generation_tokens: metrics.request_generation_tokens.get_or_create_owned(&labels),
271 + request_max_num_generation_tokens: metrics
272 + .request_max_num_generation_tokens
273 + .get_or_create_owned(&labels),
274 + request_params_max_tokens: metrics.request_params_max_tokens.get_or_create_owned(&labels),
275 + request_params_n: metrics.request_params_n.get_or_create_owned(&labels),
276 + request_prefill_kv_computed_tokens: metrics
277 + .request_prefill_kv_computed_tokens
278 + .get_or_create_owned(&labels),
279 + time_to_first_token_seconds: metrics
280 + .time_to_first_token_seconds
281 + .get_or_create_owned(&labels),
282 + inter_token_latency_seconds: metrics
283 + .inter_token_latency_seconds
284 + .get_or_create_owned(&labels),
285 + e2e_request_latency_seconds: metrics
286 + .e2e_request_latency_seconds
287 + .get_or_create_owned(&labels),
288 + request_queue_time_seconds: metrics.request_queue_time_seconds.get_or_create_owned(&labels),
289 + request_prefill_time_seconds: metrics
290 + .request_prefill_time_seconds
291 + .get_or_create_owned(&labels),
292 + request_decode_time_seconds: metrics
293 + .request_decode_time_seconds
294 + .get_or_create_owned(&labels),
295 + request_inference_time_seconds: metrics
296 + .request_inference_time_seconds
297 + .get_or_create_owned(&labels),
298 + request_time_per_output_token_seconds: metrics
299 + .request_time_per_output_token_seconds
300 + .get_or_create_owned(&labels),
301 + labels,
302 + }
303 }
304
305 fn prompt_token_source_labels(
@@ -266,45 +314,6 @@ fn prompt_token_source_labels(
314 }
315 }
316
269 -fn record_prompt_tokens(model_name: &str, engine: u32, prefill_stats: &PrefillStats) {
270 - let computed = prefill_stats.num_computed_tokens as u64;
271 - let local_cache_hit = prefill_stats.num_local_cached_tokens as u64;
272 - let external_kv_transfer = prefill_stats.num_external_cached_tokens as u64;
273 -
274 - metrics()
275 - .prompt_tokens
276 - .get_or_create(&engine_labels(model_name, engine))
277 - .inc_by(prefill_stats.num_prompt_tokens as u64);
278 - metrics()
279 - .prompt_tokens_by_source
280 - .get_or_create(&prompt_token_source_labels(
281 - model_name,
282 - engine,
283 - PROMPT_TOKEN_SOURCE_LOCAL_COMPUTE,
284 - ))
285 - .inc_by(computed);
286 - metrics()
287 - .prompt_tokens_by_source
288 - .get_or_create(&prompt_token_source_labels(
289 - model_name,
290 - engine,
291 - PROMPT_TOKEN_SOURCE_LOCAL_CACHE_HIT,
292 - ))
293 - .inc_by(local_cache_hit);
294 - metrics()
295 - .prompt_tokens_by_source
296 - .get_or_create(&prompt_token_source_labels(
297 - model_name,
298 - engine,
299 - PROMPT_TOKEN_SOURCE_EXTERNAL_KV_TRANSFER,
300 - ))
301 - .inc_by(external_kv_transfer);
302 - metrics()
303 - .prompt_tokens_cached
304 - .get_or_create(&engine_labels(model_name, engine))
305 - .inc_by(prefill_stats.num_cached_tokens as u64);
306 -}
307 -
317 fn diff_or_zero(end: f64, start: f64) -> f64 {
318 if end > 0.0 && start > 0.0 && end >= start {
319 end - start
@@ -337,10 +346,10 @@ mod tests {
346
347 #[test]
348 fn tracker_updates_timing_state_across_prefill_decode_and_finish() {
340 - let mut tracker = RequestMetricsTracker::new("model".to_string(), 100.0, 64, Some(128), 1);
349 + let mut tracker =
350 + RequestMetricsTracker::new("model".to_string(), 2, 100.0, 64, Some(128), 1);
351
352 tracker.observe_output(
343 - 2,
353 10.0,
354 100.2,
355 &vllm_engine_core_client::protocol::output::EngineCoreOutput {
@@ -368,7 +377,6 @@ mod tests {
377 },
378 );
379 tracker.observe_output(
371 - 2,
380 11.5,
381 100.4,
382 &vllm_engine_core_client::protocol::output::EngineCoreOutput {
@@ -384,7 +392,7 @@ mod tests {
392 );
393
394 assert!(!tracker.is_prefilling);
387 - assert_eq!(tracker.last_seen_engine_index, 2);
395 + assert_eq!(tracker.handles.labels.engine, 2);
396 assert_eq!(tracker.num_generation_tokens, 3);
397 assert_eq!(tracker.queued_ts, 8.0);
398 assert_eq!(tracker.scheduled_ts, 9.0);
rust/src/llm/tests/generate.rs
+3 -3
@@ -17,7 +17,7 @@ use vllm_engine_core_client::protocol::request::EngineCoreRequest;
17 use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams;
18 use vllm_engine_core_client::protocol::stats::PrefillStats;
19 use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task};
20 -use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig};
20 +use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, EngineId};
21 use vllm_llm::{
22 Error, FinishReason, GenerateOutputStreamExt as _, GeneratePromptInfo, GenerateRequest, Llm,
23 };
@@ -699,7 +699,7 @@ async fn abort_by_external_id_aborts_all_internal_requests() {
699 async fn generate_records_request_metrics_in_prometheus_output() {
700 let ipc = IpcNamespace::new().unwrap();
701 let handshake_address = ipc.handshake_endpoint();
702 - let engine_id = b"engine-metrics".to_vec();
702 + let engine_id = EngineId::from_engine_index(4);
703 let model_name = request_metrics_model_name("metrics-model");
704
705 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
@@ -832,7 +832,7 @@ async fn generate_records_request_metrics_in_prometheus_output() {
832 async fn dropping_stream_records_abort_terminal_request_metrics() {
833 let ipc = IpcNamespace::new().unwrap();
834 let handshake_address = ipc.handshake_endpoint();
835 - let engine_id = b"engine-metrics-drop".to_vec();
835 + let engine_id = EngineId::from_engine_index(5);
836 let model_name = request_metrics_model_name("metrics-drop-model");
837
838 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
rust/src/metrics/src/lib.rs
+3 -1
@@ -4,7 +4,7 @@ use std::sync::atomic::AtomicU64;
4
5 use prometheus_client::encoding::text::encode;
6 use prometheus_client::metrics::counter::Counter;
7 -use prometheus_client::metrics::family::Family;
7 +pub use prometheus_client::metrics::family::Family;
8 use prometheus_client::metrics::gauge::Gauge;
9 use prometheus_client::metrics::histogram::Histogram;
10 use prometheus_client::registry::Registry;
@@ -23,6 +23,8 @@ pub use scheduler::*;
23 pub type U64Counter = Counter<u64, AtomicU64>;
24 pub type U64Gauge = Gauge<u64, AtomicU64>;
25 pub type F64Gauge = Gauge<f64, AtomicU64>;
26 +/// Histogram metric handle cloned out of a Prometheus family.
27 +pub type HistogramMetric = Histogram;
28 pub(crate) type HistogramFamily = Family<EngineLabels, Histogram, fn() -> Histogram>;
29
30 /// Shared Prometheus registry for frontend metrics.