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

307 lines
9.8 KiB
Python

import contextlib
import os
import time
import uuid
from unittest.mock import patch
import pytest
import torch
import ray
import ray.train as train
from ray.cluster_utils import Cluster
from ray.train import RunConfig, ScalingConfig
from ray.train.examples.pytorch.torch_linear_example import (
train_func as linear_train_func,
)
from ray.train.torch import TorchCheckpoint, TorchConfig, TorchTrainer
from ray.train.trainer import TrainingFailedError
@pytest.fixture
def ray_start_4_cpus():
address_info = ray.init(num_cpus=4)
yield address_info
# The code after the yield will run as teardown code.
ray.shutdown()
@contextlib.contextmanager
def ray_start_2_node_cluster(num_cpus_per_node: int, num_gpus_per_node: int):
cluster = Cluster()
for _ in range(2):
cluster.add_node(num_cpus=num_cpus_per_node, num_gpus=num_gpus_per_node)
ray.init(address=cluster.address)
yield
ray.shutdown()
cluster.shutdown()
@pytest.mark.parametrize("num_workers", [1, 2])
def test_torch_linear(ray_start_4_cpus, num_workers):
def train_func(config):
result = linear_train_func(config)
assert len(result) == epochs
assert result[-1]["loss"] < result[0]["loss"]
num_workers = num_workers
epochs = 3
scaling_config = ScalingConfig(num_workers=num_workers)
config = {"lr": 1e-2, "hidden_size": 1, "batch_size": 4, "epochs": epochs}
trainer = TorchTrainer(
train_loop_per_worker=train_func,
train_loop_config=config,
scaling_config=scaling_config,
)
trainer.fit()
@pytest.mark.parametrize("prepare_model", (True, False))
def test_torch_e2e(ray_start_4_cpus, prepare_model):
def train_func():
model = torch.nn.Linear(3, 1)
if prepare_model:
model = train.torch.prepare_model(model)
train.report({}, checkpoint=TorchCheckpoint.from_model(model))
scaling_config = ScalingConfig(num_workers=2)
trainer = TorchTrainer(
train_loop_per_worker=train_func,
scaling_config=scaling_config,
)
trainer.fit()
@pytest.mark.parametrize("prepare_model", (True, False))
def test_torch_e2e_state_dict(ray_start_4_cpus, prepare_model):
def train_func():
model = torch.nn.Linear(3, 1)
if prepare_model:
model = train.torch.prepare_model(model)
train.report({}, checkpoint=TorchCheckpoint.from_state_dict(model.state_dict()))
scaling_config = ScalingConfig(num_workers=2)
trainer = TorchTrainer(
train_loop_per_worker=train_func,
scaling_config=scaling_config,
)
result = trainer.fit()
# If loading from a state dict, a model definition must be passed in.
with pytest.raises(ValueError):
torch_checkpoint = TorchCheckpoint(
path=result.checkpoint.path, filesystem=result.checkpoint.filesystem
)
torch_checkpoint.get_model()
def test_checkpoint_freq(ray_start_4_cpus):
# checkpoint_freq is not supported so raise an error
trainer = TorchTrainer(
train_loop_per_worker=lambda config: None,
scaling_config=train.ScalingConfig(num_workers=1),
run_config=train.RunConfig(
checkpoint_config=train.CheckpointConfig(
checkpoint_frequency=2,
),
),
)
with pytest.raises(ValueError):
trainer.fit()
def test_torch_session_errors(ray_start_4_cpus):
"""Test fail-fast behavior when reporting dicts with Torch tensors"""
def train_func():
model = torch.nn.Linear(1, 1).state_dict()
with pytest.raises(ValueError):
train.report(model)
scaling_config = ScalingConfig(num_workers=2)
trainer = TorchTrainer(
train_loop_per_worker=train_func,
scaling_config=scaling_config,
)
trainer.fit()
def test_single_worker_failure(ray_start_4_cpus):
"""Tests if training fails upon any worker failure."""
def single_worker_fail():
if train.get_context().get_world_rank() == 0:
raise RuntimeError
else:
time.sleep(1000000)
scaling_config = ScalingConfig(num_workers=2)
trainer = TorchTrainer(
train_loop_per_worker=single_worker_fail,
scaling_config=scaling_config,
)
with pytest.raises(TrainingFailedError) as exc_info:
trainer.fit()
assert isinstance(exc_info.value.__cause__, RuntimeError)
@pytest.mark.parametrize("num_gpus_per_worker", [0.5, 1, 2])
def test_tune_torch_get_device_gpu(num_gpus_per_worker):
"""Tests if GPU ids are set correctly when running train concurrently in nested actors
(for example when used with Tune).
"""
from ray.train import ScalingConfig
num_samples = 2
num_workers = 2
# We should have exactly enough resources in the cluster to run both samples
# concurrently.
total_gpus_required = num_workers * num_gpus_per_worker * num_samples
# Divide by two because of a 2 node cluster.
gpus_per_node = total_gpus_required // 2
exception = None
# Use the same number of cpus per node as gpus per node.
with ray_start_2_node_cluster(
num_cpus_per_node=gpus_per_node, num_gpus_per_node=gpus_per_node
):
@patch("torch.cuda.is_available", lambda: True)
def train_fn():
# We use STRICT_SPREAD strategy to force multiple samples on the same node.
# For single or fractional GPU case, each worker has only 1 visible device (
# the other is taken by the other sample) so device index should be 0.
# For the multiple GPU case, each worker has 2 visible devices so device
# index should be either 0 or 1. It doesn't matter which.
device_ids = sorted([device.index for device in train.torch.get_devices()])
assert device_ids in [[0], [0, 1]]
@ray.remote(num_cpus=0)
class TrialActor:
def __init__(self, warmup_steps):
self.trainer = TorchTrainer(
train_fn,
torch_config=TorchConfig(backend="gloo"),
run_config=RunConfig(
# Use a unique name to avoid using the same
# experiment directory
name=f"test_tune_torch_get_device_gpu_{uuid.uuid4()}"
),
scaling_config=ScalingConfig(
num_workers=num_workers,
use_gpu=True,
resources_per_worker={"CPU": 1, "GPU": num_gpus_per_worker},
# Need to specify 0 trainer resources so STRICT_SPREAD
# will work.
trainer_resources={"CPU": 0},
placement_strategy="STRICT_SPREAD",
# Each gpu worker will be spread onto separate nodes. This
# forces different samples to run concurrently on the same
# node.
),
)
def run(self):
return self.trainer.fit()
try:
actors = [TrialActor.remote(1) for _ in range(num_samples)]
ray.get([actor.run.remote() for actor in actors])
except Exception as exc:
exception = exc
# Raise exception after Ray cluster has been shutdown to avoid corrupted state
if exception:
raise exception
def test_torch_amp(ray_start_4_cpus):
def train_fn():
train.torch.accelerate(amp=True)
model = torch.nn.Linear(1, 1)
model = train.torch.prepare_model(model)
train.report({}, checkpoint=TorchCheckpoint.from_model(model))
trainer = TorchTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=2),
)
results = trainer.fit()
assert results.checkpoint
def test_torch_amp_with_custom_get_state(ray_start_4_cpus):
"""Tests amp with a model that has a custom __getstate__ method defined.
See https://discuss.ray.io/t/ray-train-hangs-for-long-time/6333/7
"""
def train_fn():
train.torch.accelerate(amp=True)
class CustomLinear(torch.nn.Linear):
def __getstate__(self):
return self.__dict__.copy()
model = CustomLinear(1, 1)
model = train.torch.prepare_model(model)
# TorchCheckpoint.from_model fails, so just save the state dict only.
train.report(
{}, checkpoint=TorchCheckpoint.from_state_dict(model.module.state_dict())
)
trainer = TorchTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=2),
)
results = trainer.fit()
assert results.checkpoint
def test_torch_env_vars(ray_start_4_cpus):
"""Check that env vars are set as expected."""
def train_func(config):
context = train.get_context()
assert os.environ["LOCAL_RANK"] == str(context.get_local_rank())
assert os.environ["RANK"] == str(context.get_world_rank())
assert os.environ["LOCAL_WORLD_SIZE"] == str(context.get_local_world_size())
assert os.environ["WORLD_SIZE"] == str(context.get_world_size())
assert os.environ["NODE_RANK"] == str(context.get_node_rank())
assert os.environ["ACCELERATE_TORCH_DEVICE"] == str(train.torch.get_device())
num_workers = 1
scaling_config = ScalingConfig(num_workers=num_workers)
trainer = TorchTrainer(
train_loop_per_worker=train_func,
scaling_config=scaling_config,
)
trainer.fit()
def test_nonserializable_train_function(ray_start_4_cpus):
import threading
lock = threading.Lock()
def train_func():
print(lock)
trainer = TorchTrainer(train_func)
# Check that the `inspect_serializability` trace was printed
with pytest.raises(TypeError, match=r".*was found to be non-serializable.*"):
trainer.fit()
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-x", __file__]))