Files
ray-project--ray/python/ray/train/v2/tests/test_worker_group.py
T
2026-07-13 13:17:40 +08:00

1338 lines
44 KiB
Python

import collections
import os
import time
from typing import Dict, List, Optional
from unittest.mock import MagicMock, patch
import pytest
import ray
from ray._private.state import state as ray_state
from ray.exceptions import RayActorError, RayTaskError
from ray.runtime_env import RuntimeEnv
from ray.train.v2._internal.callbacks import backend_setup
from ray.train.v2._internal.callbacks.backend_setup import BackendSetupCallback
from ray.train.v2._internal.constants import (
ENV_VARS_TO_PROPAGATE,
WORKER_GROUP_START_TIMEOUT_S_ENV_VAR,
WORKER_HEALTH_CHECK_TIMEOUT_S_ENV_VAR,
)
from ray.train.v2._internal.exceptions import (
InsufficientClusterResourcesError,
WorkerGroupStartupFailedError,
WorkerGroupStartupTimeoutError,
WorkerHealthCheckFailedError,
WorkerHealthCheckTimeoutError,
)
from ray.train.v2._internal.execution.callback import (
ReplicaGroupCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.context import (
DistributedContext,
get_train_context,
)
from ray.train.v2._internal.execution.worker_group import (
ActorMetadata,
RayTrainWorker,
Worker,
WorkerGroup,
WorkerGroupContext,
WorkerGroupState,
)
from ray.train.v2._internal.util import ObjectRefWrapper
from ray.train.v2.api.config import RunConfig, ScalingConfig
from ray.train.v2.tests.util import DummyObjectRefWrapper, create_dummy_run_context
from ray.util.state import list_actors
pytestmark = pytest.mark.usefixtures("mock_runtime_context")
@pytest.fixture(autouse=True, scope="module")
def ray_start_4_cpus():
ray.init(num_cpus=4)
yield
ray.shutdown()
def _default_inactive_worker_group(**kwargs):
default_config = {
"train_run_context": create_dummy_run_context(),
"worker_group_context": _default_worker_group_context(),
}
default_config.update(kwargs)
return WorkerGroup(**default_config)
def _default_worker_group_context(**kwargs):
default_config = {
"run_attempt_id": "test_run_attempt_id",
"train_fn_ref": DummyObjectRefWrapper(lambda: None),
"num_workers": 4,
"resources_per_worker": {"CPU": 1},
}
default_config.update(kwargs)
return WorkerGroupContext(**default_config)
def test_worker_group_create():
"""Test WorkerGroup.create() factory method."""
worker_group = WorkerGroup.create(
train_run_context=create_dummy_run_context(),
worker_group_context=_default_worker_group_context(),
)
assert len(worker_group) == 4
assert worker_group.has_started()
with pytest.raises(ValueError, match="Worker group is active"):
worker_group._start()
worker_group.shutdown()
with pytest.raises(ValueError, match="Worker group is not active"):
worker_group.get_workers()
def test_replace_replica_group():
"""Test that replace_replica_group correctly replaces a failing replica group."""
wg = _default_inactive_worker_group()
wg._start()
# Remember old state.
old_workers = wg.get_workers()
old_state = wg.get_worker_group_state()
old_replica_groups = wg.get_replica_groups()
old_rg0_workers = old_replica_groups[0].get_workers()
old_rg1_workers = old_replica_groups[1].get_workers()
# Replace replica group 0 and get new state.
wg.replace_replica_group(0)
new_workers = wg.get_workers()
new_state = wg.get_worker_group_state()
new_replica_groups = wg.get_replica_groups()
# Assert most of WorkerGroupState is preserved.
assert len(new_workers) == len(old_workers)
assert new_state.start_time == old_state.start_time
assert new_state.placement_group_handle is old_state.placement_group_handle
assert new_state.sync_actor is old_state.sync_actor
# Assert replica group 0 workers are replaced but with same distributed contexts.
new_rg0_workers = new_replica_groups[0].get_workers()
for old_w, new_w in zip(old_rg0_workers, new_rg0_workers):
assert new_w is not old_w
new_rg1_workers = new_replica_groups[1].get_workers()
for old_w, new_w in zip(old_rg1_workers, new_rg1_workers):
assert new_w is old_w
for old_w, new_w in zip(old_rg0_workers, new_rg0_workers):
assert (
new_w.distributed_context.world_rank == old_w.distributed_context.world_rank
)
# Assert other state is as expected.
for w in new_rg0_workers:
assert (
wg._worker_rank_to_replica_group_rank[w.distributed_context.world_rank] == 0
)
for old_w in old_rg0_workers:
assert (
old_w.distributed_context.world_rank not in wg._world_rank_to_ongoing_poll
)
wg.shutdown()
def test_replace_replica_group_succeed_on_retry():
"""Test that replace_replica_group raises WorkerGroupStartupFailedError
when a replacement worker fails to initialize."""
class FailingWorker(RayTrainWorker):
def __init__(self):
raise RuntimeError("Replacement worker failed to start.")
wg = _default_inactive_worker_group()
wg._start()
# Swap the worker class so replacement workers will fail.
wg._worker_cls = FailingWorker
with pytest.raises(WorkerGroupStartupFailedError):
wg.replace_replica_group(0)
# Swap worker class so second attempt succeeds.
wg._worker_cls = RayTrainWorker
wg.replace_replica_group(0)
wg.shutdown()
@pytest.mark.parametrize(
"runtime_env",
[{"env_vars": {"DUMMY_VAR": "abcd"}}, RuntimeEnv(env_vars={"DUMMY_VAR": "abcd"})],
)
def test_worker_group_create_with_runtime_env(runtime_env):
"""Test WorkerGroup.create() factory method with a custom runtime environment."""
run_config = RunConfig(worker_runtime_env=runtime_env)
train_run_context = create_dummy_run_context(run_config=run_config)
worker_group_context = _default_worker_group_context()
worker_group = WorkerGroup.create(
train_run_context=train_run_context,
worker_group_context=worker_group_context,
)
env_vars = worker_group.execute(lambda: os.environ.get("DUMMY_VAR"))
assert env_vars == ["abcd"] * worker_group_context.num_workers
worker_group.shutdown()
def test_env_var_propagation(monkeypatch):
"""Ray Train should automatically propagate some environment variables
from the driver to the workers."""
test_env_var = list(ENV_VARS_TO_PROPAGATE)[0]
monkeypatch.setenv(test_env_var, "1")
wg = _default_inactive_worker_group()
wg._start()
env_vars = wg.execute(lambda: os.environ.get(test_env_var))
wg.shutdown()
assert env_vars == ["1"] * 4
def test_actor_start_failure():
class FailingWorker(RayTrainWorker):
def __init__(self):
raise RuntimeError("Worker failed to start.")
wg = _default_inactive_worker_group()
wg._worker_cls = FailingWorker
with pytest.raises(WorkerGroupStartupFailedError):
wg._start()
# TODO: this and other tests should verify that we shut down the worker group.
def test_callback_start_failure():
class FailingCallback(WorkerGroupCallback):
def after_worker_group_start(self, worker_group):
raise RuntimeError("Worker failed to start.")
wg = _default_inactive_worker_group(callbacks=[FailingCallback()])
with pytest.raises(RuntimeError):
wg._start()
wg.shutdown()
def test_start_timeout(monkeypatch):
from ray.train.v2._internal.execution.worker_group.placement_group_handle import (
DefaultPlacementGroupHandle,
)
monkeypatch.setenv(WORKER_GROUP_START_TIMEOUT_S_ENV_VAR, "0.1")
monkeypatch.setattr(
DefaultPlacementGroupHandle,
"wait",
lambda self, timeout_seconds=None: False,
)
wg = _default_inactive_worker_group()
with pytest.raises(WorkerGroupStartupTimeoutError):
# Not enough CPU resources are available, so the workers will not start.
wg._start()
def test_tpu_slice_reservation_timeout_is_retryable(monkeypatch):
"""TPU head reservation timeouts should surface as the retryable
``WorkerGroupStartupTimeoutError`` so the controller transitions
SCHEDULING -> RESCHEDULING instead of failing the run, matching the
behavior of CPU/GPU placement-group timeouts.
Also verifies that Ray Train's worker-group-start timeout is forwarded
to ``SlicePlacementGroup`` so users have a single knob governing how
long the cluster has to provide capacity.
"""
from ray.train.v2._internal.execution.worker_group import worker_group as wg_mod
monkeypatch.setenv(WORKER_GROUP_START_TIMEOUT_S_ENV_VAR, "0.1")
# SlicePlacementGroup blocks synchronously on a TPU head PG; simulate the
# "cluster still autoscaling" scenario where reserve_tpu_slice times out
# and capture the timeout that Ray Train passed in.
captured_kwargs = {}
def _raise_timeout(*args, **kwargs):
captured_kwargs.update(kwargs)
raise TimeoutError(
"Failed to reserve TPU head for slice with shape: v5litepod-8 "
"after 0.1 seconds."
)
monkeypatch.setattr(wg_mod, "SlicePlacementGroup", _raise_timeout)
monkeypatch.setattr(
wg_mod, "get_tpu_version_from_type", lambda accelerator_type: "v5litepod"
)
tpu_scaling_config = ScalingConfig(
num_workers=4,
resources_per_worker={"TPU": 4},
accelerator_type="TPU-V5LITEPOD",
topology="2x4",
use_tpu=True,
)
wg = _default_inactive_worker_group(
train_run_context=create_dummy_run_context(scaling_config=tpu_scaling_config),
worker_group_context=_default_worker_group_context(
num_workers=4,
resources_per_worker={"TPU": 4},
num_slices=2,
),
)
with pytest.raises(WorkerGroupStartupTimeoutError):
wg._create_placement_group(tpu_scaling_config, wg._worker_group_context)
# Ray Train should forward its own startup timeout to the TPU head wait
# rather than relying on the hard-coded default in reserve_tpu_slice.
assert captured_kwargs.get("head_reservation_timeout_s") == 0.1
def test_tpu_slice_reservation_non_timeout_failure_is_retryable(monkeypatch):
"""Non-timeout failures from ``SlicePlacementGroup`` (e.g. transient
RPC errors) should surface as the retryable
``WorkerGroupStartupFailedError``, matching the precedent of the
worker-actor startup path (``RayActorError`` -> ``WorkerGroupStartupFailedError``)
so non-timeout startup failures retry consistently across CPU/GPU/TPU.
"""
from ray.train.v2._internal.execution.worker_group import worker_group as wg_mod
def _raise_runtime_error(*args, **kwargs):
raise RuntimeError("transient placement group reservation error")
monkeypatch.setattr(wg_mod, "SlicePlacementGroup", _raise_runtime_error)
monkeypatch.setattr(
wg_mod, "get_tpu_version_from_type", lambda accelerator_type: "v5litepod"
)
tpu_scaling_config = ScalingConfig(
num_workers=4,
resources_per_worker={"TPU": 4},
accelerator_type="TPU-V5LITEPOD",
topology="2x4",
use_tpu=True,
)
wg = _default_inactive_worker_group(
train_run_context=create_dummy_run_context(scaling_config=tpu_scaling_config),
worker_group_context=_default_worker_group_context(
num_workers=4,
resources_per_worker={"TPU": 4},
num_slices=2,
),
)
with pytest.raises(
WorkerGroupStartupFailedError, match="Failed to reserve TPU slice"
):
wg._create_placement_group(tpu_scaling_config, wg._worker_group_context)
def test_zombie_actor_termination(ray_start_4_cpus):
"""This test checks that RayTrainWorker actors are terminated correctly even if python garbage collection hangs on actor shutdown."""
NUM_WORKERS = 4
def is_process_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
else:
return True
class Node:
def __init__(self, name):
self.name = name
self.other = None
def __del__(self):
# Simulate hang during garbage collection
while True:
time.sleep(1)
def train_fn():
# Create a circular reference to delay garbage collection
a, b = Node("a"), Node("b")
a.other = b
b.other = a
train_fn_ref = ObjectRefWrapper(train_fn)
train_run_context = create_dummy_run_context(
scaling_config=ScalingConfig(num_workers=NUM_WORKERS)
)
worker_group_context = _default_worker_group_context(
train_fn_ref=train_fn_ref,
num_workers=NUM_WORKERS,
)
# Starts the worker group and runs the train function
worker_group = WorkerGroup.create(
train_run_context=train_run_context,
worker_group_context=worker_group_context,
callbacks=[],
)
train_worker_pids = [
actor.pid
for actor in list_actors()
if actor.class_name == RayTrainWorker.__name__ and actor.state == "ALIVE"
]
assert len(train_worker_pids) == NUM_WORKERS
worker_group.shutdown()
# ray.kill is async, allow some time for the processes to terminate
TIMEOUT_S = 5
deadline = time.monotonic() + TIMEOUT_S
remaining = set(train_worker_pids)
while remaining and time.monotonic() < deadline:
remaining = {pid for pid in remaining if is_process_alive(pid)}
if remaining:
time.sleep(0.1)
assert not remaining
def test_insufficient_cluster_resources_startup_failure(monkeypatch):
"""Test that WorkerGroup startup fails when cluster has insufficient resources.
This test mocks the cluster resources to match the test environment and
verifies that the resource check properly catches insufficient resources.
"""
# Mock the cluster resources to return the test cluster configuration (4 CPUs)
monkeypatch.setattr(
ray_state, "get_max_resources_from_cluster_config", lambda: {"CPU": 4.0}
)
# The test cluster has 4 CPUs, so requesting 8 workers with 1 CPU each should fail
worker_group_context = _default_worker_group_context(
num_workers=8, # More workers than available CPUs
resources_per_worker={"CPU": 1.0},
)
wg = _default_inactive_worker_group(worker_group_context=worker_group_context)
# This should fail during startup due to insufficient resources
with pytest.raises(
InsufficientClusterResourcesError, match="Insufficient cluster resources"
):
wg._start()
# TODO: consider test_poll_status methods that verify that _world_rank_to_ongoing_poll
# is updated correctly.
def test_poll_status_running():
worker_group_context = _default_worker_group_context(
train_fn_ref=DummyObjectRefWrapper(lambda: time.sleep(60)),
)
wg = _default_inactive_worker_group(worker_group_context=worker_group_context)
wg._start()
status = wg.poll_status()
wg.shutdown()
assert len(status.worker_statuses) == 4
assert not status.finished
assert not status.errors
assert status.worker_rank_to_replica_group_rank == {0: 0, 1: 1, 2: 2, 3: 3}
assert status.failing_replica_group_indices == set()
def test_poll_status_finished():
worker_group_context = _default_worker_group_context(
train_fn_ref=DummyObjectRefWrapper(lambda: "done"),
)
wg = _default_inactive_worker_group(worker_group_context=worker_group_context)
wg._start()
# Wait for the workers to finish the training fn before polling.
# Otherwise, the poll_status call may return before the workers finish.
while not wg.poll_status().finished:
time.sleep(0.01)
status = wg.poll_status()
wg.shutdown()
assert len(status.worker_statuses) == 4
assert status.finished
assert not status.errors
assert status.worker_rank_to_replica_group_rank == {0: 0, 1: 1, 2: 2, 3: 3}
assert status.failing_replica_group_indices == set()
@pytest.mark.parametrize("actor_failure", [True, False])
def test_poll_status_failures(monkeypatch, tmp_path, actor_failure):
"""Tests that the worker group raises the correct errors when the
actor fails or the user code raises an error on any worker."""
dummy_file = tmp_path / "dummy.txt"
def train_fn():
# Error when the worker group initialization is finished.
while not dummy_file.exists():
time.sleep(0.01)
if actor_failure:
os._exit(1)
else:
raise RuntimeError("Mock user code error")
worker_group_context = _default_worker_group_context(
train_fn_ref=DummyObjectRefWrapper(train_fn),
)
wg = _default_inactive_worker_group(worker_group_context=worker_group_context)
wg._start()
dummy_file.touch()
while not wg.poll_status().finished:
time.sleep(0.01)
status = wg.poll_status()
wg.shutdown()
assert len(status.worker_statuses) == 4
assert status.finished
assert status.worker_rank_to_replica_group_rank == {0: 0, 1: 1, 2: 2, 3: 3}
assert status.failing_replica_group_indices == {0, 1, 2, 3}
if actor_failure:
assert len(status.errors) == 4
assert [
isinstance(error, WorkerHealthCheckFailedError)
for error in status.errors.values()
]
assert [
isinstance(error.health_check_failure, RuntimeError)
for error in status.errors.values()
]
else:
assert len(status.errors) == 4
assert all(
["user code error" in str(error) for error in status.errors.values()]
)
def test_poll_status_healthcheck_timeout(monkeypatch):
monkeypatch.setenv(WORKER_HEALTH_CHECK_TIMEOUT_S_ENV_VAR, "0")
def hanging_poll_status(worker_self):
time.sleep(60)
monkeypatch.setattr(RayTrainWorker, "poll_status", hanging_poll_status)
wg = _default_inactive_worker_group()
# Try 2x to ensure that shutdown clears the health-check hanging timer.
for _ in range(2):
wg._start()
status = wg.poll_status(timeout=0.01)
assert len(status.errors) == 4
assert all(
[
isinstance(error, WorkerHealthCheckTimeoutError)
for error in status.errors.values()
]
)
assert status.failing_replica_group_indices == {0, 1, 2, 3}
wg.shutdown()
@pytest.mark.parametrize("queue_backlog_length", [0, 1, 3])
def test_flush_worker_result_queue(queue_backlog_length):
"""Test that the worker group is still considered running while the
result queue is not fully consumed."""
wg = _default_inactive_worker_group()
wg._start()
def populate_result_queue():
# Note that the result queue is a thread-safe queue of maxsize 1.
get_train_context().get_result_queue().put("result")
for _ in range(queue_backlog_length):
wg.execute(populate_result_queue)
status = wg.poll_status()
assert all(
worker_status.training_report
for worker_status in status.worker_statuses.values()
)
assert not status.finished
# Wait for the workers to finish the training fn and for any pending
# training_report(s) to be flushed/consumed.
timeout_s = 5
deadline = time.monotonic() + timeout_s
while True:
status = wg.poll_status()
if status.finished:
break
assert (
time.monotonic() < deadline
), f"Timed out waiting for worker group to finish. Last status: {status}"
time.sleep(0.01)
assert all(
worker_status.training_report is None
for worker_status in status.worker_statuses.values()
)
wg.shutdown()
def test_group_workers_by_ip():
def create_workers(node_ids):
return [
Worker(
actor=None,
metadata=ActorMetadata(
node_id=node_id,
node_ip="dummy",
hostname="dummy",
accelerator_ids={},
pid=0,
),
resources={"CPU": 1},
)
for node_id in node_ids
]
workers = create_workers(["2", "3", "1", "4", "2", "1", "3", "3", "4", "2"])
workers = WorkerGroup._sort_workers_by_gpu_id_grouped_by_node(workers)
expected = ["2", "2", "2", "3", "3", "3", "1", "1", "4", "4"]
ips = [w.metadata.node_id for w in workers]
assert ips == expected, (
"Workers should be grouped by node ID "
"and follow the same original order of IDs encountered (2, 3, 1, 4)."
)
workers = create_workers(["2", "3", "1", "4", "2", "1", "3", "3", "4", "2"])
workers = WorkerGroup._sort_workers_by_gpu_id_grouped_by_node(
workers, _first_id="1"
)
expected = ["1", "1", "2", "2", "2", "3", "3", "3", "4", "4"]
ips = [w.metadata.node_id for w in workers]
assert (
ips == expected
), "Workers should be grouped by ID, with the first ID being 1."
def test_local_rank_assignment():
def create_workers(pids, node_ids, gpu_ids):
return [
Worker(
actor=None,
metadata=ActorMetadata(
node_id=node_id,
node_ip="dummy",
hostname="dummy",
accelerator_ids={"GPU": gpu_id.split(",") if gpu_id else []},
pid=pid,
),
resources={"CPU": 1},
)
for pid, node_id, gpu_id in zip(pids, node_ids, gpu_ids)
]
def setup_and_check_worker_group(
pids: List[int],
node_ids: List[str],
gpu_ids: List[Optional[str]],
expected_local_ranks: Dict[int, int],
):
"""
Create a worker group, group workers by IP, and check local ranks assignment.
Args:
pids: List of unique process IDs.
node_ids: List of node ids corresponding to each PID.
gpu_ids: List of GPU IDs or None for each PID.
expected_local_ranks: Dictionary mapping PID to the
expected local rank.
"""
workers = create_workers(pids=pids, node_ids=node_ids, gpu_ids=gpu_ids)
workers = WorkerGroup._sort_workers_by_gpu_id_grouped_by_node(workers)
# Build local ranks according to the logics in
# TODO: Replace this with the actual implementation later
node_id_dict = collections.defaultdict(int)
local_ranks_map = collections.defaultdict(int)
for w in workers:
local_ranks_map[w.metadata.pid] = node_id_dict[w.metadata.node_id]
node_id_dict[w.metadata.node_id] += 1
local_ranks = [local_ranks_map[pid] for pid in pids]
assert (
local_ranks == expected_local_ranks
), "Incorrect local ranks allocation!\n"
f"Expect: {expected_local_ranks}\nGot: {local_ranks}"
# Define the worker configurations for different scenarios
# For workers without GPU resources, their original order will be preserved
cpu_workers_config = {
"pids": [0, 1, 2, 3, 4, 5, 6, 7],
"node_ids": ["2", "2", "1", "1", "2", "1", "1", "2"],
"gpu_ids": [None] * 8,
"expected_local_ranks": [0, 1, 0, 1, 2, 2, 3, 3],
}
gpu_workers_single_gpu_config = {
"pids": [0, 1, 2, 3, 4, 5, 6, 7],
"node_ids": ["2", "2", "1", "1", "2", "1", "1", "2"],
"gpu_ids": ["1", "0", "3", "2", "2", "0", "1", "3"],
"expected_local_ranks": [1, 0, 3, 2, 2, 0, 1, 3],
}
# For workers with multiple gpus, sort by their lowest gpu id
gpu_workers_multiple_gpus_config = {
"pids": [0, 1, 2, 3],
"node_ids": ["2", "1", "1", "2"],
"gpu_ids": ["1,3", "2,1", "0,3", "0,2"],
"expected_local_ranks": [1, 1, 0, 0],
}
# Setup and check worker groups for each configuration
setup_and_check_worker_group(**cpu_workers_config)
setup_and_check_worker_group(**gpu_workers_single_gpu_config)
setup_and_check_worker_group(**gpu_workers_multiple_gpus_config)
@pytest.mark.parametrize("replace_rg", [False, True], ids=["start", "replace_rg"])
def test_setup_worker_group(tmp_path, replace_rg):
num_workers = 4
worker_group = WorkerGroup(
train_run_context=create_dummy_run_context(
run_config=RunConfig(name="test", storage_path=str(tmp_path))
),
worker_group_context=_default_worker_group_context(num_workers=num_workers),
)
if replace_rg:
worker_group._manages_replica_groups = True
worker_group._start()
if replace_rg:
worker_group.replace_replica_group(0)
def get_world_size():
return ray.train.get_context().get_world_size()
def get_world_rank():
return ray.train.get_context().get_world_rank()
def get_storage_context_name():
return ray.train.get_context().get_storage().experiment_dir_name
def get_local_rank():
return ray.train.get_context().get_local_rank()
def get_local_world_size():
return ray.train.get_context().get_local_world_size()
def get_node_rank():
return ray.train.get_context().get_node_rank()
if replace_rg:
assert worker_group.execute(get_local_rank) == [0] * num_workers
assert worker_group.execute(get_local_world_size) == [1] * num_workers
else:
assert worker_group.execute(get_local_rank) == list(range(num_workers))
assert worker_group.execute(get_local_world_size) == [num_workers] * num_workers
assert worker_group.execute(get_node_rank) == [0] * num_workers
assert worker_group.execute(get_world_size) == [num_workers] * num_workers
assert sorted(worker_group.execute(get_world_rank)) == list(range(num_workers))
assert worker_group.execute(get_storage_context_name) == ["test"] * num_workers
worker_group.shutdown()
def test_worker_group_callback():
"""Check that all worker group callback hooks are called."""
class AssertCallback(WorkerGroupCallback):
def __init__(self):
self.start_hook_called = False
self.training_start_hook_called = False
self.shutdown_hook_called = False
self.poll_status_hook_called = False
self.abort_hook_called = False
def after_worker_group_start(self, worker_group):
self.start_hook_called = True
def after_worker_group_training_start(self, worker_group):
self.training_start_hook_called = True
def before_worker_group_shutdown(self, worker_group):
self.shutdown_hook_called = True
def after_worker_group_shutdown(self, worker_group_context):
self.after_worker_group_shutdown_hook_called = True
def after_worker_group_poll_status(self, worker_group_status):
assert len(worker_group_status.worker_statuses) == 4
self.poll_status_hook_called = True
hooks = AssertCallback()
wg = _default_inactive_worker_group(callbacks=[hooks])
wg._start()
assert hooks.start_hook_called
assert hooks.training_start_hook_called
wg.poll_status()
assert hooks.poll_status_hook_called
wg.shutdown()
assert hooks.shutdown_hook_called
assert hooks.after_worker_group_shutdown_hook_called
def _make_backend_setup_callback_with_failing_shutdown(
error: Exception,
) -> BackendSetupCallback:
"""Build a `BackendSetupCallback` whose backend raises `error` from `on_shutdown`."""
failing_backend = MagicMock()
failing_backend.on_shutdown.side_effect = error
backend_config = MagicMock()
backend_config.backend_cls.return_value = failing_backend
cb = BackendSetupCallback(backend_config)
cb._backend = failing_backend
return cb
@pytest.mark.parametrize(
"shutdown_error",
[
RayActorError(actor_id="abc", error_msg="actor died"),
RayTaskError(
function_name="_shutdown_torch",
traceback_str="traceback",
cause=RuntimeError("NCCL error: remote process exited"),
proctitle="test",
pid=1,
ip="127.0.0.1",
),
],
ids=["RayActorError", "RayTaskError"],
)
def test_backend_setup_callback_swallows_shutdown_failure(shutdown_error):
"""Test `BackendSetupCallback` swallows both RayActorError and RayTaskError so
`WorkerGroup.shutdown()` does not propagate the cleanup failure.
"""
cb = _make_backend_setup_callback_with_failing_shutdown(shutdown_error)
failing_backend = cb._backend
wg = _default_inactive_worker_group(callbacks=[cb])
wg._start()
with patch.object(backend_setup, "logger") as mock_logger:
wg.shutdown() # must not raise
failing_backend.on_shutdown.assert_called_once()
mock_logger.warning.assert_called_once()
msg = mock_logger.warning.call_args.args[0]
assert "Graceful shutdown of backend failed" in msg
# exc_info=True keeps the underlying NCCL/actor failure in the logs.
assert mock_logger.warning.call_args.kwargs.get("exc_info") is True
def test_backend_setup_callback_propagates_unexpected_shutdown_error():
"""Non-Ray exceptions from `on_shutdown` must propagate so they aren't
silently masked."""
cb = _make_backend_setup_callback_with_failing_shutdown(
ValueError("unexpected backend bug")
)
with pytest.raises(ValueError, match="unexpected backend bug"):
cb.before_execution_group_shutdown(MagicMock())
@pytest.mark.parametrize("replace_rg", [False, True], ids=["start", "replace_rg"])
def test_worker_log_file_paths(replace_rg):
"""Test that log file paths are correctly assigned to workers."""
wg = _default_inactive_worker_group()
wg._start()
if replace_rg:
wg.replace_replica_group(0)
# Check that all workers have log file paths assigned
workers = wg.get_workers()
for worker in workers:
assert worker.log_file_path is not None
assert "ray-train-app-worker" in worker.log_file_path
wg.shutdown()
def test_replica_group_callback():
"""Check that replica group callback hooks are called during replace_replica_group."""
class AssertCallback(ReplicaGroupCallback):
def __init__(self):
self.shutdown_rg = None
self.start_rg = None
self.init_context_workers = None
def before_replica_group_shutdown(self, replica_group):
self.shutdown_rg = replica_group
def after_replica_group_start(self, replica_group):
self.start_rg = replica_group
def before_init_train_context(self, workers):
self.init_context_workers = workers
return {}
hooks = AssertCallback()
wg = _default_inactive_worker_group(callbacks=[hooks])
wg._start()
old_rg = wg.get_replica_groups()[0]
wg.replace_replica_group(0)
new_rg = wg.get_replica_groups()[0]
assert hooks.shutdown_rg is old_rg
assert hooks.start_rg is new_rg
assert hooks.start_rg is not hooks.shutdown_rg
assert hooks.init_context_workers == new_rg.get_workers()
wg.shutdown()
def test_worker_group_abort(monkeypatch):
class AssertCallback(WorkerGroupCallback):
def __init__(self):
self.abort_hook_called = False
def before_worker_group_abort(self, worker_group_context):
self.abort_hook_called = True
def after_worker_group_abort(self, worker_group_context):
self.after_worker_group_abort_hook_called = True
hooks = AssertCallback()
wg = _default_inactive_worker_group(callbacks=[hooks])
wg._start()
# Track shutdown calls without preventing actual cleanup
shutdown_call_count = 0
original_shutdown = WorkerGroupState.shutdown
def track_shutdown_calls(self):
nonlocal shutdown_call_count
shutdown_call_count += 1
return original_shutdown(self)
monkeypatch.setattr(WorkerGroupState, "shutdown", track_shutdown_calls)
wg.abort()
assert (
shutdown_call_count == 1
), f"Expected shutdown to be called once, but was called {shutdown_call_count} times"
assert hooks.abort_hook_called
assert hooks.after_worker_group_abort_hook_called
# Bypass _assert_active method, allowing for shutdown
monkeypatch.setattr(wg, "_assert_active", lambda: None)
wg.shutdown()
def test_shutdown_hook_with_dead_actors():
"""Check that the shutdown hook raises correctly if run
on a mix of alive and dead actors."""
class ShutdownCallback(WorkerGroupCallback):
def before_worker_group_shutdown(self, worker_group):
# Mock a hanging collective call on the remaining workers.
def f():
print(ray.train.get_context().get_world_rank())
time.sleep(10)
wg.execute(f)
def conditional_failure():
if ray.train.get_context().get_world_rank() % 2 == 0:
ray.actor.exit_actor()
wg = _default_inactive_worker_group(callbacks=[ShutdownCallback()])
wg._start()
# Kill some of the actors
try:
wg.execute(conditional_failure)
except RayActorError:
pass
# The shutdown hook should not hang here and should immediately raise.
start = time.monotonic()
with pytest.raises(RayActorError):
wg.shutdown()
# Should not wait for the full 10 seconds.
assert time.monotonic() - start < 1
# TODO: This test leaves the WorkerGroup in a bad state.
# If more tests are added below this, they may not be able to run.
def test_check_cluster_resources_and_raise_if_insufficient(monkeypatch):
"""Test _check_cluster_resources_and_raise_if_insufficient static method."""
def _assert_resource_check(
available_resources, resources_per_worker, num_workers, should_raise
):
"""Helper to test resource checking with different scenarios."""
monkeypatch.setattr(
ray_state,
"get_max_resources_from_cluster_config",
lambda: available_resources,
)
if should_raise:
with pytest.raises(
InsufficientClusterResourcesError,
match="Insufficient cluster resources",
):
WorkerGroup._check_cluster_resources_and_raise_if_insufficient(
resources_per_worker=resources_per_worker, num_workers=num_workers
)
else:
# Should not raise
WorkerGroup._check_cluster_resources_and_raise_if_insufficient(
resources_per_worker=resources_per_worker, num_workers=num_workers
)
# Test case 1: Sufficient resources - should not raise
_assert_resource_check(
available_resources={"CPU": 8.0, "GPU": 4.0},
resources_per_worker={"CPU": 1.0, "GPU": 0.5},
num_workers=4,
should_raise=False,
)
# Test case 2: Insufficient CPU resources - should raise
_assert_resource_check(
available_resources={"CPU": 8.0, "GPU": 4.0},
resources_per_worker={"CPU": 3.0},
num_workers=4, # Requires 12 CPU but only 8 available
should_raise=True,
)
# Test case 3: Insufficient GPU resources - should raise
_assert_resource_check(
available_resources={"CPU": 8.0, "GPU": 4.0},
resources_per_worker={"GPU": 2.0},
num_workers=3, # Requires 6 GPU but only 4 available
should_raise=True,
)
# Test case 4: Missing resource type in cluster - should raise
_assert_resource_check(
available_resources={"CPU": 8.0, "GPU": 4.0},
resources_per_worker={"TPU": 1.0},
num_workers=1, # TPU not available in cluster
should_raise=True,
)
# Test case 5: Resource available but zero - should raise
_assert_resource_check(
available_resources={"CPU": 8.0, "GPU": 0},
resources_per_worker={"GPU": 1.0},
num_workers=1,
should_raise=True,
)
# Test case 6: Empty cluster resources - should not raise
_assert_resource_check(
available_resources={},
resources_per_worker={"CPU": 1.0},
num_workers=2,
should_raise=False,
)
# Test case 7: None cluster resources - should not raise
_assert_resource_check(
available_resources=None,
resources_per_worker={"CPU": 1.0},
num_workers=2,
should_raise=False,
)
# Test case 8: Edge case with zero resources - should not raise
_assert_resource_check(
available_resources={"CPU": 4.0},
resources_per_worker={"CPU": 0.0},
num_workers=10,
should_raise=False,
)
# Test case 9: Exact resource match - should not raise
_assert_resource_check(
available_resources={"CPU": 4.0},
resources_per_worker={"CPU": 1.0},
num_workers=4, # Exactly matches 4.0 CPU available
should_raise=False,
)
def _make_worker(node_id, node_ip, gpu_ids=None):
"""Helper to create a Worker with minimal metadata for rank assignment tests."""
return Worker(
actor=None,
metadata=ActorMetadata(
node_id=node_id,
node_ip=node_ip,
hostname="dummy",
accelerator_ids={"GPU": gpu_ids} if gpu_ids else {},
pid=0,
),
resources={"GPU": 1} if gpu_ids else {"CPU": 1},
)
@pytest.mark.parametrize(
"workers, starting_world_rank, world_size, replica_group_size, "
"expected_contexts",
[
pytest.param(
# 4 workers on 2 nodes, 2 GPUs each
[
_make_worker("node0", "10.0.0.1", ["1"]),
_make_worker("node1", "10.0.0.2", ["1"]),
_make_worker("node0", "10.0.0.1", ["0"]),
_make_worker("node1", "10.0.0.2", ["0"]),
],
0,
None,
None,
# After sorting: node0/gpu0, node0/gpu1, node1/gpu0, node1/gpu1
[
DistributedContext(
local_rank=0,
local_world_size=2,
world_rank=0,
world_size=4,
node_rank=0,
),
DistributedContext(
local_rank=1,
local_world_size=2,
world_rank=1,
world_size=4,
node_rank=0,
),
DistributedContext(
local_rank=0,
local_world_size=2,
world_rank=2,
world_size=4,
node_rank=1,
),
DistributedContext(
local_rank=1,
local_world_size=2,
world_rank=3,
world_size=4,
node_rank=1,
),
],
id="no_replica_groups",
),
pytest.param(
# 4 workers on 2 nodes — each worker is its own replica group
[
_make_worker("node0", "10.0.0.1", ["1"]),
_make_worker("node1", "10.0.0.2", ["0"]),
_make_worker("node0", "10.0.0.1", ["0"]),
_make_worker("node1", "10.0.0.2", ["1"]),
],
0,
None,
1,
# After sorting: node0/gpu0, node0/gpu1, node1/gpu0, node1/gpu1
# Each worker is its own replica group, so local_rank=0,
# local_world_size=1, node_rank=0 for all.
[
DistributedContext(
local_rank=0,
local_world_size=1,
world_rank=0,
world_size=4,
node_rank=0,
),
DistributedContext(
local_rank=0,
local_world_size=1,
world_rank=1,
world_size=4,
node_rank=0,
),
DistributedContext(
local_rank=0,
local_world_size=1,
world_rank=2,
world_size=4,
node_rank=0,
),
DistributedContext(
local_rank=0,
local_world_size=1,
world_rank=3,
world_size=4,
node_rank=0,
),
],
id="replica_group_size_1",
),
pytest.param(
# 8 workers across 3 nodes (2-4-2 GPUs), replica_group_size=4
[
_make_worker("nodeA", "10.0.0.1", ["1"]),
_make_worker("nodeB", "10.0.0.2", ["3"]),
_make_worker("nodeA", "10.0.0.1", ["0"]),
_make_worker("nodeB", "10.0.0.2", ["0"]),
_make_worker("nodeC", "10.0.0.3", ["1"]),
_make_worker("nodeB", "10.0.0.2", ["2"]),
_make_worker("nodeB", "10.0.0.2", ["1"]),
_make_worker("nodeC", "10.0.0.3", ["0"]),
],
0,
None,
4,
[
# RG0: A/gpu0, A/gpu1, B/gpu0, B/gpu1
DistributedContext(
local_rank=0,
local_world_size=2,
world_rank=0,
world_size=8,
node_rank=0,
),
DistributedContext(
local_rank=1,
local_world_size=2,
world_rank=1,
world_size=8,
node_rank=0,
),
DistributedContext(
local_rank=0,
local_world_size=2,
world_rank=2,
world_size=8,
node_rank=1,
),
DistributedContext(
local_rank=1,
local_world_size=2,
world_rank=3,
world_size=8,
node_rank=1,
),
# RG1: B/gpu2, B/gpu3, C/gpu0, C/gpu1
DistributedContext(
local_rank=0,
local_world_size=2,
world_rank=4,
world_size=8,
node_rank=0,
),
DistributedContext(
local_rank=1,
local_world_size=2,
world_rank=5,
world_size=8,
node_rank=0,
),
DistributedContext(
local_rank=0,
local_world_size=2,
world_rank=6,
world_size=8,
node_rank=1,
),
DistributedContext(
local_rank=1,
local_world_size=2,
world_rank=7,
world_size=8,
node_rank=1,
),
],
id="replica_group_size_4_node_straddling",
),
pytest.param(
# Simulates replacing replica group 1 in a 4-worker setup with replica_group_size=2.
[
_make_worker("node0", "10.0.0.1", ["0"]),
_make_worker("node1", "10.0.0.2", ["0"]),
],
2,
4,
2,
[
DistributedContext(
local_rank=0,
local_world_size=1,
world_rank=2,
world_size=4,
node_rank=0,
),
DistributedContext(
local_rank=0,
local_world_size=1,
world_rank=3,
world_size=4,
node_rank=1,
),
],
id="replica_group_size_2_replace",
),
],
)
def test_assign_worker_ranks(
workers,
starting_world_rank,
world_size,
replica_group_size,
expected_contexts,
):
result = WorkerGroup._assign_worker_ranks(
workers,
starting_world_rank=starting_world_rank,
world_size=world_size,
replica_group_size=replica_group_size,
)
assert len(result) == len(expected_contexts)
for i, (worker, expected) in enumerate(zip(result, expected_contexts)):
ctx = worker.distributed_context
assert ctx == expected, f"Worker {i}: expected {expected}, got {ctx}"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-x", __file__]))