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

478 lines
19 KiB
Python

import logging
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from aiohttp.web import Request, Response
import ray
import ray.dashboard.optional_utils as dashboard_optional_utils
from ray.core.generated import gcs_service_pb2_grpc
from ray.dashboard.modules.job.common import JobInfoStorageClient
from ray.dashboard.modules.job.utils import find_jobs_by_job_ids
from ray.dashboard.subprocesses.module import SubprocessModule
from ray.dashboard.subprocesses.routes import SubprocessRouteTable as routes
from ray.dashboard.subprocesses.utils import get_http_session_to_module
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.dashboard.modules.job.pydantic_models import JobDetails
from ray.train.v2._internal.state.schema import (
DecoratedTrainRun,
DecoratedTrainRunAttempt,
DecoratedTrainWorker,
RunStatus,
TrainRun,
TrainRunAttempt,
TrainWorker,
)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class TrainHead(SubprocessModule):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._train_stats_actor = None # Train V1
self._train_v2_state_actor = None # Train V2
self._job_info_client = None
self._gcs_actor_info_stub = None
# Lazy initialized HTTP session to NodeHead
self._node_head_http_session = None
# TODO: The next iteration of this should be "/api/train/v2/runs/v2".
# This follows the naming convention of "/api/train/{train_version}/runs/{api_version}".
# This API corresponds to the Train V2 API.
@routes.get("/api/train/v2/runs/v1")
@dashboard_optional_utils.init_ray_and_catch_exceptions()
@DeveloperAPI
async def get_train_v2_runs(self, req: Request) -> Response:
"""Get all TrainRuns for Ray Train V2."""
try:
from ray.train.v2._internal.state.schema import TrainRunsResponse
except ImportError:
logger.exception(
"Train is not installed. Please run `pip install ray[train]` "
"when setting up Ray on your cluster."
)
return Response(
status=500,
text="Train is not installed. Please run `pip install ray[train]` "
"when setting up Ray on your cluster.",
)
state_actor = await self.get_train_v2_state_actor()
if state_actor is None:
return Response(
status=500,
text=(
"Train state data is not available. Please make sure Ray Train "
"is running and that the Train state actor is enabled by setting "
'the RAY_TRAIN_ENABLE_STATE_TRACKING environment variable to "1".'
),
)
else:
try:
train_runs = await state_actor.get_train_runs.remote()
decorated_train_runs = await self._decorate_train_runs(
train_runs.values()
)
details = TrainRunsResponse(train_runs=decorated_train_runs)
except ray.exceptions.RayTaskError as e:
# Task failure sometimes are due to GCS
# failure. When GCS failed, we expect a longer time
# to recover.
return Response(
status=503,
text=(
"Failed to get a response from the train stats actor. "
f"The GCS may be down, please retry later: {e}"
),
)
return Response(
text=details.json(),
content_type="application/json",
)
async def _decorate_train_runs(
self, train_runs: List["TrainRun"]
) -> List["DecoratedTrainRun"]:
"""Decorate the train runs with run attempts, job details, status, and status details.
Args:
train_runs: The train runs to decorate.
Returns:
List[DecoratedTrainRun]: The decorated train runs in reverse chronological order.
"""
from ray.train.v2._internal.state.schema import DecoratedTrainRun
decorated_train_runs: List[DecoratedTrainRun] = []
state_actor = await self.get_train_v2_state_actor()
all_train_run_attempts = await state_actor.get_train_run_attempts.remote()
jobs = await self._get_jobs([train_run.job_id for train_run in train_runs])
for train_run in train_runs:
# TODO: Batch these together across TrainRuns if needed.
train_run_attempts = all_train_run_attempts[train_run.id].values()
decorated_train_run_attempts: List[
DecoratedTrainRunAttempt
] = await self._decorate_train_run_attempts(train_run_attempts)
job_details = jobs[train_run.job_id]
status, status_details = await self._get_run_status(train_run)
decorated_train_run = DecoratedTrainRun.parse_obj(
{
**train_run.dict(),
"attempts": decorated_train_run_attempts,
"job_details": job_details,
"status": status,
"status_detail": status_details,
}
)
decorated_train_runs.append(decorated_train_run)
# Sort train runs in reverse chronological order
decorated_train_runs = sorted(
decorated_train_runs,
key=lambda run: run.start_time_ns,
reverse=True,
)
return decorated_train_runs
async def _get_jobs(self, job_ids: List[str]) -> Dict[str, "JobDetails"]:
return await find_jobs_by_job_ids(
self.gcs_client,
self._job_info_client,
job_ids,
)
async def _decorate_train_run_attempts(
self, train_run_attempts: List["TrainRunAttempt"]
) -> List["DecoratedTrainRunAttempt"]:
from ray.train.v2._internal.state.schema import DecoratedTrainRunAttempt
decorated_train_run_attempts: List[DecoratedTrainRunAttempt] = []
for train_run_attempt in train_run_attempts:
# TODO: Batch these together across TrainRunAttempts if needed.
decorated_train_workers: List[
DecoratedTrainWorker
] = await self._decorate_train_workers(train_run_attempt.workers)
decorated_train_run_attempt = DecoratedTrainRunAttempt.parse_obj(
{**train_run_attempt.dict(), "workers": decorated_train_workers}
)
decorated_train_run_attempts.append(decorated_train_run_attempt)
return decorated_train_run_attempts
async def _decorate_train_workers(
self, train_workers: List["TrainWorker"]
) -> List["DecoratedTrainWorker"]:
from ray.train.v2._internal.state.schema import DecoratedTrainWorker
decorated_train_workers: List[DecoratedTrainWorker] = []
actor_ids = [worker.actor_id for worker in train_workers]
logger.info(f"Getting all actor info from GCS (actor_ids={actor_ids})")
train_run_actors = await self._get_actor_infos(actor_ids)
for train_worker in train_workers:
actor = train_run_actors.get(train_worker.actor_id, None)
# Add hardware metrics to API response
if actor:
gpus = [
gpu
for gpu in actor["gpus"]
if train_worker.pid
in [process["pid"] for process in gpu["processesPids"]]
]
# Need to convert processesPids into a proper list.
# It's some weird ImmutableList structure
# We also convert the list of processes into a single item since
# an actor is only a single process and cannot match multiple
# processes.
formatted_gpus = [
{
**gpu,
"processInfo": [
process
for process in gpu["processesPids"]
if process["pid"] == train_worker.pid
][0],
}
for gpu in gpus
]
decorated_train_worker = DecoratedTrainWorker.parse_obj(
{
**train_worker.dict(),
"status": actor["state"],
"processStats": actor["processStats"],
"gpus": formatted_gpus,
}
)
else:
decorated_train_worker = DecoratedTrainWorker.parse_obj(
train_worker.dict()
)
decorated_train_workers.append(decorated_train_worker)
return decorated_train_workers
async def _get_run_status(
self, train_run: "TrainRun"
) -> Tuple["RunStatus", Optional[str]]:
from ray.train.v2._internal.state.schema import ActorStatus, RunStatus
# TODO: Move this to the TrainStateActor.
# The train run can be unexpectedly terminated before the final run
# status was updated. This could be due to errors outside of the training
# function (e.g., system failure or user interruption) that crashed the
# train controller.
# We need to detect this case and mark the train run as ABORTED.
actor_infos = await self._get_actor_infos([train_run.controller_actor_id])
controller_actor_info = actor_infos[train_run.controller_actor_id]
controller_actor_status = (
controller_actor_info.get("state") if controller_actor_info else None
)
if (
controller_actor_status == ActorStatus.DEAD
and train_run.status == RunStatus.RUNNING
):
run_status = RunStatus.ABORTED
status_detail = "Terminated due to system errors or killed by the user."
return (run_status, status_detail)
# Default to original.
return (train_run.status, train_run.status_detail)
# TODO: The next iteration of this should be "/api/train/v1/runs/v3".
# This follows the naming convention of "/api/train/{train_version}/runs/{api_version}".
# This API corresponds to the Train V1 API.
@routes.get("/api/train/v2/runs")
@dashboard_optional_utils.init_ray_and_catch_exceptions()
@DeveloperAPI
async def get_train_runs(self, req: Request) -> Response:
"""Get all TrainRunInfos for Ray Train V1."""
try:
from ray.train._internal.state.schema import TrainRunsResponse
except ImportError:
logger.exception(
"Train is not installed. Please run `pip install ray[train]` "
"when setting up Ray on your cluster."
)
return Response(
status=500,
text="Train is not installed. Please run `pip install ray[train]` "
"when setting up Ray on your cluster.",
)
stats_actor = await self.get_train_stats_actor()
if stats_actor is None:
return Response(
status=500,
text=(
"Train state data is not available. Please make sure Ray Train "
"is running and that the Train state actor is enabled by setting "
'the RAY_TRAIN_ENABLE_STATE_TRACKING environment variable to "1".'
),
)
else:
try:
train_runs = await stats_actor.get_all_train_runs.remote()
train_runs_with_details = (
await self._add_actor_status_and_update_run_status(train_runs)
)
# Sort train runs in reverse chronological order
train_runs_with_details = sorted(
train_runs_with_details,
key=lambda run: run.start_time_ms,
reverse=True,
)
job_details = await find_jobs_by_job_ids(
self.gcs_client,
self._job_info_client,
[run.job_id for run in train_runs_with_details],
)
for run in train_runs_with_details:
run.job_details = job_details.get(run.job_id)
details = TrainRunsResponse(train_runs=train_runs_with_details)
except ray.exceptions.RayTaskError as e:
# Task failure sometimes are due to GCS
# failure. When GCS failed, we expect a longer time
# to recover.
return Response(
status=503,
text=(
"Failed to get a response from the train stats actor. "
f"The GCS may be down, please retry later: {e}"
),
)
return Response(
text=details.json(),
content_type="application/json",
)
async def _get_actor_infos(self, actor_ids: List[str]):
if self._node_head_http_session is None:
self._node_head_http_session = get_http_session_to_module(
"NodeHead", self._config.socket_dir, self._config.session_name
)
actor_ids_qs_str = ",".join(actor_ids)
url = f"http://localhost/logical/actors?ids={actor_ids_qs_str}&nocache=1"
async with self._node_head_http_session.get(url) as resp:
resp.raise_for_status()
resp_json = await resp.json()
return resp_json["data"]["actors"]
async def _add_actor_status_and_update_run_status(self, train_runs):
from ray.train._internal.state.schema import (
ActorStatusEnum,
RunStatusEnum,
TrainRunInfoWithDetails,
TrainWorkerInfoWithDetails,
)
train_runs_with_details: List[TrainRunInfoWithDetails] = []
for train_run in train_runs.values():
worker_infos_with_details: List[TrainWorkerInfoWithDetails] = []
actor_ids = [worker.actor_id for worker in train_run.workers]
logger.info(f"Getting all actor info from GCS (actor_ids={actor_ids})")
train_run_actors = await self._get_actor_infos(actor_ids)
for worker_info in train_run.workers:
actor = train_run_actors.get(worker_info.actor_id, None)
# Add hardware metrics to API response
if actor:
gpus = [
gpu
for gpu in actor["gpus"]
if worker_info.pid
in [process["pid"] for process in gpu["processesPids"]]
]
# Need to convert processesPids into a proper list.
# It's some weird ImmutableList structureo
# We also convert the list of processes into a single item since
# an actor is only a single process and cannot match multiple
# processes.
formatted_gpus = [
{
**gpu,
"processInfo": [
process
for process in gpu["processesPids"]
if process["pid"] == worker_info.pid
][0],
}
for gpu in gpus
]
worker_info_with_details = TrainWorkerInfoWithDetails.parse_obj(
{
**worker_info.dict(),
"status": actor["state"],
"processStats": actor["processStats"],
"gpus": formatted_gpus,
}
)
else:
worker_info_with_details = TrainWorkerInfoWithDetails.parse_obj(
worker_info.dict()
)
worker_infos_with_details.append(worker_info_with_details)
train_run_with_details = TrainRunInfoWithDetails.parse_obj(
{**train_run.dict(), "workers": worker_infos_with_details}
)
# The train run can be unexpectedly terminated before the final run
# status was updated. This could be due to errors outside of the training
# function (e.g., system failure or user interruption) that crashed the
# train controller.
# We need to detect this case and mark the train run as ABORTED.
actor = train_run_actors.get(train_run.controller_actor_id)
controller_actor_status = actor.get("state") if actor else None
if (
controller_actor_status == ActorStatusEnum.DEAD
and train_run.run_status == RunStatusEnum.RUNNING
):
train_run_with_details.run_status = RunStatusEnum.ABORTED
train_run_with_details.status_detail = (
"Terminated due to system errors or killed by the user."
)
train_runs_with_details.append(train_run_with_details)
return train_runs_with_details
async def run(self):
await super().run()
if not self._job_info_client:
self._job_info_client = JobInfoStorageClient(self.gcs_client)
gcs_channel = self.aiogrpc_gcs_channel
self._gcs_actor_info_stub = gcs_service_pb2_grpc.ActorInfoGcsServiceStub(
gcs_channel
)
async def get_train_stats_actor(self):
"""
Gets the train stats actor and caches it as an instance variable.
"""
try:
from ray.train._internal.state.state_actor import get_state_actor
if self._train_stats_actor is None:
self._train_stats_actor = get_state_actor()
return self._train_stats_actor
except ImportError:
logger.exception(
"Train is not installed. Please run `pip install ray[train]` "
"when setting up Ray on your cluster."
)
return None
async def get_train_v2_state_actor(self):
"""
Gets the Train state actor and caches it as an instance variable.
"""
try:
from ray.train.v2._internal.state.state_actor import get_state_actor
if self._train_v2_state_actor is None:
self._train_v2_state_actor = get_state_actor()
return self._train_v2_state_actor
except ImportError:
logger.exception(
"Train is not installed. Please run `pip install ray[train]` "
"when setting up Ray on your cluster."
)
return None