chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from ray.train.v2._internal.execution.training_report import _TrainingReport
|
||||
from ray.train.v2.api.callback import RayTrainCallback
|
||||
from ray.train.v2.api.config import ScalingConfig
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train.v2._internal.execution.context import TrainRunContext
|
||||
from ray.train.v2._internal.execution.controller import (
|
||||
TrainControllerState,
|
||||
)
|
||||
from ray.train.v2._internal.execution.failure_handling import FailureDecision
|
||||
from ray.train.v2._internal.execution.scaling_policy import ResizeDecision
|
||||
from ray.train.v2._internal.execution.worker_group import (
|
||||
ExecutionGroup,
|
||||
ReplicaGroup,
|
||||
Worker,
|
||||
WorkerGroup,
|
||||
WorkerGroupContext,
|
||||
WorkerGroupPollStatus,
|
||||
)
|
||||
from ray.train.v2.api.result import Result
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ExecutionGroupCallback(RayTrainCallback):
|
||||
"""Base callback for execution groups (worker groups and replica groups)."""
|
||||
|
||||
def before_init_train_context(
|
||||
self, workers: List["Worker"]
|
||||
) -> Dict[str, List[Any]]:
|
||||
"""Called before initializing the TrainContext for an execution group.
|
||||
|
||||
Return:
|
||||
A dictionary of additional arguments for TrainContext.
|
||||
The key is the argument name and the value is a list of argument values
|
||||
to pass to the TrainContext constructor of each worker in the group.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def after_execution_group_start(self, execution_group: "ExecutionGroup"):
|
||||
"""Called after an execution group is started or replaced.
|
||||
All workers in the execution group should be ready to execute tasks."""
|
||||
pass
|
||||
|
||||
def before_execution_group_shutdown(self, execution_group: "ExecutionGroup"):
|
||||
"""Called before an execution group is shut down.
|
||||
Workers may be dead at this point due to actor failures."""
|
||||
pass
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class WorkerGroupCallback(ExecutionGroupCallback):
|
||||
@contextmanager
|
||||
def on_worker_group_start(self):
|
||||
yield
|
||||
|
||||
def before_worker_group_start(self, worker_group_context: "WorkerGroupContext"):
|
||||
"""Called before the worker group actors are initialized."""
|
||||
pass
|
||||
|
||||
def after_worker_group_start(self, worker_group: "WorkerGroup"):
|
||||
"""Called after the worker group actors are initialized.
|
||||
All workers should be ready to execute tasks."""
|
||||
return self.after_execution_group_start(worker_group)
|
||||
|
||||
def after_worker_group_training_start(self, worker_group: "WorkerGroup"):
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def on_worker_group_shutdown(self):
|
||||
yield
|
||||
|
||||
def before_worker_group_shutdown(self, worker_group: "WorkerGroup"):
|
||||
"""Called before the worker group is shut down.
|
||||
Workers may be dead at this point due to actor failures, so this method
|
||||
should catch and handle exceptions if attempting to execute tasks."""
|
||||
return self.before_execution_group_shutdown(worker_group)
|
||||
|
||||
def after_worker_group_shutdown(self, worker_group_context: "WorkerGroupContext"):
|
||||
"""Called after the worker group is shut down."""
|
||||
pass
|
||||
|
||||
def after_worker_group_poll_status(
|
||||
self, worker_group_status: "WorkerGroupPollStatus"
|
||||
):
|
||||
pass
|
||||
|
||||
def before_worker_group_abort(self, worker_group_context: "WorkerGroupContext"):
|
||||
"""Called before the worker group is aborted."""
|
||||
pass
|
||||
|
||||
def after_worker_group_abort(self, worker_group_context: "WorkerGroupContext"):
|
||||
"""Called after the worker group is aborted."""
|
||||
pass
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ReplicaGroupCallback(ExecutionGroupCallback):
|
||||
"""Callback for replica group lifecycle events."""
|
||||
|
||||
def after_replica_group_start(self, replica_group: "ReplicaGroup"):
|
||||
"""Called after a replica group is started or replaced.
|
||||
All workers in the replica group should be ready to execute tasks."""
|
||||
return self.after_execution_group_start(replica_group)
|
||||
|
||||
def before_replica_group_shutdown(self, replica_group: "ReplicaGroup"):
|
||||
"""Called before a replica group is shut down.
|
||||
Workers may be dead at this point due to actor failures."""
|
||||
return self.before_execution_group_shutdown(replica_group)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ControllerCallback(RayTrainCallback):
|
||||
def after_controller_start(self, train_run_context: "TrainRunContext"):
|
||||
"""Called immediately after `TrainController.run` is called,
|
||||
before the control loop starts executing."""
|
||||
pass
|
||||
|
||||
# TODO(matthewdeng): Revisit this callback interface for better extensibility.
|
||||
# This hook was added for the specific use case of setting a `label_selector`
|
||||
# for new worker groups (e.g., for TPU reservations). The current interface is
|
||||
# tightly coupled to this purpose and limits its reuse for other use-cases.
|
||||
def on_controller_start_worker_group(
|
||||
self, *, scaling_config: ScalingConfig, num_workers: int
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Called by the TrainController before the worker group is started.
|
||||
|
||||
This hook can be used to perform setup that modifies the worker group's
|
||||
placement, such as reserving an accelerator slice.
|
||||
|
||||
Args:
|
||||
scaling_config: The scaling configuration for the run.
|
||||
num_workers: The number of workers to be started.
|
||||
|
||||
Returns:
|
||||
An optional dictionary defining a `label_selector`
|
||||
to gang schedule the worker group on the reserved TPU slice.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def before_controller_shutdown(self):
|
||||
"""Called before `TrainController.run` exits,
|
||||
after the control loop has exited."""
|
||||
pass
|
||||
|
||||
def after_controller_state_update(
|
||||
self,
|
||||
previous_state: "TrainControllerState",
|
||||
current_state: "TrainControllerState",
|
||||
):
|
||||
"""Called whenever the controller state is updated."""
|
||||
pass
|
||||
|
||||
def before_controller_execute_failure_decision(
|
||||
self,
|
||||
failure_decision: "FailureDecision",
|
||||
):
|
||||
"""Called before the controller executes a failure decision."""
|
||||
pass
|
||||
|
||||
def before_controller_execute_resize_decision(
|
||||
self,
|
||||
resize_decision: "ResizeDecision",
|
||||
):
|
||||
"""Called before the controller executes a resize decision."""
|
||||
pass
|
||||
|
||||
def after_controller_finish(self, result: "Result"):
|
||||
"""Called after the training run completes, providing access to the final result.
|
||||
|
||||
Args:
|
||||
result: The final training result containing metrics and checkpoint.
|
||||
"""
|
||||
pass
|
||||
|
||||
def before_controller_abort(self):
|
||||
"""Called during `TrainController.abort` before the actor process exits."""
|
||||
pass
|
||||
|
||||
|
||||
# TODO: consider consolidating all metrics into one dict, possibly with UDF
|
||||
@DeveloperAPI
|
||||
class ReportCallback(RayTrainCallback):
|
||||
def after_report(
|
||||
self,
|
||||
training_report: _TrainingReport,
|
||||
metrics: List[Dict[str, Any]],
|
||||
):
|
||||
"""Called after all workers have reported a training result.
|
||||
|
||||
Note that this differs from `after_worker_group_poll_status`,
|
||||
which may only contain a subset of workers that have reported.
|
||||
For example, if only rank 0 is performing checkpointing, then
|
||||
rank 0 would report a training result the slowest.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class WorkerCallback(RayTrainCallback):
|
||||
"""
|
||||
Callbacks that are hooked to the worker event.
|
||||
|
||||
These callbacks are created on the train driver process and then
|
||||
copied and passed to all the workers.
|
||||
The execution of these callbacks happens on each of the workers,
|
||||
not on the train driver process.
|
||||
"""
|
||||
|
||||
def after_init_train_context(self):
|
||||
pass
|
||||
|
||||
def before_worker_shutdown(self):
|
||||
pass
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class TrainContextCallback(RayTrainCallback):
|
||||
"""
|
||||
Callbacks that are hooked to the train context event.
|
||||
|
||||
These callbacks are created on the train driver process and then
|
||||
copied and passed to all the workers.
|
||||
The execution of these callbacks happens on the train context of the workers.
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def on_report(self):
|
||||
yield
|
||||
|
||||
@contextmanager
|
||||
def on_checkpoint_sync(self):
|
||||
yield
|
||||
|
||||
@contextmanager
|
||||
def on_checkpoint_transfer(self):
|
||||
yield
|
||||
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
|
||||
from ray.train.v2.api.exceptions import ControllerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallbackManager:
|
||||
def __init__(self, callbacks):
|
||||
self._callbacks = callbacks
|
||||
|
||||
def _get_method(self, callback, hook_name: str):
|
||||
"""Look up a hook method on a callback, raising if missing."""
|
||||
callback_name = type(callback).__name__
|
||||
method = getattr(callback, hook_name, None)
|
||||
if method is None or not callable(method):
|
||||
raise ControllerError(
|
||||
AttributeError(
|
||||
f"Callback '{callback_name}' hook '{hook_name}' is missing "
|
||||
"or not callable."
|
||||
)
|
||||
)
|
||||
return method, callback_name
|
||||
|
||||
def invoke(self, hook_name: str, *args, **context) -> None:
|
||||
for callback in self._callbacks:
|
||||
method, callback_name = self._get_method(callback, hook_name)
|
||||
try:
|
||||
method(*args, **context)
|
||||
except Exception as e:
|
||||
# TODO: Enable configuration to suppress exceptions.
|
||||
logger.exception(
|
||||
f"Exception raised in callback hook '{hook_name}' from callback "
|
||||
f"'{callback_name}'."
|
||||
)
|
||||
raise ControllerError(e) from e
|
||||
|
||||
async def async_invoke(self, hook_name: str, *args, **context) -> None:
|
||||
for callback in self._callbacks:
|
||||
method, callback_name = self._get_method(callback, hook_name)
|
||||
try:
|
||||
await method(*args, **context)
|
||||
except Exception as e:
|
||||
# TODO: Enable configuration to suppress exceptions.
|
||||
logger.exception(
|
||||
f"Exception raised in callback hook '{hook_name}' from callback "
|
||||
f"'{callback_name}'."
|
||||
)
|
||||
raise ControllerError(e) from e
|
||||
|
||||
def invoke_best_effort(self, hook_name: str, *args, **context) -> None:
|
||||
"""Invoke a hook on every callback, logging and suppressing errors.
|
||||
|
||||
Unlike ``invoke``, this does not fail fast — every callback is
|
||||
attempted even if earlier ones raise. Used for cleanup hooks
|
||||
(e.g. ``before_controller_abort``) where partial execution is
|
||||
better than skipping remaining callbacks.
|
||||
"""
|
||||
for callback in self._callbacks:
|
||||
method, callback_name = self._get_method(callback, hook_name)
|
||||
try:
|
||||
method(*args, **context)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error in callback hook '{hook_name}' from callback "
|
||||
f"'{callback_name}': {e}"
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import ray
|
||||
import ray.cloudpickle as pickle
|
||||
from ray.train.v2._internal.execution.context import get_train_context
|
||||
|
||||
# For reference, {1:1} is 19 bytes, {"1":"1"} is 21 bytes,
|
||||
# and {"12345": "12345"} is 25 bytes.
|
||||
_MAX_BROADCAST_SIZE_BYTES = 1000
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def barrier() -> None:
|
||||
"""
|
||||
Create a barrier across all training workers.
|
||||
"""
|
||||
train_context = get_train_context()
|
||||
sync_actor = train_context.get_synchronization_actor()
|
||||
return ray.get(
|
||||
sync_actor.broadcast_from_rank_zero.remote(
|
||||
world_rank=train_context.get_world_rank(),
|
||||
world_size=train_context.get_world_size(),
|
||||
data=None,
|
||||
caller_method_name="ray.train.collective.barrier",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def broadcast_from_rank_zero(data: Any) -> Any:
|
||||
"""Broadcast data from the rank 0 worker to all other workers.
|
||||
|
||||
This method is used by the public API function :func:`ray.train.collective.broadcast_from_rank_zero`.
|
||||
Users should typically call ``ray.train.collective.broadcast_from_rank_zero()`` instead of calling this method directly.
|
||||
"""
|
||||
# Validate data.
|
||||
if data is not None:
|
||||
data_bytes = len(pickle.dumps(data))
|
||||
if data_bytes > _MAX_BROADCAST_SIZE_BYTES:
|
||||
logger.warning(
|
||||
f"Data size {data_bytes} bytes exceeds the maximum broadcast "
|
||||
f"size of {_MAX_BROADCAST_SIZE_BYTES} bytes"
|
||||
)
|
||||
|
||||
train_context = get_train_context()
|
||||
sync_actor = train_context.get_synchronization_actor()
|
||||
return ray.get(
|
||||
sync_actor.broadcast_from_rank_zero.remote(
|
||||
world_rank=train_context.get_world_rank(),
|
||||
world_size=train_context.get_world_size(),
|
||||
data=data,
|
||||
caller_method_name="ray.train.collective.broadcast_from_rank_zero",
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,559 @@
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
import ray
|
||||
from ray._common.retry import retry
|
||||
from ray._common.utils import env_float
|
||||
from ray.actor import ActorHandle
|
||||
from ray.train.v2._internal.constants import (
|
||||
AWS_RETRYABLE_TOKENS,
|
||||
CHECKPOINT_UPLOAD_WARN_INTERVAL_S_ENV_VAR,
|
||||
DEFAULT_CHECKPOINT_UPLOAD_WARN_INTERVAL_S,
|
||||
)
|
||||
from ray.train.v2._internal.execution.checkpoint.sync_actor import (
|
||||
SynchronizationActor,
|
||||
SynchronizationBarrierResetError,
|
||||
)
|
||||
from ray.train.v2._internal.execution.preemption import PreemptionContext
|
||||
from ray.train.v2._internal.execution.storage import StorageContext, delete_fs_path
|
||||
from ray.train.v2._internal.execution.training_report import (
|
||||
_TrainingReport,
|
||||
)
|
||||
from ray.train.v2._internal.util import (
|
||||
construct_user_exception_with_traceback,
|
||||
context_watchdog,
|
||||
invoke_context_managers,
|
||||
)
|
||||
from ray.train.v2.api.config import RunConfig, ScalingConfig
|
||||
from ray.train.v2.api.report_config import (
|
||||
CheckpointConsistencyMode,
|
||||
CheckpointUploadMode,
|
||||
)
|
||||
from ray.train.v2.api.validation_config import ValidationTaskConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data import DataIterator
|
||||
from ray.train import BackendConfig, Checkpoint, DataConfig
|
||||
from ray.train.v2._internal.data_integration.interfaces import (
|
||||
DatasetShardMetadata,
|
||||
DatasetShardProvider,
|
||||
)
|
||||
from ray.train.v2._internal.execution.callback import TrainContextCallback
|
||||
from ray.train.v2._internal.execution.worker_group.thread_runner import ThreadRunner
|
||||
from ray.train.v2.api.reported_checkpoint import ReportedCheckpoint
|
||||
|
||||
|
||||
logger = logging.getLogger(__file__)
|
||||
|
||||
|
||||
# TODO: make this value manually or automatically configurable.
|
||||
MAX_CHECKPOINT_UPLOAD_THREADS = 1
|
||||
|
||||
DEFAULT_CHECKPOINT_UPLOAD_WARN_MESSAGE = "Checkpoint upload for {checkpoint_dir_name} has been running for {elapsed}s (warning interval: {interval}s). This may indicate a network issue or slow storage backend. Consider specifying a different filesystem via RunConfig(storage_filesystem=...)."
|
||||
CUSTOM_CHECKPOINT_UPLOAD_WARN_MESSAGE = "Custom checkpoint upload for {checkpoint_dir_name} has been running for {elapsed}s (warning interval: {interval}s). This may indicate an issue in your custom upload function passed to `ray.train.report(custom_upload_fn)`."
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrainRunContext:
|
||||
"""Holds the metadata and context for the current training run."""
|
||||
|
||||
# The unique ID of the training run.
|
||||
run_id: str = field(init=False, default_factory=lambda: uuid.uuid4().hex)
|
||||
|
||||
# The run configuration for the current training run.
|
||||
run_config: RunConfig
|
||||
|
||||
# The configuration passed to the training function.
|
||||
train_loop_config: Optional[Dict]
|
||||
|
||||
# The scaling configuration for the current training run.
|
||||
scaling_config: ScalingConfig
|
||||
|
||||
# The configuration for the training backend (e.g., PyTorch, XGBoost).
|
||||
backend_config: "BackendConfig"
|
||||
|
||||
# The configuration for dataset ingestion and sharding.
|
||||
dataset_config: "DataConfig"
|
||||
|
||||
def get_run_config(self) -> RunConfig:
|
||||
"""Returns the run config of the current training run."""
|
||||
return self.run_config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DistributedContext:
|
||||
world_rank: int
|
||||
world_size: int
|
||||
local_rank: int
|
||||
local_world_size: int
|
||||
node_rank: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionContext:
|
||||
"""Holds the execution context for the current worker process.
|
||||
|
||||
Every worker process has a single execution context accessed via the
|
||||
`TrainContext`, which includes the training thread that is actually
|
||||
running the user code.
|
||||
"""
|
||||
|
||||
# A shared synchronization actor that helps broadcast data across ranks.
|
||||
synchronization_actor: SynchronizationActor
|
||||
|
||||
# A queue that receives training results from the user training code.
|
||||
# `ray.train.report` in user code populates this queue.
|
||||
result_queue: Queue
|
||||
|
||||
# The thread launcher that runs the user training loop.
|
||||
training_thread_runner: "ThreadRunner"
|
||||
|
||||
# The callbacks that are run in the worker train context.
|
||||
train_context_callbacks: List["TrainContextCallback"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainContext:
|
||||
train_run_context: TrainRunContext
|
||||
distributed_context: DistributedContext
|
||||
execution_context: ExecutionContext
|
||||
storage_context: StorageContext
|
||||
preemption_context: PreemptionContext
|
||||
controller_actor: ActorHandle
|
||||
|
||||
dataset_shard_provider: "DatasetShardProvider"
|
||||
has_validation_fn: Optional[bool] = None
|
||||
|
||||
# TODO: consolidate into CheckpointContext
|
||||
checkpoint: Optional["Checkpoint"] = None
|
||||
current_report_index: int = 0
|
||||
report_call_index: int = 0
|
||||
report_order_condition: threading.Condition = threading.Condition()
|
||||
checkpoint_upload_threadpool: ThreadPoolExecutor = ThreadPoolExecutor(
|
||||
max_workers=MAX_CHECKPOINT_UPLOAD_THREADS
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
# Ray train initializes worker with current report index
|
||||
# report_call_index should start at the current report index
|
||||
self.report_call_index = self.current_report_index
|
||||
|
||||
def get_experiment_name(self) -> str:
|
||||
return self.train_run_context.run_config.name
|
||||
|
||||
def get_world_size(self) -> int:
|
||||
return self.distributed_context.world_size
|
||||
|
||||
def get_world_rank(self) -> int:
|
||||
return self.distributed_context.world_rank
|
||||
|
||||
def get_local_rank(self) -> int:
|
||||
return self.distributed_context.local_rank
|
||||
|
||||
def get_local_world_size(self) -> int:
|
||||
return self.distributed_context.local_world_size
|
||||
|
||||
def get_node_rank(self) -> int:
|
||||
return self.distributed_context.node_rank
|
||||
|
||||
def get_storage(self):
|
||||
return self.storage_context
|
||||
|
||||
# TODO: Don't allow these private methods to be called from user code.
|
||||
def get_result_queue(self):
|
||||
return self.execution_context.result_queue
|
||||
|
||||
def get_synchronization_actor(self):
|
||||
return self.execution_context.synchronization_actor
|
||||
|
||||
def get_checkpoint(self):
|
||||
with self.report_order_condition:
|
||||
return self.checkpoint
|
||||
|
||||
def get_all_reported_checkpoints(
|
||||
self,
|
||||
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
|
||||
timeout_s: Optional[float] = None,
|
||||
) -> List["ReportedCheckpoint"]:
|
||||
return ray.get(
|
||||
self.controller_actor.get_all_reported_checkpoints.remote(
|
||||
self.report_call_index,
|
||||
consistency_mode,
|
||||
timeout_s,
|
||||
)
|
||||
)
|
||||
|
||||
def get_dataset_shard(self, dataset_info: "DatasetShardMetadata") -> "DataIterator":
|
||||
"""Returns the :class:`ray.data.DataIterator` shard for this worker.
|
||||
|
||||
Call :meth:`~ray.data.DataIterator.iter_torch_batches` or
|
||||
:meth:`~ray.data.DataIterator.to_tf` on this shard to convert it to the
|
||||
appropriate framework-specific data type.
|
||||
|
||||
Args:
|
||||
dataset_info: The shard metadata, including the dataset name and worker rank.
|
||||
Returns:
|
||||
The ``DataIterator`` shard with the given name for this worker.
|
||||
Raises:
|
||||
KeyError: If the dataset shard with the given name is not found.
|
||||
"""
|
||||
return self.dataset_shard_provider.get_dataset_shard(dataset_info)
|
||||
|
||||
def get_context_callbacks(self) -> List["TrainContextCallback"]:
|
||||
return self.execution_context.train_context_callbacks
|
||||
|
||||
def _sync_checkpoint_dir_name_across_ranks(
|
||||
self, checkpoint_dir_name: Optional[str] = None
|
||||
) -> str:
|
||||
"""Sync the checkpoint dir name across ranks.
|
||||
|
||||
Args:
|
||||
checkpoint_dir_name: The checkpoint dir name to sync.
|
||||
|
||||
Returns:
|
||||
The synced checkpoint dir name.
|
||||
"""
|
||||
# If checkpoint_dir_name is not set, use default checkpoint_dir_name
|
||||
# created by the storage context.
|
||||
checkpoint_dir_name = (
|
||||
checkpoint_dir_name
|
||||
or self.storage_context.make_default_checkpoint_dir_name()
|
||||
)
|
||||
# Get a consensus across ranks on the remote storage path, so distributed
|
||||
# checkpoints will be stored to the same place.
|
||||
sync_actor = self.get_synchronization_actor()
|
||||
with invoke_context_managers(
|
||||
[
|
||||
callback.on_checkpoint_sync
|
||||
for callback in self.execution_context.train_context_callbacks
|
||||
]
|
||||
):
|
||||
return ray.get(
|
||||
sync_actor.broadcast_from_rank_zero.remote(
|
||||
world_rank=self.distributed_context.world_rank,
|
||||
world_size=self.distributed_context.world_size,
|
||||
data=checkpoint_dir_name,
|
||||
caller_method_name="ray.train.report",
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: make retry configurable
|
||||
@retry(description="upload checkpoint", max_attempts=3, match=AWS_RETRYABLE_TOKENS)
|
||||
def _upload_checkpoint(
|
||||
self,
|
||||
checkpoint_dir_name: str,
|
||||
metrics: Dict[str, Any],
|
||||
checkpoint: Optional["Checkpoint"] = None,
|
||||
delete_local_checkpoint_after_upload: bool = False,
|
||||
checkpoint_upload_fn: Optional[
|
||||
Callable[["Checkpoint", str], "Checkpoint"]
|
||||
] = None,
|
||||
validation: Union[bool, ValidationTaskConfig] = False,
|
||||
) -> _TrainingReport:
|
||||
"""Save the checkpoint to remote storage.
|
||||
|
||||
Args:
|
||||
checkpoint_dir_name: The checkpoint dir to persist to.
|
||||
metrics: The metrics to report.
|
||||
checkpoint: The checkpoint to report.
|
||||
delete_local_checkpoint_after_upload: Whether to delete the checkpoint after it is uploaded.
|
||||
checkpoint_upload_fn: A user defined function that will be called with the
|
||||
checkpoint to upload it. If not provided, defaults to using the `pyarrow.fs.copy_files`
|
||||
utility for copying to the destination `storage_path`.
|
||||
validation: The validation configuration.
|
||||
|
||||
Returns:
|
||||
The training result object containing the persisted checkpoint.
|
||||
"""
|
||||
|
||||
if not checkpoint:
|
||||
return _TrainingReport(checkpoint=None, metrics=metrics, validation=False)
|
||||
|
||||
def slow_upload_warning(stop_event: threading.Event, message: str):
|
||||
# Log a warning for the checkpoint upload every `CHECKPOINT_UPLOAD_WARN_INTERVAL_S_ENV_VAR`
|
||||
# seconds until `stop_event` is set.
|
||||
elapsed = 0.0
|
||||
interval = env_float(
|
||||
CHECKPOINT_UPLOAD_WARN_INTERVAL_S_ENV_VAR,
|
||||
DEFAULT_CHECKPOINT_UPLOAD_WARN_INTERVAL_S,
|
||||
)
|
||||
while not stop_event.wait(interval):
|
||||
elapsed += interval
|
||||
logger.warning(
|
||||
message.format(
|
||||
checkpoint_dir_name=checkpoint_dir_name,
|
||||
elapsed=elapsed,
|
||||
interval=interval,
|
||||
)
|
||||
)
|
||||
|
||||
# Records how long the checkpoint transfer took
|
||||
warn_message = (
|
||||
CUSTOM_CHECKPOINT_UPLOAD_WARN_MESSAGE
|
||||
if checkpoint_upload_fn
|
||||
else DEFAULT_CHECKPOINT_UPLOAD_WARN_MESSAGE
|
||||
)
|
||||
with invoke_context_managers(
|
||||
[
|
||||
callback.on_checkpoint_transfer
|
||||
for callback in self.execution_context.train_context_callbacks
|
||||
]
|
||||
):
|
||||
try:
|
||||
with context_watchdog(slow_upload_warning, warn_message):
|
||||
if checkpoint_upload_fn:
|
||||
# Upload the checkpoint using the custom checkpoint_upload_fn
|
||||
persisted_checkpoint = checkpoint_upload_fn(
|
||||
checkpoint, checkpoint_dir_name
|
||||
)
|
||||
else:
|
||||
# Upload the checkpoint using PyArrow
|
||||
persisted_checkpoint = (
|
||||
self.storage_context.persist_current_checkpoint(
|
||||
checkpoint, checkpoint_dir_name
|
||||
)
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.exception(
|
||||
f"Failed to find local checkpoint ({checkpoint}) when attempting to upload it. "
|
||||
"This could be caused by multiple workers on a node attempting to upload the "
|
||||
"same directory, and then one of the workers deletes the directory before the "
|
||||
"others finish."
|
||||
)
|
||||
raise
|
||||
|
||||
# Check that the checkpoint generated is a `ray.train.Checkpoint` instance
|
||||
if checkpoint_upload_fn and not isinstance(
|
||||
persisted_checkpoint, ray.train.Checkpoint
|
||||
):
|
||||
raise ValueError(
|
||||
f"checkpoint_upload_fn must return a `ray.train.Checkpoint`. Actual type is {type(persisted_checkpoint)}"
|
||||
)
|
||||
|
||||
# TODO: consider deleting local checkpoint as async callback instead
|
||||
if delete_local_checkpoint_after_upload:
|
||||
try:
|
||||
delete_fs_path(checkpoint.filesystem, checkpoint.path)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to delete the local checkpoint after a successful upload: {checkpoint}"
|
||||
)
|
||||
|
||||
return _TrainingReport(
|
||||
checkpoint=persisted_checkpoint,
|
||||
metrics=metrics,
|
||||
validation=validation,
|
||||
)
|
||||
|
||||
def _wait_then_report(
|
||||
self, training_report: _TrainingReport, report_call_index: int
|
||||
):
|
||||
"""Thread waits for its turn before reporting training result to result queue.
|
||||
|
||||
It does this in order to guarantee the FIFO processing of checkpoints.
|
||||
|
||||
The queue size is set to 1 to avoid accumulating unprocessed results.
|
||||
If the queue is full, the put operation blocks until a result is consumed.
|
||||
|
||||
TODO: Add a metric to track the blocking time waiting for the
|
||||
training result to be consumed by the controller.
|
||||
"""
|
||||
with self.report_order_condition:
|
||||
self.report_order_condition.wait_for(
|
||||
lambda: self.current_report_index == report_call_index - 1
|
||||
)
|
||||
logger.info(
|
||||
f"Reporting training result {report_call_index}: {training_report} "
|
||||
f"from rank {self.get_world_rank()}"
|
||||
)
|
||||
# Update latest checkpoint as the persisted checkpoint.
|
||||
if training_report.checkpoint:
|
||||
self.checkpoint = training_report.checkpoint
|
||||
self.get_result_queue().put(training_report)
|
||||
self.current_report_index += 1
|
||||
self.report_order_condition.notify_all()
|
||||
|
||||
def report(
|
||||
self,
|
||||
metrics: Dict[str, Any],
|
||||
checkpoint: Optional["Checkpoint"] = None,
|
||||
checkpoint_dir_name: Optional[str] = None,
|
||||
checkpoint_upload_mode: CheckpointUploadMode = CheckpointUploadMode.SYNC,
|
||||
delete_local_checkpoint_after_upload: Optional[bool] = None,
|
||||
checkpoint_upload_fn: Optional[
|
||||
Callable[["Checkpoint", str], "Checkpoint"]
|
||||
] = None,
|
||||
validation: Union[bool, ValidationTaskConfig] = False,
|
||||
) -> None:
|
||||
"""
|
||||
Upload checkpoint to remote storage and put a training
|
||||
result on the result queue of this worker process.
|
||||
|
||||
TODO: the report function should be implemented in the worker instead
|
||||
of in the train context. The train context should only keep the train
|
||||
related information and not the worker related actions. This refactor
|
||||
would also require the `TrainContextCallback` to be updated as well.
|
||||
"""
|
||||
if "torch" in sys.modules:
|
||||
from ray.air._internal.torch_utils import contains_tensor
|
||||
|
||||
if contains_tensor(metrics):
|
||||
raise ValueError(
|
||||
"Passing objects containing Torch tensors as metrics "
|
||||
"is not supported as it will throw an exception on "
|
||||
"deserialization. You can either convert the tensors "
|
||||
"to Python objects (ex: `.numpy()`, `.item()`, etc.) "
|
||||
"or save tensors as part of the checkpoint files instead."
|
||||
)
|
||||
|
||||
if validation and not self.has_validation_fn:
|
||||
raise ValueError(
|
||||
"`validation_config` was not set on the trainer, but a validation was requested."
|
||||
)
|
||||
|
||||
if delete_local_checkpoint_after_upload and checkpoint is not None:
|
||||
experiment_path = Path(self.storage_context.experiment_fs_path)
|
||||
checkpoint_path = Path(checkpoint.path)
|
||||
|
||||
# Resolve symlinks only for local (absolute) paths.
|
||||
# Remote paths (S3, GCS, etc.) are relative after URI and resolve()
|
||||
# would prepend CWD, producing a meaningless local path.
|
||||
# Mixed absolute/relative paths return False
|
||||
if experiment_path.is_absolute():
|
||||
experiment_path = experiment_path.resolve()
|
||||
if checkpoint_path.is_absolute():
|
||||
checkpoint_path = checkpoint_path.resolve()
|
||||
|
||||
if experiment_path.is_relative_to(checkpoint_path):
|
||||
raise ValueError(
|
||||
f"Ray Train's experiment directory ({self.storage_context.experiment_fs_path}) "
|
||||
f"is contained within the checkpoint path ({checkpoint.path}) "
|
||||
f"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 temporary directory, "
|
||||
"a subdirectory of the experiment directory, "
|
||||
"or use `delete_local_checkpoint_after_upload=False`."
|
||||
)
|
||||
|
||||
with invoke_context_managers(
|
||||
[
|
||||
callback.on_report
|
||||
for callback in self.execution_context.train_context_callbacks
|
||||
]
|
||||
):
|
||||
self.report_call_index += 1
|
||||
report_call_index = self.report_call_index
|
||||
|
||||
# Sync the checkpoint dir name across ranks.
|
||||
try:
|
||||
checkpoint_dir_name = self._sync_checkpoint_dir_name_across_ranks(
|
||||
checkpoint_dir_name
|
||||
)
|
||||
except ray.exceptions.RayTaskError as e:
|
||||
if not isinstance(e.cause, SynchronizationBarrierResetError):
|
||||
raise e
|
||||
logger.warning(
|
||||
"Synchronization barrier was reset (likely due to a "
|
||||
"worker failure). Skipping this report."
|
||||
)
|
||||
# Keep report indexes aligned across workers.
|
||||
self.report_call_index -= 1
|
||||
return
|
||||
|
||||
# Upload checkpoint, wait for turn, and report.
|
||||
if checkpoint_upload_mode == CheckpointUploadMode.SYNC:
|
||||
training_report = self._upload_checkpoint(
|
||||
checkpoint_dir_name,
|
||||
metrics,
|
||||
checkpoint,
|
||||
delete_local_checkpoint_after_upload,
|
||||
checkpoint_upload_fn,
|
||||
validation,
|
||||
)
|
||||
self._wait_then_report(training_report, report_call_index)
|
||||
|
||||
elif checkpoint_upload_mode == CheckpointUploadMode.NO_UPLOAD:
|
||||
training_report = _TrainingReport(
|
||||
checkpoint=checkpoint,
|
||||
metrics=metrics,
|
||||
validation=validation,
|
||||
)
|
||||
self._wait_then_report(training_report, report_call_index)
|
||||
|
||||
elif checkpoint_upload_mode == CheckpointUploadMode.ASYNC:
|
||||
|
||||
def _upload_checkpoint_and_report(
|
||||
checkpoint_dir_name: str,
|
||||
metrics: Dict[str, Any],
|
||||
checkpoint: Optional["Checkpoint"],
|
||||
report_call_index: int,
|
||||
) -> None:
|
||||
try:
|
||||
training_report = self._upload_checkpoint(
|
||||
checkpoint_dir_name,
|
||||
metrics,
|
||||
checkpoint,
|
||||
delete_local_checkpoint_after_upload,
|
||||
checkpoint_upload_fn,
|
||||
validation,
|
||||
)
|
||||
self._wait_then_report(training_report, report_call_index)
|
||||
except Exception as e:
|
||||
# TODO: env var to disable eager raising
|
||||
logger.exception(
|
||||
"Checkpoint upload failed in the background thread. Raising eagerly "
|
||||
"to avoid training in a corrupted state with more potential progress "
|
||||
"lost due to checkpointing failures."
|
||||
)
|
||||
self.execution_context.training_thread_runner.get_exception_queue().put(
|
||||
construct_user_exception_with_traceback(e)
|
||||
)
|
||||
|
||||
self.checkpoint_upload_threadpool.submit(
|
||||
_upload_checkpoint_and_report,
|
||||
checkpoint_dir_name,
|
||||
metrics,
|
||||
checkpoint,
|
||||
report_call_index,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid checkpoint upload mode: {checkpoint_upload_mode}"
|
||||
)
|
||||
|
||||
|
||||
# The global variable holding the current TrainContext
|
||||
_train_context: Optional[TrainContext] = None
|
||||
|
||||
# Thread lock to protect the global TrainContext
|
||||
_context_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_train_context() -> TrainContext:
|
||||
"""Get the internal train context.
|
||||
|
||||
Note:
|
||||
This should not be used directly by user-facing APIs. User-facing APIs should
|
||||
call :class:`~ray.train.v2._internal.execution.train_fn_utils.TrainFnUtils`
|
||||
or use :class:`~ray.train.v2.api.context.TrainContext` instead.
|
||||
|
||||
Returns:
|
||||
The internal TrainContext for this worker.
|
||||
"""
|
||||
with _context_lock:
|
||||
if _train_context is None:
|
||||
raise RuntimeError("TrainContext has not been initialized.")
|
||||
return _train_context
|
||||
|
||||
|
||||
def set_train_context(context) -> None:
|
||||
global _train_context
|
||||
with _context_lock:
|
||||
_train_context = context
|
||||
@@ -0,0 +1,3 @@
|
||||
from .controller import TrainController
|
||||
|
||||
__all__ = ["TrainController"]
|
||||
@@ -0,0 +1,865 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Callable, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
import ray
|
||||
import ray._private.ray_constants as ray_constants
|
||||
from ray.exceptions import AsyncioActorExit
|
||||
from ray.train.v2._internal.constants import (
|
||||
DEFAULT_ENABLE_CONTROLLER_LOGGING,
|
||||
DEFAULT_ENABLE_PREEMPTION_WATCHER,
|
||||
DEFAULT_HEALTH_CHECK_INTERVAL_S,
|
||||
ENABLE_CONTROLLER_STRUCTURED_LOGGING_ENV_VAR,
|
||||
ENABLE_PREEMPTION_WATCHER_ENV_VAR,
|
||||
HEALTH_CHECK_INTERVAL_S_ENV_VAR,
|
||||
)
|
||||
from ray.train.v2._internal.execution.callback import (
|
||||
ControllerCallback,
|
||||
ReportCallback,
|
||||
TrainContextCallback,
|
||||
WorkerCallback,
|
||||
WorkerGroupCallback,
|
||||
)
|
||||
from ray.train.v2._internal.execution.callback_manager import CallbackManager
|
||||
from ray.train.v2._internal.execution.checkpoint.checkpoint_manager import (
|
||||
CheckpointManager,
|
||||
)
|
||||
from ray.train.v2._internal.execution.checkpoint.report_handler import (
|
||||
ReportCallbackHandler,
|
||||
)
|
||||
from ray.train.v2._internal.execution.checkpoint.validation_manager import (
|
||||
ValidationManager,
|
||||
)
|
||||
from ray.train.v2._internal.execution.context import TrainRunContext
|
||||
from ray.train.v2._internal.execution.controller.state import (
|
||||
AbortedState,
|
||||
ErroredState,
|
||||
FinishedState,
|
||||
InitializingState,
|
||||
ReschedulingState,
|
||||
ResizingState,
|
||||
RestartingState,
|
||||
RunningState,
|
||||
SchedulingState,
|
||||
ShuttingDownState,
|
||||
TrainControllerState,
|
||||
)
|
||||
from ray.train.v2._internal.execution.failure_handling import (
|
||||
FailureDecision,
|
||||
FailurePolicy,
|
||||
)
|
||||
from ray.train.v2._internal.execution.scaling_policy import (
|
||||
NoopDecision,
|
||||
ResizeDecision,
|
||||
ScalingPolicy,
|
||||
)
|
||||
from ray.train.v2._internal.execution.worker_group import (
|
||||
WorkerGroup,
|
||||
WorkerGroupContext,
|
||||
WorkerGroupPollStatus,
|
||||
)
|
||||
from ray.train.v2._internal.logging import LoggingManager
|
||||
from ray.train.v2._internal.util import ObjectRefWrapper, time_monotonic
|
||||
from ray.train.v2.api.callback import RayTrainCallback
|
||||
from ray.train.v2.api.exceptions import (
|
||||
ControllerError,
|
||||
TrainingFailedError,
|
||||
)
|
||||
from ray.train.v2.api.report_config import CheckpointConsistencyMode
|
||||
from ray.train.v2.api.result import Result
|
||||
from ray.train.v2.api.validation_config import ValidationConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train.v2.api.reported_checkpoint import ReportedCheckpoint
|
||||
|
||||
from ray.util.tpu import get_tpu_num_slices_for_workers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainControllerLoopIterationResult:
|
||||
"""The result of a single iteration of the control loop."""
|
||||
|
||||
run_attempt_id: str
|
||||
previous_state: TrainControllerState
|
||||
next_state: TrainControllerState
|
||||
training_failed_error: Optional[TrainingFailedError] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"TrainControllerLoopIterationResult(\n"
|
||||
f" run_attempt_id={self.run_attempt_id},\n"
|
||||
f" previous_state={self.previous_state._state_type.state_name},\n"
|
||||
f" next_state={self.next_state._state_type.state_name}\n"
|
||||
f" training_failed_error={self.training_failed_error}\n"
|
||||
f")"
|
||||
)
|
||||
|
||||
|
||||
class TrainController:
|
||||
"""Manages the execution of a distributed training job.
|
||||
|
||||
Responsibilities include:
|
||||
* Triggering the training function to run on the worker group.
|
||||
* Monitoring the status of the worker group.
|
||||
* Handling scaling decisions by restarting the worker group.
|
||||
* Handling failure decisions by restarting the worker group or terminating training.
|
||||
* Running callback logic on different hooks in the control loop.
|
||||
"""
|
||||
|
||||
worker_group_cls = WorkerGroup
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
train_fn_ref: ObjectRefWrapper[Callable[[], None]],
|
||||
train_run_context: TrainRunContext,
|
||||
scaling_policy: ScalingPolicy,
|
||||
failure_policy: FailurePolicy,
|
||||
callbacks: Optional[List[RayTrainCallback]] = None,
|
||||
validation_config: Optional[ValidationConfig] = None,
|
||||
):
|
||||
self._train_run_context = train_run_context
|
||||
if ray_constants.env_bool(
|
||||
ENABLE_CONTROLLER_STRUCTURED_LOGGING_ENV_VAR,
|
||||
DEFAULT_ENABLE_CONTROLLER_LOGGING,
|
||||
):
|
||||
LoggingManager.configure_controller_logger(self._train_run_context)
|
||||
self._train_fn_ref = train_fn_ref
|
||||
self._scaling_policy = scaling_policy
|
||||
self._failure_policy = failure_policy
|
||||
self._run_config = self._train_run_context.run_config
|
||||
self._callbacks = callbacks or []
|
||||
self._storage_context = self._train_run_context.run_config.storage_context
|
||||
|
||||
self._checkpoint_manager = CheckpointManager(
|
||||
checkpoint_config=self._run_config.checkpoint_config,
|
||||
storage_context=self._storage_context,
|
||||
)
|
||||
if validation_config:
|
||||
validation_manager = ValidationManager(
|
||||
checkpoint_manager=self._checkpoint_manager,
|
||||
validation_config=validation_config,
|
||||
)
|
||||
else:
|
||||
validation_manager = None
|
||||
report_handler = ReportCallbackHandler(
|
||||
report_callbacks=(
|
||||
[self._checkpoint_manager]
|
||||
+ ([validation_manager] if validation_manager else [])
|
||||
+ [c for c in self._callbacks if isinstance(c, ReportCallback)]
|
||||
)
|
||||
)
|
||||
|
||||
# Group callbacks by the hooks they're subscribed to.
|
||||
self._controller_callbacks = (
|
||||
[
|
||||
self._scaling_policy,
|
||||
]
|
||||
+ ([validation_manager] if validation_manager else [])
|
||||
+ [c for c in self._callbacks if isinstance(c, ControllerCallback)]
|
||||
)
|
||||
self._controller_callback_manager = CallbackManager(self._controller_callbacks)
|
||||
# Group callbacks that will be propagated to the worker group,
|
||||
# train worker and the train context.
|
||||
self._worker_group_callbacks_to_propagate = (
|
||||
[report_handler]
|
||||
+ ([validation_manager] if validation_manager else [])
|
||||
+ [
|
||||
c
|
||||
for c in self._callbacks
|
||||
if isinstance(
|
||||
c, (WorkerGroupCallback, WorkerCallback, TrainContextCallback)
|
||||
)
|
||||
]
|
||||
+ [self._checkpoint_manager]
|
||||
)
|
||||
|
||||
self._health_check_interval_s = float(
|
||||
os.getenv(HEALTH_CHECK_INTERVAL_S_ENV_VAR, DEFAULT_HEALTH_CHECK_INTERVAL_S)
|
||||
)
|
||||
|
||||
self._manages_replica_groups = (
|
||||
train_run_context.backend_config.backend_cls.has_replica_groups
|
||||
if train_run_context.backend_config
|
||||
else False
|
||||
)
|
||||
|
||||
# Register the preemption-observability callback when not in TorchFT
|
||||
# mode (replica groups handle peer loss via their own quorum).
|
||||
enable_preemption_watcher = ray_constants.env_bool(
|
||||
ENABLE_PREEMPTION_WATCHER_ENV_VAR,
|
||||
DEFAULT_ENABLE_PREEMPTION_WATCHER,
|
||||
)
|
||||
if self._manages_replica_groups:
|
||||
if enable_preemption_watcher and ray_constants.env_set_by_user(
|
||||
ENABLE_PREEMPTION_WATCHER_ENV_VAR
|
||||
):
|
||||
logger.info(
|
||||
"The preemption watcher is not compatible with replica "
|
||||
"groups (e.g. TorchFT), which handle peer loss via their "
|
||||
"own quorum; skipping it."
|
||||
)
|
||||
elif enable_preemption_watcher:
|
||||
from ray.train.v2._internal.callbacks.preemption_callback import (
|
||||
PreemptionCallback,
|
||||
)
|
||||
|
||||
self._worker_group_callbacks_to_propagate.append(PreemptionCallback())
|
||||
|
||||
self._worker_group: Optional[WorkerGroup] = None
|
||||
self._state = InitializingState()
|
||||
self._return_value: Optional[Any] = None
|
||||
|
||||
# TODO: These can be attributes of a RunAttempt?
|
||||
self._latest_poll_time = float("-inf")
|
||||
|
||||
# Generate an initial run attempt ID so that `_run_controller_hook`
|
||||
# can reference it if a callback fails during `_start`.
|
||||
self._generate_run_attempt_id()
|
||||
self._start()
|
||||
|
||||
def _run_controller_hook(
|
||||
self,
|
||||
hook_name: str,
|
||||
*args,
|
||||
invoke_failure_decision_callbacks: bool = True,
|
||||
**context,
|
||||
) -> Optional["TrainControllerLoopIterationResult"]:
|
||||
"""Invoke a named controller hook and catch any exceptions.
|
||||
|
||||
This method invokes all callbacks registered for the given controller hook.
|
||||
If a callback raises an error, the error is routed through the failure policy
|
||||
and may produce a ``TrainControllerLoopIterationResult``, indicating that the
|
||||
current controller step should exit early with this failure result.
|
||||
|
||||
Args:
|
||||
hook_name: The controller hook name to invoke.
|
||||
*args: Positional arguments to pass to the hook.
|
||||
invoke_failure_decision_callbacks: Whether to invoke failure-decision hooks
|
||||
when handling a callback failure.
|
||||
**context: Keyword arguments to pass to the hook.
|
||||
|
||||
Returns:
|
||||
failure_result: A``TrainControllerLoopIterationResult`` if the hook execution results
|
||||
in an early exit from the controller loop to raise the callback error,
|
||||
or ``None`` if hook execution completes successfully.
|
||||
"""
|
||||
try:
|
||||
self._controller_callback_manager.invoke(hook_name, *args, **context)
|
||||
except ControllerError as error:
|
||||
failure_decision = self._failure_policy.make_decision(
|
||||
training_failed_error=error,
|
||||
)
|
||||
# Avoid re-entering controller callback hooks while handling a callback failure.
|
||||
return self._execute_failure_decision(
|
||||
failure_decision,
|
||||
training_failed_error=error,
|
||||
invoke_failure_decision_callbacks=invoke_failure_decision_callbacks,
|
||||
)
|
||||
return None
|
||||
|
||||
def _execute_resize_decision(
|
||||
self, decision: ResizeDecision
|
||||
) -> TrainControllerLoopIterationResult:
|
||||
"""Executes resize decisions.
|
||||
|
||||
Errors from worker group shutdown, callbacks, or worker group startup
|
||||
are allowed to propagate to the catch-all in ``run()``.
|
||||
"""
|
||||
|
||||
failure_result = self._run_controller_hook(
|
||||
"before_controller_execute_resize_decision", decision
|
||||
)
|
||||
if failure_result:
|
||||
return failure_result
|
||||
current_num_workers = (
|
||||
len(self._worker_group.get_workers()) if self._worker_group else 0
|
||||
)
|
||||
poll_status = (
|
||||
self._worker_group.get_latest_poll_status() if self._worker_group else None
|
||||
)
|
||||
failing_rgs = (
|
||||
poll_status.failing_replica_group_indices if poll_status else set()
|
||||
)
|
||||
all_rgs = poll_status.all_replica_group_indices if poll_status else set()
|
||||
if (
|
||||
self._manages_replica_groups
|
||||
and bool(failing_rgs)
|
||||
and failing_rgs != all_rgs
|
||||
and self._worker_group
|
||||
# TODO: relax this after integrating replica groups with elastic training.
|
||||
and decision.num_workers == current_num_workers
|
||||
):
|
||||
# Torchft: replace only failing replica groups.
|
||||
self._replace_bad_workers(poll_status)
|
||||
else:
|
||||
# Standard: full restart.
|
||||
if self._worker_group:
|
||||
self._shutdown_worker_group()
|
||||
self._start_worker_group(
|
||||
num_workers=decision.num_workers,
|
||||
resources_per_worker=decision.resources_per_worker,
|
||||
)
|
||||
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=self._state,
|
||||
next_state=RunningState(),
|
||||
)
|
||||
|
||||
def _replace_bad_workers(self, poll_status: WorkerGroupPollStatus):
|
||||
"""Replace failing replica groups in the worker group.
|
||||
|
||||
Args:
|
||||
poll_status: The poll status containing error information.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
failing_rg_indices = poll_status.failing_replica_group_indices
|
||||
|
||||
if not failing_rg_indices:
|
||||
logger.warning("No failing replica groups found in poll status.")
|
||||
return
|
||||
|
||||
logger.info(f"Replacing failing replica groups: {failing_rg_indices}")
|
||||
|
||||
for rg_index in failing_rg_indices:
|
||||
# TODO: parallelize this.
|
||||
# TODO: also ensure that if earlier replacements succeed and later replacements fail,
|
||||
# we don't redo the earlier replacements.
|
||||
# See https://github.com/ray-project/ray/pull/61475#discussion_r3055217289
|
||||
self._worker_group.replace_replica_group(rg_index)
|
||||
|
||||
def _get_retry_state(
|
||||
self,
|
||||
controller_state: TrainControllerState,
|
||||
training_failed_error: TrainingFailedError,
|
||||
) -> TrainControllerState:
|
||||
if isinstance(controller_state, RunningState):
|
||||
return RestartingState(training_failed_error=training_failed_error)
|
||||
elif isinstance(controller_state, SchedulingState):
|
||||
return ReschedulingState(training_failed_error=training_failed_error)
|
||||
else:
|
||||
# Cannot retry from this state (e.g. InitializingState,
|
||||
# ShuttingDownState); force shutdown with error.
|
||||
logger.warning(
|
||||
"Cannot retry from state %s; forcing shutdown.",
|
||||
type(controller_state).__name__,
|
||||
)
|
||||
return ShuttingDownState(
|
||||
next_state=ErroredState(training_failed_error=training_failed_error)
|
||||
)
|
||||
|
||||
def _execute_failure_decision(
|
||||
self,
|
||||
failure_decision: FailureDecision,
|
||||
training_failed_error: TrainingFailedError,
|
||||
invoke_failure_decision_callbacks: bool = True,
|
||||
) -> TrainControllerLoopIterationResult:
|
||||
"""Executes failure handling decisions for a scheduling or poll error."""
|
||||
|
||||
controller_state = self.get_state()
|
||||
|
||||
if invoke_failure_decision_callbacks:
|
||||
failure_result = self._run_controller_hook(
|
||||
"before_controller_execute_failure_decision",
|
||||
failure_decision,
|
||||
invoke_failure_decision_callbacks=False,
|
||||
)
|
||||
if failure_result:
|
||||
return failure_result
|
||||
|
||||
# TODO: What should we do here?
|
||||
# This currently never happens because there must be errors.
|
||||
if failure_decision == FailureDecision.NOOP:
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=controller_state,
|
||||
next_state=controller_state,
|
||||
training_failed_error=training_failed_error,
|
||||
)
|
||||
|
||||
if failure_decision == FailureDecision.RETRY:
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=controller_state,
|
||||
next_state=self._get_retry_state(
|
||||
controller_state, training_failed_error
|
||||
),
|
||||
)
|
||||
elif failure_decision == FailureDecision.RAISE:
|
||||
next_state = ShuttingDownState(
|
||||
next_state=ErroredState(
|
||||
training_failed_error=training_failed_error,
|
||||
),
|
||||
)
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=controller_state,
|
||||
next_state=next_state,
|
||||
training_failed_error=training_failed_error,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unexpected failure decision: {failure_decision}")
|
||||
|
||||
async def _poll_workers(self) -> WorkerGroupPollStatus:
|
||||
# Ensure that the time between polls is at least HEALTH_CHECK_INTERVAL_S.
|
||||
time_since_last_poll = time_monotonic() - self._latest_poll_time
|
||||
if time_since_last_poll < self._health_check_interval_s:
|
||||
remaining_time = max(
|
||||
self._health_check_interval_s - time_since_last_poll, 0
|
||||
)
|
||||
await asyncio.sleep(remaining_time)
|
||||
if self.get_state().is_terminal():
|
||||
logger.debug(
|
||||
f"Controller is unexpectedly in terminal state {self.get_state()} after "
|
||||
"sleeping and before polling workers. Exiting actor."
|
||||
)
|
||||
ray.actor.exit_actor()
|
||||
|
||||
status = self._worker_group.poll_status(timeout=self._health_check_interval_s)
|
||||
self._latest_poll_time = time_monotonic()
|
||||
return status
|
||||
|
||||
def _start_worker_group(self, num_workers: int, resources_per_worker: dict) -> None:
|
||||
"""Start the worker group and launch the train function.
|
||||
|
||||
Args:
|
||||
num_workers: The number of workers to start.
|
||||
resources_per_worker: The resources per worker to start.
|
||||
|
||||
Raises:
|
||||
Exception: If the worker group failed to start.
|
||||
"""
|
||||
placement_strategy = self._scaling_policy.scaling_config.placement_strategy
|
||||
scaling_config = self._train_run_context.scaling_config
|
||||
|
||||
# Check for `label_selector` to influence WorkerGroup scheduling.
|
||||
label_selector = scaling_config._label_selector_per_worker(num_workers)
|
||||
for callback in self._controller_callbacks:
|
||||
selector = callback.on_controller_start_worker_group(
|
||||
scaling_config=scaling_config, num_workers=num_workers
|
||||
)
|
||||
if selector:
|
||||
if label_selector:
|
||||
logger.warning(
|
||||
f"Overriding `ScalingConfig.label_selector` {label_selector} "
|
||||
f"with label_selector returned by user-specified callback {selector}"
|
||||
)
|
||||
label_selector = [selector.copy() for _ in range(num_workers)]
|
||||
|
||||
# Calculate num_slices for the worker group if using TPU.
|
||||
num_slices = 1
|
||||
if scaling_config.use_tpu:
|
||||
num_slices = get_tpu_num_slices_for_workers(
|
||||
topology=scaling_config.topology,
|
||||
accelerator_type=scaling_config.accelerator_type,
|
||||
num_workers=num_workers,
|
||||
resources_per_worker=resources_per_worker,
|
||||
)
|
||||
|
||||
worker_group_context = WorkerGroupContext(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
train_fn_ref=self._train_fn_ref,
|
||||
num_workers=num_workers,
|
||||
resources_per_worker=resources_per_worker,
|
||||
placement_strategy=placement_strategy,
|
||||
label_selector=label_selector,
|
||||
num_slices=num_slices,
|
||||
)
|
||||
self._worker_group = self.worker_group_cls.create(
|
||||
train_run_context=self._train_run_context,
|
||||
worker_group_context=worker_group_context,
|
||||
callbacks=self._worker_group_callbacks_to_propagate,
|
||||
)
|
||||
|
||||
def _start(self):
|
||||
failure_result = self._run_controller_hook(
|
||||
"after_controller_start", self._train_run_context
|
||||
)
|
||||
if failure_result:
|
||||
self._set_state(failure_result.next_state)
|
||||
|
||||
async def _shutdown(self) -> "TrainControllerLoopIterationResult":
|
||||
"""Execute shutdown and return the final state transition.
|
||||
|
||||
Shutdown errors are never retried. If an error occurs during shutdown:
|
||||
- If we're already shutting down after a training error
|
||||
(next_state is ErroredState), the original error is preserved.
|
||||
- Otherwise the shutdown error becomes the training failure.
|
||||
"""
|
||||
controller_state = self.get_state()
|
||||
assert isinstance(controller_state, ShuttingDownState)
|
||||
|
||||
shutdown_error = None
|
||||
|
||||
# TODO: move to __del__ after https://github.com/ray-project/ray/issues/53169
|
||||
if self._worker_group:
|
||||
try:
|
||||
self._shutdown_worker_group()
|
||||
except Exception as e:
|
||||
logger.exception("Error shutting down worker group.")
|
||||
shutdown_error = ControllerError(e)
|
||||
|
||||
try:
|
||||
await self._controller_callback_manager.async_invoke(
|
||||
"before_controller_shutdown"
|
||||
)
|
||||
except ControllerError as e:
|
||||
if shutdown_error:
|
||||
logger.warning(
|
||||
"An additional error occurred in the before_controller_shutdown "
|
||||
"callback after a worker group shutdown error. "
|
||||
"This error is being ignored to preserve the original "
|
||||
"shutdown error. Error: %s",
|
||||
e,
|
||||
)
|
||||
else:
|
||||
shutdown_error = e
|
||||
|
||||
if shutdown_error:
|
||||
if isinstance(controller_state.next_state, ErroredState):
|
||||
logger.warning(
|
||||
"Another error occurred during shutdown after a training error. "
|
||||
"This error is being ignored to preserve the original "
|
||||
"training error. Error: %s",
|
||||
shutdown_error,
|
||||
)
|
||||
else:
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=controller_state,
|
||||
next_state=ErroredState(training_failed_error=shutdown_error),
|
||||
training_failed_error=shutdown_error,
|
||||
)
|
||||
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=controller_state,
|
||||
next_state=controller_state.next_state,
|
||||
)
|
||||
|
||||
def _shutdown_worker_group(self):
|
||||
"""Shutdown the worker group and set the worker group to None."""
|
||||
self._worker_group.shutdown()
|
||||
self._worker_group = None
|
||||
|
||||
def get_worker_group(self) -> Optional[WorkerGroup]:
|
||||
return self._worker_group
|
||||
|
||||
def get_state(self) -> TrainControllerState:
|
||||
return self._state
|
||||
|
||||
def _set_state(self, state: TrainControllerState):
|
||||
previous_state = self._state
|
||||
self._state = state
|
||||
|
||||
failure_result = self._run_controller_hook(
|
||||
"after_controller_state_update", previous_state, state
|
||||
)
|
||||
if failure_result:
|
||||
# If we're transitioning into a terminal state, or if we're already in the shutdown path to an errored terminal state
|
||||
# (ShuttingDownState -> ErroredState), preserve the original failure as the
|
||||
# surfaced error. A failure in a state-update callback should not overwrite
|
||||
# the underlying root-cause error.
|
||||
if state.is_terminal() or (
|
||||
isinstance(state, ShuttingDownState)
|
||||
and isinstance(state.next_state, ErroredState)
|
||||
):
|
||||
logger.warning(
|
||||
"A callback failed during a terminal state transition. "
|
||||
"This failure is being ignored to preserve the original "
|
||||
"training result. Error: %s",
|
||||
failure_result.training_failed_error,
|
||||
)
|
||||
return
|
||||
|
||||
# NOTE: We intentionally do *not* re-invoke `after_controller_state_update`
|
||||
# for this transition to avoid re-entering callback hooks while handling
|
||||
# a callback failure.
|
||||
self._state = failure_result.next_state
|
||||
|
||||
def _make_and_handle_scaling_decision_for_non_running_worker_group(
|
||||
self,
|
||||
controller_state: TrainControllerState,
|
||||
) -> TrainControllerLoopIterationResult:
|
||||
"""Make a scaling decision for a non-running worker group and return the appropriate next state.
|
||||
|
||||
This method should be called when entering a state that requires a scaling decision
|
||||
for a non-running worker group.
|
||||
|
||||
This method handles the complete flow of:
|
||||
1. Shutting down the non-running worker group if it still exists.
|
||||
2. Getting a scaling decision for a non-running worker group
|
||||
3. Determining the next state based on the decision type
|
||||
4. Creating and returning the iteration result
|
||||
|
||||
Args:
|
||||
controller_state: The current controller state
|
||||
|
||||
Returns:
|
||||
TrainControllerLoopIterationResult with the appropriate next state
|
||||
"""
|
||||
scaling_decision = (
|
||||
self._scaling_policy.make_decision_for_non_running_worker_group()
|
||||
)
|
||||
|
||||
if isinstance(scaling_decision, NoopDecision):
|
||||
next_state = controller_state
|
||||
elif isinstance(scaling_decision, ResizeDecision):
|
||||
next_state = SchedulingState(scaling_decision)
|
||||
else:
|
||||
raise ValueError(f"Unexpected scaling decision: {scaling_decision}")
|
||||
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=controller_state,
|
||||
next_state=next_state,
|
||||
)
|
||||
|
||||
async def _step(self) -> TrainControllerLoopIterationResult:
|
||||
"""Run a single iteration of the control loop.
|
||||
|
||||
Returns:
|
||||
The result of the iteration.
|
||||
"""
|
||||
controller_state = self.get_state()
|
||||
|
||||
if isinstance(
|
||||
controller_state, (InitializingState, RestartingState, ReschedulingState)
|
||||
):
|
||||
return self._make_and_handle_scaling_decision_for_non_running_worker_group(
|
||||
controller_state
|
||||
)
|
||||
elif isinstance(controller_state, SchedulingState):
|
||||
assert isinstance(controller_state.scaling_decision, ResizeDecision)
|
||||
return self._execute_resize_decision(controller_state.scaling_decision)
|
||||
elif isinstance(controller_state, RunningState):
|
||||
worker_group_status: WorkerGroupPollStatus = await self._poll_workers()
|
||||
|
||||
if worker_group_status.finished and not worker_group_status.errors:
|
||||
self._return_value = worker_group_status.worker_statuses[0].return_value
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=controller_state,
|
||||
next_state=ShuttingDownState(
|
||||
next_state=FinishedState(),
|
||||
),
|
||||
)
|
||||
if worker_group_status.errors:
|
||||
worker_group_error = worker_group_status.get_worker_group_error()
|
||||
failure_decision = self._failure_policy.make_decision(
|
||||
training_failed_error=worker_group_error,
|
||||
)
|
||||
return self._execute_failure_decision(
|
||||
failure_decision, training_failed_error=worker_group_error
|
||||
)
|
||||
|
||||
scaling_decision = (
|
||||
self._scaling_policy.make_decision_for_running_worker_group(
|
||||
worker_group_state=self.get_worker_group().get_worker_group_state(),
|
||||
worker_group_status=worker_group_status,
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(scaling_decision, NoopDecision):
|
||||
next_state = RunningState()
|
||||
elif isinstance(scaling_decision, ResizeDecision):
|
||||
next_state = ResizingState(
|
||||
scaling_decision=scaling_decision,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unexpected scaling decision: {scaling_decision}")
|
||||
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=controller_state,
|
||||
next_state=next_state,
|
||||
)
|
||||
elif isinstance(controller_state, ResizingState):
|
||||
return TrainControllerLoopIterationResult(
|
||||
run_attempt_id=self._get_run_attempt_id(),
|
||||
previous_state=controller_state,
|
||||
next_state=SchedulingState(
|
||||
scaling_decision=controller_state.scaling_decision
|
||||
),
|
||||
)
|
||||
elif isinstance(controller_state, ShuttingDownState):
|
||||
return await self._shutdown()
|
||||
else:
|
||||
raise ValueError(f"Unexpected controller state: {controller_state}")
|
||||
|
||||
def _generate_run_attempt_id(self):
|
||||
self._run_attempt_id = uuid.uuid4().hex
|
||||
return self._run_attempt_id
|
||||
|
||||
def _get_run_attempt_id(self):
|
||||
return self._run_attempt_id
|
||||
|
||||
async def _run_control_loop_iteration(self):
|
||||
"""Run a single iteration of the control loop.
|
||||
|
||||
Steps:
|
||||
1. Poll the worker group for status.
|
||||
2. If the worker group is initializing or recovering from an error,
|
||||
make a scaling decision and execute it.
|
||||
3. If the worker group has finished, set the controller state to FINISHED.
|
||||
4. If the worker group has errors, make a failure decision and execute it.
|
||||
5. Otherwise, the worker group is running healthily.
|
||||
Query the scaling policy for a scaling decision and execute it.
|
||||
|
||||
Errors raised by ``_step`` are caught and routed through the failure
|
||||
policy (retry / raise). If the failure policy itself fails, the
|
||||
controller is forced into ``ErroredState`` as a last resort.
|
||||
|
||||
``AsyncioActorExit`` is always re-raised so that the actor can shut
|
||||
down cleanly.
|
||||
"""
|
||||
controller_state = self.get_state()
|
||||
assert not controller_state.is_terminal()
|
||||
|
||||
if controller_state.needs_new_run_attempt():
|
||||
self._generate_run_attempt_id()
|
||||
|
||||
try:
|
||||
result = await self._step()
|
||||
except AsyncioActorExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Preserve the original error type if it is already a
|
||||
# TrainingFailedError (e.g. WorkerGroupError); otherwise
|
||||
# wrap it in a ControllerError.
|
||||
if isinstance(e, TrainingFailedError):
|
||||
training_error = e
|
||||
else:
|
||||
# Log the full traceback only for unexpected errors.
|
||||
logger.exception("Error in control loop iteration: %s", e)
|
||||
training_error = ControllerError(e)
|
||||
try:
|
||||
failure_decision = self._failure_policy.make_decision(
|
||||
training_failed_error=training_error,
|
||||
)
|
||||
result = self._execute_failure_decision(
|
||||
failure_decision,
|
||||
training_failed_error=training_error,
|
||||
)
|
||||
except Exception:
|
||||
# Last resort: force into errored state, bypassing callbacks.
|
||||
logger.exception(
|
||||
"Failed to execute failure decision, forcing error state."
|
||||
)
|
||||
self._state = ErroredState(training_failed_error=training_error)
|
||||
return
|
||||
|
||||
self._set_state(result.next_state)
|
||||
|
||||
async def run(self):
|
||||
"""Run the main control loop. Exits when training is finished or errored."""
|
||||
while not self.get_state().is_terminal():
|
||||
await self._run_control_loop_iteration()
|
||||
|
||||
# Call after_controller_finish with the final result.
|
||||
result = self._build_result()
|
||||
failure_result = self._run_controller_hook(
|
||||
"after_controller_finish", result, invoke_failure_decision_callbacks=False
|
||||
)
|
||||
# Since we are already in a terminal state, a callback failure should
|
||||
# not overwrite the training outcome — log and preserve the result.
|
||||
if failure_result:
|
||||
logger.warning(
|
||||
"A callback failed after training finished. "
|
||||
"This failure is being ignored to preserve the original "
|
||||
"training result. Error: %s",
|
||||
failure_result.training_failed_error,
|
||||
)
|
||||
|
||||
async def abort(self):
|
||||
"""Trigger callback abort hooks and terminate the controller process."""
|
||||
# Do not abort run if it's already finished.
|
||||
if self.get_state().is_terminal():
|
||||
return
|
||||
|
||||
self._controller_callback_manager.invoke_best_effort("before_controller_abort")
|
||||
|
||||
# Intentionally abort worker group before setting train run state because
|
||||
# we only reconcile the states of live train runs.
|
||||
try:
|
||||
if self._worker_group:
|
||||
self._worker_group.abort()
|
||||
self._set_state(AbortedState())
|
||||
except Exception as e:
|
||||
logger.exception("Error aborting worker group: %s", e)
|
||||
|
||||
ray.actor.exit_actor()
|
||||
|
||||
def _build_result(self) -> Result:
|
||||
storage = self._checkpoint_manager._storage_context
|
||||
|
||||
latest_checkpoint_result = self._checkpoint_manager.latest_checkpoint_result
|
||||
latest_metrics = (
|
||||
latest_checkpoint_result.metrics if latest_checkpoint_result else None
|
||||
)
|
||||
latest_checkpoint = (
|
||||
latest_checkpoint_result.checkpoint if latest_checkpoint_result else None
|
||||
)
|
||||
best_checkpoints = [
|
||||
(r.checkpoint, r.metrics)
|
||||
for r in self._checkpoint_manager.best_checkpoint_results
|
||||
]
|
||||
|
||||
# Provide the history of metrics attached to checkpoints as a dataframe.
|
||||
metrics_dataframe = None
|
||||
if best_checkpoints:
|
||||
metrics_dataframe = pd.DataFrame([m for _, m in best_checkpoints])
|
||||
|
||||
return Result(
|
||||
metrics=latest_metrics,
|
||||
checkpoint=latest_checkpoint,
|
||||
error=self.get_training_failed_error(),
|
||||
path=storage.experiment_fs_path,
|
||||
best_checkpoints=best_checkpoints,
|
||||
metrics_dataframe=metrics_dataframe,
|
||||
_storage_filesystem=storage.storage_filesystem,
|
||||
return_value=self._return_value,
|
||||
)
|
||||
|
||||
def get_result(self) -> Result:
|
||||
"""Get the final training result from the TrainController."""
|
||||
|
||||
controller_state = self.get_state()
|
||||
if not controller_state.is_terminal():
|
||||
raise ValueError(
|
||||
f"Cannot get result when controller is in state {controller_state}"
|
||||
)
|
||||
return self._build_result()
|
||||
|
||||
def get_training_failed_error(self) -> Optional[TrainingFailedError]:
|
||||
"""Get the training failed error from the controller state.
|
||||
|
||||
Returns:
|
||||
The training failed error if the controller is in an errored state,
|
||||
None otherwise.
|
||||
"""
|
||||
controller_state = self.get_state()
|
||||
|
||||
if isinstance(controller_state, ErroredState):
|
||||
return controller_state.training_failed_error
|
||||
|
||||
return None
|
||||
|
||||
async def get_all_reported_checkpoints(
|
||||
self,
|
||||
current_report_index: int,
|
||||
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
|
||||
timeout_s: Optional[float] = None,
|
||||
) -> List["ReportedCheckpoint"]:
|
||||
return await self._checkpoint_manager.get_all_reported_checkpoints(
|
||||
current_report_index, consistency_mode, timeout_s
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
import ray
|
||||
from ray.train.v2._internal.state.util import is_actor_alive
|
||||
from ray.util.placement_group import PlacementGroup, remove_placement_group
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlacementGroupCleaner:
|
||||
"""Detached helper that ensures PG cleanup if Ray Train Controller exits ungracefully.
|
||||
|
||||
This actor should be created with lifetime='detached' to avoid being
|
||||
fate-shared with the Train controller.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller_actor_id: str,
|
||||
check_interval_s: float,
|
||||
get_actor_timeout_s: float,
|
||||
stop_timeout: Optional[float],
|
||||
):
|
||||
self._controller_actor_id = controller_actor_id
|
||||
self._check_interval_s = check_interval_s
|
||||
self._get_actor_timeout_s = get_actor_timeout_s
|
||||
self._stop_timeout = stop_timeout
|
||||
self._pg_queue: queue.Queue = queue.Queue()
|
||||
self._stop_event = threading.Event()
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
self._exiting: bool = False
|
||||
|
||||
def register_placement_group(self, placement_group: PlacementGroup):
|
||||
logger.debug(
|
||||
"PlacementGroupCleaner registered placement group %s for controller %s",
|
||||
placement_group.id,
|
||||
self._controller_actor_id,
|
||||
)
|
||||
# Send placement group update to the monitor thread via queue
|
||||
self._pg_queue.put(placement_group)
|
||||
|
||||
def start_monitoring(self):
|
||||
"""Start monitoring the controller and placement group."""
|
||||
if self._monitor_thread is not None and self._monitor_thread.is_alive():
|
||||
# Thread already running, just return True
|
||||
logger.debug("Monitor thread already running")
|
||||
return True
|
||||
|
||||
self._monitor_thread = threading.Thread(
|
||||
target=self._monitor_loop,
|
||||
name="PlacementGroupCleanerMonitor",
|
||||
daemon=True,
|
||||
)
|
||||
self._monitor_thread.start()
|
||||
logger.debug("PlacementGroupCleaner started monitoring in background thread")
|
||||
return True
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""Monitor controller; remove PG when controller is gone.
|
||||
|
||||
This runs continuously until controller dies or stop() is called.
|
||||
Uses a queue to receive placement group updates.
|
||||
"""
|
||||
curr_placement_group: Optional[PlacementGroup] = None
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
# Check for new placement group updates from queue
|
||||
try:
|
||||
pg = self._pg_queue.get(timeout=self._check_interval_s)
|
||||
curr_placement_group = pg
|
||||
logger.debug(f"Updated current placement group to {pg.id}")
|
||||
except queue.Empty:
|
||||
pass # continue to monitor current placement group
|
||||
|
||||
# Check if controller is still alive
|
||||
try:
|
||||
alive = is_actor_alive(
|
||||
actor_id=self._controller_actor_id,
|
||||
timeout=self._get_actor_timeout_s,
|
||||
)
|
||||
except ray.util.state.exception.RayStateApiException:
|
||||
logger.warning(
|
||||
"Failed to query Ray Train Controller actor state. "
|
||||
"State API may be temporarily unavailable. Continuing to monitor."
|
||||
)
|
||||
continue
|
||||
|
||||
# Cleanup if controller is dead
|
||||
if not alive:
|
||||
# Drain any queued placement groups
|
||||
while True:
|
||||
try:
|
||||
pg = self._pg_queue.get_nowait()
|
||||
curr_placement_group = pg
|
||||
except queue.Empty:
|
||||
break
|
||||
self._cleanup_placement_group(curr_placement_group)
|
||||
break
|
||||
|
||||
# Exit the actor after cleanup since controller is dead
|
||||
self._exit()
|
||||
self._monitor_thread = None
|
||||
|
||||
def _cleanup_placement_group(self, placement_group: Optional[PlacementGroup]):
|
||||
"""Clean up the current placement group if it hasn't been removed."""
|
||||
if placement_group is None:
|
||||
logger.debug("No placement group registered; skipping cleanup.")
|
||||
return
|
||||
|
||||
if self._is_placement_group_removed(placement_group):
|
||||
logger.debug(
|
||||
"Controller actor died but placement group already removed; "
|
||||
"skipping cleanup."
|
||||
)
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
f"Detected that the Ray Train controller actor ({self._controller_actor_id}) is dead. "
|
||||
f"Cleaning up placement group = [{placement_group.id}] created by this run."
|
||||
)
|
||||
try:
|
||||
remove_placement_group(placement_group)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up placement group: {e}")
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Placement group = [{placement_group.id}] cleaned up successfully"
|
||||
)
|
||||
|
||||
def _stop_monitor_thread(self):
|
||||
"""Stop the monitor thread and wait for it to exit.
|
||||
|
||||
Returns:
|
||||
bool: True if the thread was stopped, False if there was no active thread.
|
||||
"""
|
||||
if self._monitor_thread is None or not self._monitor_thread.is_alive():
|
||||
return False
|
||||
|
||||
# Signal stop and wait for thread to exit
|
||||
self._stop_event.set()
|
||||
self._monitor_thread.join(timeout=self._stop_timeout)
|
||||
if self._monitor_thread.is_alive():
|
||||
logger.warning(
|
||||
"Monitor thread did not exit within %.2f seconds", self._stop_timeout
|
||||
)
|
||||
return False
|
||||
|
||||
self._monitor_thread = None
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
"""Request the cleaner to stop monitoring and exit."""
|
||||
self._stop_monitor_thread()
|
||||
self._exit()
|
||||
|
||||
def _is_placement_group_removed(self, placement_group: PlacementGroup) -> bool:
|
||||
"""Check if a placement group has been removed."""
|
||||
try:
|
||||
table = ray.util.placement_group_table(placement_group)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to query placement group table: {e}. "
|
||||
"Assuming placement group is not removed."
|
||||
)
|
||||
return False
|
||||
if "state" not in table:
|
||||
return True
|
||||
return table["state"] == "REMOVED"
|
||||
|
||||
def _exit(self):
|
||||
"""Exit the actor."""
|
||||
if self._exiting:
|
||||
return
|
||||
self._exiting = True
|
||||
try:
|
||||
ray.actor.exit_actor()
|
||||
except Exception as e:
|
||||
# If exit fails for any reason, just log it.
|
||||
logger.warning(f"Failed to exit actor: {e}")
|
||||
@@ -0,0 +1,156 @@
|
||||
from enum import Enum
|
||||
|
||||
from ray.train.v2._internal.execution.scaling_policy.scaling_policy import (
|
||||
ScalingDecision,
|
||||
)
|
||||
from ray.train.v2.api.exceptions import TrainingFailedError
|
||||
|
||||
|
||||
class TrainControllerStateType(Enum):
|
||||
"""Enum representing different states of the train controller.
|
||||
|
||||
States:
|
||||
INITIALIZING: The train controller is starting up. This is always the initial
|
||||
state of the controller.
|
||||
SCHEDULING: The train controller is in the process of scheduling a new worker
|
||||
group.
|
||||
RESCHEDULING: The train controller is in the process of rescheduling the worker
|
||||
group.
|
||||
RUNNING: The train controller is actively running training tasks.
|
||||
RESTARTING: The train controller is in the process of recovering from an error.
|
||||
RESIZING: The train controller is in the process of resizing a running worker
|
||||
group.
|
||||
SHUTTING_DOWN: The train controller has already shut down the worker group and
|
||||
and is in the process of shutting itself down.
|
||||
ERRORED: A terminal state indicating that training has encountered an error and
|
||||
cannot continue.
|
||||
FINISHED: A terminal state indicating that training has completed.
|
||||
ABORTED: A terminal state indicating that training has been aborted.
|
||||
|
||||
Args:
|
||||
state_name: The name of the state.
|
||||
is_terminal: Whether this is a terminal state that should not be further processed.
|
||||
needs_new_run_attempt: Whether this state requires starting a new run attempt, where
|
||||
a run attempt is a logical unit that encompasses both scheduling workers and
|
||||
executing training on those workers.
|
||||
"""
|
||||
|
||||
INITIALIZING = ("INITIALIZING", False, True)
|
||||
SCHEDULING = ("SCHEDULING", False, False)
|
||||
RESCHEDULING = ("RESCHEDULING", False, False)
|
||||
RUNNING = ("RUNNING", False, False)
|
||||
RESTARTING = ("RESTARTING", False, True)
|
||||
RESIZING = ("RESIZING", False, True)
|
||||
SHUTTING_DOWN = ("SHUTTING_DOWN", False, False)
|
||||
ERRORED = ("ERRORED", True, False)
|
||||
FINISHED = ("FINISHED", True, False)
|
||||
ABORTED = ("ABORTED", True, False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_name: str,
|
||||
is_terminal: bool,
|
||||
needs_new_run_attempt: bool,
|
||||
):
|
||||
self.state_name = state_name
|
||||
self.is_terminal = is_terminal
|
||||
self.needs_new_run_attempt = needs_new_run_attempt
|
||||
|
||||
|
||||
class TrainControllerState:
|
||||
"""Base class for all train controller states.
|
||||
|
||||
Methods:
|
||||
get_type() -> TrainControllerStateType: Returns the type of the state.
|
||||
is_terminal() -> bool: Returns whether the state is terminal.
|
||||
needs_new_run_attempt() -> bool: Returns whether a new run attempt is needed.
|
||||
"""
|
||||
|
||||
def __init__(self, state_type: TrainControllerStateType):
|
||||
self._state_type = state_type
|
||||
|
||||
def __repr__(self) -> str:
|
||||
attrs = {
|
||||
"type": self._state_type.name,
|
||||
"is_terminal": self._state_type.is_terminal,
|
||||
"needs_new_run_attempt": self._state_type.needs_new_run_attempt,
|
||||
**{k: v for k, v in vars(self).items() if not k.startswith("_")},
|
||||
}
|
||||
attrs_str = "\n ".join(f"{k}={v}" for k, v in attrs.items())
|
||||
return f"{self.__class__.__name__}(\n {attrs_str}\n)"
|
||||
|
||||
def is_terminal(self) -> bool:
|
||||
return self._state_type.is_terminal
|
||||
|
||||
def needs_new_run_attempt(self) -> bool:
|
||||
return self._state_type.needs_new_run_attempt
|
||||
|
||||
|
||||
class InitializingState(TrainControllerState):
|
||||
def __init__(self):
|
||||
super().__init__(state_type=TrainControllerStateType.INITIALIZING)
|
||||
|
||||
|
||||
class SchedulingState(TrainControllerState):
|
||||
def __init__(self, scaling_decision: ScalingDecision):
|
||||
super().__init__(state_type=TrainControllerStateType.SCHEDULING)
|
||||
self.scaling_decision = scaling_decision
|
||||
|
||||
|
||||
class ReschedulingState(TrainControllerState):
|
||||
def __init__(
|
||||
self,
|
||||
training_failed_error: TrainingFailedError,
|
||||
):
|
||||
super().__init__(state_type=TrainControllerStateType.RESCHEDULING)
|
||||
self.training_failed_error = training_failed_error
|
||||
|
||||
|
||||
class RunningState(TrainControllerState):
|
||||
# TODO: Split into multiple more granular states, or add more fields.
|
||||
# For example, we may want to indicate if any health checks failed.
|
||||
def __init__(self):
|
||||
super().__init__(state_type=TrainControllerStateType.RUNNING)
|
||||
|
||||
|
||||
class RestartingState(TrainControllerState):
|
||||
def __init__(
|
||||
self,
|
||||
training_failed_error: TrainingFailedError,
|
||||
):
|
||||
super().__init__(state_type=TrainControllerStateType.RESTARTING)
|
||||
self.training_failed_error = training_failed_error
|
||||
|
||||
|
||||
class ResizingState(TrainControllerState):
|
||||
def __init__(
|
||||
self,
|
||||
scaling_decision: ScalingDecision,
|
||||
):
|
||||
super().__init__(state_type=TrainControllerStateType.RESIZING)
|
||||
self.scaling_decision = scaling_decision
|
||||
|
||||
|
||||
class ShuttingDownState(TrainControllerState):
|
||||
def __init__(self, next_state: "TrainControllerState"):
|
||||
super().__init__(state_type=TrainControllerStateType.SHUTTING_DOWN)
|
||||
self.next_state = next_state
|
||||
|
||||
|
||||
class ErroredState(TrainControllerState):
|
||||
def __init__(
|
||||
self,
|
||||
training_failed_error: TrainingFailedError,
|
||||
):
|
||||
super().__init__(state_type=TrainControllerStateType.ERRORED)
|
||||
self.training_failed_error = training_failed_error
|
||||
|
||||
|
||||
class FinishedState(TrainControllerState):
|
||||
def __init__(self):
|
||||
super().__init__(state_type=TrainControllerStateType.FINISHED)
|
||||
|
||||
|
||||
class AbortedState(TrainControllerState):
|
||||
def __init__(self):
|
||||
super().__init__(state_type=TrainControllerStateType.ABORTED)
|
||||
@@ -0,0 +1,16 @@
|
||||
# isort: off
|
||||
from .failure_policy import FailureDecision, FailurePolicy
|
||||
from .default import DefaultFailurePolicy
|
||||
from .factory import create_failure_policy
|
||||
|
||||
# isort: on
|
||||
|
||||
__all__ = [
|
||||
"DefaultFailurePolicy",
|
||||
"FailureDecision",
|
||||
"FailurePolicy",
|
||||
"create_failure_policy",
|
||||
]
|
||||
|
||||
|
||||
# DO NOT ADD ANYTHING AFTER THIS LINE.
|
||||
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
|
||||
from .failure_policy import FailureDecision, FailurePolicy
|
||||
from ray.train.v2._internal.exceptions import (
|
||||
WorkerGroupStartupFailedError,
|
||||
WorkerGroupStartupTimeoutError,
|
||||
)
|
||||
from ray.train.v2.api.config import FailureConfig
|
||||
from ray.train.v2.api.exceptions import (
|
||||
ControllerError,
|
||||
TrainingFailedError,
|
||||
WorkerGroupError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
RETRYABLE_CONTROLLER_ERRORS = (
|
||||
WorkerGroupStartupFailedError,
|
||||
WorkerGroupStartupTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
class DefaultFailurePolicy(FailurePolicy):
|
||||
def __init__(self, failure_config: FailureConfig):
|
||||
super().__init__(failure_config)
|
||||
self._worker_group_failures = 0
|
||||
self._controller_failures = 0
|
||||
|
||||
def _log_decision(
|
||||
self,
|
||||
decision: FailureDecision,
|
||||
training_failed_error: TrainingFailedError,
|
||||
error_count: int,
|
||||
retry_limit: int,
|
||||
):
|
||||
if isinstance(training_failed_error, ControllerError):
|
||||
error_source = "controller"
|
||||
elif isinstance(training_failed_error, WorkerGroupError):
|
||||
error_source = "worker group"
|
||||
else:
|
||||
raise ValueError(f"Unknown error type: {type(training_failed_error)}")
|
||||
|
||||
logger.info(
|
||||
f"[FailurePolicy] {decision.value}\n"
|
||||
f" Source: {error_source}\n"
|
||||
f" Error count: {error_count} (max allowed: {retry_limit})\n"
|
||||
f"Error: {training_failed_error}",
|
||||
exc_info=(
|
||||
type(training_failed_error),
|
||||
training_failed_error,
|
||||
training_failed_error.__traceback__,
|
||||
),
|
||||
)
|
||||
|
||||
def _is_retryable_error(self, training_failed_error: TrainingFailedError) -> bool:
|
||||
if isinstance(training_failed_error, WorkerGroupError):
|
||||
return True
|
||||
elif isinstance(training_failed_error, ControllerError):
|
||||
return isinstance(
|
||||
training_failed_error.controller_failure, RETRYABLE_CONTROLLER_ERRORS
|
||||
)
|
||||
return False
|
||||
|
||||
def make_decision(
|
||||
self,
|
||||
training_failed_error: TrainingFailedError,
|
||||
) -> FailureDecision:
|
||||
|
||||
if not self._is_retryable_error(training_failed_error):
|
||||
decision = FailureDecision.RAISE
|
||||
error_count = 1
|
||||
retry_limit = 0
|
||||
else:
|
||||
if isinstance(training_failed_error, ControllerError):
|
||||
self._controller_failures += 1
|
||||
error_count = self._controller_failures
|
||||
retry_limit = (
|
||||
self.failure_config.controller_failure_limit
|
||||
if self.failure_config.controller_failure_limit != -1
|
||||
else float("inf")
|
||||
)
|
||||
elif isinstance(training_failed_error, WorkerGroupError):
|
||||
self._worker_group_failures += 1
|
||||
error_count = self._worker_group_failures
|
||||
retry_limit = (
|
||||
self.failure_config.max_failures
|
||||
if self.failure_config.max_failures != -1
|
||||
else float("inf")
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown error type: {type(training_failed_error)}")
|
||||
|
||||
if error_count > retry_limit:
|
||||
decision = FailureDecision.RAISE
|
||||
else:
|
||||
decision = FailureDecision.RETRY
|
||||
|
||||
self._log_decision(decision, training_failed_error, error_count, retry_limit)
|
||||
return decision
|
||||
@@ -0,0 +1,13 @@
|
||||
from ray.train import FailureConfig
|
||||
from ray.train.v2._internal.execution.failure_handling import (
|
||||
DefaultFailurePolicy,
|
||||
FailurePolicy,
|
||||
)
|
||||
|
||||
|
||||
def create_failure_policy(failure_config: FailureConfig) -> FailurePolicy:
|
||||
"""Create a failure policy from the given failure config.
|
||||
|
||||
Defaults to the `DefaultFailurePolicy` implementation.
|
||||
"""
|
||||
return DefaultFailurePolicy(failure_config=failure_config)
|
||||
@@ -0,0 +1,29 @@
|
||||
import abc
|
||||
from enum import Enum
|
||||
|
||||
from ray.train.v2.api.config import FailureConfig
|
||||
from ray.train.v2.api.exceptions import TrainingFailedError
|
||||
|
||||
|
||||
class FailureDecision(Enum):
|
||||
RETRY = "RETRY"
|
||||
RAISE = "RAISE"
|
||||
NOOP = "NOOP"
|
||||
|
||||
|
||||
class FailurePolicy(abc.ABC):
|
||||
"""A policy that determines how to handle user and system failures.
|
||||
FailurePolicy will handle the controller failure and worker errors during training.
|
||||
|
||||
This can be used to implement fault tolerance and error recovery.
|
||||
"""
|
||||
|
||||
def __init__(self, failure_config: FailureConfig):
|
||||
self.failure_config = failure_config
|
||||
|
||||
@abc.abstractmethod
|
||||
def make_decision(
|
||||
self,
|
||||
training_failed_error: TrainingFailedError,
|
||||
) -> FailureDecision:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,93 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from ray.train import Result
|
||||
from ray.train.v2._internal.execution.local_mode.utils import LocalController
|
||||
from ray.train.v2._internal.execution.train_fn_utils import (
|
||||
LocalTrainFnUtils,
|
||||
get_train_fn_utils,
|
||||
set_train_fn_utils,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def has_torchrun_env() -> bool:
|
||||
"""Return True if this process has torch.distributed env vars set.
|
||||
|
||||
For torch.distributed.init_process_group with init_method="env://", these variables are required:
|
||||
- RANK: The rank of the current process
|
||||
- LOCAL_RANK: The local rank of the current process
|
||||
- WORLD_SIZE: Total number of processes participating in the job
|
||||
- LOCAL_WORLD_SIZE: Total number of processes participating in the job on the current node
|
||||
- MASTER_ADDR: The IP address or hostname of the master node (rank 0)
|
||||
- MASTER_PORT: A free port on the master node for communication
|
||||
|
||||
"""
|
||||
torch_dist_required_vars = {
|
||||
"RANK",
|
||||
"LOCAL_RANK",
|
||||
"WORLD_SIZE",
|
||||
"LOCAL_WORLD_SIZE",
|
||||
"MASTER_ADDR",
|
||||
"MASTER_PORT",
|
||||
}
|
||||
|
||||
return torch_dist_required_vars.issubset(os.environ.keys())
|
||||
|
||||
|
||||
class LocalTorchController(LocalController):
|
||||
def _set_train_fn_utils(self) -> None:
|
||||
world_size = 1
|
||||
global_rank = 0
|
||||
local_rank = 0
|
||||
nproc_per_node = 1
|
||||
node_rank = 0
|
||||
if has_torchrun_env():
|
||||
assert not dist.is_initialized(), "torch.distributed is already initialized"
|
||||
torch.distributed.init_process_group(
|
||||
backend="nccl" if torch.cuda.is_available() else "gloo"
|
||||
)
|
||||
world_size = torch.distributed.get_world_size()
|
||||
global_rank = torch.distributed.get_rank()
|
||||
local_rank = int(os.environ["LOCAL_RANK"])
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.set_device(local_rank)
|
||||
nproc_per_node = int(os.environ.get("LOCAL_WORLD_SIZE"))
|
||||
node_rank = global_rank // nproc_per_node
|
||||
|
||||
if world_size != 1:
|
||||
assert (
|
||||
self.datasets is None or len(self.datasets) == 0
|
||||
), "Ray Data is not supported in local mode with multiple workers."
|
||||
set_train_fn_utils(
|
||||
LocalTrainFnUtils(
|
||||
experiment_name=self.experiment_name,
|
||||
world_size=world_size,
|
||||
world_rank=global_rank,
|
||||
local_rank=local_rank,
|
||||
local_world_size=nproc_per_node,
|
||||
node_rank=node_rank,
|
||||
dataset_shards=self.datasets,
|
||||
)
|
||||
)
|
||||
|
||||
def run(self, train_func: Callable[[], Any]) -> Result:
|
||||
self._set_train_fn_utils()
|
||||
train_result = train_func()
|
||||
train_fn_utils = get_train_fn_utils()
|
||||
assert isinstance(train_fn_utils, LocalTrainFnUtils)
|
||||
result = Result(
|
||||
metrics=train_fn_utils._get_last_metrics(),
|
||||
checkpoint=train_fn_utils.get_checkpoint(),
|
||||
path=None,
|
||||
error=None,
|
||||
return_value=train_result,
|
||||
)
|
||||
if dist.is_initialized():
|
||||
dist.destroy_process_group()
|
||||
return result
|
||||
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from ray.train import Result
|
||||
from ray.train.trainer import GenDataset
|
||||
from ray.train.v2._internal.execution.train_fn_utils import (
|
||||
LocalTrainFnUtils,
|
||||
get_train_fn_utils,
|
||||
set_train_fn_utils,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalController:
|
||||
def __init__(
|
||||
self, experiment_name: str, datasets: Optional[Dict[str, GenDataset]] = None
|
||||
):
|
||||
if datasets is not None:
|
||||
datasets = {k: v() if callable(v) else v for k, v in datasets.items()}
|
||||
|
||||
self.datasets = datasets
|
||||
self.experiment_name = experiment_name
|
||||
|
||||
def run(self, train_func: Callable[[], Any]) -> Result:
|
||||
set_train_fn_utils(
|
||||
LocalTrainFnUtils(
|
||||
experiment_name=self.experiment_name,
|
||||
dataset_shards=self.datasets,
|
||||
)
|
||||
)
|
||||
result = train_func()
|
||||
train_fn_utils = get_train_fn_utils()
|
||||
assert isinstance(train_fn_utils, LocalTrainFnUtils)
|
||||
return Result(
|
||||
metrics=train_fn_utils._get_last_metrics(),
|
||||
checkpoint=train_fn_utils.get_checkpoint(),
|
||||
path=None,
|
||||
error=None,
|
||||
return_value=result,
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Set
|
||||
|
||||
import ray
|
||||
from ray.actor import ActorHandle
|
||||
from ray.train.v2._internal.constants import DEFAULT_PREEMPTION_POLL_INTERVAL_S
|
||||
from ray.util.tpu import get_tpu_slice_name_from_node
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train.v2._internal.worker import RayTrainWorker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreemptionInfo:
|
||||
"""Information about an imminent preemption event.
|
||||
|
||||
Attributes:
|
||||
deadline_ms: Earliest preemption deadline (UNIX time in milliseconds)
|
||||
across all preempted nodes. ``None`` if no deadline was reported.
|
||||
preempted_node_to_ranks: Map of preempted ``node_id`` to the worker ``world_rank``s affected when that node
|
||||
is preempted.
|
||||
"""
|
||||
|
||||
deadline_ms: Optional[int]
|
||||
preempted_node_to_ranks: Dict[str, List[int]]
|
||||
|
||||
@property
|
||||
def preempted_node_ids(self) -> List[str]:
|
||||
"""Preempted node IDs, sorted lexicographically."""
|
||||
return sorted(self.preempted_node_to_ranks)
|
||||
|
||||
@property
|
||||
def preempted_ranks(self) -> List[int]:
|
||||
"""All affected ranks across the preempted nodes, sorted ascending."""
|
||||
return sorted(
|
||||
{r for ranks in self.preempted_node_to_ranks.values() for r in ranks}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreemptionContext:
|
||||
"""Thread-shared preemption signal for one worker actor."""
|
||||
|
||||
_preemption_info: Optional[PreemptionInfo] = field(default=None, init=False)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
|
||||
|
||||
def set(self, info: PreemptionInfo) -> None:
|
||||
with self._lock:
|
||||
self._preemption_info = info
|
||||
|
||||
def get(self) -> Optional[PreemptionInfo]:
|
||||
"""Return the current preemption signal, or ``None`` if none received."""
|
||||
with self._lock:
|
||||
return self._preemption_info
|
||||
|
||||
|
||||
def _get_draining_nodes() -> Dict[str, int]:
|
||||
"""Ray Core's draining nodes as ``{node_id_hex: deadline_ms}`` (0 = no deadline)."""
|
||||
return ray._private.state.state.get_draining_nodes()
|
||||
|
||||
|
||||
class PreemptionWatcher:
|
||||
"""Polls Ray Core for node drains and logs detected preemption events.
|
||||
|
||||
One watcher per worker group, spawned as a ``num_cpus=0`` actor by
|
||||
``PreemptionCallback``. The poll loop runs in a background thread. The
|
||||
failure-domain map is built once on construction and is immutable for the
|
||||
watcher's lifetime.
|
||||
|
||||
The failure-domain map records which of our ranks are affected if a node is
|
||||
preempted: for a GPU node, the ranks on that node; for a TPU node, every
|
||||
rank in the node's slice, since a TPU slice is preempted atomically.
|
||||
|
||||
Args:
|
||||
node_to_ranks: Map ``node_id_hex -> [ranks on that node]``. Used both
|
||||
as the set of nodes we care about (drains elsewhere are ignored)
|
||||
and as the seed for failure-domain expansion.
|
||||
poll_interval_s: Seconds between drain-state polls.
|
||||
worker_actors_by_rank: Map ``world_rank -> worker actor handle``. On a
|
||||
detected preemption, ``mark_preempt`` is called on every worker.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node_to_ranks: Dict[str, List[int]],
|
||||
poll_interval_s: float = DEFAULT_PREEMPTION_POLL_INTERVAL_S,
|
||||
worker_actors_by_rank: Optional[
|
||||
Dict[int, ActorHandle["RayTrainWorker"]]
|
||||
] = None,
|
||||
):
|
||||
self._node_to_ranks: Dict[str, List[int]] = {
|
||||
nid: sorted(ranks) for nid, ranks in node_to_ranks.items()
|
||||
}
|
||||
self._poll_interval_s = poll_interval_s
|
||||
self._worker_actors_by_rank: Dict[int, ActorHandle["RayTrainWorker"]] = (
|
||||
worker_actors_by_rank or {}
|
||||
)
|
||||
self._failure_domain_map: Dict[str, List[int]] = self._build_failure_domain_map(
|
||||
self._node_to_ranks
|
||||
)
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._last_drained: Dict[str, int] = {}
|
||||
self._latest_info: Optional[PreemptionInfo] = None
|
||||
|
||||
self._monitor_thread = threading.Thread(
|
||||
target=self._watch_loop,
|
||||
name="PreemptionWatcher",
|
||||
daemon=True,
|
||||
)
|
||||
self._monitor_thread.start()
|
||||
|
||||
@staticmethod
|
||||
def _build_failure_domain_map(
|
||||
node_to_ranks: Dict[str, List[int]],
|
||||
) -> Dict[str, List[int]]:
|
||||
"""Map each node we host to all ranks in its failure domain.
|
||||
|
||||
- Non-TPU (e.g. GPU) clusters: the failure domain is the node itself,
|
||||
so a drain on a node flags only the ranks this job runs there.
|
||||
- TPU multislice: every host in a slice is reclaimed atomically, so a
|
||||
drain on any host is fate-shared with the rest.
|
||||
"""
|
||||
per_node = {nid: sorted(set(ranks)) for nid, ranks in node_to_ranks.items()}
|
||||
|
||||
try:
|
||||
all_nodes = ray.nodes()
|
||||
|
||||
# Slice label for each node we host (None for non-TPU nodes).
|
||||
node_to_slice: Dict[str, Optional[str]] = {
|
||||
node["NodeID"]: get_tpu_slice_name_from_node(node)
|
||||
for node in all_nodes
|
||||
if node["NodeID"] in node_to_ranks
|
||||
}
|
||||
|
||||
# Union our ranks per slice.
|
||||
slice_to_ranks: Dict[str, Set[int]] = {}
|
||||
for node_id, ranks in node_to_ranks.items():
|
||||
slice_label = node_to_slice.get(node_id)
|
||||
if slice_label:
|
||||
slice_to_ranks.setdefault(slice_label, set()).update(ranks)
|
||||
|
||||
# Non-TPU cluster (or none of our nodes are on a slice): per-node.
|
||||
if not slice_to_ranks:
|
||||
return per_node
|
||||
|
||||
result: Dict[str, List[int]] = {}
|
||||
for node_id, ranks in node_to_ranks.items():
|
||||
slice_label = node_to_slice.get(node_id)
|
||||
if slice_label:
|
||||
result[node_id] = sorted(slice_to_ranks[slice_label])
|
||||
else:
|
||||
result[node_id] = sorted(set(ranks))
|
||||
return result
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not build failure-domain map; falling back to per-node "
|
||||
"domains (no TPU-slice expansion).",
|
||||
exc_info=True,
|
||||
)
|
||||
return per_node
|
||||
|
||||
def get_latest_preemption_info(self) -> Optional[PreemptionInfo]:
|
||||
"""Most recent :class:`PreemptionInfo` observed, or ``None``."""
|
||||
return self._latest_info
|
||||
|
||||
def _watch_loop(self) -> None:
|
||||
logger.debug(
|
||||
"PreemptionWatcher polling %d node(s) every %.1fs.",
|
||||
len(self._node_to_ranks),
|
||||
self._poll_interval_s,
|
||||
)
|
||||
while not self._stop_event.is_set():
|
||||
self._poll_once()
|
||||
self._stop_event.wait(timeout=self._poll_interval_s)
|
||||
logger.debug("PreemptionWatcher stopped.")
|
||||
|
||||
def _poll_once(self) -> None:
|
||||
"""Poll the drain source once and dispatch on change.
|
||||
|
||||
Per-poll exceptions are caught and logged so a transient GCS hiccup
|
||||
doesn't kill the watcher loop.
|
||||
"""
|
||||
try:
|
||||
drained = _get_draining_nodes() or {}
|
||||
# Keep only drains on this job's own nodes (others are ignored).
|
||||
# That's complete for TPU — an SPMD job fully occupies its slice, so
|
||||
# every fate-shared host is one of our nodes and a drain on any slice
|
||||
# host appears here. For GPU, a drain on a host we don't run on is
|
||||
# correctly irrelevant.
|
||||
relevant = {
|
||||
n: d for n, d in drained.items() if n in self._failure_domain_map
|
||||
}
|
||||
if relevant != self._last_drained:
|
||||
self._on_drain_change(relevant)
|
||||
self._last_drained = relevant
|
||||
except Exception:
|
||||
# TODO(lehui): consider exponential backoff when the drain API keeps
|
||||
# failing, instead of retrying at the fixed poll interval.
|
||||
logger.warning("PreemptionWatcher poll failed", exc_info=True)
|
||||
|
||||
def _on_drain_change(self, drained: Dict[str, int]) -> None:
|
||||
"""Handle a change in the drained-node set.
|
||||
|
||||
``drained`` has already been narrowed to this job's nodes by the
|
||||
caller (``_poll_once``).
|
||||
"""
|
||||
if not drained:
|
||||
return
|
||||
|
||||
affected_node_ids = sorted(drained.keys())
|
||||
preempted_node_to_ranks = {
|
||||
node_id: self._failure_domain_map[node_id] for node_id in affected_node_ids
|
||||
}
|
||||
|
||||
# Earliest deadline across the preempted nodes; None if none reported one
|
||||
# (Ray Core uses 0 for "no deadline", which is falsy and filtered out).
|
||||
reported_deadlines = [drained[n] for n in affected_node_ids if drained[n]]
|
||||
deadline_ms = min(reported_deadlines) if reported_deadlines else None
|
||||
|
||||
info = PreemptionInfo(
|
||||
deadline_ms=deadline_ms,
|
||||
preempted_node_to_ranks=preempted_node_to_ranks,
|
||||
)
|
||||
self._latest_info = info
|
||||
|
||||
logger.warning(
|
||||
"PreemptionWatcher: preemption detected — "
|
||||
"preempted_node_ids=%s, preempted_ranks=%s, deadline_ms=%s",
|
||||
info.preempted_node_ids,
|
||||
info.preempted_ranks,
|
||||
deadline_ms,
|
||||
)
|
||||
|
||||
for rank, actor in self._worker_actors_by_rank.items():
|
||||
actor.mark_preempt.remote(info)
|
||||
# TODO(lehui): coalesce preemptions seen within one window into a single
|
||||
# worker-group restart, so a staggered drain (node A at t, node B at
|
||||
# t+60s) doesn't cause back-to-back restarts.
|
||||
@@ -0,0 +1,29 @@
|
||||
# isort: off
|
||||
from .scaling_policy import ScalingDecision, ScalingPolicy, NoopDecision, ResizeDecision
|
||||
from .scaling_policy import (
|
||||
AUTOSCALING_REQUESTS_EXPIRE_TIME_S,
|
||||
AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
|
||||
AUTOSCALING_REQUESTS_INTERVAL_S,
|
||||
)
|
||||
from .elastic import ElasticScalingPolicy
|
||||
from .fixed import FixedScalingPolicy
|
||||
from .factory import create_scaling_policy
|
||||
|
||||
# isort: on
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AUTOSCALING_REQUESTS_EXPIRE_TIME_S",
|
||||
"AUTOSCALING_REQUESTS_GET_TIMEOUT_S",
|
||||
"AUTOSCALING_REQUESTS_INTERVAL_S",
|
||||
"ScalingPolicy",
|
||||
"ElasticScalingPolicy",
|
||||
"FixedScalingPolicy",
|
||||
"ScalingDecision",
|
||||
"NoopDecision",
|
||||
"ResizeDecision",
|
||||
"create_scaling_policy",
|
||||
]
|
||||
|
||||
|
||||
# DO NOT ADD ANYTHING AFTER THIS LINE.
|
||||
@@ -0,0 +1,291 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
import ray
|
||||
from ray.train.v2._internal.execution.scaling_policy import (
|
||||
NoopDecision,
|
||||
ResizeDecision,
|
||||
ScalingDecision,
|
||||
ScalingPolicy,
|
||||
)
|
||||
from ray.train.v2._internal.execution.scaling_policy.scaling_policy import (
|
||||
AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
|
||||
)
|
||||
from ray.train.v2._internal.execution.worker_group import (
|
||||
WorkerGroupPollStatus,
|
||||
WorkerGroupState,
|
||||
)
|
||||
from ray.train.v2._internal.util import time_monotonic
|
||||
from ray.train.v2.api.config import ScalingConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.cluster_autoscaler.default_autoscaling_coordinator import (
|
||||
ResourceDict,
|
||||
)
|
||||
|
||||
|
||||
class ElasticScalingPolicy(ScalingPolicy):
|
||||
|
||||
# Minimum interval in seconds between querying the AutoscalingCoordinator for allocated resources.
|
||||
GET_ALLOCATED_RESOURCES_INTERVAL_S = 1
|
||||
# Minimum interval in seconds between logging warnings about insufficient workers.
|
||||
INSUFFICIENT_WORKERS_WARNING_INTERVAL_S = 30
|
||||
|
||||
def __init__(self, scaling_config: ScalingConfig):
|
||||
super().__init__(scaling_config)
|
||||
|
||||
self._latest_monitor_time = float("-inf")
|
||||
self._latest_insufficient_workers_warning_time = float("-inf")
|
||||
self._latest_allocated_resources_query_time = float("-inf")
|
||||
self._latest_allocated_resources: Optional[List["ResourceDict"]] = None
|
||||
|
||||
def _get_num_workers_for_resource_request(self) -> int:
|
||||
return self.scaling_config.max_workers
|
||||
|
||||
def _count_possible_workers(
|
||||
self, allocated_resources: List[Dict[str, float]]
|
||||
) -> int:
|
||||
"""Count the number of workers that can be started/restarted with the given
|
||||
the list of node resources. The returned number is capped at the maximum
|
||||
number of workers.
|
||||
|
||||
For GPUs, this divides raw allocated resources by per-worker requirements.
|
||||
For TPUs, an additional check ensures workers align with physically intact
|
||||
TPU slices (see ``_get_strict_tpu_worker_count``).
|
||||
|
||||
Args:
|
||||
allocated_resources: The resources currently allocated by the AutoscalingCoordinator.
|
||||
|
||||
Returns:
|
||||
The number of workers that can be started/restarted with the current resources.
|
||||
"""
|
||||
# TODO: Fractional resources do not work well here.
|
||||
single_worker_resources = self.scaling_config._resources_per_worker_not_none
|
||||
total_num_workers = 0
|
||||
|
||||
# If workers require no resources, we can run as many as we want.
|
||||
if sum(single_worker_resources.values()) == 0:
|
||||
return self.scaling_config.max_workers
|
||||
|
||||
for resources in allocated_resources:
|
||||
num_workers = min(
|
||||
[
|
||||
resources.get(resource, 0.0) // single_worker_resources[resource]
|
||||
for resource in single_worker_resources
|
||||
if single_worker_resources[resource] > 0
|
||||
]
|
||||
)
|
||||
total_num_workers += num_workers
|
||||
|
||||
total_num_workers = min(int(total_num_workers), self.scaling_config.max_workers)
|
||||
|
||||
# Multi-host TPUs are scheduled atomically in interconnected slices defined by a topology.
|
||||
if (
|
||||
self.scaling_config.use_tpu
|
||||
and self.scaling_config.topology
|
||||
and self.scaling_config.accelerator_type
|
||||
):
|
||||
total_num_workers = self._get_strict_tpu_worker_count(
|
||||
total_num_workers=total_num_workers,
|
||||
)
|
||||
|
||||
return total_num_workers
|
||||
|
||||
def _get_strict_tpu_worker_count(self, total_num_workers: int) -> int:
|
||||
"""Calculate the number of workers that can run on intact TPU slices.
|
||||
|
||||
The Autoscaler's allocated resources might overestimate the number of
|
||||
schedulable TPU workers because it counts raw resources. TPUs require
|
||||
atomic, interconnected slices. This function checks the cluster for
|
||||
physically intact slices to prevent scaling onto fractional/broken
|
||||
topologies.
|
||||
|
||||
The worker count is: min(resource_based_slices, intact_slices) *
|
||||
workers_per_slice, where resource_based_slices =
|
||||
total_num_workers // workers_per_slice.
|
||||
|
||||
Args:
|
||||
total_num_workers: The initial estimate of workers based on raw
|
||||
allocated resources.
|
||||
|
||||
Returns:
|
||||
The number of workers aligned to fully intact TPU slices.
|
||||
"""
|
||||
from ray.util.tpu import get_num_tpu_slices, get_tpu_worker_resources
|
||||
|
||||
single_worker_resources = self.scaling_config._resources_per_worker_not_none
|
||||
|
||||
try:
|
||||
workers_per_slice, _ = get_tpu_worker_resources(
|
||||
topology=self.scaling_config.topology,
|
||||
accelerator_type=self.scaling_config.accelerator_type,
|
||||
resources_per_unit=single_worker_resources,
|
||||
num_slices=1,
|
||||
)
|
||||
|
||||
if workers_per_slice == 0:
|
||||
# A single worker requires more resources than exist in a
|
||||
# full slice — impossible scheduling configuration for TPU.
|
||||
return 0
|
||||
|
||||
num_slices_from_resources = total_num_workers // workers_per_slice
|
||||
|
||||
if num_slices_from_resources > 0:
|
||||
try:
|
||||
num_intact_slices = get_num_tpu_slices(
|
||||
topology=self.scaling_config.topology,
|
||||
accelerator_type=self.scaling_config.accelerator_type,
|
||||
)
|
||||
num_slices_from_resources = min(
|
||||
num_slices_from_resources, num_intact_slices
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check cluster state for intact TPU slices: {e}"
|
||||
)
|
||||
|
||||
return num_slices_from_resources * workers_per_slice
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not calculate TPU slice boundaries for elastic scaling: {e}. "
|
||||
"Worker counts may not align with TPU topology."
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
def _get_resize_decision(self, num_workers: int) -> ResizeDecision:
|
||||
return ResizeDecision(
|
||||
num_workers=num_workers,
|
||||
resources_per_worker=self.scaling_config._resources_per_worker_not_none,
|
||||
)
|
||||
|
||||
def make_decision_for_non_running_worker_group(self) -> ScalingDecision:
|
||||
self._maybe_send_resource_request()
|
||||
|
||||
allocated_resources = self._get_allocated_resources()
|
||||
if allocated_resources is None:
|
||||
return NoopDecision()
|
||||
|
||||
num_workers = self._count_possible_workers(allocated_resources)
|
||||
|
||||
if num_workers < self.scaling_config.min_workers:
|
||||
now = time_monotonic()
|
||||
# Only log this warning periodically to avoid spamming logs
|
||||
if (
|
||||
now - self._latest_insufficient_workers_warning_time
|
||||
>= self.INSUFFICIENT_WORKERS_WARNING_INTERVAL_S
|
||||
):
|
||||
logger.info(
|
||||
f"Detected ready resources for {num_workers} workers "
|
||||
"in the cluster. "
|
||||
"Deciding NOT to start/restart training due to the number of workers "
|
||||
"falling below the minimum "
|
||||
f"(min_workers={self.scaling_config.min_workers})."
|
||||
)
|
||||
self._latest_insufficient_workers_warning_time = now
|
||||
return NoopDecision()
|
||||
|
||||
logger.info(
|
||||
f"Detected ready resources for {num_workers} workers "
|
||||
"in the cluster. "
|
||||
"Deciding to start/restart training with this worker group size."
|
||||
)
|
||||
return self._get_resize_decision(num_workers)
|
||||
|
||||
def make_decision_for_running_worker_group(
|
||||
self,
|
||||
worker_group_state: WorkerGroupState,
|
||||
worker_group_status: WorkerGroupPollStatus,
|
||||
) -> ScalingDecision:
|
||||
self._maybe_send_resource_request()
|
||||
|
||||
# Ensure that we don't make resizing decisions too frequently.
|
||||
# The latest restart time and the latest monitor time (whichever is later)
|
||||
# determine the time of the next resize consideration.
|
||||
latest_consideration_time = max(
|
||||
worker_group_state.start_time, self._latest_monitor_time
|
||||
)
|
||||
|
||||
now = time_monotonic()
|
||||
time_since_latest_consideration = now - latest_consideration_time
|
||||
if (
|
||||
time_since_latest_consideration
|
||||
< self.scaling_config.elastic_resize_monitor_interval_s
|
||||
):
|
||||
logger.debug(
|
||||
"Skipping resize decision due to the latest resizing consideration "
|
||||
"happening too recently: "
|
||||
"%.2f seconds < ScalingConfig(elastic_resize_monitor_interval_s=%.2f seconds).",
|
||||
time_since_latest_consideration,
|
||||
self.scaling_config.elastic_resize_monitor_interval_s,
|
||||
)
|
||||
return NoopDecision()
|
||||
|
||||
self._latest_monitor_time = now
|
||||
|
||||
allocated_resources = self._get_allocated_resources()
|
||||
if allocated_resources is None:
|
||||
return NoopDecision()
|
||||
|
||||
num_workers = self._count_possible_workers(allocated_resources)
|
||||
|
||||
if num_workers == worker_group_state.num_workers:
|
||||
logger.info(
|
||||
"Did not detect any changes in the cluster resources. "
|
||||
"Training will continue with the same worker group size "
|
||||
f"({num_workers})."
|
||||
)
|
||||
return NoopDecision()
|
||||
elif num_workers < self.scaling_config.min_workers:
|
||||
# This covers an edge case where allocated resources decrease to less
|
||||
# than the minimum number of workers.
|
||||
# This situation is rare, since cluster downsizing typically involves
|
||||
# worker failures. However, this check is still useful to fully
|
||||
# avoid entering an invalid state with fewer workers than the minimum.
|
||||
return NoopDecision()
|
||||
|
||||
logger.info(
|
||||
"Detected changes in the cluster resources. "
|
||||
"Deciding to resize the worker group from "
|
||||
f"{worker_group_state.num_workers} -> {num_workers} workers."
|
||||
)
|
||||
return self._get_resize_decision(num_workers)
|
||||
|
||||
# ---------------------------------------------------
|
||||
# Methods for interacting with AutoscalingCoordinator
|
||||
# ---------------------------------------------------
|
||||
|
||||
def _get_allocated_resources(self) -> Optional[List["ResourceDict"]]:
|
||||
"""Get allocated resources from AutoscalingCoordinator.
|
||||
Return None if there is an error."""
|
||||
now = time_monotonic()
|
||||
time_since_last_call = now - self._latest_allocated_resources_query_time
|
||||
|
||||
if time_since_last_call < self.GET_ALLOCATED_RESOURCES_INTERVAL_S:
|
||||
return self._latest_allocated_resources
|
||||
|
||||
allocated_resources = None
|
||||
try:
|
||||
allocated_resources = ray.get(
|
||||
self._autoscaling_coordinator.get_allocated_resources.remote(
|
||||
self._requester_id
|
||||
),
|
||||
timeout=AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
|
||||
)
|
||||
except Exception:
|
||||
msg = (
|
||||
f"Failed to get allocated resources for {self._requester_id}."
|
||||
" Will not resize the worker group."
|
||||
" If this only happens transiently during network partition or"
|
||||
" CPU being overloaded, it's safe to ignore this error."
|
||||
" If this error persists, file a GitHub issue."
|
||||
)
|
||||
logger.warning(msg, exc_info=True)
|
||||
finally:
|
||||
self._latest_allocated_resources_query_time = time_monotonic()
|
||||
self._latest_allocated_resources = allocated_resources
|
||||
|
||||
return self._latest_allocated_resources
|
||||
@@ -0,0 +1,16 @@
|
||||
from ray.train.v2._internal.execution.scaling_policy import (
|
||||
ElasticScalingPolicy,
|
||||
FixedScalingPolicy,
|
||||
ScalingPolicy,
|
||||
)
|
||||
from ray.train.v2.api.config import ScalingConfig
|
||||
|
||||
|
||||
def create_scaling_policy(scaling_config: ScalingConfig) -> ScalingPolicy:
|
||||
"""Create a scaling policy from the given scaling config.
|
||||
|
||||
Defaults to the `FixedScalingPolicy` implementation.
|
||||
"""
|
||||
if scaling_config.elasticity_enabled:
|
||||
return ElasticScalingPolicy(scaling_config=scaling_config)
|
||||
return FixedScalingPolicy(scaling_config=scaling_config)
|
||||
@@ -0,0 +1,30 @@
|
||||
from ray.train.v2._internal.execution.scaling_policy import (
|
||||
NoopDecision,
|
||||
ResizeDecision,
|
||||
ScalingDecision,
|
||||
ScalingPolicy,
|
||||
)
|
||||
from ray.train.v2._internal.execution.worker_group import (
|
||||
WorkerGroupPollStatus,
|
||||
WorkerGroupState,
|
||||
)
|
||||
|
||||
|
||||
class FixedScalingPolicy(ScalingPolicy):
|
||||
def _get_num_workers_for_resource_request(self) -> int:
|
||||
return self.scaling_config.num_workers
|
||||
|
||||
def make_decision_for_non_running_worker_group(self) -> ScalingDecision:
|
||||
self._maybe_send_resource_request()
|
||||
return ResizeDecision(
|
||||
num_workers=self.scaling_config.num_workers,
|
||||
resources_per_worker=self.scaling_config._resources_per_worker_not_none,
|
||||
)
|
||||
|
||||
def make_decision_for_running_worker_group(
|
||||
self,
|
||||
worker_group_state: WorkerGroupState,
|
||||
worker_group_status: WorkerGroupPollStatus,
|
||||
) -> ScalingDecision:
|
||||
self._maybe_send_resource_request()
|
||||
return NoopDecision()
|
||||
@@ -0,0 +1,183 @@
|
||||
import abc
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import Dict
|
||||
|
||||
import ray
|
||||
from ray.train.v2._internal.execution.callback import ControllerCallback
|
||||
from ray.train.v2._internal.execution.context import TrainRunContext
|
||||
from ray.train.v2._internal.execution.worker_group import (
|
||||
WorkerGroupPollStatus,
|
||||
WorkerGroupState,
|
||||
)
|
||||
from ray.train.v2._internal.util import time_monotonic
|
||||
from ray.train.v2.api.config import ScalingConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The time in seconds after which an autoscaling request will expire.
|
||||
AUTOSCALING_REQUESTS_EXPIRE_TIME_S = 180
|
||||
# Timeout in seconds for getting the result of a call to the AutoscalingCoordinator.
|
||||
AUTOSCALING_REQUESTS_GET_TIMEOUT_S = 5
|
||||
# Interval in seconds between resource requests to the AutoscalingCoordinator.
|
||||
AUTOSCALING_REQUESTS_INTERVAL_S = 20
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScalingDecision:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class NoopDecision(ScalingDecision):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResizeDecision(ScalingDecision):
|
||||
num_workers: int
|
||||
resources_per_worker: Dict[str, float]
|
||||
|
||||
|
||||
class ScalingPolicy(abc.ABC, ControllerCallback):
|
||||
"""A policy that determines when and how to scale a worker group.
|
||||
|
||||
This can be used to implement elasticity and fault tolerance.
|
||||
|
||||
Recovery decisions are made when workers are in an inactive or unhealthy state.
|
||||
Upscale decisions are optional and are made when workers are healthy.
|
||||
|
||||
Note: When adding new scaling policies, revisit the shared defaults- particularly if:
|
||||
- AutoscalingCoordinator integration is not needed or a different interface
|
||||
becomes available
|
||||
- Timeout/expiry constants need to diverge between policies
|
||||
- _get_num_workers_for_resource_request() needs variable worker counts
|
||||
- Controller lifecycle behavior diverges
|
||||
"""
|
||||
|
||||
# TODO: Restructure these APIs to consider different TrainControllerStates
|
||||
# instead of just running and non-running worker groups.
|
||||
|
||||
def __init__(self, scaling_config: ScalingConfig):
|
||||
self.scaling_config = scaling_config
|
||||
self._requester_id = "train-" + uuid.uuid4().hex
|
||||
self._latest_autoscaling_request_time = float("-inf")
|
||||
|
||||
@abc.abstractmethod
|
||||
def make_decision_for_non_running_worker_group(self) -> ScalingDecision:
|
||||
"""Makes a scaling decision when the worker group is initializing
|
||||
or recovering from an error."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def make_decision_for_running_worker_group(
|
||||
self,
|
||||
worker_group_state: WorkerGroupState,
|
||||
worker_group_status: WorkerGroupPollStatus,
|
||||
) -> ScalingDecision:
|
||||
"""Makes a scaling decision when monitoring healthy, running workers."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_num_workers_for_resource_request(self) -> int:
|
||||
"""Return the number of workers to request resources for."""
|
||||
raise NotImplementedError
|
||||
|
||||
# ---------------------------------------------------
|
||||
# Methods for interacting with AutoscalingCoordinator
|
||||
# ---------------------------------------------------
|
||||
|
||||
@cached_property
|
||||
def _autoscaling_coordinator(self):
|
||||
from ray.data._internal.cluster_autoscaler.default_autoscaling_coordinator import (
|
||||
get_or_create_autoscaling_coordinator,
|
||||
)
|
||||
|
||||
return get_or_create_autoscaling_coordinator()
|
||||
|
||||
def _maybe_send_resource_request(self):
|
||||
"""Send a resource request to AutoscalingCoordinator,
|
||||
if AUTOSCALING_REQUESTS_INTERVAL_S has passed since the last send."""
|
||||
now = time_monotonic()
|
||||
if (
|
||||
now - self._latest_autoscaling_request_time
|
||||
< AUTOSCALING_REQUESTS_INTERVAL_S
|
||||
):
|
||||
return
|
||||
self._send_resource_request()
|
||||
|
||||
def _send_resource_request(self):
|
||||
"""Register training resources with the AutoscalingCoordinator."""
|
||||
resources_per_worker = self.scaling_config._resources_per_worker_not_none
|
||||
num_workers = self._get_num_workers_for_resource_request()
|
||||
label_selectors = self.scaling_config._label_selector_per_worker(num_workers)
|
||||
try:
|
||||
from ray.data._internal.cluster_autoscaler.default_autoscaling_coordinator import (
|
||||
ResourceRequestPriority,
|
||||
)
|
||||
|
||||
ray.get(
|
||||
self._autoscaling_coordinator.request_resources.remote(
|
||||
requester_id=self._requester_id,
|
||||
resources=[resources_per_worker] * num_workers,
|
||||
label_selectors=label_selectors,
|
||||
expire_after_s=AUTOSCALING_REQUESTS_EXPIRE_TIME_S,
|
||||
priority=ResourceRequestPriority.HIGH,
|
||||
),
|
||||
timeout=AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
|
||||
)
|
||||
self._latest_autoscaling_request_time = time_monotonic()
|
||||
except Exception:
|
||||
msg = (
|
||||
f"Failed to send resource request for {self._requester_id}."
|
||||
" If this only happens transiently during network partition or"
|
||||
" CPU being overloaded, it's safe to ignore this error."
|
||||
" If this error persists, file a GitHub issue."
|
||||
)
|
||||
logger.warning(msg, exc_info=True)
|
||||
|
||||
def _cancel_resource_request(self):
|
||||
"""Cancel the resource request to AutoscalingCoordinator."""
|
||||
try:
|
||||
ray.get(
|
||||
self._autoscaling_coordinator.cancel_request.remote(
|
||||
requester_id=self._requester_id,
|
||||
),
|
||||
timeout=AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
|
||||
)
|
||||
except Exception:
|
||||
msg = (
|
||||
f"Failed to cancel resource request for {self._requester_id}."
|
||||
" The request will still expire after the timeout of"
|
||||
f" {AUTOSCALING_REQUESTS_EXPIRE_TIME_S} seconds."
|
||||
)
|
||||
logger.warning(msg, exc_info=True)
|
||||
|
||||
# --------------------------
|
||||
# ControllerCallback
|
||||
# --------------------------
|
||||
|
||||
def after_controller_start(self, train_run_context: TrainRunContext):
|
||||
"""Register training resources with the AutoscalingCoordinator."""
|
||||
self._requester_id = f"train-{train_run_context.run_id}"
|
||||
resources_per_worker = self.scaling_config._resources_per_worker_not_none
|
||||
num_workers = self._get_num_workers_for_resource_request()
|
||||
label_selectors = self.scaling_config._label_selector_per_worker(num_workers)
|
||||
if label_selectors:
|
||||
logger.info(
|
||||
f"Requesting resources: {resources_per_worker} * {num_workers} "
|
||||
f"with label_selectors={label_selectors}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Requesting resources: {resources_per_worker} * {num_workers}")
|
||||
self._send_resource_request()
|
||||
|
||||
async def before_controller_shutdown(self):
|
||||
"""Cancel the resource request when the controller shuts down."""
|
||||
self._cancel_resource_request()
|
||||
|
||||
def before_controller_abort(self):
|
||||
"""Cancel the resource request when the controller is aborted."""
|
||||
self._cancel_resource_request()
|
||||
@@ -0,0 +1,573 @@
|
||||
# Try import ray[train] core requirements (defined in setup.py)
|
||||
# isort: off
|
||||
try:
|
||||
import fsspec # noqa
|
||||
from fsspec.implementations.local import LocalFileSystem
|
||||
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
raise RuntimeError(
|
||||
"fsspec is a required dependency of Ray Train and Ray Tune. "
|
||||
"Please install with: `pip install fsspec`"
|
||||
) from e
|
||||
|
||||
try:
|
||||
import pyarrow
|
||||
import pyarrow.fs
|
||||
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
raise RuntimeError(
|
||||
"pyarrow is a required dependency of Ray Train and Ray Tune. "
|
||||
"Please install with: `pip install pyarrow`"
|
||||
) from e
|
||||
# isort: on
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Type, Union
|
||||
|
||||
from ray.air._internal.filelock import TempFileLock
|
||||
from ray.train.constants import _get_ray_train_session_dir
|
||||
from ray.train.v2._internal.constants import (
|
||||
CHECKPOINT_MANAGER_SNAPSHOT_FILENAME,
|
||||
VALIDATE_STORAGE_MARKER_FILENAME,
|
||||
)
|
||||
from ray.train.v2._internal.util import date_str
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train import Checkpoint
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _ExcludingLocalFilesystem(LocalFileSystem):
|
||||
"""LocalFileSystem wrapper to exclude files according to patterns.
|
||||
|
||||
Args:
|
||||
root_path: Root path to strip when matching with the exclude pattern.
|
||||
Ex: root_path="/tmp/a/b/c", exclude=["*a*"], will exclude
|
||||
/tmp/a/b/c/_a_.txt but not ALL of /tmp/a/*.
|
||||
exclude: List of patterns that are applied to files returned by
|
||||
``self.find()``. If a file path matches this pattern, it will
|
||||
be excluded.
|
||||
**kwargs: Additional keyword arguments forwarded to
|
||||
``pyarrow.fs.LocalFileSystem``.
|
||||
"""
|
||||
|
||||
def __init__(self, root_path: Path, exclude: List[str], **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._exclude = exclude
|
||||
self._root_path = root_path
|
||||
|
||||
@property
|
||||
def fsid(self):
|
||||
return "_excluding_local"
|
||||
|
||||
def _should_exclude(self, path: str) -> bool:
|
||||
"""Return True if `path` (relative to `root_path`) matches any of the
|
||||
`self._exclude` patterns."""
|
||||
path = Path(path)
|
||||
relative_path = path.relative_to(self._root_path).as_posix()
|
||||
match_candidates = [relative_path]
|
||||
if path.is_dir():
|
||||
# Everything is in posix path format ('/')
|
||||
match_candidates.append(relative_path + "/")
|
||||
|
||||
for excl in self._exclude:
|
||||
if any(fnmatch.fnmatch(candidate, excl) for candidate in match_candidates):
|
||||
return True
|
||||
return False
|
||||
|
||||
def find(self, path, maxdepth=None, withdirs=False, detail=False, **kwargs):
|
||||
"""Call parent find() and exclude from result."""
|
||||
paths = super().find(
|
||||
path, maxdepth=maxdepth, withdirs=withdirs, detail=detail, **kwargs
|
||||
)
|
||||
if detail:
|
||||
return {
|
||||
path: out
|
||||
for path, out in paths.items()
|
||||
if not self._should_exclude(path)
|
||||
}
|
||||
else:
|
||||
return [path for path in paths if not self._should_exclude(path)]
|
||||
|
||||
|
||||
def _pyarrow_fs_copy_files(
|
||||
source, destination, source_filesystem=None, destination_filesystem=None, **kwargs
|
||||
):
|
||||
if isinstance(destination_filesystem, pyarrow.fs.S3FileSystem):
|
||||
# Workaround multi-threading issue with pyarrow. Note that use_threads=True
|
||||
# is safe for download, just not for uploads, see:
|
||||
# https://github.com/apache/arrow/issues/32372
|
||||
kwargs.setdefault("use_threads", False)
|
||||
|
||||
# Use a large chunk size to speed up large checkpoint transfers.
|
||||
kwargs.setdefault("chunk_size", 64 * 1024 * 1024)
|
||||
|
||||
return pyarrow.fs.copy_files(
|
||||
source,
|
||||
destination,
|
||||
source_filesystem=source_filesystem,
|
||||
destination_filesystem=destination_filesystem,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# TODO(justinvyu): Add unit tests for all these utils.
|
||||
|
||||
|
||||
def delete_fs_path(fs: pyarrow.fs.FileSystem, fs_path: str):
|
||||
"""Deletes (fs, fs_path) or raises FileNotFoundError if it doesn't exist."""
|
||||
is_dir = _is_directory(fs, fs_path)
|
||||
|
||||
try:
|
||||
if is_dir:
|
||||
fs.delete_dir(fs_path)
|
||||
else:
|
||||
fs.delete_file(fs_path)
|
||||
except Exception:
|
||||
logger.exception(f"Caught exception when deleting path at ({fs}, {fs_path}):")
|
||||
|
||||
|
||||
def _download_from_fs_path(
|
||||
fs: pyarrow.fs.FileSystem,
|
||||
fs_path: str,
|
||||
local_path: str,
|
||||
filelock: bool = True,
|
||||
):
|
||||
"""Downloads a directory or file from (fs, fs_path) to a local path.
|
||||
|
||||
If fs_path points to a directory:
|
||||
- The full directory contents are downloaded directly into `local_path`,
|
||||
rather than to a subdirectory of `local_path`.
|
||||
|
||||
If fs_path points to a file:
|
||||
- The file is downloaded to `local_path`, which is expected to be a file path.
|
||||
|
||||
If the download fails, the `local_path` contents are
|
||||
cleaned up before raising, if the directory did not previously exist.
|
||||
|
||||
NOTE: This method creates `local_path`'s parent directories if they do not
|
||||
already exist. If the download fails, this does NOT clean up all the parent
|
||||
directories that were created.
|
||||
|
||||
Args:
|
||||
fs: The filesystem to download from.
|
||||
fs_path: The filesystem path (either a directory or a file) to download.
|
||||
local_path: The local path to download to.
|
||||
filelock: Whether to require a file lock before downloading, useful for
|
||||
multiple downloads to the same directory that may be happening in parallel.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: if (fs, fs_path) doesn't exist.
|
||||
"""
|
||||
|
||||
_local_path = Path(local_path).resolve()
|
||||
exists_before = _local_path.exists()
|
||||
if _is_directory(fs=fs, fs_path=fs_path):
|
||||
_local_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
_local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if filelock:
|
||||
with TempFileLock(f"{os.path.normpath(local_path)}.lock"):
|
||||
_pyarrow_fs_copy_files(fs_path, local_path, source_filesystem=fs)
|
||||
else:
|
||||
_pyarrow_fs_copy_files(fs_path, local_path, source_filesystem=fs)
|
||||
except Exception as e:
|
||||
# Clean up the directory if downloading was unsuccessful
|
||||
if not exists_before:
|
||||
shutil.rmtree(local_path, ignore_errors=True)
|
||||
raise e
|
||||
|
||||
|
||||
def _upload_to_fs_path(
|
||||
local_path: str,
|
||||
fs: pyarrow.fs.FileSystem,
|
||||
fs_path: str,
|
||||
exclude: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Uploads a local directory or file to (fs, fs_path).
|
||||
|
||||
NOTE: This will create all necessary parent directories at the destination.
|
||||
|
||||
Args:
|
||||
local_path: The local path to upload.
|
||||
fs: The filesystem to upload to.
|
||||
fs_path: The filesystem path where the dir/file will be uploaded to.
|
||||
exclude: A list of filename matches to exclude from upload. This includes
|
||||
all files under subdirectories as well.
|
||||
This pattern will match with the relative paths of all files under
|
||||
`local_path`.
|
||||
Ex: ["*.png"] to exclude all .png images.
|
||||
"""
|
||||
|
||||
if not exclude:
|
||||
# TODO(justinvyu): uploading a single file doesn't work
|
||||
# (since we always create a directory at fs_path)
|
||||
_create_directory(fs=fs, fs_path=fs_path)
|
||||
_pyarrow_fs_copy_files(local_path, fs_path, destination_filesystem=fs)
|
||||
return
|
||||
|
||||
_upload_to_uri_with_exclude_fsspec(
|
||||
local_path=local_path, fs=fs, fs_path=fs_path, exclude=exclude
|
||||
)
|
||||
|
||||
|
||||
def _upload_to_uri_with_exclude_fsspec(
|
||||
local_path: str, fs: "pyarrow.fs", fs_path: str, exclude: Optional[List[str]]
|
||||
) -> None:
|
||||
local_fs = _ExcludingLocalFilesystem(root_path=local_path, exclude=exclude)
|
||||
handler = pyarrow.fs.FSSpecHandler(local_fs)
|
||||
source_fs = pyarrow.fs.PyFileSystem(handler)
|
||||
|
||||
_create_directory(fs=fs, fs_path=fs_path)
|
||||
_pyarrow_fs_copy_files(
|
||||
local_path, fs_path, source_filesystem=source_fs, destination_filesystem=fs
|
||||
)
|
||||
|
||||
|
||||
def _list_at_fs_path(
|
||||
fs: pyarrow.fs.FileSystem,
|
||||
fs_path: str,
|
||||
file_filter: Callable[[pyarrow.fs.FileInfo], bool] = lambda x: True,
|
||||
) -> List[str]:
|
||||
"""Returns the list of filenames at (fs, fs_path), similar to os.listdir.
|
||||
|
||||
If the path doesn't exist, returns an empty list.
|
||||
"""
|
||||
selector = pyarrow.fs.FileSelector(fs_path, allow_not_found=True, recursive=False)
|
||||
return [
|
||||
os.path.relpath(file_info.path.lstrip("/"), start=fs_path.lstrip("/"))
|
||||
for file_info in fs.get_file_info(selector)
|
||||
if file_filter(file_info)
|
||||
]
|
||||
|
||||
|
||||
def _exists_at_fs_path(fs: pyarrow.fs.FileSystem, fs_path: str) -> bool:
|
||||
"""Returns True if (fs, fs_path) exists."""
|
||||
|
||||
valid = fs.get_file_info(fs_path)
|
||||
return valid.type != pyarrow.fs.FileType.NotFound
|
||||
|
||||
|
||||
def _is_directory(fs: pyarrow.fs.FileSystem, fs_path: str) -> bool:
|
||||
"""Checks if (fs, fs_path) is a directory or a file.
|
||||
|
||||
Args:
|
||||
fs: The filesystem to query.
|
||||
fs_path: The path on the filesystem.
|
||||
|
||||
Returns:
|
||||
``True`` if the path is a directory, ``False`` if it is a file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: if (fs, fs_path) doesn't exist.
|
||||
"""
|
||||
|
||||
file_info = fs.get_file_info(fs_path)
|
||||
if file_info.type == pyarrow.fs.FileType.NotFound:
|
||||
raise FileNotFoundError(f"Path not found: ({fs}, {fs_path})")
|
||||
|
||||
return not file_info.is_file
|
||||
|
||||
|
||||
def _create_directory(fs: pyarrow.fs.FileSystem, fs_path: str) -> None:
|
||||
"""Create directory at (fs, fs_path).
|
||||
|
||||
Some external filesystems require directories to already exist, or at least
|
||||
the `netloc` to be created (e.g. PyArrows ``mock://`` filesystem).
|
||||
|
||||
Generally this should be done before and outside of Ray applications. This
|
||||
utility is thus primarily used in testing, e.g. of ``mock://` URIs.
|
||||
"""
|
||||
try:
|
||||
fs.create_dir(fs_path)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Caught exception when creating directory at ({fs}, {fs_path}):"
|
||||
)
|
||||
|
||||
|
||||
def get_fs_and_path(
|
||||
storage_path: Union[str, os.PathLike],
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
|
||||
) -> Tuple[pyarrow.fs.FileSystem, str]:
|
||||
"""Returns the fs and path from a storage path and an optional custom fs.
|
||||
|
||||
Args:
|
||||
storage_path: A storage path or URI. (ex: s3://bucket/path or /tmp/ray_results)
|
||||
storage_filesystem: A custom filesystem to use. If not provided,
|
||||
this will be auto-resolved by pyarrow. If provided, the storage_path
|
||||
is assumed to be prefix-stripped already, and must be a valid path
|
||||
on the filesystem.
|
||||
|
||||
Returns:
|
||||
A ``(filesystem, path)`` tuple.
|
||||
"""
|
||||
storage_path = str(storage_path)
|
||||
|
||||
if storage_filesystem:
|
||||
return storage_filesystem, storage_path
|
||||
|
||||
return pyarrow.fs.FileSystem.from_uri(storage_path)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class StorageContext:
|
||||
"""Shared context that holds the source of truth for all paths and
|
||||
storage utilities, passed along from the driver to workers.
|
||||
|
||||
This object defines a few types of paths:
|
||||
1. *_fs_path: A path on the `storage_filesystem`. This is a regular path
|
||||
which has been prefix-stripped by pyarrow.fs.FileSystem.from_uri and
|
||||
can be joined with `Path(...).as_posix()`.
|
||||
2. *_driver_staging_path: The temporary staging directory on the local filesystem
|
||||
where driver artifacts are saved to before persisting them to storage.
|
||||
3. trial_working_directory: The local filesystem path that the remote
|
||||
actors' working directories are moved to by default.
|
||||
This is separated from the driver staging path so that driver syncing
|
||||
does not implicitly upload the trial working directory, for trials on the
|
||||
driver node.
|
||||
|
||||
Example with storage_path="mock:///bucket/path?param=1":
|
||||
|
||||
>>> import ray
|
||||
>>> from ray.train._internal.storage import StorageContext
|
||||
>>> import os
|
||||
>>> _ = ray.init()
|
||||
>>> storage = StorageContext(
|
||||
... storage_path="mock://netloc/bucket/path?param=1",
|
||||
... experiment_dir_name="exp_name",
|
||||
... )
|
||||
>>> storage.storage_filesystem # Auto-resolved # doctest: +ELLIPSIS
|
||||
<pyarrow._fs._MockFileSystem object...
|
||||
>>> storage.experiment_fs_path
|
||||
'bucket/path/exp_name'
|
||||
>>> storage.experiment_driver_staging_path # doctest: +ELLIPSIS
|
||||
'/tmp/ray/session_.../artifacts/.../exp_name/driver_artifacts'
|
||||
>>> storage.trial_dir_name = "trial_dir"
|
||||
>>> storage.trial_fs_path
|
||||
'bucket/path/exp_name/trial_dir'
|
||||
>>> storage.trial_driver_staging_path # doctest: +ELLIPSIS
|
||||
'/tmp/ray/session_.../artifacts/.../exp_name/driver_artifacts/trial_dir'
|
||||
>>> storage.trial_working_directory # doctest: +ELLIPSIS
|
||||
'/tmp/ray/session_.../artifacts/.../exp_name/working_dirs/trial_dir'
|
||||
>>> ray.shutdown()
|
||||
|
||||
Example with storage_path="/tmp/ray_results":
|
||||
|
||||
>>> from ray.train._internal.storage import StorageContext
|
||||
>>> storage = StorageContext(
|
||||
... storage_path="/tmp/ray_results",
|
||||
... experiment_dir_name="exp_name",
|
||||
... )
|
||||
>>> storage.storage_fs_path
|
||||
'/tmp/ray_results'
|
||||
>>> storage.experiment_fs_path
|
||||
'/tmp/ray_results/exp_name'
|
||||
>>> storage.storage_filesystem # Auto-resolved # doctest: +ELLIPSIS
|
||||
<pyarrow._fs.LocalFileSystem object...
|
||||
|
||||
Internal Usage Examples:
|
||||
- To copy files to the trial directory on the storage filesystem:
|
||||
|
||||
pyarrow.fs.copy_files(
|
||||
local_dir,
|
||||
Path(storage.trial_fs_path, "subdir").as_posix(),
|
||||
destination_filesystem=storage.filesystem
|
||||
)
|
||||
|
||||
.. warning::
|
||||
This is an experimental developer API and is subject to change
|
||||
without notice between versions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage_path: Union[str, os.PathLike],
|
||||
experiment_dir_name: str,
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
|
||||
read_only: bool = False,
|
||||
):
|
||||
self.custom_fs_provided = storage_filesystem is not None
|
||||
|
||||
# Invariant: (`storage_filesystem`, `storage_path`) is the location where
|
||||
# *all* results can be accessed.
|
||||
self.experiment_dir_name = experiment_dir_name
|
||||
|
||||
self.storage_filesystem, self.storage_fs_path = get_fs_and_path(
|
||||
storage_path, storage_filesystem
|
||||
)
|
||||
self.storage_fs_path = Path(self.storage_fs_path).as_posix()
|
||||
|
||||
self.read_only = read_only
|
||||
if not self.read_only:
|
||||
self._create_validation_file()
|
||||
self._check_validation_file()
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
"StorageContext<\n"
|
||||
f" storage_filesystem='{self.storage_filesystem.type_name}',\n"
|
||||
f" storage_fs_path='{self.storage_fs_path}',\n"
|
||||
f" experiment_dir_name='{self.experiment_dir_name}',\n"
|
||||
">"
|
||||
)
|
||||
|
||||
def _create_validation_file(self):
|
||||
"""On the creation of a storage context, create a validation file at the
|
||||
storage path to verify that the storage path can be written to.
|
||||
This validation file is also used to check whether the storage path is
|
||||
accessible by all nodes in the cluster."""
|
||||
valid_file = Path(
|
||||
self.experiment_fs_path, VALIDATE_STORAGE_MARKER_FILENAME
|
||||
).as_posix()
|
||||
self.storage_filesystem.create_dir(self.experiment_fs_path)
|
||||
with self.storage_filesystem.open_output_stream(valid_file):
|
||||
pass
|
||||
|
||||
def _check_validation_file(self):
|
||||
"""Checks that the validation file exists at the storage path."""
|
||||
valid_file = Path(
|
||||
self.experiment_fs_path, VALIDATE_STORAGE_MARKER_FILENAME
|
||||
).as_posix()
|
||||
if not _exists_at_fs_path(fs=self.storage_filesystem, fs_path=valid_file):
|
||||
raise RuntimeError(
|
||||
f"Unable to set up cluster storage with the following settings:\n{self}"
|
||||
"\nCheck that all nodes in the cluster have read/write access "
|
||||
"to the configured storage path. `RunConfig(storage_path)` should be "
|
||||
"set to a cloud storage URI or a shared filesystem path accessible "
|
||||
"by all nodes in your cluster ('s3://bucket' or '/mnt/nfs'). "
|
||||
"A local path on the head node is not accessible by worker nodes. "
|
||||
"See: https://docs.ray.io/en/latest/train/user-guides/persistent-storage.html" # noqa: E501
|
||||
)
|
||||
|
||||
def persist_current_checkpoint(
|
||||
self, checkpoint: "Checkpoint", checkpoint_dir_name: str
|
||||
) -> "Checkpoint":
|
||||
"""Persists a given checkpoint to the current checkpoint path on the filesystem.
|
||||
|
||||
This method copies the checkpoint files to the storage location.
|
||||
It's up to the user to delete the original checkpoint files if desired.
|
||||
|
||||
For example, the original directory is typically a local temp directory.
|
||||
|
||||
Args:
|
||||
checkpoint: The checkpoint to persist to
|
||||
(fs, experiment_fs_path / checkpoint_dir_name).
|
||||
checkpoint_dir_name: Name of the destination directory for the
|
||||
checkpoint, relative to ``experiment_fs_path``.
|
||||
|
||||
Returns:
|
||||
Checkpoint: A Checkpoint pointing to the persisted checkpoint location.
|
||||
"""
|
||||
if self.read_only:
|
||||
raise RuntimeError(
|
||||
"Cannot perform write/validation operations as the StorageContext is read-only."
|
||||
)
|
||||
|
||||
# TODO(justinvyu): Fix this cyclical import.
|
||||
from ray.train import Checkpoint
|
||||
|
||||
checkpoint_fs_path = self.build_checkpoint_path_from_name(checkpoint_dir_name)
|
||||
|
||||
logger.debug(
|
||||
"Copying checkpoint files to storage path:\n"
|
||||
"({source_fs}, {source}) -> ({dest_fs}, {destination})".format(
|
||||
source=checkpoint.path,
|
||||
destination=checkpoint_fs_path,
|
||||
source_fs=checkpoint.filesystem,
|
||||
dest_fs=self.storage_filesystem,
|
||||
)
|
||||
)
|
||||
|
||||
# Raise an error if the storage path is not accessible when
|
||||
# attempting to upload a checkpoint from a remote worker.
|
||||
# Ex: If storage_path is a local path, then a validation marker
|
||||
# will only exist on the head node but not the worker nodes.
|
||||
self._check_validation_file()
|
||||
|
||||
self.storage_filesystem.create_dir(checkpoint_fs_path)
|
||||
_pyarrow_fs_copy_files(
|
||||
source=checkpoint.path,
|
||||
destination=checkpoint_fs_path,
|
||||
source_filesystem=checkpoint.filesystem,
|
||||
destination_filesystem=self.storage_filesystem,
|
||||
)
|
||||
|
||||
persisted_checkpoint = Checkpoint(
|
||||
filesystem=self.storage_filesystem,
|
||||
path=checkpoint_fs_path,
|
||||
)
|
||||
logger.info(f"Checkpoint successfully created at: {persisted_checkpoint}")
|
||||
return persisted_checkpoint
|
||||
|
||||
@property
|
||||
def experiment_fs_path(self) -> str:
|
||||
"""The path on the `storage_filesystem` to the experiment directory.
|
||||
|
||||
NOTE: This does not have a URI prefix anymore, since it has been stripped
|
||||
by pyarrow.fs.FileSystem.from_uri already. The URI scheme information is
|
||||
kept in `storage_filesystem` instead.
|
||||
"""
|
||||
return Path(self.storage_fs_path, self.experiment_dir_name).as_posix()
|
||||
|
||||
@property
|
||||
def local_working_directory(self) -> str:
|
||||
"""Every ray train worker will set this directory as its working directory."""
|
||||
if self.experiment_dir_name is None:
|
||||
raise RuntimeError(
|
||||
"Cannot access `local_working_directory` without "
|
||||
"setting `experiment_dir_name`"
|
||||
)
|
||||
return Path(_get_ray_train_session_dir(), self.experiment_dir_name).as_posix()
|
||||
|
||||
@property
|
||||
def checkpoint_manager_snapshot_path(self) -> str:
|
||||
"""The path to the checkpoint manager snapshot file."""
|
||||
return Path(
|
||||
self.experiment_fs_path, CHECKPOINT_MANAGER_SNAPSHOT_FILENAME
|
||||
).as_posix()
|
||||
|
||||
@staticmethod
|
||||
def get_experiment_dir_name(run_obj: Union[str, Callable, Type]) -> str:
|
||||
from ray.tune.experiment import Experiment
|
||||
|
||||
run_identifier = Experiment.get_trainable_name(run_obj)
|
||||
|
||||
if bool(int(os.environ.get("TUNE_DISABLE_DATED_SUBDIR", 0))):
|
||||
dir_name = run_identifier
|
||||
else:
|
||||
dir_name = "{}_{}".format(run_identifier, date_str())
|
||||
return dir_name
|
||||
|
||||
@staticmethod
|
||||
def make_default_checkpoint_dir_name():
|
||||
"""Get the name of the checkpoint directory by timestamp."""
|
||||
return f"checkpoint_{date_str(include_ms=True)}"
|
||||
|
||||
def extract_checkpoint_dir_name_from_path(self, checkpoint_path: str) -> str:
|
||||
"""Get the checkpoint name from the checkpoint path.
|
||||
The parent directory of the checkpoint path should be the experiment directory.
|
||||
"""
|
||||
# TODO: Use Pathlib to extract the name when supports at least Python 3.9
|
||||
experiment_fs_path = self.experiment_fs_path + "/"
|
||||
if not checkpoint_path.startswith(experiment_fs_path):
|
||||
raise ValueError(
|
||||
f"Checkpoint path {checkpoint_path} is not under the experiment "
|
||||
f"directory {self.experiment_fs_path}."
|
||||
)
|
||||
return checkpoint_path[len(experiment_fs_path) :]
|
||||
|
||||
def build_checkpoint_path_from_name(self, checkpoint_name: str) -> str:
|
||||
"""Get the checkpoint path from the checkpoint name.
|
||||
The parent directory of the checkpoint path should be the experiment directory.
|
||||
"""
|
||||
return Path(self.experiment_fs_path, checkpoint_name).as_posix()
|
||||
@@ -0,0 +1,297 @@
|
||||
import logging
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from ray.train.v2._internal.data_integration.interfaces import DatasetShardMetadata
|
||||
from ray.train.v2._internal.execution import collective_impl
|
||||
from ray.train.v2._internal.execution.context import (
|
||||
get_train_context as get_internal_train_context,
|
||||
)
|
||||
from ray.train.v2.api.context import (
|
||||
DistributedTrainContext,
|
||||
LocalTrainContext,
|
||||
TrainContext as ExternalTrainContext,
|
||||
)
|
||||
from ray.train.v2.api.report_config import (
|
||||
CheckpointConsistencyMode,
|
||||
CheckpointUploadMode,
|
||||
)
|
||||
from ray.train.v2.api.validation_config import ValidationTaskConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data import DataIterator
|
||||
from ray.train import Checkpoint
|
||||
from ray.train.v2.api.reported_checkpoint import ReportedCheckpoint
|
||||
|
||||
|
||||
class TrainFnUtils(ABC):
|
||||
"""Utility class providing an abstraction layer between user-facing APIs
|
||||
and :class:`~ray.train.v2.api.context.TrainContext`.
|
||||
|
||||
It should be set before the users' training function is called.
|
||||
This class can be patched if new user APIs behaviors is wanted.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def report(
|
||||
self,
|
||||
metrics: Dict[str, Any],
|
||||
checkpoint: Optional["Checkpoint"] = None,
|
||||
checkpoint_dir_name: Optional[str] = None,
|
||||
checkpoint_upload_mode: CheckpointUploadMode = CheckpointUploadMode.SYNC,
|
||||
delete_local_checkpoint_after_upload: Optional[bool] = None,
|
||||
checkpoint_upload_fn: Optional[
|
||||
Callable[["Checkpoint", str], "Checkpoint"]
|
||||
] = None,
|
||||
validation: Union[bool, ValidationTaskConfig] = False,
|
||||
) -> None:
|
||||
"""Upload checkpoint to remote storage and put a training result on the result queue.
|
||||
|
||||
Args:
|
||||
metrics: The metrics to report.
|
||||
checkpoint: The checkpoint to report.
|
||||
checkpoint_dir_name: The name of the checkpoint dir
|
||||
in this iteration. Note: If not set, the checkpoint will
|
||||
be stored in the default storage path. If set, make sure
|
||||
this value is unique for each iteration.
|
||||
checkpoint_upload_mode: The manner in which we want to upload the checkpoint.
|
||||
Defaults to uploading the checkpoint synchronously.
|
||||
This works when no checkpoint is provided but is not useful in that case.
|
||||
delete_local_checkpoint_after_upload: Whether to delete the checkpoint after it is uploaded.
|
||||
checkpoint_upload_fn: A user defined function that will be called with the
|
||||
checkpoint to upload it. If not provided, defaults to using the `pyarrow.fs.copy_files`
|
||||
utility for copying to the destination `storage_path`.
|
||||
validation: [Alpha] If True, triggers validation with default kwargs from validation_config.
|
||||
If a ValidationTaskConfig, validation is run using fn_kwargs merged with validation_config
|
||||
defaults, with fn_kwargs taking precedence on conflicts. If False, no validation.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_checkpoint(self) -> Optional["Checkpoint"]:
|
||||
"""Get the latest checkpoint to resume training from.
|
||||
|
||||
Returns:
|
||||
The latest checkpoint if available, None otherwise.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_all_reported_checkpoints(
|
||||
self,
|
||||
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
|
||||
timeout_s: Optional[float] = None,
|
||||
) -> List["ReportedCheckpoint"]:
|
||||
"""Get all the checkpoints reported by the workers.
|
||||
|
||||
Args:
|
||||
consistency_mode: Read semantics for checkpoint retrieval. Defaults to VALIDATED.
|
||||
timeout_s: Timeout in seconds for reading checkpoints and validation data.
|
||||
Defaults to ``None`` to not time out.
|
||||
|
||||
Returns:
|
||||
A list of ReportedCheckpoint objects that represent the checkpoints and
|
||||
corresponding metrics reported by the workers.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_dataset_shard(self, dataset_info: DatasetShardMetadata) -> "DataIterator":
|
||||
"""Get the dataset shard for this training process.
|
||||
|
||||
Args:
|
||||
dataset_info: The metadata of the dataset to get the shard for.
|
||||
|
||||
Returns:
|
||||
The DataIterator shard for this worker.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_context(self) -> ExternalTrainContext:
|
||||
"""Get the TrainContext for this training process.
|
||||
The specific type of TrainContext returned depends on the implementation of TrainFnUtils.
|
||||
|
||||
Returns:
|
||||
The train context for this training process.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_distributed(self) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def barrier(self) -> None:
|
||||
"""Create a barrier across all workers.
|
||||
|
||||
All workers must call this method before the training function can continue.
|
||||
|
||||
This method is used by the public API function :func:`ray.train.collective.barrier`.
|
||||
Users should typically call ``ray.train.collective.barrier()`` instead of calling this method directly.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def broadcast_from_rank_zero(self, data: Any) -> Any:
|
||||
"""Broadcast data from the rank 0 worker to all other workers.
|
||||
|
||||
This method is used by the public API function :func:`ray.train.collective.broadcast_from_rank_zero`.
|
||||
Users should typically call ``ray.train.collective.broadcast_from_rank_zero()`` instead of calling this method directly.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class DistributedTrainFnUtils(TrainFnUtils):
|
||||
def report(
|
||||
self,
|
||||
metrics: Dict[str, Any],
|
||||
checkpoint: Optional["Checkpoint"] = None,
|
||||
checkpoint_dir_name: Optional[str] = None,
|
||||
checkpoint_upload_mode: CheckpointUploadMode = CheckpointUploadMode.SYNC,
|
||||
delete_local_checkpoint_after_upload: Optional[bool] = None,
|
||||
checkpoint_upload_fn: Optional[
|
||||
Callable[["Checkpoint", str], "Checkpoint"]
|
||||
] = None,
|
||||
validation: Union[bool, ValidationTaskConfig] = False,
|
||||
) -> None:
|
||||
return get_internal_train_context().report(
|
||||
metrics,
|
||||
checkpoint,
|
||||
checkpoint_dir_name,
|
||||
checkpoint_upload_mode,
|
||||
delete_local_checkpoint_after_upload,
|
||||
checkpoint_upload_fn,
|
||||
validation,
|
||||
)
|
||||
|
||||
def get_checkpoint(self):
|
||||
return get_internal_train_context().get_checkpoint()
|
||||
|
||||
def get_dataset_shard(self, dataset_info: DatasetShardMetadata) -> "DataIterator":
|
||||
return get_internal_train_context().get_dataset_shard(dataset_info)
|
||||
|
||||
def get_context(self) -> DistributedTrainContext:
|
||||
return DistributedTrainContext()
|
||||
|
||||
def is_distributed(self) -> bool:
|
||||
return True
|
||||
|
||||
def barrier(self) -> None:
|
||||
return collective_impl.barrier()
|
||||
|
||||
def broadcast_from_rank_zero(self, data: Any) -> Any:
|
||||
return collective_impl.broadcast_from_rank_zero(data)
|
||||
|
||||
def get_all_reported_checkpoints(
|
||||
self,
|
||||
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
|
||||
timeout_s: Optional[float] = None,
|
||||
) -> List["ReportedCheckpoint"]:
|
||||
return get_internal_train_context().get_all_reported_checkpoints(
|
||||
consistency_mode=consistency_mode, timeout_s=timeout_s
|
||||
)
|
||||
|
||||
|
||||
class LocalTrainFnUtils(TrainFnUtils):
|
||||
def __init__(
|
||||
self,
|
||||
experiment_name: str,
|
||||
dataset_shards: Optional[Dict[str, "DataIterator"]] = None,
|
||||
world_size: int = 1,
|
||||
world_rank: int = 0,
|
||||
local_rank: int = 0,
|
||||
local_world_size: int = 1,
|
||||
node_rank: int = 0,
|
||||
):
|
||||
self._context = LocalTrainContext(
|
||||
experiment_name=experiment_name,
|
||||
world_size=world_size,
|
||||
world_rank=world_rank,
|
||||
local_rank=local_rank,
|
||||
local_world_size=local_world_size,
|
||||
node_rank=node_rank,
|
||||
)
|
||||
self._dataset_shards = dataset_shards
|
||||
self._last_metrics = None
|
||||
self._last_checkpoint = None
|
||||
|
||||
def report(
|
||||
self,
|
||||
metrics: Dict[str, Any],
|
||||
checkpoint: Optional["Checkpoint"] = None,
|
||||
checkpoint_dir_name: Optional[str] = None,
|
||||
checkpoint_upload_mode: CheckpointUploadMode = CheckpointUploadMode.SYNC,
|
||||
delete_local_checkpoint_after_upload: Optional[bool] = None,
|
||||
checkpoint_upload_fn: Optional[
|
||||
Callable[["Checkpoint", str], "Checkpoint"]
|
||||
] = None,
|
||||
validation: Union[bool, ValidationTaskConfig] = False,
|
||||
) -> None:
|
||||
self._last_metrics = metrics
|
||||
self._last_checkpoint = checkpoint
|
||||
logger.info(f"Reported metrics: {metrics}")
|
||||
|
||||
def get_checkpoint(self) -> Optional["Checkpoint"]:
|
||||
return self._last_checkpoint
|
||||
|
||||
def get_dataset_shard(self, dataset_info: DatasetShardMetadata) -> "DataIterator":
|
||||
dataset_name = dataset_info.dataset_name
|
||||
assert (
|
||||
self._dataset_shards is not None and dataset_name in self._dataset_shards
|
||||
), f"Dataset shard {dataset_name} not found."
|
||||
return self._dataset_shards[dataset_name]
|
||||
|
||||
def get_context(self) -> LocalTrainContext:
|
||||
return self._context
|
||||
|
||||
def is_distributed(self) -> bool:
|
||||
return False
|
||||
|
||||
def barrier(self) -> None:
|
||||
pass
|
||||
|
||||
def broadcast_from_rank_zero(self, data: Any) -> Any:
|
||||
return data
|
||||
|
||||
def _get_last_metrics(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return the last metrics reported by the training function.
|
||||
This function should only be called by LocalController
|
||||
"""
|
||||
return self._last_metrics
|
||||
|
||||
def get_all_reported_checkpoints(
|
||||
self,
|
||||
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
|
||||
timeout_s: Optional[float] = None,
|
||||
) -> List["ReportedCheckpoint"]:
|
||||
return []
|
||||
|
||||
|
||||
_train_fn_utils: Optional[TrainFnUtils] = None
|
||||
_train_fn_utils_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_train_fn_utils() -> TrainFnUtils:
|
||||
"""Return the Ray Train function utilities.
|
||||
|
||||
Returns:
|
||||
The TrainFnUtils instance for the current worker.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the Ray Train function utilities are not initialized.
|
||||
"""
|
||||
global _train_fn_utils
|
||||
with _train_fn_utils_lock:
|
||||
if _train_fn_utils is None:
|
||||
raise RuntimeError("Ray Train function utilities not initialized.")
|
||||
return _train_fn_utils
|
||||
|
||||
|
||||
def set_train_fn_utils(train_fn_utils) -> None:
|
||||
global _train_fn_utils
|
||||
with _train_fn_utils_lock:
|
||||
_train_fn_utils = train_fn_utils
|
||||
@@ -0,0 +1,22 @@
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train import Checkpoint
|
||||
from ray.train.v2.api.validation_config import ValidationTaskConfig
|
||||
|
||||
|
||||
class _TrainingReport:
|
||||
"""Checkpoint and metrics reported by user, as well as optional validation configuration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
checkpoint: Optional["Checkpoint"],
|
||||
metrics: Dict[str, Any],
|
||||
validation: Union[bool, "ValidationTaskConfig"],
|
||||
):
|
||||
self.checkpoint = checkpoint
|
||||
self.metrics = metrics
|
||||
self.validation = validation
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TrainingReport(checkpoint={self.checkpoint}, metrics={self.metrics}, validation={self.validation})"
|
||||
@@ -0,0 +1,31 @@
|
||||
from .execution_group import ExecutionGroup, ReplicaGroup
|
||||
from .placement_group_handle import (
|
||||
DefaultPlacementGroupHandle,
|
||||
PlacementGroupHandle,
|
||||
SlicePlacementGroupHandle,
|
||||
)
|
||||
from .poll import WorkerGroupPollStatus, WorkerStatus
|
||||
from .state import (
|
||||
WorkerGroupContext,
|
||||
WorkerGroupState,
|
||||
WorkerGroupStateBuilder,
|
||||
)
|
||||
from .worker import ActorMetadata, RayTrainWorker, Worker
|
||||
from .worker_group import WorkerGroup
|
||||
|
||||
__all__ = [
|
||||
"ActorMetadata",
|
||||
"DefaultPlacementGroupHandle",
|
||||
"ExecutionGroup",
|
||||
"PlacementGroupHandle",
|
||||
"RayTrainWorker",
|
||||
"ReplicaGroup",
|
||||
"SlicePlacementGroupHandle",
|
||||
"Worker",
|
||||
"WorkerGroup",
|
||||
"WorkerGroupContext",
|
||||
"WorkerGroupPollStatus",
|
||||
"WorkerGroupState",
|
||||
"WorkerGroupStateBuilder",
|
||||
"WorkerStatus",
|
||||
]
|
||||
@@ -0,0 +1,117 @@
|
||||
import abc
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, TypeVar
|
||||
|
||||
import ray
|
||||
from ray.train._internal.base_worker_group import BaseWorkerGroup
|
||||
from ray.train.v2._internal.execution.worker_group.state import _shutdown_workers
|
||||
from ray.train.v2._internal.execution.worker_group.worker import Worker
|
||||
from ray.types import ObjectRef
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train.v2._internal.execution.callback import ReplicaGroupCallback
|
||||
from ray.train.v2._internal.execution.worker_group.state import WorkerGroupContext
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ExecutionGroup(BaseWorkerGroup):
|
||||
"""Base class for groups that can execute functions on workers.
|
||||
|
||||
Provides concrete implementations of the 4 execution methods and __len__
|
||||
based on two abstract primitives: _assert_active() and get_workers().
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _assert_active(self):
|
||||
"""Assert that this execution group is active."""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_workers(self) -> List[Worker]:
|
||||
"""Return the list of workers in this group."""
|
||||
pass
|
||||
|
||||
def execute_async(self, fn: Callable, *fn_args, **fn_kwargs) -> List[ObjectRef]:
|
||||
self._assert_active()
|
||||
workers = self.get_workers()
|
||||
|
||||
return [worker.execute_async(fn, *fn_args, **fn_kwargs) for worker in workers]
|
||||
|
||||
def execute(self, fn: Callable[..., T], *fn_args, **fn_kwargs) -> List[T]:
|
||||
return ray.get(self.execute_async(fn, *fn_args, **fn_kwargs))
|
||||
|
||||
def execute_single_async(
|
||||
self, rank: int, fn: Callable[..., T], *fn_args, **fn_kwargs
|
||||
) -> ObjectRef:
|
||||
self._assert_active()
|
||||
workers = self.get_workers()
|
||||
|
||||
if rank >= len(workers):
|
||||
raise ValueError(
|
||||
f"The provided {rank=} is " f"not valid for {len(workers)} workers."
|
||||
)
|
||||
|
||||
return workers[rank].execute_async(fn, *fn_args, **fn_kwargs)
|
||||
|
||||
def execute_single(
|
||||
self, rank: int, fn: Callable[..., T], *fn_args, **fn_kwargs
|
||||
) -> T:
|
||||
return ray.get(self.execute_single_async(rank, fn, *fn_args, **fn_kwargs))
|
||||
|
||||
def __len__(self) -> int:
|
||||
self._assert_active()
|
||||
return len(self.get_workers())
|
||||
|
||||
|
||||
class ReplicaGroup(ExecutionGroup):
|
||||
"""A group representing a subset of workers from a WorkerGroup.
|
||||
|
||||
Used to pass a replica's workers to backend methods (on_start, etc.)
|
||||
as if they were a standalone worker group.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workers: List[Worker],
|
||||
resources_per_worker: dict,
|
||||
callbacks: Optional[List["ReplicaGroupCallback"]] = None,
|
||||
):
|
||||
self._workers = workers
|
||||
self._resources_per_worker = resources_per_worker
|
||||
self._callbacks = callbacks or []
|
||||
# An inactive ReplicaGroup still needs to keep track of workers
|
||||
# so we can replace them later.
|
||||
self._active = True
|
||||
|
||||
def _assert_active(self):
|
||||
if not self.is_active():
|
||||
raise ValueError("ReplicaGroup has been shut down.")
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
def get_workers(self) -> List[Worker]:
|
||||
return self._workers
|
||||
|
||||
def get_resources_per_worker(self) -> dict:
|
||||
return self._resources_per_worker
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown all workers in this replica group and clear state."""
|
||||
if self.is_active():
|
||||
for cb in self._callbacks:
|
||||
cb.before_replica_group_shutdown(self)
|
||||
|
||||
_shutdown_workers(self._workers)
|
||||
self._active = False
|
||||
|
||||
def start_training(self, worker_group_context: "WorkerGroupContext"):
|
||||
"""Start training on all workers in this replica group."""
|
||||
for cb in self._callbacks:
|
||||
cb.after_replica_group_start(self)
|
||||
ray.get(
|
||||
[
|
||||
worker.actor.run_train_fn.remote(worker_group_context.train_fn_ref)
|
||||
for worker in self._workers
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
from ray.types import ObjectRef
|
||||
from ray.util.placement_group import PlacementGroup, remove_placement_group
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.util.tpu import SlicePlacementGroup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlacementGroupHandle(ABC):
|
||||
"""Unified interface for placement groups in Ray Train.
|
||||
|
||||
This abstract base class provides a common interface for both standard
|
||||
PlacementGroup and SlicePlacementGroup, allowing WorkerGroup to handle
|
||||
them uniformly without conditional logic.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def placement_group(self) -> PlacementGroup:
|
||||
"""The underlying PlacementGroup for worker scheduling."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def ready(self) -> ObjectRef:
|
||||
"""Returns an ObjectRef to check if the placement group is ready.
|
||||
|
||||
Compatible with ray.get() and ray.wait().
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def wait(self, timeout_seconds: Union[float, int] = 30) -> bool:
|
||||
"""Wait for the placement group to be ready within the specified time.
|
||||
Args:
|
||||
timeout_seconds: Timeout in seconds.
|
||||
Returns:
|
||||
True if the placement group is created. False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def shutdown(self) -> None:
|
||||
"""Release all resources associated with this placement group.
|
||||
|
||||
After calling this method, the placement group should no longer be used.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class DefaultPlacementGroupHandle(PlacementGroupHandle):
|
||||
"""Wrapper for standard PlacementGroup."""
|
||||
|
||||
def __init__(self, pg: PlacementGroup):
|
||||
self._pg = pg
|
||||
|
||||
@property
|
||||
def placement_group(self) -> PlacementGroup:
|
||||
return self._pg
|
||||
|
||||
def ready(self) -> ObjectRef:
|
||||
return self._pg.ready()
|
||||
|
||||
def wait(self, timeout_seconds: Union[float, int] = 30) -> bool:
|
||||
try:
|
||||
return self._pg.wait(timeout_seconds)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Placement group wait failed; treating as not ready.",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def shutdown(self) -> None:
|
||||
remove_placement_group(self._pg)
|
||||
|
||||
|
||||
class SlicePlacementGroupHandle(PlacementGroupHandle):
|
||||
"""Wrapper for SlicePlacementGroup that delegates to its underlying PlacementGroup."""
|
||||
|
||||
def __init__(self, spg: "SlicePlacementGroup"):
|
||||
self._spg = spg
|
||||
|
||||
@property
|
||||
def placement_group(self) -> PlacementGroup:
|
||||
return self._spg.placement_group
|
||||
|
||||
def ready(self) -> ObjectRef:
|
||||
return self._spg.placement_group.ready()
|
||||
|
||||
def wait(self, timeout_seconds: Union[float, int] = 30) -> bool:
|
||||
try:
|
||||
return self._spg.placement_group.wait(timeout_seconds)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Slice placement group wait failed; treating as not ready.",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._spg.shutdown()
|
||||
@@ -0,0 +1,152 @@
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from ray._private.ray_logging import NUMBERS
|
||||
from ray.train.v2._internal.exceptions import (
|
||||
UserExceptionWithTraceback,
|
||||
WorkerHealthCheckFailedError,
|
||||
)
|
||||
from ray.train.v2._internal.execution.preemption import PreemptionInfo
|
||||
from ray.train.v2._internal.execution.training_report import _TrainingReport
|
||||
from ray.train.v2.api.exceptions import WorkerGroupError
|
||||
from ray.types import ObjectRef
|
||||
|
||||
ERR_CHAR_LIMIT = 1000
|
||||
|
||||
|
||||
def _normalize_error_string(error_str: str) -> str:
|
||||
# Replace numbers with <NUM> based on NUMBERS regex
|
||||
normalized = re.sub(NUMBERS, "<NUM>", error_str)
|
||||
return normalized
|
||||
|
||||
|
||||
def _truncate_error_string(error_str: str) -> str:
|
||||
"""
|
||||
Truncates error strings to include the first ERR_CHAR_LIMIT // 2
|
||||
characters and the last ERR_CHAR_LIMIT // 2 characters.
|
||||
"""
|
||||
if len(error_str) >= ERR_CHAR_LIMIT:
|
||||
return (
|
||||
error_str[: ERR_CHAR_LIMIT // 2]
|
||||
+ "...\n... (Output truncated. See individual worker logs for full details) ...\n"
|
||||
+ error_str[len(error_str) - ERR_CHAR_LIMIT // 2 :]
|
||||
)
|
||||
return error_str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerStatus:
|
||||
running: bool
|
||||
error: Optional[Exception] = None
|
||||
training_report: Optional[_TrainingReport] = None
|
||||
return_value: Any = field(default=None)
|
||||
preemption_info: Optional[PreemptionInfo] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerGroupPollStatus:
|
||||
worker_statuses: Dict[int, WorkerStatus]
|
||||
worker_rank_to_replica_group_rank: Optional[Dict[int, int]] = None
|
||||
|
||||
@property
|
||||
def all_replica_group_indices(self) -> Set[int]:
|
||||
"""Return the set of all replica group indices."""
|
||||
if self.worker_rank_to_replica_group_rank is None:
|
||||
return set()
|
||||
return set(self.worker_rank_to_replica_group_rank.values())
|
||||
|
||||
@property
|
||||
def failing_replica_group_indices(self) -> Set[int]:
|
||||
"""Return the set of replica group indices that have failing workers."""
|
||||
if self.worker_rank_to_replica_group_rank is None:
|
||||
return set()
|
||||
return {
|
||||
self.worker_rank_to_replica_group_rank[rank]
|
||||
for rank in self.errors
|
||||
if rank in self.worker_rank_to_replica_group_rank
|
||||
}
|
||||
|
||||
@property
|
||||
def errors(self) -> Dict[int, Exception]:
|
||||
errors = {}
|
||||
for world_rank, status in self.worker_statuses.items():
|
||||
if status.error is not None:
|
||||
error = status.error
|
||||
if isinstance(error, UserExceptionWithTraceback):
|
||||
error = error._base_exc
|
||||
errors[world_rank] = error
|
||||
return errors
|
||||
|
||||
def get_worker_group_error(self) -> WorkerGroupError:
|
||||
return WorkerGroupError(
|
||||
error_message=self.get_error_string(),
|
||||
worker_failures=self.errors,
|
||||
)
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
return self.worker_statuses and all(
|
||||
not status.running for status in self.worker_statuses.values()
|
||||
)
|
||||
|
||||
def get_error_string(self) -> str:
|
||||
"""
|
||||
Returns a string representation of worker group errors.
|
||||
Groups similar errors (ignoring numbers) and shows original error examples.
|
||||
"""
|
||||
# Group errors by normalized strings (ignoring numbers)
|
||||
normalized_error_to_ranks = defaultdict(list)
|
||||
normalized_error_to_original = {}
|
||||
show_full_error = set()
|
||||
|
||||
for world_rank, status in self.worker_statuses.items():
|
||||
if status.error:
|
||||
error_str = str(status.error)
|
||||
normalized_error = _normalize_error_string(error_str)
|
||||
|
||||
normalized_error_to_ranks[normalized_error].append(str(world_rank))
|
||||
|
||||
# Store the first original error for this normalized group
|
||||
if normalized_error not in normalized_error_to_original:
|
||||
normalized_error_to_original[normalized_error] = error_str
|
||||
|
||||
# Fully show errors for non-graceful worker failures or running workers
|
||||
if (
|
||||
isinstance(status.error, WorkerHealthCheckFailedError)
|
||||
or status.running
|
||||
):
|
||||
show_full_error.add(normalized_error)
|
||||
|
||||
errors = []
|
||||
for normalized_error, ranks in normalized_error_to_ranks.items():
|
||||
# Show the original error
|
||||
orig_error = normalized_error_to_original[normalized_error]
|
||||
|
||||
# Convert rank list to comma-separated strings
|
||||
ranks_str = ",".join(ranks)
|
||||
|
||||
if normalized_error in show_full_error:
|
||||
errors.append(f"[Rank {ranks_str} Error Snippet]:\n{orig_error}")
|
||||
else:
|
||||
errors.append(
|
||||
f"[Rank {ranks_str} Error Snippet]:\n{_truncate_error_string(orig_error)}"
|
||||
)
|
||||
|
||||
error_str = "\n".join(errors)
|
||||
|
||||
return error_str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollTask:
|
||||
"""Represents a poll task for a worker.
|
||||
|
||||
Attributes:
|
||||
start_time: The time when the poll task was started.
|
||||
task: The ObjectRef representing the poll task.
|
||||
"""
|
||||
|
||||
start_time: float
|
||||
task: ObjectRef
|
||||
@@ -0,0 +1,170 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
|
||||
|
||||
import ray
|
||||
from ray.actor import ActorHandle
|
||||
from ray.train.v2._internal.execution.checkpoint.sync_actor import SynchronizationActor
|
||||
from ray.train.v2._internal.execution.worker_group.worker import Worker
|
||||
from ray.train.v2._internal.util import ObjectRefWrapper, time_monotonic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train.v2._internal.execution.worker_group.placement_group_handle import (
|
||||
PlacementGroupHandle,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerGroupContext:
|
||||
"""Context for a worker group.
|
||||
|
||||
This stores the context that is shared when starting a worker group.
|
||||
|
||||
Attributes:
|
||||
run_attempt_id: The ID of the run attempt.
|
||||
train_fn_ref: An object store reference to the training function to execute.
|
||||
num_workers: The number of workers in the worker group.
|
||||
resources_per_worker: The resources per worker.
|
||||
placement_strategy: Strategy for placing workers.
|
||||
label_selector: Optional label selectors to apply per-bundle for workers.
|
||||
num_slices: The number of TPU slices (if using TPU). Defaults to 1.
|
||||
"""
|
||||
|
||||
run_attempt_id: str
|
||||
train_fn_ref: ObjectRefWrapper[Callable[[], None]]
|
||||
num_workers: int
|
||||
resources_per_worker: Dict[str, float]
|
||||
placement_strategy: str = "PACK"
|
||||
label_selector: Optional[List[Dict[str, str]]] = None
|
||||
num_slices: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerGroupState:
|
||||
"""Ongoing state of an active worker group.
|
||||
|
||||
Attributes:
|
||||
start_time: The time when the worker group was started.
|
||||
workers: The workers in the worker group.
|
||||
These should always be in sorted order by world rank.
|
||||
placement_group_handle: The placement group handle for the worker group.
|
||||
sync_actor: The synchronization actor for the worker group.
|
||||
"""
|
||||
|
||||
start_time: float
|
||||
placement_group_handle: "PlacementGroupHandle"
|
||||
workers: List[Worker]
|
||||
sync_actor: ActorHandle
|
||||
|
||||
@property
|
||||
def num_workers(self) -> int:
|
||||
return len(self.workers)
|
||||
|
||||
def replace_workers(
|
||||
self, old_workers: List[Worker], new_workers: List[Worker]
|
||||
) -> "WorkerGroupState":
|
||||
"""Return a new WorkerGroupState with old_workers replaced by new_workers."""
|
||||
current_workers = list(self.workers)
|
||||
for old_w, new_w in zip(old_workers, new_workers):
|
||||
idx = current_workers.index(old_w)
|
||||
current_workers[idx] = new_w
|
||||
return WorkerGroupState(
|
||||
start_time=self.start_time,
|
||||
placement_group_handle=self.placement_group_handle,
|
||||
workers=current_workers,
|
||||
sync_actor=self.sync_actor,
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
_shutdown_workers(self.workers)
|
||||
_shutdown_sync_actor(self.sync_actor)
|
||||
self.placement_group_handle.shutdown()
|
||||
|
||||
|
||||
class WorkerGroupStateBuilder:
|
||||
"""Builder for WorkerGroupState.
|
||||
|
||||
Example usage:
|
||||
```python
|
||||
builder = WorkerGroupStateBuilder()
|
||||
builder.with_placement_group_handle(placement_group_handle)
|
||||
builder.with_workers(workers)
|
||||
builder.with_sync_actor(sync_actor)
|
||||
state = builder.build()
|
||||
|
||||
builder.shutdown(patience_s=10)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.placement_group_handle = None
|
||||
self.workers = None
|
||||
self.sync_actor = None
|
||||
|
||||
def with_placement_group_handle(
|
||||
self, placement_group_handle: "PlacementGroupHandle"
|
||||
) -> "WorkerGroupStateBuilder":
|
||||
self.placement_group_handle = placement_group_handle
|
||||
return self
|
||||
|
||||
def with_workers(self, workers: List[Worker]) -> "WorkerGroupStateBuilder":
|
||||
self.workers = workers
|
||||
return self
|
||||
|
||||
def with_sync_actor(
|
||||
self, sync_actor: SynchronizationActor
|
||||
) -> "WorkerGroupStateBuilder":
|
||||
self.sync_actor = sync_actor
|
||||
return self
|
||||
|
||||
def build(self) -> WorkerGroupState:
|
||||
required_attrs = {
|
||||
"placement_group_handle": self.placement_group_handle,
|
||||
"workers": self.workers,
|
||||
"sync_actor": self.sync_actor,
|
||||
}
|
||||
missing = [name for name, attr in required_attrs.items() if attr is None]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Cannot build incomplete state. Missing: {', '.join(missing)}"
|
||||
)
|
||||
return WorkerGroupState(
|
||||
start_time=time_monotonic(),
|
||||
placement_group_handle=self.placement_group_handle,
|
||||
workers=self.workers,
|
||||
sync_actor=self.sync_actor,
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
if self.workers:
|
||||
_shutdown_workers(self.workers)
|
||||
self.workers = None
|
||||
|
||||
if self.sync_actor:
|
||||
_shutdown_sync_actor(self.sync_actor)
|
||||
self.sync_actor = None
|
||||
|
||||
if self.placement_group_handle:
|
||||
self.placement_group_handle.shutdown()
|
||||
self.placement_group_handle = None
|
||||
|
||||
|
||||
def _shutdown_workers(workers: List[Worker], patience_s: float = 5):
|
||||
"""Shuts down workers after allowing a maximum of patience_s seconds for shutdown hooks to run."""
|
||||
if patience_s < 0:
|
||||
raise ValueError("Invalid patience_s: must be non-negative")
|
||||
|
||||
done_refs = [w.actor.shutdown.remote() for w in workers]
|
||||
|
||||
logger.debug(f"Shutting down {len(workers)} workers.")
|
||||
|
||||
ray.wait(done_refs, num_returns=len(done_refs), timeout=patience_s)
|
||||
|
||||
for worker in workers:
|
||||
ray.kill(worker.actor)
|
||||
|
||||
|
||||
def _shutdown_sync_actor(sync_actor: SynchronizationActor):
|
||||
ray.kill(sync_actor)
|
||||
@@ -0,0 +1,93 @@
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from typing import Callable, Optional, TypeVar
|
||||
|
||||
from ray.train.v2._internal.exceptions import UserExceptionWithTraceback
|
||||
from ray.train.v2._internal.util import (
|
||||
construct_user_exception_with_traceback,
|
||||
get_callable_name,
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThreadRunner:
|
||||
"""Utility to run a user function as a thread and capture its return value
|
||||
or exception.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._ret: Optional[T] = None
|
||||
self._exc: Optional[UserExceptionWithTraceback] = None
|
||||
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
self._exc_queue: queue.SimpleQueue[Optional[Exception]] = queue.SimpleQueue()
|
||||
|
||||
def run(self, target: Callable[[], T]) -> None:
|
||||
if self._thread is not None:
|
||||
raise RuntimeError("Thread is already running.")
|
||||
|
||||
def _run_target():
|
||||
try:
|
||||
result = target()
|
||||
with self._lock:
|
||||
self._ret = result
|
||||
self._exc_queue.put(None)
|
||||
except BaseException as e:
|
||||
# Exclude the first 3 frames from the traceback, which are
|
||||
# the `ThreadRunner._run_target`, `construct_train_func`, and
|
||||
# train_fn_with_final_checkpoint_flush calls.
|
||||
self._exc_queue.put(
|
||||
construct_user_exception_with_traceback(e, exclude_frames=3)
|
||||
)
|
||||
|
||||
# Join the monitor thread. This ensures that a queued exception
|
||||
# is processed before the target function is considered done.
|
||||
self._monitor_thread.join()
|
||||
|
||||
self._monitor_thread = threading.Thread(
|
||||
target=self._monitor_target,
|
||||
daemon=True,
|
||||
name=f"MonitoringThread({get_callable_name(target)})",
|
||||
)
|
||||
self._monitor_thread.start()
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=_run_target,
|
||||
daemon=True,
|
||||
name=f"TrainingThread({get_callable_name(target)})",
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _monitor_target(self):
|
||||
"""Monitor the exception queue and set the exception if an exception is found.
|
||||
|
||||
This should run as a daemon thread and exit when None is put into the exception queue.
|
||||
"""
|
||||
exc: Optional[UserExceptionWithTraceback] = self._exc_queue.get()
|
||||
if exc is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._exc = exc
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Returns whether the target function is still running."""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def get_error(self) -> Optional[BaseException]:
|
||||
with self._lock:
|
||||
return self._exc
|
||||
|
||||
def get_return_value(self) -> Optional[T]:
|
||||
with self._lock:
|
||||
return self._ret
|
||||
|
||||
def get_exception_queue(self) -> queue.SimpleQueue:
|
||||
"""Returns a queue that nested threads can add exceptions to."""
|
||||
return self._exc_queue
|
||||
@@ -0,0 +1,319 @@
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, TypeVar, Union
|
||||
|
||||
import ray
|
||||
import ray._private.ray_constants as ray_constants
|
||||
from .thread_runner import ThreadRunner
|
||||
from ray.actor import ActorHandle
|
||||
from ray.train import Checkpoint
|
||||
from ray.train.v2._internal.constants import (
|
||||
DEFAULT_ENABLE_WORKER_LOGGING,
|
||||
ENABLE_WORKER_STRUCTURED_LOGGING_ENV_VAR,
|
||||
)
|
||||
from ray.train.v2._internal.execution.callback import (
|
||||
TrainContextCallback,
|
||||
WorkerCallback,
|
||||
)
|
||||
from ray.train.v2._internal.execution.context import (
|
||||
DistributedContext,
|
||||
ExecutionContext,
|
||||
TrainContext,
|
||||
TrainRunContext,
|
||||
get_train_context,
|
||||
set_train_context,
|
||||
)
|
||||
from ray.train.v2._internal.execution.preemption import (
|
||||
PreemptionContext,
|
||||
PreemptionInfo,
|
||||
)
|
||||
from ray.train.v2._internal.execution.storage import StorageContext
|
||||
from ray.train.v2._internal.execution.train_fn_utils import (
|
||||
DistributedTrainFnUtils,
|
||||
set_train_fn_utils,
|
||||
)
|
||||
from ray.train.v2._internal.execution.worker_group.poll import WorkerStatus
|
||||
from ray.train.v2._internal.logging.logging import LoggingManager
|
||||
from ray.train.v2._internal.logging.patch_print import patch_print_function
|
||||
from ray.train.v2._internal.util import ObjectRefWrapper
|
||||
from ray.types import ObjectRef
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train.v2._internal.data_integration.interfaces import DatasetShardProvider
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActorMetadata:
|
||||
hostname: str
|
||||
node_id: str
|
||||
node_ip: str
|
||||
pid: int
|
||||
accelerator_ids: Dict[str, List[Union[int, str]]]
|
||||
|
||||
@property
|
||||
def gpu_ids(self) -> List[Union[int, str]]:
|
||||
return self.accelerator_ids.get("GPU", [])
|
||||
|
||||
@cached_property
|
||||
def _repr(self) -> str:
|
||||
indent = " "
|
||||
repr_lines = [
|
||||
"ActorMetadata(",
|
||||
f"{indent}hostname={repr(self.hostname)},",
|
||||
f"{indent}node_id={repr(self.node_id)},",
|
||||
f"{indent}node_ip={repr(self.node_ip)},",
|
||||
f"{indent}pid={repr(self.pid)},",
|
||||
]
|
||||
non_empty_accelerator_ids = {k: v for k, v in self.accelerator_ids.items() if v}
|
||||
if non_empty_accelerator_ids:
|
||||
repr_lines.append(f"{indent}accelerator_ids={non_empty_accelerator_ids},")
|
||||
|
||||
repr_lines.append(")")
|
||||
return "\n".join(repr_lines)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self._repr
|
||||
|
||||
|
||||
@dataclass
|
||||
class Worker:
|
||||
actor: ActorHandle
|
||||
metadata: ActorMetadata
|
||||
resources: Dict[str, float]
|
||||
distributed_context: Optional[DistributedContext] = None
|
||||
log_file_path: Optional[str] = None
|
||||
placement_group_bundle_index: Optional[int] = None
|
||||
|
||||
@cached_property
|
||||
def _repr(self) -> str:
|
||||
indent = " "
|
||||
metadata_repr = repr(self.metadata).replace("\n", f"\n{indent}")
|
||||
context_repr = repr(self.distributed_context).replace("\n", f"\n{indent}")
|
||||
|
||||
repr_lines = [
|
||||
"Worker(",
|
||||
f"{indent}actor={repr(self.actor)},",
|
||||
f"{indent}metadata={metadata_repr},",
|
||||
f"{indent}distributed_context={context_repr},",
|
||||
f"{indent}log_file_path={repr(self.log_file_path)},",
|
||||
")",
|
||||
]
|
||||
return "\n".join(repr_lines)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self._repr
|
||||
|
||||
def execute_async(self, fn: Callable[..., T], *fn_args, **fn_kwargs) -> ObjectRef:
|
||||
"""Execute ``func`` on worker.
|
||||
|
||||
Args:
|
||||
fn: The function to execute on the worker.
|
||||
*fn_args: Positional arguments to forward to ``fn``.
|
||||
**fn_kwargs: Keyword arguments to forward to ``fn``.
|
||||
|
||||
Returns:
|
||||
(ObjectRef) An ObjectRef representing the output of func.
|
||||
|
||||
"""
|
||||
return self.actor.execute.options(name=f"execute.{fn.__name__}").remote(
|
||||
fn, *fn_args, **fn_kwargs
|
||||
)
|
||||
|
||||
|
||||
class RayTrainWorker:
|
||||
def __init__(self):
|
||||
self._callbacks: List[WorkerCallback] = []
|
||||
|
||||
def execute(self, fn: Callable[..., T], *fn_args, **fn_kwargs) -> T:
|
||||
return fn(*fn_args, **fn_kwargs)
|
||||
|
||||
def run_train_fn(self, train_fn_ref: ObjectRefWrapper[Callable[[], None]]):
|
||||
"""Run the training function in a separate thread.
|
||||
|
||||
This function should return immediately, freeing up the main actor thread
|
||||
to perform other tasks such as polling the status.
|
||||
"""
|
||||
try:
|
||||
train_fn = train_fn_ref.get()
|
||||
except Exception as e:
|
||||
logger.error(f"Error deserializing the training function: {e}")
|
||||
raise
|
||||
|
||||
def train_fn_with_final_checkpoint_flush():
|
||||
result = train_fn()
|
||||
get_train_context().checkpoint_upload_threadpool.shutdown()
|
||||
|
||||
if "torch" in sys.modules:
|
||||
from ray.air._internal.torch_utils import contains_tensor
|
||||
|
||||
if contains_tensor(result):
|
||||
raise ValueError(
|
||||
"Returning objects containing Torch tensors from the "
|
||||
"training function is not supported as it will throw an "
|
||||
"exception on deserialization. You can either convert "
|
||||
"the tensors to Python objects (ex: `.numpy()`, "
|
||||
"`.item()`, etc.) or save tensors as part of the "
|
||||
"checkpoint files instead."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# Create and start the training thread.
|
||||
logger.debug(
|
||||
f"Rank {get_train_context().get_world_rank()}: Launching training function."
|
||||
)
|
||||
get_train_context().execution_context.training_thread_runner.run(
|
||||
train_fn_with_final_checkpoint_flush
|
||||
)
|
||||
|
||||
def get_metadata(self) -> ActorMetadata:
|
||||
return ActorMetadata(
|
||||
hostname=socket.gethostname(),
|
||||
node_id=ray.get_runtime_context().get_node_id(),
|
||||
node_ip=ray.util.get_node_ip_address(),
|
||||
pid=os.getpid(),
|
||||
accelerator_ids=ray.get_runtime_context().get_accelerator_ids(),
|
||||
)
|
||||
|
||||
def mark_preempt(self, info: PreemptionInfo) -> None:
|
||||
"""Store an incoming preemption signal for the UDF to read.
|
||||
|
||||
Called by the PreemptionWatcher on every worker when a preemption
|
||||
affecting the worker group is detected.
|
||||
"""
|
||||
train_context = get_train_context()
|
||||
rank = train_context.get_world_rank()
|
||||
train_context.preemption_context.set(info)
|
||||
logger.info(
|
||||
"Rank %d received preemption signal "
|
||||
"(this_worker_preempted=%s, preempted_ranks=%s, deadline_ms=%s).",
|
||||
rank,
|
||||
rank in info.preempted_ranks,
|
||||
info.preempted_ranks,
|
||||
info.deadline_ms,
|
||||
)
|
||||
|
||||
def poll_status(self) -> WorkerStatus:
|
||||
train_context = get_train_context()
|
||||
execution_context = train_context.execution_context
|
||||
|
||||
# TODO: We can implement two phase commit here.
|
||||
# Only mark the task done when the result has been processed by the controller.
|
||||
try:
|
||||
training_report = execution_context.result_queue.get_nowait()
|
||||
execution_context.result_queue.task_done()
|
||||
except queue.Empty:
|
||||
training_report = None
|
||||
|
||||
error = execution_context.training_thread_runner.get_error()
|
||||
|
||||
# TODO: The running state should not be conflated with queue flushing.
|
||||
# Running should only be true if the user code is still running.
|
||||
# This relies on `worker_group_status.finished` returning False
|
||||
# until all training results have been flushed.
|
||||
running = execution_context.training_thread_runner.is_running() or bool(
|
||||
training_report
|
||||
)
|
||||
|
||||
return_value = (
|
||||
execution_context.training_thread_runner.get_return_value()
|
||||
if not running
|
||||
else None
|
||||
)
|
||||
|
||||
return WorkerStatus(
|
||||
running=running,
|
||||
error=error,
|
||||
training_report=training_report,
|
||||
return_value=return_value,
|
||||
preemption_info=train_context.preemption_context.get(),
|
||||
)
|
||||
|
||||
def clear_result_queue(self) -> bool:
|
||||
"""Drain the result queue, discarding any pending training reports.
|
||||
|
||||
Returns:
|
||||
True if the queue had at least one result, False if it was empty.
|
||||
"""
|
||||
execution_context = get_train_context().execution_context
|
||||
had_result = False
|
||||
while True:
|
||||
try:
|
||||
execution_context.result_queue.get_nowait()
|
||||
execution_context.result_queue.task_done()
|
||||
had_result = True
|
||||
except queue.Empty:
|
||||
break
|
||||
return had_result
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the worker.
|
||||
|
||||
This method is not doing the real shutdown, but it is used by the worker
|
||||
group to signal the worker to stop running the training function.
|
||||
Any shutdown worker callbacks can hook on this method to implement the
|
||||
corresponding shutdown logic. Note that the shutdown logic needs to be
|
||||
thread-safe if it is running in a separate thread.
|
||||
"""
|
||||
for callback in self._callbacks:
|
||||
callback.before_worker_shutdown()
|
||||
|
||||
def init_train_context(
|
||||
self,
|
||||
train_run_context: TrainRunContext,
|
||||
distributed_context: DistributedContext,
|
||||
synchronization_actor: ActorHandle,
|
||||
storage_context: StorageContext,
|
||||
worker_callbacks: List[Union[WorkerCallback, TrainContextCallback]],
|
||||
controller_actor: ActorHandle,
|
||||
dataset_shard_provider: Optional["DatasetShardProvider"] = None,
|
||||
checkpoint: Optional[Checkpoint] = None,
|
||||
has_validation_fn: Optional[bool] = None,
|
||||
current_report_index: int = 0,
|
||||
):
|
||||
self._callbacks = [c for c in worker_callbacks if isinstance(c, WorkerCallback)]
|
||||
context_callbacks_to_propagate = [
|
||||
c for c in worker_callbacks if isinstance(c, TrainContextCallback)
|
||||
]
|
||||
context = TrainContext(
|
||||
train_run_context=train_run_context,
|
||||
distributed_context=distributed_context,
|
||||
execution_context=ExecutionContext(
|
||||
synchronization_actor=synchronization_actor,
|
||||
# Make the queue size 1 to avoid building up too
|
||||
# many unprocessed results.
|
||||
result_queue=queue.Queue(maxsize=1),
|
||||
training_thread_runner=ThreadRunner(),
|
||||
train_context_callbacks=context_callbacks_to_propagate,
|
||||
),
|
||||
storage_context=storage_context,
|
||||
preemption_context=PreemptionContext(),
|
||||
controller_actor=controller_actor,
|
||||
checkpoint=checkpoint,
|
||||
dataset_shard_provider=dataset_shard_provider,
|
||||
has_validation_fn=has_validation_fn,
|
||||
current_report_index=current_report_index,
|
||||
)
|
||||
# Configure the train and root logger for the worker processes.
|
||||
if ray_constants.env_bool(
|
||||
ENABLE_WORKER_STRUCTURED_LOGGING_ENV_VAR, DEFAULT_ENABLE_WORKER_LOGGING
|
||||
):
|
||||
LoggingManager.configure_worker_logger(context)
|
||||
patch_print_function()
|
||||
# Set the train context global variable for the worker.
|
||||
set_train_context(context)
|
||||
|
||||
# user facing train fn utils
|
||||
set_train_fn_utils(DistributedTrainFnUtils())
|
||||
|
||||
for callback in self._callbacks:
|
||||
callback.after_init_train_context()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user