Source code for nemo_rl.distributed.virtual_cluster

# Copyright (c) 2025, NVIDIA CORPORATION.  All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import os
import sys
import time
from typing import Optional, TypedDict

import ray
from ray.util.placement_group import (
    PlacementGroup,
    placement_group,
    remove_placement_group,
)
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


[docs] class ClusterConfig(TypedDict): gpus_per_node: int num_nodes: int
# Get the directory path of the current module and the root of the package dir_path = os.path.dirname(os.path.abspath(__file__)) git_root = os.path.abspath(os.path.join(dir_path, "../.."))
[docs] class PY_EXECUTABLES: SYSTEM = sys.executable # Use NeMo-RL direct dependencies. BASE = "uv run --locked" # Use NeMo-RL direct dependencies and vllm. VLLM = "uv run --locked --extra vllm" # Megatron-core (and nemo dependencies) # We always run with --reinstall to avoid issues where someone runs "uv run ... --extra mcore ..." # but the submodules are not downloaded yet. This results in errors where it appears Megatron/Nemo # aren't installed. Simple workaround is to always run the mcore py_executable with --reinstall. MCORE = "uv run --reinstall --extra mcore"
@ray.remote # pragma: no cover def _get_node_ip_and_free_port() -> tuple[str, int]: import socket # Get the IP address of the current node node_ip = ray._private.services.get_node_ip_address() with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("", 0)) # Bind to port 0 to get a random free port s.listen(1) port = s.getsockname()[1] return node_ip, port
[docs] def init_ray(log_dir: Optional[str] = None) -> None: """Initialise Ray. Try to attach to an existing local cluster. If that cluster uses the same CUDA_VISIBLE_DEVICES or Slurm managed tag we will reuse it. Otherwise, we will detach and start a fresh local cluster. """ # Set up runtime environment env_vars = dict(os.environ) env_vars.pop("RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES", None) runtime_env = { "env_vars": env_vars, # Pass thru all user environment variables } cvd = os.environ.get("CUDA_VISIBLE_DEVICES", "ALL") # sort cvd to ensure consistent tag cvd = ",".join(sorted(cvd.split(","))) cvd_tag_prefix = "nrl_tag_" cvd_tag = f"{cvd_tag_prefix}{cvd.replace(',', '_')}" # Try to attach to an existing cluster try: ray.init( address="auto", log_to_driver=True, include_dashboard=False, runtime_env=runtime_env, _temp_dir=os.path.abspath(log_dir) if log_dir else None, ) cluster_res = ray.cluster_resources() # Check reusability for NeMo-RL managed local clusters if any(k.startswith(cvd_tag_prefix) for k in cluster_res): # Reuse if the driver's cvd_tag matches a tag in the cluster. # This is for reusing a previously self-started local cluster. if cvd_tag in cluster_res: logger.info( f"Connected to existing Ray cluster (driver CVD_TAG '{cvd_tag}' matched): {cluster_res}" ) return # If neither reuse condition is met, but we connected to *something* logger.info( f"Existing Ray cluster found ({cluster_res}) but it does not meet reuse criteria. " f"Driver's cvd_tag: '{[k for k in cluster_res if k.startswith(cvd_tag_prefix)][0]}'. Expected cvd_tag: '{cvd_tag}'. " "Starting a new local cluster..." ) ray.shutdown() # Clear driver-side package cache so working_dir is re-uploaded import importlib import ray._private.runtime_env.packaging as _pkg importlib.reload(_pkg) # Always reuse if it's an externally managed cluster. else: logger.info(f"Connected to existing Ray cluster: {cluster_res}") return except ConnectionError: logger.debug("No existing Ray cluster found, will start a new one.") # If ConnectionError, proceed to start a new local cluster without further action here. # Clear driver-side package cache so working_dir is re-uploaded ray.shutdown() pass # Start a brand-new local cluster # Reuse `runtime_env` but drop `working_dir` to avoid packaging the whole repo (prevents ray OSError: Failed to download runtime_env file package issue) local_runtime_env = dict(runtime_env) local_runtime_env.pop("working_dir", None) ray.init( log_to_driver=True, include_dashboard=True, runtime_env=local_runtime_env, _temp_dir=os.path.abspath(log_dir) if log_dir else None, resources={cvd_tag: 1}, ) logger.info( f"Started local cluster with tag '{cvd_tag}': {ray.cluster_resources()}" )
[docs] class ResourceInsufficientError(Exception): """Exception raised when the cluster does not have enough resources to satisfy the requested configuration."""
[docs] class RayVirtualCluster: """Creates a virtual distributed cluster using Ray placement groups. This class simplifies distributed training setup by: - Creating placement groups that represent logical compute nodes - Allocating GPU and CPU resources for distributed workers - Managing communication between distributed processes - Bundle: A resource allocation unit (ex: 4 GPUs on a single node) - Worker: A process that performs computation (model training/inference) - Node: A physical or virtual machine containing multiple bundles """ def __init__( self, bundle_ct_per_node_list: list[int], use_gpus: bool = True, max_colocated_worker_groups: int = 1, num_gpus_per_node: int = 8, name: str = "", placement_group_strategy: str = "SPREAD", ): """Initialize a virtual cluster using Ray placement groups. Args: bundle_ct_per_node_list: List specifying GPU bundles per node (e.g., [2,2] creates 2 nodes with 2 GPU bundles each) use_gpus: Whether to allocate GPU resources max_colocated_worker_groups: Maximum number of worker groups that can be colocated num_gpus_per_node: Number of GPUs per node name: Name prefix for placement groups placement_group_strategy: Ray placement group strategy ("STRICT_PACK", "PACK", or "SPREAD") """ self._bundle_ct_per_node_list = bundle_ct_per_node_list self._world_size = sum(self._bundle_ct_per_node_list) self._node_placement_groups: Optional[list[PlacementGroup]] = None self.num_gpus_per_node = num_gpus_per_node self.use_gpus = use_gpus if use_gpus: assert num_gpus_per_node > 0, ( "num_gpus_per_node must be greater than 0 if using GPUs" ) self.max_colocated_worker_groups = max_colocated_worker_groups self.name = name self.placement_group_strategy = placement_group_strategy
[docs] def _init_placement_groups( self, strategy: str | None = None, use_unified_pg: bool = False ) -> list[PlacementGroup]: """Creates placement groups based on whether cross-node model parallelism is needed. Args: strategy: Ray placement group strategy (defaults to self.placement_group_strategy) use_unified_pg: If True, create a single unified placement group. If False, create per-node placement groups. Returns: List of placement groups """ if self._node_placement_groups is not None: return self._node_placement_groups if strategy is None: strategy = self.placement_group_strategy # Add retry logic that was previously in __init__ max_retries = int(os.environ.get("NRL_VIRTUAL_CLUSTER_MAX_RETRIES", 6)) assert max_retries > 0, ( f"NRL_VIRTUAL_CLUSTER_MAX_RETRIES={max_retries} must be an integer greater than 0" ) for i in range(max_retries): try: self._node_placement_groups = self._create_placement_groups_internal( strategy, use_unified_pg ) return self._node_placement_groups except ResourceInsufficientError as e: print(e) print( f"Retrying placement group creation... {i + 1}/{max_retries}. Next retry in {2**i} seconds." ) time.sleep(2**i) continue raise ResourceInsufficientError( f"Maximum number of retries reached ({max_retries}). Cluster resources may be insufficient or cluster itself is highly unstable. Please check your cluster configuration and your cluster logs." )
[docs] def _create_placement_groups_internal( self, strategy: str, use_unified_pg: bool = False ) -> list[PlacementGroup]: """Internal method to create placement groups without retry logic.""" # Check available resources in the Ray cluster cluster_resources = ray.cluster_resources() total_available_gpus = int(cluster_resources.get("GPU", 0)) total_available_cpus = int(cluster_resources.get("CPU", 0)) # Calculate required resources total_requested_gpus = ( sum(self._bundle_ct_per_node_list) if self.use_gpus else 0 ) total_requested_cpus = ( sum(self._bundle_ct_per_node_list) * self.max_colocated_worker_groups ) # Validate resources if self.use_gpus and total_requested_gpus > total_available_gpus: raise ResourceInsufficientError( f"Not enough GPUs available. Requested {total_requested_gpus} GPUs, but only {total_available_gpus} are available in the cluster." ) if total_requested_cpus > total_available_cpus: raise ResourceInsufficientError( f"Not enough CPUs available. Requested {total_requested_cpus} CPUs, but only {total_available_cpus} are available in the cluster." ) num_cpus_per_bundle = self.max_colocated_worker_groups # num_gpus_per_bundle == 1 indicates that there is 1 GPU per process num_gpus_per_bundle = 1 if self.use_gpus else 0 placement_groups = [] if use_unified_pg: # Create a single unified placement group for cross-node model parallelism all_bundles = [] for bundle_count in self._bundle_ct_per_node_list: for _ in range(bundle_count): all_bundles.append( {"CPU": num_cpus_per_bundle, "GPU": num_gpus_per_bundle} ) placement_groups = [ placement_group( bundles=all_bundles, strategy=strategy, name=f"{self.name}-unified" ) ] else: # Create per-node placement groups to respect bundle_ct_per_node_list for node_idx, bundle_count in enumerate(self._bundle_ct_per_node_list): if bundle_count > 0: node_bundles = [ {"CPU": num_cpus_per_bundle, "GPU": num_gpus_per_bundle} for _ in range(bundle_count) ] pg = placement_group( bundles=node_bundles, strategy="PACK", # Use PACK to keep bundles together name=f"{self.name}-node{node_idx}", ) placement_groups.append(pg) # Add timeout to prevent hanging indefinitely try: ray.get( [pg.ready() for pg in placement_groups], timeout=180 ) # 3-minute timeout except (TimeoutError, ray.exceptions.GetTimeoutError): # Clean up any created placement groups for pg in placement_groups: try: remove_placement_group(pg) except Exception: pass raise TimeoutError( "Timed out waiting for placement groups to be ready. The cluster may not have enough resources " "to satisfy the requested configuration, or the resources may be busy with other tasks." ) return placement_groups
[docs] def get_placement_groups(self) -> list[PlacementGroup]: # Initialize placement groups if not already created if self._node_placement_groups is None: self._init_placement_groups() assert self._node_placement_groups is not None, ( "Placement groups must be initialized before calling get_placement_groups" ) return [pg for pg in self._node_placement_groups if pg.bundle_specs]
[docs] def world_size(self) -> int: return self._world_size
[docs] def node_count(self) -> int: return sum(1 for count in self._bundle_ct_per_node_list if count > 0)
[docs] def get_master_address_and_port(self) -> tuple[str, int]: """Gets the master address and port for the distributed training setup. Returns: Tuple of (address, port) """ # Get placement groups if not already created if not self._node_placement_groups: self.get_placement_groups() # Use the first bundle of the first placement group # This works for both unified PG and per-node PGs pg = self.get_placement_groups()[0] if pg.bundle_specs: # Launch port finder on the first bundle of this placement group addr, port = ray.get( _get_node_ip_and_free_port.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=pg, placement_group_bundle_index=0 ), # Need to explicitly set to 0 since it's possible for this to be unschedulable if all CPUs are already in use. num_cpus=0, ).remote() ) return addr, port raise RuntimeError("No valid placement groups found to get master address")
[docs] def shutdown(self) -> bool: """Cleans up and releases all resources associated with this virtual cluster. This includes removing all placement groups and resetting the internal state. This method is idempotent and can be safely called multiple times. """ if self._node_placement_groups is not None: # Remove all placement groups for pg in self._node_placement_groups: try: remove_placement_group(pg) except Exception as e: # Log but continue if a placement group can't be removed print(f"Error removing placement group {pg.id}: {e}") # Reset internal state self._node_placement_groups = None return True
[docs] def __del__(self) -> None: """Shutsdown the virtual cluster when the object is deleted or is garbage collected. This is an extra safety net in case the user forgets to call shutdown and the pointer to the cluster is lost due to leaving a function scope. It's always recommended that the user calls shutdown(). """ self.shutdown()