[Rust Frontend]: Add `/get_world_size` route with static parallel size (#44801)

Ashar committed Jun 18, 2026 at 18:40 UTC 4cb5e746b63707ed470f952cfb77778a3dd34400
11 files changed +258 -2
rust/src/engine-core-client/src/client.rs
+18
@@ -414,6 +414,24 @@ impl EngineCoreClient {
414 .expect("engine core client requires at least one engine")
415 }
416
417 + /// Return the world size (TP * PP) from the parallel config, if available.
418 + pub fn world_size(&self) -> u64 {
419 + self.engines
420 + .first()
421 + .expect("engine core client requires at least one engine")
422 + .ready_response
423 + .world_size
424 + }
425 +
426 + /// Return the data parallel size from the parallel config, if available.
427 + pub fn data_parallel_size(&self) -> u64 {
428 + self.engines
429 + .first()
430 + .expect("engine core client requires at least one engine")
431 + .ready_response
432 + .data_parallel_size
433 + }
434 +
435 /// Get the model name associated with this client used for metrics
436 /// labeling.
437 pub fn model_name(&self) -> &str {
rust/src/engine-core-client/src/mock_engine.rs
+2
@@ -52,6 +52,8 @@ pub fn default_ready_response() -> EngineCoreReadyResponse {
52 dp_stats_address: None,
53 dtype: ModelDtype::Float32,
54 vllm_version: "test-vllm-version".to_string(),
55 + world_size: 1,
56 + data_parallel_size: 1,
57 kv_cache_size_tokens: None,
58 kv_cache_max_concurrency: None,
59 }
rust/src/engine-core-client/src/protocol/handshake.rs
+4
@@ -44,6 +44,10 @@ pub struct EngineCoreReadyResponse {
44 pub dtype: ModelDtype,
45 /// Python vLLM version reported by the engine process.
46 pub vllm_version: String,
47 + /// World size (TP * PP) from the parallel config.
48 + pub world_size: u64,
49 + /// Data parallelism size from the parallel config.
50 + pub data_parallel_size: u64,
51 /// Total KV cache capacity in tokens, if reported.
52 pub kv_cache_size_tokens: Option<u64>,
53 /// Maximum achievable request concurrency given the KV cache, if reported.
rust/src/engine-core-client/src/test_utils.rs
+56 -1
@@ -12,7 +12,7 @@ use crate::mock_engine::{
12 MockEngineConfig, MockEngineDataSockets, connect_to_bootstrapped_frontend, connect_to_frontend,
13 default_ready_response,
14 };
15 -use crate::protocol::handshake::HandshakeInitMessage;
15 +use crate::protocol::handshake::{EngineCoreReadyResponse, HandshakeInitMessage};
16
17 /// Per-test IPC endpoint namespace backed by a unique temporary directory.
18 ///
@@ -62,6 +62,15 @@ fn test_mock_engine_config() -> MockEngineConfig {
62 }
63 }
64
65 +fn test_mock_engine_config_with_ready(ready_response: EngineCoreReadyResponse) -> MockEngineConfig {
66 + MockEngineConfig {
67 + local: true,
68 + headless: true,
69 + ready_response,
70 + ..Default::default()
71 + }
72 +}
73 +
74 /// Complete the engine-core handshake and connect mock input/output sockets
75 /// plus optional coordinator sockets.
76 pub async fn setup_mock_engine_sockets(
@@ -147,3 +156,49 @@ where
156 });
157 (shutdown_tx, engine_task)
158 }
159 +
160 +/// Like [`setup_mock_engine`] but uses a custom ready response for the
161 +/// handshake, allowing tests to control `world_size`, `data_parallel_size`,
162 +/// etc.
163 +async fn setup_mock_engine_with_ready(
164 + engine_handshake: String,
165 + engine_id: impl Into<EngineId>,
166 + ready_response: EngineCoreReadyResponse,
167 +) -> (DealerSocket, PushSocket) {
168 + let config = test_mock_engine_config_with_ready(ready_response);
169 + let MockEngineSockets { data_sockets, .. } =
170 + connect_to_frontend(engine_handshake, engine_id, config)
171 + .await
172 + .expect("connect mock engine with custom ready response");
173 + let MockEngineDataSockets { dealer, push } =
174 + data_sockets.into_iter().next().expect("mock engine data socket");
175 + (dealer, push)
176 +}
177 +
178 +/// Like [`spawn_mock_engine_task`] but uses a custom ready response for the
179 +/// handshake, allowing tests to set `world_size` and `data_parallel_size` to
180 +/// non-default values.
181 +pub fn spawn_mock_engine_task_with_ready<F>(
182 + engine_handshake: String,
183 + engine_id: impl Into<EngineId>,
184 + ready_response: EngineCoreReadyResponse,
185 + run: F,
186 +) -> (oneshot::Sender<()>, tokio::task::JoinHandle<()>)
187 +where
188 + F: for<'a> FnOnce(
189 + &'a mut DealerSocket,
190 + &'a mut PushSocket,
191 + ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
192 + + Send
193 + + 'static,
194 +{
195 + let (shutdown_tx, shutdown_rx) = oneshot::channel();
196 + let engine_id = engine_id.into();
197 + let engine_task = tokio::spawn(async move {
198 + let (mut dealer, mut push) =
199 + setup_mock_engine_with_ready(engine_handshake, engine_id, ready_response).await;
200 + run(&mut dealer, &mut push).await;
201 + let _ = shutdown_rx.await;
202 + });
203 + (shutdown_tx, engine_task)
204 +}
rust/src/engine-core-client/src/tests/python_compat.py
+4
@@ -358,6 +358,8 @@ class EngineCoreReadyResponse:
358 dp_stats_address: str | None
359 dtype: str
360 vllm_version: str
361 + world_size: int
362 + data_parallel_size: int
363 kv_cache_size_tokens: int | None = None
364 kv_cache_max_concurrency: float | None = None
365
@@ -369,6 +371,8 @@ ready_response = EngineCoreReadyResponse(
371 dp_stats_address=None,
372 dtype="float32",
373 vllm_version="0.0.0",
374 + data_parallel_size=1,
375 + world_size=1,
376 )
377
378 print(msgspec.msgpack.encode(request).hex())
rust/src/server/src/routes.rs
+2
@@ -12,6 +12,7 @@ mod server_info;
12 mod sleep;
13 mod tokenize;
14 mod version;
15 +mod world_size;
16
17 use std::sync::Arc;
18
@@ -100,6 +101,7 @@ fn build_router_with_options(
101 .route("/resume", post(pause::resume))
102 .route("/is_paused", get(pause::is_paused))
103 .route("/server_info", get(server_info::server_info))
104 + .route("/get_world_size", get(world_size::get_world_size))
105 }
106
107 let enable_request_id_headers = state.api_server_options.enable_request_id_headers;
rust/src/server/src/routes/tests.rs
+112 -1
@@ -23,6 +23,7 @@ use vllm_chat::{
23 ChatTextBackend, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer,
24 NewChatOutputProcessorOptions,
25 };
26 +use vllm_engine_core_client::mock_engine::default_ready_response;
27 use vllm_engine_core_client::protocol::logprobs::{
28 Logprobs, MaybeWireLogprobs, PositionLogprobs, TokenLogprob,
29 };
@@ -31,7 +32,9 @@ use vllm_engine_core_client::protocol::{
32 EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, EngineCoreRequest, StopReason,
33 decode_value,
34 };
34 -use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task};
35 +use vllm_engine_core_client::test_utils::{
36 + IpcNamespace, spawn_mock_engine_task, spawn_mock_engine_task_with_ready,
37 +};
38 use vllm_engine_core_client::{
39 ENGINE_CORE_DEAD_SENTINEL, EngineCoreClient, EngineCoreClientConfig, EngineId,
40 };
@@ -788,6 +791,45 @@ async fn test_app_with_dev_mode(dev_mode_enabled: bool) -> axum::Router {
791 )
792 }
793
794 +/// Build a dev-mode router backed by a mock engine using a custom ready
795 +/// response, returning the router and the engine task handle so the engine
796 +/// stays alive for the duration of the test.
797 +async fn test_dev_mode_app_with_ready(
798 + ready_response: vllm_engine_core_client::protocol::handshake::EngineCoreReadyResponse,
799 +) -> (axum::Router, MockEngineTask) {
800 + let ipc = IpcNamespace::new().expect("create ipc namespace");
801 + let handshake_address = ipc.handshake_endpoint();
802 + let engine_id = b"engine-world-size".to_vec();
803 +
804 + let engine_task = MockEngineTask::new(spawn_mock_engine_task_with_ready(
805 + handshake_address.clone(),
806 + engine_id.clone(),
807 + ready_response,
808 + |_dealer, _push| boxed_test_future(async {}),
809 + ));
810 +
811 + let client = EngineCoreClient::connect(
812 + EngineCoreClientConfig::new_single(handshake_address)
813 + .with_model_name("test-model")
814 + .with_local_input_output_addresses(
815 + Some(ipc.input_endpoint()),
816 + Some(ipc.output_endpoint()),
817 + ),
818 + )
819 + .await
820 + .expect("connect client");
821 +
822 + let chat = ChatLlm::from_shared_backend(test_llm(client), Arc::new(FakeChatBackend::new()));
823 + let app = build_router_with_dev_mode(
824 + Arc::new(AppState::new(
825 + vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()],
826 + chat,
827 + )),
828 + true,
829 + );
830 + (app, engine_task)
831 +}
832 +
833 async fn test_app_with_request_id_headers() -> (axum::Router, MockEngineTask) {
834 let (chat, engine_task) = test_models_with_engine_outputs_and_backend(
835 b"engine-openai-request-id",
@@ -5876,3 +5918,72 @@ async fn tokenize_chat_continue_final_vs_new_assistant_differs() {
5918 let new_len = new_assistant["tokens"].as_array().unwrap().len();
5919 assert!(new_len > continue_len);
5920 }
5921 +
5922 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5923 +#[serial]
5924 +async fn world_size_endpoint_is_dev_mode_only() {
5925 + let mut app = test_app().await;
5926 + let response = app
5927 + .call(
5928 + Request::builder()
5929 + .uri("/get_world_size")
5930 + .body(Body::empty())
5931 + .expect("build request"),
5932 + )
5933 + .await
5934 + .expect("call app");
5935 +
5936 + assert_eq!(response.status(), StatusCode::NOT_FOUND);
5937 +}
5938 +
5939 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5940 +#[serial]
5941 +async fn world_size_includes_data_parallelism_by_default() {
5942 + let ready = vllm_engine_core_client::protocol::handshake::EngineCoreReadyResponse {
5943 + world_size: 2,
5944 + data_parallel_size: 4,
5945 + ..default_ready_response()
5946 + };
5947 + let (mut app, _engine_task) = test_dev_mode_app_with_ready(ready).await;
5948 +
5949 + let response = app
5950 + .call(
5951 + Request::builder()
5952 + .uri("/get_world_size")
5953 + .body(Body::empty())
5954 + .expect("build request"),
5955 + )
5956 + .await
5957 + .expect("call app");
5958 +
5959 + assert_eq!(response.status(), StatusCode::OK);
5960 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
5961 + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
5962 + assert_eq!(json, json!({"world_size": 8}));
5963 +}
5964 +
5965 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5966 +#[serial]
5967 +async fn world_size_excludes_data_parallelism_when_include_dp_false() {
5968 + let ready = vllm_engine_core_client::protocol::handshake::EngineCoreReadyResponse {
5969 + world_size: 2,
5970 + data_parallel_size: 4,
5971 + ..default_ready_response()
5972 + };
5973 + let (mut app, _engine_task) = test_dev_mode_app_with_ready(ready).await;
5974 +
5975 + let response = app
5976 + .call(
5977 + Request::builder()
5978 + .uri("/get_world_size?include_dp=false")
5979 + .body(Body::empty())
5980 + .expect("build request"),
5981 + )
5982 + .await
5983 + .expect("call app");
5984 +
5985 + assert_eq!(response.status(), StatusCode::OK);
5986 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
5987 + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
5988 + assert_eq!(json, json!({"world_size": 2}));
5989 +}
rust/src/server/src/routes/world_size.rs new
+54
@@ -0,0 +1,54 @@
1 +use std::sync::Arc;
2 +
3 +use axum::Json;
4 +use axum::extract::{Query, State};
5 +use serde::{Deserialize, Serialize};
6 +
7 +use crate::error::ApiError;
8 +use crate::state::AppState;
9 +
10 +#[derive(Debug, Deserialize)]
11 +pub(crate) struct WorldSizeParams {
12 + /// If true (default), returns the world size including data parallelism
13 + /// (TP * PP * DP). If false, returns the world size without data
14 + /// parallelism (TP * PP).
15 + #[serde(default = "default_true")]
16 + include_dp: bool,
17 +}
18 +
19 +const fn default_true() -> bool {
20 + true
21 +}
22 +
23 +#[derive(Serialize)]
24 +pub(crate) struct WorldSizeResponse {
25 + world_size: u64,
26 +}
27 +
28 +/// Get the world size from the parallel config.
29 +///
30 +/// Currently reads static values captured during the engine startup handshake.
31 +///
32 +/// TODO: If the world size can change at runtime (e.g. elastic EP scaling,
33 +/// DP rank recovery), this should be switched to either:
34 +/// - A `call_utility("get_world_size", (include_dp,))` RPC to the Python
35 +/// engine for live values (simple, adds one ZMQ round-trip per request), or
36 +/// - A push-based approach where the engine sends config updates via the
37 +/// output stream into shared state (zero per-request overhead, more complex).
38 +pub async fn get_world_size(
39 + State(state): State<Arc<AppState>>,
40 + Query(params): Query<WorldSizeParams>,
41 +) -> Result<Json<WorldSizeResponse>, ApiError> {
42 + let client = state.engine_core_client();
43 +
44 + let ws = client.world_size();
45 +
46 + let world_size = if params.include_dp {
47 + let dp = client.data_parallel_size();
48 + ws * dp
49 + } else {
50 + ws
51 + };
52 +
53 + Ok(Json(WorldSizeResponse { world_size }))
54 +}
tests/v1/engine/test_engine_core_client.py
+2
@@ -255,6 +255,8 @@ def test_apply_ready_response_syncs_block_size():
255 dp_stats_address=None,
256 dtype="bfloat16",
257 vllm_version="test",
258 + world_size=1,
259 + data_parallel_size=1,
260 )
261 )
262 client._apply_ready_response(payload)
vllm/v1/engine/__init__.py
+2
@@ -78,6 +78,8 @@ class EngineCoreReadyResponse:
78 dp_stats_address: str | None
79 dtype: str
80 vllm_version: str
81 + world_size: int
82 + data_parallel_size: int
83 # KV cache capacity (None for encoder-only/attention-free models).
84 kv_cache_size_tokens: int | None = None
85 kv_cache_max_concurrency: float | None = None
vllm/v1/engine/core.py
+2
@@ -1527,6 +1527,8 @@ class EngineCoreProc(EngineCore):
1527 dp_stats_address=self.frontend_stats_publish_address,
1528 dtype=str(self.vllm_config.model_config.dtype).removeprefix("torch."),
1529 vllm_version=VLLM_VERSION,
1530 + world_size=self.vllm_config.parallel_config.world_size,
1531 + data_parallel_size=self.vllm_config.parallel_config.data_parallel_size,
1532 kv_cache_size_tokens=(
1533 self.vllm_config.cache_config.kv_cache_size_tokens
1534 ),