-
Notifications
You must be signed in to change notification settings - Fork 344
Basic SLURM orchestration #2176
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4d88403
8f7ed77
748fd41
864a876
356881c
743ce41
b243b9e
7e72d6f
6525533
4c56c88
f1d9683
a247be9
a7914f8
8f63fbe
d5c9b8a
ad69cd6
473a78f
ec63660
5e818bd
0ae8867
be265f3
cf8ae94
3d7fba1
e4f1435
a1f681e
4d79207
8c27a4c
427346e
9308727
c7487e9
eef3f1f
3d0a1ef
8ec6fac
2daaa18
be3a1a0
2e263ea
dd335c3
4afa47b
787ad32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| compute: | ||
| cluster: | ||
| type: slurm | ||
| hostname: myslurmcluster | ||
| walltime: "00:30:00" | ||
| account: myaccount | ||
| node_pools: | ||
| compute: | ||
| partition: batch | ||
| nodes: 1 | ||
| ntasks_per_node: 1 | ||
| gpus_per_node: 4 | ||
|
|
||
| services: | ||
| vllm_model: | ||
| container: vllm/vllm-openai:v0.26.0 # optional, defaults | ||
| type: vllm | ||
| model: Qwen/Qwen2.5-0.5B-Instruct | ||
| trust_remote_code: true | ||
| tensor_parallel_size: 1 | ||
| pipeline_parallel_size: 1 | ||
| port: 8000 | ||
| health_check: | ||
| timeout_seconds: 1200 | ||
|
|
||
| driver: | ||
| policy_model: vllm_model # synthactic sugar | ||
| container: python:3.12 # optional, defaults to the service container | ||
| gym_install: | ||
| ref: main | ||
| benchmarks: | ||
| gpqa: | ||
| prepare: | ||
| config_paths: | ||
| - benchmarks/ifbench/config.yaml | ||
| run: # gym eval run | ||
| split: benchmark | ||
| overwrite_metrics_conflicts: true | ||
| responses_create_params: | ||
| temperature: 0.6 | ||
| top_p: 0.9 | ||
| config_paths: # if services include pre-defined service types, some configs might be created and added to the command automatically | ||
| - benchmarks/ifbench/config.yaml | ||
| job: | ||
| output_path: /lustre/fsw/my-path | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| import functools | ||
|
|
||
| import rich | ||
|
|
||
|
|
||
| def experimental(fn): | ||
| """Decorator that prints an experimental warning before the function runs.""" | ||
|
|
||
| @functools.wraps(fn) | ||
| def wrapper(*args, **kwargs): | ||
| rich.print( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I didn't check the complete repo, but I saw Same for all other rich.print statements. |
||
| f"[yellow]Warning:[/yellow] [bold]{fn.__name__}[/bold] is experimental and may change or be removed without notice." | ||
| ) | ||
| return fn(*args, **kwargs) | ||
|
|
||
| return wrapper | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| from typing import Annotated, Any, Literal | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Discriminator, Tag, model_validator | ||
|
|
||
|
|
||
| # Reject unknown fields on all config models so typos in YAML surface immediately. | ||
| class _StrictModel(BaseModel): | ||
| model_config = ConfigDict(extra="forbid") | ||
|
|
||
|
|
||
| class HealthCheckConfig(_StrictModel): | ||
| path: str = "/health" | ||
|
prokotg marked this conversation as resolved.
|
||
| # port defaults to None so VllmServiceConfig can fill it from service.port when omitted. | ||
| port: int | None = None | ||
| timeout_seconds: int = 60 | ||
|
|
||
|
|
||
| class BaseServiceConfig(_StrictModel): | ||
| container: str | ||
| # Resolved to the sole compute resource name at validation time when not set. | ||
| placement: str | None = None | ||
| health_check: HealthCheckConfig | None = None | ||
|
|
||
|
|
||
| class BaseModelServiceConfig(BaseServiceConfig): | ||
| """Base for services that serve a model and can be wired as the policy model.""" | ||
|
|
||
| model: str | ||
| port: int = 8000 | ||
|
|
||
|
|
||
| class VllmServiceConfig(BaseModelServiceConfig): | ||
| type: Literal["vllm"] | ||
| tensor_parallel_size: int = 1 | ||
| pipeline_parallel_size: int = 1 | ||
| trust_remote_code: bool = False | ||
|
|
||
| @model_validator(mode="after") | ||
| def _default_health_check(self) -> "VllmServiceConfig": | ||
|
prokotg marked this conversation as resolved.
|
||
| # vLLM always exposes /health on its serving port; set it automatically | ||
| # so the sbatch script gets a health check without the user having to repeat the port. | ||
| if self.health_check is None: | ||
| self.health_check = HealthCheckConfig(port=self.port) | ||
| elif self.health_check.port is None: | ||
| self.health_check.port = self.port | ||
| return self | ||
|
|
||
|
|
||
| class RayServiceConfig(BaseServiceConfig): | ||
| type: Literal["ray"] | ||
|
|
||
|
|
||
| # Discriminated union keyed on `type`; Pydantic rejects unknown type values at parse time. | ||
| ServiceConfig = Annotated[ | ||
| Annotated[VllmServiceConfig, Tag("vllm")] | Annotated[RayServiceConfig, Tag("ray")], | ||
| Discriminator("type"), | ||
| ] | ||
|
|
||
|
|
||
| class NodePool(_StrictModel): | ||
| partition: str | ||
| nodes: int = 1 | ||
| ntasks_per_node: int = 1 | ||
| # Structured field the executor uses for smart deployment decisions (e.g. multi-instance vLLM). | ||
| gpus_per_node: int | None = None | ||
| # Arbitrary #SBATCH directives forwarded verbatim for options we don't model explicitly. | ||
| extra_args: dict[str, str] = {} | ||
|
|
||
|
|
||
| class BaseComputeConfig(_StrictModel): | ||
| pass | ||
|
|
||
|
|
||
| class SlurmComputeConfig(BaseComputeConfig): | ||
| type: Literal["slurm"] | ||
| account: str | ||
| hostname: str | None = None # None means we're already on the login node; skip SSH. | ||
| walltime: str | None = None | ||
| node_pools: dict[str, NodePool] = {} | ||
| extra_args: dict[str, str] = {} # Job-level #SBATCH directives (e.g. --comment, --mail-user). | ||
|
|
||
|
|
||
| ComputeConfig = Annotated[ | ||
| Annotated[SlurmComputeConfig, Tag("slurm")], | ||
| Discriminator("type"), | ||
| ] | ||
|
|
||
|
|
||
| class BenchmarkRunConfig(_StrictModel): | ||
| # Hydra overrides forwarded to `gym eval prepare`. Flattened to +key=value tokens. | ||
| prepare: dict[str, Any] = {} | ||
| # Hydra overrides forwarded to `gym eval run`. policy_model wiring is injected here at | ||
| # validation time so all executors see it uniformly via flatten_run_args. | ||
| run: dict[str, Any] = {} | ||
|
|
||
|
|
||
| class GymInstallConfig(_StrictModel): | ||
| repo: str = "https://github.com/NVIDIA-NeMo/gym" | ||
| ref: str # Git tag or commit hash. | ||
|
|
||
|
|
||
| class DriverConfig(_StrictModel): | ||
| container: str = "python:3.12" | ||
| gym_install: GymInstallConfig | None = None | ||
| # Name of a service in `services:` to use as the policy model. When set, injects | ||
| # policy_base_url/policy_model_name/policy_api_key into each benchmark's run config. | ||
| policy_model: str | None = None | ||
| benchmarks: dict[str, BenchmarkRunConfig] | ||
|
|
||
|
|
||
| class JobConfig(_StrictModel): | ||
| # Remote base directory. Each submit creates a timestamped subdirectory here. | ||
| output_path: str | ||
|
|
||
|
|
||
| class SubmitConfig(_StrictModel): | ||
| services: dict[str, ServiceConfig] | ||
| compute: dict[str, ComputeConfig] | ||
| driver: DriverConfig | ||
| job: JobConfig | ||
|
|
||
| @model_validator(mode="after") | ||
| def _resolve_and_validate_placements(self) -> "SubmitConfig": | ||
| compute_names = set(self.compute) | ||
|
|
||
| if len(compute_names) > 1: | ||
| raise ValueError(f"Multiple compute resources are not supported yet ({', '.join(sorted(compute_names))}).") | ||
|
|
||
| sole_compute = next(iter(compute_names)) | ||
|
|
||
| for service_name, service in self.services.items(): | ||
| if service.placement is None: | ||
| service.placement = sole_compute | ||
| elif service.placement not in compute_names: | ||
| raise ValueError( | ||
| f"Service '{service_name}' placement '{service.placement}' does not match any compute resource " | ||
| f"({', '.join(sorted(compute_names))})." | ||
| ) | ||
|
|
||
| if self.driver.policy_model is not None: | ||
| if self.driver.policy_model not in self.services: | ||
| raise ValueError( | ||
| f"driver.policy_model '{self.driver.policy_model}' does not match any service " | ||
| f"({', '.join(sorted(self.services))})." | ||
| ) | ||
| service = self.services[self.driver.policy_model] | ||
| if isinstance(service, BaseModelServiceConfig): | ||
| for bench_name, benchmark in self.driver.benchmarks.items(): | ||
| conflicts = [ | ||
| k for k in ("policy_base_url", "policy_model_name", "policy_api_key") if k in benchmark.run | ||
| ] | ||
| if conflicts: | ||
| raise ValueError( | ||
| f"Benchmark '{bench_name}' run config already sets {conflicts} " | ||
| f"but driver.policy_model is also set. Remove one." | ||
| ) | ||
| benchmark.run["policy_base_url"] = f"http://localhost:{service.port}/v1" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mutates benchmark.run during validation, injecting policy_base_url / policy_model_name. api.py:135-139 raises if those keys are already present. So round-tripping a config (SubmitConfig.model_validate(cfg.model_dump())) raises "already sets" on a config that just validated cleanly.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I couldn't reproduce. In my case this mechanism works well, can you give an example where this fails? |
||
| benchmark.run["policy_model_name"] = service.model | ||
| # vLLM doesn't require auth; dummy key satisfies clients that require the header. | ||
| benchmark.run["policy_api_key"] = "dummy" # pragma: allowlist secret | ||
|
|
||
| return self | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| from abc import ABC, abstractmethod | ||
|
prokotg marked this conversation as resolved.
|
||
|
|
||
| from nemo_gym.orchestration.api import SubmitConfig | ||
|
|
||
|
|
||
| class BaseExecutor(ABC): | ||
| @abstractmethod | ||
| def run(self, config: SubmitConfig, *, dry_run: bool = False) -> None: ... | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: For my subjective, personal taste, Gym is much too flat. Wondering if we should start here by having a utils package?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll leave this to a wider audience