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

361 lines
11 KiB
Python

import stat
import uuid
from pathlib import Path
from urllib.parse import urlparse, urlunparse
import pytest
import ray
import ray.train.collective
from ray import train
from ray.train import Checkpoint, CheckpointConfig, RunConfig, ScalingConfig
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
from ray.train.torch import TorchTrainer
from ray.train.v2._internal.constants import CHECKPOINT_MANAGER_SNAPSHOT_FILENAME
from ray.train.v2._internal.execution.storage import StorageContext
from ray.train.v2.api.data_parallel_trainer import DataParallelTrainer
from ray.train.v2.api.exceptions import WorkerGroupError
from ray.train.v2.api.result import Result
def uri_join(base_uri: str, *paths: str) -> str:
"""
Join a base URI (local or remote) with one or more subpaths.
Preserves query parameters and scheme.
"""
parsed = urlparse(base_uri)
new_path = "/".join([p.strip("/") for p in [parsed.path, *paths] if p])
# If it's a local path (no scheme), ensure we preserve the leading /
if not parsed.scheme and not new_path.startswith("/"):
new_path = "/" + new_path
return urlunparse(
(
parsed.scheme,
parsed.netloc,
new_path,
parsed.params,
parsed.query,
parsed.fragment,
)
)
def build_dummy_trainer(
exp_name: str,
storage_path: str,
num_iterations: int,
num_checkpoints: int,
train_loop_config: dict,
):
"""Build a dummy TorchTrainer for testing purposes."""
def worker_loop(_config):
for i in range(num_iterations):
# Do some random reports in between checkpoints.
train.report({"metric_a": -100, "metric_b": -100})
if ray.train.get_context().get_world_rank() == 0:
with create_dict_checkpoint({"iter": i}) as checkpoint:
train.report(
metrics={"metric_a": i, "metric_b": -i},
checkpoint=checkpoint,
)
else:
train.report(metrics={"metric_a": i, "metric_b": -i})
# Ensure that all checkpoints are saved before the RuntimeError
ray.train.collective.barrier()
raise RuntimeError()
trainer = TorchTrainer(
train_loop_per_worker=worker_loop,
train_loop_config=train_loop_config,
scaling_config=ScalingConfig(num_workers=2, use_gpu=False),
run_config=RunConfig(
name=exp_name,
storage_path=storage_path,
checkpoint_config=CheckpointConfig(
num_to_keep=num_checkpoints,
checkpoint_score_attribute="metric_a",
checkpoint_score_order="max",
),
),
)
return trainer
def test_result_repr():
"""Test that the Result __repr__ function can return a string."""
res = Result(
metrics={"iter": 0, "metric": 1.0},
checkpoint=Checkpoint("/bucket/path/ckpt0"),
error=None,
path="/bucket/path",
)
assert isinstance(repr(res), str)
assert "Checkpoint(filesystem=local, path=/bucket/path/ckpt0)" in repr(res)
assert "metrics={'iter': 0, 'metric': 1.0}" in repr(res)
def test_get_best_checkpoint():
res = Result(
metrics={},
checkpoint=None,
error=None,
path="/bucket/path",
best_checkpoints=[
(Checkpoint("/bucket/path/ckpt0"), {"iter": 0, "metric": 1.0}),
(Checkpoint("/bucket/path/ckpt1"), {"iter": 1, "metric": 2.0}),
(Checkpoint("/bucket/path/ckpt2"), {"iter": 2, "metric": 3.0}),
(Checkpoint("/bucket/path/ckpt3"), {"iter": 3, "metric": 4.0}),
],
)
assert (
res.get_best_checkpoint(metric="metric", mode="max").path
== "/bucket/path/ckpt3"
)
assert (
res.get_best_checkpoint(metric="metric", mode="min").path
== "/bucket/path/ckpt0"
)
def test_get_best_checkpoint_nested_metrics():
"""Test that get_best_checkpoint works with nested metric dictionaries."""
# Test with nested metric structure
res = Result(
metrics={},
checkpoint=None,
error=None,
path="/bucket/path",
best_checkpoints=[
(
Checkpoint("/bucket/path/ckpt0"),
{
"iter": 0,
"env_runners": {"episode_return_mean": 100.0, "num_episodes": 10},
},
),
(
Checkpoint("/bucket/path/ckpt1"),
{
"iter": 1,
"env_runners": {"episode_return_mean": 200.0, "num_episodes": 10},
},
),
(
Checkpoint("/bucket/path/ckpt2"),
{
"iter": 2,
"env_runners": {"episode_return_mean": 300.0, "num_episodes": 10},
},
),
(
Checkpoint("/bucket/path/ckpt3"),
{
"iter": 3,
"env_runners": {"episode_return_mean": 400.0, "num_episodes": 10},
},
),
],
)
# Test max mode with nested metric
assert (
res.get_best_checkpoint(
metric="env_runners/episode_return_mean", mode="max"
).path
== "/bucket/path/ckpt3"
)
# Test min mode with nested metric
assert (
res.get_best_checkpoint(
metric="env_runners/episode_return_mean", mode="min"
).path
== "/bucket/path/ckpt0"
)
# Test that flat keys still work (backwards compatibility)
res_flat = Result(
metrics={},
checkpoint=None,
error=None,
path="/bucket/path",
best_checkpoints=[
(
Checkpoint("/bucket/path/ckpt0"),
{"iter": 0, "env_runners/episode_return_mean": 100.0},
),
(
Checkpoint("/bucket/path/ckpt1"),
{"iter": 1, "env_runners/episode_return_mean": 200.0},
),
],
)
assert (
res_flat.get_best_checkpoint(
metric="env_runners/episode_return_mean", mode="max"
).path
== "/bucket/path/ckpt1"
)
@pytest.mark.parametrize("path_type", ["str", "PathLike"])
@pytest.mark.parametrize("pass_storage_filesystem", [True, False])
@pytest.mark.parametrize("trailing_slash", [False, True])
def test_result_restore(
ray_start_4_cpus,
tmp_path,
path_type,
pass_storage_filesystem,
trailing_slash,
):
"""Test Result.from_path functionality similar to v1 test_result_restore."""
num_iterations = 3
num_checkpoints = 2
storage_path = str(tmp_path)
# Add UUID to ensure test isolation when sharing module-scoped S3 mock
exp_name = f"test_result_restore_v2-{uuid.uuid4().hex[:8]}"
trainer = build_dummy_trainer(
exp_name,
storage_path,
num_iterations,
num_checkpoints,
train_loop_config={"a": 1, "b": 2},
)
with pytest.raises(WorkerGroupError):
trainer.fit()
if pass_storage_filesystem:
storage_context = StorageContext(
storage_path=storage_path,
experiment_dir_name=exp_name,
)
trial_dir = storage_context.experiment_fs_path
file_system = storage_context.storage_filesystem
else:
trial_dir = uri_join(storage_path, exp_name)
file_system = None
# Add trailing slash if parameterized to test that case
if trailing_slash:
trial_dir = trial_dir + "/"
# For PathLike test, only use Path() for local paths, not URIs
if path_type == "PathLike":
trial_dir_arg = Path(trial_dir)
else:
trial_dir_arg = trial_dir
result = Result.from_path(
trial_dir_arg,
storage_filesystem=file_system,
)
assert result.checkpoint
assert len(result.best_checkpoints) == num_checkpoints
"""
Top-2 checkpoints with metrics:
| iter | metric_a metric_b
checkpoint_000002 2 2 -2
checkpoint_000001 1 1 -1
"""
# Check if the checkpoints bounded with correct metrics
best_ckpt_a = result.get_best_checkpoint(metric="metric_a", mode="max")
assert load_dict_checkpoint(best_ckpt_a)["iter"] == num_iterations - 1
best_ckpt_b = result.get_best_checkpoint(metric="metric_b", mode="max")
assert load_dict_checkpoint(best_ckpt_b)["iter"] == num_iterations - num_checkpoints
with pytest.raises(RuntimeError, match="Invalid metric name.*"):
result.get_best_checkpoint(metric="invalid_metric", mode="max")
def test_result_from_path_read_only_storage(
ray_start_4_cpus,
tmp_path,
):
"""Reproduces https://github.com/ray-project/ray/issues/64305.
If checkpoints live on write-once / read-only storage, check that
``Result.from_path`` / ``get_best_checkpoint`` are read-only operations.
"""
def train_fn():
with create_dict_checkpoint({"iter": 1}) as ckpt:
ray.train.report({"epoch": 1}, ckpt)
with create_dict_checkpoint({"iter": 2}) as ckpt:
ray.train.report({"epoch": 2}, ckpt)
trainer = DataParallelTrainer(
train_fn,
run_config=RunConfig(
name="test_read_only_storage",
storage_path=str(tmp_path),
),
)
trainer.fit()
# Strip write permissions from the experiment directory, so that any new write fails.
no_write_mask = ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
experiment_dir = Path(tmp_path) / "test_read_only_storage"
assert experiment_dir.exists()
original_modes = {
path: stat.S_IMODE(path.stat().st_mode)
for path in [experiment_dir, *experiment_dir.rglob("*")]
}
for path in original_modes:
path.chmod(original_modes[path] & no_write_mask)
try:
# Reading the best checkpoint must not require write access.
result = Result.from_path(str(experiment_dir))
assert result.checkpoint
assert len(result.best_checkpoints) == 2
best_ckpt = result.get_best_checkpoint(metric="epoch", mode="max")
assert load_dict_checkpoint(best_ckpt)["iter"] == 2
finally:
for path, mode in original_modes.items():
path.chmod(mode)
def test_result_from_path_validation(
ray_start_4_cpus,
tmp_path,
):
"""Test that Result.from_path raises RuntimeError when folder or snapshot file doesn't exist."""
nonexistent_folder = str(tmp_path / "nonexistent_experiment")
existing_folder = str(tmp_path / "existing_experiment")
# Test 1: Folder doesn't exist
with pytest.raises(RuntimeError, match="Experiment folder .* doesn't exist."):
Result.from_path(nonexistent_folder)
# Test 2: Folder exists but snapshot file doesn't exist
Path(existing_folder).mkdir(parents=True, exist_ok=True)
with pytest.raises(
RuntimeError,
match=f"Failed to restore the Result object: {CHECKPOINT_MANAGER_SNAPSHOT_FILENAME} doesn't exist in the experiment folder. Make sure that this is an output directory created "
"by a Ray Train run.",
):
Result.from_path(existing_folder)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-x", __file__]))