[Frontend][Rust] Add mm-processor benchmark for Rust frontend (#51922)
Signed-off-by: Karthik Gangula <gangula-karthik@users.noreply.github.com> Signed-off-by: gangula-karthik <gkarthik923@gmail.com> Signed-off-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: Karthik Gangula <gangula-karthik@users.noreply.github.com> Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Isotr0py <Isotr0py@outlook.com>
karthik committed
Sep 22, 2026 at 13:08 UTC
f84325c48c0acc1e3703103788c5f2976e719762
14 files changed
+980
-33
rust/Cargo.lock
+6
@@ -5735,6 +5735,11 @@ dependencies = [
5735
"url",
5736
"uuid",
5737
"vllm-build-info",
5738
+ "vllm-chat",
5739
+ "vllm-engine-core-client",
5740
+ "vllm-llm",
5741
+ "vllm-managed-engine",
5742
+ "vllm-text",
5743
"vllm-tracing",
5744
]
5745
@@ -5784,6 +5789,7 @@ dependencies = [
5789
"vllm-parser",
5790
"vllm-text",
5791
"vllm-tokenizer",
5792
+ "vllm-tracing",
5793
"xgrammar-structural-tag",
5794
"zeromq",
5795
]
rust/src/bench/AGENTS.md
+2
-1
@@ -23,7 +23,7 @@ cargo test -p vllm-bench -- --ignored
23
24
## Architecture
25
26
-- `src/main.rs` — Entry point, mimalloc, tokio runtime, mode dispatch (compare/sweep/multi-run/multi-turn/single)
26
+- `src/main.rs` — Entry point, mimalloc, tokio runtime, mode dispatch (compare/sweep/multi-run/multi-turn/single, `mm-processor` subcommand)
27
- `src/cli.rs` — clap derive CLI args (~50+ flags)
28
- `src/config.rs` — Validated config from CLI; `GoodputConfig`, `RampUpConfig`, sampling param merging
29
- `src/error.rs` — `BenchError` enum (Http, Json, Tokenizer, Config, EndpointTimeout, Backend, Io)
@@ -32,6 +32,7 @@ cargo test -p vllm-bench -- --ignored
32
- `src/sweep.rs` — Concurrency/rate parameter sweep (`--sweep-max-concurrency`, `--sweep-request-rate`)
33
- `src/multi_run.rs` — N-run aggregation with mean/std/min/max/CV (`--num-runs`)
34
- `src/compare.rs` — Side-by-side diff of two result JSON files (`--compare`)
35
+- `src/mm_processor.rs` — `vllm-bench mm-processor` subcommand: offline multimodal preprocessing latency benchmark against a managed headless engine (mirrors `vllm bench mm-processor`); per-stage timings come from `vllm_tracing::timing::RequestTimingLayer` (via `vllm_chat::mm_timing_layer`)
36
- `src/tokenizer.rs` — `TokenizerKind` enum: Local(HuggingFace), Tiktoken, OR Server-side `/tokenize`+`/detokenize` fallback
37
- `src/tiktoken.rs` — Tiktoken BPE loader (`.tiktoken`/`.model` files; built-in encodings o200k_base/cl100k_base; pat_str extraction from Python source)
38
- `src/hub.rs` — `HubRepo`: thin wrapper over hf-hub's async (reqwest/native-tls) API — file download and repo listing, optional revision; the sync ureq backend is unusable here because it pulls rustls, which `rust/deny.toml` bans
rust/src/bench/Cargo.toml
+5
@@ -36,6 +36,11 @@ tracing.workspace = true
36
url.workspace = true
37
uuid.workspace = true
38
vllm-build-info.workspace = true
39
+vllm-chat.workspace = true
40
+vllm-engine-core-client.workspace = true
41
+vllm-llm.workspace = true
42
+vllm-managed-engine.workspace = true
43
+vllm-text.workspace = true
44
vllm-tracing.workspace = true
45
46
[lints]
rust/src/bench/README.md
+59
-1
@@ -18,6 +18,7 @@ vllm-bench --backend vllm --base-url http://127.0.0.1:8000 \
18
- **Beyond a single run** — concurrency/rate **sweeps**, **multi-run** stats, **multi-turn** conversations, **LoRA** multi-adapter, and result **comparison**.
19
- **Steady-state metrics** — throughput/latency measured over the saturated plateau, excluding ramp-up and drain.
20
- **Parity** — JSON output schema and timing semantics match Python `vllm bench serve` exactly.
21
+- **Multimodal preprocessing** — `mm-processor` subcommand measures per-stage VLM preprocessing latency against a managed headless engine (mirrors `vllm bench mm-processor`).
22
23
### Performance vs. Python
24
@@ -404,6 +405,32 @@ vllm-bench \
405
406
</details>
407
408
+<details>
409
+<summary><b>Multimodal preprocessing (offline)</b></summary>
410
+
411
+The `mm-processor` subcommand benchmarks the multimodal preprocessing pipeline
412
+itself (media fetch → per-modality preprocess → prompt expansion), mirroring
413
+`vllm bench mm-processor`. It spawns a managed headless Python engine (real
414
+weights, real encoder) — no serving endpoint needed — and reports per-stage
415
+timing (mean/median/std/percentiles) plus end-to-end latency.
416
+
417
+```bash
418
+vllm-bench mm-processor \
419
+ --model Qwen/Qwen2.5-VL-7B-Instruct \
420
+ --num-prompts 100 --num-warmups 5 \
421
+ --random-input-len 512 --random-output-len 128 \
422
+ --random-mm-base-items-per-request 1 \
423
+ --random-mm-limit-mm-per-prompt '{"image": 1, "video": 0}' \
424
+ --random-mm-bucket-config '{(1024, 800, 1): 1.0}' \
425
+ --metric-percentiles "50,90,99" \
426
+ --output-json mm_processor_stats.json
427
+```
428
+
429
+`--max-concurrency` defaults to `1`, matching Python's serial driver; raise it
430
+to measure a concurrent serving-style workload.
431
+
432
+</details>
433
+
434
## Supported Backends
435
436
### Generation
@@ -705,6 +732,36 @@ With `--multi-turn`, `--num-prompts` controls the number of **conversations**, n
732
733
</details>
734
735
+<details>
736
+<summary><b><code>mm-processor</code> subcommand</b></summary>
737
+
738
+Offline multimodal preprocessing latency benchmark (mirrors `vllm bench mm-processor`). Spawns a managed headless Python engine and drives the full chat preprocessing pipeline.
739
+
740
+| Flag | Default | Description |
741
+| ------ | --------- | ------------- |
742
+| `--model` | (required) | Model to serve and benchmark (HF id or local path) |
743
+| `--num-prompts` | `10` | Prompts to process, excluding warmups |
744
+| `--num-warmups` | `1` | Warmup prompts processed and discarded before timing |
745
+| `--metric-percentiles` | `99` | Comma-separated percentiles to report |
746
+| `--output-json` | — | Path to write aggregate stats as JSON (same schema as Python) |
747
+| `--random-input-len` | `1024` | Text input length per prompt |
748
+| `--random-output-len` | `128` | Expected output length per prompt |
749
+| `--random-prefix-len` | `0` | Prefix token length per prompt |
750
+| `--random-range-ratio` | `0.0` | Input/output length range ratio (float or `{"input": i, "output": o}`) |
751
+| `--random-mm-base-items-per-request` | `1` | Base number of multimodal items per request |
752
+| `--random-mm-num-mm-items-range-ratio` | `0.0` | Range ratio (in [0, 1]) for the number of mm items per request |
753
+| `--random-mm-limit-mm-per-prompt` | `{"image": 255, "video": 1}` | Per-modality item caps |
754
+| `--random-mm-bucket-config` | `{(256,256,1): 0.5, (720,1280,1): 0.5}` | Bucket config |
755
+| `--seed` | `0` | Seed for dataset generation |
756
+| `--request-id-prefix` | `mm-proc-` | Request-id prefix for per-request timing keys |
757
+| `--max-concurrency` | `1` | Maximum concurrent requests (1 matches Python's serial driver) |
758
+| `--trust-remote-code` | `false` | Trust remote code for the tokenizer and the managed engine |
759
+| `--chat-template` | — | Chat-template override (inline text or path) |
760
+
761
+Managed headless-engine options (e.g. `--python`, `--data-parallel-address`, `--data-parallel-rpc-port`, `--data-parallel-size`, `--max-model-len`, `--python-args`) are shared with other managed-engine commands; see `vllm-bench mm-processor --help`.
762
+
763
+</details>
764
+
765
## Tokenizer Support
766
767
Tokenizers are loaded with a three-tier fallback chain:
@@ -734,10 +791,11 @@ Use `--append-result` to append multiple runs to the same file in JSONL format.
791
792
```text
793
src/
737
-├── main.rs # Entry point, mimalloc, tokio runtime, mode dispatch
794
+├── main.rs # Entry point, mimalloc, tokio runtime, mode/subcommand dispatch
795
├── cli.rs # clap CLI argument definitions
796
├── config.rs # Validated config, goodput/ramp-up parsing
797
├── benchmark.rs # Core orchestrator (schedule, spawn, collect, verify, profile)
798
+├── mm_processor.rs # Offline mm preprocessing benchmark (mm-processor subcommand)
799
├── multi_turn.rs # Multi-turn conversation orchestrator (channel workers)
800
├── compare.rs # Result diff (--compare file_a.json file_b.json)
801
├── sweep.rs # Parameter sweep (--sweep-max-concurrency, --sweep-request-rate)
rust/src/bench/src/lib.rs
+2
@@ -10,6 +10,7 @@ mod datasets;
10
mod error;
11
mod hub;
12
mod metrics;
13
+mod mm_processor;
14
mod multi_run;
15
mod multi_turn;
16
mod output;
@@ -25,6 +26,7 @@ pub use cli::{
26
BackendKind, BenchServeArgs, DatasetName, LoraAssignment, RampUpStrategy, SpeedBenchConfig,
27
};
28
use config::BenchConfig;
29
+pub use mm_processor::{MmProcessorArgs, run_mm_processor};
30
31
/// Prepare process-wide resources for a benchmark run.
32
pub fn prepare_process() {
rust/src/bench/src/main.rs
+23
-5
@@ -6,22 +6,30 @@
6
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
7
8
use anyhow::Context;
9
-use clap::Parser;
9
+use clap::{Parser, Subcommand};
10
11
#[derive(Parser)]
12
#[command(
13
name = "vllm-bench",
14
- about = "Benchmark online serving throughput",
14
+ about = "Benchmark online serving throughput and offline multimodal preprocessing",
15
version = vllm_build_info::VERSION
16
)]
17
struct Cli {
18
#[command(flatten)]
19
args: vllm_bench::BenchServeArgs,
20
+
21
+ /// Optional subcommand; absent by default for the online serving benchmark.
22
+ #[command(subcommand)]
23
+ command: Option<Command>,
24
}
25
22
-fn main() -> anyhow::Result<()> {
23
- vllm_tracing::init_tracing("Bench");
26
+#[derive(Subcommand)]
27
+enum Command {
28
+ /// Offline multimodal preprocessing latency benchmark.
29
+ MmProcessor(vllm_bench::MmProcessorArgs),
30
+}
31
32
+fn main() -> anyhow::Result<()> {
33
let cli = Cli::parse();
34
vllm_bench::prepare_process();
35
@@ -30,5 +38,15 @@ fn main() -> anyhow::Result<()> {
38
.build()
39
.context("Failed to build tokio runtime")?;
40
33
- runtime.block_on(vllm_bench::run(cli.args))
41
+ match cli.command {
42
+ Some(Command::MmProcessor(args)) => {
43
+ let (timing_layer, timing_stats) = vllm_chat::mm_timing_layer();
44
+ vllm_tracing::init_tracing_with("Bench", timing_layer);
45
+ runtime.block_on(vllm_bench::run_mm_processor(args, timing_stats))
46
+ }
47
+ None => {
48
+ vllm_tracing::init_tracing("Bench");
49
+ runtime.block_on(vllm_bench::run(cli.args))
50
+ }
51
+ }
52
}
rust/src/bench/src/metrics/calculator.rs
+5
-5
@@ -216,20 +216,20 @@ pub fn calculate_metrics(
216
(metrics, actual_output_lens)
217
}
218
219
-fn mean(data: &[f64]) -> f64 {
219
+pub(crate) fn mean(data: &[f64]) -> f64 {
220
if data.is_empty() {
221
return 0.0;
222
}
223
data.iter().sum::<f64>() / data.len() as f64
224
}
225
226
-fn sort_clone(data: &[f64]) -> Vec<f64> {
226
+pub(crate) fn sort_clone(data: &[f64]) -> Vec<f64> {
227
let mut sorted = data.to_vec();
228
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
229
sorted
230
}
231
232
-fn median_sorted(sorted: &[f64]) -> f64 {
232
+pub(crate) fn median_sorted(sorted: &[f64]) -> f64 {
233
if sorted.is_empty() {
234
return 0.0;
235
}
@@ -241,7 +241,7 @@ fn median_sorted(sorted: &[f64]) -> f64 {
241
}
242
}
243
244
-fn std_dev(data: &[f64]) -> f64 {
244
+pub(crate) fn std_dev(data: &[f64]) -> f64 {
245
if data.is_empty() {
246
return 0.0;
247
}
@@ -250,7 +250,7 @@ fn std_dev(data: &[f64]) -> f64 {
250
variance.sqrt()
251
}
252
253
-fn percentile_sorted(sorted_data: &[f64], p: f64) -> f64 {
253
+pub(crate) fn percentile_sorted(sorted_data: &[f64], p: f64) -> f64 {
254
if sorted_data.is_empty() {
255
return 0.0;
256
}
rust/src/bench/src/mm_processor.rs
new
+572
@@ -0,0 +1,572 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Multimodal preprocessing benchmark (`vllm-bench mm-processor`), mirroring
5
+//! `vllm bench mm-processor`.
6
+
7
+use std::collections::{BTreeMap, HashMap};
8
+use std::sync::Arc;
9
+use std::time::{Duration, Instant};
10
+
11
+use anyhow::{Context, Result};
12
+use clap::Args;
13
+use futures::future::join_all;
14
+use serde::Serialize;
15
+use tokio::sync::Semaphore;
16
+
17
+use vllm_chat::{
18
+ ChatContent, ChatContentPart, ChatLlm, ChatMessage, ChatRequest, LoadModelBackendsOptions,
19
+ MultiModalTimingStats, load_model_backends,
20
+};
21
+use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, TransportMode};
22
+use vllm_llm::Llm;
23
+use vllm_managed_engine::cli::ManagedEngineArgs;
24
+use vllm_managed_engine::{ManagedEngineConfig, ManagedEngineHandle};
25
+use vllm_text::TextLlm;
26
+
27
+use crate::config::RangeRatio;
28
+use crate::datasets::SampleRequest;
29
+use crate::datasets::random_mm::{
30
+ MmBucketKey, MmLimitPerPrompt, generate_random_mm_dataset, parse_bucket_config,
31
+ parse_limit_mm_per_prompt,
32
+};
33
+use crate::error::BenchError;
34
+use crate::metrics::calculator::{mean, median_sorted, percentile_sorted, sort_clone, std_dev};
35
+use crate::tokenizer::{TokenizerKind, load_tokenizer};
36
+
37
+/// Seconds -> milliseconds.
38
+const SEC_TO_MS: f64 = 1000.0;
39
+
40
+/// Preferred column order; other stages appended alphabetically.
41
+const STAGE_ORDER: &[&str] = &[
42
+ "preprocessor_total_ms",
43
+ "media_fetch_ms",
44
+ "preprocess_image_ms",
45
+ "preprocess_video_ms",
46
+ "preprocess_audio_ms",
47
+ "prompt_expansion_ms",
48
+];
49
+
50
+/// Time to wait for managed engines to register.
51
+const ENGINE_READY_TIMEOUT: Duration = Duration::from_secs(600);
52
+
53
+/// Maximum time to wait for the managed engine to drain on shutdown.
54
+const ENGINE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(120);
55
+
56
+#[derive(Args, Debug, Clone)]
57
+pub struct MmProcessorArgs {
58
+ /// Model to serve and benchmark (Hugging Face id or local path).
59
+ #[arg(long)]
60
+ pub model: String,
61
+
62
+ /// Number of prompts to process, excluding warmups.
63
+ #[arg(long, default_value_t = 10)]
64
+ pub num_prompts: usize,
65
+
66
+ /// Number of warmup prompts processed and discarded before timing.
67
+ #[arg(long, default_value_t = 1)]
68
+ pub num_warmups: usize,
69
+
70
+ /// Comma-separated percentiles to report, e.g. "50,90,99".
71
+ #[arg(long, default_value = "99")]
72
+ pub metric_percentiles: String,
73
+
74
+ /// Path to write the aggregate stats as JSON.
75
+ #[arg(long)]
76
+ pub output_json: Option<String>,
77
+
78
+ /// Text input length per prompt (before range scaling).
79
+ #[arg(long, default_value_t = 1024)]
80
+ pub random_input_len: usize,
81
+
82
+ /// Expected output length per prompt.
83
+ #[arg(long, default_value_t = 128)]
84
+ pub random_output_len: usize,
85
+
86
+ /// Prefix token length per prompt.
87
+ #[arg(long, default_value_t = 0)]
88
+ pub random_prefix_len: usize,
89
+
90
+ /// Input/output length range ratio: a float or `{"input": i, "output": o}`.
91
+ #[arg(long, default_value = "0.0")]
92
+ pub random_range_ratio: String,
93
+
94
+ /// Base number of multimodal items per request.
95
+ #[arg(long, default_value_t = 1)]
96
+ pub random_mm_base_items_per_request: usize,
97
+
98
+ /// Range ratio (in [0, 1]) for the number of mm items per request.
99
+ #[arg(long, default_value_t = 0.0)]
100
+ pub random_mm_num_mm_items_range_ratio: f64,
101
+
102
+ /// Per-modality item caps, e.g. `{"image": 255, "video": 1}`.
103
+ #[arg(long, default_value = "{\"image\": 255, \"video\": 1}")]
104
+ pub random_mm_limit_mm_per_prompt: String,
105
+
106
+ /// Bucket config, e.g. `{(256,256,1): 0.5, (720,1280,1): 0.5}`.
107
+ #[arg(long, default_value = "{(256,256,1): 0.5, (720,1280,1): 0.5}")]
108
+ pub random_mm_bucket_config: String,
109
+
110
+ /// Seed for dataset generation.
111
+ #[arg(long)]
112
+ pub seed: Option<u64>,
113
+
114
+ /// Request-id prefix for per-request timing keys.
115
+ #[arg(long, default_value = "mm-proc-")]
116
+ pub request_id_prefix: String,
117
+
118
+ /// Maximum concurrent requests (default 1, matching Python's serial driver).
119
+ #[arg(long, default_value_t = 1)]
120
+ pub max_concurrency: usize,
121
+
122
+ /// Trust remote code for the tokenizer and the managed engine.
123
+ #[arg(long, default_value_t = false)]
124
+ pub trust_remote_code: bool,
125
+
126
+ /// Optional chat-template override (inline text or path).
127
+ #[arg(long)]
128
+ pub chat_template: Option<String>,
129
+
130
+ /// Managed headless-engine options.
131
+ #[command(flatten)]
132
+ pub engine: ManagedEngineArgs,
133
+}
134
+
135
+/// Entry point for `vllm-bench mm-processor`.
136
+pub async fn run_mm_processor(args: MmProcessorArgs, timing: MultiModalTimingStats) -> Result<()> {
137
+ if args.engine.data_parallel_size_local == Some(0) {
138
+ anyhow::bail!(
139
+ "`--data-parallel-size-local 0` is not supported; the benchmark requires \
140
+ a local managed engine"
141
+ );
142
+ }
143
+
144
+ // Spawn the managed headless Python engine (real weights, real encoder).
145
+ let handshake_port = args
146
+ .engine
147
+ .resolve_handshake_port()
148
+ .context("failed to allocate handshake port")?;
149
+ let mut python_args = args.engine.python_args.clone();
150
+ if args.trust_remote_code {
151
+ python_args.push("--trust-remote-code".to_string());
152
+ }
153
+ let engine_config = ManagedEngineConfig {
154
+ python: args.engine.python.clone(),
155
+ model: args.model.clone(),
156
+ handshake_host: args.engine.handshake_host.clone(),
157
+ handshake_port,
158
+ data_parallel_size: args.engine.data_parallel_size,
159
+ python_args,
160
+ };
161
+ eprintln!(
162
+ "Spawning managed headless engine: python={} model={} data_parallel_size={}",
163
+ engine_config.python, engine_config.model, engine_config.data_parallel_size
164
+ );
165
+ let engine = ManagedEngineHandle::spawn(engine_config)
166
+ .await
167
+ .context("failed to start managed Python headless engine")?;
168
+
169
+ let result = run_mm_processor_with_engine(args, handshake_port, &timing).await;
170
+ let shutdown_result = engine
171
+ .shutdown(ENGINE_SHUTDOWN_TIMEOUT)
172
+ .await
173
+ .context("failed to shut down managed engine");
174
+ result?;
175
+ shutdown_result?;
176
+ Ok(())
177
+}
178
+
179
+async fn run_mm_processor_with_engine(
180
+ args: MmProcessorArgs,
181
+ handshake_port: u16,
182
+ timing: &MultiModalTimingStats,
183
+) -> Result<()> {
184
+ let range_ratio =
185
+ RangeRatio::parse(&args.random_range_ratio).context("invalid --random-range-ratio")?;
186
+ let limit: MmLimitPerPrompt = parse_limit_mm_per_prompt(&args.random_mm_limit_mm_per_prompt)
187
+ .context("invalid --random-mm-limit-mm-per-prompt")?;
188
+ let buckets: Vec<(MmBucketKey, f64)> = parse_bucket_config(&args.random_mm_bucket_config)
189
+ .context("invalid --random-mm-bucket-config")?;
190
+ let percentiles = parse_percentiles(&args.metric_percentiles)?;
191
+ let seed = args.seed.unwrap_or(0);
192
+
193
+ // Offline tokenizer: local Hugging Face tokenizer or built-in tiktoken.
194
+ let tokenizer = load_tokenizer(&args.model, args.trust_remote_code, None)
195
+ .await
196
+ .context("failed to load tokenizer")?;
197
+
198
+ // Frontend backends: model configs + multimodal processor metadata.
199
+ let backends = load_model_backends(
200
+ &args.model,
201
+ LoadModelBackendsOptions {
202
+ chat_template: args.chat_template.clone(),
203
+ ..LoadModelBackendsOptions::default()
204
+ },
205
+ )
206
+ .await
207
+ .context("failed to load model backends")?;
208
+
209
+ if backends.chat_backend.multimodal_model_info().is_none() {
210
+ anyhow::bail!(
211
+ "model `{}` has no multimodal processor registered",
212
+ args.model
213
+ );
214
+ }
215
+
216
+ // Connect to the engine over the same transport the serving frontend uses.
217
+ let client = EngineCoreClient::connect(EngineCoreClientConfig {
218
+ transport_mode: TransportMode::HandshakeOwner {
219
+ handshake_address: format!("tcp://{}:{}", args.engine.handshake_host, handshake_port),
220
+ advertised_host: args.engine.handshake_host.clone(),
221
+ engine_count: args
222
+ .engine
223
+ .data_parallel_size_local
224
+ .unwrap_or(args.engine.data_parallel_size),
225
+ ready_timeout: ENGINE_READY_TIMEOUT,
226
+ local_input_address: None,
227
+ local_output_address: None,
228
+ },
229
+ coordinator_mode: None,
230
+ model_name: args.model.clone(),
231
+ client_index: 0,
232
+ })
233
+ .await
234
+ .context("failed to connect to engine core")?;
235
+
236
+ let chat = ChatLlm::new(
237
+ TextLlm::new(Llm::new(client), backends.text_backend),
238
+ backends.chat_backend,
239
+ );
240
+
241
+ let semaphore = Arc::new(Semaphore::new(args.max_concurrency));
242
+
243
+ // Warmup: generate a separate dataset, process it, then drain and discard
244
+ // the collected timings so cold caches do not skew the results.
245
+ if args.num_warmups > 0 {
246
+ let warmup = generate_dataset(
247
+ &tokenizer,
248
+ args.num_warmups,
249
+ seed.wrapping_add(1),
250
+ &args,
251
+ range_ratio,
252
+ &limit,
253
+ &buckets,
254
+ )?;
255
+ eprintln!("Processing {} warmup requests...", warmup.len());
256
+ run_batch(&chat, &warmup, &semaphore).await;
257
+ let _ = timing.stat();
258
+ }
259
+
260
+ let samples = generate_dataset(
261
+ &tokenizer,
262
+ args.num_prompts,
263
+ seed,
264
+ &args,
265
+ range_ratio,
266
+ &limit,
267
+ &buckets,
268
+ )?;
269
+ eprintln!("Processing {} requests...", samples.len());
270
+ let start_time = Instant::now();
271
+ let outcomes = run_batch(&chat, &samples, &semaphore).await;
272
+ let total_time = start_time.elapsed();
273
+
274
+ let completed = outcomes.iter().filter(|outcome| outcome.is_ok()).count();
275
+ let failed = outcomes.len() - completed;
276
+ let e2el_times: Vec<f64> = outcomes
277
+ .iter()
278
+ .filter_map(|outcome| outcome.as_ref().ok().map(|elapsed| elapsed.as_secs_f64()))
279
+ .collect();
280
+
281
+ let per_request = timing.stat();
282
+ let stats = aggregate_stats(per_request, &percentiles);
283
+
284
+ eprintln!(
285
+ "Processed {} requests (+{} warmup) for model `{}`",
286
+ samples.len(),
287
+ args.num_warmups,
288
+ args.model
289
+ );
290
+ print_report(&stats, &percentiles);
291
+ print_e2el_summary(completed, failed, total_time, &e2el_times, &percentiles);
292
+
293
+ if let Some(path) = &args.output_json {
294
+ write_result_json(path, completed, failed, &e2el_times, &percentiles, stats)?;
295
+ eprintln!("Wrote stats JSON to {path}");
296
+ }
297
+
298
+ chat.shutdown().await.context("failed to shut down chat facade")?;
299
+ Ok(())
300
+}
301
+
302
+/// Submit every sample through the chat pipeline and drive each stream to
303
+/// completion, returning per-request wall-clock latencies.
304
+async fn run_batch(
305
+ chat: &ChatLlm,
306
+ samples: &[SampleRequest],
307
+ semaphore: &Arc<Semaphore>,
308
+) -> Vec<Result<Duration>> {
309
+ let submissions = samples.iter().map(|sample| {
310
+ let request = build_chat_request(sample);
311
+ let semaphore = semaphore.clone();
312
+ async move {
313
+ let _permit = semaphore.acquire().await.context("semaphore closed")?;
314
+ let request = request?;
315
+ let start = Instant::now();
316
+ chat.chat(request)
317
+ .await
318
+ .context("failed to submit request")?
319
+ .collect_message()
320
+ .await
321
+ .context("request failed")?;
322
+ Ok(start.elapsed())
323
+ }
324
+ });
325
+ join_all(submissions).await
326
+}
327
+
328
+fn generate_dataset(
329
+ tokenizer: &TokenizerKind,
330
+ num_requests: usize,
331
+ seed: u64,
332
+ args: &MmProcessorArgs,
333
+ range_ratio: RangeRatio,
334
+ limit: &MmLimitPerPrompt,
335
+ buckets: &[(MmBucketKey, f64)],
336
+) -> Result<Vec<SampleRequest>> {
337
+ generate_random_mm_dataset(
338
+ tokenizer,
339
+ num_requests,
340
+ args.random_input_len,
341
+ args.random_output_len,
342
+ args.random_prefix_len,
343
+ range_ratio,
344
+ seed,
345
+ &args.request_id_prefix,
346
+ args.random_mm_base_items_per_request,
347
+ args.random_mm_num_mm_items_range_ratio,
348
+ limit,
349
+ buckets,
350
+ false,
351
+ )
352
+ .map_err(|e| anyhow::anyhow!("{e}"))
353
+}
354
+
355
+/// Build a chat request from a sample (text prompt + `image_url` parts).
356
+fn build_chat_request(sample: &SampleRequest) -> Result<ChatRequest> {
357
+ let mut request = ChatRequest::for_test();
358
+ request.request_id = sample.request_id.clone().unwrap_or_else(|| "mm-proc".to_string());
359
+ request.sampling_params.temperature = Some(0.0);
360
+ request.sampling_params.max_tokens = Some(u32::try_from(sample.expected_output_len)?);
361
+
362
+ let mut parts = vec![ChatContentPart::text(sample.prompt.as_ref())];
363
+ if let Some(mm_items) = &sample.multi_modal_content {
364
+ for fragment in mm_items.iter() {
365
+ let url = extract_image_url(fragment)?;
366
+ parts.push(ChatContentPart::image_url(url));
367
+ }
368
+ }
369
+ request.messages = vec![ChatMessage::user(ChatContent::from(parts))];
370
+ Ok(request)
371
+}
372
+
373
+/// Extract the `image_url.url` string from a pre-serialized content fragment
374
+/// (`{"type":"image_url","image_url":{"url":"data:image/jpeg;base64,..."}}`).
375
+fn extract_image_url(fragment: &str) -> Result<String> {
376
+ let value: serde_json::Value = serde_json::from_str(fragment)
377
+ .with_context(|| format!("invalid multimodal fragment: {fragment}"))?;
378
+ let url = value
379
+ .get("image_url")
380
+ .and_then(|v| v.get("url"))
381
+ .and_then(|v| v.as_str())
382
+ .ok_or_else(|| BenchError::Config("fragment missing image_url.url".to_string()))?;
383
+ Ok(url.to_string())
384
+}
385
+
386
+fn parse_percentiles(raw: &str) -> Result<Vec<f64>> {
387
+ let mut values = Vec::new();
388
+ for part in raw.split(',') {
389
+ let part = part.trim();
390
+ if part.is_empty() {
391
+ continue;
392
+ }
393
+ let value: f64 = part.parse().with_context(|| format!("invalid percentile `{part}`"))?;
394
+ if !(0.0..=100.0).contains(&value) {
395
+ anyhow::bail!("percentile `{part}` out of range [0, 100]");
396
+ }
397
+ values.push(value);
398
+ }
399
+ Ok(values)
400
+}
401
+
402
+/// Aggregate per-request stage timings into `{stage_ms: {statistic: ms}}`,
403
+/// matching the Python `mm_processor_stats` schema orientation.
404
+fn aggregate_stats(
405
+ per_request: HashMap<String, HashMap<String, f64>>,
406
+ percentiles: &[f64],
407
+) -> BTreeMap<String, BTreeMap<String, f64>> {
408
+ let mut stage_values: BTreeMap<String, Vec<f64>> = BTreeMap::new();
409
+ for stage_map in per_request.into_values() {
410
+ for (stage, seconds) in stage_map {
411
+ stage_values.entry(stage).or_default().push(seconds);
412
+ }
413
+ }
414
+
415
+ stage_values
416
+ .iter()
417
+ .map(|(stage, values)| {
418
+ let sorted = sort_clone(values);
419
+ let mut stat_map = BTreeMap::new();
420
+ stat_map.insert("mean".to_string(), mean(values) * SEC_TO_MS);
421
+ stat_map.insert("median".to_string(), median_sorted(&sorted) * SEC_TO_MS);
422
+ stat_map.insert("std".to_string(), std_dev(values) * SEC_TO_MS);
423
+ for p in percentiles {
424
+ stat_map.insert(format!("p{p}"), percentile_sorted(&sorted, *p) * SEC_TO_MS);
425
+ }
426
+ (stage.replace("_secs", "_ms"), stat_map)
427
+ })
428
+ .collect()
429
+}
430
+
431
+/// Print a right-aligned plain-text table (pandas `to_string(index=False)` style).
432
+fn print_table(rows: &[Vec<String>]) {
433
+ let ncols = rows.first().map_or(0, Vec::len);
434
+ let mut widths = vec![0usize; ncols];
435
+ for row in rows {
436
+ for (i, cell) in row.iter().enumerate() {
437
+ widths[i] = widths[i].max(cell.len());
438
+ }
439
+ }
440
+ for row in rows {
441
+ let line = row
442
+ .iter()
443
+ .enumerate()
444
+ .map(|(i, cell)| format!("{cell:>width$}", width = widths[i]))
445
+ .collect::<Vec<_>>()
446
+ .join(" ");
447
+ println!("{line}");
448
+ }
449
+}
450
+
451
+/// Print a plain-text table with one row per stage and one column per
452
+/// statistic (mirrors the Python pandas report layout).
453
+fn print_report(stats: &BTreeMap<String, BTreeMap<String, f64>>, percentiles: &[f64]) {
454
+ // Column order: mean/median/std then the requested percentiles, like Python.
455
+ let mut columns: Vec<(String, String)> =
456
+ [("mean", "Mean"), ("median", "Median"), ("std", "Std")]
457
+ .into_iter()
458
+ .map(|(key, label)| (key.to_string(), label.to_string()))
459
+ .collect();
460
+ columns.extend(percentiles.iter().map(|p| (format!("p{p}"), format!("P{p:?}"))));
461
+ for key in stats.values().flat_map(|map| map.keys()) {
462
+ if !columns.iter().any(|(k, _)| k == key) {
463
+ columns.push((key.clone(), key.clone()));
464
+ }
465
+ }
466
+
467
+ let mut stages: Vec<&String> = stats.keys().collect();
468
+ stages.sort_by_key(|stage| {
469
+ STAGE_ORDER
470
+ .iter()
471
+ .position(|s| s == stage)
472
+ .map(|i| (i as isize, (*stage).clone()))
473
+ .unwrap_or((isize::MAX, (*stage).clone()))
474
+ });
475
+
476
+ let mut rows: Vec<Vec<String>> = vec![
477
+ std::iter::once("Stage".to_string())
478
+ .chain(columns.iter().map(|(_, label)| label.clone()))
479
+ .collect(),
480
+ ];
481
+ for stage in &stages {
482
+ let mut row = vec![(*stage).clone()];
483
+ for (key, _) in &columns {
484
+ row.push(
485
+ stats[*stage]
486
+ .get(key)
487
+ .map(|v| format!("{v:.2}"))
488
+ .unwrap_or_else(|| "-".to_string()),
489
+ );
490
+ }
491
+ rows.push(row);
492
+ }
493
+ println!("MM Processor Metrics:");
494
+ print_table(&rows);
495
+}
496
+
497
+/// Print the end-to-end latency summary (mirrors the Python report layout).
498
+fn print_e2el_summary(
499
+ completed: usize,
500
+ failed: usize,
501
+ total_time: Duration,
502
+ e2el_times_secs: &[f64],
503
+ percentiles: &[f64],
504
+) {
505
+ println!("Completed: {completed}, Failed: {failed}");
506
+ if e2el_times_secs.is_empty() {
507
+ println!("End-to-End Latency (ms): no completed requests");
508
+ return;
509
+ }
510
+
511
+ let e2el_ms: Vec<f64> = e2el_times_secs.iter().map(|secs| secs * SEC_TO_MS).collect();
512
+ let sorted = sort_clone(&e2el_ms);
513
+ let mut rows = vec![
514
+ vec!["Metric".to_string(), "Value (ms)".to_string()],
515
+ vec!["Mean".to_string(), format!("{:.2}", mean(&e2el_ms))],
516
+ vec![
517
+ "Median".to_string(),
518
+ format!("{:.2}", median_sorted(&sorted)),
519
+ ],
520
+ vec!["Std".to_string(), format!("{:.2}", std_dev(&e2el_ms))],
521
+ ];
522
+ for p in percentiles {
523
+ rows.push(vec![
524
+ format!("P{p:?}"),
525
+ format!("{:.2}", percentile_sorted(&sorted, *p)),
526
+ ]);
527
+ }
528
+ println!("End-to-End Latency (ms):");
529
+ print_table(&rows);
530
+ let throughput = completed as f64 / total_time.as_secs_f64();
531
+ println!("Request throughput: {throughput:.3} req/s");
532
+}
533
+
534
+/// JSON result schema mirroring `vllm bench mm-processor`.
535
+#[derive(Serialize)]
536
+struct BenchmarkResultJson {
537
+ completed: usize,
538
+ failed: usize,
539
+ mean_e2el_ms: f64,
540
+ median_e2el_ms: f64,
541
+ std_e2el_ms: f64,
542
+ percentiles_e2el_ms: Vec<(f64, f64)>,
543
+ /// `{stage_ms: {statistic: ms}}`, keyed by stage like the Python schema.
544
+ mm_processor_stats: BTreeMap<String, BTreeMap<String, f64>>,
545
+}
546
+
547
+fn write_result_json(
548
+ path: &str,
549
+ completed: usize,
550
+ failed: usize,
551
+ e2el_times_secs: &[f64],
552
+ percentiles: &[f64],
553
+ mm_processor_stats: BTreeMap<String, BTreeMap<String, f64>>,
554
+) -> Result<()> {
555
+ let e2el_ms: Vec<f64> = e2el_times_secs.iter().map(|secs| secs * SEC_TO_MS).collect();
556
+ let sorted = sort_clone(&e2el_ms);
557
+ let result = BenchmarkResultJson {
558
+ completed,
559
+ failed,
560
+ mean_e2el_ms: mean(&e2el_ms),
561
+ median_e2el_ms: median_sorted(&sorted),
562
+ std_e2el_ms: std_dev(&e2el_ms),
563
+ percentiles_e2el_ms: percentiles
564
+ .iter()
565
+ .map(|p| (*p, percentile_sorted(&sorted, *p)))
566
+ .collect(),
567
+ mm_processor_stats,
568
+ };
569
+ let json = serde_json::to_string_pretty(&result).context("failed to serialize result")?;
570
+ std::fs::write(path, format!("{json}\n")).with_context(|| format!("failed to write {path}"))?;
571
+ Ok(())
572
+}
rust/src/chat/Cargo.toml
+1
@@ -35,6 +35,7 @@ vllm-llm.workspace = true
35
vllm-parser.workspace = true
36
vllm-text.workspace = true
37
vllm-tokenizer.workspace = true
38
+vllm-tracing.workspace = true
39
xgrammar-structural-tag.workspace = true
40
41
[dev-dependencies]
rust/src/chat/src/lib.rs
+3
@@ -46,6 +46,9 @@ pub use stream::{ChatEventStream, ChatEventStreamTrait, CollectedAssistantMessag
46
pub use vllm_engine_core_client::protocol::multimodal::MmFeatures;
47
pub use vllm_llm::FinishReason;
48
pub use vllm_text::GenerationConfigMode;
49
+pub use vllm_tracing::timing::{
50
+ RequestTimingStats as MultiModalTimingStats, mm_request_span, mm_timing_layer,
51
+};
52
53
mod backend;
54
mod error;
rust/src/chat/src/multimodal.rs
+40
-11
@@ -26,7 +26,7 @@ use llm_multimodal::{
26
};
27
use serde::{Deserialize, Serialize};
28
use thiserror_ext::AsReport as _;
29
-use tracing::warn;
29
+use tracing::{Instrument as _, warn};
30
use vllm_engine_core_client::protocol::dtype::ModelDtype;
31
use vllm_engine_core_client::protocol::multimodal::{MmFeatureSpec, MmFeatures, MmKwargsItem};
32
use vllm_text::Prompt;
@@ -47,6 +47,7 @@ mod video;
47
48
use self::expand::expand_prompt_token_ids;
49
pub use self::input::MultimodalInput;
50
+use vllm_tracing::timing::{mm_request_span, mm_stage_span};
51
52
/// Resolved multimodal support for one loaded model.
53
#[derive(Clone)]
@@ -562,7 +563,10 @@ pub(crate) async fn finalize_rendered_prompt(
563
.map_err(|error| multimodal!("{error}"))?,
564
Prompt::TokenIds(token_ids) => token_ids,
565
};
565
- let prepared = info.prepare_multimodal(media_parts, &mut prompt_token_ids, model_dtype).await?;
566
+ let prepared = info
567
+ .prepare_multimodal(media_parts, &mut prompt_token_ids, model_dtype)
568
+ .instrument(mm_request_span(&request.request_id))
569
+ .await?;
570
571
Ok((Prompt::TokenIds(prompt_token_ids), Some(prepared)))
572
}
@@ -753,27 +757,52 @@ impl MultimodalModelInfo {
757
prompt_token_ids: &mut Vec<u32>,
758
model_dtype: ModelDtype,
759
) -> Result<MmFeatures> {
756
- let media_parts_len = media_parts.len();
757
- if media_parts_len == 0 {
760
+ if media_parts.is_empty() {
761
return Ok(Vec::new());
762
}
763
+ self.prepare_multimodal_timed(media_parts, prompt_token_ids, model_dtype)
764
+ .instrument(mm_stage_span("preprocessor_total"))
765
+ .await
766
+ }
767
+
768
+ /// Timed body of [`Self::prepare_multimodal`]; each stage is a `tracing`
769
+ /// span aggregated per request by the `vllm-tracing` timing layer.
770
+ async fn prepare_multimodal_timed(
771
+ &self,
772
+ media_parts: Vec<MediaContentPart>,
773
+ prompt_token_ids: &mut Vec<u32>,
774
+ model_dtype: ModelDtype,
775
+ ) -> Result<MmFeatures> {
776
+ let media_parts_len = media_parts.len();
777
self.validate_mm_limits(&media_parts)?;
761
- let fetched = self.fetch_media(media_parts).await?;
778
+ let fetched =
779
+ self.fetch_media(media_parts).instrument(mm_stage_span("media_fetch")).await?;
780
781
let mut prepared = Vec::new();
782
if !fetched.images.is_empty() {
765
- prepared
766
- .push(self.prepare_images(fetched.images, fetched.image_uuids, model_dtype).await?);
783
+ prepared.push(
784
+ self.prepare_images(fetched.images, fetched.image_uuids, model_dtype)
785
+ .instrument(mm_stage_span("preprocess_image"))
786
+ .await?,
787
+ );
788
}
789
if !fetched.videos.is_empty() {
769
- prepared
770
- .push(self.prepare_videos(fetched.videos, fetched.video_uuids, model_dtype).await?);
790
+ prepared.push(
791
+ self.prepare_videos(fetched.videos, fetched.video_uuids, model_dtype)
792
+ .instrument(mm_stage_span("preprocess_video"))
793
+ .await?,
794
+ );
795
}
796
if !fetched.audios.is_empty() {
773
- prepared.push(self.prepare_audios(fetched.audios, fetched.audio_uuids).await?);
797
+ prepared.push(
798
+ self.prepare_audios(fetched.audios, fetched.audio_uuids)
799
+ .instrument(mm_stage_span("preprocess_audio"))
800
+ .await?,
801
+ );
802
}
803
776
- let mut ranges = expand_prompt_token_ids(prompt_token_ids, &prepared)?;
804
+ let mut ranges = mm_stage_span("prompt_expansion")
805
+ .in_scope(|| expand_prompt_token_ids(prompt_token_ids, &prepared))?;
806
807
let mut features = Vec::with_capacity(media_parts_len);
808
for media in prepared {
rust/src/server/src/routes/inference/generate.rs
+6
-1
@@ -53,7 +53,12 @@ pub async fn generate(
53
let lora_resolution = state.resolve_model_with_loras(body.model.as_deref()).await;
54
55
let mm_features = if let Some(parts) = body.content_parts.take() {
56
- match state.chat.prepare_media(parts, &mut body.token_ids).await {
56
+ match state
57
+ .chat
58
+ .prepare_media(parts, &mut body.token_ids)
59
+ .instrument(vllm_chat::mm_request_span(&request_context.request_id))
60
+ .await
61
+ {
62
Ok(features) => features,
63
Err(e) => {
64
return ApiError::invalid_request(
rust/src/tracing/src/lib.rs
+27
-9
@@ -3,13 +3,14 @@
3
4
//! Shared tracing subscriber and log formatting for vLLM Rust binaries.
5
6
+pub mod timing;
7
+
8
use std::{env, fmt, process};
9
10
use time::UtcOffset;
11
use time::macros::format_description;
12
use tracing::level_filters::LevelFilter;
13
use tracing::{Event, Level, Subscriber};
12
-use tracing_subscriber::Layer as _;
14
use tracing_subscriber::filter::Targets;
15
use tracing_subscriber::fmt::format::{FormatEvent, FormatFields, Writer};
16
use tracing_subscriber::fmt::time::FormatTime;
@@ -17,6 +18,7 @@ use tracing_subscriber::fmt::{FmtContext, FormattedFields};
18
use tracing_subscriber::layer::SubscriberExt as _;
19
use tracing_subscriber::registry::LookupSpan;
20
use tracing_subscriber::util::SubscriberInitExt as _;
21
+use tracing_subscriber::{Layer, Registry};
22
23
const CYAN: &str = "\x1b[0;36m";
24
const GREY: &str = "\x1b[90m";
@@ -30,20 +32,36 @@ const VLLM_TIME_FORMAT: &[time::format_description::FormatItem<'static>] =
32
33
/// Install the process-wide vLLM-style tracing subscriber.
34
pub fn init_tracing(process_label: &str) {
35
+ let _ = tracing_subscriber::registry().with(vllm_fmt_layer(process_label)).try_init();
36
+}
37
+
38
+/// Install the process-wide vLLM-style tracing subscriber, with an
39
+/// additional layer (e.g. the multimodal preprocessing timing collector).
40
+pub fn init_tracing_with<L>(process_label: &str, layer: L)
41
+where
42
+ L: Layer<Registry> + Send + Sync + 'static,
43
+{
44
+ let _ = tracing_subscriber::registry()
45
+ .with(layer)
46
+ .with(vllm_fmt_layer(process_label))
47
+ .try_init();
48
+}
49
+
50
+/// The vLLM-style fmt layer filtered by `VLLM_LOGGING_LEVEL` / `RUST_LOG`.
51
+fn vllm_fmt_layer<S>(process_label: &str) -> impl Layer<S> + Send + Sync + 'static + use<S>
52
+where
53
+ S: Subscriber + for<'lookup> LookupSpan<'lookup>,
54
+{
55
let filter = build_targets_filter(
56
env::var("VLLM_LOGGING_LEVEL").ok().as_deref(),
57
env::var("RUST_LOG").ok().as_deref(),
58
);
59
let formatter = VllmEventFormatter::new(process_label);
60
39
- let _ = tracing_subscriber::registry()
40
- .with(
41
- tracing_subscriber::fmt::layer()
42
- .event_format(formatter)
43
- .with_writer(std::io::stderr)
44
- .with_filter(filter),
45
- )
46
- .try_init();
61
+ tracing_subscriber::fmt::layer()
62
+ .event_format(formatter)
63
+ .with_writer(std::io::stderr)
64
+ .with_filter(filter)
65
}
66
67
/// Build the CLI log filter by merging the vLLM-style default level with
rust/src/tracing/src/timing.rs
new
+229
@@ -0,0 +1,229 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Request-scoped span timing.
5
+//!
6
+//! Pipelines emit stage spans (target = the layer's configured target, a
7
+//! `stage` field naming the stage) inside a request span carrying a
8
+//! `request_id` field; [`RequestTimingLayer`] attributes each stage span's
9
+//! wall-clock time to the nearest ancestor `request_id` and accumulates per
10
+//! request. Callers that want timings install the layer on their subscriber;
11
+//! everyone else pays only the cost of untracked spans.
12
+
13
+use std::collections::HashMap;
14
+use std::fmt;
15
+use std::sync::{Arc, Mutex};
16
+use std::time::Instant;
17
+
18
+use tracing::field::{Field, Visit};
19
+use tracing::span::{Attributes, Span};
20
+use tracing::{Id, Subscriber, info_span};
21
+use tracing_subscriber::layer::{Context, Layer};
22
+use tracing_subscriber::registry::LookupSpan;
23
+
24
+/// `"{stage}_secs" => seconds` maps returned by [`RequestTimingStats::stat`].
25
+pub type StageStats = HashMap<String, f64>;
26
+
27
+/// `request_id` extracted from a span, propagated to stage timings.
28
+struct RequestId(String);
29
+
30
+/// Start timestamp of one in-flight stage span.
31
+struct StageStart {
32
+ stage: String,
33
+ start: Instant,
34
+}
35
+
36
+/// Extracts the fields the layer cares about from span attributes.
37
+#[derive(Default)]
38
+struct SpanFields {
39
+ request_id: Option<String>,
40
+ stage: Option<String>,
41
+}
42
+
43
+impl Visit for SpanFields {
44
+ fn record_str(&mut self, field: &Field, value: &str) {
45
+ match field.name() {
46
+ "request_id" => self.request_id = Some(value.to_string()),
47
+ "stage" => self.stage = Some(value.to_string()),
48
+ _ => {}
49
+ }
50
+ }
51
+
52
+ fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
53
+ match field.name() {
54
+ "request_id" => self.request_id = Some(format!("{value:?}")),
55
+ "stage" => self.stage = Some(format!("{value:?}")),
56
+ _ => {}
57
+ }
58
+ }
59
+}
60
+
61
+/// `tracing` layer that aggregates stage-span timings per request, keyed by
62
+/// the nearest ancestor span with a `request_id` field.
63
+pub struct RequestTimingLayer {
64
+ target: &'static str,
65
+ stats: Arc<Mutex<HashMap<String, StageStats>>>,
66
+}
67
+
68
+/// Drain handle for the timings collected by [`RequestTimingLayer`].
69
+pub struct RequestTimingStats {
70
+ stats: Arc<Mutex<HashMap<String, StageStats>>>,
71
+}
72
+
73
+impl RequestTimingLayer {
74
+ /// Create the layer and its stats handle. Only spans whose target equals
75
+ /// `target` and that carry a `stage` field are timed.
76
+ pub fn new(target: &'static str) -> (Self, RequestTimingStats) {
77
+ let stats = Arc::new(Mutex::new(HashMap::new()));
78
+ (
79
+ Self {
80
+ target,
81
+ stats: Arc::clone(&stats),
82
+ },
83
+ RequestTimingStats { stats },
84
+ )
85
+ }
86
+}
87
+
88
+impl RequestTimingStats {
89
+ /// Drain and return `{request_id: {stage_secs}}` records.
90
+ pub fn stat(&self) -> HashMap<String, StageStats> {
91
+ std::mem::take(&mut *self.stats.lock().unwrap())
92
+ }
93
+}
94
+
95
+impl<S> Layer<S> for RequestTimingLayer
96
+where
97
+ S: Subscriber + for<'lookup> LookupSpan<'lookup>,
98
+{
99
+ fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
100
+ let Some(span) = ctx.span(id) else {
101
+ return;
102
+ };
103
+ let mut fields = SpanFields::default();
104
+ attrs.record(&mut fields);
105
+ let mut extensions = span.extensions_mut();
106
+ if let Some(request_id) = fields.request_id {
107
+ extensions.insert(RequestId(request_id));
108
+ }
109
+ if span.metadata().target() == self.target
110
+ && let Some(stage) = fields.stage
111
+ {
112
+ extensions.insert(StageStart {
113
+ stage,
114
+ start: Instant::now(),
115
+ });
116
+ }
117
+ }
118
+
119
+ fn on_close(&self, id: Id, ctx: Context<'_, S>) {
120
+ let Some(span) = ctx.span(&id) else {
121
+ return;
122
+ };
123
+ let (stage, start) = {
124
+ let extensions = span.extensions();
125
+ let Some(stage_start) = extensions.get::<StageStart>() else {
126
+ return;
127
+ };
128
+ (stage_start.stage.clone(), stage_start.start)
129
+ };
130
+ let Some(request_id) = span
131
+ .scope()
132
+ .find_map(|ancestor| ancestor.extensions().get::<RequestId>().map(|id| id.0.clone()))
133
+ else {
134
+ return;
135
+ };
136
+ let mut stats = self.stats.lock().unwrap();
137
+ *stats.entry(request_id).or_default().entry(format!("{stage}_secs")).or_default() +=
138
+ start.elapsed().as_secs_f64();
139
+ }
140
+}
141
+
142
+/// Target of the multimodal preprocessing stage spans.
143
+const MM_STAGE_TARGET: &str = "mm_processor_timing";
144
+
145
+/// Create the timing layer and its stats handle for multimodal preprocessing
146
+/// stage spans (`vllm-bench mm-processor`), mirroring the Python
147
+/// `TimingContext` / `MultiModalTimingRegistry`.
148
+pub fn mm_timing_layer() -> (RequestTimingLayer, RequestTimingStats) {
149
+ RequestTimingLayer::new(MM_STAGE_TARGET)
150
+}
151
+
152
+/// Span carrying the `request_id` used to attribute multimodal stage timings.
153
+pub fn mm_request_span(request_id: &str) -> Span {
154
+ info_span!("mm_request", request_id)
155
+}
156
+
157
+/// Span for one multimodal preprocessing stage; the elapsed time is recorded
158
+/// on close.
159
+pub fn mm_stage_span(stage: &'static str) -> Span {
160
+ info_span!(target: MM_STAGE_TARGET, "mm_stage", stage)
161
+}
162
+
163
+#[cfg(test)]
164
+mod tests {
165
+ use super::*;
166
+ use tracing::info_span;
167
+ use tracing_subscriber::layer::SubscriberExt as _;
168
+
169
+ const TARGET: &str = "test_request_timing";
170
+
171
+ fn stage_span(stage: &'static str) -> tracing::Span {
172
+ info_span!(target: TARGET, "stage", stage)
173
+ }
174
+
175
+ #[test]
176
+ fn records_stage_timings_per_request() {
177
+ let (layer, stats) = RequestTimingLayer::new(TARGET);
178
+ let subscriber = tracing_subscriber::registry().with(layer);
179
+ tracing::subscriber::with_default(subscriber, || {
180
+ info_span!("request", request_id = "req-1").in_scope(|| {
181
+ drop(stage_span("media_fetch"));
182
+ stage_span("prompt_expansion").in_scope(|| {});
183
+ });
184
+ });
185
+ let drained = stats.stat();
186
+ assert!(drained["req-1"].contains_key("media_fetch_secs"));
187
+ assert!(drained["req-1"].contains_key("prompt_expansion_secs"));
188
+ // `stat` drains: a second read is empty.
189
+ assert!(stats.stat().is_empty());
190
+ }
191
+
192
+ #[test]
193
+ fn attributes_to_nearest_request_id_ancestor() {
194
+ let (layer, stats) = RequestTimingLayer::new(TARGET);
195
+ let subscriber = tracing_subscriber::registry().with(layer);
196
+ tracing::subscriber::with_default(subscriber, || {
197
+ info_span!("request", request_id = "req-outer").in_scope(|| {
198
+ info_span!("request", request_id = "req-inner").in_scope(|| {
199
+ drop(stage_span("media_fetch"));
200
+ });
201
+ });
202
+ });
203
+ let drained = stats.stat();
204
+ assert!(drained["req-inner"].contains_key("media_fetch_secs"));
205
+ assert!(!drained.contains_key("req-outer"));
206
+ }
207
+
208
+ #[test]
209
+ fn stage_without_request_id_is_dropped() {
210
+ let (layer, stats) = RequestTimingLayer::new(TARGET);
211
+ let subscriber = tracing_subscriber::registry().with(layer);
212
+ tracing::subscriber::with_default(subscriber, || {
213
+ drop(stage_span("media_fetch"));
214
+ });
215
+ assert!(stats.stat().is_empty());
216
+ }
217
+
218
+ #[test]
219
+ fn other_targets_are_ignored() {
220
+ let (layer, stats) = RequestTimingLayer::new(TARGET);
221
+ let subscriber = tracing_subscriber::registry().with(layer);
222
+ tracing::subscriber::with_default(subscriber, || {
223
+ info_span!("request", request_id = "req-1").in_scope(|| {
224
+ drop(info_span!("unrelated", stage = "media_fetch"));
225
+ });
226
+ });
227
+ assert!(stats.stat().is_empty());
228
+ }
229
+}