feat(rust): expose profiler control routes in Rust frontend (#46306)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Pranav Thakur committed Jul 2, 2026 at 14:16 UTC 08a8a4af3fb904ab809c6347b785407f180ccf22
11 files changed +381 -2
rust/src/cmd/src/cli.rs
+47 -1
@@ -13,8 +13,8 @@ use std::time::Duration;
13
14 use clap::{Args, Parser, Subcommand};
15 use educe::Educe;
16 -use serde::Deserialize;
16 use serde::de::DeserializeOwned;
17 +use serde::{Deserialize, Serialize};
18 use serde_json::Value;
19 use serde_with::{DefaultOnNull, OneOrMany, serde_as};
20 use thiserror_ext::AsReport as _;
@@ -290,6 +290,15 @@ pub struct SharedRuntimeArgs {
290 #[serde(default)]
291 pub ssl_ciphers: Option<String>,
292
293 + /// Profiler configuration forwarded by the Python supervisor.
294 + ///
295 + /// When set with a non-null `profiler` type, the Rust frontend registers
296 + /// the `/start_profile` and `/stop_profile` routes and forwards calls to
297 + /// the engine via the `"profile"` utility RPC.
298 + #[arg(long, value_parser = parse_json::<ProfilerConfig>, value_name = "JSON")]
299 + #[serde(default)]
300 + pub profiler_config: Option<ProfilerConfig>,
301 +
302 /// Unsupported Python vLLM frontend arguments recognized but not yet
303 /// implemented in Rust.
304 #[educe(Debug(ignore))]
@@ -317,6 +326,20 @@ impl SharedRuntimeArgs {
326 .map_or(DEFAULT_KEEP_ALIVE_TIMEOUT, Duration::from_secs)
327 }
328
329 + /// Return the configured profiler mode, when profiling is enabled.
330 + pub fn profiler(&self) -> Option<String> {
331 + self.profiler_config.as_ref().and_then(|c| c.profiler.clone())
332 + }
333 +
334 + /// Return the profiler config JSON for managed Python engine forwarding.
335 + pub fn profiler_config_json(&self) -> Option<String> {
336 + self.profiler_config
337 + .as_ref()
338 + .map(serde_json::to_string)
339 + .transpose()
340 + .expect("profiler config serialization should not fail")
341 + }
342 +
343 /// Apply fallback logic for API key configuration from env variables.
344 fn apply_env_api_key_fallback(&mut self) {
345 if self.api_key.is_empty()
@@ -345,6 +368,7 @@ impl SharedRuntimeArgs {
368 let api_server_options = self.api_server_options();
369 let cors = self.cors_config();
370 let tls = self.tls_config();
371 + let profiler = self.profiler();
372
373 Config {
374 transport_mode: TransportMode::Bootstrapped {
@@ -377,6 +401,7 @@ impl SharedRuntimeArgs {
401 grpc_port: self.grpc_port,
402 shutdown_timeout,
403 keep_alive_timeout,
404 + profiler,
405 }
406 }
407
@@ -397,6 +422,7 @@ impl SharedRuntimeArgs {
422 let api_server_options = self.api_server_options();
423 let cors = self.cors_config();
424 let tls = self.tls_config();
425 + let profiler = self.profiler();
426
427 Config {
428 transport_mode: TransportMode::HandshakeOwner {
@@ -427,6 +453,7 @@ impl SharedRuntimeArgs {
453 grpc_port: self.grpc_port,
454 shutdown_timeout,
455 keep_alive_timeout,
456 + profiler,
457 }
458 }
459
@@ -477,6 +504,23 @@ fn default_py_bootstrap_parser_selection() -> ParserSelection {
504 ParserSelection::None
505 }
506
507 +/// Minimal profiler configuration parsed from `--profiler-config`.
508 +///
509 +/// Only the `profiler` field is inspected by the Rust frontend to decide
510 +/// whether to register the `/start_profile` and `/stop_profile` routes.
511 +/// All other fields are accepted but ignored — they are consumed by the
512 +/// Python engine layer.
513 +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
514 +pub struct ProfilerConfig {
515 + /// Profiler backend type (e.g. `"torch"`, `"cuda"`). When `null` or
516 + /// absent, profiling is disabled.
517 + #[serde(default)]
518 + pub profiler: Option<String>,
519 + /// Additional Python profiler config fields consumed by the engine layer.
520 + #[serde(flatten)]
521 + pub extra: serde_json::Map<String, Value>,
522 +}
523 +
524 fn parse_json<T: DeserializeOwned>(value: &str) -> Result<T, String> {
525 serde_json::from_str(value).map_err(|e| format!("invalid JSON object: {}", e.as_report()))
526 }
@@ -603,11 +647,13 @@ impl ServeArgs {
647 pub fn to_managed_engine_config(&self, handshake_port: u16) -> ManagedEngineConfig {
648 let reasoning_parser =
649 effective_engine_reasoning_parser(&self.runtime.reasoning_parser, &self.runtime.model);
650 + let profiler_config = self.runtime.profiler_config_json();
651
652 self.managed_engine.clone().into_config(
653 self.runtime.model.clone(),
654 self.runtime.max_model_len,
655 self.runtime.max_logprobs,
656 + profiler_config,
657 reasoning_parser.as_deref(),
658 self.runtime.language_model_only,
659 self.runtime.disable_log_stats,
rust/src/cmd/src/cli/tests.rs
+112
@@ -71,6 +71,7 @@ fn serve_args_forward_python_flags_with_separator() {
71 ssl_ca_certs: None,
72 ssl_cert_reqs: 0,
73 ssl_ciphers: None,
74 + profiler_config: None,
75 },
76 managed_engine: ManagedEngineArgs {
77 python: "../vllm/.venv/bin/python",
@@ -180,6 +181,40 @@ fn serve_args_forward_disable_log_stats_to_managed_engine() {
181 .assert_debug_eq(&config.python_args);
182 }
183
184 +#[test]
185 +fn serve_args_forward_profiler_config_to_managed_engine() {
186 + let cli = Cli::try_parse_from([
187 + "vllm-rs",
188 + "serve",
189 + "Qwen/Qwen3-0.6B",
190 + "--profiler-config",
191 + r#"{"profiler":"torch","torch_profiler_dir":"/tmp/profile"}"#,
192 + ])
193 + .unwrap();
194 +
195 + let Command::Serve(args) = cli.command else {
196 + panic!("expected serve args");
197 + };
198 + assert_eq!(args.runtime.profiler().as_deref(), Some("torch"));
199 +
200 + let config = args.to_managed_engine_config(5555);
201 + let profiler_flag_index = config
202 + .python_args
203 + .iter()
204 + .position(|arg| arg == "--profiler-config")
205 + .expect("profiler config flag");
206 + let profiler_config: serde_json::Value =
207 + serde_json::from_str(&config.python_args[profiler_flag_index + 1])
208 + .expect("profiler config json");
209 + assert_eq!(
210 + profiler_config,
211 + serde_json::json!({
212 + "profiler": "torch",
213 + "torch_profiler_dir": "/tmp/profile",
214 + })
215 + );
216 +}
217 +
218 #[test]
219 fn serve_args_forward_max_logprobs_to_frontend_and_managed_engine() {
220 let cli = Cli::try_parse_from([
@@ -732,6 +767,7 @@ fn frontend_args_accept_json() {
767 ssl_ca_certs: None,
768 ssl_cert_reqs: 0,
769 ssl_ciphers: None,
770 + profiler_config: None,
771 },
772 },
773 ),
@@ -1253,6 +1289,7 @@ fn serve_args_accept_handshake_aliases() {
1289 ssl_ca_certs: None,
1290 ssl_cert_reqs: 0,
1291 ssl_ciphers: None,
1292 + profiler_config: None,
1293 },
1294 managed_engine: ManagedEngineArgs {
1295 python: "python3",
@@ -1392,6 +1429,7 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
1429 grpc_port: None,
1430 shutdown_timeout: 0ns,
1431 keep_alive_timeout: 5s,
1432 + profiler: None,
1433 }
1434 "#]]
1435 .assert_debug_eq(&Config {
@@ -1475,6 +1513,7 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
1513 grpc_port: None,
1514 shutdown_timeout: 0ns,
1515 keep_alive_timeout: 5s,
1516 + profiler: None,
1517 }
1518 "#]]
1519 .assert_debug_eq(&config);
@@ -1576,6 +1615,7 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
1615 grpc_port: None,
1616 shutdown_timeout: 0ns,
1617 keep_alive_timeout: 5s,
1618 + profiler: None,
1619 }
1620 "#]]
1621 .assert_debug_eq(&config);
@@ -1604,3 +1644,75 @@ fn serve_frontend_config_uses_unix_listener_when_uds_is_present() {
1644 }
1645 );
1646 }
1647 +
1648 +#[test]
1649 +fn frontend_args_json_enables_profiling_when_profiler_config_set() {
1650 + let cli = Cli::try_parse_from([
1651 + "vllm-rs",
1652 + "frontend",
1653 + "--listen-fd",
1654 + "3",
1655 + "--input-address",
1656 + "ipc:///tmp/input.sock",
1657 + "--output-address",
1658 + "ipc:///tmp/output.sock",
1659 + "--args-json",
1660 + r#"{"model_tag":"Qwen/Qwen3-0.6B","profiler_config":{"profiler":"torch","torch_profiler_dir":"/tmp/profile"}}"#,
1661 + ])
1662 + .unwrap();
1663 +
1664 + let Command::Frontend(args) = cli.command else {
1665 + panic!("expected frontend args");
1666 + };
1667 + assert_eq!(args.runtime.profiler().as_deref(), Some("torch"));
1668 + let config = args.into_config();
1669 + assert_eq!(config.profiler.as_deref(), Some("torch"));
1670 +}
1671 +
1672 +#[test]
1673 +fn frontend_args_json_disables_profiling_when_profiler_config_absent() {
1674 + let cli = Cli::try_parse_from([
1675 + "vllm-rs",
1676 + "frontend",
1677 + "--listen-fd",
1678 + "3",
1679 + "--input-address",
1680 + "ipc:///tmp/input.sock",
1681 + "--output-address",
1682 + "ipc:///tmp/output.sock",
1683 + "--args-json",
1684 + r#"{"model_tag":"Qwen/Qwen3-0.6B"}"#,
1685 + ])
1686 + .unwrap();
1687 +
1688 + let Command::Frontend(args) = cli.command else {
1689 + panic!("expected frontend args");
1690 + };
1691 + assert_eq!(args.runtime.profiler(), None);
1692 + let config = args.into_config();
1693 + assert_eq!(config.profiler, None);
1694 +}
1695 +
1696 +#[test]
1697 +fn frontend_args_json_disables_profiling_when_profiler_type_is_null() {
1698 + let cli = Cli::try_parse_from([
1699 + "vllm-rs",
1700 + "frontend",
1701 + "--listen-fd",
1702 + "3",
1703 + "--input-address",
1704 + "ipc:///tmp/input.sock",
1705 + "--output-address",
1706 + "ipc:///tmp/output.sock",
1707 + "--args-json",
1708 + r#"{"model_tag":"Qwen/Qwen3-0.6B","profiler_config":{"profiler":null}}"#,
1709 + ])
1710 + .unwrap();
1711 +
1712 + let Command::Frontend(args) = cli.command else {
1713 + panic!("expected frontend args");
1714 + };
1715 + assert_eq!(args.runtime.profiler(), None);
1716 + let config = args.into_config();
1717 + assert_eq!(config.profiler, None);
1718 +}
rust/src/engine-core-client/src/client.rs
+12
@@ -738,6 +738,18 @@ impl EngineCoreClient {
738 self.call_utility_consensus("is_scheduler_paused", ()).await
739 }
740
741 + /// Start profiling the engine.
742 + pub async fn start_profile(&self, profile_prefix: Option<&str>) -> Result<()> {
743 + self.call_utility::<(), _>("profile", (true, profile_prefix)).await?;
744 + Ok(())
745 + }
746 +
747 + /// Stop profiling the engine.
748 + pub async fn stop_profile(&self, profile_prefix: Option<&str>) -> Result<()> {
749 + self.call_utility::<(), _>("profile", (false, profile_prefix)).await?;
750 + Ok(())
751 + }
752 +
753 /// Shut down local client tasks and close transport state.
754 pub async fn shutdown(self) -> Result<()> {
755 let Self {
rust/src/managed-engine/src/cli.rs
+5
@@ -77,6 +77,7 @@ impl ManagedEngineArgs {
77 model: String,
78 max_model_len: Option<u32>,
79 max_logprobs: Option<i32>,
80 + profiler_config: Option<String>,
81 reasoning_parser: Option<&str>,
82 language_model_only: bool,
83 disable_log_stats: bool,
@@ -93,6 +94,10 @@ impl ManagedEngineArgs {
94 python_args.push("--max-logprobs".to_string());
95 python_args.push(max_logprobs.to_string());
96 }
97 + if let Some(profiler_config) = profiler_config {
98 + python_args.push("--profiler-config".to_string());
99 + python_args.push(profiler_config);
100 + }
101 if let Some(reasoning_parser) = reasoning_parser {
102 python_args.push("--reasoning-parser".to_string());
103 python_args.push(reasoning_parser.to_string());
rust/src/server/examples/external_engine_openai_qwen.rs
+1
@@ -77,6 +77,7 @@ async fn main() -> Result<()> {
77 grpc_port: None,
78 shutdown_timeout: Duration::ZERO,
79 keep_alive_timeout: Duration::from_secs(5),
80 + profiler: None,
81 };
82
83 let bind_address = format!("127.0.0.1:{port}");
rust/src/server/src/config.rs
+3
@@ -208,6 +208,9 @@ pub struct Config {
208 /// Maximum idle time on a keep-alive HTTP connection before the server
209 /// closes it (`VLLM_HTTP_TIMEOUT_KEEP_ALIVE`, default 5s).
210 pub keep_alive_timeout: Duration,
211 + /// Profiler mode that registers `/start_profile` and `/stop_profile`
212 + /// routes when present.
213 + pub profiler: Option<String>,
214 }
215
216 impl Config {
rust/src/server/src/lib.rs
+2 -1
@@ -134,7 +134,8 @@ async fn build_state(config: &Config) -> Result<Arc<AppState>> {
134 .with_api_server_options(config.api_server_options)
135 .with_server_info(ServerInfoSnapshot::from_config(config))
136 .with_api_keys(config.api_keys.clone())
137 - .with_cors(config.cors.clone()),
137 + .with_cors(config.cors.clone())
138 + .with_profiler(config.profiler.clone()),
139 ))
140 }
141
rust/src/server/src/routes.rs
+13
@@ -8,6 +8,7 @@ mod lora;
8 mod metrics;
9 pub(crate) mod openai;
10 mod pause;
11 +mod profile;
12 mod server_info;
13 mod sleep;
14 mod tokenize;
@@ -21,6 +22,7 @@ use axum::extract::DefaultBodyLimit;
22 use axum::middleware::{from_fn, from_fn_with_state};
23 use axum::routing::{get, post};
24 use tower_http::trace::TraceLayer;
25 +use tracing::warn;
26
27 use crate::middleware;
28 use crate::state::AppState;
@@ -107,6 +109,17 @@ fn build_router_with_options(
109 .route("/get_world_size", get(world_size::get_world_size))
110 }
111
112 + if let Some(profiler) = &state.profiler {
113 + warn!(
114 + mode = profiler,
115 + "profiler is enabled in the API server; \
116 + this should only be used for local development",
117 + );
118 + router = router
119 + .route("/start_profile", post(profile::start_profile))
120 + .route("/stop_profile", post(profile::stop_profile));
121 + }
122 +
123 let enable_request_id_headers = state.api_server_options.enable_request_id_headers;
124 let enable_api_key_auth = state.has_api_keys();
125 let mut router = router
rust/src/server/src/routes/profile.rs new
+33
@@ -0,0 +1,33 @@
1 +use std::sync::Arc;
2 +
3 +use axum::extract::State;
4 +use axum::http::StatusCode;
5 +use tracing::info;
6 +
7 +use crate::error::ApiError;
8 +use crate::state::AppState;
9 +use crate::utils::utility_call_error;
10 +
11 +/// Start profiling the engine.
12 +pub async fn start_profile(State(state): State<Arc<AppState>>) -> Result<StatusCode, ApiError> {
13 + info!("starting profiler");
14 + state
15 + .engine_core_client()
16 + .start_profile(None)
17 + .await
18 + .map_err(|error| utility_call_error("start_profile", error))?;
19 + info!("profiler started");
20 + Ok(StatusCode::OK)
21 +}
22 +
23 +/// Stop profiling the engine.
24 +pub async fn stop_profile(State(state): State<Arc<AppState>>) -> Result<StatusCode, ApiError> {
25 + info!("stopping profiler");
26 + state
27 + .engine_core_client()
28 + .stop_profile(None)
29 + .await
30 + .map_err(|error| utility_call_error("stop_profile", error))?;
31 + info!("profiler stopped");
32 + Ok(StatusCode::OK)
33 +}
rust/src/server/src/routes/tests.rs
+143
@@ -6183,3 +6183,146 @@ async fn world_size_excludes_data_parallelism_when_include_dp_false() {
6183 let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
6184 assert_eq!(json, json!({"world_size": 2}));
6185 }
6186 +
6187 +// ========================= Profiler route tests =========================
6188 +
6189 +async fn test_profiling_app_with_engine_script<F>(script: F) -> (axum::Router, MockEngineTask)
6190 +where
6191 + F: for<'a> FnOnce(&'a mut DealerSocket, &'a mut PushSocket) -> TestFuture<'a> + Send + 'static,
6192 +{
6193 + let ipc = IpcNamespace::new().expect("create ipc namespace");
6194 + let handshake_address = ipc.handshake_endpoint();
6195 + let engine_id = b"engine-openai-profiler".to_vec();
6196 +
6197 + let engine_task = MockEngineTask::new(spawn_mock_engine_task(
6198 + handshake_address.clone(),
6199 + engine_id.clone(),
6200 + move |dealer, push| script(dealer, push),
6201 + ));
6202 +
6203 + let client = EngineCoreClient::connect(
6204 + EngineCoreClientConfig::new_single(handshake_address)
6205 + .with_model_name("test-model")
6206 + .with_local_input_output_addresses(
6207 + Some(ipc.input_endpoint()),
6208 + Some(ipc.output_endpoint()),
6209 + ),
6210 + )
6211 + .await
6212 + .expect("connect client");
6213 +
6214 + let chat = ChatLlm::from_shared_backend(test_llm(client), Arc::new(FakeChatBackend::new()));
6215 + (
6216 + build_router(Arc::new(
6217 + AppState::new(vec!["test-model".to_string()], chat)
6218 + .with_profiler(Some("torch".to_string())),
6219 + )),
6220 + engine_task,
6221 + )
6222 +}
6223 +
6224 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6225 +#[serial]
6226 +async fn start_profile_route_sends_expected_utility_call() {
6227 + let (app, engine_task) = test_profiling_app_with_engine_script(|dealer, push| {
6228 + boxed_test_future(async move {
6229 + let utility = recv_engine_message(dealer).await;
6230 + assert_eq!(utility[0].as_ref(), &[0x03]);
6231 +
6232 + let payload = decode_value(&utility[1]).expect("decode utility payload");
6233 + let array = payload.as_array().expect("utility payload array");
6234 + let call_id = array[1].as_u64().expect("call id");
6235 +
6236 + assert_eq!(array[2], Value::from("profile"));
6237 + assert_eq!(array[3], Value::Array(vec![Value::from(true), Value::Nil]));
6238 +
6239 + send_outputs(push, utility_outputs(call_id, utility_none_result())).await;
6240 + })
6241 + })
6242 + .await;
6243 +
6244 + let response = app
6245 + .clone()
6246 + .call(
6247 + Request::builder()
6248 + .method("POST")
6249 + .uri("/start_profile")
6250 + .body(Body::empty())
6251 + .expect("build request"),
6252 + )
6253 + .await
6254 + .expect("call app");
6255 +
6256 + let status = response.status();
6257 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
6258 + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
6259 + assert!(body.is_empty());
6260 + engine_task.await.expect("mock engine task");
6261 +}
6262 +
6263 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6264 +#[serial]
6265 +async fn stop_profile_route_sends_expected_utility_call() {
6266 + let (app, engine_task) = test_profiling_app_with_engine_script(|dealer, push| {
6267 + boxed_test_future(async move {
6268 + let utility = recv_engine_message(dealer).await;
6269 + assert_eq!(utility[0].as_ref(), &[0x03]);
6270 +
6271 + let payload = decode_value(&utility[1]).expect("decode utility payload");
6272 + let array = payload.as_array().expect("utility payload array");
6273 + let call_id = array[1].as_u64().expect("call id");
6274 +
6275 + assert_eq!(array[2], Value::from("profile"));
6276 + assert_eq!(array[3], Value::Array(vec![Value::from(false), Value::Nil]));
6277 +
6278 + send_outputs(push, utility_outputs(call_id, utility_none_result())).await;
6279 + })
6280 + })
6281 + .await;
6282 +
6283 + let response = app
6284 + .clone()
6285 + .call(
6286 + Request::builder()
6287 + .method("POST")
6288 + .uri("/stop_profile")
6289 + .body(Body::empty())
6290 + .expect("build request"),
6291 + )
6292 + .await
6293 + .expect("call app");
6294 +
6295 + let status = response.status();
6296 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
6297 + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
6298 + assert!(body.is_empty());
6299 + engine_task.await.expect("mock engine task");
6300 +}
6301 +
6302 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6303 +#[serial]
6304 +async fn profile_routes_are_hidden_when_profiling_is_disabled() {
6305 + let (chat, engine_task) = test_chat_with_engine_handle().await;
6306 + let app = build_router(Arc::new(AppState::new(
6307 + vec!["test-model".to_string()],
6308 + chat,
6309 + )));
6310 +
6311 + for (method, uri) in [("POST", "/start_profile"), ("POST", "/stop_profile")] {
6312 + let response = app
6313 + .clone()
6314 + .call(
6315 + Request::builder()
6316 + .method(method)
6317 + .uri(uri)
6318 + .body(Body::empty())
6319 + .expect("build request"),
6320 + )
6321 + .await
6322 + .expect("call app");
6323 +
6324 + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {uri}");
6325 + }
6326 +
6327 + engine_task.abort_and_join().await;
6328 +}
rust/src/server/src/state.rs
+10
@@ -47,6 +47,9 @@ pub struct AppState {
47 model_path: Option<String>,
48 /// Lazily initialized runtime for heavyweight request paths.
49 request_runtime: OnceLock<BackgroundShutdownRuntime>,
50 + /// Profiler mode that registers `/start_profile` and `/stop_profile`
51 + /// routes when present.
52 + pub profiler: Option<String>,
53 }
54
55 impl AppState {
@@ -74,6 +77,7 @@ impl AppState {
77 lora_manager: LoraManager::new(),
78 model_path: None,
79 request_runtime: OnceLock::new(),
80 + profiler: None,
81 }
82 }
83
@@ -95,6 +99,12 @@ impl AppState {
99 self
100 }
101
102 + /// Set the profiler mode that enables `/start_profile` and `/stop_profile`.
103 + pub fn with_profiler(mut self, profiler: Option<String>) -> Self {
104 + self.profiler = profiler;
105 + self
106 + }
107 +
108 /// Attach the runtime server information snapshot used by `/server_info`.
109 pub(crate) fn with_server_info(mut self, server_info: ServerInfoSnapshot) -> Self {
110 self.server_info = Some(server_info);