Isaac Lab supports Ray for streamlining dispatching multiple training jobs (in parallel and in series), and hyperparameter tuning, both on local and remote configurations.
This independent community contributed walkthrough video demonstrates some of the core functionality of the Ray integration covered in this overview. Although there may be some differences in the codebase (such as file names being shortened) since the creation of the video, the general workflow is the same.
This functionality is experimental, and has been tested only on Linux.
Security Notice: Due to security risks associated with Ray, this workflow is not intended for use outside of a strictly controlled network environment. Ray clusters should only be deployed in trusted, isolated networks with appropriate access controls and security measures in place.
Overview
The Ray integration is useful for the following.
- Dispatching several training jobs in parallel or sequentially with minimal interaction.
- Tuning hyperparameters; in parallel or sequentially with support for multiple GPUs and/or multiple GPU Nodes.
- Using the same training setup everywhere (on cloud and local) with minimal overhead.
- Resource Isolation for training jobs (resource-wrapped jobs).
The core functionality of the Ray workflow consists of two main scripts that enable the orchestration of resource-wrapped and tuning aggregate jobs. In resource-wrapped aggregate jobs, each sub-job and its resource requirements are defined manually, enabling resource isolation. For tuning aggregate jobs, individual jobs are generated automatically based on a hyperparameter sweep configuration.
Both resource-wrapped and tuning aggregate jobs dispatch individual jobs to a designated Ray cluster, which leverages the cluster’s resources (e.g., a single workstation node or multiple nodes) to execute these jobs with workers in parallel and/or sequentially.
By default, jobs use all
available resources on each available GPU-enabled node for each sub-job worker. This can be changed through
specifying the --num_workers argument for resource-wrapped jobs, or --num_workers_per_node
for tuning jobs, which is especially critical for parallel aggregate
job processing on local/virtual multi-GPU machines. Tuning jobs assume homogeneous node resource composition for nodes with GPUs.
The three following files contain the core functionality of the Ray integration.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clause"""This script dispatches sub-job(s) (individual jobs, use `tuner.py` for tuning jobs)to worker(s) on GPU-enabled node(s) of a specific cluster as part of an resource-wrapped aggregatejob. If no desired compute resources for each sub-job are specified,this script creates one worker per available node for each node with GPU(s) in the cluster.If the desired resources for each sub-job is specified,the maximum number of workers possible with the desired resources are created for each nodewith GPU(s) in the cluster. It is also possible to split available node resources for each nodeinto the desired number of workers with the `--num_workers` flag, to be able to easilyparallelize sub-jobs on multi-GPU nodes. Due to Isaac Lab requiring a GPU,this ignores all CPU only nodes such as loggers.Sub-jobs are matched with node(s) in a cluster via the following relation:sorted_nodes = Node sorted by descending GPUs, then descending CPUs, then descending RAM, then node IDnode_submitted_to = sorted_nodes[job_index % total_node_count]To check the ordering of sorted nodes, supply the `--test` argument and run the script.Sub-jobs are separated by the + delimiter. The `--sub_jobs` argument must be the lastargument supplied to the script.If there is more than one available worker, and more than one sub-job,sub-jobs will be executed in parallel. If there are more sub-jobs than workers, sub-jobs willbe dispatched to workers as they become available. There is no limit on the numberof sub-jobs that can be near-simultaneously submitted.This script is meant to be executed on a Ray cluster head node as an aggregate cluster job.To submit aggregate cluster jobs such as this script to one or more remote clusters,see `../submit_isaac_ray_job.py`.KubeRay clusters on Google GKE can be created with `../launch.py`Usage:~~~bash# **Ensure that sub-jobs are separated by the `+` delimiter.**# Generic Templates-----------------------------------uv run python scripts/reinforcement_learning/ray/wrap_resources.py -h# No resource isolation; no parallelization:uv run python scripts/reinforcement_learning/ray/wrap_resources.py--sub_jobs <JOB0>+<JOB1>+<JOB2># Automatic Resource Isolation; Example A: needed for parallelizationuv run python scripts/reinforcement_learning/ray/wrap_resources.py \--num_workers <NUM_TO_DIVIDE_TOTAL_RESOURCES_BY> \--sub_jobs <JOB0>+<JOB1># Manual Resource Isolation; Example B: needed for parallelizationuv run python scripts/reinforcement_learning/ray/wrap_resources.py --num_cpu_per_worker <CPU> \--gpu_per_worker <GPU> --ram_gb_per_worker <RAM> --sub_jobs <JOB0>+<JOB1># Manual Resource Isolation; Example C: Needed for parallelization, for heterogeneous workloadsuv run python scripts/reinforcement_learning/ray/wrap_resources.py --num_cpu_per_worker <CPU> \--gpu_per_worker <GPU1> <GPU2> --ram_gb_per_worker <RAM> --sub_jobs <JOB0>+<JOB1># to see all argumentsuv run python scripts/reinforcement_learning/ray/wrap_resources.py -h~~~"""import argparseimport utildef wrap_resources_to_jobs(jobs: list[str], args: argparse.Namespace) -> None:"""Provided a list of jobs, dispatch jobs to one worker per available node,unless otherwise specified by resource constraints.Args:jobs: bash commands to execute on a Ray clusterargs: The arguments for resource allocation"""job_objs = []util.ray_init(ray_address=args.ray_address,runtime_env={"py_modules": None if not args.py_modules else args.py_modules,},log_to_driver=False,)gpu_node_resources = util.get_gpu_node_resources(include_id=True, include_gb_ram=True)if any([args.gpu_per_worker, args.cpu_per_worker, args.ram_gb_per_worker]) and args.num_workers:raise ValueError("Either specify only num_workers or only granular resources(GPU,CPU,RAM_GB).")num_nodes = len(gpu_node_resources)# Populate argumentsformatted_node_resources = {"gpu_per_worker": [gpu_node_resources[i]["GPU"] for i in range(num_nodes)],"cpu_per_worker": [gpu_node_resources[i]["CPU"] for i in range(num_nodes)],"ram_gb_per_worker": [gpu_node_resources[i]["ram_gb"] for i in range(num_nodes)],"num_workers": args.num_workers, # By default, 1 worker por node}args = util.fill_in_missing_resources(args, resources=formatted_node_resources, policy=min)print(f"[INFO]: Number of GPU nodes found: {num_nodes}")if args.test:jobs = ["nvidia-smi"] * num_nodesfor i, job in enumerate(jobs):gpu_node = gpu_node_resources[i % num_nodes]print(f"[INFO]: Creating job {i + 1} of {len(jobs)} with job '{job}' to node {gpu_node}")print(f"[INFO]: Resource parameters: GPU: {args.gpu_per_worker[i]}"f" CPU: {args.cpu_per_worker[i]} RAM {args.ram_gb_per_worker[i]}")print(f"[INFO] For the node parameters, creating {args.num_workers[i]} workers")num_gpus = args.gpu_per_worker[i] / args.num_workers[i]num_cpus = args.cpu_per_worker[i] / args.num_workers[i]memory = (args.ram_gb_per_worker[i] * 1024**3) / args.num_workers[i]job_objs.append(util.Job(cmd=job,name=f"Job-{i + 1}",resources=util.JobResource(num_gpus=num_gpus, num_cpus=num_cpus, memory=memory),node=util.JobNode(specific="node_id",node_id=gpu_node["id"],),))# submit jobsutil.submit_wrapped_jobs(jobs=job_objs, test_mode=args.test, concurrent=False)if __name__ == "__main__":parser = argparse.ArgumentParser(description="Submit multiple jobs with optional GPU testing.")parser = util.add_resource_arguments(arg_parser=parser)parser.add_argument("--ray_address", type=str, default="auto", help="the Ray address.")parser.add_argument("--test",action="store_true",help=("Run nvidia-smi test instead of the arbitrary job,""can use as a sanity check prior to any jobs to check ""that GPU resources are correctly isolated."),)parser.add_argument("--py_modules",type=str,nargs="*",default=[],help=("List of python modules or paths to add before running the job. Example: --py_modules my_package/my_package"),)parser.add_argument("--sub_jobs",type=str,nargs=argparse.REMAINDER,help="This should be last wrapper argument. Jobs separated by the + delimiter to run on a cluster.",)args = parser.parse_args()if args.sub_jobs is not None:jobs = " ".join(args.sub_jobs)formatted_jobs = jobs.split("+")else:formatted_jobs = []print(f"[INFO]: Isaac Ray Wrapper received jobs {formatted_jobs=}")wrap_resources_to_jobs(jobs=formatted_jobs, args=args)
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clause"""This script dispatches one or more user-defined Python tasks to workers in a Ray cluster.Each task, along with its resource requirements and execution parameters, is specified in a YAML configuration file.Users may define the number of CPUs, GPUs, and the amount of memory to allocate per task via the config file.Key features:-------------- Fine-grained, per-task resource management via config fields (`num_gpus`, `num_cpus`, `memory`).- Parallel execution of multiple tasks using available resources across the Ray cluster.- Option to specify node affinity for tasks, e.g., by hostname, node ID, or any node.- Optional batch (simultaneous) or independent scheduling of tasks.Task scheduling and distribution are handled via Ray’s built-in resource manager.YAML configuration fields:--------------------------- `pip`: List of extra pip packages to install before running any tasks.- `py_modules`: List of additional Python module paths (directories or files) to include in the runtime environment.- `concurrent`: (bool) It determines task dispatch semantics:- If `concurrent: true`, **all tasks are scheduled as a batch**. The script waits untilsufficient resources are available for every task in the batch, then launches all taskstogether. If resources are insufficient, all tasks remain blocked until the cluster cansupport the full batch.- If `concurrent: false`, tasks are launched as soon as resources are available for eachindividual task, and Ray independently schedules them. This may result in non-simultaneoustask start times.- `tasks`: List of task specifications, each with:- `name`: String identifier for the task.- `py_args`: Arguments to the Python interpreter (e.g., script/module, flags, user arguments).- `num_gpus`: Number of GPUs to allocate (float or string arithmetic, e.g., "2*2").- `num_cpus`: Number of CPUs to allocate (float or string).- `memory`: Amount of RAM in bytes (int or string).- `node` (optional): Node placement constraints.- `specific` (str): Type of node placement, support `hostname`, `node_id`, or `any`.- `any`: Place the task on any available node.- `hostname`: Place the task on a specific hostname. `hostname` must be specifiedin the node field.- `node_id`: Place the task on a specific node ID. `node_id` must be specified inthe node field.- `hostname` (str): Specific hostname to place the task on.- `node_id` (str): Specific node ID to place the task on.Typical usage:--------------~~~bash# Print help and argument details:python task_runner.py -h# Submit tasks defined in a YAML file to the Ray cluster (auto-detects Ray head address):python task_runner.py --task_cfg /path/to/tasks.yaml~~~YAML configuration example-1:-----------------------------~~~yamlpip: ["xxx"]py_modules: ["my_package/my_package"]concurrent: falsetasks:- name: "Isaac-Cartpole"py_args: "-m torch.distributed.run --nnodes=1 --nproc_per_node=2 --rdzv_endpoint=localhost:29501 /workspace/isaaclab/scripts/reinforcement_learning/train.py --rl_library rsl_rl --task=Isaac-Cartpole --max_iterations 200 --distributed"num_gpus: 2num_cpus: 10memory: 10737418240- name: "script need some dependencies"py_args: "script.py --option arg"num_gpus: 0num_cpus: 1memory: 10*1024*1024*1024~~~YAML configuration example-2:-----------------------------~~~yamlpip: ["xxx"]py_modules: ["my_package/my_package"]concurrent: truetasks:- name: "Isaac-Cartpole-multi-node-train-1"py_args: "-m torch.distributed.run --nproc_per_node=1 --nnodes=2 --node_rank=0 --rdzv_id=123 --rdzv_backend=c10d --rdzv_endpoint=localhost:5555 /workspace/isaaclab/scripts/reinforcement_learning/train.py --rl_library rsl_rl --task=Isaac-Cartpole --distributed --max_iterations 1000"num_gpus: 1num_cpus: 10memory: 10*1024*1024*1024node:specific: "hostname"hostname: "xxx"- name: "Isaac-Cartpole-multi-node-train-2"py_args: "-m torch.distributed.run --nproc_per_node=1 --nnodes=2 --node_rank=1 --rdzv_id=123 --rdzv_backend=c10d --rdzv_endpoint=x.x.x.x:5555 /workspace/isaaclab/scripts/reinforcement_learning/train.py --rl_library rsl_rl --task=Isaac-Cartpole --distributed --max_iterations 1000"num_gpus: 1num_cpus: 10memory: 10*1024*1024*1024node:specific: "hostname"hostname: "xxx"~~~To stop all tasks early, press Ctrl+C; the script will cancel all running Ray tasks.""" # noqa: E501import argparseimport astimport operatorfrom datetime import datetimeimport yaml# Local importsimport util # isort: skip# Safe operators for arithmetic expression evaluation_SAFE_OPERATORS = {ast.Add: operator.add,ast.Sub: operator.sub,ast.Mult: operator.mul,ast.Div: operator.truediv,ast.FloorDiv: operator.floordiv,ast.Pow: operator.pow,ast.Mod: operator.mod,ast.USub: operator.neg,ast.UAdd: operator.pos,}def safe_eval_arithmetic(expr: str) -> int | float:"""Safely evaluate a string containing only arithmetic expressions.Supports: +, -, *, /, //, **, % and numeric literals.Raises ValueError for any non-arithmetic expressions.Args:expr: A string containing an arithmetic expression (e.g., "10*1024*1024").Returns:The numeric result of the expression.Raises:ValueError: If the expression contains non-arithmetic operations."""def _eval_node(node: ast.AST) -> int | float:if isinstance(node, ast.Expression):return _eval_node(node.body)elif isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):return node.valueelif isinstance(node, ast.BinOp) and type(node.op) in _SAFE_OPERATORS:left = _eval_node(node.left)right = _eval_node(node.right)return _SAFE_OPERATORS[type(node.op)](left, right)elif isinstance(node, ast.UnaryOp) and type(node.op) in _SAFE_OPERATORS:operand = _eval_node(node.operand)return _SAFE_OPERATORS[type(node.op)](operand)else:raise ValueError(f"Unsafe expression: {ast.dump(node)}")try:tree = ast.parse(expr.strip(), mode="eval")return _eval_node(tree)except (SyntaxError, TypeError) as e:raise ValueError(f"Invalid arithmetic expression: {expr}") from edef parse_args() -> argparse.Namespace:"""Parse command-line arguments for the Ray task runner.Returns:A namespace containing parsed CLI arguments:- task_cfg (str): Path to the YAML task file.- ray_address (str): Ray cluster address.- test (bool): Whether to run a GPU resource isolation sanity check."""parser = argparse.ArgumentParser(description="Run tasks from a YAML config file.")parser.add_argument("--task_cfg", type=str, required=True, help="Path to the YAML task file.")parser.add_argument("--ray_address", type=str, default="auto", help="the Ray address.")parser.add_argument("--test",action="store_true",help=("Run nvidia-smi test instead of the arbitrary job,""can use as a sanity check prior to any jobs to check ""that GPU resources are correctly isolated."),)return parser.parse_args()def parse_task_resource(task: dict) -> util.JobResource:"""Parse task resource requirements from the YAML configuration.Args:task (dict): Dictionary representing a single task's configuration.Keys may include `num_gpus`, `num_cpus`, and `memory`, each eitheras a number or evaluatable string expression.Returns:util.JobResource: Resource object with the parsed values."""resource = util.JobResource()if "num_gpus" in task:value = task["num_gpus"]resource.num_gpus = safe_eval_arithmetic(value) if isinstance(value, str) else valueif "num_cpus" in task:value = task["num_cpus"]resource.num_cpus = safe_eval_arithmetic(value) if isinstance(value, str) else valueif "memory" in task:value = task["memory"]resource.memory = safe_eval_arithmetic(value) if isinstance(value, str) else valuereturn resourcedef run_tasks(tasks: list[dict], args: argparse.Namespace, runtime_env: dict | None = None, concurrent: bool = False) -> None:"""Submit tasks to the Ray cluster for execution.Args:tasks (list[dict]): A list of task configuration dictionaries.args (argparse.Namespace): Parsed command-line arguments.runtime_env (dict | None): Ray runtime environment configuration containing:- pip (list[str] | None): Additional pip packages to install.- py_modules (list[str] | None): Python modules to include in the environment.concurrent (bool): Whether to launch tasks simultaneously as a batch,or independently as resources become available.Returns:None"""job_objs = []util.ray_init(ray_address=args.ray_address, runtime_env=runtime_env, log_to_driver=False)for task in tasks:resource = parse_task_resource(task)print(f"[INFO] Creating job {task['name']} with resource={resource}")job = util.Job(name=task["name"],py_args=task["py_args"],resources=resource,node=util.JobNode(specific=task.get("node", {}).get("specific"),hostname=task.get("node", {}).get("hostname"),node_id=task.get("node", {}).get("node_id"),),)job_objs.append(job)start = datetime.now()print(f"[INFO] Creating {len(job_objs)} jobs at {start.strftime('%H:%M:%S.%f')} with runtime env={runtime_env}")# submit jobsutil.submit_wrapped_jobs(jobs=job_objs,test_mode=args.test,concurrent=concurrent,)end = datetime.now()print(f"[INFO] All jobs completed at {end.strftime('%H:%M:%S.%f')}, took {(end - start).total_seconds():.2f} seconds.")def main() -> None:"""Main entry point for the Ray task runner script.Reads the YAML task configuration file, parses CLI arguments,and dispatches tasks to the Ray cluster.Returns:None"""args = parse_args()with open(args.task_cfg) as f:config = yaml.safe_load(f)tasks = config["tasks"]runtime_env = {"pip": None if not config.get("pip") else config["pip"],"py_modules": None if not config.get("py_modules") else config["py_modules"],}concurrent = config.get("concurrent", False)run_tasks(tasks=tasks,args=args,runtime_env=runtime_env,concurrent=concurrent,)if __name__ == "__main__":main()
The following script can be used to submit aggregate jobs to one or more Ray cluster(s), which can be used for running jobs on a remote cluster or simultaneous jobs with heterogeneous resource requirements.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clause"""This script submits aggregate job(s) to cluster(s) described in aconfig file containing `name: <NAME> address: http://<IP>:<PORT>` ona new line for each cluster. For KubeRay clusters, this filecan be automatically created with `grok_cluster_with_kubectl.py`Aggregate job(s) are matched with cluster(s) via the following relation:cluster_line_index_submitted_to = job_index % total_cluster_countAggregate jobs are separated by the * delimiter. The `--aggregate_jobs` argument must bethe last argument supplied to the script.An aggregate job could be a `../tuner.py` tuning job, which automaticallycreates several individual jobs when started on a cluster. Alternatively, an aggregate jobcould be a :file:'../wrap_resources.py` resource-wrapped job,which may contain several individual sub-jobs separated bythe + delimiter. An aggregate job could also be a `../task_runner.py` multi-task submission job,where each sub-job and its resource requirements are defined in a YAML configuration file.In this mode, `../task_runner.py` will read the YAML file (via --task_cfg), andsubmit all defined sub-tasks to the Ray cluster, supporting per-job resource specification andreal-time streaming of sub-job outputs.If there are more aggregate jobs than cluster(s), aggregate jobs will be submittedas clusters become available via the defined relation above. If there are less aggregate job(s)than clusters, some clusters will not receive aggregate job(s). The maximum number ofaggregate jobs that can be run simultaneously is equal to the number of workers created bydefault by a ThreadPoolExecutor on the machine submitting jobs due to fetching the log output afterjobs finish, which is unlikely to constrain overall-job submission.Usage:~~~bash# Example; submitting a tuning jobpython3 scripts/reinforcement_learning/ray/submit_job.py \--aggregate_jobs /workspace/isaaclab/scripts/reinforcement_learning/ray/tuner.py \--cfg_file hyperparameter_tuning/vision_cartpole_cfg.py \--cfg_class CartpoleTheiaJobCfg --mlflow_uri <ML_FLOW_URI># Example: Submitting resource wrapped jobpython3 scripts/reinforcement_learning/ray/submit_job.py --aggregate_jobs wrap_resources.py --test# Example: submitting tasks with specific resources, and supporting pip packages and py_modules# You may use relative paths for task_cfg and py_modules, placing them in the# "scripts/reinforcement_learning/ray" directory, which will be uploaded to the cluster.python3 scripts/reinforcement_learning/ray/submit_job.py --aggregate_jobs task_runner.py --task_cfg tasks.yaml# For all command line argumentspython3 scripts/reinforcement_learning/ray/submit_job.py -h~~~"""import argparseimport osimport timefrom concurrent.futures import ThreadPoolExecutorfrom ray import job_submissionscript_directory = os.path.dirname(os.path.abspath(__file__))CONFIG = {"working_dir": script_directory, "executable": "/workspace/isaaclab/isaaclab.sh -p"}def read_cluster_spec(fn: str | None = None) -> list[dict]:if fn is None:cluster_spec_path = os.path.expanduser("~/.cluster_config")else:cluster_spec_path = os.path.expanduser(fn)if not os.path.exists(cluster_spec_path):raise FileNotFoundError(f"Cluster spec file not found at {cluster_spec_path}")clusters = []with open(cluster_spec_path) as f:for line in f:parts = line.strip().split(" ")http_address = parts[3]cluster_info = {"name": parts[1], "address": http_address}print(f"[INFO] Setting {cluster_info['name']}") # with {cluster_info['num_gpu']} GPUs.")clusters.append(cluster_info)return clustersdef submit_job(cluster: dict, job_command: str) -> None:"""Submits a job to a single cluster, prints the final result and Ray dashboard URL at the end."""address = cluster["address"]cluster_name = cluster["name"]print(f"[INFO]: Submitting job to cluster '{cluster_name}' at {address}") # with {num_gpus} GPUs.")client = job_submission.JobSubmissionClient(address)runtime_env = {"working_dir": CONFIG["working_dir"], "executable": CONFIG["executable"]}print(f"[INFO]: Checking contents of the directory: {CONFIG['working_dir']}")try:dir_contents = os.listdir(CONFIG["working_dir"])print(f"[INFO]: Directory contents: {dir_contents}")except Exception as e:print(f"[INFO]: Failed to list directory contents: {str(e)}")entrypoint = f"{CONFIG['executable']} {job_command}"print(f"[INFO]: Attempting entrypoint {entrypoint=} in cluster {cluster}")job_id = client.submit_job(entrypoint=entrypoint, runtime_env=runtime_env)status = client.get_job_status(job_id)while status in [job_submission.JobStatus.PENDING, job_submission.JobStatus.RUNNING]:time.sleep(5)status = client.get_job_status(job_id)final_logs = client.get_job_logs(job_id)print("----------------------------------------------------")print(f"[INFO]: Cluster {cluster_name} Logs: \n")print(final_logs)print("----------------------------------------------------")def submit_jobs_to_clusters(jobs: list[str], clusters: list[dict]) -> None:"""Submit all jobs to their respective clusters, cycling through clusters if there are more jobs than clusters."""if not clusters:raise ValueError("No clusters available for job submission.")if len(jobs) < len(clusters):print("[INFO]: Less jobs than clusters, some clusters will not receive jobs")elif len(jobs) == len(clusters):print("[INFO]: Exactly one job per cluster")else:print("[INFO]: More jobs than clusters, jobs submitted as clusters become available.")with ThreadPoolExecutor() as executor:for idx, job_command in enumerate(jobs):# Cycle through clusters using modulus to wrap around if there are more jobs than clusterscluster = clusters[idx % len(clusters)]executor.submit(submit_job, cluster, job_command)if __name__ == "__main__":parser = argparse.ArgumentParser(description="Submit multiple GPU jobs to multiple Ray clusters.")parser.add_argument("--config_file", default="~/.cluster_config", help="The cluster config path.")parser.add_argument("--aggregate_jobs",type=str,nargs=argparse.REMAINDER,help="This should be last argument. The aggregate jobs to submit separated by the * delimiter.",)args = parser.parse_args()if args.aggregate_jobs is not None:jobs = " ".join(args.aggregate_jobs)formatted_jobs = jobs.split("*")if len(formatted_jobs) > 1:print("Warning; Split jobs by cluster with the * delimiter")else:formatted_jobs = []print(f"[INFO]: Isaac Ray Wrapper received jobs {formatted_jobs=}")clusters = read_cluster_spec(args.config_file)submit_jobs_to_clusters(formatted_jobs, clusters)
The following script can be used to extract KubeRay cluster information for aggregate job submission.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clauseimport argparseimport osimport reimport subprocessimport threadingimport timefrom concurrent.futures import ThreadPoolExecutor, as_completed"""This script requires that kubectl is installed and KubeRay was used to create the cluster.Creates a config file containing `name: <NAME> address: http://<IP>:<PORT>` ona new line for each cluster, and also fetches the MLFlow URI.Usage:~~~bashpython3 scripts/reinforcement_learning/ray/grok_cluster_with_kubectl.py# For options, supply -h arg~~~"""def get_namespace() -> str:"""Get the current Kubernetes namespace from the context, fallback to default if not set"""try:namespace = (subprocess.check_output(["kubectl", "config", "view", "--minify", "--output", "jsonpath={..namespace}"]).decode().strip())if not namespace:namespace = "default"except subprocess.CalledProcessError:namespace = "default"return namespacedef get_pods(namespace: str = "default") -> list[tuple]:"""Get a list of all of the pods in the namespace"""cmd = ["kubectl", "get", "pods", "-n", namespace, "--no-headers"]output = subprocess.check_output(cmd).decode()pods = []for line in output.strip().split("\n"):fields = line.split()pod_name = fields[0]status = fields[2]pods.append((pod_name, status))return podsdef get_clusters(pods: list, cluster_name_prefix: str) -> set:"""Get unique cluster name(s). Works for one or more clusters, based off of the number of head nodes.Excludes MLflow deployments."""clusters = set()for pod_name, _ in pods:# Skip MLflow podsif "-mlflow" in pod_name:continuematch = re.match(r"(" + re.escape(cluster_name_prefix) + r"[-\w]+)", pod_name)if match:# Get base name without head/worker suffix (skip workers)if "head" in pod_name:base_name = match.group(1).split("-head")[0]clusters.add(base_name)return sorted(clusters)def get_mlflow_info(namespace: str = None, cluster_prefix: str = "isaacray") -> str:"""Get MLflow service information if it exists in the namespace with the given prefix.Only works for a single cluster instance.Args:namespace: Kubernetes namespacecluster_prefix: Base cluster name (without -head/-worker suffixes)Returns:MLflow service URL"""# Strip any -head or -worker suffixes to get base nameif namespace is None:namespace = get_namespace()pods = get_pods(namespace=namespace)clusters = get_clusters(pods=pods, cluster_name_prefix=cluster_prefix)if len(clusters) > 1:raise ValueError("More than one cluster matches prefix, could not automatically determine mlflow info.")mlflow_name = f"{cluster_prefix}-mlflow"cmd = ["kubectl", "get", "svc", mlflow_name, "-n", namespace, "--no-headers"]try:output = subprocess.check_output(cmd).decode()fields = output.strip().split()# Get cluster IPcluster_ip = fields[2]port = "5000" # Default MLflow port# This needs to be http to be resolved. HTTPS can't be resolved# This should be fine as it is on a subnet on the cluster regardlessreturn f"http://{cluster_ip}:{port}"except subprocess.CalledProcessError as e:raise ValueError(f"Could not grok MLflow: {e}") # Fixed f-stringdef check_clusters_running(pods: list, clusters: set) -> bool:"""Check that all of the pods in all provided clusters are running.Args:pods (list): A list of tuples where each tuple contains the pod name and its status.clusters (set): A set of cluster names to check.Returns:bool: True if all pods in any of the clusters are running, False otherwise."""clusters_running = Falsefor cluster in clusters:cluster_pods = [p for p in pods if p[0].startswith(cluster)]total_pods = len(cluster_pods)running_pods = len([p for p in cluster_pods if p[1] == "Running"])if running_pods == total_pods and running_pods > 0:clusters_running = Truebreakreturn clusters_runningdef get_ray_address(head_pod: str, namespace: str = "default", ray_head_name: str = "head") -> str:"""Given a cluster head pod, check its logs, which should include the ray address which can accept job requests.Args:head_pod (str): The name of the head pod.namespace (str, optional): The Kubernetes namespace. Defaults to "default".ray_head_name (str, optional): The name of the ray head container. Defaults to "head".Returns:str: The ray address if found, None otherwise.Raises:ValueError: If the logs cannot be retrieved or the ray address is not found."""cmd = ["kubectl", "logs", head_pod, "-c", ray_head_name, "-n", namespace]try:output = subprocess.check_output(cmd).decode()except subprocess.CalledProcessError as e:raise ValueError(f"Could not enter head container with cmd {cmd}: {e}Perhaps try a different namespace or ray head name.")match = re.search(r"RAY_ADDRESS='([^']+)'", output)if match:return match.group(1)else:return Nonedef process_cluster(cluster_info: dict, ray_head_name: str = "head") -> str:"""For each cluster, check that it is running, and get the Ray head address that will accept jobs.Args:cluster_info: A dictionary containing cluster information with keys 'cluster', 'pods', and 'namespace'.ray_head_name: The name of the ray head container. Defaults to "head".Returns:A string containing the cluster name and its Ray head address, or an error message ifthe head pod or Ray address is not found."""cluster, pods, namespace = cluster_infohead_pod = Nonefor pod_name, status in pods:if pod_name.startswith(cluster + "-head"):head_pod = pod_namebreakif not head_pod:return f"Error: Could not find head pod for cluster {cluster}\n"# Get RAY_ADDRESS and statusray_address = get_ray_address(head_pod, namespace=namespace, ray_head_name=ray_head_name)if not ray_address:return f"Error: Could not find RAY_ADDRESS for cluster {cluster}\n"# Return only cluster and ray addressreturn f"name: {cluster} address: {ray_address}\n"def main():# Parse command-line argumentsparser = argparse.ArgumentParser(description="Process Ray clusters and save their specifications.")parser.add_argument("--prefix", default="isaacray", help="The prefix for the cluster names.")parser.add_argument("--output", default="~/.cluster_config", help="The file to save cluster specifications.")parser.add_argument("--ray_head_name", default="head", help="The metadata name for the ray head container")parser.add_argument("--namespace", help="Kubernetes namespace to use. If not provided, will detect from current context.")args = parser.parse_args()# Get namespace from args or detect itcurrent_namespace = args.namespace if args.namespace else get_namespace()print(f"Using namespace: {current_namespace}")cluster_name_prefix = args.prefixcluster_spec_file = os.path.expanduser(args.output)# Get all podspods = get_pods(namespace=current_namespace)# Get clustersclusters = get_clusters(pods, cluster_name_prefix)if not clusters:print(f"No clusters found with prefix {cluster_name_prefix}")return# Wait for clusters to be runningwhile True:pods = get_pods(namespace=current_namespace)if check_clusters_running(pods, clusters):breakprint("Waiting for all clusters to spin up...")time.sleep(5)print("Checking for MLflow:")# Check MLflow status for each clusterfor cluster in clusters:try:mlflow_address = get_mlflow_info(current_namespace, cluster)print(f"MLflow address for {cluster}: {mlflow_address}")except ValueError as e:print(f"ML Flow not located: {e}")print()# Prepare cluster info for parallel processingcluster_infos = []for cluster in clusters:cluster_pods = [p for p in pods if p[0].startswith(cluster)]cluster_infos.append((cluster, cluster_pods, current_namespace))# Use ThreadPoolExecutor to process clusters in parallelresults = []results_lock = threading.Lock()with ThreadPoolExecutor() as executor:future_to_cluster = {executor.submit(process_cluster, info, args.ray_head_name): info[0] for info in cluster_infos}for future in as_completed(future_to_cluster):cluster_name = future_to_cluster[future]try:result = future.result()with results_lock:results.append(result)except Exception as exc:print(f"{cluster_name} generated an exception: {exc}")# Sort results alphabetically by cluster nameresults.sort()# Write sorted results to the output file (Ray info only)with open(cluster_spec_file, "w") as f:for result in results:f.write(result)print(f"Cluster spec information saved to {cluster_spec_file}")# Display the contents of the config filewith open(cluster_spec_file) as f:print(f.read())if __name__ == "__main__":main()
The following script can be used to easily create clusters on Google GKE.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clause"""This script helps create one or more KubeRay clusters.Usage:~~~bash# If the head node is stuck on container creating, make sure to create a secretpython3 scripts/reinforcement_learning/ray/launch.py -h# Examples# The following creates 8 GPUx1 nvidia l4 workerspython3 scripts/reinforcement_learning/ray/launch.py --cluster_host google_cloud \--namespace <NAMESPACE> --image <YOUR_ISAAC_RAY_IMAGE> \--num_workers 8 --num_clusters 1 --worker_accelerator nvidia-l4 --gpu_per_worker 1# The following creates 1 GPUx1 nvidia l4 worker, 2 GPUx2 nvidia-tesla-t4 workers,# and 2 GPUx4 nvidia-tesla-t4 GPU workerspython3 scripts/reinforcement_learning/ray/launch.py --cluster_host google_cloud \--namespace <NAMESPACE> --image <YOUR_ISAAC_RAY_IMAGE> \--num_workers 1 2 --num_clusters 1 \--worker_accelerator nvidia-l4 nvidia-tesla-t4 --gpu_per_worker 1 2 4~~~"""import argparseimport pathlibimport subprocessimport yamlfrom jinja2 import Environment, FileSystemLoaderfrom kubernetes import config# Local importsimport util # isort: skipRAY_DIR = pathlib.Path(__file__).parentdef apply_manifest(args: argparse.Namespace) -> None:"""Provided a Jinja templated ray.io/v1alpha1 file,populate the arguments and create the cluster. Additionally, createkubernetes containers for resources separated by '---' from the restof the file.Args:args: Possible arguments concerning cluster parameters."""# Load Kubernetes configurationconfig.load_kube_config()# Set up Jinja2 environment for loading templatestemplates_dir = RAY_DIR / "cluster_configs" / args.cluster_hostfile_loader = FileSystemLoader(str(templates_dir))jinja_env = Environment(loader=file_loader, keep_trailing_newline=True, autoescape=True)# Define template filenametemplate_file = "kuberay.yaml.jinja"# Convert args namespace to a dictionarytemplate_params = vars(args)# Load and render the templatetemplate = jinja_env.get_template(template_file)file_contents = template.render(template_params)# Parse all YAML documents in the rendered templateall_yamls = []for doc in yaml.safe_load_all(file_contents):all_yamls.append(doc)# Convert back to YAML string, preserving multiple documentscleaned_yaml_string = ""for i, doc in enumerate(all_yamls):if i > 0:cleaned_yaml_string += "\n---\n"cleaned_yaml_string += yaml.dump(doc)# Apply the Kubernetes manifest using kubectltry:print(cleaned_yaml_string)subprocess.run(["kubectl", "apply", "-f", "-"], input=cleaned_yaml_string, text=True, check=True)except subprocess.CalledProcessError as e:exit(f"An error occurred while running `kubectl`: {e}")def parse_args() -> argparse.Namespace:"""Parse command-line arguments for Kubernetes deployment script.Returns:argparse.Namespace: Parsed command-line arguments."""arg_parser = argparse.ArgumentParser(description="Script to apply manifests to create Kubernetes objects for Ray clusters.",formatter_class=argparse.ArgumentDefaultsHelpFormatter,)arg_parser.add_argument("--cluster_host",type=str,default="google_cloud",choices=["google_cloud"],help=("In the cluster_configs directory, the name of the folder where a tune.yaml.jinja""file exists defining the KubeRay config. Currently only google_cloud is supported."),)arg_parser.add_argument("--name",type=str,required=False,default="isaacray",help="Name of the Kubernetes deployment.",)arg_parser.add_argument("--namespace",type=str,required=False,default="default",help="Kubernetes namespace to deploy the Ray cluster.",)arg_parser.add_argument("--service_acount_name", type=str, required=False, default="default", help="The service account name to use.")arg_parser.add_argument("--image",type=str,required=True,help="Docker image for the Ray cluster pods.",)arg_parser.add_argument("--worker_accelerator",nargs="+",type=str,default=["nvidia-l4"],help="GPU accelerator name. Supply more than one for heterogeneous resources.",)arg_parser = util.add_resource_arguments(arg_parser, cluster_create_defaults=True)arg_parser.add_argument("--num_clusters",type=int,default=1,help="How many Ray Clusters to create.",)arg_parser.add_argument("--num_head_cpu",type=float, # to be able to schedule partial CPU headsdefault=8,help="The number of CPUs to give the Ray head.",)arg_parser.add_argument("--head_ram_gb", type=int, default=8, help="How many gigs of ram to give the Ray head")args = arg_parser.parse_args()return util.fill_in_missing_resources(args, cluster_creation_flag=True)def main():args = parse_args()if "head" in args.name:raise ValueError("For compatibility with other scripts, do not include head in the name")if args.num_clusters == 1:apply_manifest(args)else:default_name = args.namefor i in range(args.num_clusters):args.name = default_name + "-" + str(i)apply_manifest(args)if __name__ == "__main__":main()
Docker-based Local Quickstart
First, follow the Running Isaac Lab in Docker guide to set up the NVIDIA Container Toolkit and Docker Compose.
Then, run the following steps to start a tuning run.
uv (Recommended)
isaaclab.sh / isaaclab.bat
# Build the base image, but we don't need to run itpython3 docker/container.py start && python3 docker/container.py stop# Build the tuning image with extra depsdocker build -t isaacray -f scripts/reinforcement_learning/ray/cluster_configs/Dockerfile .# Start the tuning image - symlink so that changes in the source folder show up in the containerdocker run -v $(pwd)/source:/workspace/isaaclab/source -it --gpus all --net=host --entrypoint /bin/bash isaacray# Start the Ray server within the tuning imageecho "import ray; ray.init(); import time; [time.sleep(10) for _ in iter(int, 1)]" | uv run python
In a different terminal, run the following.
uv (Recommended)
isaaclab.sh / isaaclab.bat
# In a new terminal (don't close the above) , enter the image with a new shell.docker container psdocker exec -it <ISAAC_RAY_IMAGE_ID_FROM_CONTAINER_PS> /bin/bash# Start a tuning run, with one parallel worker per GPUuv run python scripts/reinforcement_learning/ray/tuner.py \--cfg_file scripts/reinforcement_learning/ray/hyperparameter_tuning/vision_cartpole_cfg.py \--cfg_class CartpoleTheiaJobCfg \--run_mode local \--workflow scripts/reinforcement_learning/train.py \--num_workers_per_node <NUMBER_OF_GPUS_IN_COMPUTER>
The Ray tuner launches training through its --workflow script path. The built-in
example configurations use the unified training entrypoint and select RL-Games with:
cfg["runner_args"]["--rl_library"] = "rl_games"
To view the training logs, in a different terminal, run the following and visit localhost:6006 in a browser afterwards.
# In a new terminal (don't close the above) , enter the image with a new shell.docker container psdocker exec -it <ISAAC_RAY_IMAGE_ID_FROM_CONTAINER_PS> /bin/bash# Start a tuning run, with one parallel worker per GPUtensorboard --logdir=.
Submitting resource-wrapped individual jobs instead of automatic tuning runs is described in the following file.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clause"""This script dispatches sub-job(s) (individual jobs, use `tuner.py` for tuning jobs)to worker(s) on GPU-enabled node(s) of a specific cluster as part of an resource-wrapped aggregatejob. If no desired compute resources for each sub-job are specified,this script creates one worker per available node for each node with GPU(s) in the cluster.If the desired resources for each sub-job is specified,the maximum number of workers possible with the desired resources are created for each nodewith GPU(s) in the cluster. It is also possible to split available node resources for each nodeinto the desired number of workers with the `--num_workers` flag, to be able to easilyparallelize sub-jobs on multi-GPU nodes. Due to Isaac Lab requiring a GPU,this ignores all CPU only nodes such as loggers.Sub-jobs are matched with node(s) in a cluster via the following relation:sorted_nodes = Node sorted by descending GPUs, then descending CPUs, then descending RAM, then node IDnode_submitted_to = sorted_nodes[job_index % total_node_count]To check the ordering of sorted nodes, supply the `--test` argument and run the script.Sub-jobs are separated by the + delimiter. The `--sub_jobs` argument must be the lastargument supplied to the script.If there is more than one available worker, and more than one sub-job,sub-jobs will be executed in parallel. If there are more sub-jobs than workers, sub-jobs willbe dispatched to workers as they become available. There is no limit on the numberof sub-jobs that can be near-simultaneously submitted.This script is meant to be executed on a Ray cluster head node as an aggregate cluster job.To submit aggregate cluster jobs such as this script to one or more remote clusters,see `../submit_isaac_ray_job.py`.KubeRay clusters on Google GKE can be created with `../launch.py`Usage:~~~bash# **Ensure that sub-jobs are separated by the `+` delimiter.**# Generic Templates-----------------------------------uv run python scripts/reinforcement_learning/ray/wrap_resources.py -h# No resource isolation; no parallelization:uv run python scripts/reinforcement_learning/ray/wrap_resources.py--sub_jobs <JOB0>+<JOB1>+<JOB2># Automatic Resource Isolation; Example A: needed for parallelizationuv run python scripts/reinforcement_learning/ray/wrap_resources.py \--num_workers <NUM_TO_DIVIDE_TOTAL_RESOURCES_BY> \--sub_jobs <JOB0>+<JOB1># Manual Resource Isolation; Example B: needed for parallelizationuv run python scripts/reinforcement_learning/ray/wrap_resources.py --num_cpu_per_worker <CPU> \--gpu_per_worker <GPU> --ram_gb_per_worker <RAM> --sub_jobs <JOB0>+<JOB1># Manual Resource Isolation; Example C: Needed for parallelization, for heterogeneous workloadsuv run python scripts/reinforcement_learning/ray/wrap_resources.py --num_cpu_per_worker <CPU> \--gpu_per_worker <GPU1> <GPU2> --ram_gb_per_worker <RAM> --sub_jobs <JOB0>+<JOB1># to see all argumentsuv run python scripts/reinforcement_learning/ray/wrap_resources.py -h~~~"""import argparseimport utildef wrap_resources_to_jobs(jobs: list[str], args: argparse.Namespace) -> None:"""Provided a list of jobs, dispatch jobs to one worker per available node,unless otherwise specified by resource constraints.Args:jobs: bash commands to execute on a Ray clusterargs: The arguments for resource allocation"""job_objs = []util.ray_init(ray_address=args.ray_address,runtime_env={"py_modules": None if not args.py_modules else args.py_modules,},log_to_driver=False,)gpu_node_resources = util.get_gpu_node_resources(include_id=True, include_gb_ram=True)if any([args.gpu_per_worker, args.cpu_per_worker, args.ram_gb_per_worker]) and args.num_workers:raise ValueError("Either specify only num_workers or only granular resources(GPU,CPU,RAM_GB).")num_nodes = len(gpu_node_resources)# Populate argumentsformatted_node_resources = {"gpu_per_worker": [gpu_node_resources[i]["GPU"] for i in range(num_nodes)],"cpu_per_worker": [gpu_node_resources[i]["CPU"] for i in range(num_nodes)],"ram_gb_per_worker": [gpu_node_resources[i]["ram_gb"] for i in range(num_nodes)],"num_workers": args.num_workers, # By default, 1 worker por node}args = util.fill_in_missing_resources(args, resources=formatted_node_resources, policy=min)print(f"[INFO]: Number of GPU nodes found: {num_nodes}")if args.test:jobs = ["nvidia-smi"] * num_nodesfor i, job in enumerate(jobs):gpu_node = gpu_node_resources[i % num_nodes]print(f"[INFO]: Creating job {i + 1} of {len(jobs)} with job '{job}' to node {gpu_node}")print(f"[INFO]: Resource parameters: GPU: {args.gpu_per_worker[i]}"f" CPU: {args.cpu_per_worker[i]} RAM {args.ram_gb_per_worker[i]}")print(f"[INFO] For the node parameters, creating {args.num_workers[i]} workers")num_gpus = args.gpu_per_worker[i] / args.num_workers[i]num_cpus = args.cpu_per_worker[i] / args.num_workers[i]memory = (args.ram_gb_per_worker[i] * 1024**3) / args.num_workers[i]job_objs.append(util.Job(cmd=job,name=f"Job-{i + 1}",resources=util.JobResource(num_gpus=num_gpus, num_cpus=num_cpus, memory=memory),node=util.JobNode(specific="node_id",node_id=gpu_node["id"],),))# submit jobsutil.submit_wrapped_jobs(jobs=job_objs, test_mode=args.test, concurrent=False)if __name__ == "__main__":parser = argparse.ArgumentParser(description="Submit multiple jobs with optional GPU testing.")parser = util.add_resource_arguments(arg_parser=parser)parser.add_argument("--ray_address", type=str, default="auto", help="the Ray address.")parser.add_argument("--test",action="store_true",help=("Run nvidia-smi test instead of the arbitrary job,""can use as a sanity check prior to any jobs to check ""that GPU resources are correctly isolated."),)parser.add_argument("--py_modules",type=str,nargs="*",default=[],help=("List of python modules or paths to add before running the job. Example: --py_modules my_package/my_package"),)parser.add_argument("--sub_jobs",type=str,nargs=argparse.REMAINDER,help="This should be last wrapper argument. Jobs separated by the + delimiter to run on a cluster.",)args = parser.parse_args()if args.sub_jobs is not None:jobs = " ".join(args.sub_jobs)formatted_jobs = jobs.split("+")else:formatted_jobs = []print(f"[INFO]: Isaac Ray Wrapper received jobs {formatted_jobs=}")wrap_resources_to_jobs(jobs=formatted_jobs, args=args)
The task_runner.py dispatches Python tasks to a Ray cluster via a single declarative YAML file. This approach allows users to specify additional pip packages and Python modules for each run. Fine-grained resource allocation is supported, with explicit control over the number of CPUs, GPUs, and memory assigned to each task. The runner also offers advanced scheduling capabilities: tasks can be restricted to specific nodes by hostname or node ID, and supports two launch modes: tasks can be executed independently as resources become available, or grouped into a simultaneous batch—ideal for multi-node training jobs—which ensures that all tasks launch together only when sufficient resources are available across the cluster.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clause"""This script dispatches one or more user-defined Python tasks to workers in a Ray cluster.Each task, along with its resource requirements and execution parameters, is specified in a YAML configuration file.Users may define the number of CPUs, GPUs, and the amount of memory to allocate per task via the config file.Key features:-------------- Fine-grained, per-task resource management via config fields (`num_gpus`, `num_cpus`, `memory`).- Parallel execution of multiple tasks using available resources across the Ray cluster.- Option to specify node affinity for tasks, e.g., by hostname, node ID, or any node.- Optional batch (simultaneous) or independent scheduling of tasks.Task scheduling and distribution are handled via Ray’s built-in resource manager.YAML configuration fields:--------------------------- `pip`: List of extra pip packages to install before running any tasks.- `py_modules`: List of additional Python module paths (directories or files) to include in the runtime environment.- `concurrent`: (bool) It determines task dispatch semantics:- If `concurrent: true`, **all tasks are scheduled as a batch**. The script waits untilsufficient resources are available for every task in the batch, then launches all taskstogether. If resources are insufficient, all tasks remain blocked until the cluster cansupport the full batch.- If `concurrent: false`, tasks are launched as soon as resources are available for eachindividual task, and Ray independently schedules them. This may result in non-simultaneoustask start times.- `tasks`: List of task specifications, each with:- `name`: String identifier for the task.- `py_args`: Arguments to the Python interpreter (e.g., script/module, flags, user arguments).- `num_gpus`: Number of GPUs to allocate (float or string arithmetic, e.g., "2*2").- `num_cpus`: Number of CPUs to allocate (float or string).- `memory`: Amount of RAM in bytes (int or string).- `node` (optional): Node placement constraints.- `specific` (str): Type of node placement, support `hostname`, `node_id`, or `any`.- `any`: Place the task on any available node.- `hostname`: Place the task on a specific hostname. `hostname` must be specifiedin the node field.- `node_id`: Place the task on a specific node ID. `node_id` must be specified inthe node field.- `hostname` (str): Specific hostname to place the task on.- `node_id` (str): Specific node ID to place the task on.Typical usage:--------------~~~bash# Print help and argument details:python task_runner.py -h# Submit tasks defined in a YAML file to the Ray cluster (auto-detects Ray head address):python task_runner.py --task_cfg /path/to/tasks.yaml~~~YAML configuration example-1:-----------------------------~~~yamlpip: ["xxx"]py_modules: ["my_package/my_package"]concurrent: falsetasks:- name: "Isaac-Cartpole"py_args: "-m torch.distributed.run --nnodes=1 --nproc_per_node=2 --rdzv_endpoint=localhost:29501 /workspace/isaaclab/scripts/reinforcement_learning/train.py --rl_library rsl_rl --task=Isaac-Cartpole --max_iterations 200 --distributed"num_gpus: 2num_cpus: 10memory: 10737418240- name: "script need some dependencies"py_args: "script.py --option arg"num_gpus: 0num_cpus: 1memory: 10*1024*1024*1024~~~YAML configuration example-2:-----------------------------~~~yamlpip: ["xxx"]py_modules: ["my_package/my_package"]concurrent: truetasks:- name: "Isaac-Cartpole-multi-node-train-1"py_args: "-m torch.distributed.run --nproc_per_node=1 --nnodes=2 --node_rank=0 --rdzv_id=123 --rdzv_backend=c10d --rdzv_endpoint=localhost:5555 /workspace/isaaclab/scripts/reinforcement_learning/train.py --rl_library rsl_rl --task=Isaac-Cartpole --distributed --max_iterations 1000"num_gpus: 1num_cpus: 10memory: 10*1024*1024*1024node:specific: "hostname"hostname: "xxx"- name: "Isaac-Cartpole-multi-node-train-2"py_args: "-m torch.distributed.run --nproc_per_node=1 --nnodes=2 --node_rank=1 --rdzv_id=123 --rdzv_backend=c10d --rdzv_endpoint=x.x.x.x:5555 /workspace/isaaclab/scripts/reinforcement_learning/train.py --rl_library rsl_rl --task=Isaac-Cartpole --distributed --max_iterations 1000"num_gpus: 1num_cpus: 10memory: 10*1024*1024*1024node:specific: "hostname"hostname: "xxx"~~~To stop all tasks early, press Ctrl+C; the script will cancel all running Ray tasks.""" # noqa: E501import argparseimport astimport operatorfrom datetime import datetimeimport yaml# Local importsimport util # isort: skip# Safe operators for arithmetic expression evaluation_SAFE_OPERATORS = {ast.Add: operator.add,ast.Sub: operator.sub,ast.Mult: operator.mul,ast.Div: operator.truediv,ast.FloorDiv: operator.floordiv,ast.Pow: operator.pow,ast.Mod: operator.mod,ast.USub: operator.neg,ast.UAdd: operator.pos,}def safe_eval_arithmetic(expr: str) -> int | float:"""Safely evaluate a string containing only arithmetic expressions.Supports: +, -, *, /, //, **, % and numeric literals.Raises ValueError for any non-arithmetic expressions.Args:expr: A string containing an arithmetic expression (e.g., "10*1024*1024").Returns:The numeric result of the expression.Raises:ValueError: If the expression contains non-arithmetic operations."""def _eval_node(node: ast.AST) -> int | float:if isinstance(node, ast.Expression):return _eval_node(node.body)elif isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):return node.valueelif isinstance(node, ast.BinOp) and type(node.op) in _SAFE_OPERATORS:left = _eval_node(node.left)right = _eval_node(node.right)return _SAFE_OPERATORS[type(node.op)](left, right)elif isinstance(node, ast.UnaryOp) and type(node.op) in _SAFE_OPERATORS:operand = _eval_node(node.operand)return _SAFE_OPERATORS[type(node.op)](operand)else:raise ValueError(f"Unsafe expression: {ast.dump(node)}")try:tree = ast.parse(expr.strip(), mode="eval")return _eval_node(tree)except (SyntaxError, TypeError) as e:raise ValueError(f"Invalid arithmetic expression: {expr}") from edef parse_args() -> argparse.Namespace:"""Parse command-line arguments for the Ray task runner.Returns:A namespace containing parsed CLI arguments:- task_cfg (str): Path to the YAML task file.- ray_address (str): Ray cluster address.- test (bool): Whether to run a GPU resource isolation sanity check."""parser = argparse.ArgumentParser(description="Run tasks from a YAML config file.")parser.add_argument("--task_cfg", type=str, required=True, help="Path to the YAML task file.")parser.add_argument("--ray_address", type=str, default="auto", help="the Ray address.")parser.add_argument("--test",action="store_true",help=("Run nvidia-smi test instead of the arbitrary job,""can use as a sanity check prior to any jobs to check ""that GPU resources are correctly isolated."),)return parser.parse_args()def parse_task_resource(task: dict) -> util.JobResource:"""Parse task resource requirements from the YAML configuration.Args:task (dict): Dictionary representing a single task's configuration.Keys may include `num_gpus`, `num_cpus`, and `memory`, each eitheras a number or evaluatable string expression.Returns:util.JobResource: Resource object with the parsed values."""resource = util.JobResource()if "num_gpus" in task:value = task["num_gpus"]resource.num_gpus = safe_eval_arithmetic(value) if isinstance(value, str) else valueif "num_cpus" in task:value = task["num_cpus"]resource.num_cpus = safe_eval_arithmetic(value) if isinstance(value, str) else valueif "memory" in task:value = task["memory"]resource.memory = safe_eval_arithmetic(value) if isinstance(value, str) else valuereturn resourcedef run_tasks(tasks: list[dict], args: argparse.Namespace, runtime_env: dict | None = None, concurrent: bool = False) -> None:"""Submit tasks to the Ray cluster for execution.Args:tasks (list[dict]): A list of task configuration dictionaries.args (argparse.Namespace): Parsed command-line arguments.runtime_env (dict | None): Ray runtime environment configuration containing:- pip (list[str] | None): Additional pip packages to install.- py_modules (list[str] | None): Python modules to include in the environment.concurrent (bool): Whether to launch tasks simultaneously as a batch,or independently as resources become available.Returns:None"""job_objs = []util.ray_init(ray_address=args.ray_address, runtime_env=runtime_env, log_to_driver=False)for task in tasks:resource = parse_task_resource(task)print(f"[INFO] Creating job {task['name']} with resource={resource}")job = util.Job(name=task["name"],py_args=task["py_args"],resources=resource,node=util.JobNode(specific=task.get("node", {}).get("specific"),hostname=task.get("node", {}).get("hostname"),node_id=task.get("node", {}).get("node_id"),),)job_objs.append(job)start = datetime.now()print(f"[INFO] Creating {len(job_objs)} jobs at {start.strftime('%H:%M:%S.%f')} with runtime env={runtime_env}")# submit jobsutil.submit_wrapped_jobs(jobs=job_objs,test_mode=args.test,concurrent=concurrent,)end = datetime.now()print(f"[INFO] All jobs completed at {end.strftime('%H:%M:%S.%f')}, took {(end - start).total_seconds():.2f} seconds.")def main() -> None:"""Main entry point for the Ray task runner script.Reads the YAML task configuration file, parses CLI arguments,and dispatches tasks to the Ray cluster.Returns:None"""args = parse_args()with open(args.task_cfg) as f:config = yaml.safe_load(f)tasks = config["tasks"]runtime_env = {"pip": None if not config.get("pip") else config["pip"],"py_modules": None if not config.get("py_modules") else config["py_modules"],}concurrent = config.get("concurrent", False)run_tasks(tasks=tasks,args=args,runtime_env=runtime_env,concurrent=concurrent,)if __name__ == "__main__":main()
To use this script, run a command similar to the following (replace tasks.yaml with your actual configuration file):
python3 scripts/reinforcement_learning/ray/submit_job.py --aggregate_jobs task_runner.py --task_cfg tasks.yaml
For detailed instructions on how to write your tasks.yaml file, please refer to the comments in task_runner.py.
Tip: Place the tasks.yaml file in the scripts/reinforcement_learning/ray directory so that it is included when the working_dir is uploaded. You can then reference it using a relative path in the command.
Transferring files from the running container can be done as follows.
docker container psdocker cp <ISAAC_RAY_IMAGE_ID_FROM_CONTAINER_PS>:</path/in/container/file> </path/on/host/>
For tuning jobs, specify the tuning job / hyperparameter sweep as child class of JobCfg .
The included JobCfg only supports the rl_games workflow due to differences in
environment entrypoints and hydra arguments, although other workflows will work if provided a compatible
JobCfg.
class JobCfg:"""To be compatible with :meth: invoke_tuning_run and :class:IsaacLabTuneTrainable,at a minimum, the tune job should inherit from this class."""def __init__(self, cfg: dict):"""Runner args include command line arguments passed to the task.For example:cfg["runner_args"]["video_singleton"] = "--video""""assert "runner_args" in cfg, "No runner arguments specified."cfg["runner_args"].setdefault("--rl_library", "rsl_rl")"""Task is the desired task to train on. For example:cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])"""assert "--task" in cfg["runner_args"], "No task specified.""""Hydra args define the hyperparameters varied within the sweep. For example:cfg["hydra_args"]["agent.params.network.cnn.activation"] = tune.choice(["relu", "elu"])"""assert "hydra_args" in cfg, "No hyperparameters specified."self.cfg = cfg
For example, see the following Cartpole Example configurations.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clauseimport pathlibimport sysfrom typing import Any# Allow for import of items from the ray workflow.CUR_DIR = pathlib.Path(__file__).parentUTIL_DIR = CUR_DIR.parentsys.path.extend([str(UTIL_DIR), str(CUR_DIR)])import utilimport vision_cfgfrom ray import tunefrom ray.tune.progress_reporter import CLIReporterfrom ray.tune.stopper import Stopperclass CartpoleRGBNoTuneJobCfg(vision_cfg.CameraJobCfg):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])super().__init__(cfg, vary_env_count=False, vary_cnn=False, vary_mlp=False)class CartpoleRGBCNNOnlyJobCfg(vision_cfg.CameraJobCfg):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])super().__init__(cfg, vary_env_count=False, vary_cnn=True, vary_mlp=False)class CartpoleRGBJobCfg(vision_cfg.CameraJobCfg):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])super().__init__(cfg, vary_env_count=True, vary_cnn=True, vary_mlp=True)class CartpoleResNetJobCfg(vision_cfg.ResNetCameraJob):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])cfg["hydra_args"]["presets"] = "resnet18"super().__init__(cfg)class CartpoleTheiaJobCfg(vision_cfg.TheiaCameraJob):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])cfg["hydra_args"]["presets"] = "theia_tiny"super().__init__(cfg)class CustomCartpoleProgressReporter(CLIReporter):def __init__(self):super().__init__(metric_columns={"training_iteration": "iter","time_total_s": "total time (s)","Episode/Episode_Reward/alive": "alive","Episode/Episode_Reward/cart_vel": "cart velocity","rewards/time": "rewards/time",},max_report_frequency=5,sort_by_metric=True,)class CartpoleEarlyStopper(Stopper):def __init__(self):self._bad_trials = set()def __call__(self, trial_id: str, result: dict[str, Any]) -> bool:iter = result.get("training_iteration", 0)out_of_bounds = result.get("Episode/Episode_Termination/cart_out_of_bounds")# Mark the trial for stopping if conditions are metif iter >= 20 and out_of_bounds is not None and out_of_bounds > 0.85:self._bad_trials.add(trial_id)return trial_id in self._bad_trialsdef stop_all(self) -> bool:return False # only stop individual trials
Remote Clusters
Select one of the following methods to create a Ray cluster to accept and execute dispatched jobs.
KubeRay Setup
If using KubeRay clusters on Google GKE with the batteries-included cluster launch file, the following dependencies are also needed.
python3 -m pip install kubernetes Jinja2
For use on Kubernetes clusters with KubeRay,
such as Google Kubernetes Engine or Amazon Elastic Kubernetes Service, kubectl is required, and can
be installed via the Kubernetes website .
Google Cloud is currently the only platform tested, although any cloud provider should work if one configures the following.
The ray command should be modified to use Isaac python, which could be achieved in a fashion similar to
sed -i "1i $(echo "#!/workspace/isaaclab/_isaac_sim/python.sh")" \ /isaac-sim/kit/python/bin/ray && ln -s /isaac-sim/kit/python/bin/ray /usr/local/bin/ray.
- An container registry (NGC, GCS artifact registry, AWS ECR, etc) with
an Isaac Lab image configured to support Ray. See
cluster_configs/Dockerfileto see how to modify theisaac-lab-basecontainer for Ray compatibility. Ray should use the isaac sim python shebang, andnvidia-smishould work within the container. Be careful with the setup here as paths need to be configured correctly for everything to work. It’s likely that the example dockerfile will work out of the box and can be pushed to the registry, as long as the base image has already been built as in the container guide. - A Kubernetes setup with available NVIDIA RTX (likely
l4orl40ortesla-t4ora10) GPU-passthrough node-pool resources, that has access to your container registry/storage bucket and has the Ray operator enabled with correct IAM permissions. This can be easily achieved with services such as Google GKE or AWS EKS, provided that your account or organization has been granted a GPU-budget. It is recommended to use manual kubernetes services as opposed to “autopilot” services for cost-effective experimentation as this way clusters can be completely shut down when not in use, although this may require installing the Nvidia GPU Operator . - An MLFlow server that your cluster has access to (already included for Google Cloud, which can be referenced for the format and MLFlow integration).
- A
kuberay.yaml.ninjafile that describes how to allocate resources (already included for Google Cloud, which can be referenced for the format and MLFlow integration).
Ray Clusters (Without Kubernetes) Setup
Modify the Ray command to use Isaac Python like in KubeRay clusters, and follow the same steps for creating an image/cluster permissions.
See the Ray Clusters Overview or Anyscale for more information.
Also, create an MLFlow server that your local host and cluster have access to.
Shared Steps Between KubeRay and Pure Ray Part I
1.) Install Ray on your local machine.
python3 -m pip install "ray[default]==2.31.0"
2.) Build the Isaac Ray image, and upload it to your container registry of choice.
# Login with NGC (nvcr.io) registry first, see docker steps in repo.python3 docker/container.py start# Build the special Isaac Lab Ray Imagedocker build -t <REGISTRY/IMAGE_NAME> -f scripts/reinforcement_learning/ray/cluster_configs/Dockerfile .# Push the image to your registry of choice.docker push <REGISTRY/IMAGE_NAME>
KubeRay Clusters Only
k9s is a great tool for monitoring your clusters that can
easily be installed with snap install k9s --devmode.
1.) Verify cluster access, and that the correct operators are installed.
# Verify cluster accesskubectl cluster-info# If using a manually managed cluster (not Autopilot or the like)# verify that there are node poolskubectl get nodes# Check that the ray operator is installed on the cluster# should list rayclusters.ray.io , rayjobs.ray.io , and rayservices.ray.iokubectl get crds | grep ray# Check that the NVIDIA Driver Operator is installed on the cluster# should list clusterpolicies.nvidia.comkubectl get crds | grep nvidia
2.) Create the KubeRay cluster and an MLFlow server for receiving logs that your cluster has access to. This can be done automatically for Google GKE, where instructions are included in the following creation file.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clause"""This script helps create one or more KubeRay clusters.Usage:~~~bash# If the head node is stuck on container creating, make sure to create a secretpython3 scripts/reinforcement_learning/ray/launch.py -h# Examples# The following creates 8 GPUx1 nvidia l4 workerspython3 scripts/reinforcement_learning/ray/launch.py --cluster_host google_cloud \--namespace <NAMESPACE> --image <YOUR_ISAAC_RAY_IMAGE> \--num_workers 8 --num_clusters 1 --worker_accelerator nvidia-l4 --gpu_per_worker 1# The following creates 1 GPUx1 nvidia l4 worker, 2 GPUx2 nvidia-tesla-t4 workers,# and 2 GPUx4 nvidia-tesla-t4 GPU workerspython3 scripts/reinforcement_learning/ray/launch.py --cluster_host google_cloud \--namespace <NAMESPACE> --image <YOUR_ISAAC_RAY_IMAGE> \--num_workers 1 2 --num_clusters 1 \--worker_accelerator nvidia-l4 nvidia-tesla-t4 --gpu_per_worker 1 2 4~~~"""import argparseimport pathlibimport subprocessimport yamlfrom jinja2 import Environment, FileSystemLoaderfrom kubernetes import config# Local importsimport util # isort: skipRAY_DIR = pathlib.Path(__file__).parentdef apply_manifest(args: argparse.Namespace) -> None:"""Provided a Jinja templated ray.io/v1alpha1 file,populate the arguments and create the cluster. Additionally, createkubernetes containers for resources separated by '---' from the restof the file.Args:args: Possible arguments concerning cluster parameters."""# Load Kubernetes configurationconfig.load_kube_config()# Set up Jinja2 environment for loading templatestemplates_dir = RAY_DIR / "cluster_configs" / args.cluster_hostfile_loader = FileSystemLoader(str(templates_dir))jinja_env = Environment(loader=file_loader, keep_trailing_newline=True, autoescape=True)# Define template filenametemplate_file = "kuberay.yaml.jinja"# Convert args namespace to a dictionarytemplate_params = vars(args)# Load and render the templatetemplate = jinja_env.get_template(template_file)file_contents = template.render(template_params)# Parse all YAML documents in the rendered templateall_yamls = []for doc in yaml.safe_load_all(file_contents):all_yamls.append(doc)# Convert back to YAML string, preserving multiple documentscleaned_yaml_string = ""for i, doc in enumerate(all_yamls):if i > 0:cleaned_yaml_string += "\n---\n"cleaned_yaml_string += yaml.dump(doc)# Apply the Kubernetes manifest using kubectltry:print(cleaned_yaml_string)subprocess.run(["kubectl", "apply", "-f", "-"], input=cleaned_yaml_string, text=True, check=True)except subprocess.CalledProcessError as e:exit(f"An error occurred while running `kubectl`: {e}")def parse_args() -> argparse.Namespace:"""Parse command-line arguments for Kubernetes deployment script.Returns:argparse.Namespace: Parsed command-line arguments."""arg_parser = argparse.ArgumentParser(description="Script to apply manifests to create Kubernetes objects for Ray clusters.",formatter_class=argparse.ArgumentDefaultsHelpFormatter,)arg_parser.add_argument("--cluster_host",type=str,default="google_cloud",choices=["google_cloud"],help=("In the cluster_configs directory, the name of the folder where a tune.yaml.jinja""file exists defining the KubeRay config. Currently only google_cloud is supported."),)arg_parser.add_argument("--name",type=str,required=False,default="isaacray",help="Name of the Kubernetes deployment.",)arg_parser.add_argument("--namespace",type=str,required=False,default="default",help="Kubernetes namespace to deploy the Ray cluster.",)arg_parser.add_argument("--service_acount_name", type=str, required=False, default="default", help="The service account name to use.")arg_parser.add_argument("--image",type=str,required=True,help="Docker image for the Ray cluster pods.",)arg_parser.add_argument("--worker_accelerator",nargs="+",type=str,default=["nvidia-l4"],help="GPU accelerator name. Supply more than one for heterogeneous resources.",)arg_parser = util.add_resource_arguments(arg_parser, cluster_create_defaults=True)arg_parser.add_argument("--num_clusters",type=int,default=1,help="How many Ray Clusters to create.",)arg_parser.add_argument("--num_head_cpu",type=float, # to be able to schedule partial CPU headsdefault=8,help="The number of CPUs to give the Ray head.",)arg_parser.add_argument("--head_ram_gb", type=int, default=8, help="How many gigs of ram to give the Ray head")args = arg_parser.parse_args()return util.fill_in_missing_resources(args, cluster_creation_flag=True)def main():args = parse_args()if "head" in args.name:raise ValueError("For compatibility with other scripts, do not include head in the name")if args.num_clusters == 1:apply_manifest(args)else:default_name = args.namefor i in range(args.num_clusters):args.name = default_name + "-" + str(i)apply_manifest(args)if __name__ == "__main__":main()
For other cloud services, the kuberay.yaml.ninja will be similar to that of
Google’s.
# Jinja is used for templating here as full helm setup is excessive for applicationapiVersion: ray.io/v1alpha1kind: RayClustermetadata:name: {{ name }}namespace: {{ namespace }}spec:rayVersion: "2.8.0"enableInTreeAutoscaling: trueautoscalerOptions:upscalingMode: DefaultidleTimeoutSeconds: 120imagePullPolicy: AlwayssecurityContext: {}envFrom: []headGroupSpec:rayStartParams:block: "true"dashboard-host: 0.0.0.0dashboard-port: "8265"port: "6379"include-dashboard: "true"ray-debugger-external: "true"object-manager-port: "8076"num-gpus: "0"num-cpus: "0" # prevent scheduling jobs to the head node - workers onlyheadService:apiVersion: v1kind: Servicemetadata:name: {{ name }}-headspec:type: LoadBalancertemplate:metadata:labels:app.kubernetes.io/instance: tunerapp.kubernetes.io/name: kuberaycloud.google.com/gke-ray-node-type: headspec:serviceAccountName: {{ service_account_name }}affinity: {}securityContext:fsGroup: 100containers:- env:image: {{ image }}imagePullPolicy: Alwaysname: headresources:limits:cpu: "{{ num_head_cpu }}"memory: {{ head_ram_gb }}Gnvidia.com/gpu: "0"requests:cpu: "{{ num_head_cpu }}"memory: {{ head_ram_gb }}Gnvidia.com/gpu: "0"securityContext: {}volumeMounts:- mountPath: /tmp/rayname: ray-logscommand: ["/bin/bash", "-c", "ray start --head --port=6379 --object-manager-port=8076 --dashboard-host=0.0.0.0 --dashboard-port=8265 --include-dashboard=true && tail -f /dev/null"]- image: fluent/fluent-bit:1.9.6name: fluentbitresources:limits:cpu: 100mmemory: 128Mirequests:cpu: 100mmemory: 128MivolumeMounts:- mountPath: /tmp/rayname: ray-logsimagePullSecrets: []nodeSelector:iam.gke.io/gke-metadata-server-enabled: "true"volumes:- configMap:name: fluentbit-configname: fluentbit-config- name: ray-logsemptyDir: {}workerGroupSpecs:{% for it in range(gpu_per_worker|length) %}- groupName: "{{ worker_accelerator[it] }}x{{ gpu_per_worker[it] }}-cpu-{{ cpu_per_worker[it] }}-ram-gb-{{ ram_gb_per_worker[it] }}"replicas: {{ num_workers[it] }}maxReplicas: {{ num_workers[it] }}minReplicas: {{ num_workers[it] }}rayStartParams:block: "true"ray-debugger-external: "true"replicas: "{{num_workers[it]}}"template:metadata:annotations: {}labels:app.kubernetes.io/instance: tunerapp.kubernetes.io/name: kuberaycloud.google.com/gke-ray-node-type: workerspec:serviceAccountName: {{ service_account_name }}affinity: {}securityContext:fsGroup: 100containers:- env:- name: NVIDIA_VISIBLE_DEVICESvalue: "all"- name: NVIDIA_DRIVER_CAPABILITIESvalue: "compute,utility"image: {{ image }}imagePullPolicy: Alwaysname: ray-workerresources:limits:cpu: "{{ cpu_per_worker[it] }}"memory: {{ ram_gb_per_worker[it] }}Gnvidia.com/gpu: "{{ gpu_per_worker[it] }}"requests:cpu: "{{ cpu_per_worker[it] }}"memory: {{ ram_gb_per_worker[it] }}Gnvidia.com/gpu: "{{ gpu_per_worker[it] }}"securityContext: {}volumeMounts:- mountPath: /tmp/rayname: ray-logscommand: ["/bin/bash", "-c", "ray start --address={{name}}-head.{{ namespace }}.svc.cluster.local:6379 && tail -f /dev/null"]- image: fluent/fluent-bit:1.9.6name: fluentbitresources:limits:cpu: 100mmemory: 128Mirequests:cpu: 100mmemory: 128MivolumeMounts:- mountPath: /tmp/rayname: ray-logsimagePullSecrets: []nodeSelector:cloud.google.com/gke-accelerator: {{ worker_accelerator[it] }}iam.gke.io/gke-metadata-server-enabled: "true"tolerations:- key: "nvidia.com/gpu"operator: "Exists"effect: "NoSchedule"volumes:- configMap:name: fluentbit-configname: fluentbit-config- name: ray-logsemptyDir: {}{% endfor %}---# ML Flow Server - for fetching logsapiVersion: apps/v1kind: Deploymentmetadata:name: {{name}}-mlflownamespace: {{ namespace }}spec:replicas: 1selector:matchLabels:app: mlflowtemplate:metadata:labels:app: mlflowspec:containers:- name: mlflowimage: ghcr.io/mlflow/mlflow:v2.9.2ports:- containerPort: 5000command: ["mlflow"]args:- server- --host=0.0.0.0- --port=5000- --backend-store-uri=sqlite:///mlflow.db---# ML Flow Service (for port forwarding, kubectl port-forward service/{name}-mlflow 5000:5000)apiVersion: v1kind: Servicemetadata:name: {{name}}-mlflownamespace: {{ namespace }}spec:selector:app: mlflowports:- port: 5000targetPort: 5000type: ClusterIP
3.) Fetch the KubeRay cluster IP addresses, and the MLFLow Server IP. This can be done automatically for KubeRay clusters, where instructions are included in the following fetching file. The KubeRay clusters are saved to a file, but the MLFLow Server IP is printed.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clauseimport argparseimport osimport reimport subprocessimport threadingimport timefrom concurrent.futures import ThreadPoolExecutor, as_completed"""This script requires that kubectl is installed and KubeRay was used to create the cluster.Creates a config file containing `name: <NAME> address: http://<IP>:<PORT>` ona new line for each cluster, and also fetches the MLFlow URI.Usage:~~~bashpython3 scripts/reinforcement_learning/ray/grok_cluster_with_kubectl.py# For options, supply -h arg~~~"""def get_namespace() -> str:"""Get the current Kubernetes namespace from the context, fallback to default if not set"""try:namespace = (subprocess.check_output(["kubectl", "config", "view", "--minify", "--output", "jsonpath={..namespace}"]).decode().strip())if not namespace:namespace = "default"except subprocess.CalledProcessError:namespace = "default"return namespacedef get_pods(namespace: str = "default") -> list[tuple]:"""Get a list of all of the pods in the namespace"""cmd = ["kubectl", "get", "pods", "-n", namespace, "--no-headers"]output = subprocess.check_output(cmd).decode()pods = []for line in output.strip().split("\n"):fields = line.split()pod_name = fields[0]status = fields[2]pods.append((pod_name, status))return podsdef get_clusters(pods: list, cluster_name_prefix: str) -> set:"""Get unique cluster name(s). Works for one or more clusters, based off of the number of head nodes.Excludes MLflow deployments."""clusters = set()for pod_name, _ in pods:# Skip MLflow podsif "-mlflow" in pod_name:continuematch = re.match(r"(" + re.escape(cluster_name_prefix) + r"[-\w]+)", pod_name)if match:# Get base name without head/worker suffix (skip workers)if "head" in pod_name:base_name = match.group(1).split("-head")[0]clusters.add(base_name)return sorted(clusters)def get_mlflow_info(namespace: str = None, cluster_prefix: str = "isaacray") -> str:"""Get MLflow service information if it exists in the namespace with the given prefix.Only works for a single cluster instance.Args:namespace: Kubernetes namespacecluster_prefix: Base cluster name (without -head/-worker suffixes)Returns:MLflow service URL"""# Strip any -head or -worker suffixes to get base nameif namespace is None:namespace = get_namespace()pods = get_pods(namespace=namespace)clusters = get_clusters(pods=pods, cluster_name_prefix=cluster_prefix)if len(clusters) > 1:raise ValueError("More than one cluster matches prefix, could not automatically determine mlflow info.")mlflow_name = f"{cluster_prefix}-mlflow"cmd = ["kubectl", "get", "svc", mlflow_name, "-n", namespace, "--no-headers"]try:output = subprocess.check_output(cmd).decode()fields = output.strip().split()# Get cluster IPcluster_ip = fields[2]port = "5000" # Default MLflow port# This needs to be http to be resolved. HTTPS can't be resolved# This should be fine as it is on a subnet on the cluster regardlessreturn f"http://{cluster_ip}:{port}"except subprocess.CalledProcessError as e:raise ValueError(f"Could not grok MLflow: {e}") # Fixed f-stringdef check_clusters_running(pods: list, clusters: set) -> bool:"""Check that all of the pods in all provided clusters are running.Args:pods (list): A list of tuples where each tuple contains the pod name and its status.clusters (set): A set of cluster names to check.Returns:bool: True if all pods in any of the clusters are running, False otherwise."""clusters_running = Falsefor cluster in clusters:cluster_pods = [p for p in pods if p[0].startswith(cluster)]total_pods = len(cluster_pods)running_pods = len([p for p in cluster_pods if p[1] == "Running"])if running_pods == total_pods and running_pods > 0:clusters_running = Truebreakreturn clusters_runningdef get_ray_address(head_pod: str, namespace: str = "default", ray_head_name: str = "head") -> str:"""Given a cluster head pod, check its logs, which should include the ray address which can accept job requests.Args:head_pod (str): The name of the head pod.namespace (str, optional): The Kubernetes namespace. Defaults to "default".ray_head_name (str, optional): The name of the ray head container. Defaults to "head".Returns:str: The ray address if found, None otherwise.Raises:ValueError: If the logs cannot be retrieved or the ray address is not found."""cmd = ["kubectl", "logs", head_pod, "-c", ray_head_name, "-n", namespace]try:output = subprocess.check_output(cmd).decode()except subprocess.CalledProcessError as e:raise ValueError(f"Could not enter head container with cmd {cmd}: {e}Perhaps try a different namespace or ray head name.")match = re.search(r"RAY_ADDRESS='([^']+)'", output)if match:return match.group(1)else:return Nonedef process_cluster(cluster_info: dict, ray_head_name: str = "head") -> str:"""For each cluster, check that it is running, and get the Ray head address that will accept jobs.Args:cluster_info: A dictionary containing cluster information with keys 'cluster', 'pods', and 'namespace'.ray_head_name: The name of the ray head container. Defaults to "head".Returns:A string containing the cluster name and its Ray head address, or an error message ifthe head pod or Ray address is not found."""cluster, pods, namespace = cluster_infohead_pod = Nonefor pod_name, status in pods:if pod_name.startswith(cluster + "-head"):head_pod = pod_namebreakif not head_pod:return f"Error: Could not find head pod for cluster {cluster}\n"# Get RAY_ADDRESS and statusray_address = get_ray_address(head_pod, namespace=namespace, ray_head_name=ray_head_name)if not ray_address:return f"Error: Could not find RAY_ADDRESS for cluster {cluster}\n"# Return only cluster and ray addressreturn f"name: {cluster} address: {ray_address}\n"def main():# Parse command-line argumentsparser = argparse.ArgumentParser(description="Process Ray clusters and save their specifications.")parser.add_argument("--prefix", default="isaacray", help="The prefix for the cluster names.")parser.add_argument("--output", default="~/.cluster_config", help="The file to save cluster specifications.")parser.add_argument("--ray_head_name", default="head", help="The metadata name for the ray head container")parser.add_argument("--namespace", help="Kubernetes namespace to use. If not provided, will detect from current context.")args = parser.parse_args()# Get namespace from args or detect itcurrent_namespace = args.namespace if args.namespace else get_namespace()print(f"Using namespace: {current_namespace}")cluster_name_prefix = args.prefixcluster_spec_file = os.path.expanduser(args.output)# Get all podspods = get_pods(namespace=current_namespace)# Get clustersclusters = get_clusters(pods, cluster_name_prefix)if not clusters:print(f"No clusters found with prefix {cluster_name_prefix}")return# Wait for clusters to be runningwhile True:pods = get_pods(namespace=current_namespace)if check_clusters_running(pods, clusters):breakprint("Waiting for all clusters to spin up...")time.sleep(5)print("Checking for MLflow:")# Check MLflow status for each clusterfor cluster in clusters:try:mlflow_address = get_mlflow_info(current_namespace, cluster)print(f"MLflow address for {cluster}: {mlflow_address}")except ValueError as e:print(f"ML Flow not located: {e}")print()# Prepare cluster info for parallel processingcluster_infos = []for cluster in clusters:cluster_pods = [p for p in pods if p[0].startswith(cluster)]cluster_infos.append((cluster, cluster_pods, current_namespace))# Use ThreadPoolExecutor to process clusters in parallelresults = []results_lock = threading.Lock()with ThreadPoolExecutor() as executor:future_to_cluster = {executor.submit(process_cluster, info, args.ray_head_name): info[0] for info in cluster_infos}for future in as_completed(future_to_cluster):cluster_name = future_to_cluster[future]try:result = future.result()with results_lock:results.append(result)except Exception as exc:print(f"{cluster_name} generated an exception: {exc}")# Sort results alphabetically by cluster nameresults.sort()# Write sorted results to the output file (Ray info only)with open(cluster_spec_file, "w") as f:for result in results:f.write(result)print(f"Cluster spec information saved to {cluster_spec_file}")# Display the contents of the config filewith open(cluster_spec_file) as f:print(f.read())if __name__ == "__main__":main()
Ray Clusters Only (Without Kubernetes)
1.) Verify cluster access.
2.) Create a ~/.cluster_config file, where name: <NAME> address: http://<IP>:<PORT> is on
a new line for each unique cluster. For one cluster, there should only be one line in this file.
3.) Start an MLFLow Server to receive the logs that the ray cluster has access to, and determine the server URI.
Dispatching Steps Shared Between KubeRay and Pure Ray Part II
1.) Test that your cluster is operational with the following.
# Test that NVIDIA GPUs are visible and that Ray is operation with the following command:python3 scripts/reinforcement_learning/ray/submit_job.py --aggregate_jobs wrap_resources.py --test
2.) Submitting tuning and/or resource-wrapped jobs is described in the submit_job.py file.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clause"""This script submits aggregate job(s) to cluster(s) described in aconfig file containing `name: <NAME> address: http://<IP>:<PORT>` ona new line for each cluster. For KubeRay clusters, this filecan be automatically created with `grok_cluster_with_kubectl.py`Aggregate job(s) are matched with cluster(s) via the following relation:cluster_line_index_submitted_to = job_index % total_cluster_countAggregate jobs are separated by the * delimiter. The `--aggregate_jobs` argument must bethe last argument supplied to the script.An aggregate job could be a `../tuner.py` tuning job, which automaticallycreates several individual jobs when started on a cluster. Alternatively, an aggregate jobcould be a :file:'../wrap_resources.py` resource-wrapped job,which may contain several individual sub-jobs separated bythe + delimiter. An aggregate job could also be a `../task_runner.py` multi-task submission job,where each sub-job and its resource requirements are defined in a YAML configuration file.In this mode, `../task_runner.py` will read the YAML file (via --task_cfg), andsubmit all defined sub-tasks to the Ray cluster, supporting per-job resource specification andreal-time streaming of sub-job outputs.If there are more aggregate jobs than cluster(s), aggregate jobs will be submittedas clusters become available via the defined relation above. If there are less aggregate job(s)than clusters, some clusters will not receive aggregate job(s). The maximum number ofaggregate jobs that can be run simultaneously is equal to the number of workers created bydefault by a ThreadPoolExecutor on the machine submitting jobs due to fetching the log output afterjobs finish, which is unlikely to constrain overall-job submission.Usage:~~~bash# Example; submitting a tuning jobpython3 scripts/reinforcement_learning/ray/submit_job.py \--aggregate_jobs /workspace/isaaclab/scripts/reinforcement_learning/ray/tuner.py \--cfg_file hyperparameter_tuning/vision_cartpole_cfg.py \--cfg_class CartpoleTheiaJobCfg --mlflow_uri <ML_FLOW_URI># Example: Submitting resource wrapped jobpython3 scripts/reinforcement_learning/ray/submit_job.py --aggregate_jobs wrap_resources.py --test# Example: submitting tasks with specific resources, and supporting pip packages and py_modules# You may use relative paths for task_cfg and py_modules, placing them in the# "scripts/reinforcement_learning/ray" directory, which will be uploaded to the cluster.python3 scripts/reinforcement_learning/ray/submit_job.py --aggregate_jobs task_runner.py --task_cfg tasks.yaml# For all command line argumentspython3 scripts/reinforcement_learning/ray/submit_job.py -h~~~"""import argparseimport osimport timefrom concurrent.futures import ThreadPoolExecutorfrom ray import job_submissionscript_directory = os.path.dirname(os.path.abspath(__file__))CONFIG = {"working_dir": script_directory, "executable": "/workspace/isaaclab/isaaclab.sh -p"}def read_cluster_spec(fn: str | None = None) -> list[dict]:if fn is None:cluster_spec_path = os.path.expanduser("~/.cluster_config")else:cluster_spec_path = os.path.expanduser(fn)if not os.path.exists(cluster_spec_path):raise FileNotFoundError(f"Cluster spec file not found at {cluster_spec_path}")clusters = []with open(cluster_spec_path) as f:for line in f:parts = line.strip().split(" ")http_address = parts[3]cluster_info = {"name": parts[1], "address": http_address}print(f"[INFO] Setting {cluster_info['name']}") # with {cluster_info['num_gpu']} GPUs.")clusters.append(cluster_info)return clustersdef submit_job(cluster: dict, job_command: str) -> None:"""Submits a job to a single cluster, prints the final result and Ray dashboard URL at the end."""address = cluster["address"]cluster_name = cluster["name"]print(f"[INFO]: Submitting job to cluster '{cluster_name}' at {address}") # with {num_gpus} GPUs.")client = job_submission.JobSubmissionClient(address)runtime_env = {"working_dir": CONFIG["working_dir"], "executable": CONFIG["executable"]}print(f"[INFO]: Checking contents of the directory: {CONFIG['working_dir']}")try:dir_contents = os.listdir(CONFIG["working_dir"])print(f"[INFO]: Directory contents: {dir_contents}")except Exception as e:print(f"[INFO]: Failed to list directory contents: {str(e)}")entrypoint = f"{CONFIG['executable']} {job_command}"print(f"[INFO]: Attempting entrypoint {entrypoint=} in cluster {cluster}")job_id = client.submit_job(entrypoint=entrypoint, runtime_env=runtime_env)status = client.get_job_status(job_id)while status in [job_submission.JobStatus.PENDING, job_submission.JobStatus.RUNNING]:time.sleep(5)status = client.get_job_status(job_id)final_logs = client.get_job_logs(job_id)print("----------------------------------------------------")print(f"[INFO]: Cluster {cluster_name} Logs: \n")print(final_logs)print("----------------------------------------------------")def submit_jobs_to_clusters(jobs: list[str], clusters: list[dict]) -> None:"""Submit all jobs to their respective clusters, cycling through clusters if there are more jobs than clusters."""if not clusters:raise ValueError("No clusters available for job submission.")if len(jobs) < len(clusters):print("[INFO]: Less jobs than clusters, some clusters will not receive jobs")elif len(jobs) == len(clusters):print("[INFO]: Exactly one job per cluster")else:print("[INFO]: More jobs than clusters, jobs submitted as clusters become available.")with ThreadPoolExecutor() as executor:for idx, job_command in enumerate(jobs):# Cycle through clusters using modulus to wrap around if there are more jobs than clusterscluster = clusters[idx % len(clusters)]executor.submit(submit_job, cluster, job_command)if __name__ == "__main__":parser = argparse.ArgumentParser(description="Submit multiple GPU jobs to multiple Ray clusters.")parser.add_argument("--config_file", default="~/.cluster_config", help="The cluster config path.")parser.add_argument("--aggregate_jobs",type=str,nargs=argparse.REMAINDER,help="This should be last argument. The aggregate jobs to submit separated by the * delimiter.",)args = parser.parse_args()if args.aggregate_jobs is not None:jobs = " ".join(args.aggregate_jobs)formatted_jobs = jobs.split("*")if len(formatted_jobs) > 1:print("Warning; Split jobs by cluster with the * delimiter")else:formatted_jobs = []print(f"[INFO]: Isaac Ray Wrapper received jobs {formatted_jobs=}")clusters = read_cluster_spec(args.config_file)submit_jobs_to_clusters(formatted_jobs, clusters)
3.) For tuning jobs, specify the tuning job / hyperparameter sweep as a JobCfg .
The included JobCfg only supports the rl_games workflow due to differences in
environment entrypoints and hydra arguments, although other workflows will work if provided a compatible
JobCfg.
class JobCfg:"""To be compatible with :meth: invoke_tuning_run and :class:IsaacLabTuneTrainable,at a minimum, the tune job should inherit from this class."""def __init__(self, cfg: dict):"""Runner args include command line arguments passed to the task.For example:cfg["runner_args"]["video_singleton"] = "--video""""assert "runner_args" in cfg, "No runner arguments specified."cfg["runner_args"].setdefault("--rl_library", "rsl_rl")"""Task is the desired task to train on. For example:cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])"""assert "--task" in cfg["runner_args"], "No task specified.""""Hydra args define the hyperparameters varied within the sweep. For example:cfg["hydra_args"]["agent.params.network.cnn.activation"] = tune.choice(["relu", "elu"])"""assert "hydra_args" in cfg, "No hyperparameters specified."self.cfg = cfg
For example, see the following Cartpole Example configurations.
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).# All rights reserved.## SPDX-License-Identifier: BSD-3-Clauseimport pathlibimport sysfrom typing import Any# Allow for import of items from the ray workflow.CUR_DIR = pathlib.Path(__file__).parentUTIL_DIR = CUR_DIR.parentsys.path.extend([str(UTIL_DIR), str(CUR_DIR)])import utilimport vision_cfgfrom ray import tunefrom ray.tune.progress_reporter import CLIReporterfrom ray.tune.stopper import Stopperclass CartpoleRGBNoTuneJobCfg(vision_cfg.CameraJobCfg):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])super().__init__(cfg, vary_env_count=False, vary_cnn=False, vary_mlp=False)class CartpoleRGBCNNOnlyJobCfg(vision_cfg.CameraJobCfg):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])super().__init__(cfg, vary_env_count=False, vary_cnn=True, vary_mlp=False)class CartpoleRGBJobCfg(vision_cfg.CameraJobCfg):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])super().__init__(cfg, vary_env_count=True, vary_cnn=True, vary_mlp=True)class CartpoleResNetJobCfg(vision_cfg.ResNetCameraJob):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])cfg["hydra_args"]["presets"] = "resnet18"super().__init__(cfg)class CartpoleTheiaJobCfg(vision_cfg.TheiaCameraJob):def __init__(self, cfg: dict = {}):cfg = util.populate_isaac_ray_cfg_args(cfg)cfg["runner_args"]["--task"] = tune.choice(["Isaac-Cartpole-Camera"])cfg["hydra_args"]["presets"] = "theia_tiny"super().__init__(cfg)class CustomCartpoleProgressReporter(CLIReporter):def __init__(self):super().__init__(metric_columns={"training_iteration": "iter","time_total_s": "total time (s)","Episode/Episode_Reward/alive": "alive","Episode/Episode_Reward/cart_vel": "cart velocity","rewards/time": "rewards/time",},max_report_frequency=5,sort_by_metric=True,)class CartpoleEarlyStopper(Stopper):def __init__(self):self._bad_trials = set()def __call__(self, trial_id: str, result: dict[str, Any]) -> bool:iter = result.get("training_iteration", 0)out_of_bounds = result.get("Episode/Episode_Termination/cart_out_of_bounds")# Mark the trial for stopping if conditions are metif iter >= 20 and out_of_bounds is not None and out_of_bounds > 0.85:self._bad_trials.add(trial_id)return trial_id in self._bad_trialsdef stop_all(self) -> bool:return False # only stop individual trials
To view the tuning results, view the MLFlow dashboard of the server that you created. For KubeRay, this can be done through port forwarding the MLFlow dashboard with the following.
kubectl port-forward service/isaacray-mlflow 5000:5000
Then visit the following address in a browser.
localhost:5000
If the MLFlow port is forwarded like above, it can be converted into tensorboard logs with this following command.
./isaaclab.sh -p scripts/reinforcement_learning/ray/mlflow_to_local_tensorboard.py \ --uri http://localhost:5000 --experiment-name IsaacRay-`<CLASS_JOB_CFG>`-tune --download-dir test
Kubernetes Cluster Cleanup
For the sake of conserving resources, and potentially freeing precious GPU resources for other people to use on shared compute platforms, please destroy the Ray cluster after use. They can be easily recreated! For KubeRay clusters, this can be done as follows.
kubectl get raycluster | egrep 'isaacray' | awk '{print $1}' | xargs kubectl delete raycluster &&kubectl get deployments | egrep 'mlflow' | awk '{print $1}' | xargs kubectl delete deployment &&kubectl get services | egrep 'mlflow' | awk '{print $1}' | xargs kubectl delete service &&kubectl get services | egrep 'isaacray' | awk '{print $1}' | xargs kubectl delete service