Skip to content

Commit 10f04f5

Browse files
nicgupta-nvidiaclaudegwarmstrong
authored
feat(ray): staged Ray scheduler-executor support (RayJobClient + RayExecutor + get_executor branch) (#1435)
Signed-off-by: Nick Gupta <nicgupta@nvidia.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: George Armstrong <georgea@nvidia.com>
1 parent 4701f10 commit 10f04f5

7 files changed

Lines changed: 1164 additions & 0 deletions

File tree

cluster_configs/example-ray.yaml

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# Ray executor cluster config — initial Ray support for SFT (and eventually GRPO).
16+
#
17+
# Use this for either:
18+
# (a) standalone Ray clusters (e.g., an existing self-managed Ray deployment), or
19+
# (b) Ray-on-Slurm setups where Slurm allocates the nodes and Ray runs the
20+
# job-submission layer inside the allocation.
21+
#
22+
# This is DISTINCT from the existing `with_ray=True` flag (Ray inside a
23+
# heterogeneous Slurm allocation) and from `nemo_run.core.execution.kuberay`
24+
# (Kubernetes-managed Ray clusters via the KubeRay operator).
25+
#
26+
# Supported in this release:
27+
# - Single-command Ray jobs (SFT, eventually GRPO)
28+
# - Dependency chaining via Ray submission IDs
29+
# - Shared-FS runtime/code visibility (head + workers see the same paths)
30+
#
31+
# Out of scope (raises NotImplementedError):
32+
# - Sandbox judge containers
33+
# - Server co-scheduling (vLLM/SGLang/TRT-LLM alongside the main job)
34+
# - Heterogeneous tasks
35+
# - Multi-command task groups
36+
37+
executor: ray
38+
39+
# Ray cluster connection.
40+
ray:
41+
# "auto" attaches to a Ray cluster started in the current environment
42+
# (e.g., `ray start --head` in a Ray-on-Slurm setup, or RAY_ADDRESS env var).
43+
# Use a "ray://host:10001" URI for a remote Ray client connection.
44+
address: auto
45+
# Namespace for job isolation across users/jobs on a shared cluster.
46+
namespace: nemo
47+
# Default *per-node* CPU allocation. NeMo-Skills multiplies this by the
48+
# workflow's `num_nodes` to compute the per-job total CPU request, and Ray's
49+
# `entrypoint_num_cpus` is then derived as total / num_nodes (= this value).
50+
# GPU count is derived separately from the workflow's `num_gpus` parameter.
51+
default_num_cpus: 8
52+
53+
# Where Ray submission metadata + per-job logs are written. Should be on a
54+
# shared filesystem visible to head + workers so both sides see the same paths.
55+
jobs:
56+
log_dir: /workspace/ray_jobs
57+
58+
containers:
59+
# Ray jobs typically use the same NeMo-RL / NeMo-Skills container as Slurm —
60+
# specify the image refs here. For air-gapped deployments, use locally-built
61+
# .sqsh / pre-staged images; runtime pulls are not required for the Ray path.
62+
# Example (uncomment and fill in):
63+
# nemo-rl: <local-path-or-registry-ref>
64+
# nemo-skills: <local-path-or-registry-ref>
65+
# vllm: <local-path-or-registry-ref>
66+
67+
# Mounts visible to Ray workers. Code is auto-mounted at /nemo_run/code by
68+
# nemo-run for the Slurm path; for Ray, packaging happens via Ray's runtime_env
69+
# `working_dir`. If your shared FS already has the code staged, you do not need
70+
# to define a code mount here.
71+
#
72+
# mounts:
73+
# - <shared-fs-path>/data:/data
74+
# - <shared-fs-path>/models:/models
75+
76+
# Environment variables for Ray jobs. HF_HOME is required by default — must be a
77+
# mounted path (or a path on the shared FS visible to Ray workers).
78+
# env_vars:
79+
# - HF_HOME=/models/hf-cache
80+
# - TOKENIZERS_PARALLELISM=false

nemo_skills/pipeline/utils/exp.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
get_packager,
4848
get_registered_external_repo,
4949
)
50+
from nemo_skills.pipeline.utils.ray_executor import (
51+
RayExecutor,
52+
RayJobConfig,
53+
get_ray_client,
54+
)
5055
from nemo_skills.pipeline.utils.server import get_free_port, get_server_command
5156
from nemo_skills.utils import get_logger_name, remove_handlers
5257

@@ -270,6 +275,35 @@ def get_executor(
270275
additional_kwargs={"entrypoint": ""},
271276
)
272277

