1110 lines
44 KiB
Python
1110 lines
44 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from typing import List, Optional, Tuple
|
|
from urllib.parse import urlencode
|
|
|
|
import aiohttp.web
|
|
|
|
import ray.dashboard.consts as dashboard_consts
|
|
import ray.dashboard.optional_utils as dashboard_optional_utils
|
|
import ray.dashboard.utils as dashboard_utils
|
|
from ray import NodeID
|
|
from ray._common.network_utils import build_address
|
|
from ray._common.usage.usage_constants import CLUSTER_METADATA_KEY
|
|
from ray._private.grpc_utils import init_grpc_channel
|
|
from ray._private.metrics_agent import PrometheusServiceDiscoveryWriter
|
|
from ray._private.ray_constants import (
|
|
DEBUG_AUTOSCALING_ERROR,
|
|
DEBUG_AUTOSCALING_STATUS,
|
|
DEBUG_AUTOSCALING_STATUS_LEGACY,
|
|
GLOBAL_GRPC_OPTIONS,
|
|
KV_NAMESPACE_CLUSTER,
|
|
KV_NAMESPACE_DASHBOARD,
|
|
RAY_DASHBOARD_ENABLE_PROFILING,
|
|
env_integer,
|
|
)
|
|
from ray.autoscaler._private.commands import debug_status
|
|
from ray.core.generated import reporter_pb2, reporter_pb2_grpc
|
|
from ray.dashboard.consts import GCS_RPC_TIMEOUT_SECONDS
|
|
from ray.dashboard.modules.reporter.utils import HealthChecker
|
|
from ray.dashboard.state_aggregator import StateAPIManager
|
|
from ray.dashboard.subprocesses.module import SubprocessModule
|
|
from ray.dashboard.subprocesses.routes import SubprocessRouteTable as routes
|
|
from ray.util.state.common import ListApiOptions
|
|
from ray.util.state.state_manager import StateDataSourceClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
EMOJI_WARNING = "⚠️"
|
|
WARNING_FOR_MULTI_TASK_IN_A_WORKER = (
|
|
"Warning: This task is running in a worker process that is running multiple tasks. "
|
|
"This can happen if you are profiling a task right as it finishes or if you"
|
|
"are using the Async Actor or Threaded Actors pattern. "
|
|
"The information that follows may come from any of these tasks:"
|
|
)
|
|
SVG_STYLE = """<style>
|
|
svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
</style>\n"""
|
|
|
|
# NOTE: Executor in this head is intentionally constrained to just 1 thread by
|
|
# default to limit its concurrency, therefore reducing potential for
|
|
# GIL contention
|
|
RAY_DASHBOARD_REPORTER_HEAD_TPE_MAX_WORKERS = env_integer(
|
|
"RAY_DASHBOARD_REPORTER_HEAD_TPE_MAX_WORKERS", 1
|
|
)
|
|
|
|
|
|
class ReportHead(SubprocessModule):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._ray_config = None
|
|
# TODO(fyrestone): Avoid using ray.state in dashboard, it's not
|
|
# asynchronous and will lead to low performance. ray disconnect()
|
|
# will be hang when the ray.state is connected and the GCS is exit.
|
|
# Please refer to: https://github.com/ray-project/ray/issues/16328
|
|
self.service_discovery = PrometheusServiceDiscoveryWriter(
|
|
self.gcs_address, self.temp_dir, self.session_dir
|
|
)
|
|
self._state_api = None
|
|
self._executor = ThreadPoolExecutor(
|
|
max_workers=RAY_DASHBOARD_REPORTER_HEAD_TPE_MAX_WORKERS,
|
|
thread_name_prefix="reporter_head_executor",
|
|
)
|
|
|
|
# Fetched from GCS only once on startup in run(). It's static throughout the
|
|
# the cluster's lifetime.
|
|
self.cluster_metadata = None
|
|
|
|
self._health_checker = HealthChecker(self.gcs_client)
|
|
|
|
def _profiling_disabled_response(self) -> aiohttp.web.Response:
|
|
return aiohttp.web.Response(
|
|
status=403,
|
|
text=(
|
|
"Profiling is disabled by default for security reasons. "
|
|
"To enable profiling, set the environment variable "
|
|
"RAY_DASHBOARD_ENABLE_PROFILING=1 on the Ray head node. "
|
|
"See https://docs.ray.io/en/latest/ray-observability/"
|
|
"user-guides/profiling.html for details."
|
|
),
|
|
)
|
|
|
|
@routes.get("/api/profiling_enabled")
|
|
async def profiling_enabled(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
|
|
return dashboard_optional_utils.rest_response(
|
|
status_code=dashboard_utils.HTTPStatusCode.OK,
|
|
message="",
|
|
profiling_enabled=RAY_DASHBOARD_ENABLE_PROFILING,
|
|
)
|
|
|
|
@routes.get("/api/v0/cluster_metadata")
|
|
async def get_cluster_metadata(self, req):
|
|
return dashboard_optional_utils.rest_response(
|
|
status_code=dashboard_utils.HTTPStatusCode.OK,
|
|
message="",
|
|
**self.cluster_metadata,
|
|
)
|
|
|
|
@routes.get("/api/cluster_status")
|
|
async def get_cluster_status(self, req):
|
|
"""Returns status information about the cluster.
|
|
|
|
Currently contains two fields:
|
|
autoscaling_status (str)-- a status message from the autoscaler.
|
|
autoscaling_error (str)-- an error message from the autoscaler if
|
|
anything has gone wrong during autoscaling.
|
|
|
|
These fields are both read from the GCS, it's expected that the
|
|
autoscaler writes them there.
|
|
"""
|
|
# TODO(rickyx): We should be able to get the cluster status from the
|
|
# autoscaler directly with V2. And we should be able to return structured data
|
|
# rather than a string.
|
|
|
|
return_formatted_output = req.query.get("format", "0") == "1"
|
|
|
|
(legacy_status, formatted_status_string, error) = await asyncio.gather(
|
|
*[
|
|
self.gcs_client.async_internal_kv_get(
|
|
key.encode(), namespace=None, timeout=GCS_RPC_TIMEOUT_SECONDS
|
|
)
|
|
for key in [
|
|
DEBUG_AUTOSCALING_STATUS_LEGACY,
|
|
DEBUG_AUTOSCALING_STATUS,
|
|
DEBUG_AUTOSCALING_ERROR,
|
|
]
|
|
]
|
|
)
|
|
|
|
formatted_status = (
|
|
json.loads(formatted_status_string.decode())
|
|
if formatted_status_string
|
|
else {}
|
|
)
|
|
|
|
if not return_formatted_output:
|
|
return dashboard_optional_utils.rest_response(
|
|
status_code=dashboard_utils.HTTPStatusCode.OK,
|
|
message="Got cluster status.",
|
|
autoscaling_status=legacy_status.decode() if legacy_status else None,
|
|
autoscaling_error=error.decode() if error else None,
|
|
cluster_status=formatted_status if formatted_status else None,
|
|
)
|
|
else:
|
|
return dashboard_optional_utils.rest_response(
|
|
status_code=dashboard_utils.HTTPStatusCode.OK,
|
|
message="Got formatted cluster status.",
|
|
cluster_status=debug_status(
|
|
formatted_status_string, error, address=self.gcs_address
|
|
),
|
|
)
|
|
|
|
async def get_task_ids_running_in_a_worker(self, worker_id: str) -> List[str]:
|
|
"""
|
|
Retrieves the task IDs of running tasks associated with a specific worker.
|
|
|
|
Args:
|
|
worker_id: The ID of the worker.
|
|
|
|
Returns:
|
|
List[str]: A list containing the task IDs
|
|
of all the running tasks associated with the worker.
|
|
"""
|
|
option = ListApiOptions(
|
|
filters=[("worker_id", "=", worker_id), ("state", "=", "RUNNING")],
|
|
detail=True,
|
|
timeout=10,
|
|
)
|
|
# Call the state API to get all tasks in a worker
|
|
tasks_in_a_worker_result = await self._state_api.list_tasks(option=option)
|
|
tasks_in_a_worker = tasks_in_a_worker_result.result
|
|
|
|
# Get task_id from each task in a worker
|
|
task_ids_in_a_worker = [
|
|
task.get("task_id")
|
|
for task in tasks_in_a_worker
|
|
if task and "task_id" in task
|
|
]
|
|
return task_ids_in_a_worker
|
|
|
|
async def get_worker_details_for_running_task(
|
|
self, task_id: str, attempt_number: int
|
|
) -> Tuple[Optional[int], Optional[str]]:
|
|
"""Retrieves worker details for a specific task and attempt number.
|
|
|
|
Args:
|
|
task_id: The ID of the task.
|
|
attempt_number: The attempt number of the task.
|
|
|
|
Returns:
|
|
Tuple[Optional[int], Optional[str]]: A tuple containing the worker's PID
|
|
(process ID), and worker's ID.
|
|
|
|
Raises:
|
|
ValueError: If the task attempt is not running or the state API is not initialized.
|
|
"""
|
|
if self._state_api is None:
|
|
raise ValueError("The state API is not initialized yet. Please retry.")
|
|
option = ListApiOptions(
|
|
filters=[
|
|
("task_id", "=", task_id),
|
|
("attempt_number", "=", attempt_number),
|
|
],
|
|
detail=True,
|
|
timeout=10,
|
|
)
|
|
|
|
result = await self._state_api.list_tasks(option=option)
|
|
tasks = result.result
|
|
if not tasks:
|
|
return None, None
|
|
|
|
pid = tasks[0]["worker_pid"]
|
|
worker_id = tasks[0]["worker_id"]
|
|
|
|
state = tasks[0]["state"]
|
|
if state != "RUNNING":
|
|
raise ValueError(
|
|
f"The task attempt is not running: the current state is {state}."
|
|
)
|
|
|
|
return pid, worker_id
|
|
|
|
@routes.get("/task/traceback")
|
|
async def get_task_traceback(
|
|
self, req: aiohttp.web.Request
|
|
) -> aiohttp.web.Response:
|
|
"""Retrieves the traceback information for a specific task.
|
|
Note that one worker process works on one task at a time
|
|
or one worker works on multiple async tasks.
|
|
|
|
Args:
|
|
req: A request with the following query parameters:
|
|
task_id: The ID of the task.
|
|
attempt_number: The attempt number of the task.
|
|
node_id: The ID of the node.
|
|
|
|
Returns:
|
|
aiohttp.web.Response: The HTTP response containing the traceback information.
|
|
|
|
Raises:
|
|
ValueError: If the "task_id" parameter is missing in the request query.
|
|
ValueError: If the "attempt_number" parameter is missing in the request query.
|
|
ValueError: If the worker begins working on another task during the traceback retrieval.
|
|
aiohttp.web.HTTPInternalServerError: If there is an internal server error during the traceback retrieval.
|
|
"""
|
|
if not RAY_DASHBOARD_ENABLE_PROFILING:
|
|
return self._profiling_disabled_response()
|
|
if "task_id" not in req.query:
|
|
raise ValueError("task_id is required")
|
|
if "attempt_number" not in req.query:
|
|
raise ValueError("task's attempt number is required")
|
|
if "node_id" not in req.query:
|
|
raise ValueError("node_id is required")
|
|
|
|
task_id = req.query.get("task_id")
|
|
attempt_number = req.query.get("attempt_number")
|
|
node_id_hex = req.query.get("node_id")
|
|
|
|
addrs = await self._get_stub_address_by_node_id(NodeID.from_hex(node_id_hex))
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node {node_id_hex}"
|
|
)
|
|
node_id, ip, http_port, grpc_port = addrs
|
|
reporter_stub = self._make_stub(build_address(ip, grpc_port))
|
|
|
|
# Default not using `--native` for profiling
|
|
native = req.query.get("native", False) == "1"
|
|
# Default not using `--subprocesses` for profiling
|
|
subprocesses = req.query.get("subprocesses", False) == "1"
|
|
|
|
try:
|
|
(pid, _) = await self.get_worker_details_for_running_task(
|
|
task_id, attempt_number
|
|
)
|
|
except ValueError as e:
|
|
raise aiohttp.web.HTTPInternalServerError(text=str(e))
|
|
|
|
logger.info(
|
|
"Sending stack trace request to {}:{} with native={}, subprocesses={}".format(
|
|
ip, pid, native, subprocesses
|
|
)
|
|
)
|
|
reply = await reporter_stub.GetTraceback(
|
|
reporter_pb2.GetTracebackRequest(
|
|
pid=pid, native=native, subprocesses=subprocesses
|
|
)
|
|
)
|
|
|
|
"""
|
|
In order to truly confirm whether there are any other tasks
|
|
running during the profiling, we need to retrieve all tasks
|
|
that are currently running or have finished, and then parse
|
|
the task events (i.e., their start and finish times) to check
|
|
for any potential overlap. However, this process can be
|
|
quite extensive, so here we will make our best efforts
|
|
to check for any overlapping tasks.
|
|
Therefore, we will check if the task is still running
|
|
"""
|
|
try:
|
|
(_, worker_id) = await self.get_worker_details_for_running_task(
|
|
task_id, attempt_number
|
|
)
|
|
|
|
except ValueError as e:
|
|
raise aiohttp.web.HTTPInternalServerError(text=str(e))
|
|
if not reply.success:
|
|
return aiohttp.web.HTTPInternalServerError(text=reply.output)
|
|
|
|
logger.info("Returning stack trace, size {}".format(len(reply.output)))
|
|
|
|
task_ids_in_a_worker = await self.get_task_ids_running_in_a_worker(worker_id)
|
|
return aiohttp.web.Response(
|
|
text=(
|
|
WARNING_FOR_MULTI_TASK_IN_A_WORKER
|
|
+ str(task_ids_in_a_worker)
|
|
+ "\n"
|
|
+ reply.output
|
|
if len(task_ids_in_a_worker) > 1
|
|
else reply.output
|
|
)
|
|
)
|
|
|
|
@routes.get("/task/cpu_profile")
|
|
async def get_task_cpu_profile(
|
|
self, req: aiohttp.web.Request
|
|
) -> aiohttp.web.Response:
|
|
"""Retrieves the CPU profile for a specific task.
|
|
Note that one worker process works on one task at a time
|
|
or one worker works on multiple async tasks.
|
|
|
|
Args:
|
|
req: A request with the following query parameters:
|
|
task_id: The ID of the task.
|
|
attempt_number: The attempt number of the task.
|
|
node_id: The ID of the node.
|
|
duration: Optional. Duration in seconds for profiling
|
|
(default: 5, max: 60).
|
|
format: Optional. Output format (default: "flamegraph").
|
|
native: Optional. Whether to use native profiling
|
|
(default: false).
|
|
|
|
Returns:
|
|
aiohttp.web.Response: The HTTP response containing the CPU profile data.
|
|
|
|
Raises:
|
|
ValueError: If the "task_id" parameter is missing in the request query.
|
|
ValueError: If the "attempt_number" parameter is missing in the request query.
|
|
ValueError: If the maximum duration allowed is exceeded.
|
|
ValueError: If the worker begins working on another task during the profile retrieval.
|
|
aiohttp.web.HTTPInternalServerError: If there is an internal server error during the profile retrieval.
|
|
aiohttp.web.HTTPInternalServerError: If the CPU Flame Graph information for the task is not found.
|
|
"""
|
|
if not RAY_DASHBOARD_ENABLE_PROFILING:
|
|
return self._profiling_disabled_response()
|
|
if "task_id" not in req.query:
|
|
raise ValueError("task_id is required")
|
|
if "attempt_number" not in req.query:
|
|
raise ValueError("task's attempt number is required")
|
|
if "node_id" not in req.query:
|
|
raise ValueError("node_id is required")
|
|
|
|
task_id = req.query.get("task_id")
|
|
attempt_number = req.query.get("attempt_number")
|
|
node_id_hex = req.query.get("node_id")
|
|
|
|
try:
|
|
duration_s = int(req.query.get("duration", 5))
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(
|
|
text="duration query parameter must be an integer"
|
|
)
|
|
if duration_s > 60:
|
|
raise ValueError(f"The max duration allowed is 60 seconds: {duration_s}.")
|
|
format = req.query.get("format", "flamegraph")
|
|
|
|
# Default not using `--native` for profiling
|
|
native = req.query.get("native", False) == "1"
|
|
# Default not using `--idle` for profiling
|
|
idle = req.query.get("idle", False) == "1"
|
|
# Default not using `--subprocesses` for profiling
|
|
subprocesses = req.query.get("subprocesses", False) == "1"
|
|
addrs = await self._get_stub_address_by_node_id(NodeID.from_hex(node_id_hex))
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node {node_id_hex}"
|
|
)
|
|
node_id, ip, http_port, grpc_port = addrs
|
|
reporter_stub = self._make_stub(build_address(ip, grpc_port))
|
|
|
|
try:
|
|
(pid, _) = await self.get_worker_details_for_running_task(
|
|
task_id, attempt_number
|
|
)
|
|
except ValueError as e:
|
|
raise aiohttp.web.HTTPInternalServerError(text=str(e))
|
|
|
|
logger.info(
|
|
f"Sending CPU profiling request to {build_address(ip, grpc_port)}, pid {pid}, for {task_id} with native={native}, idle={idle}, subprocesses={subprocesses}"
|
|
)
|
|
|
|
reply = await reporter_stub.CpuProfiling(
|
|
reporter_pb2.CpuProfilingRequest(
|
|
pid=pid,
|
|
duration=duration_s,
|
|
format=format,
|
|
native=native,
|
|
idle=idle,
|
|
subprocesses=subprocesses,
|
|
)
|
|
)
|
|
|
|
"""
|
|
In order to truly confirm whether there are any other tasks
|
|
running during the profiling, we need to retrieve all tasks
|
|
that are currently running or have finished, and then parse
|
|
the task events (i.e., their start and finish times) to check
|
|
for any potential overlap. However, this process can be quite
|
|
extensive, so here we will make our best efforts to check
|
|
for any overlapping tasks. Therefore, we will check if
|
|
the task is still running
|
|
"""
|
|
try:
|
|
(_, worker_id) = await self.get_worker_details_for_running_task(
|
|
task_id, attempt_number
|
|
)
|
|
except ValueError as e:
|
|
raise aiohttp.web.HTTPInternalServerError(text=str(e))
|
|
|
|
if not reply.success:
|
|
return aiohttp.web.HTTPInternalServerError(text=reply.output)
|
|
logger.info("Returning profiling response, size {}".format(len(reply.output)))
|
|
|
|
task_ids_in_a_worker = await self.get_task_ids_running_in_a_worker(worker_id)
|
|
return aiohttp.web.Response(
|
|
body=(
|
|
'<p style="color: #E37400;">{} {} </br> </p> </br>'.format(
|
|
EMOJI_WARNING,
|
|
WARNING_FOR_MULTI_TASK_IN_A_WORKER + str(task_ids_in_a_worker),
|
|
)
|
|
+ SVG_STYLE
|
|
+ (reply.output)
|
|
if len(task_ids_in_a_worker) > 1
|
|
else SVG_STYLE + reply.output
|
|
),
|
|
headers={"Content-Type": "text/html"},
|
|
)
|
|
|
|
@routes.get("/worker/traceback")
|
|
async def get_traceback(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
|
|
"""Retrieves the traceback information for a specific worker.
|
|
|
|
Args:
|
|
req: A request with the following query parameters:
|
|
pid: Required. The PID of the worker.
|
|
ip or node_id: Required. The IP address or hex ID of the node.
|
|
|
|
Returns:
|
|
aiohttp.web.Response: The HTTP response containing the traceback
|
|
information, or an HTTPInternalServerError if the request fails.
|
|
"""
|
|
if not RAY_DASHBOARD_ENABLE_PROFILING:
|
|
return self._profiling_disabled_response()
|
|
pid = req.query.get("pid")
|
|
ip = req.query.get("ip")
|
|
node_id_hex = req.query.get("node_id")
|
|
if not pid:
|
|
raise ValueError("pid is required")
|
|
if not node_id_hex and not ip:
|
|
raise ValueError("ip or node_id is required")
|
|
|
|
if node_id_hex:
|
|
addrs = await self._get_stub_address_by_node_id(
|
|
NodeID.from_hex(node_id_hex)
|
|
)
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node at node_id {node_id_hex}"
|
|
)
|
|
else:
|
|
addrs = await self._get_stub_address_by_ip(ip)
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node at IP {ip}"
|
|
)
|
|
|
|
node_id, ip, http_port, grpc_port = addrs
|
|
reporter_stub = self._make_stub(build_address(ip, grpc_port))
|
|
# Default not using `--native` for profiling
|
|
native = req.query.get("native", False) == "1"
|
|
# Default not using `--subprocesses` for profiling
|
|
subprocesses = req.query.get("subprocesses", False) == "1"
|
|
logger.info(
|
|
f"Sending stack trace request to {build_address(ip, grpc_port)}, pid {pid}, with native={native}, subprocesses={subprocesses}"
|
|
)
|
|
try:
|
|
pid = int(pid)
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(text="pid must be an integer")
|
|
reply = await reporter_stub.GetTraceback(
|
|
reporter_pb2.GetTracebackRequest(
|
|
pid=pid, native=native, subprocesses=subprocesses
|
|
)
|
|
)
|
|
if reply.success:
|
|
logger.info("Returning stack trace, size {}".format(len(reply.output)))
|
|
return aiohttp.web.Response(text=reply.output)
|
|
else:
|
|
return aiohttp.web.HTTPInternalServerError(text=reply.output)
|
|
|
|
@routes.get("/worker/cpu_profile")
|
|
async def cpu_profile(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
|
|
"""Retrieves the CPU profile for a specific worker.
|
|
|
|
Args:
|
|
req: A request with the following query parameters:
|
|
pid: Required. The PID of the worker.
|
|
ip or node_id: Required. The IP address or hex ID of the node.
|
|
duration: Optional. Duration in seconds for profiling
|
|
(default: 5, max: 60).
|
|
format: Optional. Output format (default: "flamegraph").
|
|
native: Optional. Whether to use native profiling
|
|
(default: false).
|
|
idle: Optional. Whether to include off-CPU / sleeping threads
|
|
in the profile (default: false).
|
|
subprocesses: Optional. Whether to also profile child processes
|
|
of the worker (default: false).
|
|
|
|
Returns:
|
|
aiohttp.web.Response: The HTTP response containing the CPU profile data,
|
|
or an HTTPInternalServerError if the request fails.
|
|
|
|
Raises:
|
|
ValueError: If pid is not provided.
|
|
ValueError: If ip or node_id is not provided.
|
|
ValueError: If duration exceeds 60 seconds.
|
|
aiohttp.web.HTTPInternalServerError: If there is an internal server error during the profile retrieval.
|
|
"""
|
|
if not RAY_DASHBOARD_ENABLE_PROFILING:
|
|
return self._profiling_disabled_response()
|
|
pid = req.query.get("pid")
|
|
ip = req.query.get("ip")
|
|
node_id_hex = req.query.get("node_id")
|
|
if not pid:
|
|
raise ValueError("pid is required")
|
|
if not node_id_hex and not ip:
|
|
raise ValueError("ip or node_id is required")
|
|
|
|
if node_id_hex:
|
|
addrs = await self._get_stub_address_by_node_id(
|
|
NodeID.from_hex(node_id_hex)
|
|
)
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node at node_id {node_id_hex}"
|
|
)
|
|
else:
|
|
addrs = await self._get_stub_address_by_ip(ip)
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node at IP {ip}"
|
|
)
|
|
|
|
node_id, ip, http_port, grpc_port = addrs
|
|
reporter_stub = self._make_stub(build_address(ip, grpc_port))
|
|
|
|
try:
|
|
pid = int(pid)
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(text="pid must be an integer")
|
|
try:
|
|
duration_s = int(req.query.get("duration", 5))
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(
|
|
text="duration query parameter must be an integer"
|
|
)
|
|
if duration_s > 60:
|
|
raise ValueError(f"The max duration allowed is 60 seconds: {duration_s}.")
|
|
format = req.query.get("format", "flamegraph")
|
|
|
|
# Default not using `--native` for profiling
|
|
native = req.query.get("native", False) == "1"
|
|
# Default not using `--idle` for profiling
|
|
idle = req.query.get("idle", False) == "1"
|
|
# Default not using `--subprocesses` for profiling
|
|
subprocesses = req.query.get("subprocesses", False) == "1"
|
|
logger.info(
|
|
f"Sending CPU profiling request to {build_address(ip, grpc_port)}, pid {pid}, with native={native}, idle={idle}, subprocesses={subprocesses}"
|
|
)
|
|
reply = await reporter_stub.CpuProfiling(
|
|
reporter_pb2.CpuProfilingRequest(
|
|
pid=pid,
|
|
duration=duration_s,
|
|
format=format,
|
|
native=native,
|
|
idle=idle,
|
|
subprocesses=subprocesses,
|
|
)
|
|
)
|
|
if reply.success:
|
|
logger.info(
|
|
"Returning profiling response, size {}".format(len(reply.output))
|
|
)
|
|
return aiohttp.web.Response(
|
|
body=reply.output,
|
|
headers={
|
|
"Content-Type": (
|
|
"image/svg+xml" if format == "flamegraph" else "text/plain"
|
|
)
|
|
},
|
|
)
|
|
else:
|
|
return aiohttp.web.HTTPInternalServerError(text=reply.output)
|
|
|
|
@routes.get("/worker/gpu_profile")
|
|
async def gpu_profile(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
|
|
"""Retrieves the Torch GPU profile trace for a specific worker.
|
|
|
|
This is a Torch-specific API. It is not supported for other frameworks.
|
|
|
|
Params:
|
|
req: A request with the following query parameters:
|
|
pid: Required. The PID of the GPU training worker.
|
|
ip or node_id: Required. The IP address or hex ID of the node where the GPU training worker is running.
|
|
num_iterations: Number of training steps for profiling. Defaults to 4
|
|
This is the number of calls to the torch Optimizer.step().
|
|
|
|
Returns:
|
|
A redirect to the log API to download the GPU profiling trace file.
|
|
|
|
Raises:
|
|
aiohttp.web.HTTPInternalServerError: if one of the following happens:
|
|
(1) The GPU profiling dependencies are not installed on the target node.
|
|
(2) The target node doesn't have GPUs.
|
|
(3) The GPU profiling fails or times out.
|
|
The output will contain a description of the error.
|
|
For example, trying to profile a non-Torch training process will
|
|
result in an error.
|
|
"""
|
|
if not RAY_DASHBOARD_ENABLE_PROFILING:
|
|
return self._profiling_disabled_response()
|
|
|
|
pid = req.query.get("pid")
|
|
ip = req.query.get("ip")
|
|
node_id_hex = req.query.get("node_id")
|
|
if not pid:
|
|
raise ValueError("pid is required")
|
|
if not node_id_hex and not ip:
|
|
raise ValueError("ip or node_id is required")
|
|
|
|
if node_id_hex:
|
|
addrs = await self._get_stub_address_by_node_id(
|
|
NodeID.from_hex(node_id_hex)
|
|
)
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node at node_id {node_id_hex}, pid {pid}"
|
|
)
|
|
else:
|
|
addrs = await self._get_stub_address_by_ip(ip)
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node at IP {ip}, pid {pid}"
|
|
)
|
|
|
|
node_id, ip, http_port, grpc_port = addrs
|
|
reporter_stub = self._make_stub(build_address(ip, grpc_port))
|
|
|
|
# Profile for num_iterations training steps (calls to optimizer.step())
|
|
try:
|
|
num_iterations = int(req.query.get("num_iterations", 4))
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(text="num_iterations must be an integer")
|
|
|
|
logger.info(
|
|
f"Sending GPU profiling request to {build_address(ip, grpc_port)}, pid {pid}. "
|
|
f"Profiling for {num_iterations} training steps."
|
|
)
|
|
|
|
try:
|
|
pid = int(pid)
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(text="pid must be an integer")
|
|
|
|
reply = await reporter_stub.GpuProfiling(
|
|
reporter_pb2.GpuProfilingRequest(pid=pid, num_iterations=num_iterations)
|
|
)
|
|
|
|
if not reply.success:
|
|
return aiohttp.web.HTTPInternalServerError(text=reply.output)
|
|
logger.info("Returning profiling response, size {}".format(len(reply.output)))
|
|
|
|
filepath = str(reply.output)
|
|
download_filename = Path(filepath).name
|
|
|
|
query = urlencode(
|
|
{
|
|
"node_ip": ip,
|
|
"filename": filepath,
|
|
"download_filename": download_filename,
|
|
"lines": "-1",
|
|
}
|
|
)
|
|
redirect_url = f"/api/v0/logs/file?{query}"
|
|
raise aiohttp.web.HTTPFound(redirect_url)
|
|
|
|
@routes.get("/worker/jax_profile")
|
|
async def jax_profile(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
|
|
"""Retrieves the JAX profile trace for a specific worker.
|
|
|
|
Params:
|
|
req: A request with the following query parameters:
|
|
pid: Required. The PID of the worker.
|
|
port: Optional. The port where JAX profiler server is listening.
|
|
If not provided, it will be automatically discovered from GCS.
|
|
ip or node_id: Required. The IP address or hex ID of the node.
|
|
duration: Optional. Duration in seconds for profiling (default: 5).
|
|
|
|
Returns:
|
|
JSON response with the path where trace files are saved.
|
|
"""
|
|
if not RAY_DASHBOARD_ENABLE_PROFILING:
|
|
return self._profiling_disabled_response()
|
|
|
|
pid = req.query.get("pid")
|
|
port = req.query.get("port")
|
|
ip = req.query.get("ip")
|
|
node_id_hex = req.query.get("node_id")
|
|
|
|
if not pid:
|
|
raise ValueError("pid is required")
|
|
if not node_id_hex and not ip:
|
|
raise ValueError("ip or node_id is required")
|
|
|
|
if node_id_hex:
|
|
addrs = await self._get_stub_address_by_node_id(
|
|
NodeID.from_hex(node_id_hex)
|
|
)
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node at node_id {node_id_hex}"
|
|
)
|
|
else:
|
|
addrs = await self._get_stub_address_by_ip(ip)
|
|
if not addrs:
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to get agent address for node at IP {ip}"
|
|
)
|
|
|
|
node_id, ip, http_port, grpc_port = addrs
|
|
reporter_stub = self._make_stub(build_address(ip, grpc_port))
|
|
|
|
try:
|
|
duration_s = int(req.query.get("duration", 5))
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(
|
|
text="duration query parameter must be an integer"
|
|
)
|
|
try:
|
|
pid = int(pid)
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(text="pid must be an integer")
|
|
|
|
if not port:
|
|
port_bytes = await self.gcs_client.async_internal_kv_get(
|
|
f"jax_profiler_port:{node_id.hex()}:{pid}".encode(),
|
|
namespace=KV_NAMESPACE_DASHBOARD,
|
|
timeout=GCS_RPC_TIMEOUT_SECONDS,
|
|
)
|
|
if port_bytes:
|
|
try:
|
|
port = int(port_bytes.decode())
|
|
except (UnicodeDecodeError, ValueError):
|
|
raise aiohttp.web.HTTPInternalServerError(
|
|
text=(
|
|
f"Discovered JAX profiler port for worker {pid} on node"
|
|
f"{node_id.hex()} is corrupt: {port_bytes!r}"
|
|
)
|
|
)
|
|
else:
|
|
raise aiohttp.web.HTTPBadRequest(
|
|
text=(
|
|
f"port is required because JAX profiler port could not be "
|
|
f"automatically discovered for worker {pid} on node {node_id.hex()}"
|
|
)
|
|
)
|
|
else:
|
|
try:
|
|
port = int(port)
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(text="port must be an integer")
|
|
|
|
logger.info(
|
|
f"Sending JAX profiling request to {build_address(ip, grpc_port)}, pid {pid}, port {port}"
|
|
)
|
|
|
|
reply = await reporter_stub.JaxProfiling(
|
|
reporter_pb2.JaxProfilingRequest(pid=pid, port=port, duration=duration_s)
|
|
)
|
|
|
|
if not reply.success:
|
|
return aiohttp.web.HTTPInternalServerError(text=reply.output)
|
|
|
|
logger.info("Returning profiling response, location {}".format(reply.output))
|
|
|
|
return dashboard_optional_utils.rest_response(
|
|
status_code=dashboard_utils.HTTPStatusCode.OK,
|
|
message="JAX profiling finished.",
|
|
trace_directory=reply.output,
|
|
)
|
|
|
|
@routes.get("/memory_profile")
|
|
async def memory_profile(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
|
|
"""Retrieves the memory profile for a specific worker or task.
|
|
Note that for tasks, one worker process works on one task at a time
|
|
or one worker works on multiple async tasks.
|
|
|
|
Args:
|
|
req: A request with one of the following sets of query parameters.
|
|
|
|
Worker:
|
|
pid: The PID of the worker.
|
|
ip or node_id: The IP address or hex ID of the node.
|
|
|
|
Task:
|
|
task_id: The ID of the task.
|
|
attempt_number: The attempt number of the task.
|
|
node_id: The ID of the node.
|
|
|
|
Returns:
|
|
aiohttp.web.Response: The HTTP response containing the memory profile data.
|
|
|
|
Raises:
|
|
aiohttp.web.HTTPInternalServerError: If no stub
|
|
found from the given IP address or hex ID value
|
|
aiohttp.web.HTTPInternalServerError: If the
|
|
"task_id" parameter exists but either "attempt_number"
|
|
or "node id" is missing in the request query.
|
|
aiohttp.web.HTTPInternalServerError: If the maximum
|
|
duration allowed is exceeded.
|
|
aiohttp.web.HTTPInternalServerError: If requesting task
|
|
profiling for the worker begins working on another task
|
|
during the profile retrieval.
|
|
aiohttp.web.HTTPInternalServerError: If there is
|
|
an internal server error during the profile retrieval.
|
|
"""
|
|
if not RAY_DASHBOARD_ENABLE_PROFILING:
|
|
return self._profiling_disabled_response()
|
|
is_task = "task_id" in req.query
|
|
|
|
# Either is_task or not, we need to get ip and grpc_port.
|
|
if is_task:
|
|
if "attempt_number" not in req.query:
|
|
raise aiohttp.web.HTTPBadRequest(
|
|
text=(
|
|
"Failed to execute task profiling: "
|
|
"task's attempt number is required"
|
|
)
|
|
)
|
|
if "node_id" not in req.query:
|
|
raise aiohttp.web.HTTPBadRequest(
|
|
text=(
|
|
"Failed to execute task profiling: task's node id is required"
|
|
)
|
|
)
|
|
|
|
task_id = req.query.get("task_id")
|
|
attempt_number = req.query.get("attempt_number")
|
|
try:
|
|
(pid, _) = await self.get_worker_details_for_running_task(
|
|
task_id, attempt_number
|
|
)
|
|
except ValueError as e:
|
|
raise aiohttp.web.HTTPInternalServerError(text=str(e))
|
|
node_id_hex = req.query.get("node_id")
|
|
addrs = await self._get_stub_address_by_node_id(
|
|
NodeID.from_hex(node_id_hex)
|
|
)
|
|
if not addrs:
|
|
return aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to execute: no agent address found for node {node_id_hex}"
|
|
)
|
|
_, ip, _, grpc_port = addrs
|
|
else:
|
|
if "pid" not in req.query:
|
|
raise aiohttp.web.HTTPBadRequest(text="pid is required")
|
|
try:
|
|
pid = int(req.query["pid"])
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(text="pid must be an integer")
|
|
ip = req.query.get("ip")
|
|
node_id_hex = req.query.get("node_id")
|
|
|
|
if not node_id_hex and not ip:
|
|
raise ValueError("ip or node_id is required")
|
|
|
|
if node_id_hex:
|
|
addrs = await self._get_stub_address_by_node_id(
|
|
NodeID.from_hex(node_id_hex)
|
|
)
|
|
if not addrs:
|
|
return aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to execute: no agent address found for node {node_id_hex}"
|
|
)
|
|
_, ip, _, grpc_port = addrs
|
|
else:
|
|
addrs = await self._get_stub_address_by_ip(ip)
|
|
if not addrs:
|
|
return aiohttp.web.HTTPInternalServerError(
|
|
text=f"Failed to execute: no agent address found for node IP {ip}"
|
|
)
|
|
_, ip, _, grpc_port = addrs
|
|
|
|
assert pid is not None
|
|
ip_port = build_address(ip, grpc_port)
|
|
|
|
try:
|
|
duration_s = int(req.query.get("duration", 10))
|
|
except ValueError:
|
|
raise aiohttp.web.HTTPBadRequest(
|
|
text="duration query parameter must be an integer"
|
|
)
|
|
|
|
# Default not using `--native`, `--leaks` and `--format` for profiling
|
|
format = req.query.get("format", "flamegraph")
|
|
native = req.query.get("native", False) == "1"
|
|
leaks = req.query.get("leaks", False) == "1"
|
|
trace_python_allocators = req.query.get("trace_python_allocators", False) == "1"
|
|
|
|
reporter_stub = self._make_stub(ip_port)
|
|
|
|
logger.info(
|
|
f"Retrieving memory profiling request to {build_address(ip, grpc_port)}, pid {pid}, with native={native}"
|
|
)
|
|
|
|
reply = await reporter_stub.MemoryProfiling(
|
|
reporter_pb2.MemoryProfilingRequest(
|
|
pid=pid,
|
|
format=format,
|
|
leaks=leaks,
|
|
duration=duration_s,
|
|
native=native,
|
|
trace_python_allocators=trace_python_allocators,
|
|
)
|
|
)
|
|
|
|
task_ids_in_a_worker = None
|
|
warning = reply.warning if reply.warning else ""
|
|
if is_task:
|
|
"""
|
|
In order to truly confirm whether there are any other tasks
|
|
running during the profiling, Ray needs to retrieve all tasks
|
|
that are currently running or have finished, and then parse
|
|
the task events (i.e., their start and finish times) to check
|
|
for any potential overlap. However, this process can be quite
|
|
extensive, so Ray makes our best efforts to check
|
|
for any overlapping tasks. Therefore, Ray checks if
|
|
the task is still running.
|
|
"""
|
|
try:
|
|
(_, worker_id) = await self.get_worker_details_for_running_task(
|
|
task_id, attempt_number
|
|
)
|
|
except ValueError as e:
|
|
raise aiohttp.web.HTTPInternalServerError(text=str(e))
|
|
|
|
task_ids_in_a_worker = await self.get_task_ids_running_in_a_worker(
|
|
worker_id
|
|
)
|
|
if len(task_ids_in_a_worker) > 1:
|
|
warning += (
|
|
"\n"
|
|
+ WARNING_FOR_MULTI_TASK_IN_A_WORKER
|
|
+ str(task_ids_in_a_worker)
|
|
)
|
|
|
|
if not reply.success:
|
|
return aiohttp.web.HTTPInternalServerError(text=reply.output)
|
|
logger.info("Returning profiling response, size {}".format(len(reply.output)))
|
|
|
|
return aiohttp.web.Response(
|
|
body=(
|
|
'<p style="color: #E37400;">{} {} </br> </p> </br>'.format(
|
|
EMOJI_WARNING, warning
|
|
)
|
|
+ (reply.output)
|
|
if warning != ""
|
|
else reply.output
|
|
),
|
|
headers={"Content-Type": "text/html"},
|
|
)
|
|
|
|
@routes.get("/api/gcs_healthz")
|
|
async def health_check(self, req: aiohttp.web.Request) -> aiohttp.web.Response:
|
|
try:
|
|
alive = await self._health_checker.check_gcs_liveness()
|
|
if alive is True:
|
|
return aiohttp.web.Response(
|
|
text="success",
|
|
content_type="application/text",
|
|
)
|
|
except Exception as e:
|
|
return aiohttp.web.HTTPServiceUnavailable(
|
|
reason=f"Health check failed: {e}"
|
|
)
|
|
|
|
return aiohttp.web.HTTPServiceUnavailable(reason="Health check failed")
|
|
|
|
@routes.get("/api/prometheus/sd")
|
|
async def prometheus_service_discovery(self, req) -> aiohttp.web.Response:
|
|
"""
|
|
Expose Prometheus metrics targets through HTTP Service Discovery.
|
|
"""
|
|
content = self.service_discovery.get_latest_service_discovery_content()
|
|
if not isinstance(content, list):
|
|
error_message = "service discovery error: content is not a list"
|
|
logger.warning(error_message)
|
|
return aiohttp.web.json_response(
|
|
{"error": error_message},
|
|
status=dashboard_utils.HTTPStatusCode.INTERNAL_ERROR,
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
return aiohttp.web.Response(
|
|
text=json.dumps(content),
|
|
content_type="application/json",
|
|
charset="utf-8",
|
|
status=dashboard_utils.HTTPStatusCode.OK,
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
async def _get_stub_address_by_node_id(
|
|
self, node_id: NodeID
|
|
) -> Optional[Tuple[NodeID, str, int, int]]:
|
|
"""
|
|
Given a NodeID, get agent port from InternalKV.
|
|
|
|
returns a tuple of (ip, http_port, grpc_port).
|
|
|
|
If not found, return None.
|
|
"""
|
|
agent_addr_json = await self.gcs_client.async_internal_kv_get(
|
|
f"{dashboard_consts.DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX}{node_id.hex()}".encode(),
|
|
namespace=KV_NAMESPACE_DASHBOARD,
|
|
timeout=GCS_RPC_TIMEOUT_SECONDS,
|
|
)
|
|
if not agent_addr_json:
|
|
return None
|
|
ip, http_port, grpc_port = json.loads(agent_addr_json)
|
|
return node_id, ip, http_port, grpc_port
|
|
|
|
async def _get_stub_address_by_ip(
|
|
self, ip: str
|
|
) -> Optional[Tuple[str, str, int, int]]:
|
|
agent_addr_json = await self.gcs_client.async_internal_kv_get(
|
|
f"{dashboard_consts.DASHBOARD_AGENT_ADDR_IP_PREFIX}{ip}".encode(),
|
|
namespace=KV_NAMESPACE_DASHBOARD,
|
|
timeout=GCS_RPC_TIMEOUT_SECONDS,
|
|
)
|
|
if not agent_addr_json:
|
|
return None
|
|
node_id, http_port, grpc_port = json.loads(agent_addr_json)
|
|
return NodeID.from_hex(node_id), ip, http_port, grpc_port
|
|
|
|
def _make_stub(
|
|
self, ip_port: str
|
|
) -> Optional[reporter_pb2_grpc.ReporterServiceStub]:
|
|
options = GLOBAL_GRPC_OPTIONS
|
|
channel = init_grpc_channel(ip_port, options=options, asynchronous=True)
|
|
return reporter_pb2_grpc.ReporterServiceStub(channel)
|
|
|
|
async def run(self):
|
|
await super().run()
|
|
self._state_api_data_source_client = StateDataSourceClient(
|
|
self.aiogrpc_gcs_channel, self.gcs_client
|
|
)
|
|
# Set up the state API in order to fetch task information.
|
|
# This is only used to get task info. If we have Task APIs in GcsClient we can
|
|
# remove this.
|
|
# TODO(ryw): unify the StateAPIManager in reporter_head and state_head.
|
|
self._state_api = StateAPIManager(
|
|
self._state_api_data_source_client,
|
|
self._executor,
|
|
)
|
|
|
|
# Need daemon True to avoid dashboard hangs at exit.
|
|
self.service_discovery.daemon = True
|
|
self.service_discovery.start()
|
|
|
|
cluster_metadata = await self.gcs_client.async_internal_kv_get(
|
|
CLUSTER_METADATA_KEY,
|
|
namespace=KV_NAMESPACE_CLUSTER,
|
|
)
|
|
self.cluster_metadata = json.loads(cluster_metadata.decode("utf-8"))
|