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

1177 lines
42 KiB
Python

import multiprocessing
import os
import shutil
import signal
import time
from unittest.mock import create_autospec
import pytest
import ray
import ray.cloudpickle as ray_pickle
from ray._common.test_utils import simulate_s3_bucket
from ray.air._internal.uri_utils import URI
from ray.tests.client_test_utils import create_remote_signal_actor
from ray.train import Checkpoint, CheckpointConfig, RunConfig, ScalingConfig
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
from ray.train.v2.api.context import LocalTrainContext
from ray.train.v2.api.data_parallel_trainer import DataParallelTrainer
from ray.train.v2.api.exceptions import WorkerGroupError
from ray.train.v2.api.report_config import (
CheckpointConsistencyMode,
CheckpointUploadMode,
)
from ray.train.v2.api.reported_checkpoint import ReportedCheckpointStatus
from ray.train.v2.api.validation_config import (
ValidationConfig,
ValidationTaskConfig,
)
@pytest.fixture(scope="module", autouse=True)
def ray_start_4_cpus():
ray.init(num_cpus=4)
yield
ray.shutdown()
def test_report_mixed_checkpoint_upload_modes(tmp_path):
"""Run all 10 possible pairs (e.g. (SYNC, ASYNC)) of checkpoint upload modes between 2 workers."""
def get_checkpoint_iteration(checkpoint):
if not checkpoint:
return -1
return int(checkpoint.path.split("_")[-1])
def train_fn():
# When reporting with async checkpointing, write the checkpoint to
# tmp_path, which stays alive for the duration of the test, instead of
# tempfile.TemporaryDirectory(), which might get deleted before the
# async checkpoint upload completes.
# Run all 10 possible pairs of checkpoint upload modes
rank = ray.train.get_context().get_world_rank()
if rank == 0:
ASYNC_ITERATIONS = [0, 1, 2, 3]
SYNC_ITERATIONS = [4, 5, 6]
NO_UPLOAD_ITERATIONS = [7, 8]
NO_CHECKPOINT_ITERATIONS = [9]
else:
ASYNC_ITERATIONS = [0]
SYNC_ITERATIONS = [1, 4]
NO_UPLOAD_ITERATIONS = [2, 5, 7]
NO_CHECKPOINT_ITERATIONS = [3, 6, 8, 9]
prev_latest_checkpoint_iteration = -1
for i in range(10):
# Set variables
if i in ASYNC_ITERATIONS:
checkpoint_upload_mode = CheckpointUploadMode.ASYNC
elif i in SYNC_ITERATIONS:
checkpoint_upload_mode = CheckpointUploadMode.SYNC
else:
checkpoint_upload_mode = CheckpointUploadMode.NO_UPLOAD
metrics = {"metric": f"iteration_{i}_shard_{rank}"}
# Create and report checkpoint
if i in NO_CHECKPOINT_ITERATIONS:
ray.train.report(
metrics=metrics,
checkpoint=None,
validation=False,
)
assert prev_latest_checkpoint_iteration <= get_checkpoint_iteration(
ray.train.get_checkpoint()
)
else:
# Create remote or local checkpoint_dir
checkpoint_dir_name = f"checkpoint_iteration_{i}"
if i in NO_UPLOAD_ITERATIONS:
checkpoint_dir = (
ray.train.get_context()
.get_storage()
.build_checkpoint_path_from_name(checkpoint_dir_name)
)
else:
checkpoint_dir = os.path.join(
tmp_path, checkpoint_dir_name, f"_{rank}"
)
# Create and report that remote or local checkpoint
os.makedirs(checkpoint_dir, exist_ok=True)
with open(os.path.join(checkpoint_dir, f"shard_{rank}"), "wb") as f:
ray_pickle.dump(f"iteration_{i}_shard_{rank}", f)
checkpoint = Checkpoint(checkpoint_dir)
ray.train.report(
metrics=metrics,
checkpoint=checkpoint,
checkpoint_upload_mode=checkpoint_upload_mode,
checkpoint_dir_name=checkpoint_dir_name,
)
# Check the status of latest_checkpoint
latest_checkpoint = ray.train.get_checkpoint()
if i in NO_UPLOAD_ITERATIONS:
assert latest_checkpoint == checkpoint
elif i in SYNC_ITERATIONS:
assert checkpoint_dir_name in latest_checkpoint.path
else:
assert prev_latest_checkpoint_iteration <= get_checkpoint_iteration(
latest_checkpoint
)
prev_latest_checkpoint_iteration = get_checkpoint_iteration(
latest_checkpoint
)
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=2),
run_config=RunConfig(storage_path=str(tmp_path)),
)
result = trainer.fit()
# Note that the (checkpoint=None, checkpoint=None) pair does not produce any checkpoint
assert len(result.best_checkpoints) == 9
for i, (checkpoint, metrics) in enumerate(result.best_checkpoints):
assert checkpoint.path.endswith(f"checkpoint_iteration_{i}")
assert metrics["metric"] == f"iteration_{i}_shard_0"
@pytest.mark.parametrize(
"delete_local_checkpoint_after_upload,checkpoint_upload_mode",
[
(True, CheckpointUploadMode.ASYNC),
(False, CheckpointUploadMode.ASYNC),
(True, CheckpointUploadMode.SYNC),
(False, CheckpointUploadMode.SYNC),
(True, CheckpointUploadMode.NO_UPLOAD),
(False, CheckpointUploadMode.NO_UPLOAD),
],
)
def test_report_delete_local_checkpoint_after_upload(
tmp_path,
delete_local_checkpoint_after_upload,
checkpoint_upload_mode,
):
"""Check that the local checkpoint is deleted after upload."""
def train_fn():
rank = ray.train.get_context().get_world_rank()
if rank == 0:
if checkpoint_upload_mode == CheckpointUploadMode.NO_UPLOAD:
checkpoint_dir = (
ray.train.get_context()
.get_storage()
.build_checkpoint_path_from_name("my_checkpoint_dir")
)
else:
checkpoint_dir = os.path.join(
tmp_path,
"my_checkpoint_dir",
)
os.makedirs(checkpoint_dir, exist_ok=True)
with open(os.path.join(checkpoint_dir, "shard_0"), "wb") as f:
ray_pickle.dump("some_checkpoint_contents", f)
checkpoint = Checkpoint(checkpoint_dir)
ray.train.report(
{},
checkpoint,
checkpoint_upload_mode=checkpoint_upload_mode,
delete_local_checkpoint_after_upload=delete_local_checkpoint_after_upload,
)
else:
ray.train.report(
{},
None,
)
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=2),
run_config=RunConfig(storage_path=str(tmp_path)),
)
trainer.fit()
if (
delete_local_checkpoint_after_upload
or checkpoint_upload_mode == CheckpointUploadMode.NO_UPLOAD
):
assert not os.path.exists(os.path.join(tmp_path, "my_checkpoint_dir"))
else:
assert os.path.exists(os.path.join(tmp_path, "my_checkpoint_dir"))
def test_report_checkpoint_upload_error(monkeypatch, tmp_path):
"""Check that the trainer shuts down when an error occurs during checkpoint upload."""
def train_fn():
if ray.train.get_context().get_world_rank() == 0:
# Mock persist_current_checkpoint to raise an error
mock_persist_current_checkpoint = create_autospec(
ray.train.get_context().get_storage().persist_current_checkpoint
)
mock_persist_current_checkpoint.side_effect = ValueError("error")
monkeypatch.setattr(
ray.train.get_context().get_storage(),
"persist_current_checkpoint",
mock_persist_current_checkpoint,
)
# Report minimal valid checkpoint
local_checkpoint_dir = os.path.join(tmp_path, "local_checkpoint_dir")
os.makedirs(local_checkpoint_dir, exist_ok=True)
ray.train.report(
{},
Checkpoint.from_directory(local_checkpoint_dir),
checkpoint_upload_mode=CheckpointUploadMode.ASYNC,
)
else:
ray.train.report(
{}, None, checkpoint_upload_mode=CheckpointUploadMode.ASYNC
)
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=2),
run_config=RunConfig(storage_path=str(tmp_path)),
)
with pytest.raises(WorkerGroupError, match="error") as exc_info:
trainer.fit()
assert isinstance(exc_info.value.worker_failures[0], ValueError)
@pytest.mark.parametrize(
"kwarg",
(
# both of these cases can cause the local checkpoint to be deleted after upload
dict(delete_local_checkpoint_after_upload=True),
dict(checkpoint_upload_mode=CheckpointUploadMode.ASYNC),
),
ids=["delete_local_checkpoint_after_upload=True", "checkpoint_upload_mode=ASYNC"],
)
def test_report_checkpoint_delete_storage_path(kwarg, tmp_path):
"""Test that the trainer raises an error if the Checkpoint path is contains the storage_path."""
# Test in `tmp_path` in case the test fails which means that the tmp_path.parent might be deleted
base_dir = tmp_path / "test_base"
storage_dir = base_dir / "storage"
os.makedirs(storage_dir, exist_ok=True)
def train_fn_equal_storage_path():
ray.train.report(
{},
Checkpoint(str(storage_dir)),
**kwarg,
)
def train_fn_within_storage_path():
ray.train.report(
{},
Checkpoint(str(base_dir)),
**kwarg,
)
for train_fn in [train_fn_equal_storage_path, train_fn_within_storage_path]:
trainer = DataParallelTrainer(
train_fn, run_config=RunConfig(storage_path=str(storage_dir))
)
with pytest.raises(WorkerGroupError, match="error") as exc_info:
trainer.fit()
assert isinstance(exc_info.value.worker_failures[0], ValueError)
assert (
exc_info.value.worker_failures[0]
.args[0]
.startswith("Ray Train's experiment directory")
)
# full error message:
# Ray Train's experiment directory (<file path>) is contained within the checkpoint path (<file path>)
# and `ray.train.report(delete_local_checkpoint_after_upload=True)`.
# As a result, this would delete the experiment directory.
# Please write the checkpoint to a subdirectory of the experiment directory
# or use `delete_local_checkpoint_after_upload=False`.
@pytest.mark.parametrize(
"kwarg",
(
# both of these cases can cause the local checkpoint to be deleted after upload
dict(delete_local_checkpoint_after_upload=True),
dict(checkpoint_upload_mode=CheckpointUploadMode.ASYNC),
),
ids=["delete_local_checkpoint_after_upload=True", "checkpoint_upload_mode=ASYNC"],
)
def test_report_checkpoint_delete_s3_storage_path(kwarg):
"""Test that the trainer raises an error if a s3 checkpoint path is contains a s3 storage_path."""
port, region = 5002, "us-west-2"
with simulate_s3_bucket(port=port, region=region) as s3_uri:
import boto3
s3 = boto3.client(
"s3", region_name=region, endpoint_url=f"http://localhost:{port}"
)
# Bucket name will be autogenerated/unique per test
bucket_name = URI(s3_uri).name
s3.create_bucket(
Bucket=bucket_name,
CreateBucketConfiguration={"LocationConstraint": region},
)
# Use URI(s3_uri) / "storage" to correctly insert the path before query params.
s3_storage_path = str(URI(s3_uri) / "storage")
def train_fn_equal_storage_path():
ray.train.report(
{},
Checkpoint(s3_storage_path),
**kwarg,
)
def train_fn_within_storage_path():
# s3_uri is the bucket root, which is a parent of s3_storage_path.
ray.train.report(
{},
Checkpoint(s3_uri),
**kwarg,
)
for train_fn in [train_fn_equal_storage_path, train_fn_within_storage_path]:
trainer = DataParallelTrainer(
train_fn, run_config=RunConfig(storage_path=s3_storage_path)
)
with pytest.raises(WorkerGroupError, match="error") as exc_info:
trainer.fit()
assert isinstance(exc_info.value.worker_failures[0], ValueError)
assert (
exc_info.value.worker_failures[0]
.args[0]
.startswith("Ray Train's experiment directory")
)
# full error message:
# Ray Train's experiment directory (<file path>) is contained within the checkpoint path (<file path>)
# and `ray.train.report(delete_local_checkpoint_after_upload=True)`.
# As a result, this would delete the experiment directory.
# Please write the checkpoint to a subdirectory of the experiment directory
# or use `delete_local_checkpoint_after_upload=False`.
def test_report_validation_without_validation_fn():
def train_fn():
with create_dict_checkpoint({}) as checkpoint:
ray.train.report(metrics={}, checkpoint=checkpoint, validation=True)
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=1),
)
with pytest.raises(
WorkerGroupError,
match="`validation_config` was not set on the trainer, but a validation was requested.",
) as exc_info:
trainer.fit()
assert isinstance(exc_info.value.worker_failures[0], ValueError)
def test_report_validation_without_checkpoint():
def train_fn():
ray.train.report(metrics={}, validation=True)
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=1),
)
with pytest.raises(
WorkerGroupError, match="Validation requires a checkpoint to be provided."
) as exc_info:
trainer.fit()
assert isinstance(exc_info.value.worker_failures[0], ValueError)
def test_report_validation_fn_keeps_correct_checkpoints(tmp_path):
def validation_fn(checkpoint, new_score=None):
if new_score:
return {"score": new_score}
else:
return {}
def train_fn():
rank = ray.train.get_context().get_world_rank()
checkpoint_dir = os.path.join(
tmp_path,
"my_checkpoint_dir",
)
os.makedirs(checkpoint_dir, exist_ok=True)
with open(os.path.join(checkpoint_dir, f"shard_{rank}"), "wb") as f:
ray_pickle.dump("some_checkpoint_contents", f)
ray.train.report(
metrics={"score": 1},
checkpoint=Checkpoint(checkpoint_dir),
checkpoint_upload_mode=CheckpointUploadMode.ASYNC,
delete_local_checkpoint_after_upload=False,
validation=ValidationTaskConfig(fn_kwargs={}),
)
with create_dict_checkpoint({}) as cp2:
ray.train.report(
metrics={"score": 3},
checkpoint=cp2,
checkpoint_upload_mode=CheckpointUploadMode.SYNC,
validation=True,
)
with create_dict_checkpoint({}) as cp3:
ray.train.report(
metrics={"score": 2},
checkpoint=cp3,
checkpoint_upload_mode=CheckpointUploadMode.SYNC,
validation=ValidationTaskConfig(fn_kwargs={"new_score": 5}),
)
trainer = DataParallelTrainer(
train_fn,
validation_config=ValidationConfig(fn=validation_fn),
scaling_config=ScalingConfig(num_workers=2),
run_config=RunConfig(
storage_path=str(tmp_path),
checkpoint_config=CheckpointConfig(
num_to_keep=2, checkpoint_score_attribute="score"
),
),
)
result = trainer.fit()
assert result.error is None
assert result.checkpoint == result.best_checkpoints[1][0]
assert len(result.best_checkpoints) == 2
assert result.best_checkpoints[0][1] == {"score": 3}
assert result.best_checkpoints[1][1] == {"score": 5}
@pytest.mark.parametrize("num_validation_workers", [0, 1])
def test_report_validation_fn_with_trainer_train_fn_report(num_validation_workers):
"""Test implementing the validation_fn with train_fn that reports metrics."""
def eval_only_train_fn(config_dict):
if isinstance(ray.train.get_context(), LocalTrainContext):
checkpoint = config_dict["checkpoint"]
else:
checkpoint = ray.train.Checkpoint(
ray.train.get_context()
.get_storage()
.build_checkpoint_path_from_name("placeholder")
)
ray.train.report(
metrics={"validation": ray.train.get_context().get_world_rank()},
checkpoint=checkpoint,
checkpoint_upload_mode=CheckpointUploadMode.NO_UPLOAD,
)
def validation_fn(checkpoint: ray.train.Checkpoint):
validation_trainer = DataParallelTrainer(
eval_only_train_fn,
train_loop_config={"checkpoint": checkpoint},
scaling_config=ScalingConfig(num_workers=num_validation_workers),
)
validation_results = validation_trainer.fit()
return validation_results.metrics
def train_fn(config: dict):
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={"training": ray.train.get_context().get_world_rank()},
checkpoint=cp,
validation=True,
)
trainer = DataParallelTrainer(
train_fn,
validation_config=ValidationConfig(fn=validation_fn),
)
results = trainer.fit()
assert results.error is None
assert results.metrics == {"training": 0, "validation": 0}
@pytest.mark.parametrize("num_validation_workers", [0, 1])
def test_report_validation_fn_with_trainer_train_fn_return(num_validation_workers):
"""Test implementing the validation_fn with train_fn returns metrics."""
def eval_only_train_fn(config_dict):
return {"validation": ray.train.get_context().get_world_rank()}
def validation_fn(checkpoint: ray.train.Checkpoint):
validation_trainer = DataParallelTrainer(
eval_only_train_fn,
scaling_config=ScalingConfig(num_workers=num_validation_workers),
)
validation_results = validation_trainer.fit()
return validation_results.return_value
def train_fn(config: dict):
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={"training": ray.train.get_context().get_world_rank()},
checkpoint=cp,
validation=True,
)
trainer = DataParallelTrainer(
train_fn,
validation_config=ValidationConfig(fn=validation_fn),
)
results = trainer.fit()
assert results.error is None
assert results.metrics == {"training": 0, "validation": 0}
assert results.return_value is None
def test_report_validation_fn_overrides_default_kwargs(tmp_path):
def validation_fn(checkpoint, validation_score, other_key):
return {"validation_score": validation_score, "other_key": other_key}
def train_fn():
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={},
checkpoint=cp,
validation=ValidationTaskConfig(fn_kwargs={"validation_score": 2}),
)
trainer = DataParallelTrainer(
train_fn,
validation_config=ValidationConfig(
fn=validation_fn,
task_config=ValidationTaskConfig(
fn_kwargs={"validation_score": 1, "other_key": "other_value"}
),
),
run_config=RunConfig(storage_path=str(tmp_path)),
scaling_config=ScalingConfig(num_workers=1),
)
result = trainer.fit()
assert result.best_checkpoints[0][1] == {
"validation_score": 2,
"other_key": "other_value",
}
def test_report_validation_fn_error(tmp_path):
def validation_fn(checkpoint, rank=None, iteration=None):
if rank == 0 and iteration == 0:
raise ValueError("validation failed")
return {"validation_score": iteration}
def train_fn():
rank = ray.train.get_context().get_world_rank()
with create_dict_checkpoint({}) as cp1:
ray.train.report(
metrics={"training_score": 0},
checkpoint=cp1,
validation=ValidationTaskConfig(
fn_kwargs={"rank": rank, "iteration": 0}
),
)
with create_dict_checkpoint({}) as cp2:
ray.train.report(
metrics={"training_score": 1},
checkpoint=cp2,
validation=ValidationTaskConfig(
fn_kwargs={"rank": rank, "iteration": 1}
),
)
reported_checkpoints = ray.train.get_all_reported_checkpoints()
assert len(reported_checkpoints) == 2
assert (
reported_checkpoints[0].status == ReportedCheckpointStatus.VALIDATION_FAILED
)
assert reported_checkpoints[0].metrics == {"training_score": 0}
assert reported_checkpoints[1].status == ReportedCheckpointStatus.VALIDATED
assert reported_checkpoints[1].metrics == {
"training_score": 1,
"validation_score": 1,
}
trainer = DataParallelTrainer(
train_fn,
validation_config=ValidationConfig(fn=validation_fn),
scaling_config=ScalingConfig(num_workers=2),
run_config=RunConfig(storage_path=str(tmp_path)),
)
result = trainer.fit()
assert result.error is None
assert len(result.best_checkpoints) == 2
assert result.best_checkpoints[0][1] == {"training_score": 0}
assert result.best_checkpoints[1][1] == {"training_score": 1, "validation_score": 1}
def test_report_validation_fn_timeout(tmp_path):
def validation_fn(checkpoint):
while True:
time.sleep(1)
def train_fn():
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={"training_score": 0}, checkpoint=cp, validation=True
)
reported_checkpoints = ray.train.get_all_reported_checkpoints()
assert len(reported_checkpoints) == 1
assert (
reported_checkpoints[0].status
== ReportedCheckpointStatus.VALIDATION_TIMEOUT
)
assert reported_checkpoints[0].metrics == {"training_score": 0}
trainer = DataParallelTrainer(
train_fn,
validation_config=ValidationConfig(
fn=validation_fn, task_config=ValidationTaskConfig(timeout_s=2)
),
scaling_config=ScalingConfig(num_workers=1),
run_config=RunConfig(storage_path=str(tmp_path)),
)
result = trainer.fit()
assert result.error is None
assert len(result.best_checkpoints) == 1
assert result.best_checkpoints[0][1] == {"training_score": 0}
def test_report_validation_fn_success_after_retry():
@ray.remote
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
return self.value
counter = Counter.remote()
def validation_fn(checkpoint):
if ray.get(counter.increment.remote()) < 2:
raise ValueError("validation failed")
return {"score": 100}
def train_fn():
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={},
checkpoint=cp,
validation=True,
)
reported_checkpoints = ray.train.get_all_reported_checkpoints()
assert len(reported_checkpoints) == 1
assert reported_checkpoints[0].status == ReportedCheckpointStatus.VALIDATED
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=1),
validation_config=ValidationConfig(
fn=validation_fn,
ray_remote_kwargs={"max_retries": 1, "retry_exceptions": [ValueError]},
),
)
result = trainer.fit()
assert result.best_checkpoints[0][1] == {"score": 100}
def _run_first_trainer_for_resumption(storage_path, validation_task_config):
"""Subprocess target: run a trainer with a stalling validation, then get SIGINT'd."""
# Lives outside the test because multiprocessing cannot pickle nested functions.
ray.init(address="auto")
def validation_fn_stall(checkpoint, score):
signal_actor = ray.get_actor(
"validation_resumption_signal", namespace="test_validation_resumption"
)
ray.get(signal_actor.send.remote())
while True:
time.sleep(1)
def train_fn():
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={},
checkpoint=cp,
validation=validation_task_config,
)
trainer = DataParallelTrainer(
train_fn,
validation_config=ValidationConfig(
fn=validation_fn_stall,
task_config=ValidationTaskConfig(fn_kwargs={"score": 1}),
),
scaling_config=ScalingConfig(num_workers=1),
run_config=RunConfig(
name="validation_fn_resumption", storage_path=storage_path
),
)
trainer.fit()
@pytest.mark.parametrize(
"validation_task_config, expected_score",
[
(True, 1),
(ValidationTaskConfig(fn_kwargs={"score": 2}), 2),
],
)
def test_report_validation_fn_resumption(
tmp_path, validation_task_config, expected_score
):
"""A train_func call a validation_fn that stalls and the trainer is cancelled.
Does the resumed trainer restart the validation?"""
signal_actor = (
create_remote_signal_actor(ray)
.options(
name="validation_resumption_signal",
namespace="test_validation_resumption",
)
.remote()
)
multiprocessing.set_start_method("spawn", force=True)
process = multiprocessing.Process(
target=_run_first_trainer_for_resumption,
args=(str(tmp_path), validation_task_config),
)
process.start()
# Wait for validation to start, then SIGINT the trainer process.
ray.get(signal_actor.wait.remote())
os.kill(process.pid, signal.SIGINT)
process.join()
def validation_fn_finish(checkpoint, score):
return {"score": score}
def train_fn_second():
rc = ray.train.get_all_reported_checkpoints(
consistency_mode=CheckpointConsistencyMode.VALIDATED
)
assert len(rc) == 1
assert rc[0].status == ReportedCheckpointStatus.VALIDATED
assert rc[0].metrics == {"score": expected_score}
# Run second trainer that should finish interrupted validations.
trainer = DataParallelTrainer(
train_fn_second,
validation_config=ValidationConfig(
fn=validation_fn_finish,
task_config=ValidationTaskConfig(fn_kwargs={"score": 1}),
),
scaling_config=ScalingConfig(num_workers=1),
run_config=RunConfig(
name="validation_fn_resumption", storage_path=str(tmp_path)
),
)
result = trainer.fit()
assert result.metrics == {"score": expected_score}
@pytest.mark.parametrize(
"validation_task_config, expected_score",
[
(True, 1),
(ValidationTaskConfig(fn_kwargs={"score": 2}), 2),
],
)
def test_report_validation_fn_resumption_on_train_fn_error(
tmp_path, validation_task_config, expected_score
):
"""Train run where train_fn fails after reporting a checkpoint with pending validation.
The validation only returns after train_fn signals failure. before_controller_shutdown
drains the validation, persisting the validated metrics. The second run sees them."""
signal_actor = create_remote_signal_actor(ray).remote()
def validation_fn(checkpoint, score):
# Block until train_fn has signaled and sleep to ensure that the train_func has closed.
ray.get(signal_actor.wait.remote())
time.sleep(1)
return {"score": score}
def train_fn_first():
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={},
checkpoint=cp,
validation=validation_task_config,
)
try:
raise RuntimeError("train_fn failed intentionally")
finally:
signal_actor.send.remote()
def train_fn_second():
rc = ray.train.get_all_reported_checkpoints(
consistency_mode=CheckpointConsistencyMode.VALIDATED
)
assert len(rc) == 1
assert rc[0].status == ReportedCheckpointStatus.VALIDATED
assert rc[0].metrics == {"score": expected_score}
run_config = RunConfig(
name="validation_fn_resumption_on_train_fn_error",
storage_path=str(tmp_path),
)
validation_config = ValidationConfig(
fn=validation_fn,
task_config=ValidationTaskConfig(fn_kwargs={"score": 1}),
)
with pytest.raises(WorkerGroupError):
DataParallelTrainer(
train_fn_first,
validation_config=validation_config,
run_config=run_config,
).fit()
result = DataParallelTrainer(
train_fn_second,
validation_config=validation_config,
run_config=run_config,
).fit()
assert result.metrics == {"score": expected_score}
def test_report_validation_fn_resumption_checkpoint_status(tmp_path):
def validation_fn(checkpoint, name):
if name == "timeout":
while True:
time.sleep(1)
elif name == "error":
raise ValueError("validation error")
else:
return {"validation": name}
def train_fn_first():
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={"score": 0},
checkpoint=cp,
validation=ValidationTaskConfig(fn_kwargs={"name": "success"}),
)
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={"score": 1},
checkpoint=cp,
validation=ValidationTaskConfig(
fn_kwargs={"name": "timeout"}, timeout_s=1
),
)
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={"score": 2},
checkpoint=cp,
validation=ValidationTaskConfig(fn_kwargs={"name": "error"}),
)
with create_dict_checkpoint({}) as cp:
ray.train.report(
metrics={"score": 3},
checkpoint=cp,
validation=ValidationTaskConfig(fn_kwargs={"name": "success"}),
)
reported_checkpoints = ray.train.get_all_reported_checkpoints()
assert len(reported_checkpoints) == 4
assert reported_checkpoints[0].status == ReportedCheckpointStatus.VALIDATED
assert (
reported_checkpoints[1].status
== ReportedCheckpointStatus.VALIDATION_TIMEOUT
)
assert (
reported_checkpoints[2].status == ReportedCheckpointStatus.VALIDATION_FAILED
)
assert reported_checkpoints[3].status == ReportedCheckpointStatus.VALIDATED
assert reported_checkpoints[3].metrics == {"score": 3, "validation": "success"}
raise RuntimeError("train_fn failed intentionally")
def train_fn_second():
reported_checkpoints = ray.train.get_all_reported_checkpoints()
assert len(reported_checkpoints) == 4
assert reported_checkpoints[0].status == ReportedCheckpointStatus.VALIDATED
assert (
reported_checkpoints[1].status
== ReportedCheckpointStatus.VALIDATION_TIMEOUT
)
assert (
reported_checkpoints[2].status == ReportedCheckpointStatus.VALIDATION_FAILED
)
assert reported_checkpoints[3].status == ReportedCheckpointStatus.VALIDATED
with pytest.raises(WorkerGroupError):
DataParallelTrainer(
train_fn_first,
run_config=RunConfig(
"test-trainer-resumption-with-checkpoint-status",
storage_path=str(tmp_path),
),
validation_config=ValidationConfig(fn=validation_fn),
).fit()
result = DataParallelTrainer(
train_fn_second,
run_config=RunConfig(
"test-trainer-resumption-with-checkpoint-status", storage_path=str(tmp_path)
),
).fit()
assert len(result.best_checkpoints) == 4
def test_multiple_workers_return_value_only_worker_zero():
"""Check that the `return_value` is of worker 0."""
def train_fn():
return (
ray.train.get_context().get_world_size(),
ray.train.get_context().get_world_rank(),
)
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=3),
)
result = trainer.fit()
assert result.return_value == (3, 0)
def test_report_checkpoint_upload_fn(tmp_path):
def checkpoint_upload_fn(checkpoint, checkpoint_dir_name):
full_checkpoint_path = (
ray.train.get_context()
.get_storage()
.build_checkpoint_path_from_name(checkpoint_dir_name)
)
shutil.copytree(checkpoint.path, full_checkpoint_path)
return Checkpoint.from_directory(full_checkpoint_path)
def train_fn():
if ray.train.get_context().get_world_rank() == 0:
with create_dict_checkpoint(
{"checkpoint_key": "checkpoint_value"}
) as checkpoint:
ray.train.report(
metrics={},
checkpoint=checkpoint,
checkpoint_dir_name="my_checkpoint_dir_name",
checkpoint_upload_fn=checkpoint_upload_fn,
)
else:
ray.train.report(metrics={}, checkpoint=None)
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=2),
run_config=RunConfig(storage_path=str(tmp_path)),
)
result = trainer.fit()
assert load_dict_checkpoint(result.checkpoint) == {
"checkpoint_key": "checkpoint_value"
}
def test_checkpoint_upload_fn_returns_checkpoint(tmp_path):
def train_fn():
with create_dict_checkpoint({}) as checkpoint:
ray.train.report(
metrics={},
checkpoint=checkpoint,
checkpoint_upload_fn=lambda x, y: None,
)
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=1),
run_config=RunConfig(storage_path=str(tmp_path)),
)
with pytest.raises(
WorkerGroupError,
match="checkpoint_upload_fn must return a `ray.train.Checkpoint`",
):
trainer.fit()
def test_report_get_all_reported_checkpoints(tmp_path):
"""Check that get_all_reported_checkpoints returns checkpoints depending on # report calls."""
def train_fn():
if ray.train.get_context().get_world_rank() == 0:
ray.train.report(metrics={}, checkpoint=None)
with create_dict_checkpoint({}) as checkpoint:
ray.train.report(metrics={}, checkpoint=checkpoint)
reported_checkpoints = ray.train.get_all_reported_checkpoints()
assert len(reported_checkpoints) == 1
assert reported_checkpoints[0].status == ReportedCheckpointStatus.COMMITTED
with create_dict_checkpoint({}) as checkpoint:
ray.train.report(metrics={}, checkpoint=checkpoint)
else:
ray.train.report(metrics={}, checkpoint=None)
ray.train.report(metrics={}, checkpoint=None)
ray.train.report(metrics={}, checkpoint=None)
reported_checkpoints = ray.train.get_all_reported_checkpoints()
assert len(reported_checkpoints) == 2
assert all(
rc.status == ReportedCheckpointStatus.COMMITTED
for rc in reported_checkpoints
)
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=2),
run_config=RunConfig(storage_path=str(tmp_path)),
)
trainer.fit()
def test_get_all_reported_checkpoints_all_consistency_modes(tmp_path):
signal_actor = create_remote_signal_actor(ray).remote()
def validation_fn(checkpoint, validation_score):
ray.get(signal_actor.wait.remote())
return {
"validation_score": validation_score,
}
def train_fn(config):
signal_actor = config["signal_actor"]
if ray.train.get_context().get_world_rank() == 0:
# Assert that we get committed checkpoints
with create_dict_checkpoint({}) as cp1:
# The validation check will hang until signal_actor.send.remote() is called
ray.train.report(
metrics={"training_score": 1},
checkpoint=cp1,
validation=True,
)
# Check with ConsistencyMode.COMMITTED
reported_checkpoints = ray.train.get_all_reported_checkpoints(
consistency_mode=CheckpointConsistencyMode.COMMITTED
)
assert len(reported_checkpoints) == 1
assert (
reported_checkpoints[0].status
== ReportedCheckpointStatus.PENDING_VALIDATION
)
assert reported_checkpoints[0].metrics == {"training_score": 1}
# Check with ConsistencyMode.VALIDATED with timeout as the validation is hanging currently
reported_checkpoints = ray.train.get_all_reported_checkpoints(
consistency_mode=CheckpointConsistencyMode.VALIDATED,
timeout_s=2,
)
assert len(reported_checkpoints) == 1
assert (
reported_checkpoints[0].status
== ReportedCheckpointStatus.PENDING_VALIDATION
)
assert reported_checkpoints[0].metrics == {"training_score": 1}
# Assert that we get validated checkpoints
signal_actor.send.remote()
reported_checkpoints = ray.train.get_all_reported_checkpoints(
consistency_mode=CheckpointConsistencyMode.VALIDATED
)
assert len(reported_checkpoints) == 1
assert reported_checkpoints[0].status == ReportedCheckpointStatus.VALIDATED
assert reported_checkpoints[0].metrics == {
"training_score": 1,
"validation_score": 100,
}
else:
ray.train.report(metrics={}, checkpoint=None)
trainer = DataParallelTrainer(
train_fn,
validation_config=ValidationConfig(
fn=validation_fn,
task_config=ValidationTaskConfig(fn_kwargs={"validation_score": 100}),
),
scaling_config=ScalingConfig(num_workers=2),
train_loop_config={"signal_actor": signal_actor},
run_config=RunConfig(storage_path=str(tmp_path)),
)
trainer.fit()
def test_hanging_checkpoint_upload_fn(tmp_path):
"""Test hanging async checkpoint upload fn with `get_all_reported_checkpoints` timeout."""
signal_actor = create_remote_signal_actor(ray).remote()
def checkpoint_fn(checkpoint, checkpoint_name):
ray.get(signal_actor.wait.remote())
full_checkpoint_path = (
ray.train.get_context()
.get_storage()
.build_checkpoint_path_from_name(checkpoint_name)
)
shutil.copytree(checkpoint.path, full_checkpoint_path)
return Checkpoint.from_directory(full_checkpoint_path)
def train_fn(config):
signal_actor = config["signal_actor"]
with create_dict_checkpoint({}) as checkpoint:
ray.train.report(
metrics={"training_score": 1},
checkpoint=checkpoint,
checkpoint_upload_mode=CheckpointUploadMode.ASYNC,
checkpoint_upload_fn=checkpoint_fn,
checkpoint_dir_name="my_checkpoint",
)
# This will hang without a timeout.
reported_checkpoints = ray.train.get_all_reported_checkpoints(
consistency_mode=CheckpointConsistencyMode.COMMITTED, timeout_s=1
)
# As the checkpoint hasn't been committed yet, then the length is zero.
assert len(reported_checkpoints) == 0
signal_actor.send.remote()
# Now the checkpoint has been completed, a timeout isn't necessary
reported_checkpoints = ray.train.get_all_reported_checkpoints(
consistency_mode=CheckpointConsistencyMode.COMMITTED
)
assert len(reported_checkpoints) == 1
assert reported_checkpoints[0].status == ReportedCheckpointStatus.COMMITTED
assert reported_checkpoints[0].metrics == {"training_score": 1}
assert "my_checkpoint" in reported_checkpoints[0].checkpoint.path
trainer = DataParallelTrainer(
train_fn,
train_loop_config={"signal_actor": signal_actor},
run_config=RunConfig(
storage_path=str(tmp_path),
checkpoint_config=CheckpointConfig(num_to_keep=1),
),
)
trainer.fit()
def test_get_all_reported_checkpoints_empty_reports():
def train_fn():
ray.train.report(metrics={}, checkpoint=None)
assert len(ray.train.get_all_reported_checkpoints()) == 0
trainer = DataParallelTrainer(
train_fn,
scaling_config=ScalingConfig(num_workers=2),
)
trainer.fit()
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-x", __file__]))