[Rust Frontend][Perf] Use dedicated runtime for HTTP/request-processing/ZMQ (#46051)

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

Bugen Zhao committed Jun 23, 2026 at 12:03 UTC a8481be7a9e9a4faa1fbf386f3e46e5c2b4acdc2
13 files changed +328 -15
rust/src/chat/src/multimodal.rs
+1
@@ -365,6 +365,7 @@ impl MultimodalModelInfo {
365 let processor = self.image_processor.raw;
366 let images = image_frames.iter().map(|frame| frame.data().clone()).collect::<Vec<_>>();
367
368 + // TODO: is it still necessary given that we've already in a dedicated runtime?
369 tokio::task::spawn_blocking(move || {
370 processor.preprocess(&images, &config).map_err(|error| multimodal!("{error}"))
371 })
rust/src/engine-core-client/src/client.rs
+19 -11
@@ -15,6 +15,7 @@ use crate::protocol::handshake::EngineCoreReadyResponse;
15 use crate::protocol::lora::LoraRequest;
16 use crate::protocol::utility::{EngineCoreUtilityRequest, PauseMode};
17 use crate::protocol::{EngineCoreRequest, EngineCoreRequestType, ModelDtype};
18 +use crate::runtime::{BackgroundShutdownRuntime, build_zmq_runtime};
19 use crate::transport::{self, ConnectedEngine};
20
21 pub(crate) mod imp;
@@ -201,6 +202,8 @@ pub struct EngineCoreClient {
202 coordinator: Option<CoordinatorHandle>,
203 abort_tx: mpsc::UnboundedSender<AbortRequest>,
204
205 + /// Runtime used to send messages to the engine and drive all background tasks.
206 + runtime: BackgroundShutdownRuntime,
207 // Background tasks
208 output_task: AbortOnDropHandle<()>,
209 dispatcher_task: AbortOnDropHandle<()>,
@@ -280,21 +283,22 @@ impl EngineCoreClient {
283 let (output_tx, output_rx) = mpsc::channel(64);
284 let (abort_tx, abort_rx) = mpsc::unbounded_channel();
285 let engines = connected.engines;
286 + let runtime = build_zmq_runtime();
287 let inner = Arc::new(ClientInner::new(
288 connected.input_send,
289 + runtime.handle().clone(),
290 config.model_name.clone(),
291 &engines,
292 ));
288 - let output_task = AbortOnDropHandle::new(tokio::spawn(transport::run_output_loop(
293 + let output_task = AbortOnDropHandle::new(runtime.spawn(transport::run_output_loop(
294 connected.output_socket,
295 output_tx,
296 )));
292 - let dispatcher_task = AbortOnDropHandle::new(tokio::spawn(run_output_dispatcher_loop(
293 - inner.clone(),
294 - output_rx,
295 - )));
297 + let dispatcher_task = AbortOnDropHandle::new(
298 + runtime.spawn(run_output_dispatcher_loop(inner.clone(), output_rx)),
299 + );
300 let abort_task =
297 - AbortOnDropHandle::new(tokio::spawn(run_abort_loop(inner.clone(), abort_rx)));
301 + AbortOnDropHandle::new(runtime.spawn(run_abort_loop(inner.clone(), abort_rx)));
302
303 // If any engine reported a dp_stats_address in its ready response, use it
304 // as the external coordinator address.
@@ -307,13 +311,13 @@ impl EngineCoreClient {
311 CoordinatorHandle::new_inproc(coordinator_transport.input_socket);
312 let (coordinator_output_tx, coordinator_output_rx) = mpsc::channel(64);
313 let coordinator_output_task =
310 - AbortOnDropHandle::new(tokio::spawn(transport::run_output_loop(
314 + AbortOnDropHandle::new(runtime.spawn(transport::run_output_loop(
315 coordinator_transport.output_socket,
316 coordinator_output_tx,
317 )));
314 - let coordinator_task = AbortOnDropHandle::new(tokio::spawn(
315 - runner.run(coordinator_output_rx, inner.clone()),
316 - ));
318 + let coordinator_task = AbortOnDropHandle::new(
319 + runtime.spawn(runner.run(coordinator_output_rx, inner.clone())),
320 + );
321 (
322 Some(handle),
323 Some(coordinator_output_task),
@@ -327,7 +331,7 @@ impl EngineCoreClient {
331 {
332 let (handle, service) = CoordinatorHandle::connect_external(address).await?;
333 let coordinator_task =
330 - AbortOnDropHandle::new(tokio::spawn(service.run(inner.clone())));
334 + AbortOnDropHandle::new(runtime.spawn(service.run(inner.clone())));
335 (Some(handle), None, Some(coordinator_task))
336 } else {
337 (None, None, None)
@@ -341,6 +345,7 @@ impl EngineCoreClient {
345 inner,
346 coordinator,
347 abort_tx,
348 + runtime,
349 output_task,
350 dispatcher_task,
351 abort_task,
@@ -737,6 +742,7 @@ impl EngineCoreClient {
742 let Self {
743 inner,
744 abort_tx,
745 + runtime,
746 output_task,
747 dispatcher_task,
748 abort_task,
@@ -757,6 +763,8 @@ impl EngineCoreClient {
763
764 tasks.iter().for_each(|t| t.abort());
765 join_all(tasks).await;
766 + drop(inner);
767 + drop(runtime);
768
769 info!("engine-core client shut down");
770 Ok(())
rust/src/engine-core-client/src/client/imp.rs
+19 -3
@@ -5,6 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
5 use arc_swap::ArcSwapOption;
6 use parking_lot::Mutex;
7 use thiserror_ext::AsReport as _;
8 +use tokio::runtime::Handle;
9 use tokio::sync::mpsc;
10 use tracing::{debug, info, trace, warn};
11 use vllm_metrics::METRICS;
@@ -26,6 +27,8 @@ use crate::{Error, Result, transport};
27
28 pub(crate) struct ClientInner {
29 input_send: RouterSendHalf,
30 + /// The runtime handle used for sending messages to the engine.
31 + handle: Handle,
32 model_name: String,
33 request_reg: Mutex<RequestRegistry>,
34 utility_reg: Mutex<UtilityRegistry>,
@@ -37,11 +40,13 @@ impl ClientInner {
40 /// handshake completes.
41 pub fn new(
42 input_send: RouterSendHalf,
43 + handle: Handle,
44 model_name: String,
45 engines: &[ConnectedEngine],
46 ) -> Self {
47 Self {
48 input_send,
49 + handle,
50 model_name,
51 request_reg: Mutex::new(RequestRegistry::new(engines)),
52 utility_reg: Mutex::new(UtilityRegistry::default()),
@@ -213,9 +218,19 @@ impl ClientInner {
218 // frames instead of always producing a single msgpack frame.
219 let payload = encode_msgpack(payload)?;
220 let mut input_send = self.input_send.clone();
216 - transport::send_message(&mut input_send, engine_id, request_type.to_frame(), payload)
217 - .await?;
218 - Ok(())
221 + let engine_id = engine_id.clone();
222 +
223 + self.handle
224 + .spawn(async move {
225 + transport::send_message(
226 + &mut input_send,
227 + &engine_id,
228 + request_type.to_frame(),
229 + payload,
230 + )
231 + .await
232 + })
233 + .await?
234 }
235
236 /// Handle an abort request by sending the abort message to the engine.
@@ -434,6 +449,7 @@ mod tests {
449 let (send, _) = socket.split();
450 ClientInner::new(
451 send,
452 + Handle::current(),
453 "test-model".to_string(),
454 &[ConnectedEngine {
455 engine_id: EngineId::from(b"engine-0"),
rust/src/engine-core-client/src/error.rs
+2
@@ -29,6 +29,8 @@ pub enum Error {
29 Io(#[from] std::io::Error),
30 #[error("transport error")]
31 Transport(#[from] zeromq::ZmqError),
32 + #[error("ZMQ runtime task failed")]
33 + ZmqRuntimeTask(#[from] tokio::task::JoinError),
34 #[error("engine core reported fatal failure")]
35 EngineCoreDead,
36 #[error("startup handshake timed out while waiting for {stage} after {timeout:?}")]
rust/src/engine-core-client/src/lib.rs
+1
@@ -4,6 +4,7 @@ mod error;
4 mod metrics;
5 pub mod mock_engine;
6 pub mod protocol;
7 +pub mod runtime;
8 #[cfg(any(test, feature = "test-util"))]
9 pub mod test_utils;
10 mod transport;
rust/src/engine-core-client/src/runtime.rs new
+75
@@ -0,0 +1,75 @@
1 +use std::mem::ManuallyDrop;
2 +use std::ops::{Deref, DerefMut};
3 +use std::sync::OnceLock;
4 +use std::sync::atomic::{AtomicUsize, Ordering};
5 +
6 +use tokio::runtime::Runtime;
7 +
8 +/// A wrapper around [`Runtime`] that shuts down the runtime in the background when dropped.
9 +///
10 +/// This can be useful in some cases, because sometimes we want to drop the runtime without
11 +/// blocking the current thread, for example, when it's nested inside another runtime.
12 +pub struct BackgroundShutdownRuntime(ManuallyDrop<Runtime>);
13 +
14 +impl Drop for BackgroundShutdownRuntime {
15 + fn drop(&mut self) {
16 + // Safety: The runtime is only dropped once here.
17 + let runtime = unsafe { ManuallyDrop::take(&mut self.0) };
18 + runtime.shutdown_background();
19 + }
20 +}
21 +
22 +impl Deref for BackgroundShutdownRuntime {
23 + type Target = Runtime;
24 +
25 + fn deref(&self) -> &Self::Target {
26 + &self.0
27 + }
28 +}
29 +
30 +impl DerefMut for BackgroundShutdownRuntime {
31 + fn deref_mut(&mut self) -> &mut Self::Target {
32 + &mut self.0
33 + }
34 +}
35 +
36 +impl From<Runtime> for BackgroundShutdownRuntime {
37 + fn from(runtime: Runtime) -> Self {
38 + Self(ManuallyDrop::new(runtime))
39 + }
40 +}
41 +
42 +const ZMQ_WORKER_THREADS_ENV: &str = "VLLM_RS_ZMQ_WORKER_THREADS";
43 +/// The number of tasks running on the ZMQ runtime is fixed and expected to remain
44 +/// small, and multiple engines share the same ZMQ socket. Therefore, based on
45 +/// benchmarks, a default value of 4 is generally sufficient.
46 +const DEFAULT_ZMQ_WORKER_THREADS: usize = 4;
47 +
48 +static ZMQ_RUNTIME_SEQUENCE: OnceLock<AtomicUsize> = OnceLock::new();
49 +
50 +/// Build a Tokio runtime for ZMQ tasks. Multiple calls to this function will
51 +/// return multiple runtimes with distinct thread name suffixes.
52 +pub(crate) fn build_zmq_runtime() -> BackgroundShutdownRuntime {
53 + let sequence = ZMQ_RUNTIME_SEQUENCE
54 + .get_or_init(|| AtomicUsize::new(0))
55 + .fetch_add(1, Ordering::Relaxed);
56 +
57 + tokio::runtime::Builder::new_multi_thread()
58 + .worker_threads(zmq_worker_threads())
59 + .thread_name_fn(move || format!("vllm-zmq-{sequence}"))
60 + .enable_all()
61 + .build()
62 + .expect("failed to build vLLM ZMQ runtime")
63 + .into()
64 +}
65 +
66 +/// Get the number of worker threads to use for the ZMQ runtime. If env var
67 +/// `VLLM_RS_ZMQ_WORKER_THREADS` is set and a valid positive integer, it will be used.
68 +/// Otherwise, the default value of `DEFAULT_ZMQ_WORKER_THREADS` will be used.
69 +fn zmq_worker_threads() -> usize {
70 + std::env::var(ZMQ_WORKER_THREADS_ENV)
71 + .ok()
72 + .and_then(|value| value.parse::<usize>().ok())
73 + .filter(|value| *value > 0)
74 + .unwrap_or(DEFAULT_ZMQ_WORKER_THREADS)
75 +}
rust/src/server/Cargo.toml
+1
@@ -30,6 +30,7 @@ tokio-stream.workspace = true
30 tokio-util.workspace = true
31 tonic.workspace = true
32 tonic-prost.workspace = true
33 +tower.workspace = true
34 tower-http.workspace = true
35 tracing.workspace = true
36 tracing-futures.workspace = true
rust/src/server/src/lib.rs
+5 -1
@@ -7,6 +7,7 @@ mod listener;
7 mod lora;
8 mod middleware;
9 mod routes;
10 +mod runtime;
11 mod server_info;
12 mod state;
13 mod utils;
@@ -157,6 +158,9 @@ where
158 .with_context(|| format!("failed to bind gRPC listener on {grpc_host}:{grpc_port}"))?;
159 let addr = grpc_listener.local_addr()?;
160 let svc = grpc::GenerateServer::new(grpc::GenerateServiceImpl::new(state.clone()));
161 + let svc = TonicServer::builder()
162 + .layer(middleware::request_runtime_layer(state.clone()))
163 + .add_service(svc);
164 info!(%addr, "starting gRPC server");
165 Some((grpc_listener, svc))
166 } else {
@@ -238,7 +242,7 @@ where
242 shutdown.cancelled().await;
243 return Ok(());
244 };
241 - let server = TonicServer::builder().add_service(svc).serve_with_incoming_shutdown(
245 + let server = svc.serve_with_incoming_shutdown(
246 TcpListenerStream::new(grpc_listener),
247 shutdown.cancelled_owned(),
248 );
rust/src/server/src/middleware/mod.rs
+2
@@ -2,10 +2,12 @@ mod auth;
2 mod cors;
3 mod load;
4 mod metrics;
5 +mod offload;
6 mod request_id;
7
8 pub use auth::authenticate_api_key;
9 pub use cors::{cors_layer, strip_cors_on_no_origin};
10 pub use load::track_server_load;
11 pub use metrics::track_http_metrics;
12 +pub(crate) use offload::request_runtime_layer;
13 pub use request_id::set_request_id_header;
rust/src/server/src/middleware/offload.rs new
+134
@@ -0,0 +1,134 @@
1 +use std::sync::Arc;
2 +use std::task::{Context, Poll};
3 +
4 +use axum::http::Request;
5 +use axum::response::{IntoResponse, Response};
6 +use futures::future::BoxFuture;
7 +use tokio_util::task::AbortOnDropHandle;
8 +use tonic::Status;
9 +use tower::Service;
10 +use tower::layer::layer_fn;
11 +use tracing::error;
12 +
13 +use crate::error::{ApiError, server_error};
14 +use crate::state::AppState;
15 +
16 +/// Request paths that are run on the request runtime.
17 +///
18 +/// These routes can perform CPU-heavy request preparation, including JSON
19 +/// extraction, validation, chat-template rendering, tokenization, request
20 +/// lowering, and engine submission. Lightweight operational routes stay on the
21 +/// HTTP runtime.
22 +const OFFLOADED_PATHS: &[&str] = &[
23 + // HTTP routes:
24 + "/v1/chat/completions",
25 + "/v1/completions",
26 + "/tokenize",
27 + "/detokenize",
28 + "/inference/v1/generate",
29 + // gRPC routes:
30 + "/vllm.Generate/Generate",
31 + "/vllm.Generate/GenerateStream",
32 +];
33 +
34 +/// Return a Tower layer that runs selected data-plane requests on the request runtime,
35 +/// so that we can offset heavy request parsing and preprocessing from the HTTP runtime.
36 +pub(crate) fn request_runtime_layer<S>(
37 + state: Arc<AppState>,
38 +) -> impl tower::Layer<S, Service = RequestRuntimeService<S>> + Clone {
39 + layer_fn(move |inner| RequestRuntimeService {
40 + inner,
41 + state: state.clone(),
42 + })
43 +}
44 +
45 +/// Service produced by [`request_runtime_layer`].
46 +#[derive(Clone)]
47 +pub(crate) struct RequestRuntimeService<S> {
48 + inner: S,
49 + state: Arc<AppState>,
50 +}
51 +
52 +impl<S, B> Service<Request<B>> for RequestRuntimeService<S>
53 +where
54 + S: Service<Request<B>> + Clone + Send + 'static,
55 + S::Future: Send + 'static,
56 + S::Response: RequestRuntimeErrorResponse + Send + 'static,
57 + S::Error: Send + 'static,
58 + B: Send + 'static,
59 +{
60 + type Response = S::Response;
61 + type Error = S::Error;
62 + type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
63 +
64 + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
65 + self.inner.poll_ready(cx)
66 + }
67 +
68 + fn call(&mut self, req: Request<B>) -> Self::Future {
69 + if !should_offload(req.uri().path()) {
70 + return Box::pin(self.inner.call(req));
71 + }
72 +
73 + // Axum extractors and route handlers execute inside the inner service,
74 + // so offloading here moves request parsing and preprocessing off the
75 + // HTTP runtime without wrapping each handler manually. For streaming
76 + // HTTP responses, the response body is still polled on the HTTP runtime.
77 + let clone = self.inner.clone();
78 + let mut inner = std::mem::replace(&mut self.inner, clone);
79 + let task = AbortOnDropHandle::new(self.state.request_runtime().spawn(inner.call(req)));
80 +
81 + Box::pin(async move {
82 + match task.await {
83 + Ok(result) => result,
84 + Err(error) => {
85 + error!(%error, "request runtime task failed");
86 + Ok(S::Response::request_runtime_error_response())
87 + }
88 + }
89 + })
90 + }
91 +}
92 +
93 +trait RequestRuntimeErrorResponse {
94 + fn request_runtime_error_response() -> Self;
95 +}
96 +
97 +impl RequestRuntimeErrorResponse for Response {
98 + fn request_runtime_error_response() -> Self {
99 + server_error!("request runtime task failed").into_response()
100 + }
101 +}
102 +
103 +impl RequestRuntimeErrorResponse for axum::http::Response<tonic::body::Body> {
104 + fn request_runtime_error_response() -> Self {
105 + Status::internal("request runtime task failed").into_http()
106 + }
107 +}
108 +
109 +fn should_offload(path: &str) -> bool {
110 + OFFLOADED_PATHS.contains(&path)
111 +}
112 +
113 +#[cfg(test)]
114 +mod tests {
115 + use super::should_offload;
116 +
117 + #[test]
118 + fn offloads_generation_and_tokenization_paths() {
119 + assert!(should_offload("/v1/chat/completions"));
120 + assert!(should_offload("/v1/completions"));
121 + assert!(should_offload("/tokenize"));
122 + assert!(should_offload("/detokenize"));
123 + assert!(should_offload("/inference/v1/generate"));
124 + assert!(should_offload("/vllm.Generate/Generate"));
125 + assert!(should_offload("/vllm.Generate/GenerateStream"));
126 + }
127 +
128 + #[test]
129 + fn passes_through_lightweight_paths() {
130 + assert!(!should_offload("/health"));
131 + assert!(!should_offload("/metrics"));
132 + assert!(!should_offload("/v1/models"));
133 + }
134 +}
rust/src/server/src/routes.rs
+1
@@ -108,6 +108,7 @@ fn build_router_with_options(
108 let enable_api_key_auth = state.has_api_keys();
109 let mut router = router
110 .with_state(state.clone())
111 + .layer(middleware::request_runtime_layer(state.clone()))
112 .layer(from_fn_with_state(
113 state.clone(),
114 middleware::track_server_load,
rust/src/server/src/runtime.rs new
+54
@@ -0,0 +1,54 @@
1 +use tokio::runtime::Builder;
2 +use tracing::{info, warn};
3 +use vllm_engine_core_client::runtime::BackgroundShutdownRuntime;
4 +
5 +const REQUEST_WORKER_THREADS_ENV: &str = "VLLM_RS_REQUEST_WORKER_THREADS";
6 +const DEFAULT_MAX_REQUEST_WORKER_THREADS: usize = 32;
7 +
8 +/// Build a Tokio runtime for heavyweight request paths outside the HTTP runtime.
9 +///
10 +/// The server middleware uses this runtime for inference and tokenization
11 +/// routes so CPU-heavy request preparation does not monopolize the HTTP
12 +/// runtime's worker queue. Dropping the wrapper shuts the runtime down in the
13 +/// background.
14 +pub(crate) fn build_request_runtime() -> BackgroundShutdownRuntime {
15 + Builder::new_multi_thread()
16 + .enable_all()
17 + .thread_name("vllm-request")
18 + .worker_threads(request_worker_threads())
19 + .build()
20 + .expect("failed to build request runtime")
21 + .into()
22 +}
23 +
24 +/// Get the number of worker threads to use for the request runtime.
25 +///
26 +/// If `VLLM_RS_REQUEST_WORKER_THREADS` is set to a valid positive integer, it is
27 +/// used directly. Otherwise, the runtime uses available parallelism capped by
28 +/// `DEFAULT_MAX_REQUEST_WORKER_THREADS`.
29 +fn request_worker_threads() -> usize {
30 + if let Some(value) = std::env::var_os(REQUEST_WORKER_THREADS_ENV) {
31 + match value.to_string_lossy().parse::<usize>() {
32 + Ok(worker_threads) if worker_threads > 0 => return worker_threads,
33 + _ => warn!(
34 + value = %value.to_string_lossy(),
35 + "ignoring invalid {REQUEST_WORKER_THREADS_ENV}"
36 + ),
37 + }
38 + }
39 +
40 + std::thread::available_parallelism()
41 + .map(|parallelism| {
42 + let available = parallelism.get();
43 + let worker_threads = available.min(DEFAULT_MAX_REQUEST_WORKER_THREADS);
44 + if worker_threads < available {
45 + info!(
46 + available_parallelism = available,
47 + capped_worker_threads = worker_threads,
48 + "capping request runtime worker threads, set {REQUEST_WORKER_THREADS_ENV} to override"
49 + );
50 + }
51 + worker_threads
52 + })
53 + .unwrap_or(DEFAULT_MAX_REQUEST_WORKER_THREADS)
54 +}
rust/src/server/src/state.rs
+14
@@ -1,16 +1,20 @@
1 use std::sync::Arc;
2 +use std::sync::OnceLock;
3 use std::sync::atomic::{AtomicU64, Ordering};
4
5 use serde_json::Value;
6 use sha2::{Digest, Sha256};
7 +use tokio::runtime::Runtime;
8 use tokio::time::{Duration, Instant, sleep_until};
9 use tracing::warn;
10 use vllm_chat::ChatLlm;
11 use vllm_engine_core_client::EngineCoreClient;
12 use vllm_engine_core_client::protocol::lora::LoraRequest;
13 +use vllm_engine_core_client::runtime::BackgroundShutdownRuntime;
14
15 use crate::config::{ApiServerOptions, CorsConfig};
16 use crate::lora::{LoadLoraError, LoraManager, LoraModelResolution, UnloadLoraError};
17 +use crate::runtime::build_request_runtime;
18 use crate::server_info::{ServerInfoConfigFormat, ServerInfoSnapshot};
19
20 const SHUTDOWN_REFCOUNT_POLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -42,6 +46,8 @@ pub struct AppState {
46 lora_manager: LoraManager,
47 /// Backend model path reported as `root` for base-model cards.
48 model_path: Option<String>,
49 + /// Lazily initialized runtime for heavyweight request paths.
50 + request_runtime: OnceLock<BackgroundShutdownRuntime>,
51 }
52
53 impl AppState {
@@ -68,6 +74,7 @@ impl AppState {
74 server_load: AtomicU64::new(0),
75 lora_manager: LoraManager::new(),
76 model_path: None,
77 + request_runtime: OnceLock::new(),
78 }
79 }
80
@@ -185,6 +192,12 @@ impl AppState {
192 self.chat.engine_core_client()
193 }
194
195 + /// Runtime used by middleware to isolate heavyweight request handlers from
196 + /// the HTTP reactor.
197 + pub(crate) fn request_runtime(&self) -> &Runtime {
198 + self.request_runtime.get_or_init(build_request_runtime)
199 + }
200 +
201 /// Return the current in-flight inference request count for the `/load`
202 /// endpoint.
203 pub fn server_load(&self) -> u64 {
@@ -214,6 +227,7 @@ impl AppState {
227 match Arc::try_unwrap(self) {
228 Ok(state) => {
229 state.chat.shutdown().await?;
230 + drop(state.request_runtime); // shutdown in background
231 return Ok(());
232 }
233 Err(state) => self = state,