chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,656 @@
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional, Tuple, Union
import ray
from ray._common.pydantic_compat import BaseModel
from ray._private.ray_constants import env_float
from ray.air.config import CheckpointConfig
from ray.train._checkpoint import Checkpoint
from ray.train._internal.checkpoint_manager import (
_CheckpointManager,
_insert_into_sorted_list,
)
from ray.train._internal.session import _TrainingResult
from ray.train.v2._internal.constants import (
COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
DEFAULT_COLLECTIVE_WARN_INTERVAL_S,
)
from ray.train.v2._internal.exceptions import CheckpointManagerInitializationError
from ray.train.v2._internal.execution.callback import (
ReportCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.context import StorageContext
from ray.train.v2._internal.execution.storage import _exists_at_fs_path, delete_fs_path
from ray.train.v2._internal.execution.training_report import _TrainingReport
from ray.train.v2._internal.execution.worker_group import Worker
from ray.train.v2._internal.util import wait_with_logging
from ray.train.v2.api.report_config import CheckpointConsistencyMode
from ray.train.v2.api.reported_checkpoint import (
ReportedCheckpoint,
ReportedCheckpointStatus,
)
from ray.train.v2.api.validation_config import ValidationTaskConfig
logger = logging.getLogger(__name__)
GET_ALL_REPORTED_CHECKPOINTS_PERIODIC_WARNING = """
`get_all_reported_checkpoints` has been waiting for all checkpoints to get to the {consistency_mode} state for {time_elapsed_s:.2f} s.
You can set the {warn_interval_env_var} environment variable to change the frequency of this warning (current value: {warn_interval_s} s).
"""
class _TrainingResultState(BaseModel):
# Increment version if the schema changes
version: int = 0
checkpoint_dir_name: str
metrics: dict
class _CheckpointManagerState(BaseModel):
ray_version: str = ray.__version__
checkpoint_results: List[_TrainingResultState]
checkpoint_report_indices: List[int]
latest_checkpoint_result: Optional[_TrainingResultState] = None
pending_training_results: List[_TrainingResultState]
pending_validation_specs: List[Union[bool, ValidationTaskConfig]]
current_report_index: int
# List of processed checkpoints based on if successfully validated,
# timed out or failed due to an error or canceled for some reason.
validated_checkpoint_dir_names: List[str]
timed_out_validation_checkpoint_dir_names: List[str]
failed_validation_checkpoint_dir_names: List[str]
def _get_training_result_from_state(
state: _TrainingResultState,
storage_context: StorageContext,
) -> _TrainingResult:
"""Get a TrainingResult object from a Pydantic state object."""
return _TrainingResult(
checkpoint=Checkpoint(
path=storage_context.build_checkpoint_path_from_name(
state.checkpoint_dir_name
),
filesystem=storage_context.storage_filesystem,
),
metrics=state.metrics,
)
def _get_state_from_training_result(
training_result: _TrainingResult,
storage_context: StorageContext,
) -> _TrainingResultState:
"""Get a Pydantic state object from a TrainingResult object."""
return _TrainingResultState(
checkpoint_dir_name=storage_context.extract_checkpoint_dir_name_from_path(
training_result.checkpoint.path
),
metrics=training_result.metrics,
)
class CheckpointManager(_CheckpointManager, ReportCallback, WorkerGroupCallback):
def __init__(
self,
checkpoint_config: CheckpointConfig,
storage_context: StorageContext,
):
self._storage_context = storage_context
self._checkpoint_config = checkpoint_config
# This tracks the number of report calls that have been processed
# for the current worker group.
self._current_report_index = 0
# Map from pending checkpoint to validation.
self._pending_training_results: Dict[
Checkpoint, Tuple[_TrainingResult, Union[bool, ValidationTaskConfig]]
] = {}
# Set of checkpoints that have successfully completed, been timed out
# or failed validation.
self._validated_checkpoints: set = set()
self._timed_out_validation_checkpoints: set = set()
self._failed_validation_checkpoints: set = set()
# Map from checkpoint to report index. Used to order checkpoints.
self._checkpoint_to_report_index = {}
self._condition = asyncio.Condition()
# Strong references to background tasks created via
# ``asyncio.create_task`` to prevent them from being garbage
# collected mid-execution. The event loop only keeps weak refs.
self._background_tasks: set = set()
self._collective_warn_interval_s = env_float(
COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
DEFAULT_COLLECTIVE_WARN_INTERVAL_S,
)
super().__init__(checkpoint_config)
# If the snapshot is found, the checkpoint manager will restore its state.
# TODO(xgui): CheckpointManager is used to save or restore the checkpoint manager state.
# We should sanity check if we should see old state in the storage folder.
self._maybe_load_state_from_storage()
def register_checkpoint(
self,
training_report: _TrainingReport,
):
"""Register new checkpoint and add to bookkeeping.
This method will register a new checkpoint and add it to the internal
bookkeeping logic. This means the checkpoint manager will decide if
this checkpoint should be kept, and if older or worse performing
checkpoints should be deleted.
Args:
training_report: Training report to register.
"""
checkpoint_result = _TrainingResult(
checkpoint=training_report.checkpoint,
metrics=training_report.metrics,
)
self._latest_checkpoint_result = checkpoint_result
self._checkpoint_to_report_index[
checkpoint_result.checkpoint
] = self._current_report_index
if self._checkpoint_config.checkpoint_score_attribute is not None:
# If we're ordering by a score, insert the checkpoint
# so that the list remains sorted.
_insert_into_sorted_list(
self._checkpoint_results,
checkpoint_result,
key=self._get_checkpoint_score,
checkpoint_to_report_index=self._checkpoint_to_report_index,
)
else:
# If no metric is provided, just append (ordering by time of registration).
self._checkpoint_results.append(checkpoint_result)
if training_report.validation:
self._pending_training_results[checkpoint_result.checkpoint] = (
checkpoint_result,
training_report.validation,
)
self._current_report_index += 1
self._save_state_and_delete_old_checkpoints()
self._notify()
def update_checkpoints_with_validation_result(
self,
checkpoint_updates: Dict[
Checkpoint, Tuple[Dict[str, Any], ReportedCheckpointStatus]
],
):
"""Finalize pending validations by recording terminal status and metrics.
* For VALIDATED checkpoints, metrics are merged into the checkpoint's
existing metrics and the checkpoint is re-sorted.
* For VALIDATION_TIMEOUT and VALIDATION_FAILED checkpoints, metrics are
left untouched and the checkpoint retains its original training-time
score position.
"""
for checkpoint, (metrics, status) in checkpoint_updates.items():
if checkpoint not in self._pending_training_results:
logger.warning(
f"Checkpoint {checkpoint} not found in pending training results. "
)
continue
checkpoint_result, _ = self._pending_training_results[checkpoint]
if checkpoint_result not in self._checkpoint_results:
raise ValueError(
f"Checkpoint {checkpoint} was in pending training results but not "
"checkpoint results. "
)
self._pending_training_results.pop(checkpoint)
if status == ReportedCheckpointStatus.VALIDATED:
# Update the metrics and sort into checkpoint_results
checkpoint_result.metrics.update(metrics)
self._checkpoint_results.remove(checkpoint_result)
_insert_into_sorted_list(
self._checkpoint_results,
checkpoint_result,
key=self._get_checkpoint_score,
checkpoint_to_report_index=self._checkpoint_to_report_index,
)
self._validated_checkpoints.add(checkpoint)
elif status == ReportedCheckpointStatus.VALIDATION_TIMEOUT:
self._timed_out_validation_checkpoints.add(checkpoint)
elif status == ReportedCheckpointStatus.VALIDATION_FAILED:
self._failed_validation_checkpoints.add(checkpoint)
else:
raise ValueError(
f"Unexpected terminal validation status {status} for "
f"checkpoint {checkpoint}."
)
self._save_state_and_delete_old_checkpoints()
self._notify()
def get_pending_training_results(
self,
) -> Dict[Checkpoint, Tuple[_TrainingResult, Union[bool, ValidationTaskConfig]]]:
"""Get the pending training results which includes their validation specs."""
return self._pending_training_results
def _notify(self):
"""Notify condition so all listeners know state has changed."""
async def async_notify():
async with self._condition:
self._condition.notify_all()
# Keep a strong reference to the task so it isn't garbage
# collected before completing, which would silently drop
# the notification and could leave listeners waiting forever.
task = asyncio.create_task(async_notify())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
def _save_state_and_delete_old_checkpoints(self):
"""Delete the old checkpoints."""
# Get checkpoints to delete
results_to_delete = set()
if self._checkpoint_config.num_to_keep is not None:
# Delete the bottom (N - K) checkpoints
worst_results = set(
self._checkpoint_results[: -self._checkpoint_config.num_to_keep]
)
# Except for the latest checkpoint and pending checkpoints
results_to_delete = worst_results - {self._latest_checkpoint_result}
results_to_delete = results_to_delete - {
v for v, _ in self._pending_training_results.values()
}
# Update internal state before actually deleting them.
self._checkpoint_results = [
checkpoint_result
for checkpoint_result in self._checkpoint_results
if checkpoint_result not in results_to_delete
]
for checkpoint_result in results_to_delete:
del self._checkpoint_to_report_index[checkpoint_result.checkpoint]
# discard doesn't raise an error if the element isn't found
self._validated_checkpoints.discard(checkpoint_result.checkpoint)
self._timed_out_validation_checkpoints.discard(
checkpoint_result.checkpoint
)
self._failed_validation_checkpoints.discard(
checkpoint_result.checkpoint
)
# Save the checkpoint manager state to storage.
# Note: We save the state before deleting the old checkpoints.
# If deletion happens first and the process crashes, our snapshot
# may point to some stale checkpoints that are already deleted.
# TODO: Make this writing operation non-blocking.
self._write_state_to_storage()
# Delete the old checkpoints.
for checkpoint_result in results_to_delete:
checkpoint = checkpoint_result.checkpoint
logger.debug("Deleting checkpoint: %s", checkpoint)
delete_fs_path(fs=checkpoint.filesystem, fs_path=checkpoint.path)
# --------------------------
# CheckpointManager state
# --------------------------
def _save_state(self) -> str:
"""Save the checkpoint manager state to a JSON str."""
checkpoint_results = [
_get_state_from_training_result(checkpoint_result, self._storage_context)
for checkpoint_result in self._checkpoint_results
]
checkpoint_report_indices = [
self._checkpoint_to_report_index[checkpoint_result.checkpoint]
for checkpoint_result in self._checkpoint_results
]
latest_checkpoint_result = (
_get_state_from_training_result(
self._latest_checkpoint_result, self._storage_context
)
if self._latest_checkpoint_result is not None
else None
)
pending_training_results = [
_get_state_from_training_result(v, self._storage_context)
for v, _ in self._pending_training_results.values()
]
pending_validation_specs = [
v for _, v in self._pending_training_results.values()
]
validated_ckpt_dir_names = [
self._storage_context.extract_checkpoint_dir_name_from_path(checkpoint.path)
for checkpoint in self._validated_checkpoints
]
timed_out_validation_ckpt_dir_names = [
self._storage_context.extract_checkpoint_dir_name_from_path(checkpoint.path)
for checkpoint in self._timed_out_validation_checkpoints
]
failed_validation_ckpt_dir_names = [
self._storage_context.extract_checkpoint_dir_name_from_path(checkpoint.path)
for checkpoint in self._failed_validation_checkpoints
]
manager_snapshot = _CheckpointManagerState(
checkpoint_results=checkpoint_results,
checkpoint_report_indices=checkpoint_report_indices,
latest_checkpoint_result=latest_checkpoint_result,
pending_training_results=pending_training_results,
pending_validation_specs=pending_validation_specs,
current_report_index=self._current_report_index,
validated_checkpoint_dir_names=validated_ckpt_dir_names,
timed_out_validation_checkpoint_dir_names=timed_out_validation_ckpt_dir_names,
failed_validation_checkpoint_dir_names=failed_validation_ckpt_dir_names,
)
return manager_snapshot.json()
def _load_state(self, json_state: str):
"""Load the checkpoint manager state from a JSON str."""
json_dict = None
try:
json_dict = json.loads(json_state)
manager_snapshot = _CheckpointManagerState.parse_obj(json_dict)
except Exception as e:
if not json_dict:
error = e
elif "ray_version" not in json_dict:
error = (
"You are loading a checkpoint manager snapshot saved with an unknown Ray version "
f"but you are running Ray version {ray.__version__}. Please use the same Ray version "
"the checkpoint manager snapshot was saved with."
)
elif json_dict["ray_version"] != ray.__version__:
error = (
f"You are loading a checkpoint manager snapshot saved with Ray version "
f"{json_dict['ray_version']} but you are running Ray version "
f"{ray.__version__}. Please use the same Ray version the checkpoint "
"manager snapshot was saved with."
)
else:
error = e
raise CheckpointManagerInitializationError(error) from e
# Do this so we are using the same checkpoint and trainingresult objects.
# TODO: consider asserting that every checkpoint has a unique dir name
checkpoint_dir_name_to_checkpoint_result = {}
for training_result_state in manager_snapshot.checkpoint_results:
training_result = _get_training_result_from_state(
training_result_state, self._storage_context
)
checkpoint_dir_name_to_checkpoint_result[
training_result_state.checkpoint_dir_name
] = training_result
self._checkpoint_results.append(training_result)
self._assert_checkpoints_exist()
assert len(self._checkpoint_results) == len(
manager_snapshot.checkpoint_report_indices
)
self._checkpoint_to_report_index = {
checkpoint_result.checkpoint: report_index
for checkpoint_result, report_index in zip(
self._checkpoint_results, manager_snapshot.checkpoint_report_indices
)
}
self._latest_checkpoint_result = (
checkpoint_dir_name_to_checkpoint_result[
manager_snapshot.latest_checkpoint_result.checkpoint_dir_name
]
if manager_snapshot.latest_checkpoint_result is not None
else None
)
assert len(manager_snapshot.pending_training_results) == len(
manager_snapshot.pending_validation_specs
)
for training_result_state, validation_spec in zip(
manager_snapshot.pending_training_results,
manager_snapshot.pending_validation_specs,
):
training_result = checkpoint_dir_name_to_checkpoint_result[
training_result_state.checkpoint_dir_name
]
self._pending_training_results[training_result.checkpoint] = (
training_result,
validation_spec,
)
# Restore terminal validation statuses. Only checkpoints still in
# _checkpoint_results can be looked up; evicted checkpoints are irrelevant.
for dir_names, target_set in (
(
manager_snapshot.validated_checkpoint_dir_names,
self._validated_checkpoints,
),
(
manager_snapshot.timed_out_validation_checkpoint_dir_names,
self._timed_out_validation_checkpoints,
),
(
manager_snapshot.failed_validation_checkpoint_dir_names,
self._failed_validation_checkpoints,
),
):
for dir_name in dir_names:
if dir_name in checkpoint_dir_name_to_checkpoint_result:
target_set.add(
checkpoint_dir_name_to_checkpoint_result[dir_name].checkpoint
)
self._current_report_index = manager_snapshot.current_report_index
def _maybe_load_state_from_storage(self):
"""Load the checkpoint manager state from storage.
If no snapshot is found, start with a clean state.
"""
if not _exists_at_fs_path(
fs=self._storage_context.storage_filesystem,
fs_path=self._storage_context.checkpoint_manager_snapshot_path,
):
logger.debug(
"No checkpoint manager snapshot found. "
"No checkpoint will be available via `ray.train.get_checkpoint`, "
"so training will start from scratch."
)
return
with self._storage_context.storage_filesystem.open_input_stream(
self._storage_context.checkpoint_manager_snapshot_path
) as f:
logger.info(
"A run snapshot was found in storage folder at: "
f"'{self._storage_context.experiment_fs_path}'\n"
"This snapshot contains a list of checkpoints reported via "
"`ray.train.report` and will be loaded. "
"This allows the latest checkpoint found in the snapshot to be "
"accessible within your training function via "
"`ray.train.get_checkpoint`.\n"
"If you meant to start a brand new training job without any "
"information about previous checkpoints found in this directory, "
"please configure a new, unique `RunConfig(name)` or delete the "
f"existing folder at '{self._storage_context.experiment_fs_path}'."
)
json_state = f.read().decode("utf-8")
self._load_state(json_state)
def _write_state_to_storage(self):
"""Write the checkpoint manager state to storage."""
checkpoint_manager_snapshot = self._save_state()
with self._storage_context.storage_filesystem.open_output_stream(
self._storage_context.checkpoint_manager_snapshot_path
) as f:
f.write(checkpoint_manager_snapshot.encode("utf-8"))
def _assert_checkpoints_exist(self):
"""Validate the checkpoint manager state.
This method will validate the checkpoint manager state by checking if
the checkpoints specified in manager snapshot is compatible with the
checkpoint folders of the experiment storage filesystem.
Raises:
CheckpointManagerInitializationError: If the checkpoint manager snapshot
is not consistent with the stored checkpoints.
"""
for checkpoint_result in self._checkpoint_results:
checkpoint = checkpoint_result.checkpoint
assert checkpoint is not None
if not _exists_at_fs_path(
fs=checkpoint.filesystem, fs_path=checkpoint.path
):
raise CheckpointManagerInitializationError(
"The run snapshot contains a reference to a checkpoint "
f"that does not exist anymore ({checkpoint}). You are "
"running in a corrupted run directory `experiment_fs_path`. "
"Please configure a new, unique `RunConfig(name)` "
"or delete the existing folder at "
f"`{self._storage_context.experiment_fs_path}`."
)
# --------------------------
# ReportCallback
# --------------------------
def after_report(
self,
training_report: _TrainingReport,
metrics: List[Dict[str, Any]],
):
if not training_report.checkpoint:
self._current_report_index += 1
self._notify()
return
self.register_checkpoint(training_report)
# --------------------------
# WorkerGroupCallback
# --------------------------
def before_init_train_context(self, workers: List[Worker]) -> Dict[str, List[Any]]:
latest_checkpoint = (
self.latest_checkpoint_result.checkpoint
if self.latest_checkpoint_result
else None
)
train_context_args = {
"checkpoint": [latest_checkpoint] * len(workers),
"current_report_index": [self._current_report_index] * len(workers),
}
return train_context_args
# --------------------------------
# Get all reported checkpoints API
# --------------------------------
def _get_checkpoint_status(
self, checkpoint: Checkpoint
) -> ReportedCheckpointStatus:
"""Get ReportedCheckpoint's status."""
if checkpoint in self._pending_training_results:
return ReportedCheckpointStatus.PENDING_VALIDATION
elif checkpoint in self._timed_out_validation_checkpoints:
return ReportedCheckpointStatus.VALIDATION_TIMEOUT
elif checkpoint in self._failed_validation_checkpoints:
return ReportedCheckpointStatus.VALIDATION_FAILED
elif checkpoint in self._validated_checkpoints:
return ReportedCheckpointStatus.VALIDATED
else:
return ReportedCheckpointStatus.COMMITTED
def _generate_get_all_reported_checkpoints_periodic_warning(
self, start_time: float, consistency_mode: CheckpointConsistencyMode
) -> str:
"""Generates the warning message for the get_all_reported_checkpoints periodic warning."""
return GET_ALL_REPORTED_CHECKPOINTS_PERIODIC_WARNING.format(
consistency_mode=consistency_mode,
time_elapsed_s=asyncio.get_event_loop().time() - start_time,
warn_interval_env_var=COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
warn_interval_s=self._collective_warn_interval_s,
)
async def get_all_reported_checkpoints(
self,
current_report_index: int,
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
timeout_s: Optional[float] = None,
) -> List[ReportedCheckpoint]:
"""Get all the reported checkpoints so far.
Args:
current_report_index: The current report index.
consistency_mode: Read semantics for checkpoint retrieval. Defaults to VALIDATED.
timeout_s: Timeout in seconds. Defaults to None to run forever.
Returns:
A list of ReportedCheckpoint objects that represent the checkpoints and
corresponding metrics reported by the workers.
"""
start_time = asyncio.get_event_loop().time()
if consistency_mode == CheckpointConsistencyMode.COMMITTED:
def predicate() -> bool:
return self._current_report_index == current_report_index
elif consistency_mode == CheckpointConsistencyMode.VALIDATED:
def predicate() -> bool:
return (
self._current_report_index == current_report_index
and not self._pending_training_results
)
else:
raise ValueError(
f"Unexpected CheckpointConsistencyMode: {consistency_mode}"
)
async with self._condition:
try:
await wait_with_logging(
self._condition,
predicate=predicate,
generate_warning_message=lambda: self._generate_get_all_reported_checkpoints_periodic_warning(
start_time, consistency_mode
),
warn_interval_s=self._collective_warn_interval_s,
timeout_s=timeout_s,
)
except (asyncio.TimeoutError, TimeoutError):
# Time out due to checkpoint upload or validation in progress
logger.debug(
"Timed out waiting for reported_checkpoint to become available."
)
# TODO: might be nice for CheckpointManager to manage ReportedCheckpoint
# instead of _TrainingResult but that is a large refactor.
return [
ReportedCheckpoint(
checkpoint=tr.checkpoint,
metrics=tr.metrics,
status=self._get_checkpoint_status(tr.checkpoint),
)
for tr in self._checkpoint_results
]
@@ -0,0 +1,129 @@
from collections import deque
from typing import Deque, List, Optional
from ray.train.v2._internal.execution.callback import (
ReplicaGroupCallback,
ReportCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.training_report import _TrainingReport
from ray.train.v2._internal.execution.worker_group import (
WorkerGroup,
WorkerGroupPollStatus,
)
from ray.train.v2._internal.execution.worker_group.execution_group import ReplicaGroup
class ReportCallbackHandler(ReplicaGroupCallback, WorkerGroupCallback):
"""Consolidate training results from multiple workers and call
subscribers implementing the `ReportCallback` interface sequentially.
"""
def __init__(self, report_callbacks: List[ReportCallback]):
# We set the worker group after it has been started and remove it after it
# has been shut down.
self._worker_group: Optional[WorkerGroup] = None
# A list of queues holding training reports from workers.
self._training_report_queues: Optional[List[Deque[_TrainingReport]]] = None
self._report_callbacks = report_callbacks
def _assert_initialized(self):
assert (
self._worker_group and self._training_report_queues
), "Need to call initialize state with `after_worker_group_start` first."
# --------------------------
# WorkerGroupCallback
# --------------------------
def after_worker_group_poll_status(
self, worker_group_status: WorkerGroupPollStatus
) -> None:
"""Handle training results as they roll in from worker status polls.
Wait for all workers to report training results to collect
a consolidated training result.
"""
# Step 1: Assert that the worker group has been started and not shut down.
self._assert_initialized()
assert len(self._worker_group) == len(worker_group_status.worker_statuses), (
f"The number of workers in the worker group has changed unexpectedly. "
f"Expected: {len(self._worker_group)}, got: {len(worker_group_status.worker_statuses)}"
)
# Step 2: Update training_reports_queues with poll_results.
for i in range(len(self._worker_group)):
training_report = worker_group_status.worker_statuses[i].training_report
if training_report:
self._training_report_queues[i].append(training_report)
# Directly return if any of the worker result queues are empty.
if not all(self._training_report_queues):
return
training_reports = [q.popleft() for q in self._training_report_queues]
# Step 3: Consolidate a list of checkpoints to single checkpoint.
# Use the first checkpoint as the consolidated checkpoint.
checkpoint_results = [
tr for tr in training_reports if tr.checkpoint is not None
]
consolidated_checkpoint = None
validation = False
if checkpoint_results:
# Double check the storage path of the checkpoints in the training results.
unique_checkpoint_paths = {tr.checkpoint.path for tr in checkpoint_results}
if len(unique_checkpoint_paths) > 1:
# TODO: Support for inconsistent checkpoints path from workers
# instead of hard raising error. Maybe drop this iteration of
# training results and continue with the next iteration.
raise RuntimeError(
"The storage path of the checkpoints in the training results "
"is not the same. This means the checkpoints are not consistent."
"Got a mix of the following checkpoint paths: "
f"{unique_checkpoint_paths}\n"
"This is unexpected -- please file a Github issue."
)
consolidated_checkpoint = checkpoint_results[0].checkpoint
validation = checkpoint_results[0].validation
# Step 4: Invoke all dependent `ReportCallback`s.
metrics_per_worker = [
training_report.metrics for training_report in training_reports
]
for callback in self._report_callbacks:
callback.after_report(
training_report=_TrainingReport(
checkpoint=consolidated_checkpoint,
metrics=metrics_per_worker[0],
validation=validation,
),
metrics=metrics_per_worker,
)
def after_worker_group_start(self, worker_group: WorkerGroup) -> None:
"""Handle worker group start. Initialize internal states."""
self._worker_group = worker_group
self._training_report_queues = [deque() for _ in range(len(self._worker_group))]
def before_worker_group_shutdown(self, worker_group: WorkerGroup) -> None:
"""Handle worker group shutdown. Clear internal states.
None of the partial reported results are valid at this point.
"""
self._worker_group = None
self._training_report_queues = None
# --------------------------
# ReplicaGroupCallback
# --------------------------
def after_replica_group_start(self, replica_group: ReplicaGroup) -> None:
"""Handle replica group start. Initialize internal states."""
self._assert_initialized()
# TODO: it might be possible to reuse existing queues.
# For example, if 3/4 ddp workers reported a checkpoint, that checkpoint is usable.
self._training_report_queues = [deque() for _ in range(len(self._worker_group))]
@@ -0,0 +1,226 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import List, Optional, TypeVar
import ray
from ray.train.v2._internal.constants import (
COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
DEFAULT_COLLECTIVE_TIMEOUT_S,
DEFAULT_COLLECTIVE_WARN_INTERVAL_S,
)
from ray.train.v2._internal.exceptions import BroadcastCollectiveTimeoutError
from ray.train.v2._internal.util import wait_with_logging
T = TypeVar("T", bound=Optional[object])
logger = logging.getLogger(__name__)
class SynchronizationBarrierResetError(Exception):
"""Raised when the synchronization barrier is reset, e.g. due to a worker failure."""
pass
BROADCAST_PERIODIC_WARNING = """
`{caller_method_name}` has not been called by all {world_size} workers in the group.
The workers have been waiting for {max_time_elapsed_s:.2f} s for the following ranks to join the `{caller_method_name}` call: {missing_ranks}.
Also ensure that workers are not hanging on other operations, causing them to miss this synchronization barrier.
You can set the {warn_interval_env_var} environment variable to change the frequency of this warning (current value: {warn_interval_s} s).
"""
@ray.remote(num_cpus=0) # type: ignore
class SynchronizationActor:
"""A Ray actor that synchronizes the workers in a distributed training job.
This actor forms a synchronization barrier on a group of processes.
Every time a worker calls the broadcast_from_rank_zero method,
the counter is incremented. When the counter equals to the world size,
the actor notifies all the workers to continue.
"""
def __init__(
self,
timeout_s: Optional[float] = DEFAULT_COLLECTIVE_TIMEOUT_S,
warn_interval_s: float = DEFAULT_COLLECTIVE_WARN_INTERVAL_S,
):
self._counter: int = 0
self._world_size: int = 0
self._condition = asyncio.Condition()
self._reduced_data = None
self._reset = False
# The time when workers from different ranks
# enters the synchronization barrier.
self._sync_start_times: List[Optional[float]] = []
# The timeout in seconds for the synchronization barrier.
self._timeout_s: Optional[float] = timeout_s
# The interval in seconds to log a warning when waiting for the barrier.
self._warn_interval_s: float = warn_interval_s
def get_counter(self):
"""Returns the current value of the counter."""
return self._counter
def get_world_size(self):
"""Returns the current value of the world_size."""
return self._world_size
def get_reduced_data(self):
"""Returns the current value of the reduced_data."""
return self._reduced_data
def _clear_states(self):
"""Clears the states of the actor. When the last worker has
called the _clear_states method, the actor clears its states
"""
self._counter -= 1
if self._counter == 0:
self._reduced_data = None
self._world_size = 0
self._reset = False
self._condition.notify_all()
async def _setup_or_validate_collective_op(self, world_size: int):
"""The setup method for the synchronization actor if it is not setup yet.
It initializes the world size and the start times for the
synchronization barrier.
"""
# Wait for previous collective reset to finish.
await self._condition.wait_for(lambda: not self._reset)
if self._world_size == 0:
self._world_size = world_size
self._sync_start_times = [None] * world_size
elif world_size != self._world_size:
raise ValueError(
f"Expected all callers to provide the same world size. \
Got {world_size} and expected {self._world_size}."
)
@asynccontextmanager
async def _broadcast_collective_context_manager(
self, world_rank: int, world_size: int, data: T
):
"""A context manager that ensures the synchronization barrier is lifted
after the block of code is executed.
"""
try:
await self._setup_or_validate_collective_op(world_size)
if world_rank == 0:
self._reduced_data = data
if self._counter < self._world_size:
self._counter += 1
yield
finally:
self._clear_states()
def _get_time_elapsed(self) -> Optional[float]:
"""Return the time elapsed since the first worker entered the barrier.
If no workers have entered the barrier, returns None.
"""
start_times = [t for t in self._sync_start_times if t is not None]
if not start_times:
return None
return asyncio.get_event_loop().time() - min(start_times)
def _get_missing_ranks(self) -> List[int]:
"""Returns the ranks that have not entered the synchronization barrier."""
return [i for i, t in enumerate(self._sync_start_times) if t is None]
def _generate_broadcast_periodic_warning(self, caller_method_name: str) -> str:
"""Generates the warning message for the broadcast periodic warning."""
return BROADCAST_PERIODIC_WARNING.format(
caller_method_name=caller_method_name,
world_size=self._world_size,
max_time_elapsed_s=self._get_time_elapsed(),
missing_ranks=self._get_missing_ranks(),
warn_interval_env_var=COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
warn_interval_s=self._warn_interval_s,
)
async def reset(self):
"""Reset the synchronization barrier, unblocking any waiting workers.
If no workers are currently at the barrier, this is a no-op.
Waiting workers will raise SynchronizationBarrierResetError.
The actor remains alive and usable for subsequent barriers.
"""
async with self._condition:
if self._counter == 0:
return
self._reset = True
self._condition.notify_all()
async def broadcast_from_rank_zero(
self,
world_rank: int,
world_size: int,
data: T,
caller_method_name: str,
) -> T:
"""Broadcasts a data from the worker with rank 0 to all other workers.
This method is a coroutine that blocks until all workers have called this
method with the their data. The data from the worker with rank 0 will
be returned.
Args:
world_rank: The rank of the worker that calls this method.
world_size: The total number of workers in the group.
data: The data to broadcast.
caller_method_name: The name of the method that calls this method.
Returns:
The data broadcasted from the worker with rank 0.
"""
# TODO: resolve https://github.com/ray-project/ray/pull/54066#discussion_r2180657435
# We couldn't reproduce the issue but the asyncio docs don't say it can't happen.
# Ensures that all global states manipulation is done within the async context
# manager which makes the condition variable awaiting and the counter
# incrementing an atomic operation.
async with self._condition:
async with self._broadcast_collective_context_manager(
world_rank, world_size, data
):
# If the counter is equal to the world size, it means the last worker
# has called the broadcast_from_rank_zero method. The actor notifies
# all the workers to continue.
if self._counter == self._world_size:
self._condition.notify_all()
return self._reduced_data
# If the counter is less than the world size, the actor waits for the
# other workers to call the broadcast_from_rank_zero method.
try:
current_time = asyncio.get_event_loop().time()
self._sync_start_times[world_rank] = current_time
await wait_with_logging(
self._condition,
predicate=None,
generate_warning_message=(
lambda: self._generate_broadcast_periodic_warning(
caller_method_name
)
)
if world_rank == 0
else None,
warn_interval_s=self._warn_interval_s,
timeout_s=self._timeout_s,
)
if self._reset:
raise SynchronizationBarrierResetError(
"Synchronization barrier was reset, likely due "
"to a worker failure and replica group replacement."
)
return self._reduced_data
except (asyncio.TimeoutError, TimeoutError) as e:
raise BroadcastCollectiveTimeoutError(
time_elapsed=self._get_time_elapsed(),
missing_ranks=self._get_missing_ranks(),
timeout_s=self._timeout_s,
) from e
# TODO: Implement a general consensus_from_votes method that takes a callable
# reduce_fn and a list of votes from each worker. The method returns the consensus
@@ -0,0 +1,289 @@
import asyncio
import logging
import time
from collections import OrderedDict, deque
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
import ray
from ray.train._checkpoint import Checkpoint
from ray.train.v2._internal.execution.callback import (
ControllerCallback,
ReportCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.checkpoint.checkpoint_manager import (
CheckpointManager,
)
from ray.train.v2._internal.execution.training_report import (
_TrainingReport,
)
from ray.train.v2.api.reported_checkpoint import ReportedCheckpointStatus
from ray.train.v2.api.validation_config import ValidationConfig, ValidationTaskConfig
if TYPE_CHECKING:
from ray.train.v2._internal.execution.controller import TrainControllerState
from ray.train.v2._internal.execution.worker_group.worker import Worker
logger = logging.getLogger(__name__)
VALIDATION_TASK_POLL_INTERVAL_S = 1
MAX_IN_FLIGHT_VALIDATIONS = 1
@dataclass
class _PendingValidation:
checkpoint: Checkpoint
start_time: float
# None when no timeout applies.
timeout_s: Optional[float]
def __post_init__(self):
assert (
self.timeout_s is None or self.timeout_s > 0
), f"timeout_s needs to be None (for no timeout) or a positive value in seconds. Actual value: {self.timeout_s}"
@ray.remote
def run_validation_fn(
validation_config: ValidationConfig,
validation_task_config: Union[bool, ValidationTaskConfig],
checkpoint: Checkpoint,
) -> Dict:
"""Run the user-defined validation function.
Merges fn_kwargs from validation_config.task_config (defaults) with
fn_kwargs from validation_task_config (per-report overrides).
"""
# Merge kwargs: defaults from validation_config, overrides from validation_task_config
if validation_task_config is True:
merged_kwargs = validation_config.task_config.fn_kwargs
else:
merged_kwargs = {
**validation_config.task_config.fn_kwargs,
**validation_task_config.fn_kwargs,
}
metrics_dict = validation_config.fn(
checkpoint,
**merged_kwargs,
)
if not isinstance(metrics_dict, dict):
raise ValueError(
"The validation function must return a dictionary of metrics. "
f"Got {type(metrics_dict)} instead."
)
return metrics_dict
class ValidationManager(ControllerCallback, ReportCallback, WorkerGroupCallback):
def __init__(
self,
checkpoint_manager: CheckpointManager,
validation_config: ValidationConfig,
):
self._checkpoint_manager = checkpoint_manager
self._validation_config = validation_config
# _TrainingReports that we will validate
self._training_report_queue = deque()
# Map from in flight validation task to its pending-validation record
# (checkpoint + start_time + resolved timeout).
self._pending_validations: "OrderedDict[ray.ObjectRef, _PendingValidation]" = (
OrderedDict()
)
# Tasks that this manager proactively cancelled due to timeout. Used to
# distinguish timeout-cancels from controller-abort-cancels (both raise
# TaskCancelledError on ray.get).
self._timed_out_tasks: set = set()
# Map from validation task to checkpoint
# Finished validations that have yet to be processed
self._finished_validations: "OrderedDict[ray.ObjectRef, Checkpoint]" = (
OrderedDict()
)
self._requeue_incomplete_validations()
def _requeue_incomplete_validations(self):
"""Add _TrainingReports for incomplete validations to the queue."""
for checkpoint, (
training_result,
validation,
) in self._checkpoint_manager.get_pending_training_results().items():
if validation:
self._training_report_queue.append(
_TrainingReport(
metrics=training_result.metrics,
checkpoint=checkpoint,
validation=validation,
)
)
def after_report(
self,
training_report: _TrainingReport,
metrics: List[Dict[str, Any]],
):
if training_report.validation:
self._training_report_queue.append(training_report)
def _cancel_timed_out_validations(self):
"""Cancel any in-flight validation that has exceeded its timeout_s.
Cancelled tasks are moved directly from `_pending_validations` to
`_finished_validations` so the MAX_IN_FLIGHT slot is freed immediately
and the task flows through the normal finished-processing pipeline
without waiting for `ray.wait` to echo the cancellation.
"""
now = time.monotonic()
for task, pending in list(self._pending_validations.items()):
if (
pending.timeout_s is None
or now - pending.start_time < pending.timeout_s
):
continue
self._pending_validations.pop(task)
logger.warning(
f"Validation for checkpoint {pending.checkpoint} exceeded "
f"timeout_s={pending.timeout_s}s. Cancelling."
)
self._timed_out_tasks.add(task)
ray.cancel(task, force=True)
self._finished_validations[task] = pending.checkpoint
def _poll_validations(self) -> int:
"""Poll/process validations, update checkpoint manager, return num pending validations."""
self._cancel_timed_out_validations()
# Move pending validations to finished validations
validation_tasks = list(self._pending_validations.keys())
done, _ = ray.wait(
validation_tasks, timeout=0, num_returns=len(validation_tasks)
)
done_checkpoints = []
for task in done:
pending = self._pending_validations.pop(task)
done_checkpoints.append(pending.checkpoint)
self._finished_validations[task] = pending.checkpoint
if done_checkpoints:
logger.info(
f"Finished async validation task(s) for checkpoint(s): {done_checkpoints}.\n"
f"Running validations for checkpoint(s): {[p.checkpoint for p in self._pending_validations.values()]}.\n"
f"Staged validations for checkpoint(s): {[tr.checkpoint for tr in self._training_report_queue]}."
)
# Process finished validations (one at a time)
if self._finished_validations:
task, checkpoint = self._finished_validations.popitem(last=False)
update = self._process_finished_validation(task, checkpoint)
if update is not None:
self._checkpoint_manager.update_checkpoints_with_validation_result(
{checkpoint: update}
)
return len(self._pending_validations)
def _kick_off_validations(self) -> int:
"""Kick off validations and return the number of pending validations."""
# TODO: figure out where to place run_validation_fn task:
# TODO: provide option to run this on gpu?
num_validations_to_start = max(
MAX_IN_FLIGHT_VALIDATIONS - len(self._pending_validations), 0
)
num_validations_to_start = min(
num_validations_to_start, len(self._training_report_queue)
)
for _ in range(num_validations_to_start):
training_report = self._training_report_queue.popleft()
run_validation_fn_with_options = run_validation_fn.options(
**self._validation_config.ray_remote_kwargs,
)
validate_task = run_validation_fn_with_options.remote(
self._validation_config,
training_report.validation,
training_report.checkpoint,
)
if isinstance(training_report.validation, ValidationTaskConfig):
timeout_s = training_report.validation.timeout_s
else:
timeout_s = self._validation_config.task_config.timeout_s
self._pending_validations[validate_task] = _PendingValidation(
checkpoint=training_report.checkpoint,
start_time=time.monotonic(),
timeout_s=timeout_s,
)
logger.info(
f"Launched async validation task for checkpoint {training_report.checkpoint}"
)
return len(self._pending_validations)
def _process_finished_validation(
self, task: ray.ObjectRef, checkpoint: Checkpoint
) -> Optional[Tuple[Dict[str, Any], ReportedCheckpointStatus]]:
"""Process finished validation. Returns (metrics, status) or None.
Returns None when the task was cancelled by a controller abort (not a
timeout), leaving it pending so it re-queues on resumption.
"""
was_timed_out = task in self._timed_out_tasks
self._timed_out_tasks.discard(task)
if was_timed_out:
logger.info(
f"Validation for checkpoint {checkpoint} was cancelled due to timeout."
)
return {}, ReportedCheckpointStatus.VALIDATION_TIMEOUT
try:
metrics = ray.get(task)
return metrics, ReportedCheckpointStatus.VALIDATED
except ray.exceptions.TaskCancelledError:
logger.info(
f"Validation was cancelled for checkpoint {checkpoint}, likely because the train run was aborted. "
"It will be retried in the next train run with the same storage path if there is one."
)
return None
except ray.exceptions.RayTaskError:
logger.exception(f"Validation failed for checkpoint {checkpoint}")
return {}, ReportedCheckpointStatus.VALIDATION_FAILED
async def before_controller_shutdown(self):
while self._poll_validations() != 0 or self._kick_off_validations() != 0:
await asyncio.sleep(VALIDATION_TASK_POLL_INTERVAL_S)
checkpoint_updates: Dict[
Checkpoint, Tuple[Dict[str, Any], ReportedCheckpointStatus]
] = {}
tasks = list(self._finished_validations.keys())
for task in tasks:
checkpoint = self._finished_validations[task]
self._finished_validations.pop(task)
update = self._process_finished_validation(task, checkpoint)
if update is not None:
checkpoint_updates[checkpoint] = update
self._checkpoint_manager.update_checkpoints_with_validation_result(
checkpoint_updates
)
def before_controller_abort(self):
for task in self._pending_validations.keys():
ray.cancel(task)
def after_controller_state_update(
self,
previous_state: "TrainControllerState",
current_state: "TrainControllerState",
):
# TODO: figure out if there's a better place to poll validations
if current_state.is_terminal():
return
self._poll_validations()
self._kick_off_validations()
def before_init_train_context(
self, workers: List["Worker"]
) -> Dict[str, List[bool]]:
return {
"has_validation_fn": [True] * len(workers),
}