Files
2026-07-13 13:17:40 +08:00

430 lines
14 KiB
Python

import abc
import logging
import threading
import time
import traceback
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
from ray._private.thirdparty.tabulate.tabulate import tabulate
from ray.util.annotations import Deprecated, DeveloperAPI
from ray.widgets import Template
logger = logging.getLogger(__name__)
# Syncing period for syncing checkpoints between nodes or to cloud.
DEFAULT_SYNC_PERIOD = 300
# Default sync timeout after which syncing processes are aborted
DEFAULT_SYNC_TIMEOUT = 1800
@Deprecated
@dataclass
class SyncConfig:
sync_period: int = DEFAULT_SYNC_PERIOD
sync_timeout: int = DEFAULT_SYNC_TIMEOUT
sync_artifacts: bool = False
sync_artifacts_on_checkpoint: bool = True
def _repr_html_(self) -> str:
"""Generate an HTML representation of the SyncConfig."""
return Template("scrollableTable.html.j2").render(
table=tabulate(
{
"Setting": ["Sync period", "Sync timeout"],
"Value": [self.sync_period, self.sync_timeout],
},
tablefmt="html",
showindex=False,
headers="keys",
),
max_height="none",
)
class _BackgroundProcess:
def __init__(self, fn: Callable):
self._fn = fn
self._process = None
self._result = {}
self._start_time = float("-inf")
@property
def is_running(self):
return self._process and self._process.is_alive()
@property
def start_time(self):
return self._start_time
def start(self, *args, **kwargs):
if self.is_running:
return False
self._result = {}
def entrypoint():
try:
result = self._fn(*args, **kwargs)
except Exception as e:
self._result["exception"] = e
return
self._result["result"] = result
self._process = threading.Thread(target=entrypoint)
self._process.daemon = True
self._process.start()
self._start_time = time.time()
def wait(self, timeout: Optional[float] = None) -> Any:
"""Waits for the background process to finish running. Waits until the
background process has run for at least `timeout` seconds, counting from
the time when the process was started."""
if not self._process:
return None
time_remaining = None
if timeout:
elapsed = time.time() - self.start_time
time_remaining = max(timeout - elapsed, 0)
self._process.join(timeout=time_remaining)
if self._process.is_alive():
self._process = None
raise TimeoutError(
f"{getattr(self._fn, '__name__', str(self._fn))} did not finish "
f"running within the timeout of {timeout} seconds."
)
self._process = None
exception = self._result.get("exception")
if exception:
raise exception
result = self._result.get("result")
self._result = {}
return result
@DeveloperAPI
class Syncer(abc.ABC):
"""Syncer class for synchronizing data between Ray nodes and remote (cloud) storage.
This class handles data transfer for two cases:
1. Synchronizing data such as experiment state snapshots from the driver to
cloud storage.
2. Synchronizing data such as trial checkpoints from remote trainables to
cloud storage.
Synchronizing tasks are usually asynchronous and can be awaited using ``wait()``.
The base class implements a ``wait_or_retry()`` API that will retry a failed
sync command.
The base class also exposes an API to only kick off syncs every ``sync_period``
seconds.
Args:
sync_period: The minimum time in seconds between sync operations, as
used by ``sync_up/down_if_needed``.
sync_timeout: The maximum time to wait for a sync process to finish before
issuing a new sync operation. Ex: should be used by ``wait`` if launching
asynchronous sync tasks.
"""
def __init__(
self,
sync_period: float = DEFAULT_SYNC_PERIOD,
sync_timeout: float = DEFAULT_SYNC_TIMEOUT,
):
self.sync_period = sync_period
self.sync_timeout = sync_timeout
self.last_sync_up_time = float("-inf")
self.last_sync_down_time = float("-inf")
@abc.abstractmethod
def sync_up(
self, local_dir: str, remote_dir: str, exclude: Optional[List] = None
) -> bool:
"""Synchronize local directory to remote directory.
This function can spawn an asynchronous process that can be awaited in
``wait()``.
Args:
local_dir: Local directory to sync from.
remote_dir: Remote directory to sync up to. This is an URI
(``protocol://remote/path``).
exclude: Pattern of files to exclude, e.g.
``["*/checkpoint_*]`` to exclude trial checkpoints.
Returns:
True if sync process has been spawned, False otherwise.
"""
raise NotImplementedError
@abc.abstractmethod
def sync_down(
self, remote_dir: str, local_dir: str, exclude: Optional[List] = None
) -> bool:
"""Synchronize remote directory to local directory.
This function can spawn an asynchronous process that can be awaited in
``wait()``.
Args:
remote_dir: Remote directory to sync down from. This is an URI
(``protocol://remote/path``).
local_dir: Local directory to sync to.
exclude: Pattern of files to exclude, e.g.
``["*/checkpoint_*]`` to exclude trial checkpoints.
Returns:
True if sync process has been spawned, False otherwise.
"""
raise NotImplementedError
@abc.abstractmethod
def delete(self, remote_dir: str) -> bool:
"""Delete directory on remote storage.
This function can spawn an asynchronous process that can be awaited in
``wait()``.
Args:
remote_dir: Remote directory to delete. This is an URI
(``protocol://remote/path``).
Returns:
True if sync process has been spawned, False otherwise.
"""
raise NotImplementedError
def retry(self):
"""Retry the last sync up, sync down, or delete command.
You should implement this method if you spawn asynchronous syncing
processes.
"""
pass
def wait(self, timeout: Optional[float] = None):
"""Wait for asynchronous sync command to finish.
You should implement this method if you spawn asynchronous syncing
processes. This method should timeout after the asynchronous command
has run for `sync_timeout` seconds and raise a `TimeoutError`.
"""
pass
def sync_up_if_needed(
self, local_dir: str, remote_dir: str, exclude: Optional[List] = None
) -> bool:
"""Syncs up if time since last sync up is greater than sync_period.
Args:
local_dir: Local directory to sync from.
remote_dir: Remote directory to sync up to. This is an URI
(``protocol://remote/path``).
exclude: Pattern of files to exclude, e.g.
``["*/checkpoint_*]`` to exclude trial checkpoints.
Returns:
The result of ``sync_up`` if a sync was triggered, otherwise ``None``.
"""
now = time.time()
if now - self.last_sync_up_time >= self.sync_period:
result = self.sync_up(
local_dir=local_dir, remote_dir=remote_dir, exclude=exclude
)
self.last_sync_up_time = now
return result
def sync_down_if_needed(
self, remote_dir: str, local_dir: str, exclude: Optional[List] = None
):
"""Syncs down if time since last sync down is greater than sync_period.
Args:
remote_dir: Remote directory to sync down from. This is an URI
(``protocol://remote/path``).
local_dir: Local directory to sync to.
exclude: Pattern of files to exclude, e.g.
``["*/checkpoint_*]`` to exclude trial checkpoints.
Returns:
The result of ``sync_down`` if a sync was triggered, otherwise ``None``.
"""
now = time.time()
if now - self.last_sync_down_time >= self.sync_period:
result = self.sync_down(
remote_dir=remote_dir, local_dir=local_dir, exclude=exclude
)
self.last_sync_down_time = now
return result
def wait_or_retry(self, max_retries: int = 2, backoff_s: int = 5):
assert max_retries > 0
last_error_traceback = None
for i in range(max_retries + 1):
try:
self.wait()
except Exception as e:
attempts_remaining = max_retries - i
# If we're out of retries, then save the full traceback of the last
# error and show it when raising an exception.
if attempts_remaining == 0:
last_error_traceback = traceback.format_exc()
break
logger.error(
f"The latest sync operation failed with the following error: "
f"{repr(e)}\n"
f"Retrying {attempts_remaining} more time(s) after sleeping "
f"for {backoff_s} seconds..."
)
time.sleep(backoff_s)
self.retry()
continue
# Succeeded!
return
raise RuntimeError(
f"Failed sync even after {max_retries} retries. "
f"The latest sync failed with the following error:\n{last_error_traceback}"
)
def reset(self):
self.last_sync_up_time = float("-inf")
self.last_sync_down_time = float("-inf")
def close(self):
pass
def _repr_html_(self) -> str:
return
class _BackgroundSyncer(Syncer):
"""Syncer using a background process for asynchronous file transfer."""
def __init__(
self,
sync_period: float = DEFAULT_SYNC_PERIOD,
sync_timeout: float = DEFAULT_SYNC_TIMEOUT,
):
super(_BackgroundSyncer, self).__init__(
sync_period=sync_period, sync_timeout=sync_timeout
)
self._sync_process = None
self._current_cmd = None
def _should_continue_existing_sync(self):
"""Returns whether a previous sync is still running within the timeout."""
return (
self._sync_process
and self._sync_process.is_running
and time.time() - self._sync_process.start_time < self.sync_timeout
)
def _launch_sync_process(self, sync_command: Tuple[Callable, Dict]):
"""Waits for the previous sync process to finish,
then launches a new process that runs the given command."""
if self._sync_process:
try:
self.wait()
except Exception:
logger.warning(
f"Last sync command failed with the following error:\n"
f"{traceback.format_exc()}"
)
self._current_cmd = sync_command
self.retry()
def sync_up(
self, local_dir: str, remote_dir: str, exclude: Optional[List] = None
) -> bool:
if self._should_continue_existing_sync():
logger.debug(
f"Last sync still in progress, "
f"skipping sync up of {local_dir} to {remote_dir}"
)
return False
sync_up_cmd = self._sync_up_command(
local_path=local_dir, uri=remote_dir, exclude=exclude
)
self._launch_sync_process(sync_up_cmd)
return True
def _sync_up_command(
self, local_path: str, uri: str, exclude: Optional[List] = None
) -> Tuple[Callable, Dict]:
raise NotImplementedError
def sync_down(
self, remote_dir: str, local_dir: str, exclude: Optional[List] = None
) -> bool:
if self._should_continue_existing_sync():
logger.warning(
f"Last sync still in progress, "
f"skipping sync down of {remote_dir} to {local_dir}"
)
return False
sync_down_cmd = self._sync_down_command(uri=remote_dir, local_path=local_dir)
self._launch_sync_process(sync_down_cmd)
return True
def _sync_down_command(self, uri: str, local_path: str) -> Tuple[Callable, Dict]:
raise NotImplementedError
def delete(self, remote_dir: str) -> bool:
if self._should_continue_existing_sync():
logger.warning(
f"Last sync still in progress, skipping deletion of {remote_dir}"
)
return False
delete_cmd = self._delete_command(uri=remote_dir)
self._launch_sync_process(delete_cmd)
return True
def _delete_command(self, uri: str) -> Tuple[Callable, Dict]:
raise NotImplementedError
def wait(self, timeout: Optional[float] = None):
if self._sync_process:
try:
self._sync_process.wait(timeout=timeout or self.sync_timeout)
except Exception as e:
raise e
finally:
# Regardless of whether the sync process succeeded within the timeout,
# clear the sync process so a new one can be created.
self._sync_process = None
def retry(self):
if not self._current_cmd:
raise RuntimeError("No sync command set, cannot retry.")
cmd, kwargs = self._current_cmd
self._sync_process = _BackgroundProcess(cmd)
self._sync_process.start(**kwargs)
def __getstate__(self):
state = self.__dict__.copy()
state["_sync_process"] = None
return state