278+
if cluster_config["executor"] == "ray":
279+
# Ray top-level scheduler executor (standalone Ray clusters or Ray-on-Slurm).
280+
# Distinct from the existing `with_ray=True` flag, which is "Ray inside a Slurm
281+
# allocation" via heterogeneous Slurm jobs. Here, Ray IS the scheduler.
282+
# Actual job submission is performed in `add_task()` via `RayJobClient`; this
283+
# branch returns a `RayExecutor` config object for callers that introspect the
284+
# executor type (e.g., `Pipeline._create_executor`).
285+
ray_config = cluster_config.get("ray", {})
286+
# gpus_per_node convention (per get_executor docstring): 0 or None for
287+
# CPU-only jobs. Treat None as "caller didn't say" → default 1, but
288+
# respect an explicit 0 so CPU-only Ray jobs don't silently get a GPU.
289+
if gpus_per_node is None:
290+
ray_num_gpus = 1
291+
else:
292+
ray_num_gpus = int(gpus_per_node) * num_nodes
293+
return RayExecutor(
294+
ray_address=ray_config.get("address", "auto"),
295+
ray_namespace=ray_config.get("namespace", "nemo"),
296+
num_gpus=ray_num_gpus,
297+
# cluster_config.ray.default_num_cpus is per-node; multiply by
298+
# num_nodes to get the per-job total RayExecutor expects.
299+
num_cpus=ray_config.get("default_num_cpus", 8) * num_nodes,
300+
num_nodes=num_nodes,
301+
ntasks_per_node=tasks_per_node,
302+
log_dir=cluster_config.get("jobs", {}).get("log_dir", "/tmp/ray_jobs"),
303+
env_vars=env_vars,
304+
packager=packager,
305+
)
306+
273307
if not heterogeneous:
274308
env_vars["SLURM_MASTER_NODE"] = "$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n1)"
275309
else:
@@ -533,6 +567,78 @@ def add_task(
533567
if not is_mounted_filepath(cluster_config, env_vars["HF_HOME"]):
534568
raise RuntimeError(f"Invalid cluster_config: HF_HOME={env_vars['HF_HOME']} is not a mounted path.")
535569

570+
# Ray executor path: bypass nemo_run.Experiment.add() and submit directly via
571+
# RayJobClient. Scoped to single-command Ray jobs; unsupported modes raise
572+
# NotImplementedError to fail fast. Distinct from `with_ray=True`, which runs
573+
# Ray inside a Slurm allocation rather than as the top-level scheduler.
574+
if cluster_config["executor"] == "ray":
575+
if with_sandbox:
576+
raise NotImplementedError(
577+
"Ray executor does not support sandbox containers in this release. "
578+
"Sandbox judge containers are out of scope for the initial Ray support. "
579+
"Use cluster_config.executor='slurm' for sandbox workflows."
580+
)
581+
if server_config is not None:
582+
raise NotImplementedError(
583+
"Ray executor does not support co-scheduled servers (vLLM/SGLang/TRT-LLM) "
584+
"alongside the main job in this release. For Ray-based workflows that "
585+
"require an inference endpoint, use a separately-deployed external endpoint "
586+
"and call it via server_type='openai' (OpenAI-compatible)."
587+
)
588+
if heterogeneous:
589+
raise NotImplementedError("Ray executor does not support heterogeneous tasks in this release.")
590+
if isinstance(cmd, list) and len(cmd) > 1:
591+
raise NotImplementedError(
592+
f"Ray executor only supports single-command tasks in this release. Got {len(cmd)} commands."
593+
)
594+
if with_ray:
595+
raise NotImplementedError(
596+
"with_ray=True (Ray-inside-Slurm-allocation, heterogeneous Slurm jobs) is "
597+
"not compatible with cluster_config.executor='ray' (Ray-as-top-level-scheduler). "
598+
"Use exactly one path per task."
599+
)
600+
601+
# Validate task_name length already happened above. Build the Ray submission.
602+
ray_cmd = cmd if isinstance(cmd, str) else cmd[0]
603+
ray_dependencies = []
604+
for dep in task_dependencies or []:
605+
# task_dependencies for the Ray path must be Ray submission IDs (strings).
606+
# Slurm callers commonly pass nemo-run task handles; silently dropping those
607+
# would cause the new job to run without waiting for its dependency, defeating
608+
# the contract without any signal. Fail fast instead.
609+
if not isinstance(dep, str):
610+
raise NotImplementedError(
611+
f"Ray executor task_dependencies must be Ray submission IDs (str); "
612+
f"got {type(dep).__name__}. If you copied a slurm pattern, replace the "
613+
f"nemo-run task handle with the string returned by the prior "
614+
f"add_task() call under cluster_config.executor='ray'."
615+
)
616+
ray_dependencies.append(dep)
617+
ray_cluster_config = cluster_config.get("ray", {})
618+
# default_num_cpus is per-node; multiply by num_nodes to get the per-job total.
619+
ray_default_cpus_per_node = ray_cluster_config.get("default_num_cpus", 8)
620+
ray_log_dir = log_dir or cluster_config.get("jobs", {}).get("log_dir", "/tmp/ray_jobs")
621+
622+
ray_job_config = RayJobConfig(
623+
name=task_name,
624+
command=ray_cmd,
625+
num_gpus=num_gpus if num_gpus is not None else 1,
626+
num_cpus=ray_default_cpus_per_node * num_nodes,
627+
num_nodes=num_nodes,
628+
env_vars=env_vars,
629+
log_dir=ray_log_dir,
630+
dependencies=ray_dependencies if ray_dependencies else None,
631+
)
632+
633+
if dry_run:
634+
LOG.info("Dry run mode: would submit Ray job %s with command: %s", task_name, ray_cmd)
635+
return f"<dry-run-ray:{task_name}>"
636+
637+
ray_client = get_ray_client(cluster_config)
638+
submission_id = ray_client.submit_job(ray_job_config)
639+
LOG.info("Ray job submitted: task=%s submission_id=%s", task_name, submission_id)
640+
return submission_id
641+
536642
het_group = 0
537643
het_group_indices = []
538644
total_het_groups = (n_servers if server_config is not None else 0) + bool(cmd) + with_sandbox

0 commit comments

Comments
 (0)