Skip to content

Commit 509f18d

Browse files
oyilmaz-nvidiaprokotgclaude
authored
Multi-Instance vLLM Support in gym eval submit (#2482)
## Multi-Instance vLLM Support in `gym eval submit` ### Summary Adds support for running multiple vLLM engine replicas in `gym eval submit` using vLLM's native data-parallel multi-instance mode. Previously, `VllmServiceConfig` only supported a single vLLM engine. This change introduces two new fields: - **`number_of_instances`** — the number of engine replicas to run (default `1`, preserving existing behavior) - **`distributed_backend`** — a typed, extensible sub-config declaring how replicas are coordinated. The only type available in this PR is `vllm_service`, which maps to vLLM's `--data-parallel-size N` flag. vLLM manages load balancing internally, so no external router or extra processes are needed. When `number_of_instances > 1`, the generated sbatch script passes `--data-parallel-size N` to the single `vllm serve` command. The single endpoint, health check, and driver URL wiring are all unchanged. The `distributed_backend` field is designed to be extended in future PRs — Ray Serve and Dynamo routing backends will each add a new type to the discriminated union without touching existing configs. ### Validation rules - `number_of_instances > 1` requires `distributed_backend` to be set (fails loudly with a clear message) - `number_of_instances == 1` with `distributed_backend` set is rejected - `number_of_instances < 1` is rejected ### Also included - `--pipeline-parallel-size` is now emitted in the generated `vllm serve` command when `> 1` (it was in the config but was never passed to the CLI) - An example YAML (`examples/slurm_vllm_multi_instance.yaml`) showing a 4-instance setup with TP=2 across 8 GPUs --------- Signed-off-by: Tomasz Grzegorzek <tgrzegorzek@nvidia.com> Signed-off-by: prokotg <19536019+prokotg@users.noreply.github.com> Signed-off-by: Onur Yilmaz <oyilmaz@nvidia.com> Signed-off-by: Onur Yilmaz <35306097+oyilmaz-nvidia@users.noreply.github.com> Co-authored-by: Tomasz Grzegorzek <tgrzegorzek@nvidia.com> Co-authored-by: prokotg <19536019+prokotg@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 500203a commit 509f18d

5 files changed

Lines changed: 240 additions & 2 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
compute:
2+
cluster:
3+
type: slurm
4+
hostname: myslurmcluster
5+
walltime: "01:00:00"
6+
account: myaccount
7+
node_pools:
8+
compute:
9+
partition: batch
10+
nodes: 1
11+
ntasks_per_node: 1
12+
gpus_per_node: 8
13+
14+
services:
15+
vllm_model:
16+
container: vllm/vllm-openai:v0.9.0
17+
type: vllm
18+
model: Qwen/Qwen2.5-7B-Instruct
19+
trust_remote_code: true
20+
tensor_parallel_size: 2
21+
number_of_instances: 4 # 4 engine replicas, total 8 GPUs
22+
port: 8000
23+
health_check:
24+
timeout_seconds: 1200
25+
mounts:
26+
- /lustre/datasets:/data
27+
28+
driver:
29+
policy_model: vllm_model
30+
container: python:3.12
31+
gym_install:
32+
ref: main
33+
benchmarks:
34+
gpqa:
35+
run:
36+
split: benchmark
37+
config_paths:
38+
- benchmarks/gpqa/config.yaml
39+
40+
job:
41+
output_path: /lustre/fsw/my-path

nemo_gym/orchestration/api.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
import warnings
1617
from typing import Annotated, Any, Literal
1718

18-
from pydantic import BaseModel, ConfigDict, Discriminator, Tag, model_validator
19+
from pydantic import BaseModel, ConfigDict, Discriminator, Tag, field_validator, model_validator
1920

2021

2122
# Reject unknown fields on all config models so typos in YAML surface immediately.
@@ -48,11 +49,41 @@ class BaseModelServiceConfig(BaseServiceConfig):
4849
port: int = 8000
4950

5051

52+
class VllmServiceDistributedBackend(_StrictModel):
53+
"""Use vLLM's native data-parallel multi-instance (--data-parallel-size N)."""
54+
55+
type: Literal["mp"] = "mp"
56+
57+
58+
# Future backends: add Annotated[RayServeDistributedBackend, Tag("ray_serve")], etc.
59+
DistributedBackendConfig = Annotated[
60+
Annotated[VllmServiceDistributedBackend, Tag("mp")],
61+
Discriminator("type"),
62+
]
63+
64+
5165
class VllmServiceConfig(BaseModelServiceConfig):
5266
type: Literal["vllm"]
5367
tensor_parallel_size: int = 1
5468
pipeline_parallel_size: int = 1
5569
trust_remote_code: bool = False
70+
number_of_instances: int = 1
71+
distributed_backend: DistributedBackendConfig | None = None
72+
73+
@field_validator("number_of_instances")
74+
@classmethod
75+
def _validate_number_of_instances(cls, v: int) -> int:
76+
if v < 1:
77+
raise ValueError(f"number_of_instances must be >= 1, got {v}")
78+
return v
79+
80+
@model_validator(mode="after")
81+
def _validate_distributed_backend(self) -> "VllmServiceConfig":
82+
if self.number_of_instances == 1 and self.distributed_backend is not None:
83+
raise ValueError("distributed_backend should not be set when number_of_instances == 1")
84+
if self.number_of_instances > 1 and self.distributed_backend is None:
85+
self.distributed_backend = VllmServiceDistributedBackend()
86+
return self
5687

5788
@model_validator(mode="after")
5889
def _default_health_check(self) -> "VllmServiceConfig":
@@ -160,6 +191,9 @@ def _resolve_and_validate_placements(self) -> "SubmitConfig":
160191
f"({', '.join(sorted(compute_names))})."
161192
)
162193

