Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env/python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
"""
|
||||
|
||||
Torchelastic agent and user worker failover contract:
|
||||
|
||||
**TL;DR;**:
|
||||
|
||||
* TE(torchelastic) expects user workers to finish with the 5 minutes drift
|
||||
* It is better to design DDP app to fail for all workers, rather than a single one.
|
||||
* TE does not synchronize number of restarts between agents
|
||||
* TE re-rendezvous does not trigger restart decrease
|
||||
* When a single agent finishes its job(successfully or not), it will close rendezvous.
|
||||
If other agents still have workers in progress, they will be terminated.
|
||||
* Based on above, scale down does not work if at least single agent finishes the job.
|
||||
* When Scale up is detected by agents, it will not decrease ``max_restarts``
|
||||
|
||||
|
||||
In general TE(torchelastic) can launch arbitrary user code, but there is some
|
||||
clarifications need to be done around what failover mechanism torchelastic
|
||||
provides and what failover mechanism it expects from user workers.
|
||||
|
||||
Torchelastic currently supports DDP style applications. That means that
|
||||
TE expects *ALL* workers finish approximately at the same time. In practice,
|
||||
it is nearly to impossible to guarantee that all workers in arbitrary
|
||||
DDP application finish at the time, so TE provides a finalization barrier
|
||||
that waits for TIMEOUT(5 minutes) for worker finalization.
|
||||
|
||||
**Worker Failure**
|
||||
|
||||
When worker fails, TE will check the number of restarts
|
||||
available, if there is more than 0 restarts, TE will start a new rendezvous
|
||||
round and restart the worker process. New rendezvous round will other
|
||||
TE agents to terminate their workers.
|
||||
|
||||
.. note:: The TE agent does not synchronize restarts between themselves.
|
||||
When a single agent performs restart, it will trigger a local ``max_restarts``
|
||||
decrease, other agent will not decrease their ``max_restarts``.
|
||||
the user to run the distributed application locally on a dev host.
|
||||
|
||||
A single worker failure can cause the whole cluster to fail:
|
||||
If a single worker is constantly failing, it will cause the TE agent
|
||||
``max_restarts`` to go to zero. This will cause an agent to finish its
|
||||
work and close rendezvous. If there are any other workers on different
|
||||
agents, they will be terminated.
|
||||
|
||||
|
||||
**Re-Rendezvous**
|
||||
|
||||
Re-rendezvous occurs when TE agents detect a new node
|
||||
trying to joint a cluster. TE will not decrease ``max_restarts``. TE agents
|
||||
will terminate its workers and start a new rendezvous round.
|
||||
|
||||
Note about DynamicRendezvous(etcd-v2, c10d-experimental): If the rendezvous
|
||||
has already max_nodes, the new node won't be added to the wait list right
|
||||
away since there is no need to tear down a rendezvous that is already fully
|
||||
utilized. The new node will wait until its timeout (600 secs by default)
|
||||
and periodically check the number of participants. If the number becomes
|
||||
less than max_nodes, it will be added to the wait list; otherwise, it will time out after 600 secs.
|
||||
|
||||
*Scale up event*. When scale up event happens, torchelastic rendezvous
|
||||
will detect that there are new nodes trying to join. Torchelastic agent
|
||||
will stop all workers and perform re-rendezvous. Note: when scale up event
|
||||
happens, *``max_restarts``* will *not* decrease.
|
||||
|
||||
*Scale down event*. When scale down event happens, rendezvous will not
|
||||
notify the torchelastic agent about it. If TE agent launched with ``max_restarts=0`` ,
|
||||
it relies on the underlying scheduler to handle job restart. If the ``max_restarts>0`` ,
|
||||
TE agent will terminate workers and start a new rdzv round, which is a *Scale up event*.
|
||||
|
||||
"""
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
"""
|
||||
The elastic agent is the control plane of torchelastic.
|
||||
|
||||
It is a process that launches and manages underlying worker processes.
|
||||
The agent is responsible for:
|
||||
|
||||
1. Working with distributed torch: the workers are started with all the
|
||||
necessary information to successfully and trivially call
|
||||
``torch.distributed.init_process_group()``.
|
||||
|
||||
2. Fault tolerance: monitors workers and upon detecting worker failures
|
||||
or unhealthiness, tears down all workers and restarts everyone.
|
||||
|
||||
3. Elasticity: Reacts to membership changes and restarts workers with the new
|
||||
members.
|
||||
|
||||
The simplest agents are deployed per node and works with local processes.
|
||||
A more advanced agent can launch and manage workers remotely. Agents can
|
||||
be completely decentralized, making decisions based on the workers it manages.
|
||||
Or can be coordinated, communicating to other agents (that manage workers
|
||||
in the same job) to make a collective decision.
|
||||
"""
|
||||
|
||||
from .api import ( # noqa: F401
|
||||
ElasticAgent,
|
||||
RunResult,
|
||||
SimpleElasticAgent,
|
||||
Worker,
|
||||
WorkerGroup,
|
||||
WorkerSpec,
|
||||
WorkerState,
|
||||
)
|
||||
from .local_elastic_agent import TORCHELASTIC_ENABLE_FILE_TIMER, TORCHELASTIC_TIMER_FILE
|
||||
+1013
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from torch.distributed.elastic.utils.logging import get_logger
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
__all__ = ["HealthCheckServer", "create_healthcheck_server"]
|
||||
|
||||
|
||||
class HealthCheckServer:
|
||||
"""
|
||||
Interface for health check monitoring server, which can be extended
|
||||
by starting tcp/http server on the specified port.
|
||||
|
||||
Args:
|
||||
|
||||
alive_callback: Callable[[], int], callback to last progress time of agent
|
||||
|
||||
port: int, port number to start tcp/http server
|
||||
|
||||
timeout: int, timeout seconds to decide agent is alive/dead
|
||||
"""
|
||||
|
||||
_alive_callback: Callable[[], int]
|
||||
_port: int
|
||||
_timeout: int
|
||||
|
||||
def __init__(
|
||||
self, alive_callback: Callable[[], int], port: int, timeout: int
|
||||
) -> None:
|
||||
self._alive_callback = alive_callback
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Unsupported functionality for Pytorch, doesn't start any health check server
|
||||
"""
|
||||
log.warning("No health check server started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Function to stop health check server
|
||||
"""
|
||||
log.info("Stopping noop health check server.")
|
||||
|
||||
|
||||
def create_healthcheck_server(
|
||||
alive_callback: Callable[[], int],
|
||||
port: int,
|
||||
timeout: int,
|
||||
) -> HealthCheckServer:
|
||||
"""
|
||||
creates health check server object
|
||||
"""
|
||||
return HealthCheckServer(alive_callback, port, timeout)
|
||||
+530
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from string import Template
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
import torch.distributed.elastic.timer as timer
|
||||
from torch._utils_internal import justknobs_check
|
||||
from torch.distributed.elastic import events
|
||||
from torch.distributed.elastic.agent.server.api import (
|
||||
RunResult,
|
||||
SimpleElasticAgent,
|
||||
WorkerGroup,
|
||||
WorkerSpec,
|
||||
WorkerState,
|
||||
)
|
||||
from torch.distributed.elastic.agent.server.health_check_server import (
|
||||
create_healthcheck_server,
|
||||
HealthCheckServer,
|
||||
)
|
||||
from torch.distributed.elastic.metrics.api import prof
|
||||
from torch.distributed.elastic.multiprocessing import (
|
||||
LogsSpecs,
|
||||
PContext,
|
||||
start_processes,
|
||||
)
|
||||
from torch.distributed.elastic.utils import macros
|
||||
from torch.distributed.elastic.utils.logging import get_logger
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from torch.distributed.elastic.events.api import EventMetadataValue
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"LocalElasticAgent",
|
||||
"TORCHELASTIC_ENABLE_FILE_TIMER",
|
||||
"TORCHELASTIC_TIMER_FILE",
|
||||
"TORCHELASTIC_HEALTH_CHECK_PORT",
|
||||
]
|
||||
|
||||
TORCHELASTIC_ENABLE_FILE_TIMER = "TORCHELASTIC_ENABLE_FILE_TIMER"
|
||||
TORCHELASTIC_HEALTH_CHECK_PORT = "TORCHELASTIC_HEALTH_CHECK_PORT"
|
||||
TORCHELASTIC_TIMER_FILE = "TORCHELASTIC_TIMER_FILE"
|
||||
|
||||
|
||||
class _AliveCallbackProxy:
|
||||
"""Mutable callback wrapper for the health check server.
|
||||
|
||||
The C++ pybind ``HealthCheckThriftServer`` binds its ``alive_callback``
|
||||
at construction time and cannot update it afterward. This proxy is
|
||||
created *before* the health check server so it can be passed as the
|
||||
callback. Initially it returns ``time.time()`` (signalling "alive").
|
||||
After the agent is constructed, :meth:`set_delegate` wires it to
|
||||
``agent._get_alive_time`` for real liveness tracking.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._delegate: Callable[[], int] | None = None
|
||||
|
||||
def __call__(self) -> int:
|
||||
if self._delegate is not None:
|
||||
return self._delegate()
|
||||
return int(time.time())
|
||||
|
||||
def set_delegate(self, delegate: Callable[[], int]) -> None:
|
||||
self._delegate = delegate
|
||||
|
||||
|
||||
class LocalElasticAgent(SimpleElasticAgent):
|
||||
"""An implementation of :py:class:`torchelastic.agent.server.ElasticAgent` that handles host-local workers.
|
||||
|
||||
This agent is deployed per host and is configured to spawn ``n`` workers.
|
||||
When using GPUs, ``n`` maps to the number of GPUs available on the host.
|
||||
|
||||
The local agent does not communicate to other local agents deployed on
|
||||
other hosts, even if the workers may communicate inter-host. The worker id
|
||||
is interpreted to be a local process. The agent starts and stops all worker
|
||||
processes as a single unit.
|
||||
|
||||
|
||||
The worker function and argument passed to the worker function must be
|
||||
python multiprocessing compatible. To pass multiprocessing data structures
|
||||
to the workers you may create the data structure in the same multiprocessing
|
||||
context as the specified ``start_method`` and pass it as a function argument.
|
||||
|
||||
The ``exit_barrier_timeout`` specifies the amount of time (in seconds) to wait
|
||||
for other agents to finish. This acts as a safety net to handle cases where
|
||||
workers finish at different times, to prevent agents from viewing workers
|
||||
that finished early as a scale-down event. It is strongly advised that the
|
||||
user code deal with ensuring that workers are terminated in a synchronous
|
||||
manner rather than relying on the exit_barrier_timeout.
|
||||
|
||||
A named pipe based watchdog can be enabled in ```LocalElasticAgent``` if an
|
||||
environment variable ``TORCHELASTIC_ENABLE_FILE_TIMER`` with value 1 has
|
||||
been defined in the ```LocalElasticAgent``` process.
|
||||
Optionally, another environment variable ```TORCHELASTIC_TIMER_FILE```
|
||||
can be set with a unique file name for the named pipe. If the environment
|
||||
variable ```TORCHELASTIC_TIMER_FILE``` is not set, ```LocalElasticAgent```
|
||||
will internally create a unique file name and set it to the environment
|
||||
variable ```TORCHELASTIC_TIMER_FILE```, and this environment variable will
|
||||
be propagated to the worker processes to allow them to connect to the same
|
||||
named pipe that ```LocalElasticAgent``` uses.
|
||||
|
||||
Logs are written to the specified log directory. Each log line will be by default
|
||||
prefixed by ``[${role_name}${local_rank}]:`` (e.g. ``[trainer0]: foobar``).
|
||||
Log prefixes can be customized by passing a `template string
|
||||
<https://docs.python.org/3/library/string.html#template-strings>`_ as the
|
||||
``log_line_prefix_template`` argument.
|
||||
The following macros (identifiers) are substituted at runtime:
|
||||
``${role_name}, ${local_rank}, ${rank}``. For example, to prefix each log line with
|
||||
global rank instead of the local rank, set ``log_line_prefix_template = "[${rank}]:``.
|
||||
|
||||
|
||||
Example launching function
|
||||
|
||||
::
|
||||
|
||||
def trainer(args) -> str:
|
||||
return "do train"
|
||||
|
||||
def main():
|
||||
start_method="spawn"
|
||||
shared_queue= multiprocessing.get_context(start_method).Queue()
|
||||
spec = WorkerSpec(
|
||||
role="trainer",
|
||||
local_world_size=nproc_per_process,
|
||||
entrypoint=trainer,
|
||||
args=("foobar",),
|
||||
...<OTHER_PARAMS...>)
|
||||
agent = LocalElasticAgent(spec, start_method)
|
||||
results = agent.run()
|
||||
|
||||
if results.is_failed():
|
||||
print("trainer failed")
|
||||
else:
|
||||
print(f"rank 0 return value: {results.return_values[0]}")
|
||||
# prints -> rank 0 return value: do train
|
||||
|
||||
Example launching binary
|
||||
|
||||
::
|
||||
|
||||
def main():
|
||||
spec = WorkerSpec(
|
||||
role="trainer",
|
||||
local_world_size=nproc_per_process,
|
||||
entrypoint="/usr/local/bin/trainer",
|
||||
args=("--trainer-args", "foobar"),
|
||||
...<OTHER_PARAMS...>)
|
||||
agent = LocalElasticAgent(spec)
|
||||
results = agent.run()
|
||||
|
||||
if not results.is_failed():
|
||||
print("binary launches do not have return values")
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spec: WorkerSpec,
|
||||
logs_specs: LogsSpecs,
|
||||
start_method="spawn",
|
||||
exit_barrier_timeout: float = 300,
|
||||
log_line_prefix_template: str | None = None,
|
||||
shutdown_timeout: int = 30,
|
||||
health_check_server: HealthCheckServer | None = None,
|
||||
):
|
||||
super().__init__(spec, exit_barrier_timeout, shutdown_timeout)
|
||||
self._start_method = start_method
|
||||
self._pcontext: PContext | None = None
|
||||
self._rdzv_handler = spec.rdzv_handler
|
||||
self._log_line_prefix_template = log_line_prefix_template
|
||||
self._worker_watchdog: timer.FileTimerServer | None = None
|
||||
self._logs_specs = logs_specs
|
||||
self._health_check_server = health_check_server
|
||||
|
||||
def _setup_local_watchdog(self, envs: dict[int, dict[str, str]]) -> None:
|
||||
enable_watchdog_env_name = TORCHELASTIC_ENABLE_FILE_TIMER
|
||||
watchdog_enabled = os.getenv(enable_watchdog_env_name)
|
||||
watchdog_file_env_name = TORCHELASTIC_TIMER_FILE
|
||||
watchdog_file_path = os.getenv(watchdog_file_env_name)
|
||||
if watchdog_enabled is not None and str(watchdog_enabled) == "1":
|
||||
if watchdog_file_path is None:
|
||||
watchdog_file_path = os.path.join(
|
||||
tempfile.gettempdir(), "watchdog_timer_" + str(uuid.uuid4())
|
||||
)
|
||||
logger.info("Starting a FileTimerServer with %s ...", watchdog_file_path)
|
||||
if not envs:
|
||||
logger.warning(
|
||||
"Empty envs variables, using empty run_id for FileTimerServer"
|
||||
)
|
||||
run_id = ""
|
||||
else:
|
||||
run_id = envs[0]["TORCHELASTIC_RUN_ID"]
|
||||
self._worker_watchdog = timer.FileTimerServer(
|
||||
file_path=watchdog_file_path,
|
||||
run_id=run_id,
|
||||
max_interval=0.1,
|
||||
daemon=True,
|
||||
log_event=self._log_watchdog_event,
|
||||
)
|
||||
self._worker_watchdog.start()
|
||||
logger.info("FileTimerServer started")
|
||||
else:
|
||||
logger.info(
|
||||
"Environment variable '%s' not found. Do not start FileTimerServer.",
|
||||
enable_watchdog_env_name,
|
||||
)
|
||||
# Propagate the watchdog file env to worker processes
|
||||
if watchdog_file_path is not None:
|
||||
for worker_env in envs.values():
|
||||
worker_env[watchdog_file_env_name] = watchdog_file_path
|
||||
|
||||
@staticmethod
|
||||
def _get_current_time_secs() -> int:
|
||||
return int(time.time())
|
||||
|
||||
def _get_alive_time(self) -> int:
|
||||
"""Return the last progress time from the watchdog, or the current time.
|
||||
|
||||
This callback is passed to the health check server at startup and
|
||||
is called on every TW health check poll. During initialization
|
||||
(before rendezvous and worker launch), the watchdog does not exist
|
||||
yet, so we return the current time to signal the agent is alive.
|
||||
Once workers are running and the watchdog is active, we delegate
|
||||
to the watchdog's ``get_last_progress_time`` for real liveness
|
||||
tracking.
|
||||
|
||||
During the exit barrier wait, workers have finished and the watchdog
|
||||
progress time is stale. We return the current time to prevent TW
|
||||
from killing the task while agents coordinate shutdown.
|
||||
"""
|
||||
if self._in_exit_barrier:
|
||||
return int(time.time())
|
||||
if self._worker_watchdog is not None:
|
||||
return self._worker_watchdog.get_last_progress_time()
|
||||
return int(time.time())
|
||||
|
||||
def _setup_healthcheck(self) -> None:
|
||||
healthcheck_port_env_name = TORCHELASTIC_HEALTH_CHECK_PORT
|
||||
healthcheck_port = os.getenv(healthcheck_port_env_name)
|
||||
if healthcheck_port is not None:
|
||||
logger.info(
|
||||
"Found healthcheck port %s: %s",
|
||||
healthcheck_port_env_name,
|
||||
healthcheck_port,
|
||||
)
|
||||
|
||||
if justknobs_check(
|
||||
"ai_infra/pytorch_distributed:torchelastic_enable_healthcheck_before_rendezvous",
|
||||
default=False,
|
||||
):
|
||||
# New behavior: idempotent guard + dynamic callback that
|
||||
# returns current time before watchdog exists and delegates
|
||||
# to watchdog once workers are running.
|
||||
if self._health_check_server is not None:
|
||||
return
|
||||
alive_callback = self._get_alive_time
|
||||
else:
|
||||
# Original behavior: pick callback based on watchdog state
|
||||
# at call time (only called from _start_workers where
|
||||
# watchdog is already set up).
|
||||
if self._worker_watchdog is None:
|
||||
logger.info(
|
||||
"FileTimerServer doesn't exist, using current time as dummy callback"
|
||||
)
|
||||
alive_callback = LocalElasticAgent._get_current_time_secs
|
||||
else:
|
||||
alive_callback = self._worker_watchdog.get_last_progress_time
|
||||
|
||||
try:
|
||||
healthcheck_port_as_int = int(healthcheck_port)
|
||||
self._health_check_server = create_healthcheck_server(
|
||||
alive_callback=alive_callback,
|
||||
port=healthcheck_port_as_int,
|
||||
timeout=60,
|
||||
)
|
||||
self._health_check_server.start()
|
||||
except ValueError:
|
||||
logger.info(
|
||||
"Invalid healthcheck port value: '%s', expecting integer. Not starting healthcheck server.",
|
||||
healthcheck_port,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Environment variable '%s' not found. Do not start health check.",
|
||||
healthcheck_port_env_name,
|
||||
)
|
||||
|
||||
def _get_fq_hostname(self) -> str:
|
||||
return socket.getfqdn(socket.gethostname())
|
||||
|
||||
def _log_watchdog_event(
|
||||
self,
|
||||
name: str,
|
||||
request: timer.FileTimerRequest | None,
|
||||
) -> None:
|
||||
wg = self._worker_group
|
||||
spec = wg.spec
|
||||
md = {"watchdog_event": name}
|
||||
if request is not None:
|
||||
md["worker_pid"] = str(request.worker_pid)
|
||||
md["scope_id"] = request.scope_id
|
||||
md["expiration_time"] = str(request.expiration_time)
|
||||
md["signal"] = str(request.signal)
|
||||
md_str = json.dumps(md)
|
||||
state = "RUNNING"
|
||||
metadata: dict[str, EventMetadataValue] = {
|
||||
"run_id": spec.rdzv_handler.get_run_id(),
|
||||
"global_rank": None,
|
||||
"group_rank": wg.group_rank,
|
||||
"worker_id": None,
|
||||
"role": spec.role,
|
||||
"hostname": self._get_fq_hostname(),
|
||||
"state": state,
|
||||
"total_run_time": self._total_execution_time,
|
||||
"rdzv_backend": spec.rdzv_handler.get_backend(),
|
||||
"raw_error": None,
|
||||
"metadata": md_str,
|
||||
"agent_restarts": spec.max_restarts - self._remaining_restarts,
|
||||
}
|
||||
# Note: The 'metadata' field of the Event is converted to a TorchelasticStatusLogEntry later.
|
||||
# The 'name' field of the Event is NOT used in the TorchelasticStatusLogEntry.
|
||||
event = events.Event(
|
||||
name=name, source=events.EventSource.AGENT, metadata=metadata
|
||||
)
|
||||
events.record(event, self._worker_group.spec.event_log_handler)
|
||||
|
||||
# pyre-fixme[56]: Pyre was not able to infer the type of the decorator
|
||||
# `torch.distributed.elastic.metrics.prof`.
|
||||
@prof
|
||||
def _stop_workers(self, worker_group: WorkerGroup) -> None:
|
||||
self._shutdown()
|
||||
|
||||
# pyre-fixme[56]: Pyre was not able to infer the type of the decorator
|
||||
# `torch.distributed.elastic.metrics.prof`.
|
||||
@prof
|
||||
def _start_workers(self, worker_group: WorkerGroup) -> dict[int, Any]:
|
||||
spec = worker_group.spec
|
||||
store = worker_group.store
|
||||
if store is None:
|
||||
raise AssertionError
|
||||
restart_count = spec.max_restarts - self._remaining_restarts
|
||||
|
||||
use_agent_store: bool = spec.rdzv_handler.use_agent_store
|
||||
logger.info("use_agent_store: %s", use_agent_store)
|
||||
|
||||
args: dict[int, tuple] = {}
|
||||
envs: dict[int, dict[str, str]] = {}
|
||||
log_line_prefixes: dict[int, str] | None = (
|
||||
{} if self._log_line_prefix_template else None
|
||||
)
|
||||
for worker in worker_group.workers:
|
||||
local_rank = worker.local_rank
|
||||
worker_env = {
|
||||
"RANK": str(worker.global_rank),
|
||||
"GROUP_RANK": str(worker_group.group_rank),
|
||||
"ROLE_RANK": str(worker.role_rank),
|
||||
"ROLE_NAME": spec.role,
|
||||
"LOCAL_WORLD_SIZE": str(spec.local_world_size),
|
||||
"WORLD_SIZE": str(worker.world_size),
|
||||
"GROUP_WORLD_SIZE": str(worker_group.group_world_size),
|
||||
"ROLE_WORLD_SIZE": str(worker.role_world_size),
|
||||
"MASTER_ADDR": worker_group.master_addr,
|
||||
"MASTER_PORT": str(worker_group.master_port),
|
||||
"TORCHELASTIC_RESTART_COUNT": str(restart_count),
|
||||
"TORCHELASTIC_MAX_RESTARTS": str(spec.max_restarts),
|
||||
"TORCHELASTIC_RUN_ID": spec.rdzv_handler.get_run_id(),
|
||||
"TORCHELASTIC_USE_AGENT_STORE": str(use_agent_store),
|
||||
"TORCH_NCCL_ASYNC_ERROR_HANDLING": os.getenv(
|
||||
"TORCH_NCCL_ASYNC_ERROR_HANDLING", str(1)
|
||||
),
|
||||
}
|
||||
self._set_local_rank_env(worker_env, local_rank, spec)
|
||||
if "OMP_NUM_THREADS" in os.environ:
|
||||
worker_env["OMP_NUM_THREADS"] = os.environ["OMP_NUM_THREADS"]
|
||||
|
||||
if self._log_line_prefix_template:
|
||||
log_line_prefix = Template(
|
||||
self._log_line_prefix_template
|
||||
).safe_substitute(
|
||||
role_name=spec.role,
|
||||
rank=worker.global_rank,
|
||||
local_rank=local_rank,
|
||||
)
|
||||
# pyrefly: ignore [unsupported-operation]
|
||||
log_line_prefixes[local_rank] = log_line_prefix
|
||||
|
||||
# pyrefly: ignore [unsupported-operation]
|
||||
envs[local_rank] = worker_env
|
||||
worker_args = list(spec.args)
|
||||
worker_args = macros.substitute(worker_args, str(local_rank))
|
||||
args[local_rank] = tuple(worker_args)
|
||||
|
||||
self._setup_local_watchdog(envs=envs)
|
||||
self._setup_healthcheck()
|
||||
|
||||
if spec.entrypoint is None:
|
||||
raise AssertionError
|
||||
if self._logs_specs is None:
|
||||
raise AssertionError
|
||||
self._pcontext = start_processes(
|
||||
name=spec.role,
|
||||
entrypoint=spec.entrypoint,
|
||||
args=args,
|
||||
envs=envs,
|
||||
logs_specs=self._logs_specs,
|
||||
log_line_prefixes=log_line_prefixes,
|
||||
start_method=self._start_method,
|
||||
numa_options=spec.numa_options,
|
||||
duplicate_stdout_filters=spec.duplicate_stdout_filters,
|
||||
duplicate_stderr_filters=spec.duplicate_stderr_filters,
|
||||
)
|
||||
|
||||
return self._pcontext.pids()
|
||||
|
||||
def _set_local_rank_env(
|
||||
self, worker_env: dict[str, str | None], local_rank: int, spec: WorkerSpec
|
||||
) -> None:
|
||||
# Set CUDA_VISIBLE_DEVICES and LOCAL_RANK based on virtual_local_rank mode.
|
||||
# Virtual mode: Each worker sees only its assigned GPU as device 0, LOCAL_RANK=0
|
||||
# Traditional mode: Workers see all GPUs, LOCAL_RANK matches actual local rank
|
||||
|
||||
if spec.virtual_local_rank:
|
||||
# Set LOCAL_RANK=0 and use CUDA_VISIBLE_DEVICES to control the actual GPU access.
|
||||
|
||||
worker_env["LOCAL_RANK"] = "0"
|
||||
|
||||
# Map local_rank through existing CUDA_VISIBLE_DEVICES
|
||||
# HIP uses CUDA_VISIBLE_DEVICES as a compatibility hack:
|
||||
# https://rocm.docs.amd.com/en/latest/conceptual/gpu-isolation.html#cuda-visible-devices
|
||||
parent_visible_devices = os.getenv("CUDA_VISIBLE_DEVICES")
|
||||
if parent_visible_devices is not None:
|
||||
# Parse comma-separated list of GPU IDs
|
||||
available_gpus = parent_visible_devices.split(",")
|
||||
if local_rank >= len(available_gpus):
|
||||
raise ValueError(
|
||||
f"local_rank {local_rank} exceeds available GPUs in "
|
||||
f"CUDA_VISIBLE_DEVICES={parent_visible_devices}"
|
||||
)
|
||||
|
||||
visible_gpu = available_gpus[local_rank].strip()
|
||||
else:
|
||||
# No restriction, use local_rank directly
|
||||
visible_gpu = str(local_rank)
|
||||
|
||||
worker_env["CUDA_VISIBLE_DEVICES"] = visible_gpu
|
||||
return
|
||||
|
||||
# In traditional mode, don't override CUDA_VISIBLE_DEVICES
|
||||
# (inherit from parent environment)
|
||||
worker_env["LOCAL_RANK"] = str(local_rank)
|
||||
|
||||
if "CUDA_VISIBLE_DEVICES" in os.environ:
|
||||
worker_env["CUDA_VISIBLE_DEVICES"] = os.environ["CUDA_VISIBLE_DEVICES"]
|
||||
|
||||
def _shutdown(
|
||||
self, death_sig: signal.Signals = signal.SIGTERM, timeout: int = 30
|
||||
) -> None:
|
||||
if self._worker_watchdog is not None:
|
||||
self._worker_watchdog.stop()
|
||||
self._worker_watchdog = None
|
||||
if self._health_check_server is not None:
|
||||
self._health_check_server.stop()
|
||||
self._health_check_server = None
|
||||
if self._pcontext:
|
||||
self._pcontext.close(death_sig, timeout)
|
||||
|
||||
# pyre-fixme[56]: Pyre was not able to infer the type of the decorator
|
||||
# `torch.distributed.elastic.metrics.prof`.
|
||||
@prof
|
||||
def _monitor_workers(self, worker_group: WorkerGroup) -> RunResult:
|
||||
role = worker_group.spec.role
|
||||
worker_pids = {w.id for w in worker_group.workers}
|
||||
if self._pcontext is None:
|
||||
raise AssertionError
|
||||
pc_pids = set(self._pcontext.pids().values())
|
||||
if worker_pids != pc_pids:
|
||||
logger.error(
|
||||
"[%s] worker pids do not match process_context pids."
|
||||
" Expected: %s, actual: %s",
|
||||
role,
|
||||
worker_pids,
|
||||
pc_pids,
|
||||
)
|
||||
return RunResult(state=WorkerState.UNKNOWN)
|
||||
|
||||
result = self._pcontext.wait(0)
|
||||
if result:
|
||||
if result.is_failed():
|
||||
# map local rank failure to global rank
|
||||
worker_failures = {}
|
||||
for local_rank, failure in result.failures.items():
|
||||
worker = worker_group.workers[local_rank]
|
||||
worker_failures[worker.global_rank] = failure
|
||||
return RunResult(
|
||||
state=WorkerState.FAILED,
|
||||
failures=worker_failures,
|
||||
)
|
||||
else:
|
||||
# copy ret_val_queue into a map with a global ranks
|
||||
workers_ret_vals = {}
|
||||
for local_rank, ret_val in result.return_values.items():
|
||||
worker = worker_group.workers[local_rank]
|
||||
workers_ret_vals[worker.global_rank] = ret_val
|
||||
return RunResult(
|
||||
state=WorkerState.SUCCEEDED,
|
||||
return_values=workers_ret_vals,
|
||||
)
|
||||
else:
|
||||
return RunResult(state=WorkerState.HEALTHY)
|
||||
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager, ExitStack
|
||||
|
||||
from torch.distributed.elastic.multiprocessing.errors import record
|
||||
|
||||
|
||||
__all__ = [
|
||||
"worker_main",
|
||||
]
|
||||
|
||||
TORCH_WORKER_SERVER_SOCKET = "TORCH_WORKER_SERVER_SOCKET"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _worker_server(socket_path: str) -> Generator[None, None, None]:
|
||||
from torch._C._distributed_c10d import _WorkerServer
|
||||
|
||||
server = _WorkerServer(socket_path)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
@record
|
||||
@contextmanager
|
||||
def worker_main() -> Generator[None, None, None]:
|
||||
"""
|
||||
This is a context manager that wraps your main entry function. This combines
|
||||
the existing ``errors.record`` logic as well as a new ``_WorkerServer`` that
|
||||
exposes handlers via a unix socket specified by
|
||||
``Torch_WORKER_SERVER_SOCKET``.
|
||||
|
||||
Example
|
||||
|
||||
::
|
||||
|
||||
@worker_main()
|
||||
def main():
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
"""
|
||||
with ExitStack() as stack:
|
||||
socket_path = os.environ.get(TORCH_WORKER_SERVER_SOCKET)
|
||||
if socket_path is not None:
|
||||
stack.enter_context(_worker_server(socket_path))
|
||||
|
||||
yield
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env/python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
"""
|
||||
Module contains events processing mechanisms that are integrated with the standard python logging.
|
||||
|
||||
Example of usage:
|
||||
|
||||
::
|
||||
|
||||
from torch.distributed.elastic import events
|
||||
|
||||
event = events.Event(
|
||||
name="test_event", source=events.EventSource.WORKER, metadata={...}
|
||||
)
|
||||
events.get_logging_handler(destination="console").info(event)
|
||||
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
from torch.distributed.elastic.events.handlers import get_logging_handler
|
||||
|
||||
from .api import ( # noqa: F401
|
||||
Event,
|
||||
EventMetadataValue,
|
||||
EventSource,
|
||||
NodeState,
|
||||
RdzvEvent,
|
||||
)
|
||||
|
||||
|
||||
_events_loggers: dict[str, logging.Logger] = {}
|
||||
|
||||
|
||||
def _get_or_create_logger(destination: str = "null") -> logging.Logger:
|
||||
"""
|
||||
Construct python logger based on the destination type or extends if provided.
|
||||
|
||||
Available destination could be found in ``handlers.py`` file.
|
||||
The constructed logger does not propagate messages to the upper level loggers,
|
||||
e.g. root logger. This makes sure that a single event can be processed once.
|
||||
|
||||
Args:
|
||||
destination: The string representation of the event handler.
|
||||
Available handlers found in ``handlers`` module
|
||||
"""
|
||||
global _events_loggers
|
||||
|
||||
if destination not in _events_loggers:
|
||||
_events_logger = logging.getLogger(f"torchelastic-events-{destination}")
|
||||
_events_logger.setLevel(os.environ.get("LOGLEVEL", "INFO"))
|
||||
# Do not propagate message to the root logger
|
||||
_events_logger.propagate = False
|
||||
|
||||
logging_handler = get_logging_handler(destination)
|
||||
_events_logger.addHandler(logging_handler)
|
||||
|
||||
# Add the logger to the global dictionary
|
||||
_events_loggers[destination] = _events_logger
|
||||
|
||||
return _events_loggers[destination]
|
||||
|
||||
|
||||
def record(event: Event, destination: str = "null") -> None:
|
||||
_get_or_create_logger(destination).info(event.serialize())
|
||||
|
||||
|
||||
def record_rdzv_event(event: RdzvEvent) -> None:
|
||||
_get_or_create_logger("dynamic_rendezvous").info(event.serialize())
|
||||
|
||||
|
||||
def construct_and_record_rdzv_event(
|
||||
run_id: str,
|
||||
message: str,
|
||||
node_state: NodeState,
|
||||
name: str = "",
|
||||
hostname: str = "",
|
||||
pid: int | None = None,
|
||||
master_endpoint: str = "",
|
||||
local_id: int | None = None,
|
||||
rank: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize rendezvous event object and record its operations.
|
||||
|
||||
Args:
|
||||
run_id (str): The run id of the rendezvous.
|
||||
message (str): The message describing the event.
|
||||
node_state (NodeState): The state of the node (INIT, RUNNING, SUCCEEDED, FAILED).
|
||||
name (str): Event name. (E.g. Current action being performed).
|
||||
hostname (str): Hostname of the node.
|
||||
pid (Optional[int]): The process id of the node.
|
||||
master_endpoint (str): The master endpoint for the rendezvous store, if known.
|
||||
local_id (Optional[int]): The local_id of the node, if defined in dynamic_rendezvous.py
|
||||
rank (Optional[int]): The rank of the node, if known.
|
||||
Returns:
|
||||
None
|
||||
Example:
|
||||
>>> # See DynamicRendezvousHandler class
|
||||
>>> def _record(
|
||||
... self,
|
||||
... message: str,
|
||||
... node_state: NodeState = NodeState.RUNNING,
|
||||
... rank: Optional[int] = None,
|
||||
... ) -> None:
|
||||
... construct_and_record_rdzv_event(
|
||||
... name=f"{self.__class__.__name__}.{get_method_name()}",
|
||||
... run_id=self._settings.run_id,
|
||||
... message=message,
|
||||
... node_state=node_state,
|
||||
... hostname=self._this_node.addr,
|
||||
... pid=self._this_node.pid,
|
||||
... local_id=self._this_node.local_id,
|
||||
... rank=rank,
|
||||
... )
|
||||
"""
|
||||
# We don't want to perform an extra computation if not needed.
|
||||
if isinstance(get_logging_handler("dynamic_rendezvous"), logging.NullHandler):
|
||||
return
|
||||
|
||||
# Set up parameters.
|
||||
if not hostname:
|
||||
hostname = socket.getfqdn()
|
||||
if not pid:
|
||||
pid = os.getpid()
|
||||
|
||||
# Determines which file called this function.
|
||||
callstack = inspect.stack()
|
||||
filename = "no_file"
|
||||
if len(callstack) > 1:
|
||||
stack_depth_1 = callstack[1]
|
||||
filename = os.path.basename(stack_depth_1.filename)
|
||||
if not name:
|
||||
name = stack_depth_1.function
|
||||
|
||||
# Delete the callstack variable. If kept, this can mess with python's
|
||||
# garbage collector as we are holding on to stack frame information in
|
||||
# the inspect module.
|
||||
del callstack
|
||||
|
||||
# Set up error trace if this is an exception
|
||||
if node_state == NodeState.FAILED:
|
||||
error_trace = traceback.format_exc()
|
||||
else:
|
||||
error_trace = ""
|
||||
|
||||
# Initialize event object
|
||||
event = RdzvEvent(
|
||||
name=f"{filename}:{name}",
|
||||
run_id=run_id,
|
||||
message=message,
|
||||
hostname=hostname,
|
||||
pid=pid,
|
||||
node_state=node_state,
|
||||
master_endpoint=master_endpoint,
|
||||
rank=rank,
|
||||
local_id=local_id,
|
||||
error_trace=error_trace,
|
||||
)
|
||||
|
||||
# Finally, record the event.
|
||||
record_rdzv_event(event)
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Union
|
||||
|
||||
|
||||
__all__ = ["EventSource", "Event", "NodeState", "RdzvEvent"]
|
||||
|
||||
EventMetadataValue = str | int | float | bool | None
|
||||
|
||||
|
||||
class EventSource(str, Enum):
|
||||
"""Known identifiers of the event producers."""
|
||||
|
||||
AGENT = "AGENT"
|
||||
WORKER = "WORKER"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""
|
||||
The class represents the generic event that occurs during the torchelastic job execution.
|
||||
|
||||
The event can be any kind of meaningful action.
|
||||
|
||||
Args:
|
||||
name: event name.
|
||||
source: the event producer, e.g. agent or worker
|
||||
timestamp: timestamp in milliseconds when event occurred.
|
||||
metadata: additional data that is associated with the event.
|
||||
"""
|
||||
|
||||
name: str
|
||||
source: EventSource
|
||||
timestamp: int = 0
|
||||
metadata: dict[str, EventMetadataValue] = field(default_factory=dict)
|
||||
|
||||
def __str__(self):
|
||||
return self.serialize()
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Union[str, "Event"]) -> "Event":
|
||||
if isinstance(data, Event):
|
||||
return data
|
||||
if isinstance(data, str):
|
||||
data_dict = json.loads(data)
|
||||
data_dict["source"] = EventSource[data_dict["source"]] # type: ignore[possibly-undefined]
|
||||
# pyrefly: ignore [unbound-name]
|
||||
return Event(**data_dict)
|
||||
|
||||
def serialize(self) -> str:
|
||||
return json.dumps(asdict(self))
|
||||
|
||||
|
||||
class NodeState(str, Enum):
|
||||
"""The states that a node can be in rendezvous."""
|
||||
|
||||
INIT = "INIT"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RdzvEvent:
|
||||
"""
|
||||
Dataclass to represent any rendezvous event.
|
||||
|
||||
Args:
|
||||
name: Event name. (E.g. Current action being performed)
|
||||
run_id: The run id of the rendezvous
|
||||
message: The message describing the event
|
||||
hostname: Hostname of the node
|
||||
pid: The process id of the node
|
||||
node_state: The state of the node (INIT, RUNNING, SUCCEEDED, FAILED)
|
||||
master_endpoint: The master endpoint for the rendezvous store, if known
|
||||
rank: The rank of the node, if known
|
||||
local_id: The local_id of the node, if defined in dynamic_rendezvous.py
|
||||
error_trace: Error stack trace, if this is an error event.
|
||||
"""
|
||||
|
||||
name: str
|
||||
run_id: str
|
||||
message: str
|
||||
hostname: str
|
||||
pid: int
|
||||
node_state: NodeState
|
||||
master_endpoint: str = ""
|
||||
rank: int | None = None
|
||||
local_id: int | None = None
|
||||
error_trace: str = ""
|
||||
|
||||
def __str__(self):
|
||||
return self.serialize()
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Union[str, "RdzvEvent"]) -> "RdzvEvent":
|
||||
if isinstance(data, RdzvEvent):
|
||||
return data
|
||||
if isinstance(data, str):
|
||||
data_dict = json.loads(data)
|
||||
data_dict["node_state"] = NodeState[data_dict["node_state"]] # type: ignore[possibly-undefined]
|
||||
# pyrefly: ignore [unbound-name]
|
||||
return RdzvEvent(**data_dict)
|
||||
|
||||
def serialize(self) -> str:
|
||||
return json.dumps(asdict(self))
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
_log_handlers: dict[str, logging.Handler] = {
|
||||
"console": logging.StreamHandler(),
|
||||
"dynamic_rendezvous": logging.NullHandler(),
|
||||
"null": logging.NullHandler(),
|
||||
}
|
||||
|
||||
|
||||
def get_logging_handler(destination: str = "null") -> logging.Handler:
|
||||
global _log_handlers
|
||||
return _log_handlers[destination]
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env/python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
"""Metrics API.
|
||||
|
||||
**Overview**:
|
||||
|
||||
The metrics API in torchelastic is used to publish telemetry metrics.
|
||||
It is designed to be used by torchelastic's internal modules to
|
||||
publish metrics for the end user with the goal of increasing visibility
|
||||
and helping with debugging. However you may use the same API in your
|
||||
jobs to publish metrics to the same metrics ``sink``.
|
||||
|
||||
A ``metric`` can be thought of as timeseries data
|
||||
and is uniquely identified by the string-valued tuple
|
||||
``(metric_group, metric_name)``.
|
||||
|
||||
torchelastic makes no assumptions about what a ``metric_group`` is
|
||||
and what relationship it has with ``metric_name``. It is totally up
|
||||
to the user to use these two fields to uniquely identify a metric.
|
||||
|
||||
.. note:: The metric group ``torchelastic`` is reserved by torchelastic for
|
||||
platform level metrics that it produces.
|
||||
For instance torchelastic may output the latency (in milliseconds)
|
||||
of a re-rendezvous operation from the agent as
|
||||
``(torchelastic, agent.rendezvous.duration.ms)``
|
||||
|
||||
A sensible way to use metric groups is to map them to a stage or module
|
||||
in your job. You may also encode certain high level properties
|
||||
the job such as the region or stage (dev vs prod).
|
||||
|
||||
**Publish Metrics**:
|
||||
|
||||
Using torchelastic's metrics API is similar to using python's logging
|
||||
framework. You first have to configure a metrics handler before
|
||||
trying to add metric data.
|
||||
|
||||
The example below measures the latency for the ``calculate()`` function.
|
||||
|
||||
::
|
||||
|
||||
import time
|
||||
import torch.distributed.elastic.metrics as metrics
|
||||
|
||||
# makes all metrics other than the one from "my_module" to go /dev/null
|
||||
metrics.configure(metrics.NullMetricsHandler())
|
||||
metrics.configure(metrics.ConsoleMetricsHandler(), "my_module")
|
||||
|
||||
|
||||
def my_method():
|
||||
start = time.time()
|
||||
calculate()
|
||||
end = time.time()
|
||||
metrics.put_metric("calculate_latency", int(end - start), "my_module")
|
||||
|
||||
You may also use the torch.distributed.elastic.metrics.prof` decorator
|
||||
to conveniently and succinctly profile functions
|
||||
|
||||
::
|
||||
|
||||
# -- in module examples.foobar --
|
||||
|
||||
import torch.distributed.elastic.metrics as metrics
|
||||
|
||||
metrics.configure(metrics.ConsoleMetricsHandler(), "foobar")
|
||||
metrics.configure(metrics.ConsoleMetricsHandler(), "Bar")
|
||||
|
||||
|
||||
@metrics.prof
|
||||
def foo():
|
||||
pass
|
||||
|
||||
|
||||
class Bar:
|
||||
@metrics.prof
|
||||
def baz():
|
||||
pass
|
||||
|
||||
``@metrics.prof`` will publish the following metrics
|
||||
::
|
||||
|
||||
<leaf_module or classname>.success - 1 if the function finished successfully
|
||||
<leaf_module or classname>.failure - 1 if the function threw an exception
|
||||
<leaf_module or classname>.duration.ms - function duration in milliseconds
|
||||
|
||||
**Configuring Metrics Handler**:
|
||||
|
||||
`torch.distributed.elastic.metrics.MetricHandler` is responsible for emitting
|
||||
the added metric values to a particular destination. Metric groups can be
|
||||
configured with different metric handlers.
|
||||
|
||||
By default torchelastic emits all metrics to ``/dev/null``.
|
||||
By adding the following configuration metrics,
|
||||
``torchelastic`` and ``my_app`` metric groups will be printed out to
|
||||
console.
|
||||
|
||||
::
|
||||
|
||||
import torch.distributed.elastic.metrics as metrics
|
||||
|
||||
metrics.configure(metrics.ConsoleMetricHandler(), group="torchelastic")
|
||||
metrics.configure(metrics.ConsoleMetricHandler(), group="my_app")
|
||||
|
||||
**Writing a Custom Metric Handler**:
|
||||
|
||||
If you want your metrics to be emitted to a custom location, implement
|
||||
the `torch.distributed.elastic.metrics.MetricHandler` interface
|
||||
and configure your job to use your custom metric handler.
|
||||
|
||||
Below is a toy example that prints the metrics to ``stdout``
|
||||
|
||||
::
|
||||
|
||||
import torch.distributed.elastic.metrics as metrics
|
||||
|
||||
|
||||
class StdoutMetricHandler(metrics.MetricHandler):
|
||||
def emit(self, metric_data):
|
||||
ts = metric_data.timestamp
|
||||
group = metric_data.group_name
|
||||
name = metric_data.name
|
||||
value = metric_data.value
|
||||
print(f"[{ts}][{group}]: {name}={value}")
|
||||
|
||||
|
||||
metrics.configure(StdoutMetricHandler(), group="my_app")
|
||||
|
||||
Now all metrics in the group ``my_app`` will be printed to stdout as:
|
||||
|
||||
::
|
||||
|
||||
[1574213883.4182858][my_app]: my_metric=<value>
|
||||
[1574213940.5237644][my_app]: my_metric=<value>
|
||||
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .api import ( # noqa: F401
|
||||
configure,
|
||||
ConsoleMetricHandler,
|
||||
get_elapsed_time_ms,
|
||||
getStream,
|
||||
MetricData,
|
||||
MetricHandler,
|
||||
MetricsConfig,
|
||||
NullMetricHandler,
|
||||
prof,
|
||||
profile,
|
||||
publish_metric,
|
||||
put_metric,
|
||||
)
|
||||
|
||||
|
||||
def initialize_metrics(cfg: MetricsConfig | None = None):
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
from torch.distributed.elastic.metrics.static_init import * # type: ignore[import] # noqa: F401 F403
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import abc
|
||||
import time
|
||||
from collections import namedtuple
|
||||
from functools import wraps
|
||||
from typing_extensions import deprecated
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MetricsConfig",
|
||||
"MetricHandler",
|
||||
"ConsoleMetricHandler",
|
||||
"NullMetricHandler",
|
||||
"MetricStream",
|
||||
"configure",
|
||||
"getStream",
|
||||
"prof",
|
||||
"profile",
|
||||
"put_metric",
|
||||
"publish_metric",
|
||||
"get_elapsed_time_ms",
|
||||
"MetricData",
|
||||
]
|
||||
|
||||
MetricData = namedtuple("MetricData", ["timestamp", "group_name", "name", "value"])
|
||||
|
||||
|
||||
class MetricsConfig:
|
||||
__slots__ = ["params"]
|
||||
|
||||
def __init__(self, params: dict[str, str] | None = None):
|
||||
self.params = params
|
||||
if self.params is None:
|
||||
self.params = {}
|
||||
|
||||
|
||||
class MetricHandler(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def emit(self, metric_data: MetricData):
|
||||
pass
|
||||
|
||||
|
||||
class ConsoleMetricHandler(MetricHandler):
|
||||
def emit(self, metric_data: MetricData):
|
||||
print(
|
||||
f"[{metric_data.timestamp}][{metric_data.group_name}]: {metric_data.name}={metric_data.value}"
|
||||
)
|
||||
|
||||
|
||||
class NullMetricHandler(MetricHandler):
|
||||
def emit(self, metric_data: MetricData):
|
||||
pass
|
||||
|
||||
|
||||
class MetricStream:
|
||||
def __init__(self, group_name: str, handler: MetricHandler):
|
||||
self.group_name = group_name
|
||||
self.handler = handler
|
||||
|
||||
def add_value(self, metric_name: str, metric_value: int):
|
||||
self.handler.emit(
|
||||
MetricData(time.time(), self.group_name, metric_name, metric_value)
|
||||
)
|
||||
|
||||
|
||||
_metrics_map: dict[str, MetricHandler] = {}
|
||||
_default_metrics_handler: MetricHandler = NullMetricHandler()
|
||||
|
||||
|
||||
# pyre-fixme[9]: group has type `str`; used as `None`.
|
||||
def configure(handler: MetricHandler, group: str | None = None):
|
||||
if group is None:
|
||||
global _default_metrics_handler
|
||||
# pyre-fixme[9]: _default_metrics_handler has type `NullMetricHandler`; used
|
||||
# as `MetricHandler`.
|
||||
_default_metrics_handler = handler
|
||||
else:
|
||||
_metrics_map[group] = handler
|
||||
|
||||
|
||||
def getStream(group: str):
|
||||
handler = _metrics_map.get(group, _default_metrics_handler)
|
||||
return MetricStream(group, handler)
|
||||
|
||||
|
||||
def _get_metric_name(fn):
|
||||
qualname = fn.__qualname__
|
||||
split = qualname.split(".")
|
||||
if len(split) == 1:
|
||||
module = fn.__module__
|
||||
if module:
|
||||
return module.split(".")[-1] + "." + split[0]
|
||||
else:
|
||||
return split[0]
|
||||
else:
|
||||
return qualname
|
||||
|
||||
|
||||
def prof(fn=None, group: str = "torchelastic"):
|
||||
r"""
|
||||
@profile decorator publishes duration.ms, count, success, failure metrics for the function that it decorates.
|
||||
|
||||
The metric name defaults to the qualified name (``class_name.def_name``) of the function.
|
||||
If the function does not belong to a class, it uses the leaf module name instead.
|
||||
|
||||
Usage
|
||||
|
||||
::
|
||||
|
||||
@metrics.prof
|
||||
def x():
|
||||
pass
|
||||
|
||||
|
||||
@metrics.prof(group="agent")
|
||||
def y():
|
||||
pass
|
||||
"""
|
||||
|
||||
def wrap(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
key = _get_metric_name(f)
|
||||
try:
|
||||
start = time.time()
|
||||
result = f(*args, **kwargs)
|
||||
put_metric(f"{key}.success", 1, group)
|
||||
except Exception:
|
||||
put_metric(f"{key}.failure", 1, group)
|
||||
raise
|
||||
finally:
|
||||
put_metric(f"{key}.duration.ms", get_elapsed_time_ms(start), group) # type: ignore[possibly-undefined]
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
if fn:
|
||||
return wrap(fn)
|
||||
else:
|
||||
return wrap
|
||||
|
||||
|
||||
@deprecated("Deprecated, use `@prof` instead", category=FutureWarning)
|
||||
def profile(group=None):
|
||||
"""
|
||||
@profile decorator adds latency and success/failure metrics to any given function.
|
||||
|
||||
Usage
|
||||
|
||||
::
|
||||
|
||||
@metrics.profile("my_metric_group")
|
||||
def some_function(<arguments>):
|
||||
"""
|
||||
|
||||
def wrap(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
start_time = time.time()
|
||||
result = func(*args, **kwargs)
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
publish_metric(group, f"{func.__name__}.success", 1)
|
||||
except Exception:
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
publish_metric(group, f"{func.__name__}.failure", 1)
|
||||
raise
|
||||
finally:
|
||||
publish_metric(
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
group,
|
||||
f"{func.__name__}.duration.ms",
|
||||
get_elapsed_time_ms(start_time), # type: ignore[possibly-undefined]
|
||||
)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def put_metric(metric_name: str, metric_value: int, metric_group: str = "torchelastic"):
|
||||
"""
|
||||
Publish a metric data point.
|
||||
|
||||
Usage
|
||||
|
||||
::
|
||||
|
||||
put_metric("metric_name", 1)
|
||||
put_metric("metric_name", 1, "metric_group_name")
|
||||
"""
|
||||
getStream(metric_group).add_value(metric_name, metric_value)
|
||||
|
||||
|
||||
@deprecated(
|
||||
"Deprecated, use `put_metric(metric_group)(metric_name, metric_value)` instead",
|
||||
category=FutureWarning,
|
||||
)
|
||||
def publish_metric(metric_group: str, metric_name: str, metric_value: int):
|
||||
metric_stream = getStream(metric_group)
|
||||
metric_stream.add_value(metric_name, metric_value)
|
||||
|
||||
|
||||
def get_elapsed_time_ms(start_time_in_seconds: float):
|
||||
"""Return the elapsed time in millis from the given start time."""
|
||||
end_time = time.time()
|
||||
return int((end_time - start_time_in_seconds) * 1000)
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
"""
|
||||
Library that launches and manages ``n`` copies of worker subprocesses either specified by a function or a binary.
|
||||
|
||||
For functions, it uses ``torch.multiprocessing`` (and therefore python
|
||||
``multiprocessing``) to spawn/fork worker processes. For binaries it uses python
|
||||
``subprocessing.Popen`` to create worker processes.
|
||||
|
||||
|
||||
Usage 1: Launching two trainers as a function
|
||||
|
||||
::
|
||||
|
||||
from torch.distributed.elastic.multiprocessing import Std, start_processes
|
||||
|
||||
|
||||
def trainer(a, b, c):
|
||||
pass # train
|
||||
|
||||
|
||||
# runs two trainers
|
||||
# LOCAL_RANK=0 trainer(1,2,3)
|
||||
# LOCAL_RANK=1 trainer(4,5,6)
|
||||
ctx = start_processes(
|
||||
name="trainer",
|
||||
entrypoint=trainer,
|
||||
args={0: (1, 2, 3), 1: (4, 5, 6)},
|
||||
envs={0: {"LOCAL_RANK": 0}, 1: {"LOCAL_RANK": 1}},
|
||||
log_dir="/tmp/foobar",
|
||||
redirects=Std.ALL, # write all worker stdout/stderr to a log file
|
||||
tee={0: Std.ERR}, # tee only local rank 0's stderr to console
|
||||
)
|
||||
|
||||
# waits for all copies of trainer to finish
|
||||
ctx.wait()
|
||||
|
||||
Usage 2: Launching 2 echo workers as a binary
|
||||
|
||||
::
|
||||
|
||||
# same as invoking
|
||||
# echo hello
|
||||
# echo world > stdout.log
|
||||
ctx = start_processes(
|
||||
name="echo"
|
||||
entrypoint="echo",
|
||||
log_dir="/tmp/foobar",
|
||||
args={0: "hello", 1: "world"},
|
||||
redirects={1: Std.OUT},
|
||||
)
|
||||
|
||||
Just like ``torch.multiprocessing``, the return value of the function
|
||||
:func:`start_processes` is a process context (:class:`api.PContext`). If a function
|
||||
was launched, a :class:`api.MultiprocessContext` is returned and if a binary
|
||||
was launched a :class:`api.SubprocessContext` is returned. Both are specific
|
||||
implementations of the parent :class:`api.PContext` class.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Optional, Union
|
||||
|
||||
from torch.distributed.elastic.multiprocessing.api import ( # noqa: F401
|
||||
_validate_full_rank,
|
||||
DefaultLogsSpecs,
|
||||
LogsDest,
|
||||
LogsSpecs,
|
||||
MultiprocessContext,
|
||||
PContext,
|
||||
ProcessFailure,
|
||||
RunProcsResult,
|
||||
SignalException,
|
||||
Std,
|
||||
SubprocessContext,
|
||||
to_map,
|
||||
)
|
||||
from torch.distributed.elastic.utils.logging import get_logger
|
||||
from torch.numa.binding import NumaOptions
|
||||
|
||||
|
||||
__all__ = [
|
||||
"start_processes",
|
||||
"MultiprocessContext",
|
||||
"PContext",
|
||||
"ProcessFailure",
|
||||
"RunProcsResult",
|
||||
"SignalException",
|
||||
"Std",
|
||||
"LogsDest",
|
||||
"LogsSpecs",
|
||||
"DefaultLogsSpecs",
|
||||
"SubprocessContext",
|
||||
"to_map",
|
||||
]
|
||||
|
||||
|
||||
def start_processes(
|
||||
name: str,
|
||||
entrypoint: Callable | str,
|
||||
args: dict[int, tuple],
|
||||
envs: dict[int, dict[str, str]],
|
||||
logs_specs: LogsSpecs,
|
||||
log_line_prefixes: dict[int, str] | None = None,
|
||||
start_method: str = "spawn",
|
||||
numa_options: NumaOptions | None = None,
|
||||
duplicate_stdout_filters: list[str] | None = None,
|
||||
duplicate_stderr_filters: list[str] | None = None,
|
||||
) -> PContext:
|
||||
"""
|
||||
Start ``n`` copies of ``entrypoint`` processes with the provided options.
|
||||
|
||||
``entrypoint`` is either a ``Callable`` (function) or a ``str`` (binary).
|
||||
The number of copies is determined by the number of entries for ``args`` and
|
||||
``envs`` arguments, which need to have the same key set.
|
||||
|
||||
``args`` and ``env`` parameters are the arguments and environment variables
|
||||
to pass down to the entrypoint mapped by the replica index (local rank).
|
||||
All local ranks must be accounted for.
|
||||
That is, the keyset should be ``{0,1,...,(nprocs-1)}``.
|
||||
|
||||
.. note:: When the ``entrypoint`` is a binary (``str``), ``args`` can only be strings.
|
||||
If any other type is given, then it is casted to a string representation
|
||||
(e.g. ``str(arg1)``). Furthermore, a binary failure will only write
|
||||
an ``error.json`` error file if the main function is annotated with
|
||||
``torch.distributed.elastic.multiprocessing.errors.record``. For function launches,
|
||||
this is done by default and there is no need to manually annotate
|
||||
with the ``@record`` annotation.
|
||||
|
||||
Inside ``logs_specs``, ``redirects`` and ``tee`` are bitmasks specifying which std
|
||||
stream(s) to redirect to a log file in the ``log_dir``. Valid mask values are defined
|
||||
in ``Std``. To redirect/tee only certain local ranks, pass ``redirects`` as a map
|
||||
with the key as the local rank to specify the redirect behavior for.
|
||||
Any missing local ranks will default to ``Std.NONE``.
|
||||
|
||||
``duplicate_stdout_filters`` and ``duplicate_stderr_filters``, if non-empty,
|
||||
duplicate stdouts and stderrs respectively specified in ``logs_specs``'s ``tee``
|
||||
to a file containing only lines that match _any_ of the filter strings. The log
|
||||
file is aggregated across all ranks selected by ``tee``.
|
||||
|
||||
``tee`` acts like the unix "tee" command in that it redirects + prints to console.
|
||||
To avoid worker stdout/stderr from printing to console, use the ``redirects`` parameter.
|
||||
|
||||
For each process, the ``log_dir`` will contain:
|
||||
|
||||
#. ``{local_rank}/error.json``: if the process failed, a file with the error info
|
||||
#. ``{local_rank}/stdout.log``: if ``redirect & STDOUT == STDOUT``
|
||||
#. ``{local_rank}/stderr.log``: if ``redirect & STDERR == STDERR``
|
||||
#. ``filtered_stdout.log``: if ``duplicate_stdout_filters`` is non-empty
|
||||
#. ``filtered_stderr.log``: if ``duplicate_stderr_filters`` is non-empty
|
||||
|
||||
.. note:: It is expected that the ``log_dir`` exists, is empty, and is a directory.
|
||||
|
||||
Example:
|
||||
::
|
||||
|
||||
log_dir = "/tmp/test"
|
||||
|
||||
# ok; two copies of foo: foo("bar0"), foo("bar1")
|
||||
start_processes(
|
||||
name="trainer",
|
||||
entrypoint=foo,
|
||||
args:{0:("bar0",), 1:("bar1",),
|
||||
envs:{0:{}, 1:{}},
|
||||
log_dir=log_dir
|
||||
)
|
||||
|
||||
# invalid; envs missing for local rank 1
|
||||
start_processes(
|
||||
name="trainer",
|
||||
entrypoint=foo,
|
||||
args:{0:("bar0",), 1:("bar1",),
|
||||
envs:{0:{}},
|
||||
log_dir=log_dir
|
||||
)
|
||||
|
||||
# ok; two copies of /usr/bin/touch: touch file1, touch file2
|
||||
start_processes(
|
||||
name="trainer",
|
||||
entrypoint="/usr/bin/touch",
|
||||
args:{0:("file1",), 1:("file2",),
|
||||
envs:{0:{}, 1:{}},
|
||||
log_dir=log_dir
|
||||
)
|
||||
|
||||
# caution; arguments casted to string, runs:
|
||||
# echo "1" "2" "3" and echo "[1, 2, 3]"
|
||||
start_processes(
|
||||
name="trainer",
|
||||
entrypoint="/usr/bin/echo",
|
||||
args:{0:(1,2,3), 1:([1,2,3],),
|
||||
envs:{0:{}, 1:{}},
|
||||
log_dir=log_dir
|
||||
)
|
||||
|
||||
Args:
|
||||
name: a human readable short name that describes what the processes are
|
||||
(used as header when tee'ing stdout/stderr outputs)
|
||||
entrypoint: either a ``Callable`` (function) or ``cmd`` (binary)
|
||||
args: arguments to each replica
|
||||
envs: env vars to each replica
|
||||
log_dir: directory used to write log files
|
||||
start_method: multiprocessing start method (spawn, fork, forkserver)
|
||||
ignored for binaries
|
||||
logs_specs: defines ``log_dir``, ``redirects``, and ``tee``.
|
||||
inside ``logs_specs``:
|
||||
- redirects: which std streams to redirect to a log file
|
||||
- tee: which std streams to redirect + print to console
|
||||
local_ranks_filter: which ranks' logs to print to console
|
||||
duplicate_stdout_filters: filters for the duplicated stdout logs
|
||||
duplicate_stderr_filters: filters for the duplicated stderr logs
|
||||
|
||||
"""
|
||||
|
||||
nprocs = len(args)
|
||||
_validate_full_rank(args, nprocs, "args")
|
||||
_validate_full_rank(envs, nprocs, "envs")
|
||||
|
||||
context: PContext
|
||||
if isinstance(entrypoint, str):
|
||||
context = SubprocessContext(
|
||||
name=name,
|
||||
entrypoint=entrypoint,
|
||||
args=args,
|
||||
envs=envs,
|
||||
duplicate_stdout_filters=duplicate_stdout_filters,
|
||||
duplicate_stderr_filters=duplicate_stderr_filters,
|
||||
logs_specs=logs_specs,
|
||||
log_line_prefixes=log_line_prefixes,
|
||||
numa_options=numa_options,
|
||||
)
|
||||
else:
|
||||
context = MultiprocessContext(
|
||||
name=name,
|
||||
entrypoint=entrypoint,
|
||||
args=args,
|
||||
envs=envs,
|
||||
duplicate_stdout_filters=duplicate_stdout_filters,
|
||||
duplicate_stderr_filters=duplicate_stderr_filters,
|
||||
log_line_prefixes=log_line_prefixes,
|
||||
start_method=start_method,
|
||||
logs_specs=logs_specs,
|
||||
numa_options=numa_options,
|
||||
)
|
||||
|
||||
try:
|
||||
context.start()
|
||||
return context
|
||||
except Exception:
|
||||
context.close()
|
||||
raise
|
||||
+1040
File diff suppressed because it is too large
Load Diff
+395
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
"""
|
||||
Each host in a distributed PyTorch job runs with a single TorchElastic agent,
|
||||
and multiple workers (as children processes of the TorchElastic agent).
|
||||
Since the workers are user-provided (your PyTorch script/job), TorchElastic
|
||||
has a way to propagate errors on the trainers through the agent and up to the
|
||||
scheduler, which ultimately informs the end-user about the state of the job
|
||||
and applies any retry policies.
|
||||
|
||||
TorchElastic categorizes errors into 3 categories:
|
||||
|
||||
+----------------+----------------+--------------------------------------------------------------+
|
||||
| Category | Sub-Category | Description |
|
||||
+================+================+==============================================================+
|
||||
| User Error | Input Error | invalid inputs to TorchElastic APIs (e.g. min > max nodes) |
|
||||
| +----------------+--------------------------------------------------------------+
|
||||
| | Worker Failure | any failures on the worker child process |
|
||||
+----------------+----------------+--------------------------------------------------------------+
|
||||
| Platform Error | n/a | failures caused by the agent |
|
||||
+----------------+----------------+--------------------------------------------------------------+
|
||||
| Infra Error | n/a | failures outside the domain of the agent and workers |
|
||||
| | | (e.g. host failures) |
|
||||
+----------------+----------------+--------------------------------------------------------------+
|
||||
|
||||
All errors other than "Worker Failure" are either raised canonically from the
|
||||
agent process or implicitly or explicitly crash the agent process. So the
|
||||
standard language (python) provided exception handling strategies apply.
|
||||
|
||||
Worker Failures are special because the exception/failure originates on a different
|
||||
process from the agent so the error needs to be propagated inter-process
|
||||
(e.g. the agent cannot simply ``try-catch`` an exception raised on the worker process).
|
||||
|
||||
TorchElastic agents use :func:`torch.distributed.elastic.multiprocessing.start_processes`
|
||||
to launch the workers which has a simple file based inter-process error propagation
|
||||
built-in.
|
||||
|
||||
Any function or binary entrypoint decorated with :func:`record`
|
||||
will write uncaught exceptions (with the trace information) to a file specified by the
|
||||
environment variable ``TORCHELASTIC_ERROR_FILE``. The parent process (e.g. agent)
|
||||
sets this env var on each child it launches, then aggregates the error files for all
|
||||
children, and propagates the one with the **smallest** timestamp (e.g. the **first** error).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from string import Template
|
||||
from typing import Any, Optional, TypeVar, Union
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
from torch.distributed.elastic.utils.logging import get_logger
|
||||
|
||||
from .error_handler import ErrorHandler # noqa: F401
|
||||
from .handlers import get_error_handler # noqa: F401
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProcessFailure",
|
||||
"ChildFailedError",
|
||||
"record",
|
||||
"ErrorHandler",
|
||||
"get_error_handler",
|
||||
]
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
JSON = dict[str, Any]
|
||||
|
||||
_EMPTY_ERROR_DATA: dict[str, Any] = {"message": "<NONE>"}
|
||||
_NOT_AVAILABLE = "<N/A>"
|
||||
|
||||
_R = TypeVar("_R")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessFailure:
|
||||
"""
|
||||
Represent the failed process result. When the worker process fails, it may record failure root cause into the file.
|
||||
|
||||
Tries to read the failure timestamp from the provided ``error_file``,
|
||||
if the ``error_file`` does not exist, the timestamp is the current
|
||||
timestamp (seconds since epoch).
|
||||
|
||||
The ``message`` field is a concise explanation of the failure. If
|
||||
the error file exists then the message is obtained from the error file.
|
||||
Otherwise one is generated based on the failure signature.
|
||||
|
||||
.. note:: It is assumed that the ``error_file`` is written by
|
||||
``torch.distributed.elastic.multiprocessing.errors.error_handler.ErrorHandler``.
|
||||
Otherwise the behavior is undefined.
|
||||
|
||||
"""
|
||||
|
||||
local_rank: int
|
||||
pid: int
|
||||
exitcode: int
|
||||
error_file: str
|
||||
error_file_data: JSON = field(init=False)
|
||||
message: str = field(init=False)
|
||||
timestamp: int = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self.error_file_data = _EMPTY_ERROR_DATA
|
||||
if os.path.isfile(self.error_file):
|
||||
try:
|
||||
with open(self.error_file) as fp:
|
||||
self.error_file_data = json.load(fp)
|
||||
logger.debug(
|
||||
"User process failed with error data: %s",
|
||||
json.dumps(self.error_file_data, indent=2),
|
||||
)
|
||||
self.message, self.timestamp = self._get_error_data(
|
||||
self.error_file_data
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to parse reply file: %s", self.error_file)
|
||||
raise
|
||||
else:
|
||||
self._set_no_reply_file()
|
||||
|
||||
# make up an informative message if not already present
|
||||
if not self.message:
|
||||
# signals typically do not generate an error file message
|
||||
if self.exitcode < 0:
|
||||
self.message = (
|
||||
f"Signal {-self.exitcode} ({self.signal_name()})"
|
||||
f" received by PID {self.pid}"
|
||||
)
|
||||
else:
|
||||
self.error_file_data["errorTraits"] = {
|
||||
"category": "system_terminated_error",
|
||||
"retryability": "False",
|
||||
}
|
||||
self.message = "To enable traceback see: https://pytorch.org/docs/stable/elastic/errors.html"
|
||||
|
||||
def _get_error_data(self, error_file_data: dict[str, Any]) -> tuple[str, int]:
|
||||
message = error_file_data["message"]
|
||||
if isinstance(message, str):
|
||||
timestamp = int(error_file_data.get("timestamp", 0))
|
||||
else:
|
||||
timestamp = int(message["extraInfo"]["timestamp"])
|
||||
return (message, timestamp)
|
||||
|
||||
def _set_no_reply_file(self):
|
||||
self.error_file = _NOT_AVAILABLE
|
||||
self.error_file_data = _EMPTY_ERROR_DATA
|
||||
self.message = ""
|
||||
self.timestamp = int(time.time())
|
||||
|
||||
def signal_name(self) -> str:
|
||||
if self.exitcode < 0:
|
||||
# We don't want to kill the parent process trying to find the signal name.
|
||||
# if the signal doesn't map to a known name, use not available.
|
||||
try:
|
||||
return signal.Signals(-self.exitcode).name
|
||||
except Exception:
|
||||
return _NOT_AVAILABLE
|
||||
else:
|
||||
return _NOT_AVAILABLE
|
||||
|
||||
def timestamp_isoformat(self):
|
||||
"""Return timestamp in ISO format (YYYY-MM-DD_HH:MM:SS)."""
|
||||
return datetime.fromtimestamp(self.timestamp).isoformat(sep="_")
|
||||
|
||||
|
||||
GlobalRank = int
|
||||
|
||||
_FAILURE_FORMAT_TEMPLATE = """[${idx}]:
|
||||
time : ${time}
|
||||
host : ${hostname}
|
||||
rank : ${rank} (local_rank: ${local_rank})
|
||||
exitcode : ${exitcode} (pid: ${pid}) ${signal_name}
|
||||
error_file: ${error_file}
|
||||
traceback : ${message}"""
|
||||
|
||||
# extra new lines before and after are intentional
|
||||
_MSG_FORMAT_TEMPLATE = """
|
||||
${boarder}
|
||||
${title}
|
||||
${section}
|
||||
Failures:
|
||||
${other_failures}
|
||||
${section}
|
||||
Root Cause (first observed failure):
|
||||
${root_failure}
|
||||
${boarder}"""
|
||||
|
||||
|
||||
class ChildFailedError(Exception):
|
||||
"""
|
||||
Special exception type that can be raised from a function annotated with the
|
||||
``@record`` decorator to have the child process' (root exception) propagate
|
||||
up the stack as-is (e.g. without being wrapped in the parent's traceback).
|
||||
|
||||
Useful in cases where the parent is a simple nanny process
|
||||
and the child (worker) processes are actually doing meaningful compute.
|
||||
In this case, errors typically occur on the child process as the parent
|
||||
is not doing anything non-trivial, and child errors should be propagated
|
||||
to the scheduler for accurate root cause diagnostics.
|
||||
|
||||
.. note:: The propagation relies on error files rather than exception handling to
|
||||
support both function and binary launches.
|
||||
|
||||
Example:
|
||||
::
|
||||
|
||||
# process tree on a host (container)
|
||||
0: scheduler-init-process:
|
||||
|- 1: torchelastic_agent:
|
||||
|- 2: trainer_0 (ok)
|
||||
|- 3: trainer_1 (fail) -> error.json
|
||||
|- ...
|
||||
|- n+2: trainer_n (ok)
|
||||
|- n+3: other processes
|
||||
|- ...
|
||||
|
||||
In the example above, trainer 1's failure (written into error.json) is
|
||||
the root cause and should be reported to the scheduler's init process.
|
||||
The torchelastic agent raises a ``ChildFailedError("trainer", {1: "trainer_1/error.json"})``
|
||||
upon detecting trainer 1's failure which would propagate the contents
|
||||
of trainer 1's error file to the scheduler's init process.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, failures: dict[GlobalRank, ProcessFailure]):
|
||||
self.name = name
|
||||
self.failures = failures
|
||||
# does not make sense to create a ChildFaileError with no failures
|
||||
if not self.failures:
|
||||
raise AssertionError
|
||||
super().__init__(self.format_msg())
|
||||
|
||||
def get_first_failure(self) -> tuple[GlobalRank, ProcessFailure]:
|
||||
rank = min(self.failures.keys(), key=lambda r: self.failures[r].timestamp)
|
||||
return rank, self.failures[rank]
|
||||
|
||||
def format_msg(self, boarder_delim="=", section_delim="-"):
|
||||
title = f"{self.name} FAILED"
|
||||
root_rank, _root_failure = self.get_first_failure()
|
||||
|
||||
root_failure_fmt: str = ""
|
||||
other_failures_fmt: list[str] = []
|
||||
width = len(title)
|
||||
for idx, (rank, failure) in enumerate(self.failures.items()):
|
||||
fmt, w = self._format_failure(idx, rank, failure)
|
||||
width = max(width, w)
|
||||
if rank == root_rank:
|
||||
root_failure_fmt = fmt
|
||||
else:
|
||||
other_failures_fmt.append(fmt)
|
||||
|
||||
# upper boundary on width
|
||||
width = min(width, 60)
|
||||
|
||||
return Template(_MSG_FORMAT_TEMPLATE).substitute(
|
||||
boarder=boarder_delim * width,
|
||||
title=title,
|
||||
section=section_delim * width,
|
||||
root_failure=root_failure_fmt,
|
||||
other_failures="\n".join(other_failures_fmt or [" <NO_OTHER_FAILURES>"]),
|
||||
)
|
||||
|
||||
def _format_failure(
|
||||
self, idx: int, rank: int, failure: ProcessFailure
|
||||
) -> tuple[str, int]:
|
||||
# failure.message is either a str (when the failure does not generate a traceback - e.g. signals)
|
||||
# or a dict (json) of the form
|
||||
# {"message": $ERROR_MSG, "extraInfo": {"py_callstack": $TRACEBACK, timestamp: $TS}}
|
||||
# so the display logic is:
|
||||
# 1. if failure.message is not a dict (it is a str) just show it as is
|
||||
# 2. else try to get the traceback (py_callstack)
|
||||
# 3. if the traceback is not there, use the message
|
||||
# 4. if the message is not there show <N/A>
|
||||
msg = failure.message
|
||||
if isinstance(failure.message, dict):
|
||||
msg = (
|
||||
failure.message.get("extraInfo", {})
|
||||
.get("py_callstack", failure.message.get("message", "<N/A>"))
|
||||
.replace("\n", "\n ") # to properly indent the traceback
|
||||
)
|
||||
|
||||
signal_name = failure.signal_name()
|
||||
signal_name_str = f" ({signal_name})" if signal_name != _NOT_AVAILABLE else ""
|
||||
|
||||
fmt = Template(_FAILURE_FORMAT_TEMPLATE).substitute(
|
||||
idx=idx,
|
||||
time=failure.timestamp_isoformat(),
|
||||
hostname=socket.getfqdn(),
|
||||
rank=rank,
|
||||
local_rank=failure.local_rank,
|
||||
exitcode=failure.exitcode,
|
||||
pid=failure.pid,
|
||||
signal_name=signal_name_str,
|
||||
error_file=failure.error_file,
|
||||
message=msg,
|
||||
)
|
||||
width = 0
|
||||
for line in fmt.split("\n"):
|
||||
width = max(width, len(line))
|
||||
return fmt, width
|
||||
|
||||
|
||||
def record(
|
||||
fn: Callable[_P, _R], error_handler: ErrorHandler | None = None
|
||||
) -> Callable[_P, _R | None]:
|
||||
"""
|
||||
Syntactic sugar to record errors/exceptions that happened in the decorated
|
||||
function using the provided ``error_handler``.
|
||||
|
||||
Using this decorator is equivalent to:
|
||||
|
||||
::
|
||||
|
||||
error_handler = get_error_handler()
|
||||
error_handler.initialize()
|
||||
try:
|
||||
foobar()
|
||||
except ChildFailedError as e:
|
||||
_, failure = e.get_first_failure()
|
||||
error_handler.dump_error_file(failure.error_file, failure.exitcode)
|
||||
raise
|
||||
except Exception as e:
|
||||
error_handler.record_exception(e)
|
||||
raise
|
||||
|
||||
.. important:: use this decorator once per process at the top level method,
|
||||
typically this is the main method.
|
||||
|
||||
Example
|
||||
|
||||
::
|
||||
|
||||
@record
|
||||
def main():
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
"""
|
||||
if not error_handler:
|
||||
error_handler = get_error_handler()
|
||||
|
||||
def wrap(f: Callable[_P, _R]) -> Callable[_P, _R | None]:
|
||||
@wraps(f)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs):
|
||||
if error_handler is None:
|
||||
raise AssertionError # assertion for mypy type checker
|
||||
error_handler.initialize()
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except SystemExit as se:
|
||||
# For run_path based entrypoints, SystemExit with code = 0 will never exit.
|
||||
# Handling it here by returning a value:
|
||||
if se.code == 0:
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
except ChildFailedError as e:
|
||||
rank, failure = e.get_first_failure()
|
||||
if failure.error_file != _NOT_AVAILABLE:
|
||||
error_handler.dump_error_file(failure.error_file, failure.exitcode)
|
||||
else:
|
||||
logger.info(
|
||||
(
|
||||
"local_rank %s FAILED with no error file."
|
||||
" Decorate your entrypoint fn with @record for traceback info."
|
||||
" See: https://pytorch.org/docs/stable/elastic/errors.html",
|
||||
rank,
|
||||
)
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
error_handler.record_exception(e)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
return wrap(fn)
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
import faulthandler
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
|
||||
__all__ = ["ErrorHandler"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
"""
|
||||
Write the provided exception object along with some other metadata about
|
||||
the error in a structured way in JSON format to an error file specified by the
|
||||
environment variable: ``TORCHELASTIC_ERROR_FILE``. If this environment
|
||||
variable is not set, then simply logs the contents of what would have been
|
||||
written to the error file.
|
||||
|
||||
This handler may be subclassed to customize the handling of the error.
|
||||
Subclasses should override ``initialize()`` and ``record_exception()``.
|
||||
"""
|
||||
|
||||
def _get_error_file_path(self) -> str | None:
|
||||
"""
|
||||
Return the error file path.
|
||||
|
||||
May return ``None`` to have the structured error be logged only.
|
||||
"""
|
||||
return os.environ.get("TORCHELASTIC_ERROR_FILE", None)
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""
|
||||
Call prior to running code that we wish to capture errors/exceptions.
|
||||
|
||||
Typically registers signal/fault handlers. Users can override this
|
||||
function to add custom initialization/registrations that aid in
|
||||
propagation/information of errors/signals/exceptions/faults.
|
||||
"""
|
||||
try:
|
||||
faulthandler.enable(all_threads=True)
|
||||
except Exception as e:
|
||||
warnings.warn(
|
||||
f"Unable to enable fault handler. {type(e).__name__}: {e}", stacklevel=2
|
||||
)
|
||||
|
||||
def _write_error_file(self, file_path: str, error_msg: str) -> None:
|
||||
"""Write error message to the file."""
|
||||
try:
|
||||
with open(file_path, "w") as fp:
|
||||
fp.write(error_msg)
|
||||
except Exception as e:
|
||||
warnings.warn(
|
||||
f"Unable to write error to file. {type(e).__name__}: {e}", stacklevel=2
|
||||
)
|
||||
|
||||
def record_exception(self, e: BaseException) -> None:
|
||||
"""
|
||||
Write a structured information about the exception into an error file in JSON format.
|
||||
|
||||
If the error file cannot be determined, then logs the content
|
||||
that would have been written to the error file.
|
||||
"""
|
||||
file = self._get_error_file_path()
|
||||
if file:
|
||||
data = {
|
||||
"message": {
|
||||
"message": f"{type(e).__name__}: {e}",
|
||||
"extraInfo": {
|
||||
"py_callstack": traceback.format_exc(),
|
||||
"timestamp": str(int(time.time())),
|
||||
},
|
||||
}
|
||||
}
|
||||
with open(file, "w") as fp:
|
||||
json.dump(data, fp)
|
||||
|
||||
def override_error_code_in_rootcause_data(
|
||||
self,
|
||||
rootcause_error_file: str,
|
||||
rootcause_error: dict[str, Any],
|
||||
error_code: int = 0,
|
||||
):
|
||||
"""Modify the rootcause_error read from the file, to correctly set the exit code."""
|
||||
if "message" not in rootcause_error:
|
||||
logger.warning(
|
||||
"child error file (%s) does not have field `message`. \n"
|
||||
"cannot override error code: %s",
|
||||
rootcause_error_file,
|
||||
error_code,
|
||||
)
|
||||
elif isinstance(rootcause_error["message"], str):
|
||||
logger.warning(
|
||||
"child error file (%s) has a new message format. \n"
|
||||
"skipping error code override",
|
||||
rootcause_error_file,
|
||||
)
|
||||
else:
|
||||
rootcause_error["message"]["errorCode"] = error_code
|
||||
|
||||
def dump_error_file(self, rootcause_error_file: str, error_code: int = 0):
|
||||
"""Dump parent error file from child process's root cause error and error code."""
|
||||
with open(rootcause_error_file) as fp:
|
||||
rootcause_error = json.load(fp)
|
||||
# Override error code since the child process cannot capture the error code if it
|
||||
# is terminated by signals like SIGSEGV.
|
||||
if error_code:
|
||||
self.override_error_code_in_rootcause_data(
|
||||
rootcause_error_file, rootcause_error, error_code
|
||||
)
|
||||
logger.debug(
|
||||
"child error file (%s) contents:\n%s",
|
||||
rootcause_error_file,
|
||||
json.dumps(rootcause_error, indent=2),
|
||||
)
|
||||
|
||||
my_error_file = self._get_error_file_path()
|
||||
if my_error_file:
|
||||
# Guard against existing error files
|
||||
# This can happen when the child is created using multiprocessing
|
||||
# and the same env var (TORCHELASTIC_ERROR_FILE) is used on the
|
||||
# parent and child to specify the error files (respectively)
|
||||
# because the env vars on the child is set in the wrapper function
|
||||
# and by default the child inherits the parent's env vars, if the child
|
||||
# process receives a signal before the wrapper function kicks in
|
||||
# and the signal handler writes to the error file, then the child
|
||||
# will write to the parent's error file. In this case just log the
|
||||
# original error file contents and overwrite the error file.
|
||||
self._rm(my_error_file)
|
||||
self._write_error_file(my_error_file, json.dumps(rootcause_error))
|
||||
logger.info("dumped error file to parent's %s", my_error_file)
|
||||
else:
|
||||
logger.error(
|
||||
"no error file defined for parent, to copy child error file (%s)",
|
||||
rootcause_error_file,
|
||||
)
|
||||
|
||||
def _rm(self, my_error_file):
|
||||
if os.path.isfile(my_error_file):
|
||||
# Log the contents of the original file.
|
||||
with open(my_error_file) as fp:
|
||||
try:
|
||||
original = json.dumps(json.load(fp), indent=2)
|
||||
logger.warning(
|
||||
"%s already exists"
|
||||
" and will be overwritten."
|
||||
" Original contents:\n%s",
|
||||
my_error_file,
|
||||
original,
|
||||
)
|
||||
except json.decoder.JSONDecodeError:
|
||||
logger.warning(
|
||||
"%s already exists"
|
||||
" and will be overwritten."
|
||||
" Unable to load original contents:\n",
|
||||
my_error_file,
|
||||
)
|
||||
os.remove(my_error_file)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
# Multiprocessing error-reporting module
|
||||
|
||||
|
||||
from torch.distributed.elastic.multiprocessing.errors.error_handler import ErrorHandler
|
||||
|
||||
|
||||
__all__ = ["get_error_handler"]
|
||||
|
||||
|
||||
def get_error_handler() -> ErrorHandler:
|
||||
return ErrorHandler()
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# !/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
# Taken and modified from original source:
|
||||
# https://eli.thegreenplace.net/2015/redirecting-all-kinds-of-stdout-in-python/
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from functools import partial
|
||||
|
||||
|
||||
IS_WINDOWS = sys.platform == "win32"
|
||||
IS_MACOS = sys.platform == "darwin"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_WIN32_STD_HANDLE = {
|
||||
"stdout": -11, # STD_OUTPUT_HANDLE
|
||||
"stderr": -12, # STD_ERROR_HANDLE
|
||||
}
|
||||
|
||||
|
||||
def get_libc():
|
||||
if IS_MACOS:
|
||||
logger.warning("NOTE: Redirects are currently not supported in MacOs.")
|
||||
return None
|
||||
elif IS_WINDOWS:
|
||||
for lib_name in ("ucrtbase", "msvcrt", "msvcr110", "msvcr100"):
|
||||
try:
|
||||
lib = ctypes.CDLL(lib_name)
|
||||
logger.debug("Loaded Windows C runtime: %s", lib_name)
|
||||
return lib
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"Could not load a C runtime DLL on Windows (tried: ucrtbase, msvcrt, "
|
||||
"msvcr110, msvcr100). Redirects cannot function without a CRT."
|
||||
)
|
||||
else:
|
||||
return ctypes.CDLL("libc.so.6")
|
||||
|
||||
|
||||
libc = get_libc()
|
||||
|
||||
|
||||
def _c_std(stream: str):
|
||||
if IS_WINDOWS:
|
||||
stream_index = 2 if stream == "stderr" else 1
|
||||
try:
|
||||
iob_func = libc.__acrt_iob_func
|
||||
iob_func.restype = ctypes.POINTER(ctypes.c_void_p)
|
||||
iob_func.argtypes = [ctypes.c_uint]
|
||||
return iob_func(stream_index)
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
legacy_index = 2 if stream == "stderr" else 1
|
||||
iob = (ctypes.POINTER(ctypes.c_void_p) * 3).in_dll(libc, "_iob")
|
||||
return iob[legacy_index]
|
||||
except (AttributeError, OSError) as err:
|
||||
raise RuntimeError(
|
||||
f"Could not resolve C-runtime FILE* for '{stream}'. "
|
||||
"Neither __acrt_iob_func nor _iob are available in the loaded CRT."
|
||||
) from err
|
||||
return ctypes.c_void_p.in_dll(libc, stream)
|
||||
|
||||
|
||||
def _python_std(stream: str):
|
||||
return {"stdout": sys.stdout, "stderr": sys.stderr}[stream]
|
||||
|
||||
|
||||
_VALID_STD = {"stdout", "stderr"}
|
||||
|
||||
|
||||
if IS_WINDOWS: # libc is None on macOS; all of the below is Windows-only
|
||||
import io as _io
|
||||
import msvcrt as _msvcrt
|
||||
|
||||
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
|
||||
|
||||
_crt_dup = libc._dup
|
||||
_crt_dup2 = libc._dup2
|
||||
_crt_dup.restype = ctypes.c_int
|
||||
_crt_dup.argtypes = [ctypes.c_int]
|
||||
_crt_dup2.restype = ctypes.c_int
|
||||
_crt_dup2.argtypes = [ctypes.c_int, ctypes.c_int]
|
||||
|
||||
@contextmanager
|
||||
def redirect(std: str, to_file: str):
|
||||
"""
|
||||
Redirect ``std`` (one of ``"stdout"`` or ``"stderr"``) to a file at ``to_file``.
|
||||
|
||||
On Windows this performs a four-layer redirect:
|
||||
|
||||
1. ``sys.stdout``/``sys.stderr`` -- rewired to a new TextIOWrapper so
|
||||
Python's ``print()`` writes to the destination file.
|
||||
2. CRT fd (``_dup2``) -- captures C ``printf`` and UCRT ``FILE*`` writers.
|
||||
3. Win32 ``SetStdHandle`` -- captures native code using ``WriteFile``/
|
||||
``WriteConsole`` directly, including HIP/ROCm.
|
||||
4. ``fflush`` before each switch -- prevents lost output from CRT buffering.
|
||||
|
||||
.. note:: If ROCm/HIP caches the Win32 HANDLE before this redirect runs
|
||||
(e.g. at ``import torch`` time), set up the redirect *before*
|
||||
importing torch/ROCm to capture all output.
|
||||
|
||||
Directory of ``to_file`` is assumed to exist. The destination file is
|
||||
overwritten if it already exists.
|
||||
"""
|
||||
if std not in _VALID_STD:
|
||||
raise ValueError(
|
||||
f"unknown standard stream <{std}>, must be one of {_VALID_STD}"
|
||||
)
|
||||
|
||||
std_fd = 1 if std == "stdout" else 2
|
||||
win32_handle_id = _WIN32_STD_HANDLE[std]
|
||||
orig_sys_std = getattr(sys, std)
|
||||
orig_fd_dup = _crt_dup(std_fd)
|
||||
if orig_fd_dup == -1:
|
||||
raise OSError(f"CRT _dup failed for {std} (fd={std_fd})")
|
||||
orig_win32_handle = _kernel32.GetStdHandle(win32_handle_id)
|
||||
|
||||
with open(to_file, mode="w+b") as dst:
|
||||
dst_fd = dst.fileno()
|
||||
|
||||
try:
|
||||
libc.fflush(_c_std(std))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
orig_sys_std.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_kernel32.SetStdHandle(
|
||||
win32_handle_id,
|
||||
_msvcrt.get_osfhandle(dst_fd), # pyrefly: ignore [missing-attribute]
|
||||
)
|
||||
|
||||
if _crt_dup2(dst_fd, std_fd) == -1:
|
||||
raise OSError(f"CRT _dup2 failed redirecting {std}")
|
||||
|
||||
new_sys_std = _io.TextIOWrapper(
|
||||
open(dst_fd, mode="wb", closefd=False), # noqa: SIM115
|
||||
encoding=orig_sys_std.encoding or "utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, std, new_sys_std)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
new_sys_std.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
libc.fflush(_c_std(std))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
setattr(sys, std, orig_sys_std)
|
||||
_crt_dup2(orig_fd_dup, std_fd)
|
||||
os.close(orig_fd_dup)
|
||||
_kernel32.SetStdHandle(win32_handle_id, orig_win32_handle)
|
||||
|
||||
else:
|
||||
|
||||
@contextmanager
|
||||
def redirect(std: str, to_file: str):
|
||||
"""
|
||||
Redirect ``std`` (one of ``"stdout"`` or ``"stderr"``) to a file in the path specified by ``to_file``.
|
||||
|
||||
This method redirects the underlying std file descriptor (not just python's ``sys.stdout|stderr``).
|
||||
See usage for details.
|
||||
|
||||
Directory of ``dst_filename`` is assumed to exist and the destination file
|
||||
is overwritten if it already exists.
|
||||
|
||||
.. note:: Due to buffering cross source writes are not guaranteed to
|
||||
appear in wall-clock order. For instance in the example below
|
||||
it is possible for the C-outputs to appear before the python
|
||||
outputs in the log file.
|
||||
|
||||
Usage::
|
||||
|
||||
# syntactic-sugar for redirect("stdout", "tmp/stdout.log")
|
||||
with redirect_stdout("/tmp/stdout.log"):
|
||||
print("python stdouts are redirected")
|
||||
libc = ctypes.CDLL("libc.so.6")
|
||||
libc.printf(b"c stdouts are also redirected")
|
||||
os.system("echo system stdouts are also redirected")
|
||||
|
||||
print("stdout restored")
|
||||
"""
|
||||
if std not in _VALID_STD:
|
||||
raise ValueError(
|
||||
f"unknown standard stream <{std}>, must be one of {_VALID_STD}"
|
||||
)
|
||||
|
||||
c_std = _c_std(std)
|
||||
python_std = _python_std(std)
|
||||
std_fd = python_std.fileno()
|
||||
|
||||
def _redirect(dst):
|
||||
libc.fflush(c_std)
|
||||
python_std.flush()
|
||||
os.dup2(dst.fileno(), std_fd)
|
||||
|
||||
with os.fdopen(os.dup(std_fd)) as orig_std, open(to_file, mode="w+b") as dst:
|
||||
_redirect(dst)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_redirect(orig_std)
|
||||
|
||||
|
||||
redirect_stdout = partial(redirect, "stdout")
|
||||
redirect_stderr = partial(redirect, "stderr")
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
from torch.distributed.elastic.multiprocessing.subprocess_handler.handlers import (
|
||||
get_subprocess_handler,
|
||||
)
|
||||
from torch.distributed.elastic.multiprocessing.subprocess_handler.subprocess_handler import (
|
||||
SubprocessHandler,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SubprocessHandler", "get_subprocess_handler"]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
from torch.distributed.elastic.multiprocessing.subprocess_handler.subprocess_handler import (
|
||||
SubprocessHandler,
|
||||
)
|
||||
from torch.numa.binding import NumaOptions
|
||||
|
||||
|
||||
__all__ = ["get_subprocess_handler"]
|
||||
|
||||
|
||||
def get_subprocess_handler(
|
||||
entrypoint: str,
|
||||
args: tuple,
|
||||
env: dict[str, str],
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
local_rank_id: int,
|
||||
numa_options: NumaOptions | None = None,
|
||||
) -> SubprocessHandler:
|
||||
return SubprocessHandler(
|
||||
entrypoint=entrypoint,
|
||||
args=args,
|
||||
env=env,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
local_rank_id=local_rank_id,
|
||||
numa_options=numa_options,
|
||||
)
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from subprocess import Popen
|
||||
from typing import Any
|
||||
|
||||
from torch.numa.binding import _maybe_wrap_command_args_with_numa_binding, NumaOptions
|
||||
|
||||
|
||||
__all__ = ["SubprocessHandler"]
|
||||
|
||||
IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
|
||||
def _get_default_signal() -> signal.Signals:
|
||||
"""Get the default termination signal. SIGTERM for unix, CTRL_C_EVENT for windows."""
|
||||
if IS_WINDOWS:
|
||||
return signal.CTRL_C_EVENT # type: ignore[attr-defined] # noqa: F821
|
||||
else:
|
||||
return signal.SIGTERM
|
||||
|
||||
|
||||
class SubprocessHandler:
|
||||
"""
|
||||
Convenience wrapper around python's ``subprocess.Popen``. Keeps track of
|
||||
meta-objects associated to the process (e.g. stdout and stderr redirect fds).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entrypoint: str,
|
||||
args: tuple,
|
||||
env: dict[str, str],
|
||||
stdout: str | None,
|
||||
stderr: str | None,
|
||||
local_rank_id: int,
|
||||
numa_options: NumaOptions | None,
|
||||
):
|
||||
self._stdout = open(stdout, "w") if stdout else None # noqa: SIM115
|
||||
self._stderr = open(stderr, "w") if stderr else None # noqa: SIM115
|
||||
# inherit parent environment vars
|
||||
env_vars = os.environ.copy()
|
||||
env_vars.update(env)
|
||||
|
||||
args_str = (entrypoint, *[str(e) for e in args])
|
||||
args_str = _maybe_wrap_command_args_with_numa_binding(
|
||||
args_str,
|
||||
gpu_index=local_rank_id,
|
||||
numa_options=numa_options,
|
||||
)
|
||||
|
||||
self.local_rank_id = local_rank_id
|
||||
|
||||
self.proc: Popen = self._popen(args_str, env_vars)
|
||||
|
||||
def _popen(self, args: tuple, env: dict[str, str]) -> Popen:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not IS_WINDOWS:
|
||||
kwargs["start_new_session"] = True
|
||||
|
||||
return Popen(
|
||||
# pyre-fixme[6]: Expected `Union[typing.Sequence[Union[_PathLike[bytes],
|
||||
# _PathLike[str], bytes, str]], bytes, str]` for 1st param but got
|
||||
# `Tuple[str, *Tuple[Any, ...]]`.
|
||||
args=args,
|
||||
env=env,
|
||||
stdout=self._stdout,
|
||||
stderr=self._stderr,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def close(self, death_sig: signal.Signals | None = None) -> None:
|
||||
if not death_sig:
|
||||
death_sig = _get_default_signal()
|
||||
if IS_WINDOWS:
|
||||
self.proc.send_signal(death_sig)
|
||||
else:
|
||||
os.killpg(self.proc.pid, death_sig)
|
||||
if self._stdout:
|
||||
self._stdout.close()
|
||||
if self._stderr:
|
||||
self._stderr.close()
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
from typing import TextIO, TYPE_CHECKING
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from concurrent.futures._base import Future
|
||||
|
||||
__all__ = ["tail_logfile", "TailLog"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def tail_logfile(
|
||||
header: str,
|
||||
file: str,
|
||||
dst: TextIO,
|
||||
finished: Event,
|
||||
interval_sec: float,
|
||||
log_line_filter: Callable[[str], bool] | None = None,
|
||||
):
|
||||
while not os.path.exists(file):
|
||||
if finished.is_set():
|
||||
return
|
||||
time.sleep(interval_sec)
|
||||
|
||||
with open(file, errors="replace") as fp:
|
||||
while True:
|
||||
line = fp.readline()
|
||||
|
||||
if line:
|
||||
if log_line_filter and log_line_filter(line):
|
||||
dst.write(f"{header}{line}")
|
||||
else: # reached EOF
|
||||
if finished.is_set():
|
||||
# log line producer is finished
|
||||
break
|
||||
else:
|
||||
# log line producer is still going
|
||||
# wait for a bit before looping again
|
||||
time.sleep(interval_sec)
|
||||
|
||||
|
||||
class TailLog:
|
||||
"""
|
||||
Tail the given log files.
|
||||
|
||||
The log files do not have to exist when the ``start()`` method is called. The tail-er will gracefully wait until
|
||||
the log files are created by the producer and will tail the contents of the
|
||||
log files until the ``stop()`` method is called.
|
||||
|
||||
.. warning:: ``TailLog`` will wait indefinitely for the log file to be created!
|
||||
|
||||
Each log file's line will be suffixed with a header of the form: ``[{name}{idx}]:``,
|
||||
where the ``name`` is user-provided and ``idx`` is the index of the log file
|
||||
in the ``log_files`` mapping. ``log_line_prefixes`` can be used to override the
|
||||
header for each log file.
|
||||
|
||||
Usage:
|
||||
|
||||
::
|
||||
|
||||
log_files = {0: "/tmp/0_stdout.log", 1: "/tmp/1_stdout.log"}
|
||||
tailer = TailLog("trainer", log_files, sys.stdout).start()
|
||||
# actually run the trainers to produce 0_stdout.log and 1_stdout.log
|
||||
run_trainers()
|
||||
tailer.stop()
|
||||
|
||||
# once run_trainers() start writing the ##_stdout.log files
|
||||
# the tailer will print to sys.stdout:
|
||||
# >>> [trainer0]:log_line1
|
||||
# >>> [trainer1]:log_line1
|
||||
# >>> [trainer0]:log_line2
|
||||
# >>> [trainer0]:log_line3
|
||||
# >>> [trainer1]:log_line2
|
||||
|
||||
.. note:: Due to buffering log lines between files may not necessarily
|
||||
be printed out in order. You should configure your application's
|
||||
logger to suffix each log line with a proper timestamp.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
log_files: dict[int, str],
|
||||
dst: TextIO,
|
||||
log_line_prefixes: dict[int, str] | None = None,
|
||||
interval_sec: float = 0.1,
|
||||
log_line_filter: Callable[[str], bool] = (lambda _: True),
|
||||
):
|
||||
n = len(log_files)
|
||||
self._threadpool = None
|
||||
if n > 0:
|
||||
self._threadpool = ThreadPoolExecutor(
|
||||
max_workers=n,
|
||||
thread_name_prefix=f"{self.__class__.__qualname__}_{name}",
|
||||
)
|
||||
|
||||
self._name = name
|
||||
self._dst = dst
|
||||
self._log_files = log_files
|
||||
self._log_line_prefixes = log_line_prefixes
|
||||
self._log_line_filter = log_line_filter
|
||||
self._finished_events: dict[int, Event] = {
|
||||
local_rank: Event() for local_rank in log_files
|
||||
}
|
||||
self._futs: list[Future] = []
|
||||
self._interval_sec = interval_sec
|
||||
self._stopped = False
|
||||
|
||||
def start(self) -> "TailLog":
|
||||
if not self._threadpool or not self._dst:
|
||||
return self
|
||||
|
||||
for local_rank, file in self._log_files.items():
|
||||
header = f"[{self._name}{local_rank}]:"
|
||||
if self._log_line_prefixes and local_rank in self._log_line_prefixes:
|
||||
header = self._log_line_prefixes[local_rank]
|
||||
self._futs.append(
|
||||
self._threadpool.submit(
|
||||
tail_logfile,
|
||||
header=header,
|
||||
file=file,
|
||||
dst=self._dst,
|
||||
finished=self._finished_events[local_rank],
|
||||
interval_sec=self._interval_sec,
|
||||
log_line_filter=self._log_line_filter,
|
||||
)
|
||||
)
|
||||
return self
|
||||
|
||||
def stop(self) -> None:
|
||||
for finished in self._finished_events.values():
|
||||
finished.set()
|
||||
|
||||
for local_rank, f in enumerate(self._futs):
|
||||
try:
|
||||
f.result()
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"error in log tailor for %s%s. %s",
|
||||
self._name,
|
||||
local_rank,
|
||||
e.__class__.__qualname__,
|
||||
)
|
||||
|
||||
if self._threadpool:
|
||||
self._threadpool.shutdown(wait=True)
|
||||
|
||||
self._stopped = True
|
||||
|
||||
def stopped(self) -> bool:
|
||||
return self._stopped
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
"""
|
||||
In the context of Torch Distributed Elastic we use the term *rendezvous* to
|
||||
refer to a particular functionality that combines a **distributed
|
||||
synchronization** primitive with **peer discovery**.
|
||||
|
||||
It is used by Torch Distributed Elastic to gather participants of a training
|
||||
job (i.e. nodes) such that they all agree on the same list of participants and
|
||||
everyone's roles, as well as make a consistent collective decision on when
|
||||
training can begin/resume.
|
||||
|
||||
Torch Distributed Elastic rendezvous provides the following critical
|
||||
functionalities:
|
||||
|
||||
**Barrier**:
|
||||
|
||||
Nodes performing rendezvous will all block until the rendezvous is considered
|
||||
complete - this happens when at least ``min`` total number of nodes have joined
|
||||
the rendezvous barrier (for the same job). This also implies the barrier is not
|
||||
necessarily of fixed size.
|
||||
|
||||
There's an additional small waiting time after reaching ``min`` number of
|
||||
nodes - this is used to ensure the rendezvous is not completed "too quickly"
|
||||
(which could potentially exclude additional nodes attempting to join at
|
||||
approximately the same time).
|
||||
|
||||
If ``max`` number of nodes is gathered at the barrier, the rendezvous is
|
||||
completed immediately.
|
||||
|
||||
There's also an overall timeout which causes the rendezvous to fail if ``min``
|
||||
number of nodes is never reached - this is meant to be a simple fail-safe to
|
||||
help release partially allocated job resources, in case there's a problem with
|
||||
the resource manager, and is meant to be interpreted as non-retryable.
|
||||
|
||||
**Exclusivity**:
|
||||
|
||||
A simple distributed barrier would not be sufficient, as we also need to ensure
|
||||
that only one group of nodes exists at any given time (for a given job). In
|
||||
other words, new nodes (i.e. joining late) should not be able to form a parallel
|
||||
independent group of workers for the same job.
|
||||
|
||||
Torch Distributed Elastic rendezvous ensures that if a group of nodes has
|
||||
already completed a rendezvous (and hence might already be training), then
|
||||
additional "late" nodes attempting to rendezvous will only announce themselves
|
||||
as waiting, and will have to wait until the (previously completed) existing
|
||||
rendezvous is destroyed first.
|
||||
|
||||
**Consistency**:
|
||||
|
||||
When a rendezvous is completed, all its members will agree on the job membership
|
||||
and everyone's role in it. This role is represented using an integer, called
|
||||
rank, that is between between 0 and world size.
|
||||
|
||||
Note that ranks are *not stable*, in the sense that the same node can be
|
||||
assigned a different rank in the next (re-)rendezvous.
|
||||
|
||||
**Fault-tolerance**:
|
||||
|
||||
Torch Distributed Elastic rendezvous is designed to tolerate node failures
|
||||
during the rendezvous process. Should a process crash (or lose network
|
||||
connectivity, etc), between joining the rendezvous and it being completed, then
|
||||
a re-rendezvous with remaining healthy nodes will happen automatically.
|
||||
|
||||
A node can also fail *after* it has completed (or *has been observed* by other
|
||||
nodes to have completed) the rendezvous - this scenario will be handled by the
|
||||
Torch Distributed Elastic ``train_loop`` instead (where it will also trigger a
|
||||
re-rendezvous).
|
||||
|
||||
**Shared key-value store**:
|
||||
|
||||
When the rendezvous is completed, a shared key-value store is created and
|
||||
returned. This store implements a ``torch.distributed.Store`` API (see
|
||||
`distributed communication docs
|
||||
<https://pytorch.org/docs/stable/distributed.html>`__).
|
||||
|
||||
This store is only shared by the members of the completed rendezvous. It
|
||||
is intended to be used by Torch Distributed Elastic to exchange information
|
||||
necessary to initialize job control and data-planes.
|
||||
|
||||
**Waiting workers and rendezvous closing**:
|
||||
|
||||
Torch Distributed Elastic rendezvous handler object provides additional
|
||||
functionalities, which are technically not part of the rendezvous process:
|
||||
|
||||
1. Querying how many workers arrived late at the barrier, who can participate in
|
||||
*next* rendezvous.
|
||||
|
||||
2. Setting the rendezvous *closed* to signal all nodes not to participate in
|
||||
next rendezvous.
|
||||
|
||||
**DynamicRendezvousHandler**:
|
||||
|
||||
Torch Distributed Elastic comes with the :py:class:`.DynamicRendezvousHandler`
|
||||
class that implements the rendezvous mechanism described above. It is a backend-
|
||||
agnostic type that expects a particular :py:class:`.RendezvousBackend` instance
|
||||
to be specified during construction.
|
||||
|
||||
Torch distributed users can either implement their own backend type or use one
|
||||
of the following implementations that come with PyTorch:
|
||||
|
||||
- :py:class:`.C10dRendezvousBackend`: Uses a C10d store (by default
|
||||
``TCPStore``) as the rendezvous backend. The main advantage of using a C10d
|
||||
store is that it requires no 3rd-party dependency (such as etcd) to establish
|
||||
a rendezvous.
|
||||
- :py:class:`.EtcdRendezvousBackend`: Supersedes the legacy
|
||||
:py:class:`.EtcdRendezvousHandler` class. Passing an
|
||||
:py:class:`.EtcdRendezvousBackend` instance to
|
||||
:py:class:`.DynamicRendezvousHandler` is functionally equivalent to
|
||||
instantiating an :py:class:`.EtcdRendezvousHandler`.
|
||||
|
||||
::
|
||||
|
||||
store = TCPStore("localhost")
|
||||
|
||||
backend = C10dRendezvousBackend(store, "my_run_id")
|
||||
|
||||
rdzv_handler = DynamicRendezvousHandler.from_backend(
|
||||
run_id="my_run_id", store=store, backend=backend, min_nodes=2, max_nodes=4
|
||||
)
|
||||
"""
|
||||
|
||||
from .api import (
|
||||
rendezvous_handler_registry,
|
||||
RendezvousClosedError,
|
||||
RendezvousConnectionError,
|
||||
RendezvousError,
|
||||
RendezvousGracefulExitError,
|
||||
RendezvousHandler,
|
||||
RendezvousHandlerCreator,
|
||||
RendezvousHandlerRegistry,
|
||||
RendezvousInfo,
|
||||
RendezvousParameters,
|
||||
RendezvousStateError,
|
||||
RendezvousStoreInfo,
|
||||
RendezvousTimeoutError,
|
||||
)
|
||||
from .registry import _register_default_handlers, _register_out_of_tree_handlers
|
||||
|
||||
|
||||
_register_default_handlers()
|
||||
_register_out_of_tree_handlers()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RendezvousClosedError",
|
||||
"RendezvousConnectionError",
|
||||
"RendezvousError",
|
||||
"RendezvousGracefulExitError",
|
||||
"RendezvousHandler",
|
||||
"RendezvousHandlerCreator",
|
||||
"RendezvousHandlerRegistry",
|
||||
"RendezvousInfo",
|
||||
"RendezvousParameters",
|
||||
"RendezvousStateError",
|
||||
"RendezvousStoreInfo",
|
||||
"RendezvousTimeoutError",
|
||||
"rendezvous_handler_registry",
|
||||
]
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
"""
|
||||
This file is not meant to be used directly. It serves as a stub to allow
|
||||
other files to be safely imported without requiring the installation of
|
||||
the 'etcd' library. The classes and methods here raise exceptions to
|
||||
indicate that the real 'etcd' module is needed.
|
||||
"""
|
||||
|
||||
|
||||
class EtcdStubError(ImportError):
|
||||
"""Custom exception to indicate that the real etcd module is required."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("The 'etcd' module is required but not installed.")
|
||||
|
||||
|
||||
class EtcdAlreadyExist(Exception):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
|
||||
class EtcdCompareFailed(Exception):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
|
||||
class EtcdKeyNotFound(Exception):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
|
||||
class EtcdWatchTimedOut(Exception):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
|
||||
class EtcdEventIndexCleared(Exception):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
|
||||
class EtcdException(Exception):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
|
||||
class EtcdResult:
|
||||
def __init__(self) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
def read(self, key: str) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
def write(
|
||||
self, key: str, value: Any, ttl: int | None = None, **kwargs: Any
|
||||
) -> None:
|
||||
raise EtcdStubError
|
||||
|
||||
def test_and_set(
|
||||
self, key: str, value: Any, prev_value: Any, ttl: int | None = None
|
||||
) -> None:
|
||||
raise EtcdStubError
|
||||
@@ -0,0 +1,391 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import socket
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from torch.distributed import Store
|
||||
from torch.distributed.elastic.utils.distributed import get_free_port
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RendezvousClosedError",
|
||||
"RendezvousConnectionError",
|
||||
"RendezvousError",
|
||||
"RendezvousGracefulExitError",
|
||||
"RendezvousHandler",
|
||||
"RendezvousHandlerCreator",
|
||||
"RendezvousHandlerRegistry",
|
||||
"RendezvousInfo",
|
||||
"RendezvousParameters",
|
||||
"RendezvousStateError",
|
||||
"RendezvousStoreInfo",
|
||||
"RendezvousTimeoutError",
|
||||
"rendezvous_handler_registry",
|
||||
]
|
||||
|
||||
|
||||
class RendezvousError(Exception):
|
||||
"""Represents the base type for rendezvous errors."""
|
||||
|
||||
|
||||
class RendezvousClosedError(RendezvousError):
|
||||
"""Raised when a rendezvous is closed."""
|
||||
|
||||
|
||||
class RendezvousTimeoutError(RendezvousError):
|
||||
"""Raised when a rendezvous did not complete on time."""
|
||||
|
||||
|
||||
class RendezvousConnectionError(RendezvousError):
|
||||
"""Raised when the connection to a rendezvous backend has failed."""
|
||||
|
||||
|
||||
class RendezvousStateError(RendezvousError):
|
||||
"""Raised when the state of a rendezvous is corrupt."""
|
||||
|
||||
|
||||
class RendezvousGracefulExitError(RendezvousError):
|
||||
"""Raised when node wasn't not included in rendezvous and gracefully exits.
|
||||
|
||||
Exception is a mechanism to exit the stack, however does not mean a failure.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RendezvousStoreInfo:
|
||||
"""Store address and port that can be used to bootstrap trainer distributed comms"""
|
||||
|
||||
MASTER_ADDR_KEY: ClassVar[str] = "MASTER_ADDR"
|
||||
MASTER_PORT_KEY: ClassVar[str] = "MASTER_PORT"
|
||||
master_addr: str
|
||||
master_port: int
|
||||
|
||||
@staticmethod
|
||||
def build(
|
||||
rank: int,
|
||||
store: Store,
|
||||
local_addr: str | None,
|
||||
server_port: int | None = None,
|
||||
) -> "RendezvousStoreInfo":
|
||||
"""Factory method, finds unused new port on rank0 host and addr/port info with all ranks.
|
||||
|
||||
If master_addr/master_port is knowns (useful when sharing existing tcp store server) use the constructor.
|
||||
|
||||
Args:
|
||||
rank: rank of the current node
|
||||
store: store to use for rendezvous
|
||||
local_addr: address of the current node, if not provided will be resolved from hostname
|
||||
server_port: port of the TCPStore server, when the TCPStore is shared.
|
||||
"""
|
||||
# TODO swap to collectives comms API
|
||||
if rank == 0:
|
||||
addr = local_addr or socket.getfqdn()
|
||||
# When TCPStore is not shared, we fallback to get_free_port.
|
||||
port = server_port or get_free_port()
|
||||
store.set(
|
||||
RendezvousStoreInfo.MASTER_ADDR_KEY,
|
||||
addr.encode(encoding="UTF-8"), # type: ignore[arg-type]
|
||||
)
|
||||
store.set(
|
||||
RendezvousStoreInfo.MASTER_PORT_KEY,
|
||||
str(port).encode(encoding="UTF-8"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
addr = store.get(RendezvousStoreInfo.MASTER_ADDR_KEY).decode(encoding="UTF-8")
|
||||
port = int(
|
||||
store.get(RendezvousStoreInfo.MASTER_PORT_KEY).decode(encoding="UTF-8")
|
||||
)
|
||||
return RendezvousStoreInfo(master_addr=addr, master_port=port)
|
||||
|
||||
|
||||
class RendezvousInfo:
|
||||
"""Holds the information about the rendezvous."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: Store,
|
||||
rank: int,
|
||||
world_size: int,
|
||||
bootstrap_store_info: RendezvousStoreInfo,
|
||||
):
|
||||
self._store = store
|
||||
self._rank = rank
|
||||
self._world_size = world_size
|
||||
self._bootstrap_store_info = bootstrap_store_info
|
||||
|
||||
@property
|
||||
def store(self) -> Store:
|
||||
"""Store used by torchelastic control plane"""
|
||||
return self._store
|
||||
|
||||
@property
|
||||
def rank(self) -> int:
|
||||
"""Rank within a group"""
|
||||
return self._rank
|
||||
|
||||
@property
|
||||
def world_size(self) -> int:
|
||||
"""Global group size"""
|
||||
return self._world_size
|
||||
|
||||
@property
|
||||
def bootstrap_store_info(self) -> RendezvousStoreInfo | None:
|
||||
"""Store information that can used by trainer code to bootstrap distributed comms."""
|
||||
return self._bootstrap_store_info
|
||||
|
||||
|
||||
class RendezvousHandler(ABC):
|
||||
"""Main rendezvous interface.
|
||||
|
||||
Note:
|
||||
Distributed Torch users normally **do not** need to implement their own
|
||||
``RendezvousHandler``. An implementation based on C10d Store is already
|
||||
provided, and is recommended for most users.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_backend(self) -> str:
|
||||
"""Return the name of the rendezvous backend."""
|
||||
|
||||
@property
|
||||
def use_agent_store(self) -> bool:
|
||||
"""Indicates that store reference returned by :py:meth:`next_rendezvous` can be shared with user
|
||||
applications and will be available during application lifecycle.
|
||||
|
||||
Rendezvous handler impl will share store details as instance of :py:class:`RendezvousStoreInfo`.
|
||||
Applications as a convention use `MASTER_ADDR`/`MASTER_PORT` env variables to lookup the store.
|
||||
"""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def next_rendezvous(self) -> RendezvousInfo:
|
||||
"""Main entry-point into the rendezvous barrier.
|
||||
|
||||
Blocks until the rendezvous is complete and the current process is
|
||||
included in the formed worker group, or a timeout occurs, or the
|
||||
rendezvous was marked closed.
|
||||
|
||||
Returns:
|
||||
Instance of :py:class:`RendezvousInfo`.
|
||||
|
||||
Raises:
|
||||
RendezvousClosedError:
|
||||
The rendezvous is closed.
|
||||
RendezvousConnectionError:
|
||||
The connection to the rendezvous backend has failed.
|
||||
RendezvousStateError:
|
||||
The rendezvous state is corrupt.
|
||||
RendezvousTimeoutError:
|
||||
The rendezvous did not complete on time.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def is_closed(self) -> bool:
|
||||
"""Check whether the rendezvous has been closed.
|
||||
|
||||
A closed rendezvous means all future attempts to re-rendezvous within
|
||||
same job will fail.
|
||||
|
||||
``is_closed()`` and :py:meth:`set_closed` have semantics of eventual
|
||||
propagation and should not be used for synchronization. The intention is
|
||||
that if at least one node decides the job is finished, it will close the
|
||||
rendezvous, and other nodes will soon observe this and stop running as
|
||||
well.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def set_closed(self):
|
||||
"""Mark the rendezvous as closed."""
|
||||
|
||||
@abstractmethod
|
||||
def num_nodes_waiting(self) -> int:
|
||||
"""Return the number of nodes who arrived late at the rendezvous
|
||||
barrier, hence were not included in the current worker group.
|
||||
|
||||
Callers should periodically call this method to check whether new
|
||||
nodes are waiting to join the job and if so admit them by calling
|
||||
:py:meth:`next_rendezvous()` (re-rendezvous).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_run_id(self) -> str:
|
||||
"""Return the run id of the rendezvous.
|
||||
|
||||
The run id is a user-defined id that uniquely identifies an instance of
|
||||
a distributed application. It typically maps to a job id and is used to
|
||||
allow nodes to join the correct distributed application.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def shutdown(self) -> bool:
|
||||
"""Close all resources that were open for the rendezvous.
|
||||
|
||||
Example::
|
||||
|
||||
rdzv_handler = ...
|
||||
try:
|
||||
store, rank, world_size = rdzv_handler.next_rendezvous()
|
||||
finally:
|
||||
rdzv_handler.shutdown()
|
||||
"""
|
||||
|
||||
|
||||
class RendezvousParameters:
|
||||
"""Hold the parameters to construct a :py:class:`RendezvousHandler`.
|
||||
|
||||
Args:
|
||||
backend:
|
||||
The name of the backend to use to handle the rendezvous.
|
||||
endpoint:
|
||||
The endpoint of the rendezvous, usually in form <hostname>[:<port>].
|
||||
run_id:
|
||||
The id of the rendezvous.
|
||||
min_nodes:
|
||||
The minimum number of nodes to admit to the rendezvous.
|
||||
max_nodes:
|
||||
The maximum number of nodes to admit to the rendezvous.
|
||||
local_addr:
|
||||
The address of the local node.
|
||||
**kwargs:
|
||||
Additional parameters for the specified backend.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: str,
|
||||
endpoint: str,
|
||||
run_id: str,
|
||||
min_nodes: int,
|
||||
max_nodes: int,
|
||||
local_addr: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
if not backend:
|
||||
raise ValueError("The rendezvous backend name must be a non-empty string.")
|
||||
|
||||
if min_nodes < 1:
|
||||
raise ValueError(
|
||||
f"The minimum number of rendezvous nodes ({min_nodes}) must be greater than zero."
|
||||
)
|
||||
if max_nodes < min_nodes:
|
||||
raise ValueError(
|
||||
f"The maximum number of rendezvous nodes ({max_nodes}) must be greater than or "
|
||||
f"equal to the minimum number of rendezvous nodes ({min_nodes})."
|
||||
)
|
||||
|
||||
self.backend = backend
|
||||
self.endpoint = endpoint
|
||||
self.run_id = run_id
|
||||
self.min_nodes = min_nodes
|
||||
self.max_nodes = max_nodes
|
||||
self.config = kwargs
|
||||
self.local_addr = local_addr
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Return the value for ``key`` if ``key`` exists, else ``default``."""
|
||||
return self.config.get(key, default)
|
||||
|
||||
def get_as_bool(self, key: str, default: bool | None = None) -> bool | None:
|
||||
"""Return the value for ``key`` as a ``bool``."""
|
||||
value = self.get(key, default)
|
||||
if value is None or isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
if value == 1:
|
||||
return True
|
||||
if value == 0:
|
||||
return False
|
||||
elif isinstance(value, str):
|
||||
if value.lower() in ["1", "true", "t", "yes", "y"]:
|
||||
return True
|
||||
if value.lower() in ["0", "false", "f", "no", "n"]:
|
||||
return False
|
||||
raise ValueError(
|
||||
f"The rendezvous configuration option '{key}' does not represent a valid boolean value."
|
||||
)
|
||||
|
||||
def get_as_int(self, key: str, default: int | None = None) -> int | None:
|
||||
"""Return the value for ``key`` as an ``int``."""
|
||||
value = self.get(key, default)
|
||||
if value is None:
|
||||
return value
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"The rendezvous configuration option '{key}' does not represent a valid integer "
|
||||
"value."
|
||||
) from e
|
||||
|
||||
|
||||
RendezvousHandlerCreator = Callable[[RendezvousParameters], RendezvousHandler]
|
||||
|
||||
|
||||
class RendezvousHandlerRegistry:
|
||||
"""Represent a registry of :py:class:`RendezvousHandler` backends."""
|
||||
|
||||
_registry: dict[str, RendezvousHandlerCreator]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._registry = {}
|
||||
|
||||
def register(self, backend: str, creator: RendezvousHandlerCreator) -> None:
|
||||
"""Register a new rendezvous backend.
|
||||
|
||||
Args:
|
||||
backend:
|
||||
The name of the backend.
|
||||
creator:
|
||||
The callback to invoke to construct the
|
||||
:py:class:`RendezvousHandler`.
|
||||
"""
|
||||
if not backend:
|
||||
raise ValueError("The rendezvous backend name must be a non-empty string.")
|
||||
|
||||
current_creator: RendezvousHandlerCreator | None
|
||||
try:
|
||||
current_creator = self._registry[backend]
|
||||
except KeyError:
|
||||
current_creator = None
|
||||
|
||||
if current_creator is not None and current_creator != creator:
|
||||
raise ValueError(
|
||||
f"The rendezvous backend '{backend}' cannot be registered with '{creator}' as it "
|
||||
f"is already registered with '{current_creator}'."
|
||||
)
|
||||
|
||||
self._registry[backend] = creator
|
||||
|
||||
def create_handler(self, params: RendezvousParameters) -> RendezvousHandler:
|
||||
"""Create a new :py:class:`RendezvousHandler`."""
|
||||
try:
|
||||
creator = self._registry[params.backend]
|
||||
except KeyError as e:
|
||||
raise ValueError(
|
||||
f"The rendezvous backend '{params.backend}' is not registered. Did you forget "
|
||||
f"to call `{self.register.__name__}`?"
|
||||
) from e
|
||||
|
||||
handler = creator(params)
|
||||
|
||||
# Do some sanity check.
|
||||
if handler.get_backend() != params.backend:
|
||||
raise RuntimeError(
|
||||
f"The rendezvous backend '{handler.get_backend()}' does not match the requested "
|
||||
f"backend '{params.backend}'."
|
||||
)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
# The default global registry instance used by launcher scripts to instantiate
|
||||
# rendezvous handlers.
|
||||
rendezvous_handler_registry = RendezvousHandlerRegistry()
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from base64 import b64decode, b64encode
|
||||
from datetime import timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
from torch.distributed import FileStore, Store, TCPStore
|
||||
from torch.distributed.elastic.events import construct_and_record_rdzv_event, NodeState
|
||||
|
||||
from .api import (
|
||||
RendezvousConnectionError,
|
||||
RendezvousError,
|
||||
RendezvousParameters,
|
||||
RendezvousStateError,
|
||||
)
|
||||
from .dynamic_rendezvous import RendezvousBackend, Token
|
||||
from .utils import _matches_machine_hostname, parse_rendezvous_endpoint
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# default port for the TCP store
|
||||
DEFAULT_PORT = 29400
|
||||
|
||||
|
||||
class C10dRendezvousBackend(RendezvousBackend):
|
||||
"""Represents a C10d-backed rendezvous backend.
|
||||
|
||||
Args:
|
||||
store:
|
||||
The :py:class:`torch.distributed.Store` instance to use to
|
||||
communicate with the C10d store.
|
||||
run_id:
|
||||
The run id of the rendezvous.
|
||||
"""
|
||||
|
||||
# See the explanation in the __init__ method.
|
||||
_NULL_SENTINEL = "Y2FuaW1hZGFt"
|
||||
|
||||
_store: Store
|
||||
_key: str
|
||||
|
||||
def __init__(self, store: Store, run_id: str) -> None:
|
||||
if not run_id:
|
||||
raise ValueError("The run id must be a non-empty string.")
|
||||
|
||||
self._store = store
|
||||
|
||||
self._key = "torch.rendezvous." + run_id
|
||||
|
||||
# The read operation of a store blocks the caller until the specified
|
||||
# key becomes available. This behavior makes it tricky to use a store
|
||||
# as a regular key-value dictionary.
|
||||
#
|
||||
# As a workaround we initially set a sentinel value as the rendezvous
|
||||
# state. Whenever this value gets returned we treat it as a None.
|
||||
self._call_store("compare_set", self._key, "", self._NULL_SENTINEL)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""See base class."""
|
||||
return "c10d"
|
||||
|
||||
def get_state(self) -> tuple[bytes, Token] | None:
|
||||
"""See base class."""
|
||||
base64_state: bytes = self._call_store("get", self._key)
|
||||
|
||||
return self._decode_state(base64_state)
|
||||
|
||||
def set_state(
|
||||
self, state: bytes, token: Token | None = None
|
||||
) -> tuple[bytes, Token, bool] | None:
|
||||
"""See base class."""
|
||||
base64_state_str: str = b64encode(state).decode()
|
||||
|
||||
if token:
|
||||
# Shortcut if we know for sure that the token is not valid.
|
||||
if not isinstance(token, bytes):
|
||||
result = self.get_state()
|
||||
if result is not None:
|
||||
return *result, False
|
||||
return None
|
||||
|
||||
token = token.decode()
|
||||
else:
|
||||
token = self._NULL_SENTINEL
|
||||
|
||||
base64_state: bytes = self._call_store(
|
||||
"compare_set", self._key, token, base64_state_str
|
||||
)
|
||||
|
||||
state_token_pair = self._decode_state(base64_state)
|
||||
if state_token_pair is None:
|
||||
return None
|
||||
|
||||
new_state, new_token = state_token_pair
|
||||
|
||||
# C10d Store's compare_set method does not offer an easy way to find out
|
||||
# whether our write attempt was successful. As a brute-force solution we
|
||||
# perform a bitwise comparison of our local state and the remote state.
|
||||
return new_state, new_token, new_state == state
|
||||
|
||||
def _call_store(self, store_op: str, *args, **kwargs) -> Any:
|
||||
try:
|
||||
return getattr(self._store, store_op)(*args, **kwargs)
|
||||
except (ValueError, RuntimeError, TimeoutError) as exc:
|
||||
raise RendezvousConnectionError(
|
||||
"The connection to the C10d store has failed. See inner exception for details."
|
||||
) from exc
|
||||
|
||||
def _decode_state(self, base64_state: bytes) -> tuple[bytes, Token] | None:
|
||||
if base64_state == self._NULL_SENTINEL.encode():
|
||||
return None
|
||||
|
||||
try:
|
||||
state = b64decode(base64_state)
|
||||
except binascii.Error as exc:
|
||||
raise RendezvousStateError(
|
||||
"The state object is corrupt. See inner exception for details."
|
||||
) from exc
|
||||
|
||||
return state, base64_state
|
||||
|
||||
|
||||
def _create_tcp_store(params: RendezvousParameters) -> TCPStore:
|
||||
host, port = parse_rendezvous_endpoint(params.endpoint, default_port=DEFAULT_PORT)
|
||||
|
||||
cfg_is_host = params.get_as_bool("is_host")
|
||||
# If the user has explicitly specified whether our process should host the
|
||||
# the store, respect it.
|
||||
if cfg_is_host is not None:
|
||||
is_host = cfg_is_host
|
||||
# Otherwise try to determine whether we are the host based on our hostname
|
||||
# and IP address.
|
||||
else:
|
||||
is_host = _matches_machine_hostname(host)
|
||||
|
||||
# The timeout
|
||||
read_timeout = cast(int, params.get_as_int("read_timeout", 60))
|
||||
if read_timeout <= 0:
|
||||
raise ValueError("The read timeout must be a positive integer.")
|
||||
|
||||
# In specific cases we attempt to instantiate the store twice. For details
|
||||
# see the explanation in the except clause below.
|
||||
for is_server in [is_host, False]:
|
||||
try:
|
||||
store = TCPStore(
|
||||
host,
|
||||
port,
|
||||
is_master=is_server,
|
||||
multi_tenant=True,
|
||||
timeout=timedelta(seconds=read_timeout),
|
||||
)
|
||||
|
||||
if is_server:
|
||||
msg = f"Process {os.getpid()} hosts the TCP store for the C10d rendezvous backend."
|
||||
construct_and_record_rdzv_event(
|
||||
run_id=params.run_id, message=msg, node_state=NodeState.INIT
|
||||
)
|
||||
logger.info(msg)
|
||||
|
||||
break
|
||||
except (ValueError, RuntimeError, TimeoutError) as exc:
|
||||
# If we heuristically inferred the value of is_host as True and our
|
||||
# first attempt to instantiate the TCP store has failed, try it one
|
||||
# more time with is_host set to False. As an edge case there can be
|
||||
# more than one process that is part of the same rendezvous on this
|
||||
# machine and only one of them will eventually host the store.
|
||||
|
||||
if not is_server or cfg_is_host is not None:
|
||||
raise RendezvousConnectionError(
|
||||
"The connection to the C10d store has failed. See inner exception for details."
|
||||
) from exc
|
||||
|
||||
return store # type: ignore[possibly-undefined]
|
||||
|
||||
|
||||
def _create_file_store(params: RendezvousParameters) -> FileStore:
|
||||
# If a user specifies an endpoint, we treat it as a path to a file.
|
||||
if params.endpoint:
|
||||
path = params.endpoint
|
||||
else:
|
||||
try:
|
||||
# The temporary file is readable and writable only by the user of
|
||||
# this process.
|
||||
_, path = tempfile.mkstemp()
|
||||
except OSError as exc:
|
||||
raise RendezvousError(
|
||||
"The file creation for C10d store has failed. See inner exception for details."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
store = FileStore(path)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise RendezvousConnectionError(
|
||||
"The connection to the C10d store has failed. See inner exception for details."
|
||||
) from exc
|
||||
|
||||
return store
|
||||
|
||||
|
||||
def create_backend(params: RendezvousParameters) -> tuple[C10dRendezvousBackend, Store]:
|
||||
"""Create a new :py:class:`C10dRendezvousBackend` from the specified parameters.
|
||||
|
||||
+--------------+-----------------------------------------------------------+
|
||||
| Parameter | Description |
|
||||
+==============+===========================================================+
|
||||
| store_type | The type of the C10d store. The currently supported types |
|
||||
| | are "tcp" and "file" which correspond to |
|
||||
| | :py:class:`torch.distributed.TCPStore` and |
|
||||
| | :py:class:`torch.distributed.FileStore`, respectively. |
|
||||
| | Defaults to "tcp". |
|
||||
+--------------+-----------------------------------------------------------+
|
||||
| read_timeout | The read timeout, in seconds, for store operations. |
|
||||
| | Defaults to 60 seconds. |
|
||||
| | |
|
||||
| | Note this only applies to |
|
||||
| | :py:class:`torch.distributed.TCPStore`. It is not relevant|
|
||||
| | to :py:class:`torch.distributed.FileStore` which does not |
|
||||
| | take in timeout as a parameter. |
|
||||
+--------------+-----------------------------------------------------------+
|
||||
| is_host | A boolean value indicating whether this backend instance |
|
||||
| | will host the C10d store. If not specified it will be |
|
||||
| | inferred heuristically by matching the hostname or the IP |
|
||||
| | address of this machine against the specified rendezvous |
|
||||
| | endpoint. Defaults to ``None``. |
|
||||
| | |
|
||||
| | Note that this configuration option only applies to |
|
||||
| | :py:class:`torch.distributed.TCPStore`. In normal |
|
||||
| | circumstances you can safely skip it; the only time when |
|
||||
| | it is needed is if its value cannot be correctly |
|
||||
| | determined (e.g. the rendezvous endpoint has a CNAME as |
|
||||
| | the hostname or does not match the FQDN of the machine). |
|
||||
+--------------+-----------------------------------------------------------+
|
||||
"""
|
||||
# As of today we only support TCPStore and FileStore. Other store types do
|
||||
# not have the required functionality (e.g. compare_set) yet.
|
||||
store_type = params.get("store_type", "tcp").strip().lower()
|
||||
store: Store
|
||||
|
||||
try:
|
||||
if store_type == "file":
|
||||
store = _create_file_store(params)
|
||||
elif store_type == "tcp":
|
||||
store = _create_tcp_store(params)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Invalid store type given. Currently only supports file and tcp."
|
||||
)
|
||||
|
||||
backend = C10dRendezvousBackend(store, params.run_id)
|
||||
|
||||
except Exception as e:
|
||||
construct_and_record_rdzv_event(
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
run_id=params.run_id,
|
||||
node_state=NodeState.FAILED,
|
||||
)
|
||||
raise
|
||||
|
||||
return backend, store
|
||||
+1455
File diff suppressed because it is too large
Load Diff
+1081
File diff suppressed because it is too large
Load Diff
+214
@@ -0,0 +1,214 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import binascii
|
||||
from base64 import b64decode, b64encode
|
||||
from typing import cast
|
||||
|
||||
import urllib3.exceptions # type: ignore[import]
|
||||
|
||||
|
||||
try:
|
||||
import etcd # type: ignore[import]
|
||||
except ModuleNotFoundError:
|
||||
from . import _etcd_stub as etcd
|
||||
|
||||
from torch.distributed import Store
|
||||
|
||||
from .api import RendezvousConnectionError, RendezvousParameters, RendezvousStateError
|
||||
from .dynamic_rendezvous import RendezvousBackend, Token
|
||||
from .etcd_store import EtcdStore
|
||||
from .utils import parse_rendezvous_endpoint
|
||||
|
||||
|
||||
class EtcdRendezvousBackend(RendezvousBackend):
|
||||
"""Represents an etcd-based rendezvous backend.
|
||||
|
||||
Args:
|
||||
client:
|
||||
The ``etcd.Client`` instance to use to communicate with etcd.
|
||||
run_id:
|
||||
The run id of the rendezvous.
|
||||
key_prefix:
|
||||
The path under which to store the rendezvous state in etcd.
|
||||
ttl:
|
||||
The TTL of the rendezvous state. If not specified, defaults to two hours.
|
||||
"""
|
||||
|
||||
_DEFAULT_TTL = 7200 # 2 hours
|
||||
|
||||
_client: etcd.Client
|
||||
_key: str
|
||||
_ttl: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: etcd.Client,
|
||||
run_id: str,
|
||||
key_prefix: str | None = None,
|
||||
ttl: int | None = None,
|
||||
) -> None:
|
||||
if not run_id:
|
||||
raise ValueError("The run id must be a non-empty string.")
|
||||
|
||||
self._client = client
|
||||
|
||||
if key_prefix:
|
||||
self._key = key_prefix + "/" + run_id
|
||||
else:
|
||||
self._key = run_id
|
||||
|
||||
if ttl and ttl > 0:
|
||||
self._ttl = ttl
|
||||
else:
|
||||
self._ttl = self._DEFAULT_TTL
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""See base class."""
|
||||
return "etcd-v2"
|
||||
|
||||
def get_state(self) -> tuple[bytes, Token] | None:
|
||||
"""See base class."""
|
||||
try:
|
||||
result = self._client.read(self._key)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
return None
|
||||
except (etcd.EtcdException, urllib3.exceptions.TimeoutError) as exc:
|
||||
raise RendezvousConnectionError(
|
||||
"The connection to etcd has failed. See inner exception for details."
|
||||
) from exc
|
||||
|
||||
return self._decode_state(result)
|
||||
|
||||
def set_state(
|
||||
self, state: bytes, token: Token | None = None
|
||||
) -> tuple[bytes, Token, bool] | None:
|
||||
"""See base class."""
|
||||
base64_state = b64encode(state).decode()
|
||||
|
||||
kwargs = {}
|
||||
|
||||
def get_state():
|
||||
result = self.get_state()
|
||||
if result is not None:
|
||||
return *result, False
|
||||
return None
|
||||
|
||||
if token:
|
||||
try:
|
||||
token = int(token)
|
||||
except ValueError:
|
||||
return get_state()
|
||||
|
||||
if token:
|
||||
kwargs["prevIndex"] = token
|
||||
else:
|
||||
kwargs["prevExist"] = False
|
||||
|
||||
try:
|
||||
result = self._client.write(self._key, base64_state, self._ttl, **kwargs)
|
||||
except (etcd.EtcdAlreadyExist, etcd.EtcdCompareFailed):
|
||||
result = None
|
||||
except (etcd.EtcdException, urllib3.exceptions.TimeoutError) as exc:
|
||||
raise RendezvousConnectionError(
|
||||
"The connection to etcd has failed. See inner exception for details."
|
||||
) from exc
|
||||
|
||||
if result is None:
|
||||
return get_state()
|
||||
|
||||
tmp = *self._decode_state(result), True
|
||||
return tmp
|
||||
|
||||
def _decode_state(self, result: etcd.EtcdResult) -> tuple[bytes, Token]:
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
base64_state = result.value.encode()
|
||||
|
||||
try:
|
||||
state = b64decode(base64_state)
|
||||
except binascii.Error as exc:
|
||||
raise RendezvousStateError(
|
||||
"The state object is corrupt. See inner exception for details."
|
||||
) from exc
|
||||
|
||||
# pyrefly: ignore [missing-attribute]
|
||||
return state, result.modifiedIndex
|
||||
|
||||
|
||||
def _create_etcd_client(params: RendezvousParameters) -> etcd.Client:
|
||||
host, port = parse_rendezvous_endpoint(params.endpoint, default_port=2379)
|
||||
|
||||
# The timeout
|
||||
read_timeout = cast(int, params.get_as_int("read_timeout", 60))
|
||||
if read_timeout <= 0:
|
||||
raise ValueError("The read timeout must be a positive integer.")
|
||||
|
||||
# The communication protocol
|
||||
protocol = params.get("protocol", "http").strip().lower()
|
||||
if protocol != "http" and protocol != "https":
|
||||
raise ValueError("The protocol must be HTTP or HTTPS.")
|
||||
|
||||
# The SSL client certificate
|
||||
ssl_cert = params.get("ssl_cert")
|
||||
if ssl_cert:
|
||||
ssl_cert_key = params.get("ssl_cert_key")
|
||||
if ssl_cert_key:
|
||||
# The etcd client expects the certificate key as the second element
|
||||
# of the `cert` tuple.
|
||||
ssl_cert = (ssl_cert, ssl_cert_key)
|
||||
|
||||
# The root certificate
|
||||
ca_cert = params.get("ca_cert")
|
||||
|
||||
try:
|
||||
return etcd.Client(
|
||||
host,
|
||||
port,
|
||||
read_timeout=read_timeout,
|
||||
protocol=protocol,
|
||||
cert=ssl_cert,
|
||||
ca_cert=ca_cert,
|
||||
allow_reconnect=True,
|
||||
)
|
||||
except (etcd.EtcdException, urllib3.exceptions.TimeoutError) as exc:
|
||||
raise RendezvousConnectionError(
|
||||
"The connection to etcd has failed. See inner exception for details."
|
||||
) from exc
|
||||
|
||||
|
||||
def create_backend(params: RendezvousParameters) -> tuple[EtcdRendezvousBackend, Store]:
|
||||
"""Create a new :py:class:`EtcdRendezvousBackend` from the specified parameters.
|
||||
|
||||
+--------------+-----------------------------------------------------------+
|
||||
| Parameter | Description |
|
||||
+==============+===========================================================+
|
||||
| read_timeout | The read timeout, in seconds, for etcd operations. |
|
||||
| | Defaults to 60 seconds. |
|
||||
+--------------+-----------------------------------------------------------+
|
||||
| protocol | The protocol to use to communicate with etcd. Valid |
|
||||
| | values are "http" and "https". Defaults to "http". |
|
||||
+--------------+-----------------------------------------------------------+
|
||||
| ssl_cert | The path to the SSL client certificate to use along with |
|
||||
| | HTTPS. Defaults to ``None``. |
|
||||
+--------------+-----------------------------------------------------------+
|
||||
| ssl_cert_key | The path to the private key of the SSL client certificate |
|
||||
| | to use along with HTTPS. Defaults to ``None``. |
|
||||
+--------------+-----------------------------------------------------------+
|
||||
| ca_cert | The path to the rool SSL authority certificate. Defaults |
|
||||
| | to ``None``. |
|
||||
+--------------+-----------------------------------------------------------+
|
||||
"""
|
||||
client = _create_etcd_client(params)
|
||||
|
||||
backend = EtcdRendezvousBackend(
|
||||
client, params.run_id, key_prefix="/torch/elastic/rendezvous"
|
||||
)
|
||||
|
||||
store = EtcdStore(client, "/torch/elastic/store")
|
||||
|
||||
return backend, store
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import TextIO
|
||||
|
||||
|
||||
try:
|
||||
import etcd # type: ignore[import]
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_free_port():
|
||||
"""
|
||||
Find a free port and binds a temporary socket to it so that the port can be "reserved" until used.
|
||||
|
||||
.. note:: the returned socket must be closed before using the port,
|
||||
otherwise a ``address already in use`` error will happen.
|
||||
The socket should be held and closed as close to the
|
||||
consumer of the port as possible since otherwise, there
|
||||
is a greater chance of race-condition where a different
|
||||
process may see the port as being free and take it.
|
||||
|
||||
Returns: a socket binded to the reserved free port
|
||||
|
||||
Usage::
|
||||
|
||||
sock = find_free_port()
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
use_port(port)
|
||||
"""
|
||||
addrs = socket.getaddrinfo(
|
||||
host="localhost", port=None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM
|
||||
)
|
||||
|
||||
for addr in addrs:
|
||||
family, type, proto, _, _ = addr
|
||||
try:
|
||||
s = socket.socket(family, type, proto)
|
||||
s.bind(("localhost", 0))
|
||||
s.listen(0)
|
||||
return s
|
||||
except OSError as e:
|
||||
s.close() # type: ignore[possibly-undefined]
|
||||
print(f"Socket creation attempt failed: {e}")
|
||||
raise RuntimeError("Failed to create a socket")
|
||||
|
||||
|
||||
def stop_etcd(subprocess, data_dir: str | None = None):
|
||||
if subprocess and subprocess.poll() is None:
|
||||
logger.info("stopping etcd server")
|
||||
subprocess.terminate()
|
||||
subprocess.wait()
|
||||
|
||||
if data_dir:
|
||||
logger.info("deleting etcd data dir: %s", data_dir)
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
|
||||
|
||||
class EtcdServer:
|
||||
"""
|
||||
.. note:: tested on etcd server v3.4.3.
|
||||
|
||||
Starts and stops a local standalone etcd server on a random free
|
||||
port. Useful for single node, multi-worker launches or testing,
|
||||
where a sidecar etcd server is more convenient than having to
|
||||
separately setup an etcd server.
|
||||
|
||||
This class registers a termination handler to shutdown the etcd
|
||||
subprocess on exit. This termination handler is NOT a substitute for
|
||||
calling the ``stop()`` method.
|
||||
|
||||
The following fallback mechanism is used to find the etcd binary:
|
||||
|
||||
1. Uses env var TORCHELASTIC_ETCD_BINARY_PATH
|
||||
2. Uses ``<this file root>/bin/etcd`` if one exists
|
||||
3. Uses ``etcd`` from ``PATH``
|
||||
|
||||
Usage
|
||||
::
|
||||
|
||||
server = EtcdServer("/usr/bin/etcd", 2379, "/tmp/default.etcd")
|
||||
server.start()
|
||||
client = server.get_client()
|
||||
# use client
|
||||
server.stop()
|
||||
|
||||
Args:
|
||||
etcd_binary_path: path of etcd server binary (see above for fallback path)
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: str | None = None):
|
||||
self._port = -1
|
||||
self._host = "localhost"
|
||||
|
||||
root = os.path.dirname(__file__)
|
||||
default_etcd_bin = os.path.join(root, "bin/etcd")
|
||||
self._etcd_binary_path = os.environ.get(
|
||||
"TORCHELASTIC_ETCD_BINARY_PATH", default_etcd_bin
|
||||
)
|
||||
if not os.path.isfile(self._etcd_binary_path):
|
||||
self._etcd_binary_path = "etcd"
|
||||
|
||||
self._base_data_dir = (
|
||||
data_dir if data_dir else tempfile.mkdtemp(prefix="torchelastic_etcd_data")
|
||||
)
|
||||
self._etcd_cmd = None
|
||||
self._etcd_proc: subprocess.Popen | None = None
|
||||
|
||||
def _get_etcd_server_process(self) -> subprocess.Popen:
|
||||
if not self._etcd_proc:
|
||||
raise RuntimeError(
|
||||
"No etcd server process started. Call etcd_server.start() first"
|
||||
)
|
||||
else:
|
||||
return self._etcd_proc
|
||||
|
||||
def get_port(self) -> int:
|
||||
"""Return the port the server is running on."""
|
||||
return self._port
|
||||
|
||||
def get_host(self) -> str:
|
||||
"""Return the host the server is running on."""
|
||||
return self._host
|
||||
|
||||
def get_endpoint(self) -> str:
|
||||
"""Return the etcd server endpoint (host:port)."""
|
||||
return f"{self._host}:{self._port}"
|
||||
|
||||
def start(
|
||||
self,
|
||||
timeout: int = 60,
|
||||
num_retries: int = 3,
|
||||
stderr: int | TextIO | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Start the server, and waits for it to be ready. When this function returns the sever is ready to take requests.
|
||||
|
||||
Args:
|
||||
timeout: time (in seconds) to wait for the server to be ready
|
||||
before giving up.
|
||||
num_retries: number of retries to start the server. Each retry
|
||||
will wait for max ``timeout`` before considering it as failed.
|
||||
stderr: the standard error file handle. Valid values are
|
||||
`subprocess.PIPE`, `subprocess.DEVNULL`, an existing file
|
||||
descriptor (a positive integer), an existing file object, and
|
||||
`None`.
|
||||
|
||||
Raises:
|
||||
TimeoutError: if the server is not ready within the specified timeout
|
||||
"""
|
||||
curr_retries = 0
|
||||
while True:
|
||||
try:
|
||||
data_dir = os.path.join(self._base_data_dir, str(curr_retries))
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
return self._start(data_dir, timeout, stderr)
|
||||
except Exception as e:
|
||||
curr_retries += 1
|
||||
stop_etcd(self._etcd_proc)
|
||||
logger.warning( # noqa: G200
|
||||
"Failed to start etcd server, got error: %s, retrying", e
|
||||
)
|
||||
if curr_retries >= num_retries:
|
||||
shutil.rmtree(self._base_data_dir, ignore_errors=True)
|
||||
raise
|
||||
atexit.register(stop_etcd, self._etcd_proc, self._base_data_dir)
|
||||
|
||||
def _start(
|
||||
self, data_dir: str, timeout: int = 60, stderr: int | TextIO | None = None
|
||||
) -> None:
|
||||
sock = find_free_port()
|
||||
sock_peer = find_free_port()
|
||||
self._port = sock.getsockname()[1]
|
||||
peer_port = sock_peer.getsockname()[1]
|
||||
|
||||
etcd_cmd = shlex.split(
|
||||
" ".join(
|
||||
[
|
||||
self._etcd_binary_path,
|
||||
"--enable-v2",
|
||||
"--data-dir",
|
||||
data_dir,
|
||||
"--listen-client-urls",
|
||||
f"http://{self._host}:{self._port}",
|
||||
"--advertise-client-urls",
|
||||
f"http://{self._host}:{self._port}",
|
||||
"--listen-peer-urls",
|
||||
f"http://{self._host}:{peer_port}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Starting etcd server: [%s]", etcd_cmd)
|
||||
|
||||
sock.close()
|
||||
sock_peer.close()
|
||||
self._etcd_proc = subprocess.Popen(etcd_cmd, close_fds=True, stderr=stderr)
|
||||
self._wait_for_ready(timeout)
|
||||
|
||||
def get_client(self):
|
||||
"""Return an etcd client object that can be used to make requests to this server."""
|
||||
return etcd.Client(
|
||||
host=self._host, port=self._port, version_prefix="/v2", read_timeout=10
|
||||
)
|
||||
|
||||
def _wait_for_ready(self, timeout: int = 60) -> None:
|
||||
client = etcd.Client(
|
||||
host=f"{self._host}", port=self._port, version_prefix="/v2", read_timeout=5
|
||||
)
|
||||
max_time = time.time() + timeout
|
||||
|
||||
while time.time() < max_time:
|
||||
if self._get_etcd_server_process().poll() is not None:
|
||||
# etcd server process finished
|
||||
exitcode = self._get_etcd_server_process().returncode
|
||||
raise RuntimeError(
|
||||
f"Etcd server process exited with the code: {exitcode}"
|
||||
)
|
||||
try:
|
||||
logger.info("etcd server ready. version: %s", client.version)
|
||||
return
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
raise TimeoutError("Timed out waiting for etcd server to be ready!")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the server and cleans up auto generated resources (e.g. data dir)."""
|
||||
logger.info("EtcdServer stop method called")
|
||||
stop_etcd(self._etcd_proc, self._base_data_dir)
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import datetime
|
||||
import random
|
||||
import time
|
||||
from base64 import b64decode, b64encode
|
||||
|
||||
# pyre-ignore[21]: Could not find name `Store` in `torch.distributed`.
|
||||
from torch.distributed import Store
|
||||
|
||||
|
||||
try:
|
||||
import etcd # type: ignore[import]
|
||||
except ModuleNotFoundError:
|
||||
from . import _etcd_stub as etcd
|
||||
|
||||
|
||||
# Delay (sleep) for a small random amount to reduce CAS failures.
|
||||
# This does not affect correctness, but will reduce requests to etcd server.
|
||||
def cas_delay():
|
||||
time.sleep(random.uniform(0, 0.1))
|
||||
|
||||
|
||||
# pyre-fixme[11]: Annotation `Store` is not defined as a type.
|
||||
class EtcdStore(Store):
|
||||
"""
|
||||
Implement a c10 Store interface by piggybacking on the rendezvous etcd instance.
|
||||
|
||||
This is the store object returned by ``EtcdRendezvous``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
etcd_client,
|
||||
etcd_store_prefix,
|
||||
# Default timeout same as in c10d/Store.hpp
|
||||
timeout: datetime.timedelta | None = None,
|
||||
):
|
||||
super().__init__() # required for pybind trampoline.
|
||||
|
||||
self.client = etcd_client
|
||||
self.prefix = etcd_store_prefix
|
||||
|
||||
if timeout is not None:
|
||||
self.set_timeout(timeout)
|
||||
|
||||
if not self.prefix.endswith("/"):
|
||||
self.prefix += "/"
|
||||
|
||||
def set(self, key, value):
|
||||
"""
|
||||
Write a key/value pair into ``EtcdStore``.
|
||||
|
||||
Both key and value may be either Python ``str`` or ``bytes``.
|
||||
"""
|
||||
self.client.set(key=self.prefix + self._encode(key), value=self._encode(value))
|
||||
|
||||
def get(self, key) -> bytes:
|
||||
"""
|
||||
Get a value by key, possibly doing a blocking wait.
|
||||
|
||||
If key is not immediately present, will do a blocking wait
|
||||
for at most ``timeout`` duration or until the key is published.
|
||||
|
||||
|
||||
Returns:
|
||||
value ``(bytes)``
|
||||
|
||||
Raises:
|
||||
LookupError - If key still not published after timeout
|
||||
"""
|
||||
b64_key = self.prefix + self._encode(key)
|
||||
kvs = self._try_wait_get([b64_key])
|
||||
|
||||
if kvs is None:
|
||||
raise LookupError(f"Key {key} not found in EtcdStore")
|
||||
|
||||
return self._decode(kvs[b64_key])
|
||||
|
||||
def add(self, key, num: int) -> int:
|
||||
"""
|
||||
Atomically increment a value by an integer amount.
|
||||
|
||||
The integer is represented as a string using base 10. If key is not present,
|
||||
a default value of ``0`` will be assumed.
|
||||
|
||||
Returns:
|
||||
the new (incremented) value
|
||||
|
||||
|
||||
"""
|
||||
b64_key = self._encode(key)
|
||||
# c10d Store assumes value is an integer represented as a decimal string
|
||||
try:
|
||||
# Assume default value "0", if this key didn't yet:
|
||||
node = self.client.write(
|
||||
key=self.prefix + b64_key,
|
||||
value=self._encode(str(num)), # i.e. 0 + num
|
||||
prevExist=False,
|
||||
)
|
||||
return int(self._decode(node.value))
|
||||
except etcd.EtcdAlreadyExist:
|
||||
pass
|
||||
|
||||
while True:
|
||||
# Note: c10d Store does not have a method to delete keys, so we
|
||||
# can be sure it's still there.
|
||||
node = self.client.get(key=self.prefix + b64_key)
|
||||
new_value = self._encode(str(int(self._decode(node.value)) + num))
|
||||
try:
|
||||
node = self.client.test_and_set(
|
||||
key=node.key, value=new_value, prev_value=node.value
|
||||
)
|
||||
return int(self._decode(node.value))
|
||||
except etcd.EtcdCompareFailed:
|
||||
cas_delay()
|
||||
|
||||
# pyrefly: ignore [bad-override]
|
||||
def wait(self, keys, override_timeout: datetime.timedelta | None = None):
|
||||
"""
|
||||
Wait until all of the keys are published, or until timeout.
|
||||
|
||||
Raises:
|
||||
LookupError - if timeout occurs
|
||||
"""
|
||||
b64_keys = [self.prefix + self._encode(key) for key in keys]
|
||||
kvs = self._try_wait_get(b64_keys, override_timeout)
|
||||
if kvs is None:
|
||||
raise LookupError("Timeout while waiting for keys in EtcdStore")
|
||||
# No return value on success
|
||||
|
||||
def check(self, keys) -> bool:
|
||||
"""Check if all of the keys are immediately present (without waiting)."""
|
||||
b64_keys = [self.prefix + self._encode(key) for key in keys]
|
||||
kvs = self._try_wait_get(
|
||||
b64_keys,
|
||||
override_timeout=datetime.timedelta(microseconds=1), # as if no wait
|
||||
)
|
||||
return kvs is not None
|
||||
|
||||
#
|
||||
# Encode key/value data in base64, so we can store arbitrary binary data
|
||||
# in EtcdStore. Input can be `str` or `bytes`.
|
||||
# In case of `str`, utf-8 encoding is assumed.
|
||||
#
|
||||
def _encode(self, value) -> str:
|
||||
if type(value) is bytes:
|
||||
return b64encode(value).decode()
|
||||
elif type(value) is str:
|
||||
return b64encode(value.encode()).decode()
|
||||
raise ValueError("Value must be of type str or bytes")
|
||||
|
||||
#
|
||||
# Decode a base64 string (of type `str` or `bytes`).
|
||||
# Return type is `bytes`, which is more convenient with the Store interface.
|
||||
#
|
||||
def _decode(self, value) -> bytes:
|
||||
if type(value) is bytes:
|
||||
return b64decode(value)
|
||||
elif type(value) is str:
|
||||
return b64decode(value.encode())
|
||||
raise ValueError("Value must be of type str or bytes")
|
||||
|
||||
#
|
||||
# Get all of the (base64-encoded) etcd keys at once, or wait until all the keys
|
||||
# are published or timeout occurs.
|
||||
# This is a helper method for the public interface methods.
|
||||
#
|
||||
# On success, a dictionary of {etcd key -> etcd value} is returned.
|
||||
# On timeout, None is returned.
|
||||
#
|
||||
def _try_wait_get(self, b64_keys, override_timeout=None):
|
||||
timeout = self.timeout if override_timeout is None else override_timeout # type: ignore[attr-defined]
|
||||
deadline = time.time() + timeout.total_seconds()
|
||||
|
||||
while True:
|
||||
# Read whole directory (of keys), filter only the ones waited for
|
||||
all_nodes = None
|
||||
try:
|
||||
all_nodes = self.client.get(key=self.prefix)
|
||||
req_nodes = {
|
||||
node.key: node.value
|
||||
for node in all_nodes.children
|
||||
if node.key in b64_keys
|
||||
}
|
||||
|
||||
if len(req_nodes) == len(b64_keys):
|
||||
# All keys are available
|
||||
return req_nodes
|
||||
except etcd.EtcdKeyNotFound:
|
||||
pass
|
||||
|
||||
watch_timeout = deadline - time.time()
|
||||
if watch_timeout <= 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
index = all_nodes.etcd_index + 1 if all_nodes else 0
|
||||
self.client.watch(
|
||||
key=self.prefix,
|
||||
recursive=True,
|
||||
timeout=watch_timeout,
|
||||
index=index,
|
||||
)
|
||||
except etcd.EtcdWatchTimedOut:
|
||||
if time.time() >= deadline:
|
||||
return None
|
||||
else:
|
||||
continue
|
||||
except etcd.EtcdEventIndexCleared:
|
||||
continue
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import logging
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
from .api import (
|
||||
rendezvous_handler_registry as handler_registry,
|
||||
RendezvousHandler,
|
||||
RendezvousParameters,
|
||||
)
|
||||
from .dynamic_rendezvous import create_handler
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["get_rendezvous_handler"]
|
||||
|
||||
|
||||
def _create_static_handler(params: RendezvousParameters) -> RendezvousHandler:
|
||||
from . import static_tcp_rendezvous
|
||||
|
||||
return static_tcp_rendezvous.create_rdzv_handler(params)
|
||||
|
||||
|
||||
def _create_etcd_handler(params: RendezvousParameters) -> RendezvousHandler:
|
||||
from . import etcd_rendezvous
|
||||
|
||||
return etcd_rendezvous.create_rdzv_handler(params)
|
||||
|
||||
|
||||
def _create_etcd_v2_handler(params: RendezvousParameters) -> RendezvousHandler:
|
||||
from .etcd_rendezvous_backend import create_backend
|
||||
|
||||
backend, store = create_backend(params)
|
||||
|
||||
return create_handler(store, backend, params)
|
||||
|
||||
|
||||
def _create_c10d_handler(params: RendezvousParameters) -> RendezvousHandler:
|
||||
from .c10d_rendezvous_backend import create_backend
|
||||
|
||||
backend, store = create_backend(params)
|
||||
|
||||
return create_handler(store, backend, params)
|
||||
|
||||
|
||||
def _register_default_handlers() -> None:
|
||||
handler_registry.register("etcd", _create_etcd_handler)
|
||||
handler_registry.register("etcd-v2", _create_etcd_v2_handler)
|
||||
handler_registry.register("c10d", _create_c10d_handler)
|
||||
handler_registry.register("static", _create_static_handler)
|
||||
|
||||
|
||||
def _register_out_of_tree_handlers() -> None:
|
||||
discovered_handler_generators = entry_points(group="torchrun.handlers")
|
||||
|
||||
for handler_generator in discovered_handler_generators:
|
||||
try:
|
||||
# pyrefly: ignore [bad-index]
|
||||
get_handler = discovered_handler_generators[handler_generator.name].load()
|
||||
handler_registry.register(handler_generator.name, get_handler())
|
||||
except Exception:
|
||||
log.warning(
|
||||
"Exception while registering out of tree plugin %s: ",
|
||||
handler_generator.name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def get_rendezvous_handler(params: RendezvousParameters) -> RendezvousHandler:
|
||||
"""
|
||||
Obtain a reference to a :py:class`RendezvousHandler`.
|
||||
|
||||
Custom rendezvous handlers can be registered by
|
||||
|
||||
::
|
||||
|
||||
from torch.distributed.elastic.rendezvous import rendezvous_handler_registry
|
||||
from torch.distributed.elastic.rendezvous.registry import get_rendezvous_handler
|
||||
|
||||
|
||||
def create_my_rdzv(params: RendezvousParameters):
|
||||
return MyCustomRdzv(params)
|
||||
|
||||
|
||||
rendezvous_handler_registry.register("my_rdzv_backend_name", create_my_rdzv)
|
||||
|
||||
my_rdzv_handler = get_rendezvous_handler(
|
||||
"my_rdzv_backend_name", RendezvousParameters
|
||||
)
|
||||
"""
|
||||
return handler_registry.create_handler(params)
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from torch.distributed import PrefixStore, Store, TCPStore
|
||||
from torch.distributed.elastic.rendezvous import (
|
||||
RendezvousHandler,
|
||||
RendezvousInfo,
|
||||
RendezvousParameters,
|
||||
RendezvousStoreInfo,
|
||||
)
|
||||
from torch.distributed.elastic.rendezvous.utils import parse_rendezvous_endpoint
|
||||
|
||||
|
||||
__all__ = ["StaticTCPRendezvous", "create_rdzv_handler"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_default_timeout_seconds = 600
|
||||
|
||||
|
||||
class StaticTCPRendezvous(RendezvousHandler):
|
||||
"""
|
||||
Static rendezvous that is a wrapper around the TCPStore.
|
||||
|
||||
Creates TCPStore based on the input parameters with the
|
||||
listener on the agent with group_rank=0
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master_addr: str,
|
||||
master_port: int,
|
||||
rank: int,
|
||||
world_size: int,
|
||||
run_id: str,
|
||||
timeout: int,
|
||||
):
|
||||
self.master_addr = master_addr
|
||||
self.master_port = master_port
|
||||
self.rank = rank
|
||||
self.world_size = world_size
|
||||
self.run_id = run_id
|
||||
self.timeout = datetime.timedelta(seconds=timeout)
|
||||
self._store: Store | None = None
|
||||
|
||||
def get_backend(self) -> str:
|
||||
return "static"
|
||||
|
||||
@property
|
||||
def use_agent_store(self) -> bool:
|
||||
return True
|
||||
|
||||
def next_rendezvous(self) -> RendezvousInfo:
|
||||
logger.info("Creating TCPStore as the c10d::Store implementation")
|
||||
is_master = self.rank == 0
|
||||
if not self._store:
|
||||
self._store = TCPStore( # type: ignore[call-arg]
|
||||
self.master_addr,
|
||||
self.master_port,
|
||||
self.world_size,
|
||||
is_master,
|
||||
self.timeout,
|
||||
multi_tenant=True,
|
||||
)
|
||||
store = PrefixStore(self.run_id, self._store)
|
||||
# TCPStore server instance is used by trainer code
|
||||
bootstrap_store_info = RendezvousStoreInfo(self.master_addr, self.master_port)
|
||||
return RendezvousInfo(
|
||||
store,
|
||||
self.rank,
|
||||
self.world_size,
|
||||
bootstrap_store_info,
|
||||
)
|
||||
|
||||
def is_closed(self):
|
||||
return False
|
||||
|
||||
def set_closed(self):
|
||||
pass
|
||||
|
||||
def num_nodes_waiting(self):
|
||||
return 0
|
||||
|
||||
def get_run_id(self) -> str:
|
||||
return self.run_id
|
||||
|
||||
def shutdown(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def create_rdzv_handler(params: RendezvousParameters) -> RendezvousHandler:
|
||||
if "rank" not in params.config:
|
||||
raise ValueError(
|
||||
"rank is absent in RendezvousParameters."
|
||||
"Try add --node-rank to the cmd request"
|
||||
)
|
||||
endpoint = params.endpoint.strip()
|
||||
if not endpoint:
|
||||
raise ValueError(
|
||||
"endpoint is absent in RendezvousParameters"
|
||||
"Try add --master-port and --master-addr to the cmd request"
|
||||
)
|
||||
master_addr, master_port = parse_rendezvous_endpoint(endpoint, -1)
|
||||
if master_port == -1:
|
||||
raise ValueError(
|
||||
f"Port is absent in endpoint: {endpoint}. Try launching with --master-port"
|
||||
)
|
||||
world_size = params.max_nodes
|
||||
rank = cast(int, params.config.get("rank"))
|
||||
run_id = params.run_id
|
||||
if "timeout" in params.config:
|
||||
timeout = int(params.config["timeout"])
|
||||
else:
|
||||
timeout = _default_timeout_seconds
|
||||
|
||||
return StaticTCPRendezvous(
|
||||
master_addr, master_port, rank, world_size, run_id, timeout
|
||||
)
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import ipaddress
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
import weakref
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
|
||||
|
||||
__all__ = ["parse_rendezvous_endpoint"]
|
||||
|
||||
|
||||
def _parse_rendezvous_config(config_str: str) -> dict[str, str]:
|
||||
"""Extract key-value pairs from a rendezvous configuration string.
|
||||
|
||||
Args:
|
||||
config_str:
|
||||
A string in format <key1>=<value1>,...,<keyN>=<valueN>.
|
||||
"""
|
||||
config: dict[str, str] = {}
|
||||
|
||||
config_str = config_str.strip()
|
||||
if not config_str:
|
||||
return config
|
||||
|
||||
key_values = config_str.split(",")
|
||||
for kv in key_values:
|
||||
key, *values = kv.split("=", 1)
|
||||
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"The rendezvous configuration string must be in format "
|
||||
"<key1>=<value1>,...,<keyN>=<valueN>."
|
||||
)
|
||||
|
||||
value: str | None
|
||||
if values:
|
||||
value = values[0].strip()
|
||||
else:
|
||||
value = None
|
||||
if not value:
|
||||
raise ValueError(
|
||||
f"The rendezvous configuration option '{key}' must have a value specified."
|
||||
)
|
||||
|
||||
config[key] = value
|
||||
return config
|
||||
|
||||
|
||||
def _try_parse_port(port_str: str) -> int | None:
|
||||
"""Try to extract the port number from ``port_str``."""
|
||||
if port_str and re.match(r"^[0-9]{1,5}$", port_str):
|
||||
return int(port_str)
|
||||
return None
|
||||
|
||||
|
||||
def parse_rendezvous_endpoint(
|
||||
endpoint: str | None, default_port: int
|
||||
) -> tuple[str, int]:
|
||||
"""Extract the hostname and the port number from a rendezvous endpoint.
|
||||
|
||||
Args:
|
||||
endpoint:
|
||||
A string in format <hostname>[:<port>].
|
||||
default_port:
|
||||
The port number to use if the endpoint does not include one.
|
||||
|
||||
Returns:
|
||||
A tuple of hostname and port number.
|
||||
"""
|
||||
if endpoint is not None:
|
||||
endpoint = endpoint.strip()
|
||||
|
||||
if not endpoint:
|
||||
return ("localhost", default_port)
|
||||
|
||||
# An endpoint that starts and ends with brackets represents an IPv6 address.
|
||||
if endpoint[0] == "[" and endpoint[-1] == "]":
|
||||
host, *rest = endpoint, *[]
|
||||
else:
|
||||
host, *rest = endpoint.rsplit(":", 1)
|
||||
|
||||
# Sanitize the IPv6 address.
|
||||
if len(host) > 1 and host[0] == "[" and host[-1] == "]":
|
||||
host = host[1:-1]
|
||||
|
||||
if len(rest) == 1:
|
||||
port = _try_parse_port(rest[0])
|
||||
if port is None or port >= 2**16:
|
||||
raise ValueError(
|
||||
f"The port number of the rendezvous endpoint '{endpoint}' must be an integer "
|
||||
"between 0 and 65536."
|
||||
)
|
||||
else:
|
||||
port = default_port
|
||||
|
||||
if not re.match(r"^[\w\.:-]+$", host):
|
||||
raise ValueError(
|
||||
f"The hostname of the rendezvous endpoint '{endpoint}' must be a dot-separated list of "
|
||||
"labels, an IPv4 address, or an IPv6 address."
|
||||
)
|
||||
|
||||
return host, port
|
||||
|
||||
|
||||
def _matches_machine_hostname(host: str) -> bool:
|
||||
"""Indicate whether ``host`` matches the hostname of this machine.
|
||||
|
||||
This function compares ``host`` to the hostname as well as to the IP
|
||||
addresses of this machine. Note that it may return a false negative if this
|
||||
machine has CNAME records beyond its FQDN or IP addresses assigned to
|
||||
secondary NICs.
|
||||
"""
|
||||
if host == "localhost":
|
||||
return True
|
||||
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
addr = None
|
||||
|
||||
if addr and addr.is_loopback:
|
||||
return True
|
||||
|
||||
try:
|
||||
host_addr_list = socket.getaddrinfo(
|
||||
host, None, proto=socket.IPPROTO_TCP, flags=socket.AI_CANONNAME
|
||||
)
|
||||
except (ValueError, socket.gaierror) as _:
|
||||
host_addr_list = []
|
||||
|
||||
host_ip_list = [host_addr_info[4][0] for host_addr_info in host_addr_list]
|
||||
|
||||
this_host = socket.gethostname()
|
||||
if host == this_host:
|
||||
return True
|
||||
|
||||
addr_list = socket.getaddrinfo(
|
||||
this_host, None, proto=socket.IPPROTO_TCP, flags=socket.AI_CANONNAME
|
||||
)
|
||||
for addr_info in addr_list:
|
||||
# If we have an FQDN in the addr_info, compare it to `host`.
|
||||
if addr_info[3] and addr_info[3] == host:
|
||||
return True
|
||||
|
||||
# Otherwise if `host` represents an IP address, compare it to our IP
|
||||
# address.
|
||||
if addr and addr_info[4][0] == str(addr):
|
||||
return True
|
||||
|
||||
# If the IP address matches one of the provided host's IP addresses
|
||||
if addr_info[4][0] in host_ip_list:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _delay(seconds: float | tuple[float, float]) -> None:
|
||||
"""Suspend the current thread for ``seconds``.
|
||||
|
||||
Args:
|
||||
seconds:
|
||||
Either the delay, in seconds, or a tuple of a lower and an upper
|
||||
bound within which a random delay will be picked.
|
||||
"""
|
||||
if isinstance(seconds, tuple):
|
||||
seconds = random.uniform(*seconds)
|
||||
# Ignore delay requests that are less than 10 milliseconds.
|
||||
if seconds >= 0.01:
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
class _PeriodicTimer:
|
||||
"""Represent a timer that periodically runs a specified function.
|
||||
|
||||
Args:
|
||||
interval:
|
||||
The interval, in seconds, between each run.
|
||||
function:
|
||||
The function to run.
|
||||
"""
|
||||
|
||||
# The state of the timer is hold in a separate context object to avoid a
|
||||
# reference cycle between the timer and the background thread.
|
||||
class _Context:
|
||||
interval: float
|
||||
function: Callable[..., None]
|
||||
args: tuple[Any, ...]
|
||||
kwargs: dict[str, Any]
|
||||
stop_event: Event
|
||||
|
||||
_name: str | None
|
||||
_thread: Thread | None
|
||||
_finalizer: weakref.finalize | None
|
||||
|
||||
# The context that is shared between the timer and the background thread.
|
||||
_ctx: _Context
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
interval: timedelta,
|
||||
function: Callable[..., None],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._name = None
|
||||
|
||||
self._ctx = self._Context()
|
||||
self._ctx.interval = interval.total_seconds()
|
||||
self._ctx.function = function # type: ignore[assignment]
|
||||
self._ctx.args = args or ()
|
||||
self._ctx.kwargs = kwargs or {}
|
||||
self._ctx.stop_event = Event()
|
||||
|
||||
self._thread = None
|
||||
self._finalizer = None
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Get the name of the timer."""
|
||||
return self._name
|
||||
|
||||
def set_name(self, name: str) -> None:
|
||||
"""Set the name of the timer.
|
||||
|
||||
The specified name will be assigned to the background thread and serves
|
||||
for debugging and troubleshooting purposes.
|
||||
"""
|
||||
if self._thread:
|
||||
raise RuntimeError("The timer has already started.")
|
||||
|
||||
self._name = name
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the timer."""
|
||||
if self._thread:
|
||||
raise RuntimeError("The timer has already started.")
|
||||
|
||||
self._thread = Thread(
|
||||
target=self._run,
|
||||
name=self._name or "PeriodicTimer",
|
||||
args=(self._ctx,),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
# We avoid using a regular finalizer (a.k.a. __del__) for stopping the
|
||||
# timer as joining a daemon thread during the interpreter shutdown can
|
||||
# cause deadlocks. The weakref.finalize is a superior alternative that
|
||||
# provides a consistent behavior regardless of the GC implementation.
|
||||
self._finalizer = weakref.finalize(
|
||||
self, self._stop_thread, self._thread, self._ctx.stop_event
|
||||
)
|
||||
|
||||
# We do not attempt to stop our background thread during the interpreter
|
||||
# shutdown. At that point we do not even know whether it still exists.
|
||||
self._finalizer.atexit = False
|
||||
|
||||
self._thread.start()
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Stop the timer at the next opportunity."""
|
||||
if self._finalizer:
|
||||
self._finalizer()
|
||||
|
||||
@staticmethod
|
||||
def _run(ctx) -> None:
|
||||
while not ctx.stop_event.wait(ctx.interval):
|
||||
ctx.function(*ctx.args, **ctx.kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _stop_thread(thread, stop_event):
|
||||
stop_event.set()
|
||||
|
||||
thread.join()
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
"""
|
||||
Expiration timers are set up on the same process as the agent and
|
||||
used from your script to deal with stuck workers. When you go into
|
||||
a code-block that has the potential to get stuck you can acquire
|
||||
an expiration timer, which instructs the timer server to kill the
|
||||
process if it does not release the timer by the self-imposed expiration
|
||||
deadline.
|
||||
|
||||
Usage::
|
||||
|
||||
import torchelastic.timer as timer
|
||||
import torchelastic.agent.server as agent
|
||||
|
||||
def main():
|
||||
start_method = "spawn"
|
||||
message_queue = mp.get_context(start_method).Queue()
|
||||
server = timer.LocalTimerServer(message, max_interval=0.01)
|
||||
server.start() # non-blocking
|
||||
|
||||
spec = WorkerSpec(
|
||||
fn=trainer_func,
|
||||
args=(message_queue,),
|
||||
...<OTHER_PARAMS...>)
|
||||
agent = agent.LocalElasticAgent(spec, start_method)
|
||||
agent.run()
|
||||
|
||||
def trainer_func(message_queue):
|
||||
timer.configure(timer.LocalTimerClient(message_queue))
|
||||
with timer.expires(after=60): # 60 second expiry
|
||||
# do some work
|
||||
|
||||
In the example above if ``trainer_func`` takes more than 60 seconds to
|
||||
complete, then the worker process is killed and the agent retries the worker group.
|
||||
"""
|
||||
|
||||
from .api import ( # noqa: F401
|
||||
configure,
|
||||
expires,
|
||||
TimerClient,
|
||||
TimerRequest,
|
||||
TimerServer,
|
||||
)
|
||||
from .file_based_local_timer import ( # noqa: F401
|
||||
FileTimerClient,
|
||||
FileTimerRequest,
|
||||
FileTimerServer,
|
||||
)
|
||||
from .local_timer import LocalTimerClient, LocalTimerServer # noqa: F401
|
||||
@@ -0,0 +1,281 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
import abc
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from inspect import getframeinfo, stack
|
||||
from typing import Any
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TimerRequest",
|
||||
"TimerClient",
|
||||
"RequestQueue",
|
||||
"TimerServer",
|
||||
"configure",
|
||||
"expires",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TimerRequest:
|
||||
"""
|
||||
Data object representing a countdown timer acquisition and release
|
||||
that is used between the ``TimerClient`` and ``TimerServer``.
|
||||
A negative ``expiration_time`` should be interpreted as a "release"
|
||||
request.
|
||||
|
||||
.. note:: the type of ``worker_id`` is implementation specific.
|
||||
It is whatever the TimerServer and TimerClient implementations
|
||||
have on to uniquely identify a worker.
|
||||
"""
|
||||
|
||||
__slots__ = ["worker_id", "scope_id", "expiration_time"]
|
||||
|
||||
def __init__(self, worker_id: Any, scope_id: str, expiration_time: float):
|
||||
self.worker_id = worker_id
|
||||
self.scope_id = scope_id
|
||||
self.expiration_time = expiration_time
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, TimerRequest):
|
||||
return (
|
||||
self.worker_id == other.worker_id
|
||||
and self.scope_id == other.scope_id
|
||||
and self.expiration_time == other.expiration_time
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class TimerClient(abc.ABC):
|
||||
"""
|
||||
Client library to acquire and release countdown timers by communicating
|
||||
with the TimerServer.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def acquire(self, scope_id: str, expiration_time: float) -> None:
|
||||
"""
|
||||
Acquires a timer for the worker that holds this client object
|
||||
given the scope_id and expiration_time. Typically registers
|
||||
the timer with the TimerServer.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def release(self, scope_id: str):
|
||||
"""
|
||||
Releases the timer for the ``scope_id`` on the worker this
|
||||
client represents. After this method is
|
||||
called, the countdown timer on the scope is no longer in effect.
|
||||
"""
|
||||
|
||||
|
||||
class RequestQueue(abc.ABC):
|
||||
"""
|
||||
Consumer queue holding timer acquisition/release requests
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def size(self) -> int:
|
||||
"""
|
||||
Returns the size of the queue at the time this method is called.
|
||||
Note that by the time ``get`` is called the size of the queue
|
||||
may have increased. The size of the queue should not decrease
|
||||
until the ``get`` method is called. That is, the following assertion
|
||||
should hold:
|
||||
|
||||
size = q.size()
|
||||
res = q.get(size, timeout=0)
|
||||
assert size == len(res)
|
||||
|
||||
-- or --
|
||||
|
||||
size = q.size()
|
||||
res = q.get(size * 2, timeout=1)
|
||||
assert size <= len(res) <= size * 2
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get(self, size: int, timeout: float) -> list[TimerRequest]:
|
||||
"""
|
||||
Gets up to ``size`` number of timer requests in a blocking fashion
|
||||
(no more than ``timeout`` seconds).
|
||||
"""
|
||||
|
||||
|
||||
class TimerServer(abc.ABC):
|
||||
"""
|
||||
Entity that monitors active timers and expires them
|
||||
in a timely fashion. This server is responsible for
|
||||
reaping workers that have expired timers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, request_queue: RequestQueue, max_interval: float, daemon: bool = True
|
||||
):
|
||||
"""
|
||||
:param request_queue: Consumer ``RequestQueue``
|
||||
:param max_interval: max time (in seconds) to wait
|
||||
for an item in the request_queue
|
||||
:param daemon: whether to run the watchdog thread as a daemon
|
||||
"""
|
||||
super().__init__()
|
||||
self._request_queue = request_queue
|
||||
self._max_interval = max_interval
|
||||
self._daemon = daemon
|
||||
self._watchdog_thread: threading.Thread | None = None
|
||||
self._stop_signaled = False
|
||||
|
||||
@abc.abstractmethod
|
||||
def register_timers(self, timer_requests: list[TimerRequest]) -> None:
|
||||
"""
|
||||
Processes the incoming timer requests and registers them with the server.
|
||||
The timer request can either be a acquire-timer or release-timer request.
|
||||
Timer requests with a negative expiration_time should be interpreted
|
||||
as a release-timer request.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def clear_timers(self, worker_ids: set[Any]) -> None:
|
||||
"""
|
||||
Clears all timers for the given ``worker_ids``.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_expired_timers(self, deadline: float) -> dict[str, list[TimerRequest]]:
|
||||
"""
|
||||
Returns all expired timers for each worker_id. An expired timer
|
||||
is a timer for which the expiration_time is less than or equal to
|
||||
the provided deadline.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _reap_worker(self, worker_id: Any) -> bool:
|
||||
"""
|
||||
Reaps the given worker. Returns True if the worker has been
|
||||
successfully reaped, False otherwise. If any uncaught exception
|
||||
is thrown from this method, the worker is considered reaped
|
||||
and all associated timers will be removed.
|
||||
"""
|
||||
|
||||
def _reap_worker_no_throw(self, worker_id: Any) -> bool:
|
||||
"""
|
||||
Wraps ``_reap_worker(worker_id)``, if an uncaught exception is
|
||||
thrown, then it considers the worker as reaped.
|
||||
"""
|
||||
try:
|
||||
return self._reap_worker(worker_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Uncaught exception thrown from _reap_worker(), "
|
||||
"check that the implementation correctly catches exceptions",
|
||||
)
|
||||
return True
|
||||
|
||||
def _watchdog_loop(self):
|
||||
while not self._stop_signaled:
|
||||
try:
|
||||
self._run_watchdog()
|
||||
except Exception:
|
||||
logger.exception("Error running watchdog")
|
||||
|
||||
def _run_watchdog(self):
|
||||
batch_size = max(1, self._request_queue.size())
|
||||
timer_requests = self._request_queue.get(batch_size, self._max_interval)
|
||||
self.register_timers(timer_requests)
|
||||
now = time.time()
|
||||
reaped_worker_ids = set()
|
||||
for worker_id, expired_timers in self.get_expired_timers(now).items():
|
||||
logger.info(
|
||||
"Reaping worker_id=[%s]. Expired timers: %s",
|
||||
worker_id,
|
||||
self._get_scopes(expired_timers),
|
||||
)
|
||||
if self._reap_worker_no_throw(worker_id):
|
||||
logger.info("Successfully reaped worker=[%s]", worker_id)
|
||||
reaped_worker_ids.add(worker_id)
|
||||
else:
|
||||
logger.error(
|
||||
"Error reaping worker=[%s]. Will retry on next watchdog.", worker_id
|
||||
)
|
||||
self.clear_timers(reaped_worker_ids)
|
||||
|
||||
def _get_scopes(self, timer_requests):
|
||||
return [r.scope_id for r in timer_requests]
|
||||
|
||||
def start(self) -> None:
|
||||
logger.info(
|
||||
"Starting %s... max_interval=%s, daemon=%s",
|
||||
type(self).__name__,
|
||||
self._max_interval,
|
||||
self._daemon,
|
||||
)
|
||||
self._watchdog_thread = threading.Thread(
|
||||
target=self._watchdog_loop, daemon=self._daemon
|
||||
)
|
||||
logger.info("Starting watchdog thread...")
|
||||
self._watchdog_thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
logger.info("Stopping %s", type(self).__name__)
|
||||
self._stop_signaled = True
|
||||
if self._watchdog_thread:
|
||||
logger.info("Stopping watchdog thread...")
|
||||
self._watchdog_thread.join(self._max_interval)
|
||||
self._watchdog_thread = None
|
||||
else:
|
||||
logger.info("No watchdog thread running, doing nothing")
|
||||
|
||||
|
||||
_timer_client: TimerClient | None = None
|
||||
|
||||
|
||||
def configure(timer_client: TimerClient):
|
||||
"""
|
||||
Configures a timer client. Must be called before using ``expires``.
|
||||
"""
|
||||
global _timer_client
|
||||
_timer_client = timer_client
|
||||
logger.info("Timer client configured to: %s", type(_timer_client).__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def expires(after: float, scope: str | None = None, client: TimerClient | None = None):
|
||||
"""
|
||||
Acquires a countdown timer that expires in ``after`` seconds from now,
|
||||
unless the code-block that it wraps is finished within the timeframe.
|
||||
When the timer expires, this worker is eligible to be reaped. The
|
||||
exact meaning of "reaped" depends on the client implementation. In
|
||||
most cases, reaping means to terminate the worker process.
|
||||
Note that the worker is NOT guaranteed to be reaped at exactly
|
||||
``time.now() + after``, but rather the worker is "eligible" for being
|
||||
reaped and the ``TimerServer`` that the client talks to will ultimately
|
||||
make the decision when and how to reap the workers with expired timers.
|
||||
|
||||
Usage::
|
||||
|
||||
torch.distributed.elastic.timer.configure(LocalTimerClient())
|
||||
with expires(after=10):
|
||||
torch.distributed.all_reduce(...)
|
||||
"""
|
||||
if client is None:
|
||||
if _timer_client is None:
|
||||
raise RuntimeError("Configure timer client before using countdown timers.")
|
||||
client = _timer_client
|
||||
if scope is None:
|
||||
# grab the caller file + lineno
|
||||
caller = getframeinfo(stack()[1][0])
|
||||
scope = f"{caller.filename}#{caller.lineno}"
|
||||
expiration = time.time() + after
|
||||
client.acquire(scope, expiration)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
client.release(scope)
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
|
||||
from torch.distributed.elastic.utils.logging import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["log_debug_info_for_expired_timers"]
|
||||
|
||||
|
||||
def log_debug_info_for_expired_timers(
|
||||
run_id: str,
|
||||
expired_timers: dict[int, list[str]],
|
||||
):
|
||||
if expired_timers:
|
||||
logger.info("Timers expired for run:[%s] [%s].", run_id, expired_timers)
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Meta Platforms, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
from torch.distributed.elastic.timer.api import TimerClient, TimerRequest
|
||||
from torch.distributed.elastic.timer.debug_info_logging import (
|
||||
log_debug_info_for_expired_timers,
|
||||
)
|
||||
from torch.distributed.elastic.utils.logging import get_logger
|
||||
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
_R = TypeVar("_R")
|
||||
|
||||
__all__ = ["FileTimerClient", "FileTimerRequest", "FileTimerServer"]
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _retry(max_retries: int, sleep_time: float) -> Callable:
|
||||
"""
|
||||
A simple retry wrapper.
|
||||
|
||||
Args:
|
||||
max_retries: int, the maximum number of retries.
|
||||
sleep_time: float, the time to sleep between retries.
|
||||
"""
|
||||
|
||||
def wrapper(func: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs):
|
||||
for i in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception:
|
||||
logger.exception("Error running %s. Retrying...", func.__name__)
|
||||
if i < max_retries - 1:
|
||||
time.sleep(sleep_time)
|
||||
else:
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class FileTimerRequest(TimerRequest):
|
||||
"""
|
||||
Data object representing a countdown timer acquisition and release
|
||||
that is used between the ``FileTimerClient`` and ``FileTimerServer``.
|
||||
A negative ``expiration_time`` should be interpreted as a "release"
|
||||
request.
|
||||
``signal`` is the signal to reap the worker process from the server
|
||||
process.
|
||||
"""
|
||||
|
||||
__slots__ = ["version", "signal"]
|
||||
|
||||
def __init__(
|
||||
self, worker_pid: int, scope_id: str, expiration_time: float, signal: int = 0
|
||||
) -> None:
|
||||
super().__init__(
|
||||
worker_id=worker_pid, scope_id=scope_id, expiration_time=expiration_time
|
||||
)
|
||||
self.version = 1
|
||||
self.signal = signal
|
||||
|
||||
@property
|
||||
def worker_pid(self) -> int:
|
||||
return self.worker_id
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
if isinstance(other, FileTimerRequest):
|
||||
return (
|
||||
super().__eq__(other)
|
||||
and self.version == other.version
|
||||
and self.signal == other.signal
|
||||
)
|
||||
return False
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"version": self.version,
|
||||
"pid": self.worker_pid,
|
||||
"scope_id": self.scope_id,
|
||||
"expiration_time": self.expiration_time,
|
||||
"signal": self.signal,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FileTimerClient(TimerClient):
|
||||
"""
|
||||
Client side of ``FileTimerServer``. This client is meant to be used
|
||||
on the same host that the ``FileTimerServer`` is running on and uses
|
||||
pid to uniquely identify a worker.
|
||||
This client uses a named_pipe to send timer requests to the
|
||||
``FileTimerServer``. This client is a producer while the
|
||||
``FileTimerServer`` is a consumer. Multiple clients can work with
|
||||
the same ``FileTimerServer``.
|
||||
|
||||
Args:
|
||||
|
||||
file_path: str, the path of a FIFO special file. ``FileTimerServer``
|
||||
must have created it by calling os.mkfifo().
|
||||
|
||||
signal: signal, the signal to use to kill the process. Using a
|
||||
negative or zero signal will not kill the process.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: str,
|
||||
signal=(signal.SIGKILL if sys.platform != "win32" else signal.CTRL_C_EVENT), # type: ignore[attr-defined]
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._file_path = file_path
|
||||
self.signal = signal
|
||||
|
||||
@_retry(max_retries=10, sleep_time=0.1)
|
||||
def _open_non_blocking(self) -> io.TextIOWrapper | None:
|
||||
# The server may have crashed or may haven't started yet.
|
||||
# In such case, calling open() in blocking model blocks the client.
|
||||
# To avoid such issue, open it in non-blocking mode, and an OSError will
|
||||
# be raised if the server is not there.
|
||||
fd = os.open(self._file_path, os.O_WRONLY | os.O_NONBLOCK)
|
||||
return os.fdopen(fd, "wt")
|
||||
|
||||
def _send_request(self, request: FileTimerRequest) -> None:
|
||||
try:
|
||||
file = self._open_non_blocking()
|
||||
except Exception as e:
|
||||
raise BrokenPipeError(
|
||||
"Could not send the FileTimerRequest because FileTimerServer is not available."
|
||||
) from e
|
||||
with file:
|
||||
json_request = request.to_json()
|
||||
# Write request with no greater than select.PIPE_BUF is guarantee to be atomic.
|
||||
if len(json_request) > select.PIPE_BUF:
|
||||
raise RuntimeError(
|
||||
f"FileTimerRequest larger than {select.PIPE_BUF} bytes "
|
||||
f"is not supported: {json_request}"
|
||||
)
|
||||
file.write(json_request + "\n")
|
||||
|
||||
def acquire(self, scope_id: str, expiration_time: float) -> None:
|
||||
self._send_request(
|
||||
request=FileTimerRequest(
|
||||
worker_pid=os.getpid(),
|
||||
scope_id=scope_id,
|
||||
expiration_time=expiration_time,
|
||||
signal=self.signal,
|
||||
),
|
||||
)
|
||||
|
||||
def release(self, scope_id: str) -> None:
|
||||
self._send_request(
|
||||
request=FileTimerRequest(
|
||||
worker_pid=os.getpid(), scope_id=scope_id, expiration_time=-1, signal=0
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FileTimerServer:
|
||||
"""
|
||||
Server that works with ``FileTimerClient``. Clients are expected to be
|
||||
running on the same host as the process that is running this server.
|
||||
Each host in the job is expected to start its own timer server locally
|
||||
and each server instance manages timers for local workers (running on
|
||||
processes on the same host).
|
||||
|
||||
Args:
|
||||
|
||||
file_path: str, the path of a FIFO special file to be created.
|
||||
|
||||
max_interval: float, max interval in seconds for each watchdog loop.
|
||||
|
||||
daemon: bool, running the watchdog thread in daemon mode or not.
|
||||
A daemon thread will not block a process to stop.
|
||||
log_event: Callable[[Dict[str, str]], None], an optional callback for
|
||||
logging the events in JSON format.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: str,
|
||||
run_id: str,
|
||||
max_interval: float = 10,
|
||||
daemon: bool = True,
|
||||
log_event: Callable[[str, FileTimerRequest | None], None] | None = None,
|
||||
) -> None:
|
||||
self._file_path = file_path
|
||||
self._run_id = run_id
|
||||
self._max_interval = max_interval
|
||||
self._daemon = daemon
|
||||
self._timers: dict[tuple[int, str], FileTimerRequest] = {}
|
||||
self._stop_signaled = False
|
||||
self._watchdog_thread: threading.Thread | None = None
|
||||
|
||||
self._is_client_started = False
|
||||
if os.path.exists(self._file_path):
|
||||
os.remove(self._file_path)
|
||||
os.mkfifo(self._file_path)
|
||||
# For test only. Count the number of requests received.
|
||||
self._request_count = 0
|
||||
# For test only. Process all requests and stop the server.
|
||||
self._run_once = False
|
||||
self._log_event = (
|
||||
log_event if log_event is not None else lambda name, request: None
|
||||
)
|
||||
self._last_progress_time = int(time.time())
|
||||
|
||||
def start(self) -> None:
|
||||
logger.info(
|
||||
"Starting %s... max_interval=%s, daemon=%s, file_path=%s",
|
||||
type(self).__name__,
|
||||
self._max_interval,
|
||||
self._daemon,
|
||||
self._file_path,
|
||||
)
|
||||
self._watchdog_thread = threading.Thread(
|
||||
target=self._watchdog_loop, daemon=self._daemon
|
||||
)
|
||||
logger.info("Starting watchdog thread...")
|
||||
self._watchdog_thread.start()
|
||||
self._log_event("watchdog started", None)
|
||||
|
||||
def stop(self) -> None:
|
||||
logger.info("Stopping %s", type(self).__name__)
|
||||
self._stop_signaled = True
|
||||
if self._watchdog_thread:
|
||||
logger.info("Stopping watchdog thread...")
|
||||
self._watchdog_thread.join(self._max_interval)
|
||||
self._watchdog_thread = None
|
||||
else:
|
||||
logger.info("No watchdog thread running, doing nothing")
|
||||
if os.path.exists(self._file_path):
|
||||
os.remove(self._file_path)
|
||||
self._log_event("watchdog stopped", None)
|
||||
|
||||
def run_once(self) -> None:
|
||||
self._run_once = True
|
||||
if self._watchdog_thread:
|
||||
logger.info("Stopping watchdog thread...")
|
||||
self._watchdog_thread.join()
|
||||
self._watchdog_thread = None
|
||||
else:
|
||||
logger.info("No watchdog thread running, doing nothing")
|
||||
if os.path.exists(self._file_path):
|
||||
os.remove(self._file_path)
|
||||
|
||||
@staticmethod
|
||||
def is_process_running(pid: int):
|
||||
"""
|
||||
function to check process is running or not
|
||||
"""
|
||||
try:
|
||||
# Check if the process exists and we can send signals to it
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _watchdog_loop(self) -> None:
|
||||
# Open the pipe in blocking mode blocks the server thread.
|
||||
# This is fine for the following reasons:
|
||||
# 1. No client case usually does not happen.
|
||||
# 2. We are running the watchdog loop in a separate daemon
|
||||
# thread, which will not block the process to stop.
|
||||
try:
|
||||
with open(self._file_path) as fd:
|
||||
self._is_client_started = True
|
||||
while not self._stop_signaled:
|
||||
try:
|
||||
run_once = self._run_once
|
||||
self._run_watchdog(fd)
|
||||
if run_once:
|
||||
break
|
||||
self._last_progress_time = int(time.time())
|
||||
except Exception:
|
||||
logger.exception("Error running watchdog")
|
||||
|
||||
except Exception:
|
||||
logger.exception("Could not open the FileTimerServer pipe")
|
||||
raise
|
||||
|
||||
def _run_watchdog(self, fd: io.TextIOWrapper) -> None:
|
||||
timer_requests = self._get_requests(fd, self._max_interval)
|
||||
self.register_timers(timer_requests)
|
||||
now = time.time()
|
||||
reaped_worker_pids = set()
|
||||
kill_process = False
|
||||
reap_signal = 0
|
||||
|
||||
all_expired_timers = self.get_expired_timers(now)
|
||||
log_debug_info_for_expired_timers(
|
||||
self._run_id,
|
||||
{
|
||||
pid: [expired_timer.to_json() for expired_timer in expired_timers]
|
||||
for pid, expired_timers in all_expired_timers.items()
|
||||
},
|
||||
)
|
||||
|
||||
for worker_pid, expired_timers in all_expired_timers.items():
|
||||
logger.info(
|
||||
"Reaping worker_pid=[%s]. Expired timers: %s",
|
||||
worker_pid,
|
||||
self._get_scopes(expired_timers),
|
||||
)
|
||||
reaped_worker_pids.add(worker_pid)
|
||||
# In case we have multiple expired timers, we find the first timer
|
||||
# with a valid signal (>0) in the expiration time order.
|
||||
expired_timers.sort(key=lambda timer: timer.expiration_time)
|
||||
signal = 0
|
||||
expired_timer = None
|
||||
for timer in expired_timers:
|
||||
self._log_event("timer expired", timer)
|
||||
if timer.signal > 0:
|
||||
signal = timer.signal
|
||||
expired_timer = timer
|
||||
break
|
||||
if signal <= 0:
|
||||
logger.info(
|
||||
"No signal specified with worker=[%s]. Do not reap it.", worker_pid
|
||||
)
|
||||
continue
|
||||
if self._reap_worker(worker_pid, signal):
|
||||
logger.info(
|
||||
"Successfully reaped worker=[%s] with signal=%s", worker_pid, signal
|
||||
)
|
||||
self._log_event("kill worker process", expired_timer)
|
||||
kill_process = True
|
||||
reap_signal = signal
|
||||
else:
|
||||
logger.error(
|
||||
"Error reaping worker=[%s]. Will retry on next watchdog.",
|
||||
worker_pid,
|
||||
)
|
||||
if kill_process and reap_signal > 0:
|
||||
logger.info(
|
||||
"Terminating the server process=[%s] because of expired timers",
|
||||
os.getpid(),
|
||||
)
|
||||
self._reap_worker(os.getpid(), reap_signal)
|
||||
|
||||
self.clear_timers(reaped_worker_pids)
|
||||
|
||||
def _get_scopes(self, timer_requests: list[FileTimerRequest]) -> list[str]:
|
||||
return [r.scope_id for r in timer_requests]
|
||||
|
||||
def _get_requests(
|
||||
self, fd: io.TextIOWrapper, max_interval: float
|
||||
) -> list[FileTimerRequest]:
|
||||
start = time.time()
|
||||
requests = []
|
||||
while not self._stop_signaled or self._run_once:
|
||||
# For named pipe, readline() is blocking when at least one writer opens.
|
||||
# It returns only when flush() is called at the writer side.
|
||||
# Note that flush() is automatically called inside close().
|
||||
# After the last writer closes, readline() is not blocking.
|
||||
# It will return an empty string when it's at end-of-file.
|
||||
# Since the client side always opens the pipe, writes a message and closes
|
||||
# the pipe immediately, the readline() call below is not blocking for long.
|
||||
json_request = fd.readline()
|
||||
if len(json_request) == 0:
|
||||
if self._run_once:
|
||||
break
|
||||
time.sleep(min(max_interval, 1))
|
||||
else:
|
||||
request = json.loads(json_request)
|
||||
pid = request["pid"]
|
||||
scope_id = request["scope_id"]
|
||||
expiration_time = request["expiration_time"]
|
||||
signal = request["signal"]
|
||||
requests.append(
|
||||
FileTimerRequest(
|
||||
worker_pid=pid,
|
||||
scope_id=scope_id,
|
||||
expiration_time=expiration_time,
|
||||
signal=signal,
|
||||
)
|
||||
)
|
||||
now = time.time()
|
||||
if now - start > max_interval:
|
||||
break
|
||||
return requests
|
||||
|
||||
def register_timers(self, timer_requests: list[FileTimerRequest]) -> None:
|
||||
for request in timer_requests:
|
||||
pid = request.worker_pid
|
||||
scope_id = request.scope_id
|
||||
expiration_time = request.expiration_time
|
||||
self._request_count += 1
|
||||
|
||||
key = (pid, scope_id)
|
||||
# negative expiration is a proxy for a release call
|
||||
if expiration_time < 0:
|
||||
if key in self._timers:
|
||||
del self._timers[key]
|
||||
else:
|
||||
self._timers[key] = request
|
||||
|
||||
def clear_timers(self, worker_pids: set[int]) -> None:
|
||||
for pid, scope_id in list(self._timers.keys()):
|
||||
if pid in worker_pids or not FileTimerServer.is_process_running(pid):
|
||||
del self._timers[(pid, scope_id)]
|
||||
|
||||
def get_expired_timers(self, deadline: float) -> dict[int, list[FileTimerRequest]]:
|
||||
# pid -> [timer_requests...]
|
||||
expired_timers: dict[int, list[FileTimerRequest]] = {}
|
||||
for request in self._timers.values():
|
||||
if request.expiration_time <= deadline:
|
||||
expired_scopes = expired_timers.setdefault(request.worker_pid, [])
|
||||
expired_scopes.append(request)
|
||||
return expired_timers
|
||||
|
||||
def _reap_worker(self, worker_pid: int, signal: int) -> bool:
|
||||
try:
|
||||
os.kill(worker_pid, signal)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
logger.info("Process with pid=%s does not exist. Skipping", worker_pid)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Error terminating pid=%s", worker_pid)
|
||||
return False
|
||||
|
||||
def get_last_progress_time(self) -> int:
|
||||
return self._last_progress_time if self._is_client_started else int(time.time())
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from queue import Empty
|
||||
from typing import Any
|
||||
|
||||
from .api import RequestQueue, TimerClient, TimerRequest, TimerServer
|
||||
|
||||
|
||||
__all__ = ["LocalTimerClient", "MultiprocessingRequestQueue", "LocalTimerServer"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalTimerClient(TimerClient):
|
||||
"""
|
||||
Client side of ``LocalTimerServer``. This client is meant to be used
|
||||
on the same host that the ``LocalTimerServer`` is running on and uses
|
||||
pid to uniquely identify a worker. This is particularly useful in situations
|
||||
where one spawns a subprocess (trainer) per GPU on a host with multiple
|
||||
GPU devices.
|
||||
"""
|
||||
|
||||
def __init__(self, mp_queue):
|
||||
super().__init__()
|
||||
self._mp_queue = mp_queue
|
||||
|
||||
def acquire(self, scope_id, expiration_time):
|
||||
pid = os.getpid()
|
||||
acquire_request = TimerRequest(pid, scope_id, expiration_time)
|
||||
self._mp_queue.put(acquire_request)
|
||||
|
||||
def release(self, scope_id):
|
||||
pid = os.getpid()
|
||||
release_request = TimerRequest(pid, scope_id, -1)
|
||||
self._mp_queue.put(release_request)
|
||||
|
||||
|
||||
class MultiprocessingRequestQueue(RequestQueue):
|
||||
"""
|
||||
A ``RequestQueue`` backed by python ``multiprocessing.Queue``
|
||||
"""
|
||||
|
||||
def __init__(self, mp_queue: mp.Queue):
|
||||
super().__init__()
|
||||
self._mp_queue = mp_queue
|
||||
|
||||
def size(self) -> int:
|
||||
return self._mp_queue.qsize()
|
||||
|
||||
def get(self, size, timeout: float) -> list[TimerRequest]:
|
||||
requests = []
|
||||
wait = timeout
|
||||
for _ in range(size):
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
r = self._mp_queue.get(block=True, timeout=wait)
|
||||
except Empty:
|
||||
break
|
||||
|
||||
requests.append(r)
|
||||
wait = wait - (time.time() - start)
|
||||
if wait <= 0:
|
||||
break
|
||||
|
||||
return requests
|
||||
|
||||
|
||||
class LocalTimerServer(TimerServer):
|
||||
"""
|
||||
Server that works with ``LocalTimerClient``. Clients are expected to be
|
||||
subprocesses to the parent process that is running this server. Each host
|
||||
in the job is expected to start its own timer server locally and each
|
||||
server instance manages timers for local workers (running on processes
|
||||
on the same host).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, mp_queue: mp.Queue, max_interval: float = 60, daemon: bool = True
|
||||
):
|
||||
super().__init__(MultiprocessingRequestQueue(mp_queue), max_interval, daemon)
|
||||
self._timers: dict[tuple[Any, str], TimerRequest] = {}
|
||||
|
||||
def register_timers(self, timer_requests: list[TimerRequest]) -> None:
|
||||
for request in timer_requests:
|
||||
pid = request.worker_id
|
||||
scope_id = request.scope_id
|
||||
expiration_time = request.expiration_time
|
||||
|
||||
# negative expiration is a proxy for a release call
|
||||
if expiration_time < 0:
|
||||
self._timers.pop((pid, scope_id), None)
|
||||
else:
|
||||
self._timers[(pid, scope_id)] = request
|
||||
|
||||
def clear_timers(self, worker_ids: set[int]) -> None:
|
||||
for pid, scope_id in list(self._timers.keys()):
|
||||
if pid in worker_ids:
|
||||
self._timers.pop((pid, scope_id))
|
||||
|
||||
def get_expired_timers(self, deadline: float) -> dict[Any, list[TimerRequest]]:
|
||||
# pid -> [timer_requests...]
|
||||
expired_timers: dict[Any, list[TimerRequest]] = {}
|
||||
for request in self._timers.values():
|
||||
if request.expiration_time <= deadline:
|
||||
expired_scopes = expired_timers.setdefault(request.worker_id, [])
|
||||
expired_scopes.append(request)
|
||||
return expired_timers
|
||||
|
||||
def _reap_worker(self, worker_id: int) -> bool:
|
||||
try:
|
||||
os.kill(worker_id, signal.SIGKILL)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
logger.info("Process with pid=%s does not exist. Skipping", worker_id)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Error terminating pid=%s", worker_id)
|
||||
return False
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
from .api import get_env_variable_or_raise, get_socket_with_port, macros # noqa: F401
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import os
|
||||
import socket
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
|
||||
def get_env_variable_or_raise(env_name: str) -> str:
|
||||
r"""
|
||||
Tries to retrieve environment variable. Raises ``ValueError``
|
||||
if no environment variable found.
|
||||
|
||||
Args:
|
||||
env_name (str): Name of the env variable
|
||||
"""
|
||||
value = os.environ.get(env_name, None)
|
||||
if value is None:
|
||||
msg = f"Environment variable {env_name} expected, but not set"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def get_socket_with_port() -> socket.socket:
|
||||
addrs = socket.getaddrinfo(
|
||||
host="localhost", port=None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM
|
||||
)
|
||||
for addr in addrs:
|
||||
family, type, proto, _, _ = addr
|
||||
s = socket.socket(family, type, proto)
|
||||
try:
|
||||
s.bind(("localhost", 0))
|
||||
s.listen(0)
|
||||
return s
|
||||
except OSError:
|
||||
s.close()
|
||||
raise RuntimeError("Failed to create a socket")
|
||||
|
||||
|
||||
class macros:
|
||||
"""
|
||||
Defines simple macros for caffe2.distributed.launch cmd args substitution
|
||||
"""
|
||||
|
||||
local_rank = "${local_rank}"
|
||||
|
||||
@staticmethod
|
||||
def substitute(args: list[Any], local_rank: str) -> list[str]:
|
||||
args_sub = []
|
||||
for arg in args:
|
||||
if isinstance(arg, str):
|
||||
sub = Template(arg).safe_substitute(local_rank=local_rank)
|
||||
args_sub.append(sub)
|
||||
else:
|
||||
args_sub.append(arg)
|
||||
return args_sub
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
from .cycling_iterator import CyclingIterator # noqa: F401
|
||||
from .elastic_distributed_sampler import ElasticDistributedSampler # noqa: F401
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import TypeVar
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
__all__ = ["CyclingIterator"]
|
||||
|
||||
|
||||
class CyclingIterator(Iterator[_T]):
|
||||
"""
|
||||
An iterator decorator that cycles through the
|
||||
underlying iterator "n" times. Useful to "unroll"
|
||||
the dataset across multiple training epochs.
|
||||
|
||||
The generator function is called as ``generator_fn(epoch)``
|
||||
to obtain the underlying iterator, where ``epoch`` is a
|
||||
number less than or equal to ``n`` representing the ``k``th cycle
|
||||
|
||||
For example if ``generator_fn`` always returns ``[1,2,3]``
|
||||
then ``CyclingIterator(n=2, generator_fn)`` will iterate through
|
||||
``[1,2,3,1,2,3]``
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
n: int,
|
||||
generator_fn: Callable[[int], Iterator[_T]],
|
||||
start_epoch: int = 0,
|
||||
):
|
||||
self._n = n
|
||||
self._epoch = start_epoch
|
||||
self._generator_fn = generator_fn
|
||||
self._iter = generator_fn(self._epoch)
|
||||
|
||||
def __iter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __next__(self) -> _T:
|
||||
try:
|
||||
return next(self._iter)
|
||||
except StopIteration as eod: # eod == end of data
|
||||
if self._epoch < self._n - 1:
|
||||
self._epoch += 1
|
||||
self._iter = self._generator_fn(self._epoch)
|
||||
return self.__next__()
|
||||
else:
|
||||
raise eod
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import math
|
||||
from collections.abc import Iterator, Sized
|
||||
from typing import cast, TypeVar
|
||||
|
||||
import torch
|
||||
from torch.utils.data import Dataset
|
||||
from torch.utils.data.distributed import DistributedSampler
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
__all__ = ["ElasticDistributedSampler"]
|
||||
|
||||
|
||||
class ElasticDistributedSampler(DistributedSampler[T]):
|
||||
"""
|
||||
Sampler that restricts data loading to a subset of
|
||||
the dataset for elastic training.
|
||||
|
||||
It is especially useful in conjunction with
|
||||
:class:`torch.nn.parallel.DistributedDataParallel`. In such case, each
|
||||
process can pass a DistributedSampler instance as a DataLoader sampler,
|
||||
and load a subset of the original dataset that is exclusive to it.
|
||||
|
||||
.. note::
|
||||
Dataset is assumed to be of constant size.
|
||||
|
||||
Args:
|
||||
dataset: Dataset used for sampling.
|
||||
num_replicas (optional): Number of processes participating in
|
||||
distributed training.
|
||||
rank (optional): Rank of the current process within num_replicas.
|
||||
start_index (optional): Which index of the dataset to start sampling from
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dataset: Dataset[T],
|
||||
num_replicas: int | None = None,
|
||||
rank: int | None = None,
|
||||
start_index: int = 0,
|
||||
):
|
||||
super().__init__(dataset=dataset, num_replicas=num_replicas, rank=rank)
|
||||
if not isinstance(dataset, Sized):
|
||||
raise TypeError("Dataset must be an instance of collections.abc.Sized")
|
||||
|
||||
# Cast to Sized for mypy
|
||||
|
||||
sized_dataset = cast(Sized, dataset)
|
||||
|
||||
if start_index >= len(sized_dataset):
|
||||
raise ValueError(
|
||||
f"Start index {start_index} should be less than dataset size {len(sized_dataset)}"
|
||||
)
|
||||
|
||||
self.start_index = start_index
|
||||
sized_dataset = cast(Sized, self.dataset)
|
||||
self.num_samples = math.ceil(
|
||||
float(len(sized_dataset) - self.start_index) / self.num_replicas
|
||||
)
|
||||
self.total_size = self.num_samples * self.num_replicas
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
# deterministically shuffle based on epoch
|
||||
g = torch.Generator()
|
||||
g.manual_seed(self.epoch)
|
||||
sized_dataset = cast(Sized, self.dataset)
|
||||
indices = (
|
||||
torch.randperm(len(sized_dataset) - self.start_index, generator=g)
|
||||
.add(self.start_index)
|
||||
.tolist()
|
||||
)
|
||||
|
||||
# add extra samples to make it evenly divisible
|
||||
indices += indices[: (self.total_size - len(indices))]
|
||||
if len(indices) != self.total_size:
|
||||
raise AssertionError
|
||||
|
||||
# subsample
|
||||
indices = indices[self.rank : self.total_size : self.num_replicas]
|
||||
if len(indices) != self.num_samples:
|
||||
raise AssertionError
|
||||
|
||||
return iter(indices)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.num_samples
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
import datetime
|
||||
import os
|
||||
import socket
|
||||
from contextlib import closing
|
||||
|
||||
import torch.distributed as dist
|
||||
from torch.distributed.elastic.utils.logging import get_logger
|
||||
from torch.distributed.elastic.utils.store import barrier
|
||||
|
||||
|
||||
__all__ = ["create_c10d_store", "get_free_port", "get_socket_with_port"]
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_ADDRESS_IN_USE = "Address already in use"
|
||||
_SOCKET_TIMEOUT = "Socket Timeout"
|
||||
|
||||
_TCP_STORE_INIT = "_tcp_store/num_members"
|
||||
|
||||
|
||||
def create_c10d_store(
|
||||
is_server: bool,
|
||||
server_addr: str,
|
||||
server_port: int = -1,
|
||||
world_size: int = 1,
|
||||
timeout: float = (60 * 10), # 10 min
|
||||
wait_for_workers: bool = True,
|
||||
retries=3,
|
||||
use_libuv: bool | None = None,
|
||||
):
|
||||
if use_libuv is not None:
|
||||
logger.warning(
|
||||
"argument use_libuv is deprecated and ignored. Set USE_LIBUV environment "
|
||||
'variable to "0" to disable libuv, or "1" to enable it. If the env var '
|
||||
"is not set, libuv will be used by default."
|
||||
)
|
||||
|
||||
# check os.environ for use_libuv
|
||||
use_libuv = os.environ.get("USE_LIBUV", "1") == "1" # libuv is the default option
|
||||
|
||||
if server_port == -1 and world_size > 1:
|
||||
raise ValueError(
|
||||
f"server_port must be specified when world_size > 1, got server_port={server_port}, world_size={world_size}"
|
||||
)
|
||||
|
||||
if server_port != -1:
|
||||
logger.info("sever_port: %s, specified, ignoring retries", server_port)
|
||||
|
||||
# only retry when server_port is NOT static
|
||||
attempt = retries if server_port == -1 else 1
|
||||
while True:
|
||||
if server_port != -1:
|
||||
port = server_port
|
||||
else:
|
||||
port = get_free_port()
|
||||
|
||||
logger.info(
|
||||
"Creating c10d store on %s:%s\n"
|
||||
" world_size : %s\n"
|
||||
" is_server : %s\n"
|
||||
" timeout(sec): %s\n"
|
||||
" use_libuv : %s\n",
|
||||
server_addr,
|
||||
port,
|
||||
world_size,
|
||||
is_server,
|
||||
timeout,
|
||||
use_libuv,
|
||||
)
|
||||
|
||||
try:
|
||||
store = dist.TCPStore(
|
||||
host_name=server_addr,
|
||||
port=port,
|
||||
world_size=world_size,
|
||||
is_master=is_server,
|
||||
timeout=datetime.timedelta(seconds=timeout),
|
||||
wait_for_workers=wait_for_workers,
|
||||
use_libuv=use_libuv,
|
||||
)
|
||||
# skips full rank check when we don't have to wait for all workers
|
||||
if wait_for_workers:
|
||||
_check_full_rank(store, world_size, timeout=timeout)
|
||||
logger.info("Successfully created c10d store")
|
||||
return store
|
||||
except RuntimeError as e:
|
||||
# this is brittle, but the underlying exception type is not properly pybinded
|
||||
# so we parse the error msg for now, interestingly this is how torch itself
|
||||
# detects timeouts and port conflicts in their own unittests
|
||||
# see - caffe2/torch/testing/_internal/common_utils.py
|
||||
# TODO properly map the exceptions in pybind (c10d/init.cpp)
|
||||
if str(e) == _ADDRESS_IN_USE: # this will only happen on the server
|
||||
if attempt < retries:
|
||||
logger.warning(
|
||||
"port: %s already in use, attempt: [%s/%s]",
|
||||
port,
|
||||
attempt,
|
||||
retries,
|
||||
)
|
||||
attempt += 1
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"on {server_addr}, port: {port} already in use"
|
||||
) from e
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def _check_full_rank(store, world_size, timeout):
|
||||
try:
|
||||
barrier(store, world_size, key_prefix=_TCP_STORE_INIT, barrier_timeout=timeout)
|
||||
except RuntimeError as e:
|
||||
if str(e) == _SOCKET_TIMEOUT:
|
||||
raise TimeoutError(
|
||||
f"timed out waiting for all {world_size} members to join"
|
||||
) from e
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def get_free_port():
|
||||
"""
|
||||
Returns an unused port on localhost.
|
||||
|
||||
This function finds an unused port on localhost by opening to socket to bind
|
||||
to a port and then closing it.
|
||||
|
||||
Returns:
|
||||
int: an unused port on localhost
|
||||
|
||||
Example:
|
||||
>>> # xdoctest: +SKIP("Nondeterministic")
|
||||
>>> get_free_port()
|
||||
63976
|
||||
|
||||
.. note::
|
||||
The port returned by :func:`get_free_port` is not reserved and may be
|
||||
taken by another process after this function returns.
|
||||
"""
|
||||
sock = get_socket_with_port()
|
||||
with closing(sock):
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def get_socket_with_port() -> socket.socket:
|
||||
"""
|
||||
Returns a free port on localhost that is "reserved" by binding a temporary
|
||||
socket on it. Close the socket before passing the port to the entity
|
||||
that requires it. Usage example
|
||||
|
||||
::
|
||||
|
||||
sock = _get_socket_with_port()
|
||||
with closing(sock):
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
# there is still a race-condition that some other process
|
||||
# may grab this port before func() runs
|
||||
func(port)
|
||||
"""
|
||||
|
||||
addrs = socket.getaddrinfo(
|
||||
host="localhost", port=None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM
|
||||
)
|
||||
for addr in addrs:
|
||||
family, type, proto, _, _ = addr
|
||||
s = socket.socket(family, type, proto)
|
||||
try:
|
||||
s.bind(("localhost", 0))
|
||||
s.listen(0)
|
||||
return s
|
||||
except OSError as e:
|
||||
s.close()
|
||||
logger.warning("Socket creation attempt failed.", exc_info=e)
|
||||
raise RuntimeError("Failed to create a socket")
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
|
||||
def get_log_level() -> str:
|
||||
"""
|
||||
Return default log level for pytorch.
|
||||
"""
|
||||
return "WARNING"
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
|
||||
from torch.distributed.elastic.utils.log_level import get_log_level
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> logging.Logger:
|
||||
"""
|
||||
Util function to set up a simple logger that writes
|
||||
into stderr. The loglevel is fetched from the LOGLEVEL
|
||||
env. variable or WARNING as default. The function will use the
|
||||
module name of the caller if no name is provided.
|
||||
|
||||
Args:
|
||||
name: Name of the logger. If no name provided, the name will
|
||||
be derived from the call stack.
|
||||
"""
|
||||
|
||||
# Derive the name of the caller, if none provided
|
||||
# Use depth=2 since this function takes up one level in the call stack
|
||||
return _setup_logger(name or _derive_module_name(depth=2))
|
||||
|
||||
|
||||
def _setup_logger(name: str | None = None) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(os.environ.get("LOGLEVEL", get_log_level()))
|
||||
return logger
|
||||
|
||||
|
||||
def _derive_module_name(depth: int = 1) -> str | None:
|
||||
"""
|
||||
Derives the name of the caller module from the stack frames.
|
||||
|
||||
Args:
|
||||
depth: The position of the frame in the stack.
|
||||
"""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
if depth >= len(stack):
|
||||
raise AssertionError
|
||||
# FrameInfo is just a named tuple: (frame, filename, lineno, function, code_context, index)
|
||||
frame_info = stack[depth]
|
||||
|
||||
module = inspect.getmodule(frame_info[0])
|
||||
if module:
|
||||
module_name = module.__name__
|
||||
else:
|
||||
# inspect.getmodule(frame_info[0]) does NOT work (returns None) in
|
||||
# binaries built with @mode/opt
|
||||
# return the filename (minus the .py extension) as modulename
|
||||
filename = frame_info[1]
|
||||
module_name = os.path.splitext(os.path.basename(filename))[0]
|
||||
return module_name
|
||||
except Exception as e:
|
||||
warnings.warn(
|
||||
f"Error deriving logger module name, using <None>. Exception: {e}",
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
# Copyright (c) Facebook, Inc. and its affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the BSD-style license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
DistStoreError = torch._C._DistStoreError
|
||||
|
||||
_NUM_MEMBERS = "/num_members"
|
||||
_LAST_MEMBER_CHECKIN = "/last_member"
|
||||
_TRACE = "/TRACE"
|
||||
_TRACING_GATE = "/TRACING_GATE"
|
||||
_MAX_TRACE_MISSING_RANKS = 16
|
||||
|
||||
|
||||
__all__ = ["store_timeout", "get_all", "synchronize", "barrier"]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def store_timeout(store, timeout: float):
|
||||
"""
|
||||
This sets the timeout and then restores the old timeout when the context
|
||||
manager exits.
|
||||
|
||||
Args:
|
||||
store: the store to set the timeout on
|
||||
timeout: the timeout to set
|
||||
"""
|
||||
|
||||
old_timeout = store.timeout
|
||||
store.set_timeout(timedelta(seconds=timeout))
|
||||
yield
|
||||
store.set_timeout(old_timeout)
|
||||
|
||||
|
||||
def get_all(store, rank: int, prefix: str, world_size: int):
|
||||
r"""
|
||||
Given a store and a prefix, the method goes through the array of keys
|
||||
of the following format: ``{prefix}{idx}``, where idx is in a range
|
||||
from 0 to size, and tries to retrieve the data.
|
||||
|
||||
The Rank0 process waits at the end to make sure all other processes
|
||||
finished the procedure before exiting.
|
||||
|
||||
Usage
|
||||
|
||||
::
|
||||
|
||||
values = get_all(store, "torchelastic/data", 3)
|
||||
value1 = values[0] # retrieves the data for key torchelastic/data0
|
||||
value2 = values[1] # retrieves the data for key torchelastic/data1
|
||||
value3 = values[2] # retrieves the data for key torchelastic/data2
|
||||
|
||||
"""
|
||||
data_arr = store.multi_get([f"{prefix}{idx}" for idx in range(world_size)])
|
||||
|
||||
barrier_key = _barrier_nonblocking(
|
||||
store=store,
|
||||
world_size=world_size,
|
||||
key_prefix=f"{prefix}/finished",
|
||||
)
|
||||
if rank == 0:
|
||||
# Rank0 runs the TCPStore daemon, as a result it needs to exit last.
|
||||
# Otherwise, the barrier may timeout if rank0 process finished the work
|
||||
# before other processes finished `get_all` method
|
||||
store.wait([barrier_key])
|
||||
|
||||
return data_arr
|
||||
|
||||
|
||||
def synchronize(
|
||||
store,
|
||||
data: bytes,
|
||||
rank: int,
|
||||
world_size: int,
|
||||
key_prefix: str,
|
||||
timeout: float = 300,
|
||||
) -> list[bytes]:
|
||||
"""
|
||||
Synchronizes ``world_size`` agents between each other using the underlying c10d store.
|
||||
The ``data`` will be available on each of the agents.
|
||||
|
||||
Note: The data on the path is not deleted, as a result there can be stale data if
|
||||
you use the same key_prefix twice.
|
||||
|
||||
Time complexity: O(N) per worker, O(N^2) globally.
|
||||
"""
|
||||
with store_timeout(store, timeout):
|
||||
store.set(f"{key_prefix}{rank}", data)
|
||||
agent_data = get_all(store, rank, key_prefix, world_size)
|
||||
return agent_data
|
||||
|
||||
|
||||
def _try_detecting_missing_ranks(
|
||||
store,
|
||||
world_size: int,
|
||||
key_prefix: str,
|
||||
rank: int,
|
||||
rank_decoder: Callable[[int], str],
|
||||
trace_timeout: float,
|
||||
) -> Iterable[str] | None:
|
||||
store.set(f"{key_prefix}{rank}{_TRACE}", "<val_ignored>")
|
||||
|
||||
def _find_missing_ranks():
|
||||
missing_rank_info = set()
|
||||
ranks_missing = 0
|
||||
for i in range(1, world_size):
|
||||
# reduce noise, assuming in general 8 ranks per node
|
||||
# It is valuable to know that 1 or >1 nodes have timed-out.
|
||||
if ranks_missing >= _MAX_TRACE_MISSING_RANKS:
|
||||
break
|
||||
try:
|
||||
if ranks_missing == 0:
|
||||
store.wait(
|
||||
[f"{key_prefix}{i}{_TRACE}"], timedelta(seconds=trace_timeout)
|
||||
)
|
||||
else:
|
||||
# use a shortest timeout, some ranks have failed to check-in
|
||||
store.wait([f"{key_prefix}{i}{_TRACE}"], timedelta(milliseconds=1))
|
||||
except DistStoreError:
|
||||
ranks_missing += 1
|
||||
missing_rank_info.add(rank_decoder(i))
|
||||
return missing_rank_info
|
||||
|
||||
def _checkin():
|
||||
try:
|
||||
store.wait([f"{key_prefix}{_TRACING_GATE}"])
|
||||
return [f"[<check rank 0 ({rank_decoder(0)}) for missing rank info>]"]
|
||||
except DistStoreError:
|
||||
# in case rank0 is the source of the timeout, original exception will be raised
|
||||
return None
|
||||
|
||||
if rank == 0:
|
||||
missing_rank_info = _find_missing_ranks()
|
||||
store.set(f"{key_prefix}{_TRACING_GATE}", "<val_ignored>")
|
||||
return missing_rank_info
|
||||
else:
|
||||
return _checkin()
|
||||
|
||||
|
||||
def _barrier_nonblocking(store, world_size: int, key_prefix: str) -> str:
|
||||
"""
|
||||
Does all the non-blocking operations for a barrier and returns the final key
|
||||
that can be waited on.
|
||||
"""
|
||||
num_members_key = key_prefix + _NUM_MEMBERS
|
||||
last_member_key = key_prefix + _LAST_MEMBER_CHECKIN
|
||||
|
||||
idx = store.add(num_members_key, 1)
|
||||
if idx == world_size:
|
||||
store.set(last_member_key, "<val_ignored>")
|
||||
|
||||
return last_member_key
|
||||
|
||||
|
||||
def barrier(
|
||||
store,
|
||||
world_size: int,
|
||||
key_prefix: str,
|
||||
barrier_timeout: float = 300,
|
||||
rank: int | None = None,
|
||||
rank_tracing_decoder: Callable[[int], str] | None = None,
|
||||
trace_timeout: float = 10,
|
||||
) -> None:
|
||||
"""
|
||||
A global lock between agents. This will pause all workers until at least
|
||||
``world_size`` workers respond.
|
||||
|
||||
This uses a fast incrementing index to assign waiting ranks and a success
|
||||
flag set by the last worker.
|
||||
|
||||
Time complexity: O(1) per worker, O(N) globally.
|
||||
|
||||
Optionally, passing rank will enable tracing of missing ranks on timeouts.
|
||||
`rank_tracing_decoder` lambda arg can be used to convert rank data
|
||||
into a more meaningful information at an app level (e.g. hostname).
|
||||
|
||||
Note: Since the data is not removed from the store, the barrier can be used
|
||||
once per unique ``key_prefix``.
|
||||
"""
|
||||
|
||||
if rank is None:
|
||||
if rank_tracing_decoder is not None:
|
||||
raise AssertionError("Tracing requires rank information")
|
||||
|
||||
with store_timeout(store, barrier_timeout):
|
||||
last_member_key = _barrier_nonblocking(
|
||||
store=store, world_size=world_size, key_prefix=key_prefix
|
||||
)
|
||||
try:
|
||||
store.wait([last_member_key])
|
||||
except DistStoreError as e:
|
||||
if rank is None:
|
||||
raise e
|
||||
else:
|
||||
missing_ranks = _try_detecting_missing_ranks(
|
||||
store,
|
||||
world_size,
|
||||
key_prefix,
|
||||
rank,
|
||||
rank_tracing_decoder or (lambda x: str(x)),
|
||||
trace_timeout,
|
||||
)
|
||||
if missing_ranks is not None:
|
||||
raise DistStoreError(
|
||||
"Timed out waiting on barrier on "
|
||||
"rank {}, for key prefix: {} (world_size={}, missing_ranks={}, timeout={})".format(
|
||||
rank,
|
||||
key_prefix,
|
||||
world_size,
|
||||
f"[{', '.join(missing_ranks)}]",
|
||||
barrier_timeout,
|
||||
)
|
||||
) from None
|
||||
else:
|
||||
raise e
|
||||
Reference in New Issue
Block a user