[Rust][Benchmark] Integrate `vllm-bench` to `vllm-rs` & `vllm` CLI (#48930)

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

Bugen Zhao committed Jul 20, 2026 at 17:31 UTC c01618fdc81524ed4e91652bbf85ed23d6ddc448
12 files changed +421 -280
rust/Cargo.lock
+1
@@ -5560,6 +5560,7 @@ dependencies = [
5560 "tracing",
5561 "tracing-subscriber",
5562 "uuid",
5563 + "vllm-bench",
5564 "vllm-chat",
5565 "vllm-engine-core-client",
5566 "vllm-managed-engine",
rust/Cargo.toml
+1
@@ -132,6 +132,7 @@ trait-set = "0.3.0"
132 url = "2.5.7"
133 uuid = { version = "1.22.0", features = ["v4"] }
134 validator = { version = "0.20.0", features = ["derive"] }
135 +vllm-bench = { path = "src/bench" }
136 vllm-chat = { path = "src/chat" }
137 vllm-engine-core-client = { path = "src/engine-core-client" }
138 vllm-llm = { path = "src/llm" }
rust/src/bench/src/cli.rs
+4 -11
@@ -3,8 +3,6 @@
3
4 use std::fmt;
5
6 -use clap::Parser;
7 -
6 /// Backend type for the benchmark endpoint.
7 #[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
8 pub enum BackendKind {
@@ -77,7 +75,7 @@ pub enum DatasetName {
75 ShareGpt,
76 #[value(name = "sonnet")]
77 Sonnet,
80 - #[value(name = "speed-bench")]
78 + #[value(name = "speed-bench", alias = "speed_bench")]
79 SpeedBench,
80 #[value(name = "hf")]
81 Hf,
@@ -144,13 +142,8 @@ impl fmt::Display for SpeedBenchConfig {
142 }
143
144 /// High-performance benchmark client for vLLM serving endpoints.
147 -#[derive(Parser, Debug, Clone)]
148 -#[command(
149 - name = "vllm-bench",
150 - about = "Benchmark online serving throughput",
151 - version
152 -)]
153 -pub struct Cli {
145 +#[derive(clap::Args, Debug, Clone)]
146 +pub struct BenchServeArgs {
147 /// The type of backend or endpoint to use for the benchmark.
148 #[arg(long, default_value = "openai")]
149 pub backend: BackendKind,
@@ -659,7 +652,7 @@ pub struct Cli {
652 pub lora_assignment: LoraAssignment,
653 }
654
662 -impl Cli {
655 +impl BenchServeArgs {
656 /// Resolve the base URL from explicit --base-url or from --host/--port.
657 pub fn resolve_base_url(&self) -> String {
658 if let Some(ref base) = self.base_url {
rust/src/bench/src/config.rs
+212 -188
@@ -4,7 +4,9 @@
4 use std::collections::HashMap;
5 use std::sync::Arc;
6
7 -use crate::cli::{BackendKind, Cli, DatasetName, LoraAssignment, RampUpStrategy, SpeedBenchConfig};
7 +use crate::cli::{
8 + BackendKind, BenchServeArgs, DatasetName, LoraAssignment, RampUpStrategy, SpeedBenchConfig,
9 +};
10 use crate::datasets::random_mm::{MmBucketKey, MmLimitPerPrompt};
11 use crate::error::{BenchError, Result};
12
@@ -215,63 +217,63 @@ pub struct BenchConfig {
217 }
218
219 impl BenchConfig {
218 - pub fn from_cli(cli: &Cli) -> Result<Self> {
219 - if cli.burstiness <= 0.0 {
220 + pub fn from_args(args: &BenchServeArgs) -> Result<Self> {
221 + if args.burstiness <= 0.0 {
222 return Err(BenchError::Config("Burstiness must be positive".into()));
223 }
224
223 - if cli.num_prompts == 0 {
225 + if args.num_prompts == 0 {
226 return Err(BenchError::Config(
227 "--num-prompts must be at least 1".into(),
228 ));
229 }
230
229 - if cli.request_rate <= 0.0 && !cli.request_rate.is_infinite() {
231 + if args.request_rate <= 0.0 && !args.request_rate.is_infinite() {
232 return Err(BenchError::Config(
233 "--request-rate must be positive (or inf)".into(),
234 ));
235 }
234 - if cli.max_model_len == Some(0) {
236 + if args.max_model_len == Some(0) {
237 return Err(BenchError::Config(
238 "--max-model-len must be at least 1".into(),
239 ));
240 }
241
240 - let base_url = cli.resolve_base_url();
241 - let api_url = cli.resolve_api_url();
242 + let base_url = args.resolve_base_url();
243 + let api_url = args.resolve_api_url();
244
243 - let extra_headers = cli.parse_headers()?;
244 - let mut extra_body = cli.parse_extra_body()?;
245 + let extra_headers = args.parse_headers()?;
246 + let mut extra_body = args.parse_extra_body()?;
247
248 // Merge sampling parameters into extra_body (matches Python behavior).
249 // Python collects non-None sampling params and merges them UNDER extra_body,
250 // meaning extra_body keys take precedence over sampling params.
251 {
252 let mut sampling_params = serde_json::Map::new();
251 - if let Some(v) = cli.top_p {
253 + if let Some(v) = args.top_p {
254 sampling_params.insert("top_p".into(), serde_json::json!(v));
255 }
254 - if let Some(v) = cli.top_k {
256 + if let Some(v) = args.top_k {
257 sampling_params.insert("top_k".into(), serde_json::json!(v));
258 }
257 - if let Some(v) = cli.min_p {
259 + if let Some(v) = args.min_p {
260 sampling_params.insert("min_p".into(), serde_json::json!(v));
261 }
260 - if let Some(v) = cli.temperature {
262 + if let Some(v) = args.temperature {
263 sampling_params.insert("temperature".into(), serde_json::json!(v));
264 }
263 - if let Some(v) = cli.frequency_penalty {
265 + if let Some(v) = args.frequency_penalty {
266 sampling_params.insert("frequency_penalty".into(), serde_json::json!(v));
267 }
266 - if let Some(v) = cli.presence_penalty {
268 + if let Some(v) = args.presence_penalty {
269 sampling_params.insert("presence_penalty".into(), serde_json::json!(v));
270 }
269 - if let Some(v) = cli.repetition_penalty {
271 + if let Some(v) = args.repetition_penalty {
272 sampling_params.insert("repetition_penalty".into(), serde_json::json!(v));
273 }
274
275 if !sampling_params.is_empty() {
274 - if !cli.backend.is_openai_compatible() {
276 + if !args.backend.is_openai_compatible() {
277 return Err(BenchError::Config(
278 "Sampling parameters are only supported by openai-compatible backends."
279 .into(),
@@ -299,7 +301,7 @@ impl BenchConfig {
301 }
302
303 // Parse metadata
302 - let metadata = match &cli.metadata {
304 + let metadata = match &args.metadata {
305 None => None,
306 Some(items) => {
307 let mut pairs = Vec::new();
@@ -314,24 +316,24 @@ impl BenchConfig {
316 };
317
318 // Parse goodput SLOs
317 - let goodput = parse_goodput(&cli.goodput)?;
319 + let goodput = parse_goodput(&args.goodput)?;
320
321 // Parse ramp-up config
320 - let ramp_up = parse_ramp_up(cli)?;
322 + let ramp_up = parse_ramp_up(args)?;
323
324 // Default percentile metrics based on backend type
323 - let default_percentile_metrics = if cli.backend.is_pooling() {
325 + let default_percentile_metrics = if args.backend.is_pooling() {
326 "e2el"
327 } else {
328 "ttft,tpot,itl,e2el"
329 };
330 let percentile_metrics_str =
329 - cli.percentile_metrics.as_deref().unwrap_or(default_percentile_metrics);
331 + args.percentile_metrics.as_deref().unwrap_or(default_percentile_metrics);
332 let selected_percentile_metrics: Vec<String> =
333 percentile_metrics_str.split(',').map(|s| s.trim().to_string()).collect();
334
333 - let metric_percentiles = parse_percentiles(&cli.metric_percentiles, false)?;
334 - let sweep_summary_percentiles = cli
335 + let metric_percentiles = parse_percentiles(&args.metric_percentiles, false)?;
336 + let sweep_summary_percentiles = args
337 .sweep_summary_percentiles
338 .as_deref()
339 .map(|raw| parse_percentiles(raw, true))
@@ -344,38 +346,38 @@ impl BenchConfig {
346 selected_percentiles.push(90.0);
347 }
348
347 - let tokenizer_id = if cli.skip_tokenizer_init {
349 + let tokenizer_id = if args.skip_tokenizer_init {
350 None
351 } else {
350 - Some(cli.tokenizer.clone().or_else(|| cli.model.clone()).unwrap_or_default())
352 + args.tokenizer.clone().or_else(|| args.model.clone())
353 };
354
355 // Resolve input/output lengths
354 - let random_input_len = cli.resolved_random_input_len();
355 - let random_output_len = cli.resolved_random_output_len();
356 - let per_turn_input_len = cli.resolved_per_turn_input_len();
356 + let random_input_len = args.resolved_random_input_len();
357 + let random_output_len = args.resolved_random_output_len();
358 + let per_turn_input_len = args.resolved_per_turn_input_len();
359
360 // Normalized multi-turn turn counts (computed in validation block below, defaults
361 // to num_turns if multi-turn mode is not active)
360 - let mut multi_turn_min_turns = cli.multi_turn_num_turns;
361 - let mut multi_turn_max_turns = cli.multi_turn_num_turns;
362 + let mut multi_turn_min_turns = args.multi_turn_num_turns;
363 + let mut multi_turn_max_turns = args.multi_turn_num_turns;
364
365 // For random datasets with openai-compatible backends, default to ignore_eos.
366 // Exception: multi-turn mode, where ignore_eos causes unbounded context growth
367 // across turns. Multi-turn uses min_tokens instead for output length control.
368 // Pooling backends don't generate tokens, so ignore_eos is irrelevant.
367 - let ignore_eos = if cli.backend.is_pooling() {
369 + let ignore_eos = if args.backend.is_pooling() {
370 false
371 } else {
370 - cli.ignore_eos
371 - || ((cli.dataset_name == DatasetName::Random
372 - || cli.dataset_name == DatasetName::RandomMm)
373 - && cli.backend.is_openai_compatible()
374 - && !cli.multi_turn)
372 + args.ignore_eos
373 + || ((args.dataset_name == DatasetName::Random
374 + || args.dataset_name == DatasetName::RandomMm)
375 + && args.backend.is_openai_compatible()
376 + && !args.multi_turn)
377 };
378
379 // Pooling backends don't support multi-turn
378 - if cli.backend.is_pooling() && cli.multi_turn {
380 + if args.backend.is_pooling() && args.multi_turn {
381 return Err(BenchError::Config(
382 "Pooling/embedding backends do not support --multi-turn".into(),
383 ));
@@ -383,7 +385,7 @@ impl BenchConfig {
385
386 // LoRA validation. Adapter names must be non-empty after trim; pooling
387 // backends are out of scope (vLLM LoRA routing is for generative paths).
386 - let lora_modules = match cli.lora_modules.as_ref() {
388 + let lora_modules = match args.lora_modules.as_ref() {
389 None => None,
390 Some(names) => {
391 if names.is_empty() {
@@ -391,7 +393,7 @@ impl BenchConfig {
393 "--lora-modules requires at least one adapter name".into(),
394 ));
395 }
394 - if cli.backend.is_pooling() {
396 + if args.backend.is_pooling() {
397 return Err(BenchError::Config(
398 "--lora-modules is not supported for pooling/embedding backends".into(),
399 ));
@@ -411,18 +413,18 @@ impl BenchConfig {
413 };
414
415 // Random-MM validation and config parsing
414 - let (random_mm_limit, random_mm_buckets) = if cli.dataset_name == DatasetName::RandomMm {
415 - if cli.backend != BackendKind::OpenaiChat {
416 + let (random_mm_limit, random_mm_buckets) = if args.dataset_name == DatasetName::RandomMm {
417 + if args.backend != BackendKind::OpenaiChat {
418 return Err(BenchError::Config(
419 "Multi-modal content (images) is only supported on 'openai-chat' backend."
420 .into(),
421 ));
422 }
423 let limit = crate::datasets::random_mm::parse_limit_mm_per_prompt(
422 - &cli.random_mm_limit_mm_per_prompt,
424 + &args.random_mm_limit_mm_per_prompt,
425 )?;
426 let buckets =
425 - crate::datasets::random_mm::parse_bucket_config(&cli.random_mm_bucket_config)?;
427 + crate::datasets::random_mm::parse_bucket_config(&args.random_mm_bucket_config)?;
428 (limit, buckets)
429 } else {
430 (MmLimitPerPrompt::default(), Vec::new())
@@ -432,18 +434,18 @@ impl BenchConfig {
434 // sonnet (uses built-in Shakespeare's sonnets).
435
436 // Range ratio (Python semantics: [len*(1-r), len*(1+r)], each r in [0,1))
435 - let random_range_ratio = RangeRatio::parse(&cli.random_range_ratio)?;
437 + let random_range_ratio = RangeRatio::parse(&args.random_range_ratio)?;
438
439 // Batched inputs only make sense for pooling backends (the generation
440 // backends send one prompt per request).
439 - if cli.random_batch_size == 0 {
441 + if args.random_batch_size == 0 {
442 return Err(BenchError::Config(
443 "--random-batch-size must be at least 1".into(),
444 ));
445 }
444 - if cli.random_batch_size > 1
445 - && !cli.backend.is_pooling()
446 - && cli.dataset_name != DatasetName::RandomRerank
446 + if args.random_batch_size > 1
447 + && !args.backend.is_pooling()
448 + && args.dataset_name != DatasetName::RandomRerank
449 {
450 return Err(BenchError::Config(
451 "--random-batch-size > 1 is only supported with embeddings/pooling backends".into(),
@@ -451,16 +453,16 @@ impl BenchConfig {
453 }
454
455 // random-rerank validation (mirrors Python RandomDatasetForReranking)
454 - let is_reranker = !cli.no_reranker;
455 - if cli.dataset_name == DatasetName::RandomRerank {
456 - if !cli.backend.is_pooling() {
456 + let is_reranker = !args.no_reranker;
457 + if args.dataset_name == DatasetName::RandomRerank {
458 + if !args.backend.is_pooling() {
459 return Err(BenchError::Config(
460 "--dataset-name random-rerank requires an embeddings/pooling backend \
461 (e.g. --backend vllm-rerank)"
462 .into(),
463 ));
464 }
463 - if !is_reranker && (cli.num_prompts < 2 || cli.random_batch_size < 2) {
465 + if !is_reranker && (args.num_prompts < 2 || args.random_batch_size < 2) {
466 return Err(BenchError::Config(
467 "--no-reranker requires --num-prompts > 1 and --random-batch-size > 1 \
468 (the query is folded into the first batch slot)"
@@ -470,8 +472,8 @@ impl BenchConfig {
472 }
473
474 // Custom dataset validation
473 - if cli.dataset_name == DatasetName::Custom {
474 - match cli.dataset_path.as_deref() {
475 + if args.dataset_name == DatasetName::Custom {
476 + match args.dataset_path.as_deref() {
477 None => {
478 return Err(BenchError::Config(
479 "--dataset-path is required for --dataset-name custom \
@@ -486,7 +488,7 @@ impl BenchConfig {
488 }
489 _ => {}
490 }
489 - if !cli.skip_chat_template {
491 + if !args.skip_chat_template {
492 eprintln!(
493 "NOTE: client-side chat template rendering is not supported; custom \
494 dataset prompts are sent raw (equivalent to --skip-chat-template)."
@@ -495,29 +497,29 @@ impl BenchConfig {
497 }
498
499 // Prefix repetition validation
498 - if cli.dataset_name == DatasetName::PrefixRepetition {
499 - if cli.prefix_repetition_num_prefixes == 0 {
500 + if args.dataset_name == DatasetName::PrefixRepetition {
501 + if args.prefix_repetition_num_prefixes == 0 {
502 return Err(BenchError::Config(
503 "--prefix-repetition-num-prefixes must be at least 1".into(),
504 ));
505 }
504 - if cli.num_prompts < cli.prefix_repetition_num_prefixes {
506 + if args.num_prompts < args.prefix_repetition_num_prefixes {
507 return Err(BenchError::Config(format!(
508 "--num-prompts ({}) must be >= --prefix-repetition-num-prefixes ({})",
507 - cli.num_prompts, cli.prefix_repetition_num_prefixes
509 + args.num_prompts, args.prefix_repetition_num_prefixes
510 )));
511 }
512 }
513
514 // HF dataset validation
513 - if cli.dataset_name == DatasetName::Hf && cli.dataset_path.is_none() {
515 + if args.dataset_name == DatasetName::Hf && args.dataset_path.is_none() {
516 return Err(BenchError::Config(
517 "--dataset-path is required for --dataset-name hf \
518 (set to a HuggingFace dataset ID, e.g. 'allenai/WildChat-4.8M')"
519 .into(),
520 ));
521 }
520 - if let Some(len) = cli.hf_output_len
522 + if let Some(len) = args.hf_output_len
523 && len == 0
524 {
525 return Err(BenchError::Config(
@@ -526,13 +528,13 @@ impl BenchConfig {
528 }
529
530 // Multi-turn validation
529 - if cli.multi_turn {
530 - if cli.backend != BackendKind::OpenaiChat {
531 + if args.multi_turn {
532 + if args.backend != BackendKind::OpenaiChat {
533 return Err(BenchError::Config(
534 "--multi-turn requires --backend openai-chat".into(),
535 ));
536 }
535 - if cli.multi_turn_num_turns == 0 {
537 + if args.multi_turn_num_turns == 0 {
538 return Err(BenchError::Config(
539 "--multi-turn-num-turns must be at least 1".into(),
540 ));
@@ -541,18 +543,18 @@ impl BenchConfig {
543 // Normalize and validate min/max turns. ShareGPT only consumes max_turns
544 // (the loader walks all available turns up to the cap), so the
545 // min/num/max coupling used for synthetic generation does not apply.
544 - if cli.dataset_name == DatasetName::ShareGpt {
545 - if cli.multi_turn_max_turns == 1 {
546 + if args.dataset_name == DatasetName::ShareGpt {
547 + if args.multi_turn_max_turns == 1 {
548 return Err(BenchError::Config(
549 "--multi-turn-max-turns must be at least 2 for ShareGPT multi-turn".into(),
550 ));
551 }
552 } else {
553 (multi_turn_min_turns, multi_turn_max_turns) =
552 - match (cli.multi_turn_min_turns, cli.multi_turn_max_turns) {
553 - (0, 0) => (cli.multi_turn_num_turns, cli.multi_turn_num_turns),
554 - (m, 0) => (m, cli.multi_turn_num_turns),
555 - (0, x) => (cli.multi_turn_num_turns, x),
554 + match (args.multi_turn_min_turns, args.multi_turn_max_turns) {
555 + (0, 0) => (args.multi_turn_num_turns, args.multi_turn_num_turns),
556 + (m, 0) => (m, args.multi_turn_num_turns),
557 + (0, x) => (args.multi_turn_num_turns, x),
558 (m, x) => (m, x),
559 };
560 if multi_turn_min_turns < 1 {
@@ -575,8 +577,8 @@ impl BenchConfig {
577 }
578
579 // Validate prefix sharing ratios
578 - let pg = cli.multi_turn_prefix_global_ratio;
579 - let pc = cli.multi_turn_prefix_conversation_ratio;
580 + let pg = args.multi_turn_prefix_global_ratio;
581 + let pc = args.multi_turn_prefix_conversation_ratio;
582 if !(0.0..=1.0).contains(&pg) {
583 return Err(BenchError::Config(
584 "--multi-turn-prefix-global-ratio must be in [0.0, 1.0]".into(),
@@ -592,20 +594,20 @@ impl BenchConfig {
594 "--multi-turn-prefix-global-ratio + --multi-turn-prefix-conversation-ratio must be < 1.0 (unique suffix required)".into(),
595 ));
596 }
595 - if (pg > 0.0 || pc > 0.0) && cli.dataset_name != DatasetName::Random {
597 + if (pg > 0.0 || pc > 0.0) && args.dataset_name != DatasetName::Random {
598 return Err(BenchError::Config(
599 "Prefix sharing (--multi-turn-prefix-global-ratio / --multi-turn-prefix-conversation-ratio) only works with --dataset-name random".into(),
600 ));
601 }
602 }
603
602 - if !(cli.steady_state_threshold > 0.0 && cli.steady_state_threshold <= 1.0) {
604 + if !(args.steady_state_threshold > 0.0 && args.steady_state_threshold <= 1.0) {
605 return Err(BenchError::Config(format!(
606 "--steady-state-threshold must be in (0.0, 1.0], got {}",
605 - cli.steady_state_threshold
607 + args.steady_state_threshold
608 )));
609 }
608 - if let Some(mw) = cli.steady_state_min_window
610 + if let Some(mw) = args.steady_state_min_window
611 && mw < 0.0
612 {
613 return Err(BenchError::Config(format!(
@@ -613,122 +615,122 @@ impl BenchConfig {
615 )));
616 }
617
616 - if cli.profile_batch_threshold.is_some() && !cli.profile {
618 + if args.profile_batch_threshold.is_some() && !args.profile {
619 return Err(BenchError::Config(
620 "--profile-batch-threshold requires --profile".into(),
621 ));
622 }
621 - if cli.profile_duration <= 0.0 {
623 + if args.profile_duration <= 0.0 {
624 return Err(BenchError::Config(
625 "--profile-duration must be positive".into(),
626 ));
627 }
626 - if cli.profile_batch_threshold.is_none() && cli.profile_duration != 5.0 {
628 + if args.profile_batch_threshold.is_none() && args.profile_duration != 5.0 {
629 return Err(BenchError::Config(
630 "--profile-duration requires --profile-batch-threshold".into(),
631 ));
632 }
633
634 Ok(BenchConfig {
633 - backend: cli.backend,
635 + backend: args.backend,
636 base_url,
637 api_url,
636 - model: cli.model.clone(),
637 - model_name: cli.served_model_name.clone(),
638 + model: args.model.clone(),
639 + model_name: args.served_model_name.clone(),
640 tokenizer_id,
639 - tokenizer_mode: cli.tokenizer_mode.clone(),
640 - trust_remote_code: cli.trust_remote_code,
641 - skip_tokenizer_init: cli.skip_tokenizer_init,
642 - dataset_name: cli.dataset_name,
643 - dataset_path: cli.dataset_path.clone(),
644 - max_model_len: cli.max_model_len,
641 + tokenizer_mode: args.tokenizer_mode.clone(),
642 + trust_remote_code: args.trust_remote_code,
643 + skip_tokenizer_init: args.skip_tokenizer_init,
644 + dataset_name: args.dataset_name,
645 + dataset_path: args.dataset_path.clone(),
646 + max_model_len: args.max_model_len,
647 random_input_len,
648 random_output_len,
647 - random_prefix_len: cli.random_prefix_len,
649 + random_prefix_len: args.random_prefix_len,
650 random_range_ratio,
649 - random_batch_size: cli.random_batch_size,
651 + random_batch_size: args.random_batch_size,
652 is_reranker,
651 - custom_output_len: cli.output_len.map(|v| v as i64).unwrap_or(cli.custom_output_len),
652 - prefix_repetition_prefix_len: cli.prefix_repetition_prefix_len,
653 - prefix_repetition_suffix_len: cli.prefix_repetition_suffix_len,
654 - prefix_repetition_num_prefixes: cli.prefix_repetition_num_prefixes,
655 - prefix_repetition_output_len: cli
653 + custom_output_len: args.output_len.map(|v| v as i64).unwrap_or(args.custom_output_len),
654 + prefix_repetition_prefix_len: args.prefix_repetition_prefix_len,
655 + prefix_repetition_suffix_len: args.prefix_repetition_suffix_len,
656 + prefix_repetition_num_prefixes: args.prefix_repetition_num_prefixes,
657 + prefix_repetition_output_len: args
658 .output_len
657 - .unwrap_or(cli.prefix_repetition_output_len),
658 - random_cache_hit_fraction: cli.random_cache_hit_fraction,
659 - random_cache_ratio: cli.random_cache_ratio,
660 - sharegpt_output_len: cli.sharegpt_output_len,
661 - sonnet_input_len: cli.sonnet_input_len,
662 - sonnet_output_len: cli.sonnet_output_len,
663 - sonnet_prefix_len: cli.sonnet_prefix_len,
664 - no_oversample: cli.no_oversample,
665 - disable_shuffle: cli.disable_shuffle,
666 - num_prompts: cli.num_prompts,
667 - request_rate: cli.request_rate,
668 - burstiness: cli.burstiness,
669 - max_concurrency: cli.max_concurrency,
670 - steady_state_threshold: cli.steady_state_threshold,
671 - steady_state_min_window: cli.steady_state_min_window,
672 - no_steady_state: cli.no_steady_state,
673 - disable_tqdm: cli.disable_tqdm,
674 - num_warmups: cli.num_warmups,
675 - profile: cli.profile,
676 - profile_batch_threshold: cli.profile_batch_threshold,
677 - profile_duration: cli.profile_duration,
678 - save_result: cli.save_result,
679 - save_detailed: cli.save_detailed,
680 - append_result: cli.append_result,
681 - result_dir: cli.result_dir.clone(),
682 - result_filename: cli.result_filename.clone(),
683 - seed: cli.seed,
659 + .unwrap_or(args.prefix_repetition_output_len),
660 + random_cache_hit_fraction: args.random_cache_hit_fraction,
661 + random_cache_ratio: args.random_cache_ratio,
662 + sharegpt_output_len: args.sharegpt_output_len,
663 + sonnet_input_len: args.sonnet_input_len,
664 + sonnet_output_len: args.sonnet_output_len,
665 + sonnet_prefix_len: args.sonnet_prefix_len,
666 + no_oversample: args.no_oversample,
667 + disable_shuffle: args.disable_shuffle,
668 + num_prompts: args.num_prompts,
669 + request_rate: args.request_rate,
670 + burstiness: args.burstiness,
671 + max_concurrency: args.max_concurrency,
672 + steady_state_threshold: args.steady_state_threshold,
673 + steady_state_min_window: args.steady_state_min_window,
674 + no_steady_state: args.no_steady_state,
675 + disable_tqdm: args.disable_tqdm,
676 + num_warmups: args.num_warmups,
677 + profile: args.profile,
678 + profile_batch_threshold: args.profile_batch_threshold,
679 + profile_duration: args.profile_duration,
680 + save_result: args.save_result,
681 + save_detailed: args.save_detailed,
682 + append_result: args.append_result,
683 + result_dir: args.result_dir.clone(),
684 + result_filename: args.result_filename.clone(),
685 + seed: args.seed,
686 ignore_eos,
685 - insecure: cli.insecure,
687 + insecure: args.insecure,
688 selected_percentile_metrics,
689 selected_percentiles,
690 sweep_summary_percentiles,
689 - label: cli.label.clone(),
690 - logprobs: cli.logprobs,
691 - request_id_prefix: cli.get_request_id_prefix(),
692 - ready_check_timeout_sec: cli.ready_check_timeout_sec,
691 + label: args.label.clone(),
692 + logprobs: args.logprobs,
693 + request_id_prefix: args.get_request_id_prefix(),
694 + ready_check_timeout_sec: args.ready_check_timeout_sec,
695 extra_headers,
696 extra_body,
697 metadata,
696 - dry_run: cli.dry_run,
698 + dry_run: args.dry_run,
699 goodput,
700 ramp_up,
699 - multi_turn: cli.multi_turn,
700 - multi_turn_num_turns: cli.multi_turn_num_turns,
701 + multi_turn: args.multi_turn,
702 + multi_turn_num_turns: args.multi_turn_num_turns,
703 multi_turn_min_turns,
704 multi_turn_max_turns,
703 - sharegpt_multi_turn_max_turns: if cli.multi_turn
704 - && cli.dataset_name == DatasetName::ShareGpt
705 - && cli.multi_turn_max_turns != 0
705 + sharegpt_multi_turn_max_turns: if args.multi_turn
706 + && args.dataset_name == DatasetName::ShareGpt
707 + && args.multi_turn_max_turns != 0
708 {
707 - Some(cli.multi_turn_max_turns)
709 + Some(args.multi_turn_max_turns)
710 } else {
711 None
712 },
713 per_turn_input_len,
712 - multi_turn_concurrency: cli.multi_turn_concurrency,
713 - multi_turn_delay_ms: cli.multi_turn_delay_ms,
714 - multi_turn_prefix_global_ratio: cli.multi_turn_prefix_global_ratio,
715 - multi_turn_prefix_conversation_ratio: cli.multi_turn_prefix_conversation_ratio,
716 - speed_bench_config: cli.speed_bench_config,
717 - speed_bench_category: cli.speed_bench_category.clone(),
718 - speed_bench_max_input_len: cli.speed_bench_max_input_len,
719 - hf_split: cli.hf_split.clone(),
720 - hf_subset: cli.hf_subset.clone(),
721 - hf_output_len: cli.hf_output_len,
722 - hf_text_column: cli.hf_text_column.clone(),
723 - reset_prefix_cache: cli.reset_prefix_cache,
724 - prompt_token_ids: cli.prompt_token_ids,
725 - random_mm_base_items_per_request: cli.random_mm_base_items_per_request,
726 - random_mm_num_mm_items_range_ratio: cli.random_mm_num_mm_items_range_ratio,
714 + multi_turn_concurrency: args.multi_turn_concurrency,
715 + multi_turn_delay_ms: args.multi_turn_delay_ms,
716 + multi_turn_prefix_global_ratio: args.multi_turn_prefix_global_ratio,
717 + multi_turn_prefix_conversation_ratio: args.multi_turn_prefix_conversation_ratio,
718 + speed_bench_config: args.speed_bench_config,
719 + speed_bench_category: args.speed_bench_category.clone(),
720 + speed_bench_max_input_len: args.speed_bench_max_input_len,
721 + hf_split: args.hf_split.clone(),
722 + hf_subset: args.hf_subset.clone(),
723 + hf_output_len: args.hf_output_len,
724 + hf_text_column: args.hf_text_column.clone(),
725 + reset_prefix_cache: args.reset_prefix_cache,
726 + prompt_token_ids: args.prompt_token_ids,
727 + random_mm_base_items_per_request: args.random_mm_base_items_per_request,
728 + random_mm_num_mm_items_range_ratio: args.random_mm_num_mm_items_range_ratio,
729 random_mm_limit,
730 random_mm_buckets,
729 - enable_multimodal_chat: cli.enable_multimodal_chat,
731 + enable_multimodal_chat: args.enable_multimodal_chat,
732 lora_modules,
731 - lora_assignment: cli.lora_assignment,
733 + lora_assignment: args.lora_assignment,
734 })
735 }
736 }
@@ -811,17 +813,17 @@ fn parse_goodput(goodput_args: &Option<Vec<String>>) -> Result<GoodputConfig> {
813 Ok(config)
814 }
815
814 -fn parse_ramp_up(cli: &Cli) -> Result<Option<RampUpConfig>> {
815 - let strategy = match cli.ramp_up_strategy {
816 +fn parse_ramp_up(args: &BenchServeArgs) -> Result<Option<RampUpConfig>> {
817 + let strategy = match args.ramp_up_strategy {
818 None => return Ok(None),
819 Some(s) => s,
820 };
821
820 - let start_rps = cli.ramp_up_start_rps.ok_or_else(|| {
822 + let start_rps = args.ramp_up_start_rps.ok_or_else(|| {
823 BenchError::Config("--ramp-up-start-rps is required when --ramp-up-strategy is set".into())
824 })?;
825
824 - let end_rps = cli.ramp_up_end_rps.ok_or_else(|| {
826 + let end_rps = args.ramp_up_end_rps.ok_or_else(|| {
827 BenchError::Config("--ramp-up-end-rps is required when --ramp-up-strategy is set".into())
828 })?;
829
@@ -843,7 +845,21 @@ mod tests {
845 use clap::Parser;
846
847 use super::*;
846 - use crate::cli::Cli;
848 + use crate::cli::BenchServeArgs;
849 +
850 + #[derive(Parser)]
851 + struct TestCli {
852 + #[command(flatten)]
853 + args: BenchServeArgs,
854 + }
855 +
856 + fn parse_args<I, T>(args: I) -> BenchServeArgs
857 + where
858 + I: IntoIterator<Item = T>,
859 + T: Into<std::ffi::OsString> + Clone,
860 + {
861 + TestCli::parse_from(args).args
862 + }
863
864 fn base_multi_turn_args() -> Vec<&'static str> {
865 vec![
@@ -859,8 +875,8 @@ mod tests {
875 #[test]
876 fn test_prefix_sharing_defaults_to_zero() {
877 let args = base_multi_turn_args();
862 - let cli = Cli::parse_from(args);
863 - let config = BenchConfig::from_cli(&cli).unwrap();
878 + let args = parse_args(args);
879 + let config = BenchConfig::from_args(&args).unwrap();
880 assert_eq!(config.multi_turn_prefix_global_ratio, 0.0);
881 assert_eq!(config.multi_turn_prefix_conversation_ratio, 0.0);
882 }
@@ -874,8 +890,8 @@ mod tests {
890 "--multi-turn-prefix-conversation-ratio",
891 "0.8",
892 ]);
877 - let cli = Cli::parse_from(args);
878 - let config = BenchConfig::from_cli(&cli).unwrap();
893 + let args = parse_args(args);
894 + let config = BenchConfig::from_args(&args).unwrap();
895 assert!((config.multi_turn_prefix_global_ratio - 0.1).abs() < 1e-10);
896 assert!((config.multi_turn_prefix_conversation_ratio - 0.8).abs() < 1e-10);
897 }
@@ -889,8 +905,8 @@ mod tests {
905 "--multi-turn-prefix-conversation-ratio",
906 "0.6",
907 ]);
892 - let cli = Cli::parse_from(args);
893 - assert!(BenchConfig::from_cli(&cli).is_err());
908 + let args = parse_args(args);
909 + assert!(BenchConfig::from_args(&args).is_err());
910 }
911
912 #[test]
@@ -902,16 +918,16 @@ mod tests {
918 "--multi-turn-prefix-conversation-ratio",
919 "0.5",
920 ]);
905 - let cli = Cli::parse_from(args);
906 - assert!(BenchConfig::from_cli(&cli).is_err());
921 + let args = parse_args(args);
922 + assert!(BenchConfig::from_args(&args).is_err());
923 }
924
925 #[test]
926 fn test_prefix_sharing_out_of_range_fails() {
927 let mut args = base_multi_turn_args();
928 args.extend(["--multi-turn-prefix-global-ratio", "1.5"]);
913 - let cli = Cli::parse_from(args);
914 - assert!(BenchConfig::from_cli(&cli).is_err());
929 + let args = parse_args(args);
930 + assert!(BenchConfig::from_args(&args).is_err());
931 }
932
933 #[test]
@@ -928,8 +944,8 @@ mod tests {
944 "--multi-turn-prefix-global-ratio",
945 "0.1",
946 ];
931 - let cli = Cli::parse_from(args);
932 - assert!(BenchConfig::from_cli(&cli).is_err());
947 + let args = parse_args(args);
948 + assert!(BenchConfig::from_args(&args).is_err());
949 }
950
951 #[test]
@@ -944,8 +960,8 @@ mod tests {
960 "--dataset-name",
961 "sharegpt",
962 ];
947 - let cli = Cli::parse_from(args);
948 - let config = BenchConfig::from_cli(&cli).unwrap();
963 + let args = parse_args(args);
964 + let config = BenchConfig::from_args(&args).unwrap();
965
966 assert_eq!(config.multi_turn_max_turns, 3);
967 assert_eq!(config.sharegpt_multi_turn_max_turns, None);
@@ -968,8 +984,8 @@ mod tests {
984 "--multi-turn-max-turns",
985 "2",
986 ];
971 - let cli = Cli::parse_from(args);
972 - let config = BenchConfig::from_cli(&cli).unwrap();
987 + let args = parse_args(args);
988 + let config = BenchConfig::from_args(&args).unwrap();
989 assert_eq!(config.sharegpt_multi_turn_max_turns, Some(2));
990 }
991
@@ -987,8 +1003,8 @@ mod tests {
1003 "--multi-turn-max-turns",
1004 "1",
1005 ];
990 - let cli = Cli::parse_from(args);
991 - let err = BenchConfig::from_cli(&cli).unwrap_err().to_string();
1006 + let args = parse_args(args);
1007 + let err = BenchConfig::from_args(&args).unwrap_err().to_string();
1008 assert!(
1009 err.contains("at least 2 for ShareGPT"),
1010 "expected ShareGPT-specific error, got: {err}"
@@ -1009,8 +1025,8 @@ mod tests {
1025 "--multi-turn-max-turns",
1026 "20",
1027 ];
1012 - let cli = Cli::parse_from(args);
1013 - let config = BenchConfig::from_cli(&cli).unwrap();
1028 + let args = parse_args(args);
1029 + let config = BenchConfig::from_args(&args).unwrap();
1030
1031 assert_eq!(config.sharegpt_multi_turn_max_turns, Some(20));
1032 }
@@ -1018,8 +1034,8 @@ mod tests {
1034 #[test]
1035 fn test_sweep_summary_percentiles_default_empty() {
1036 let args = base_multi_turn_args();
1021 - let cli = Cli::parse_from(args);
1022 - let config = BenchConfig::from_cli(&cli).unwrap();
1037 + let args = parse_args(args);
1038 + let config = BenchConfig::from_args(&args).unwrap();
1039
1040 assert!(config.sweep_summary_percentiles.is_empty());
1041 assert_eq!(config.selected_percentiles, vec![99.0, 90.0]);
@@ -1034,8 +1050,8 @@ mod tests {
1050 "--sweep-summary-percentiles",
1051 "90,95,90",
1052 ]);
1037 - let cli = Cli::parse_from(args);
1038 - let config = BenchConfig::from_cli(&cli).unwrap();
1053 + let args = parse_args(args);
1054 + let config = BenchConfig::from_args(&args).unwrap();
1055
1056 assert_eq!(config.sweep_summary_percentiles, vec![90.0, 95.0]);
1057 assert_eq!(config.selected_percentiles, vec![99.0, 95.0, 90.0]);
@@ -1045,8 +1061,8 @@ mod tests {
1061 fn test_invalid_sweep_summary_percentile_fails() {
1062 let mut args = base_multi_turn_args();
1063 args.extend(["--sweep-summary-percentiles", "101"]);
1048 - let cli = Cli::parse_from(args);
1049 - assert!(BenchConfig::from_cli(&cli).is_err());
1064 + let args = parse_args(args);
1065 + assert!(BenchConfig::from_args(&args).is_err());
1066 }
1067
1068 #[test]
@@ -1058,12 +1074,20 @@ mod tests {
1074 "--max-model-len",
1075 "4096",
1076 ];
1061 - let cli = Cli::parse_from(args);
1062 - let config = BenchConfig::from_cli(&cli).unwrap();
1077 + let args = parse_args(args);
1078 + let config = BenchConfig::from_args(&args).unwrap();
1079
1080 assert_eq!(config.max_model_len, Some(4096));
1081 }
1082
1083 + #[test]
1084 + fn test_tokenizer_id_deferred_when_model_is_unspecified() {
1085 + let args = parse_args(["vllm-bench"]);
1086 + let config = BenchConfig::from_args(&args).unwrap();
1087 +
1088 + assert_eq!(config.tokenizer_id, None);
1089 + }
1090 +
1091 #[test]
1092 fn test_zero_max_model_len_fails() {
1093 let args = vec![
@@ -1073,9 +1097,9 @@ mod tests {
1097 "--max-model-len",
1098 "0",
1099 ];
1076 - let cli = Cli::parse_from(args);
1100 + let args = parse_args(args);
1101
1078 - assert!(BenchConfig::from_cli(&cli).is_err());
1102 + assert!(BenchConfig::from_args(&args).is_err());
1103 }
1104 #[test]
1105 fn test_range_ratio_parse_float() {
rust/src/bench/src/hub.rs
+5 -2
@@ -40,8 +40,11 @@ impl HubRepo {
40 .build()
41 .map_err(|e| format!("Failed to build download runtime: {e}"))?;
42 rt.block_on(async move {
43 - let api = hf_hub::api::tokio::Api::new()
44 - .map_err(|e| format!("Failed to init HF API: {e}"))?;
43 + let mut builder = hf_hub::api::tokio::ApiBuilder::from_env();
44 + if let Ok(token) = std::env::var("HF_TOKEN") {
45 + builder = builder.with_token(Some(token));
46 + }
47 + let api = builder.build().map_err(|e| format!("Failed to init HF API: {e}"))?;
48 api.repo(repo).get(&filename).await.map_err(|e| format!("{e}"))
49 })
50 })
rust/src/bench/src/lib.rs new
+86
@@ -0,0 +1,86 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +mod backends;
5 +mod benchmark;
6 +mod cli;
7 +mod compare;
8 +mod config;
9 +mod datasets;
10 +mod error;
11 +mod hub;
12 +mod metrics;
13 +mod multi_run;
14 +mod multi_turn;
15 +mod output;
16 +mod rate_control;
17 +mod ready_checker;
18 +mod sweep;
19 +mod tiktoken;
20 +mod tokenizer;
21 +
22 +use anyhow::Context;
23 +
24 +pub use cli::{
25 + BackendKind, BenchServeArgs, DatasetName, LoraAssignment, RampUpStrategy, SpeedBenchConfig,
26 +};
27 +use config::BenchConfig;
28 +
29 +/// Prepare process-wide resources for a benchmark run.
30 +pub fn prepare_process() {
31 + // Raise the open-file soft limit to the hard limit. High-concurrency
32 + // benchmarks (1024+ requests) easily exceed the default 1024 fd soft limit.
33 + if let Ok(new) = rlimit::increase_nofile_limit(u64::MAX)
34 + && new > 1024
35 + {
36 + eprintln!("Open-file limit: {new}");
37 + }
38 +}
39 +
40 +/// Run the online serving benchmark.
41 +pub async fn run(args: BenchServeArgs) -> anyhow::Result<()> {
42 + // --- Compare mode: no server needed, just diff two JSON files ---
43 + if let Some(ref files) = args.compare {
44 + return compare::compare_results(&files[0], &files[1]).context("Comparison failed");
45 + }
46 +
47 + let config = BenchConfig::from_args(&args).context("Configuration error")?;
48 +
49 + async {
50 + if config.multi_turn {
51 + if let Some(ref sweep_mc) = args.sweep_max_concurrency {
52 + // --- Sweep over concurrency in multi-turn mode ---
53 + let values = sweep::parse_concurrency_values(sweep_mc)
54 + .context("Invalid --sweep-max-concurrency")?;
55 + sweep::run_multi_turn_concurrency_sweep(
56 + &config,
57 + &values,
58 + args.sweep_num_prompts_factor,
59 + )
60 + .await?;
61 + } else {
62 + // --- Single multi-turn conversation benchmark ---
63 + multi_turn::run_multi_turn_benchmark(&config).await?;
64 + }
65 + } else if let Some(ref sweep_mc) = args.sweep_max_concurrency {
66 + // --- Sweep over max-concurrency ---
67 + let values = sweep::parse_concurrency_values(sweep_mc)
68 + .context("Invalid --sweep-max-concurrency")?;
69 + sweep::run_concurrency_sweep(&config, &values, args.sweep_num_prompts_factor).await?;
70 + } else if let Some(ref sweep_rate) = args.sweep_request_rate {
71 + // --- Sweep over request-rate ---
72 + let values =
73 + sweep::parse_rate_values(sweep_rate).context("Invalid --sweep-request-rate")?;
74 + sweep::run_rate_sweep(&config, &values).await?;
75 + } else if args.num_runs > 1 {
76 + // --- Multi-run with statistical aggregation ---
77 + multi_run::run_multi(&config, args.num_runs).await?;
78 + } else {
79 + // --- Normal single benchmark ---
80 + benchmark::run_benchmark(&config).await?;
81 + }
82 + anyhow::Ok(())
83 + }
84 + .await
85 + .context("Benchmark failed")
86 +}
rust/src/bench/src/main.rs
+14 -74
@@ -1,92 +1,32 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -mod backends;
5 -mod benchmark;
6 -mod cli;
7 -mod compare;
8 -mod config;
9 -mod datasets;
10 -mod error;
11 -mod hub;
12 -mod metrics;
13 -mod multi_run;
14 -mod multi_turn;
15 -mod output;
16 -mod rate_control;
17 -mod ready_checker;
18 -mod sweep;
19 -mod tiktoken;
20 -mod tokenizer;
21 -
4 #[cfg(not(target_env = "msvc"))]
5 #[global_allocator]
6 static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
7
8 use anyhow::Context;
9 use clap::Parser;
28 -use cli::Cli;
29 -use config::BenchConfig;
10
31 -fn main() -> anyhow::Result<()> {
32 - // Raise the open-file soft limit to the hard limit. High-concurrency
33 - // benchmarks (1024+ requests) easily exceed the default 1024 fd soft limit.
34 - if let Ok(new) = rlimit::increase_nofile_limit(u64::MAX)
35 - && new > 1024
36 - {
37 - eprintln!("Open-file limit: {new}");
38 - }
11 +#[derive(Parser)]
12 +#[command(
13 + name = "vllm-bench",
14 + about = "Benchmark online serving throughput",
15 + version
16 +)]
17 +struct Cli {
18 + #[command(flatten)]
19 + args: vllm_bench::BenchServeArgs,
20 +}
21
22 +fn main() -> anyhow::Result<()> {
23 let cli = Cli::parse();
41 -
42 - // --- Compare mode: no server needed, just diff two JSON files ---
43 - if let Some(ref files) = cli.compare {
44 - return compare::compare_results(&files[0], &files[1]).context("Comparison failed");
45 - }
46 -
47 - let config = BenchConfig::from_cli(&cli).context("Configuration error")?;
24 + vllm_bench::prepare_process();
25
26 let runtime = tokio::runtime::Builder::new_multi_thread()
27 .enable_all()
28 .build()
52 - .expect("Failed to build tokio runtime");
29 + .context("Failed to build tokio runtime")?;
30
54 - runtime
55 - .block_on(async {
56 - if config.multi_turn {
57 - if let Some(ref sweep_mc) = cli.sweep_max_concurrency {
58 - // --- Sweep over concurrency in multi-turn mode ---
59 - let values = sweep::parse_concurrency_values(sweep_mc)
60 - .context("Invalid --sweep-max-concurrency")?;
61 - sweep::run_multi_turn_concurrency_sweep(
62 - &config,
63 - &values,
64 - cli.sweep_num_prompts_factor,
65 - )
66 - .await?;
67 - } else {
68 - // --- Single multi-turn conversation benchmark ---
69 - multi_turn::run_multi_turn_benchmark(&config).await?;
70 - }
71 - } else if let Some(ref sweep_mc) = cli.sweep_max_concurrency {
72 - // --- Sweep over max-concurrency ---
73 - let values = sweep::parse_concurrency_values(sweep_mc)
74 - .context("Invalid --sweep-max-concurrency")?;
75 - sweep::run_concurrency_sweep(&config, &values, cli.sweep_num_prompts_factor)
76 - .await?;
77 - } else if let Some(ref sweep_rate) = cli.sweep_request_rate {
78 - // --- Sweep over request-rate ---
79 - let values =
80 - sweep::parse_rate_values(sweep_rate).context("Invalid --sweep-request-rate")?;
81 - sweep::run_rate_sweep(&config, &values).await?;
82 - } else if cli.num_runs > 1 {
83 - // --- Multi-run with statistical aggregation ---
84 - multi_run::run_multi(&config, cli.num_runs).await?;
85 - } else {
86 - // --- Normal single benchmark ---
87 - benchmark::run_benchmark(&config).await?;
88 - }
89 - anyhow::Ok(())
90 - })
91 - .context("Benchmark failed")
31 + runtime.block_on(vllm_bench::run(cli.args))
32 }
rust/src/cmd/Cargo.toml
+1
@@ -29,6 +29,7 @@ tokio-util.workspace = true
29 tracing.workspace = true
30 tracing-subscriber.workspace = true
31 uuid.workspace = true
32 +vllm-bench.workspace = true
33 vllm-chat.workspace = true
34 vllm-engine-core-client.workspace = true
35 vllm-managed-engine.workspace = true
rust/src/cmd/src/cli.rs
+11 -1
@@ -79,13 +79,23 @@ impl Cli {
79 }
80
81 /// Supported top-level CLI commands.
82 -#[derive(Debug, Subcommand, PartialEq, Eq)]
82 +#[derive(Debug, Subcommand)]
83 pub enum Command {
84 /// Run the Rust OpenAI frontend as a Python-supervised worker.
85 Frontend(FrontendArgs),
86 /// Launch a managed Python headless engine, then run the Rust OpenAI
87 /// frontend.
88 Serve(ServeArgs),
89 + /// Run vLLM benchmarks.
90 + #[command(subcommand)]
91 + Bench(BenchCommand),
92 +}
93 +
94 +/// Supported benchmark commands.
95 +#[derive(Debug, Subcommand)]
96 +pub enum BenchCommand {
97 + /// Benchmark online serving throughput.
98 + Serve(vllm_bench::BenchServeArgs),
99 }
100
101 /// A JSON-encoded list of strings, matching Python's `json.loads` CLI type for
rust/src/cmd/src/cli/tests.rs
+21 -1
@@ -5,7 +5,27 @@ use expect_test::expect;
5 use vllm_engine_core_client::TransportMode;
6 use vllm_server::{Config, HttpListenerMode, ParserSelection, RendererSelection};
7
8 -use super::{Cli, Command};
8 +use super::{BenchCommand, Cli, Command};
9 +
10 +#[test]
11 +fn bench_serve_args_parse_without_managed_engine_repartition() {
12 + let cli = Cli::try_parse_from([
13 + "vllm-rs",
14 + "bench",
15 + "serve",
16 + "--backend",
17 + "openai-chat",
18 + "--request-rate",
19 + "inf",
20 + ])
21 + .unwrap();
22 +
23 + let Command::Bench(BenchCommand::Serve(args)) = cli.command else {
24 + panic!("expected bench serve args");
25 + };
26 + assert_eq!(args.backend, vllm_bench::BackendKind::OpenaiChat);
27 + assert!(args.request_rate.is_infinite());
28 +}
29
30 #[test]
31 fn serve_args_forward_python_flags_with_separator() {
rust/src/cmd/src/main.rs
+5 -1
@@ -12,7 +12,7 @@ use tokio_util::sync::CancellationToken;
12 use tracing::{info, warn};
13 use vllm_managed_engine::ManagedEngineHandle;
14
15 -use crate::cli::{Cli, Command};
15 +use crate::cli::{BenchCommand, Cli, Command};
16
17 #[global_allocator]
18 static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
@@ -100,6 +100,10 @@ fn main() -> Result<()> {
100 async fn async_main(cli: Cli) -> Result<()> {
101 match cli.command {
102 Command::Frontend(args) => vllm_server::serve(args.into_config(), shutdown_signal()).await,
103 + Command::Bench(BenchCommand::Serve(bench_args)) => {
104 + vllm_bench::prepare_process();
105 + vllm_bench::run(bench_args).await
106 + }
107 Command::Serve(args) => {
108 let handshake_port = args.managed_engine.resolve_handshake_port()?;
109
vllm/entrypoints/cli/benchmark/serve.py
+60 -2
@@ -1,11 +1,68 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import argparse
4 +import os
5 +import sys
6 +from pathlib import Path
7
5 -from vllm.benchmarks.serve import add_cli_args, main
8 +from vllm.benchmarks.serve import add_cli_args
9 +from vllm.benchmarks.serve import main as python_main
10 from vllm.entrypoints.cli.benchmark.base import BenchmarkSubcommandBase
11 +from vllm.logger import init_logger
12 from vllm.utils.argparse_utils import FlexibleArgumentParser
13
14 +logger = init_logger(__name__)
15 +_RUST_CLI_PATH = Path(__file__).resolve().parents[3] / "vllm-rs"
16 +_RUST_SUPPORTED_DATASETS = frozenset(
17 + {
18 + "custom",
19 + "hf",
20 + "prefix_repetition",
21 + "random",
22 + "random-mm",
23 + "random-rerank",
24 + "sharegpt",
25 + "sonnet",
26 + "speed_bench",
27 + }
28 +)
29 +_RUST_SUPPORTED_BACKENDS = frozenset(
30 + {
31 + "openai",
32 + "openai-chat",
33 + "openai-embeddings",
34 + "openai-embeddings-chat",
35 + "vllm",
36 + "vllm-pooling",
37 + "vllm-rerank",
38 + }
39 +)
40 +
41 +
42 +def _rust_unsupported_reason(args: argparse.Namespace) -> str | None:
43 + if args.dataset_name not in _RUST_SUPPORTED_DATASETS:
44 + return f"dataset {args.dataset_name!r} is not supported by the Rust benchmark"
45 + if args.backend not in _RUST_SUPPORTED_BACKENDS:
46 + return f"backend {args.backend!r} is not supported by the Rust benchmark"
47 + return None
48 +
49 +
50 +def _maybe_exec_rust_bench(args: argparse.Namespace) -> None:
51 + if reason := _rust_unsupported_reason(args):
52 + logger.info("Using Python benchmark: %s.", reason)
53 + return
54 +
55 + if not _RUST_CLI_PATH.is_file():
56 + logger.warning(
57 + "Rust benchmark binary not found at %s; falling back to Python.",
58 + _RUST_CLI_PATH,
59 + )
60 + return
61 +
62 + rust_cli = str(_RUST_CLI_PATH)
63 + logger.info("Delegating `vllm bench serve` to Rust binary at %s.", rust_cli)
64 + os.execv(rust_cli, [rust_cli, "bench", "serve", *sys.argv[3:]])
65 +
66
67 class BenchmarkServingSubcommand(BenchmarkSubcommandBase):
68 """The `serve` subcommand for `vllm bench`."""
@@ -19,4 +76,5 @@ class BenchmarkServingSubcommand(BenchmarkSubcommandBase):
76
77 @staticmethod
78 def cmd(args: argparse.Namespace) -> None:
22 - main(args)
79 + _maybe_exec_rust_bench(args)
80 + python_main(args)