194+
if isinstance(service, VllmServiceConfig):
195+
self._validate_vllm_gpu_footprint(service_name, service)
196+
163197
if self.driver.policy_model is not None:
164198
if self.driver.policy_model not in self.services:
165199
raise ValueError(
@@ -183,3 +217,38 @@ def _resolve_and_validate_placements(self) -> "SubmitConfig":
183217
benchmark.run["policy_api_key"] = "dummy" # pragma: allowlist secret
184218

185219
return self
220+
221+
def _validate_vllm_gpu_footprint(self, service_name: str, service: "VllmServiceConfig") -> None:
222+
compute = self.compute[service.placement]
223+
if not isinstance(compute, SlurmComputeConfig):
224+
return
225+
226+
gpus_per_node_values = [
227+
pool.gpus_per_node for pool in compute.node_pools.values() if pool.gpus_per_node is not None
228+
]
229+
if not gpus_per_node_values:
230+
return
231+
232+
max_gpus_per_node = max(gpus_per_node_values)
233+
gpus_needed = service.tensor_parallel_size * service.pipeline_parallel_size * service.number_of_instances
234+
235+
if gpus_needed > max_gpus_per_node:
236+
raise ValueError(
237+
f"Service '{service_name}' requires {gpus_needed} GPUs "
238+
f"(tensor_parallel_size={service.tensor_parallel_size} x "
239+
f"pipeline_parallel_size={service.pipeline_parallel_size} x "
240+
f"number_of_instances={service.number_of_instances}), which exceeds the largest available "
241+
f"node pool's gpus_per_node ({max_gpus_per_node}) on compute '{service.placement}'. "
242+
"Multi-node vLLM services are not supported yet by the 'mp' distributed backend; "
243+
"reduce number_of_instances/tensor_parallel_size/pipeline_parallel_size to fit on a single node."
244+
)
245+
elif gpus_needed < max_gpus_per_node:
246+
warnings.warn(
247+
f"Service '{service_name}' requires {gpus_needed} GPUs "
248+
f"(tensor_parallel_size={service.tensor_parallel_size} x "
249+
f"pipeline_parallel_size={service.pipeline_parallel_size} x "
250+
f"number_of_instances={service.number_of_instances}) but compute '{service.placement}' allocates "
251+
f"nodes with {max_gpus_per_node} GPUs each, leaving {max_gpus_per_node - gpus_needed} GPU(s) idle. "
252+
"Increase number_of_instances/tensor_parallel_size or reduce gpus_per_node to use the full node.",
253+
stacklevel=2,
254+
)

nemo_gym/orchestration/executors/slurm_script.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,15 @@ def _render_service_command(
115115

116116

117117
def _build_vllm_command(service: VllmServiceConfig) -> str:
118-
cmd = f"vllm serve {shlex.quote(service.model)} --port {service.port} --tensor-parallel-size {service.tensor_parallel_size}"
118+
cmd = (
119+
f"vllm serve {shlex.quote(service.model)}"
120+
f" --port {service.port}"
121+
f" --tensor-parallel-size {service.tensor_parallel_size}"
122+
)
123+
if service.pipeline_parallel_size > 1:
124+
cmd += f" --pipeline-parallel-size {service.pipeline_parallel_size}"
125+
if service.number_of_instances > 1:
126+
cmd += f" --data-parallel-size {service.number_of_instances}"
119127
if service.trust_remote_code:
120128
cmd += " --trust-remote-code"
121129
return cmd

tests/unit_tests/test_orchestration_api.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,95 @@ def test_driver_env_accepted():
9696
def test_service_unknown_field_raises():
9797
with pytest.raises(ValidationError):
9898
SubmitConfig.model_validate(_config(services={"svc": {**SERVICE, "unknown_field": "x"}}))
99+
100+
101+
# ---------------------------------------------------------------------------
102+
# number_of_instances / distributed_backend
103+
# ---------------------------------------------------------------------------
104+
105+
_MULTI_SERVICE = {**SERVICE, "number_of_instances": 4, "distributed_backend": {"type": "mp"}}
106+
107+
108+
def test_number_of_instances_with_backend_accepted():
109+
config = SubmitConfig.model_validate(_config(services={"svc": _MULTI_SERVICE}))
110+
svc = config.services["svc"]
111+
assert svc.number_of_instances == 4
112+
assert svc.distributed_backend is not None
113+
assert svc.distributed_backend.type == "mp"
114+
115+
116+
def test_number_of_instances_defaults_to_1():
117+
config = SubmitConfig.model_validate(_config())
118+
assert config.services["svc"].number_of_instances == 1
119+
assert config.services["svc"].distributed_backend is None
120+
121+
122+
def test_multi_instance_without_backend_defaults_to_mp():
123+
config = SubmitConfig.model_validate(_config(services={"svc": {**SERVICE, "number_of_instances": 4}}))
124+
svc = config.services["svc"]
125+
assert svc.distributed_backend is not None
126+
assert svc.distributed_backend.type == "mp"
127+
128+
129+
def test_single_instance_with_backend_raises():
130+
with pytest.raises(ValidationError, match="should not be set"):
131+
SubmitConfig.model_validate(_config(services={"svc": {**SERVICE, "distributed_backend": {"type": "mp"}}}))
132+
133+
134+
def test_number_of_instances_zero_raises():
135+
with pytest.raises(ValidationError):
136+
SubmitConfig.model_validate(_config(services={"svc": {**SERVICE, "number_of_instances": 0}}))
137+
138+
139+
# ---------------------------------------------------------------------------
140+
# GPU footprint vs node pool capacity
141+
# ---------------------------------------------------------------------------
142+
143+
COMPUTE_8_GPUS_PER_NODE = {
144+
"cluster": {
145+
"type": "slurm",
146+
"account": "my-account",
147+
"hostname": "foo",
148+
"node_pools": {"compute": {"partition": "batch", "gpus_per_node": 8}},
149+
}
150+
}
151+
152+
153+
def test_gpu_footprint_exact_fit_accepted():
154+
service = {
155+
**SERVICE,
156+
"tensor_parallel_size": 2,
157+
"number_of_instances": 4,
158+
"distributed_backend": {"type": "mp"},
159+
}
160+
config = SubmitConfig.model_validate(_config(services={"svc": service}, compute=COMPUTE_8_GPUS_PER_NODE))
161+
assert config.services["svc"].number_of_instances == 4
162+
163+
164+
def test_gpu_footprint_exceeds_node_raises():
165+
service = {
166+
**SERVICE,
167+
"tensor_parallel_size": 2,
168+
"number_of_instances": 8,
169+
"distributed_backend": {"type": "mp"},
170+
}
171+
with pytest.raises(ValidationError, match="exceeds the largest available"):
172+
SubmitConfig.model_validate(_config(services={"svc": service}, compute=COMPUTE_8_GPUS_PER_NODE))
173+
174+
175+
def test_gpu_footprint_underutilized_warns():
176+
service = {
177+
**SERVICE,
178+
"tensor_parallel_size": 2,
179+
"number_of_instances": 2,
180+
"distributed_backend": {"type": "mp"},
181+
}
182+
with pytest.warns(UserWarning, match="leaving 4 GPU"):
183+
config = SubmitConfig.model_validate(_config(services={"svc": service}, compute=COMPUTE_8_GPUS_PER_NODE))
184+
assert config.services["svc"].number_of_instances == 2
185+
186+
187+
def test_gpu_footprint_no_node_pools_skips_validation():
188+
# Default COMPUTE fixture has no node_pools, so nothing to validate against.
189+
config = SubmitConfig.model_validate(_config(services={"svc": _MULTI_SERVICE}))
190+
assert config.services["svc"].number_of_instances == 4

tests/unit_tests/test_slurm_script.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,34 @@ def test_build_vllm_command_no_trust_remote_code_by_default(vllm_service):
169169
assert "--trust-remote-code" not in cmd
170170

171171

172+
def test_build_vllm_command_multi_instance():
173+
service = VllmServiceConfig(
174+
type="vllm",
175+
container="vllm:latest",
176+
model="org/model",
177+
number_of_instances=4,
178+
distributed_backend={"type": "mp"},
179+
)
180+
cmd = _build_vllm_command(service)
181+
assert "--data-parallel-size 4" in cmd
182+
183+
184+
def test_build_vllm_command_single_instance_omits_dp_flag(vllm_service):
185+
cmd = _build_vllm_command(vllm_service)
186+
assert "--data-parallel-size" not in cmd
187+
188+
189+
def test_build_vllm_command_pipeline_parallel():
190+
service = VllmServiceConfig(type="vllm", container="vllm:latest", model="org/model", pipeline_parallel_size=2)
191+
cmd = _build_vllm_command(service)
192+
assert "--pipeline-parallel-size 2" in cmd
193+
194+
195+
def test_build_vllm_command_pipeline_parallel_1_omits_flag(vllm_service):
196+
cmd = _build_vllm_command(vllm_service)
197+
assert "--pipeline-parallel-size" not in cmd
198+
199+
172200
# ---------------------------------------------------------------------------
173201
# render_gym_cmd
174202
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)