import asyncio import datetime import json import logging import os import socket import sys import traceback from collections import defaultdict, namedtuple from concurrent.futures import ThreadPoolExecutor from typing import List, Optional, Tuple import requests from grpc.aio import ServicerContext from opencensus.stats import stats as stats_module from opentelemetry.proto.collector.metrics.v1 import ( metrics_service_pb2, metrics_service_pb2_grpc, ) from opentelemetry.proto.metrics.v1.metrics_pb2 import Metric from prometheus_client.core import REGISTRY from prometheus_client.parser import text_string_to_metric_families import ray import ray._private.prometheus_exporter as prometheus_exporter import ray.dashboard.modules.reporter.reporter_consts as reporter_consts import ray.dashboard.utils as dashboard_utils from ray._common.network_utils import get_localhost_ip, is_localhost from ray._common.utils import ( get_or_create_event_loop, ) from ray._private import utils from ray._private.metrics_agent import Gauge, MetricsAgent, Record from ray._private.ray_constants import ( DEBUG_AUTOSCALING_STATUS, RAY_ENABLE_OPEN_TELEMETRY, env_integer, ) from ray._private.telemetry.open_telemetry_metric_recorder import ( OpenTelemetryMetricRecorder, ) from ray._private.utils import get_system_memory from ray._raylet import ( GCS_PID_KEY, METRICS_EXPORT_PORT_NAME, RayletClient, WorkerID, persist_port, ) from ray.autoscaler.v2.sdk import get_cluster_status from ray.autoscaler.v2.utils import _count_by, is_autoscaler_v2 from ray.core.generated import reporter_pb2, reporter_pb2_grpc from ray.dashboard import k8s_utils from ray.dashboard.consts import ( CLUSTER_TAG_KEYS, COMPONENT_GPU_TAG_KEYS, COMPONENT_METRICS_TAG_KEYS, GCS_RPC_TIMEOUT_SECONDS, GPU_TAG_KEYS, NODE_TAG_KEYS, TPU_TAG_KEYS, ) from ray.dashboard.modules.reporter.gpu_profile_manager import GpuProfilingManager from ray.dashboard.modules.reporter.gpu_providers import ( GpuMetricProvider, GpuUtilizationInfo, TpuUtilizationInfo, ) from ray.dashboard.modules.reporter.jax_profile_manager import JaxProfilingManager from ray.dashboard.modules.reporter.profile_manager import ( CpuProfilingManager, MemoryProfilingManager, ) from ray.dashboard.modules.reporter.reporter_models import ( StatsPayload, ) from ray.exceptions import ( GetTimeoutError, RpcError, ) import psutil logger = logging.getLogger(__name__) enable_tpu_usage_check = True # Are we in a K8s pod? IN_KUBERNETES_POD = "KUBERNETES_SERVICE_HOST" in os.environ # Flag to enable showing disk usage when running in a K8s pod, # disk usage defined as the result of running psutil.disk_usage("/") # in the Ray container. ENABLE_K8S_DISK_USAGE = os.environ.get("RAY_DASHBOARD_ENABLE_K8S_DISK_USAGE") == "1" # Try to determine if we're in a container. IN_CONTAINER = os.path.exists("/sys/fs/cgroup") # Using existence of /sys/fs/cgroup as the criterion is consistent with # Ray's existing resource logic, see e.g. ray._private.utils.get_num_cpus(). # 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_AGENT_TPE_MAX_WORKERS = env_integer( "RAY_DASHBOARD_REPORTER_AGENT_TPE_MAX_WORKERS", 1 ) # TPU device plugin metric address should be in the format "{HOST_IP}:2112" TPU_DEVICE_PLUGIN_ADDR = os.environ.get("TPU_DEVICE_PLUGIN_ADDR", None) def recursive_asdict(o): if isinstance(o, tuple) and hasattr(o, "_asdict"): return recursive_asdict(o._asdict()) if isinstance(o, (tuple, list)): L = [] for k in o: L.append(recursive_asdict(k)) return L if isinstance(o, dict): D = {k: recursive_asdict(v) for k, v in o.items()} return D return o def jsonify_asdict(o) -> str: return json.dumps(dashboard_utils.to_google_style(recursive_asdict(o))) # A list of gauges to record and export metrics. METRICS_GAUGES = { # CPU metrics "node_cpu_utilization": Gauge( "node_cpu_utilization", "Total CPU usage on a ray node", "percentage", NODE_TAG_KEYS, ), "node_cpu_count": Gauge( "node_cpu_count", "Total CPUs available on a ray node", "cores", NODE_TAG_KEYS, ), # Memory metrics "node_mem_used": Gauge( "node_mem_used", "Memory usage on a ray node", "bytes", NODE_TAG_KEYS, ), "node_mem_available": Gauge( "node_mem_available", "Memory available on a ray node", "bytes", NODE_TAG_KEYS, ), "node_mem_total": Gauge( "node_mem_total", "Total memory on a ray node", "bytes", NODE_TAG_KEYS, ), "node_mem_shared_bytes": Gauge( "node_mem_shared_bytes", "Total shared memory usage on a ray node", "bytes", NODE_TAG_KEYS, ), "node_mem_used_host": Gauge( "node_mem_used_host", "Host memory usage on a ray node", "bytes", NODE_TAG_KEYS, ), "node_mem_total_host": Gauge( "node_mem_total_host", "Total host memory on a ray node", "bytes", NODE_TAG_KEYS, ), "node_cgroup_mem_used": Gauge( "node_cgroup_mem_used", "Container memory usage on a ray node", "bytes", NODE_TAG_KEYS, ), "node_cgroup_mem_total": Gauge( "node_cgroup_mem_total", "Container memory limit on a ray node", "bytes", NODE_TAG_KEYS, ), # GPU metrics "node_gpus_available": Gauge( "node_gpus_available", "Total GPUs available on a ray node", "percentage", GPU_TAG_KEYS, ), "node_gpus_utilization": Gauge( "node_gpus_utilization", "Total GPUs usage on a ray node", "percentage", GPU_TAG_KEYS, ), "node_gram_used": Gauge( "node_gram_used", "Total GPU RAM usage on a ray node", "bytes", GPU_TAG_KEYS, ), "node_gram_available": Gauge( "node_gram_available", "Total GPU RAM available on a ray node", "bytes", GPU_TAG_KEYS, ), "node_gpu_power_milliwatts": Gauge( "node_gpu_power_milliwatts", "Current GPU power draw in milliwatts", "milliwatts", GPU_TAG_KEYS, ), "node_gpu_temperature_celsius": Gauge( "node_gpu_temperature_celsius", "Current GPU temperature in Celsius", "celsius", GPU_TAG_KEYS, ), # TPU metrics "tpu_tensorcore_utilization": Gauge( "tpu_tensorcore_utilization", "Percentage TPU tensorcore utilization on a ray node, value should be between 0 and 100", "percentage", TPU_TAG_KEYS, ), "tpu_memory_bandwidth_utilization": Gauge( "tpu_memory_bandwidth_utilization", "Percentage TPU memory bandwidth utilization on a ray node, value should be between 0 and 100", "percentage", TPU_TAG_KEYS, ), "tpu_duty_cycle": Gauge( "tpu_duty_cycle", "Percentage of time during which the TPU was actively processing, value should be between 0 and 100", "percentage", TPU_TAG_KEYS, ), "tpu_memory_used": Gauge( "tpu_memory_used", "Total memory used by the accelerator in bytes", "bytes", TPU_TAG_KEYS, ), "tpu_memory_total": Gauge( "tpu_memory_total", "Total memory allocatable by the accelerator in bytes", "bytes", TPU_TAG_KEYS, ), # Disk I/O metrics "node_disk_io_read": Gauge( "node_disk_io_read", "Total read from disk", "bytes", NODE_TAG_KEYS, ), "node_disk_io_write": Gauge( "node_disk_io_write", "Total written to disk", "bytes", NODE_TAG_KEYS, ), "node_disk_io_read_count": Gauge( "node_disk_io_read_count", "Total read ops from disk", "io", NODE_TAG_KEYS, ), "node_disk_io_write_count": Gauge( "node_disk_io_write_count", "Total write ops to disk", "io", NODE_TAG_KEYS, ), "node_disk_io_read_speed": Gauge( "node_disk_io_read_speed", "Disk read speed", "bytes/sec", NODE_TAG_KEYS, ), "node_disk_io_write_speed": Gauge( "node_disk_io_write_speed", "Disk write speed", "bytes/sec", NODE_TAG_KEYS, ), "node_disk_read_iops": Gauge( "node_disk_read_iops", "Disk read iops", "iops", NODE_TAG_KEYS, ), "node_disk_write_iops": Gauge( "node_disk_write_iops", "Disk write iops", "iops", NODE_TAG_KEYS, ), # Disk usage metrics "node_disk_usage": Gauge( "node_disk_usage", "Total disk usage (bytes) on a ray node", "bytes", NODE_TAG_KEYS, ), "node_disk_free": Gauge( "node_disk_free", "Total disk free (bytes) on a ray node", "bytes", NODE_TAG_KEYS, ), "node_disk_utilization_percentage": Gauge( "node_disk_utilization_percentage", "Total disk utilization (percentage) on a ray node", "percentage", NODE_TAG_KEYS, ), # Network metrics "node_network_sent": Gauge( "node_network_sent", "Total network sent", "bytes", NODE_TAG_KEYS, ), "node_network_received": Gauge( "node_network_received", "Total network received", "bytes", NODE_TAG_KEYS, ), "node_network_send_speed": Gauge( "node_network_send_speed", "Network send speed", "bytes/sec", NODE_TAG_KEYS, ), "node_network_receive_speed": Gauge( "node_network_receive_speed", "Network receive speed", "bytes/sec", NODE_TAG_KEYS, ), # Component metrics "component_cpu_percentage": Gauge( "component_cpu_percentage", "Total CPU usage of the components on a node.", "percentage", COMPONENT_METRICS_TAG_KEYS, ), "component_shared_bytes": Gauge( "component_shared_bytes", "SHM usage of all components of the node. " "It is equivalent to the top command's SHR column.", "bytes", COMPONENT_METRICS_TAG_KEYS, ), "component_rss_mb": Gauge( "component_rss_mb", "RSS usage of all components on the node.", "MB", COMPONENT_METRICS_TAG_KEYS, ), "component_rss_bytes": Gauge( "component_rss_bytes", "RSS usage of all components on the node.", "bytes", COMPONENT_METRICS_TAG_KEYS, ), "component_uss_mb": Gauge( "component_uss_mb", "USS usage of all components on the node.", "MB", COMPONENT_METRICS_TAG_KEYS, ), "component_uss_bytes": Gauge( "component_uss_bytes", "USS usage of all components on the node.", "bytes", COMPONENT_METRICS_TAG_KEYS, ), "component_num_fds": Gauge( "component_num_fds", "Number of open fds of all components on the node (Not available on Windows).", "count", COMPONENT_METRICS_TAG_KEYS, ), # Cluster metrics "cluster_active_nodes": Gauge( "cluster_active_nodes", "Active nodes on the cluster", "count", CLUSTER_TAG_KEYS, ), # cluster_idle_nodes is only available for v2 autoscaler "cluster_idle_nodes": Gauge( "cluster_idle_nodes", "Idle nodes on the cluster", "count", CLUSTER_TAG_KEYS, ), "cluster_failed_nodes": Gauge( "cluster_failed_nodes", "Failed nodes on the cluster", "count", CLUSTER_TAG_KEYS, ), "cluster_pending_nodes": Gauge( "cluster_pending_nodes", "Pending nodes on the cluster", "count", CLUSTER_TAG_KEYS, ), "component_gpu_percentage": Gauge( "component_gpu_percentage", "GPU usage of all components on the node.", "percentage", COMPONENT_GPU_TAG_KEYS, ), "component_gpu_memory_mb": Gauge( "component_gpu_memory_mb", "GPU memory usage of all components on the node.", "MB", COMPONENT_GPU_TAG_KEYS, ), } PSUTIL_PROCESS_ATTRS = ( [ "pid", "create_time", "cpu_percent", "cpu_times", "cmdline", "memory_info", ] + (["num_fds"] if sys.platform != "win32" else []) # Only collect memory_full_info in Mac OS X + (["memory_full_info"] if sys.platform == "darwin" else []) ) class ReporterAgent( dashboard_utils.DashboardAgentModule, reporter_pb2_grpc.ReporterServiceServicer, metrics_service_pb2_grpc.MetricsServiceServicer, ): """A monitor process for monitoring Ray nodes. Attributes: dashboard_agent: The DashboardAgent object contains global config raylet_client: The RayletClient object to access raylet server """ def __init__(self, dashboard_agent, raylet_client=None): """Initialize the reporter object.""" super().__init__(dashboard_agent) if IN_KUBERNETES_POD or IN_CONTAINER: # psutil does not give a meaningful logical cpu count when in a K8s pod, or # in a container in general. # Use ray._private.utils for this instead. logical_cpu_count = utils.get_num_cpus(override_docker_cpu_warning=True) # (Override the docker warning to avoid dashboard log spam.) # The dashboard expects a physical CPU count as well. # This is not always meaningful in a container, but we will go ahead # and give the dashboard what it wants using psutil. physical_cpu_count = psutil.cpu_count(logical=False) else: logical_cpu_count = psutil.cpu_count() physical_cpu_count = psutil.cpu_count(logical=False) self._cpu_counts = (logical_cpu_count, physical_cpu_count) self._gcs_client = dashboard_agent.gcs_client self._ip = dashboard_agent.ip self._log_dir = dashboard_agent.log_dir self._is_head_node = dashboard_agent.is_head self._hostname = socket.gethostname() # (pid, created_time) -> psutil.Process self._workers = {} # psutil.Process of the parent. self._raylet_proc = None # psutil.Process of the current process. self._agent_proc = None # The last reported worker proc names (e.g., ray::*). self._latest_worker_proc_names = set() self._latest_gpu_worker_proc_names = set() self._network_stats_hist = [(0, (0.0, 0.0))] # time, (sent, recv) self._disk_io_stats_hist = [ (0, (0.0, 0.0, 0, 0)) ] # time, (bytes read, bytes written, read ops, write ops) self._metrics_collection_disabled = dashboard_agent.metrics_collection_disabled self._metrics_agent = None self._open_telemetry_metric_recorder = None self._session_name = dashboard_agent.session_name if not self._metrics_collection_disabled: stats_exporter = prometheus_exporter.new_stats_exporter( prometheus_exporter.Options( namespace="ray", port=dashboard_agent.metrics_export_port, address=get_localhost_ip() if is_localhost(self._ip) else "", ) ) dashboard_agent.metrics_export_port = stats_exporter.port self._metrics_agent = MetricsAgent( stats_module.stats.view_manager, stats_module.stats.stats_recorder, stats_exporter, ) self._open_telemetry_metric_recorder = OpenTelemetryMetricRecorder() if self._metrics_agent.proxy_exporter_collector: # proxy_exporter_collector is None # if Prometheus server is not started. REGISTRY.register(self._metrics_agent.proxy_exporter_collector) # Metrics collection is disabled, write -1 to indicate the port is not in use. persist_port( dashboard_agent.session_dir, self._dashboard_agent.node_id, METRICS_EXPORT_PORT_NAME, ( dashboard_agent.metrics_export_port if not self._metrics_collection_disabled else -1 ), ) self._key = ( f"{reporter_consts.REPORTER_PREFIX}" f"{self._dashboard_agent.node_id}" ) self._executor = ThreadPoolExecutor( max_workers=RAY_DASHBOARD_REPORTER_AGENT_TPE_MAX_WORKERS, thread_name_prefix="reporter_agent_executor", ) self._gcs_pid = None self._gcs_proc = None self._gpu_profiling_manager = GpuProfilingManager( profile_dir_path=self._log_dir, ip_address=self._ip ) self._gpu_profiling_manager.start_monitoring_daemon() self._jax_profiling_manager = JaxProfilingManager( profile_dir_path=self._log_dir ) # Create GPU metric provider instance self._gpu_metric_provider = GpuMetricProvider() if raylet_client: self._raylet_client = raylet_client else: self._raylet_client = RayletClient( ip_address=self._ip, port=self._dashboard_agent.node_manager_port ) async def GetTraceback(self, request, context): pid = request.pid native = request.native subprocesses = request.subprocesses p = CpuProfilingManager(self._log_dir) success, output = await p.trace_dump( pid, native=native, subprocesses=subprocesses ) return reporter_pb2.GetTracebackReply(output=output, success=success) async def CpuProfiling(self, request, context): pid = request.pid duration = request.duration format = request.format native = request.native idle = request.idle subprocesses = request.subprocesses p = CpuProfilingManager(self._log_dir) success, output = await p.cpu_profile( pid, format=format, duration=duration, native=native, idle=idle, subprocesses=subprocesses, ) return reporter_pb2.CpuProfilingReply(output=output, success=success) async def GpuProfiling(self, request, context): pid = request.pid num_iterations = request.num_iterations success, output = await self._gpu_profiling_manager.gpu_profile( pid=pid, num_iterations=num_iterations ) return reporter_pb2.GpuProfilingReply(success=success, output=output) async def JaxProfiling(self, request, context): pid = request.pid port = request.port duration = request.duration if request.HasField("duration") else 5 success, output = await self._jax_profiling_manager.jax_profile( pid=pid, port=port, duration_s=duration ) return reporter_pb2.JaxProfilingReply(success=success, output=output) async def MemoryProfiling(self, request, context): pid = request.pid format = request.format leaks = request.leaks duration = request.duration native = request.native trace_python_allocators = request.trace_python_allocators p = MemoryProfilingManager(self._log_dir) success, profiler_filename, output = await p.attach_profiler( pid, native=native, trace_python_allocators=trace_python_allocators ) if not success: return reporter_pb2.MemoryProfilingReply(output=output, success=success) # add 1 second sleep for memray overhead await asyncio.sleep(duration + 1) success, output = await p.detach_profiler(pid) warning = None if success else output success, output = await p.get_profile_result( pid, profiler_filename=profiler_filename, format=format, leaks=leaks ) return reporter_pb2.MemoryProfilingReply( output=output, success=success, warning=warning ) async def HealthCheck( self, _request: reporter_pb2.HealthCheckRequest, _context: ServicerContext, ) -> reporter_pb2.HealthCheckReply: """This is a health check endpoint for the reporter agent. It is used to check if the reporter agent is ready to receive requests. """ return reporter_pb2.HealthCheckReply() async def ReportOCMetrics(self, request, context): # Do nothing if metrics collection is disabled. if self._metrics_collection_disabled: return reporter_pb2.ReportOCMetricsReply() # This function receives a GRPC containing OpenCensus (OC) metrics # from a Ray process, then exposes those metrics to Prometheus. try: worker_id = WorkerID(request.worker_id) worker_id = None if worker_id.is_nil() else worker_id.hex() self._metrics_agent.proxy_export_metrics(request.metrics, worker_id) except Exception: logger.error(traceback.format_exc()) return reporter_pb2.ReportOCMetricsReply() def _export_histogram_data( self, metric: Metric, ) -> None: """ TODO(can-anyscale): once we launch the new open-telemetry stack, we need to document and communicate that the histogram metric is an approximation to users. The approximation is good enough for the dashboard to display the histogram distribution. Only the sum of all data points will be the approximation. See https://github.com/ray-project/ray/issues/54538 for the complete backlog of Ray metric infra improvements. Export histogram data points to OpenTelemetry Metric Recorder. A histogram metric is aggregated into several internal representations in C++ side: - sum of all buckets - count of all buckets - count per bucket We reconstruct the histogram data points from these internal representations and export them to OpenTelemetry Metric Recorder. The reconstruction is an approximation, but it is good enough for the dashboard to display the histogram data points. """ data_points = metric.histogram.data_points if not data_points: return self._open_telemetry_metric_recorder.register_histogram_metric( metric.name, metric.description, data_points[0].explicit_bounds, ) # Collect all data points and record using a single call batch_data_points = [] for data_point in data_points: if data_point.count == 0: continue tags = {tag.key: tag.value.string_value for tag in data_point.attributes} batch_data_points.append( { "tags": tags, "bucket_counts": list(data_point.bucket_counts), } ) if batch_data_points: # Keep a single label schema for each histogram batch before # recording the reconstructed data points. all_keys = sorted({k for dp in batch_data_points for k in dp["tags"]}) for dp in batch_data_points: tags = dp["tags"] dp["tags"] = {k: tags.get(k, "") for k in all_keys} self._open_telemetry_metric_recorder.record_histogram_aggregated_batch( metric.name, batch_data_points, ) def _export_number_data( self, metric: Metric, ) -> None: data_points = [] if metric.WhichOneof("data") == "gauge": self._open_telemetry_metric_recorder.register_gauge_metric( metric.name, metric.description, ) data_points = metric.gauge.data_points if metric.WhichOneof("data") == "sum": if metric.sum.is_monotonic: self._open_telemetry_metric_recorder.register_counter_metric( metric.name, metric.description, ) else: self._open_telemetry_metric_recorder.register_sum_metric( metric.name, metric.description, ) data_points = metric.sum.data_points for data_point in data_points: self._open_telemetry_metric_recorder.set_metric_value( metric.name, {tag.key: tag.value.string_value for tag in data_point.attributes}, # Note that all data points received from other Ray components are # always double values. This is because the c++ apis # (open_telemetry_metric_recorder.cc) only create metrics with double # values. data_point.as_double, ) async def Export( self, request: metrics_service_pb2.ExportMetricsServiceRequest, context: ServicerContext, ) -> metrics_service_pb2.ExportMetricsServiceResponse: """ GRPC method that receives the open telemetry metrics exported from other Ray components running in the same node (e.g., raylet, worker, etc.). This method implements an interface of `metrics_service_pb2_grpc.MetricsServiceServicer` (https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/metrics/v1/metrics_service.proto#L30), which is the default open-telemetry metrics service interface. """ for resource_metrics in request.resource_metrics: for scope_metrics in resource_metrics.scope_metrics: for metric in scope_metrics.metrics: if metric.WhichOneof("data") == "histogram": self._export_histogram_data(metric) else: self._export_number_data(metric) return metrics_service_pb2.ExportMetricsServiceResponse() @staticmethod def _get_cpu_percent(in_k8s: bool): if in_k8s: return k8s_utils.cpu_percent() else: return psutil.cpu_percent() def _get_gpu_usage(self): """Get GPU usage information using the GPU metric provider.""" return self._gpu_metric_provider.get_gpu_usage() @staticmethod def _get_tpu_usage() -> List[TpuUtilizationInfo]: global enable_tpu_usage_check if not enable_tpu_usage_check: return [] if not TPU_DEVICE_PLUGIN_ADDR: enable_tpu_usage_check = False return [] endpoint = f"http://{TPU_DEVICE_PLUGIN_ADDR}/metrics" try: metrics = requests.get(endpoint).content metrics = metrics.decode("utf-8") except Exception as e: logger.debug( f"Failed to retrieve TPU information from device plugin: {endpoint} {e}" ) enable_tpu_usage_check = False return [] tpu_utilizations = [] # TPU metrics have a quirk where tensor core and memory bandwidth # metrics are host metrics and indexed by chip 0 to N-1, while the # other metrics are runtime metrics and are indexed globally in the # slice. # To make these useful in the dashboard, we want to re-canonicalize the # global indices onto the local host indices. # We partition the metrics into two groups to perform this re-indexing. tpu_utilizations_host = [] tpu_utilizations_other = [] # See https://cloud.google.com/monitoring/api/metrics_gcp#gcp-tpu for # schema. # Sample should look like: # Name: tensorcore_utilization_node Labels: {'accelerator_id': '4804690994094478883-0', 'make': 'cloud-tpu', 'model': 'tpu-v6e-slice', 'tpu_topology': '2x4'} Value: 0.0 try: for family in text_string_to_metric_families(metrics): for sample in family.samples: # Skip irrelevant metrics if not hasattr(sample, "labels"): continue if "accelerator_id" not in sample.labels: continue labels = sample.labels accelerator_id = labels["accelerator_id"] index = int(accelerator_id.split("-")[1]) info = TpuUtilizationInfo( index=index, name=accelerator_id, tpu_type=labels["model"], tpu_topology=labels["tpu_topology"], tensorcore_utilization=0.0, hbm_utilization=0.0, duty_cycle=0.0, memory_used=0, memory_total=0, ) known = True is_host_metric = False match sample.name: case "memory_bandwidth_utilization": info["hbm_utilization"] = sample.value is_host_metric = True case "tensorcore_utilization": info["tensorcore_utilization"] = sample.value is_host_metric = True case "duty_cycle": info["duty_cycle"] = sample.value case "memory_used": info["memory_used"] = sample.value case "memory_total": info["memory_total"] = sample.value case _: known = False if known: if is_host_metric: tpu_utilizations_host.append(info) else: tpu_utilizations_other.append(info) except Exception as e: logger.debug(f"Failed to parse metrics from device plugin: {metrics} {e}") return [] desired_indices = sorted({i["index"] for i in tpu_utilizations_host}) rewrite_indices = sorted({i["index"] for i in tpu_utilizations_other}) # Some TPU types do not have runtime metrics reported from the device # plugin and the rewrite_indices list will be empty. if len(rewrite_indices) > 0: if len(rewrite_indices) != len(desired_indices): logger.warning( f"Failed to parse metrics from device plugin: two sets of metrics for different chip counts, {len(desired_indices)} vs {len(rewrite_indices)}" ) return [] index_map = dict(zip(rewrite_indices, desired_indices)) for info in tpu_utilizations_other: info["index"] = index_map[info["index"]] tpu_utilizations = tpu_utilizations_host + tpu_utilizations_other # Each collected sample records only one metric (e.g. duty cycle) during # the metric interval for one TPU. So here we need to aggregate the # sample records together. The aggregated list should be indexed by the # TPU accelerator index. merged_tpu_utilizations = {} for info in tpu_utilizations: index = int(info.get("index")) if index in merged_tpu_utilizations: merged_info = merged_tpu_utilizations[index] merged_info["tensorcore_utilization"] += info.get( "tensorcore_utilization" ) merged_info["hbm_utilization"] += info.get("hbm_utilization") merged_info["duty_cycle"] += info.get("duty_cycle") merged_info["memory_used"] += info.get("memory_used") merged_info["memory_total"] += info.get("memory_total") else: merged_info = TpuUtilizationInfo( index=info.get("index"), name=info.get("name"), tpu_type=info.get("tpu_type"), tpu_topology=info.get("tpu_topology"), tensorcore_utilization=info.get("tensorcore_utilization"), hbm_utilization=info.get("hbm_utilization"), duty_cycle=info.get("duty_cycle"), memory_used=info.get("memory_used"), memory_total=info.get("memory_total"), ) merged_tpu_utilizations[index] = merged_info sorted_tpu_utilizations = [ value for _, value in sorted(merged_tpu_utilizations.items()) ] return sorted_tpu_utilizations @staticmethod def _get_boot_time(): if IN_KUBERNETES_POD: # Return start time of container entrypoint return psutil.Process(pid=1).create_time() else: return psutil.boot_time() @staticmethod def _get_network_stats(): ifaces = [ v for k, v in psutil.net_io_counters(pernic=True).items() if k[0] == "e" ] sent = sum((iface.bytes_sent for iface in ifaces)) recv = sum((iface.bytes_recv for iface in ifaces)) return sent, recv @staticmethod def _get_mem_usage(): total = get_system_memory() used = utils.get_used_memory() available = total - used percent = round(used / total, 3) * 100 return total, available, percent, used @staticmethod def _get_host_mem_usage(): vmem = psutil.virtual_memory() return vmem.used, vmem.total @staticmethod def _get_disk_usage(temp_dir: str): if IN_KUBERNETES_POD and not ENABLE_K8S_DISK_USAGE: # If in a K8s pod, disable disk display by passing in dummy values. sdiskusage = namedtuple("sdiskusage", ["total", "used", "free", "percent"]) return {"/": sdiskusage(total=1, used=0, free=1, percent=0.0)} if sys.platform == "win32": root = psutil.disk_partitions()[0].mountpoint else: root = os.sep return { "/": psutil.disk_usage(root), temp_dir: psutil.disk_usage(temp_dir), } @staticmethod def _get_disk_io_stats(): stats = psutil.disk_io_counters() # stats can be None or {} if the machine is diskless. # https://psutil.readthedocs.io/en/latest/#psutil.disk_io_counters if not stats: return (0, 0, 0, 0) else: return ( stats.read_bytes, stats.write_bytes, stats.read_count, stats.write_count, ) async def _async_get_worker_pids_from_raylet(self) -> List[int]: try: # Get worker pids from raylet via gRPC. return await self._raylet_client.async_get_worker_pids() except (GetTimeoutError, RpcError): logger.exception("Failed to get worker pids from raylet") return [] async def _async_get_agent_pids_from_raylet(self) -> List[int]: try: # Get agents pids from raylet via gRPC. return await self._raylet_client.async_get_agent_pids() except (GetTimeoutError, RpcError): logger.exception("Failed to get agents pids from raylet") return [] def _get_agent_proc(self) -> psutil.Process: # Agent is the current process. # This method is not necessary, but we have it for mock testing. return psutil.Process() def _generate_proc_key(self, proc: psutil.Process) -> Tuple[int, float]: return (proc.pid, proc.create_time()) async def _async_get_worker_processes(self): pids = await self._async_get_worker_pids_from_raylet() logger.debug(f"Worker PIDs from raylet: {pids}") if not pids: return {} workers = {} for pid in pids: try: proc = psutil.Process(pid) workers[self._generate_proc_key(proc)] = proc except (psutil.NoSuchProcess, psutil.AccessDenied): logger.error(f"Failed to access worker process {pid}") continue return workers async def _async_get_agent_processes(self): pids = await self._async_get_agent_pids_from_raylet() logger.debug(f"Agent PIDs from raylet: {pids}") if not pids: return {} agents = {} for pid in pids: try: proc = psutil.Process(pid) agents[self._generate_proc_key(proc)] = proc except (psutil.NoSuchProcess, psutil.AccessDenied): logger.error(f"Failed to access agent process {pid}") continue return agents async def _async_get_workers_and_agents( self, gpus: Optional[List[GpuUtilizationInfo]] = None ): workers, agents = await asyncio.gather( self._async_get_worker_processes(), self._async_get_agent_processes() ) workers.update(agents) if not workers: return [] else: # We should keep `raylet_proc.children()` in `self` because # when `cpu_percent` is first called, it returns the meaningless 0. # See more: https://github.com/ray-project/ray/issues/29848 keys_to_pop = [] # Add all new workers. for key, worker in workers.items(): if key not in self._workers: self._workers[key] = worker # Pop out stale workers. for key in self._workers: if key not in workers: keys_to_pop.append(key) for k in keys_to_pop: self._workers.pop(k) # Build process ID -> GPU info mapping for faster lookups gpu_pid_mapping = defaultdict(list) if gpus is not None: for gpu in gpus: processes = gpu.get("processes_pids") if processes: for proc in processes.values(): gpu_pid_mapping[proc["pid"]].append(proc) result = [] for w in self._workers.values(): try: if w.status() == psutil.STATUS_ZOMBIE: continue # Get basic process info worker_info = w.as_dict(attrs=PSUTIL_PROCESS_ATTRS) # Add GPU information if available worker_pid = worker_info["pid"] gpu_memory_usage = 0 gpu_utilization = 0 if worker_pid in gpu_pid_mapping: # Aggregate GPU memory and utilization across all GPUs for this process for gpu_proc in gpu_pid_mapping[worker_pid]: gpu_memory_usage += gpu_proc["gpu_memory_usage"] utilization = gpu_proc["gpu_utilization"] or 0 gpu_utilization += utilization # Add GPU information to worker info worker_info["gpu_memory_usage"] = gpu_memory_usage # in MB worker_info["gpu_utilization"] = gpu_utilization # percentage result.append(worker_info) except psutil.NoSuchProcess: # the process may have terminated due to race condition. continue return result def _get_raylet_proc(self): try: if not self._raylet_proc: curr_proc = psutil.Process() # The dashboard agent is a child of the raylet process. # It is not necessarily the direct child (python-windows # typically uses a py.exe runner to run python), so search # up for a process named 'raylet' candidate = curr_proc.parent() while candidate: if "raylet" in candidate.name(): break candidate = candidate.parent() self._raylet_proc = candidate if self._raylet_proc is not None: if self._raylet_proc.pid == 1: return None if self._raylet_proc.status() == psutil.STATUS_ZOMBIE: return None return self._raylet_proc except (psutil.AccessDenied, ProcessLookupError): pass return None def _get_gcs(self): if self._gcs_pid: if not self._gcs_proc or self._gcs_pid != self._gcs_proc.pid: self._gcs_proc = psutil.Process(self._gcs_pid) if self._gcs_proc: dictionary = self._gcs_proc.as_dict(attrs=PSUTIL_PROCESS_ATTRS) return dictionary return {} def _get_raylet(self): raylet_proc = self._get_raylet_proc() if raylet_proc is None: return None else: return raylet_proc.as_dict(attrs=PSUTIL_PROCESS_ATTRS) def _get_agent(self): # Current proc == agent proc if not self._agent_proc: self._agent_proc = psutil.Process() return self._agent_proc.as_dict(attrs=PSUTIL_PROCESS_ATTRS) def _get_load_avg(self): if sys.platform == "win32": cpu_percent = psutil.cpu_percent() load = (cpu_percent, cpu_percent, cpu_percent) else: load = os.getloadavg() if self._cpu_counts[0] > 0: per_cpu_load = tuple((round(x / self._cpu_counts[0], 2) for x in load)) else: per_cpu_load = None return load, per_cpu_load @staticmethod def _compute_speed_from_hist(hist): while len(hist) > 7: hist.pop(0) then, prev_stats = hist[0] now, now_stats = hist[-1] time_delta = now - then return tuple((y - x) / time_delta for x, y in zip(prev_stats, now_stats)) def _get_shm_usage(self): """Return the shm usage. If shm doesn't exist (e.g., MacOS), it returns None. """ mem = psutil.virtual_memory() if not hasattr(mem, "shared"): return None return mem.shared async def _async_collect_stats(self): now = dashboard_utils.to_posix_time(datetime.datetime.utcnow()) network_stats = self._get_network_stats() self._network_stats_hist.append((now, network_stats)) network_speed_stats = self._compute_speed_from_hist(self._network_stats_hist) disk_stats = self._get_disk_io_stats() self._disk_io_stats_hist.append((now, disk_stats)) disk_speed_stats = self._compute_speed_from_hist(self._disk_io_stats_hist) gpus = self._get_gpu_usage() raylet = self._get_raylet() stats = { "now": now, "hostname": self._hostname, "ip": self._ip, "cpu": self._get_cpu_percent(IN_KUBERNETES_POD), "cpus": self._cpu_counts, "mem": self._get_mem_usage(), # Unit is in bytes. None if "shm": self._get_shm_usage(), "host_mem": self._get_host_mem_usage(), "cgroup_mem": utils.get_cgroup_mem_stats(), "workers": await self._async_get_workers_and_agents(gpus), "raylet": raylet, "agent": self._get_agent(), "bootTime": self._get_boot_time(), "loadAvg": self._get_load_avg(), "disk": self._get_disk_usage(self._dashboard_agent.temp_dir), "disk_io": disk_stats, "disk_io_speed": disk_speed_stats, "gpus": gpus, "tpus": self._get_tpu_usage(), "network": network_stats, "network_speed": network_speed_stats, # Deprecated field, should be removed with frontend. "cmdline": raylet.get("cmdline", []) if raylet else [], } if self._is_head_node: stats["gcs"] = self._get_gcs() return stats def _generate_reseted_stats_record(self, component_name: str) -> List[Record]: """Return a list of Record that will reset the system metrics of a given component name. Args: component_name: a component name for a given stats. Returns: a list of Record instances of all values 0. """ tags = {"ip": self._ip, "Component": component_name} records = [] records.append( Record( gauge=METRICS_GAUGES["component_cpu_percentage"], value=0.0, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_shared_bytes"], value=0.0, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_rss_mb"], value=0.0, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_rss_bytes"], value=0.0, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_uss_mb"], value=0.0, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_uss_bytes"], value=0.0, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_num_fds"], value=0, tags=tags, ) ) return records def _generate_system_stats_record( self, stats: List[dict], component_name: str, pid: Optional[str] = None ) -> List[Record]: """Generate a list of Record class from a given component names. Args: stats: a list of stats dict generated by `psutil.as_dict`. If empty, it will create the metrics of a given "component_name" which has all 0 values. component_name: a component name for a given stats. pid: optionally provided pids. Returns: a list of Record class that will be exposed to Prometheus. """ total_cpu_percentage = 0.0 total_gpu_percentage = 0.0 total_gpu_memory = 0.0 total_rss_bytes = 0.0 total_uss_bytes = 0.0 total_shm_bytes = 0.0 total_num_fds = 0 for stat in stats: total_cpu_percentage += float(stat.get("cpu_percent", 0.0)) # noqa # Aggregate GPU stats if available total_gpu_percentage += float(stat.get("gpu_utilization", 0.0)) total_gpu_memory += float(stat.get("gpu_memory_usage", 0.0)) memory_info = stat.get("memory_info") if memory_info: total_rss_bytes += float(memory_info.rss) if hasattr(memory_info, "shared"): total_shm_bytes += float(memory_info.shared) mem_full_info = stat.get("memory_full_info") if mem_full_info is not None: # For Mac OS X, directly get USS metric from memory_full_info total_uss_bytes += float(mem_full_info.uss) elif memory_info is not None: # For linux or windows, memory_full_info is not collected. Approximated USS from memory_info if hasattr(memory_info, "shared"): # Linux: USS ≈ RSS - shared total_uss_bytes += float(memory_info.rss - memory_info.shared) elif hasattr(memory_info, "private"): # Windows: private IS USS total_uss_bytes += float(memory_info.private) total_num_fds += int(stat.get("num_fds", 0)) tags = {"ip": self._ip, "Component": component_name} if pid: tags["pid"] = pid records = [] records.append( Record( gauge=METRICS_GAUGES["component_cpu_percentage"], value=total_cpu_percentage, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_shared_bytes"], value=total_shm_bytes, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_rss_mb"], value=total_rss_bytes / 1.0e6, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_rss_bytes"], value=total_rss_bytes, tags=tags, ) ) if total_uss_bytes > 0.0: records.append( Record( gauge=METRICS_GAUGES["component_uss_mb"], value=total_uss_bytes / 1.0e6, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_uss_bytes"], value=total_uss_bytes, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_num_fds"], value=total_num_fds, tags=tags, ) ) # Add GPU records if there's GPU usage if total_gpu_memory > 0.0: records.append( Record( gauge=METRICS_GAUGES["component_gpu_memory_mb"], value=total_gpu_memory, tags=tags, ) ) if total_gpu_percentage > 0.0: records.append( Record( gauge=METRICS_GAUGES["component_gpu_percentage"], value=total_gpu_percentage, tags=tags, ) ) return records def _generate_reseted_gpu_stats_record(self, component_name: str) -> List[Record]: """Return a list of Record that will reset the GPU metrics of a given component name. Args: component_name: a component name for a given stats. Returns: a list of Record instances of GPU metrics with all values 0. """ tags = {"ip": self._ip, "Component": component_name} records = [] records.append( Record( gauge=METRICS_GAUGES["component_gpu_memory_mb"], value=0.0, tags=tags, ) ) records.append( Record( gauge=METRICS_GAUGES["component_gpu_percentage"], value=0.0, tags=tags, ) ) return records def generate_worker_stats_record(self, worker_stats: List[dict]) -> List[Record]: """Generate a list of Record class for worker processes. This API automatically sets the component_name of record as the name of worker processes. I.e., ray::* so that we can report per task/actor (grouped by a func/class name) resource usages. Args: worker_stats: a list of stats dict generated by `psutil.as_dict` for worker processes. Now with gpu usage information. Returns: A list of Record entries with per-process system and GPU stats, including reset records for processes that no longer exist or no longer report GPU usage. """ # worker cmd name (ray::*) -> stats dict. proc_name_to_stats = defaultdict(list) gpu_worker_proc_names = set() # Track processes with GPU usage for stat in worker_stats: cmdline = stat.get("cmdline") # collect both worker and driver stats if cmdline: proc_name = cmdline[0] proc_name_to_stats[proc_name].append(stat) # Track if this process has GPU usage if ( stat.get("gpu_memory_usage", 0) > 0 or stat.get("gpu_utilization", 0) > 0 ): gpu_worker_proc_names.add(proc_name) records = [] # Generate system stats records (now includes GPU stats) for proc_name, stats in proc_name_to_stats.items(): records.extend(self._generate_system_stats_record(stats, proc_name)) # Reset worker metrics that are from finished processes. new_proc_names = set(proc_name_to_stats.keys()) stale_procs = self._latest_worker_proc_names - new_proc_names self._latest_worker_proc_names = new_proc_names for stale_proc_name in stale_procs: records.extend(self._generate_reseted_stats_record(stale_proc_name)) # Reset GPU metrics for processes that no longer use GPU stale_gpu_worker_proc_names = ( self._latest_gpu_worker_proc_names - gpu_worker_proc_names ) self._latest_gpu_worker_proc_names = gpu_worker_proc_names for stale_gpu_proc in stale_gpu_worker_proc_names: records.extend(self._generate_reseted_gpu_stats_record(stale_gpu_proc)) return records def _to_records(self, stats, cluster_stats) -> List[Record]: records_reported = [] ip = stats["ip"] ray_node_type = "head" if self._is_head_node else "worker" is_head_node = "true" if self._is_head_node else "false" # Common tags for node-level metrics # We use RayNodeType to mark head/worker node, IsHeadNode is retained for backward compatibility node_tags = {"ip": ip, "RayNodeType": ray_node_type, "IsHeadNode": is_head_node} # -- Instance count of cluster -- # Only report cluster stats on head node if "autoscaler_report" in cluster_stats and self._is_head_node: active_nodes = cluster_stats["autoscaler_report"]["active_nodes"] for node_type, active_node_count in active_nodes.items(): records_reported.append( Record( gauge=METRICS_GAUGES["cluster_active_nodes"], value=active_node_count, tags={"node_type": node_type}, ) ) # Emit cluster_idle_nodes only for autoscaler v2 (v1 has no "idle" state). idle_nodes = cluster_stats.get("autoscaler_report", {}).get("idle_nodes") if idle_nodes is not None: for node_type, idle_node_count in idle_nodes.items(): records_reported.append( Record( gauge=METRICS_GAUGES["cluster_idle_nodes"], value=idle_node_count, tags={"node_type": node_type}, ) ) failed_nodes = cluster_stats["autoscaler_report"]["failed_nodes"] failed_nodes_dict = {} for node_ip, node_type in failed_nodes: if node_type in failed_nodes_dict: failed_nodes_dict[node_type] += 1 else: failed_nodes_dict[node_type] = 1 for node_type, failed_node_count in failed_nodes_dict.items(): records_reported.append( Record( gauge=METRICS_GAUGES["cluster_failed_nodes"], value=failed_node_count, tags={"node_type": node_type}, ) ) pending_nodes = cluster_stats["autoscaler_report"]["pending_nodes"] pending_nodes_dict = {} for node_ip, node_type, status_message in pending_nodes: if node_type in pending_nodes_dict: pending_nodes_dict[node_type] += 1 else: pending_nodes_dict[node_type] = 1 for node_type, pending_node_count in pending_nodes_dict.items(): records_reported.append( Record( gauge=METRICS_GAUGES["cluster_pending_nodes"], value=pending_node_count, tags={"node_type": node_type}, ) ) # -- CPU per node -- cpu_usage = float(stats["cpu"]) cpu_record = Record( gauge=METRICS_GAUGES["node_cpu_utilization"], value=cpu_usage, tags=node_tags, ) cpu_count, _ = stats["cpus"] cpu_count_record = Record( gauge=METRICS_GAUGES["node_cpu_count"], value=cpu_count, tags=node_tags ) # -- Mem per node -- mem_total, mem_available, _, mem_used = stats["mem"] mem_used_record = Record( gauge=METRICS_GAUGES["node_mem_used"], value=mem_used, tags=node_tags ) mem_available_record = Record( gauge=METRICS_GAUGES["node_mem_available"], value=mem_available, tags=node_tags, ) mem_total_record = Record( gauge=METRICS_GAUGES["node_mem_total"], value=mem_total, tags=node_tags ) shm_used = stats["shm"] if shm_used: node_mem_shared = Record( gauge=METRICS_GAUGES["node_mem_shared_bytes"], value=shm_used, tags=node_tags, ) records_reported.append(node_mem_shared) host_mem_used, host_mem_total = stats["host_mem"] records_reported.extend( [ Record( gauge=METRICS_GAUGES["node_mem_used_host"], value=host_mem_used, tags=node_tags, ), Record( gauge=METRICS_GAUGES["node_mem_total_host"], value=host_mem_total, tags=node_tags, ), ] ) cgroup_stats = stats["cgroup_mem"] if cgroup_stats is not None: cgroup_used, cgroup_total = cgroup_stats records_reported.extend( [ Record( gauge=METRICS_GAUGES["node_cgroup_mem_used"], value=cgroup_used, tags=node_tags, ), Record( gauge=METRICS_GAUGES["node_cgroup_mem_total"], value=cgroup_total, tags=node_tags, ), ] ) # The output example of GpuUtilizationInfo. """ {'index': 0, 'uuid': 'GPU-36e1567d-37ed-051e-f8ff-df807517b396', 'name': 'NVIDIA A10G', 'utilization_gpu': 1, 'memory_used': 0, 'memory_total': 22731} """ # -- GPU per node -- gpus = stats["gpus"] gpus_available = len(gpus) if gpus_available: for gpu in gpus: gpus_utilization, gram_used, gram_total = 0, 0, 0 # Consume GPU may not report its utilization. if gpu["utilization_gpu"] is not None: gpus_utilization += gpu["utilization_gpu"] gram_used += gpu["memory_used"] gram_total += gpu["memory_total"] gpu_index = gpu.get("index") gpu_name = gpu.get("name") gpu_power_mw = gpu.get("power_mw") gpu_temperature_c = gpu.get("temperature_c") gram_available = gram_total - gram_used if gpu_index is not None: gpu_tags = {**node_tags, "GpuIndex": str(gpu_index)} if gpu_name: gpu_tags["GpuDeviceName"] = gpu_name # There's only 1 GPU per each index, so we record 1 here. gpus_available_record = Record( gauge=METRICS_GAUGES["node_gpus_available"], value=1, tags=gpu_tags, ) gpus_utilization_record = Record( gauge=METRICS_GAUGES["node_gpus_utilization"], value=gpus_utilization, tags=gpu_tags, ) gram_used_record = Record( gauge=METRICS_GAUGES["node_gram_used"], value=gram_used, tags=gpu_tags, ) gram_available_record = Record( gauge=METRICS_GAUGES["node_gram_available"], value=gram_available, tags=gpu_tags, ) gpu_records_to_add = [ gpus_available_record, gpus_utilization_record, gram_used_record, gram_available_record, ] # Optional GPU power and temperature (e.g. NVIDIA, AMD) if gpu_power_mw is not None: gpu_records_to_add.append( Record( gauge=METRICS_GAUGES["node_gpu_power_milliwatts"], value=gpu_power_mw, tags=gpu_tags, ) ) if gpu_temperature_c is not None: gpu_records_to_add.append( Record( gauge=METRICS_GAUGES["node_gpu_temperature_celsius"], value=gpu_temperature_c, tags=gpu_tags, ) ) records_reported.extend(gpu_records_to_add) # -- TPU per node -- tpus = stats["tpus"] for tpu in tpus: tpu_index = tpu.get("index") tpu_name = tpu.get("name") tpu_type = tpu.get("tpu_type") tpu_topology = tpu.get("tpu_topology") tensorcore_utilization = tpu.get("tensorcore_utilization") hbm_utilization = tpu.get("hbm_utilization") duty_cycle = tpu.get("duty_cycle") memory_used = tpu.get("memory_used") memory_total = tpu.get("memory_total") tpu_tags = { **node_tags, "TpuIndex": str(tpu_index), "TpuDeviceName": tpu_name, "TpuType": tpu_type, "TpuTopology": tpu_topology, } tensorcore_utilization_record = Record( gauge=METRICS_GAUGES["tpu_tensorcore_utilization"], value=tensorcore_utilization, tags=tpu_tags, ) hbm_utilization_record = Record( gauge=METRICS_GAUGES["tpu_memory_bandwidth_utilization"], value=hbm_utilization, tags=tpu_tags, ) duty_cycle_record = Record( gauge=METRICS_GAUGES["tpu_duty_cycle"], value=duty_cycle, tags=tpu_tags, ) memory_used_record = Record( gauge=METRICS_GAUGES["tpu_memory_used"], value=memory_used, tags=tpu_tags, ) memory_total_record = Record( gauge=METRICS_GAUGES["tpu_memory_total"], value=memory_total, tags=tpu_tags, ) records_reported.extend( [ tensorcore_utilization_record, hbm_utilization_record, duty_cycle_record, memory_used_record, memory_total_record, ] ) # -- Disk per node -- disk_io_stats = stats["disk_io"] disk_read_record = Record( gauge=METRICS_GAUGES["node_disk_io_read"], value=disk_io_stats[0], tags=node_tags, ) disk_write_record = Record( gauge=METRICS_GAUGES["node_disk_io_write"], value=disk_io_stats[1], tags=node_tags, ) disk_read_count_record = Record( gauge=METRICS_GAUGES["node_disk_io_read_count"], value=disk_io_stats[2], tags=node_tags, ) disk_write_count_record = Record( gauge=METRICS_GAUGES["node_disk_io_write_count"], value=disk_io_stats[3], tags=node_tags, ) disk_io_speed_stats = stats["disk_io_speed"] disk_read_speed_record = Record( gauge=METRICS_GAUGES["node_disk_io_read_speed"], value=disk_io_speed_stats[0], tags=node_tags, ) disk_write_speed_record = Record( gauge=METRICS_GAUGES["node_disk_io_write_speed"], value=disk_io_speed_stats[1], tags=node_tags, ) disk_read_iops_record = Record( gauge=METRICS_GAUGES["node_disk_read_iops"], value=disk_io_speed_stats[2], tags=node_tags, ) disk_write_iops_record = Record( gauge=METRICS_GAUGES["node_disk_write_iops"], value=disk_io_speed_stats[3], tags=node_tags, ) used = stats["disk"]["/"].used free = stats["disk"]["/"].free disk_utilization = float(used / (used + free)) * 100 disk_usage_record = Record( gauge=METRICS_GAUGES["node_disk_usage"], value=used, tags=node_tags ) disk_free_record = Record( gauge=METRICS_GAUGES["node_disk_free"], value=free, tags=node_tags ) disk_utilization_percentage_record = Record( gauge=METRICS_GAUGES["node_disk_utilization_percentage"], value=disk_utilization, tags=node_tags, ) # -- Network speed (send/receive) stats per node -- network_stats = stats["network"] network_sent_record = Record( gauge=METRICS_GAUGES["node_network_sent"], value=network_stats[0], tags=node_tags, ) network_received_record = Record( gauge=METRICS_GAUGES["node_network_received"], value=network_stats[1], tags=node_tags, ) # -- Network speed (send/receive) per node -- network_speed_stats = stats["network_speed"] network_send_speed_record = Record( gauge=METRICS_GAUGES["node_network_send_speed"], value=network_speed_stats[0], tags=node_tags, ) network_receive_speed_record = Record( gauge=METRICS_GAUGES["node_network_receive_speed"], value=network_speed_stats[1], tags=node_tags, ) """ Record system stats. """ if self._is_head_node: gcs_stats = stats["gcs"] if gcs_stats: records_reported.extend( self._generate_system_stats_record( [gcs_stats], "gcs", pid=str(gcs_stats["pid"]) ) ) # Record component metrics. raylet_stats = stats["raylet"] if raylet_stats: raylet_pid = str(raylet_stats["pid"]) records_reported.extend( self._generate_system_stats_record( [raylet_stats], "raylet", pid=raylet_pid ) ) workers_stats = stats["workers"] records_reported.extend(self.generate_worker_stats_record(workers_stats)) agent_stats = stats["agent"] if agent_stats: agent_pid = str(agent_stats["pid"]) records_reported.extend( self._generate_system_stats_record( [agent_stats], "agent", pid=agent_pid ) ) # NOTE: Dashboard metrics is recorded within the dashboard because # it can be deployed as a standalone instance. It shouldn't # depend on the agent. records_reported.extend( [ cpu_record, cpu_count_record, mem_used_record, mem_available_record, mem_total_record, disk_read_record, disk_write_record, disk_read_count_record, disk_write_count_record, disk_read_speed_record, disk_write_speed_record, disk_read_iops_record, disk_write_iops_record, disk_usage_record, disk_free_record, disk_utilization_percentage_record, network_sent_record, network_received_record, network_send_speed_record, network_receive_speed_record, ] ) return records_reported async def _run_loop(self): """Get any changes to the log files and push updates to kv.""" loop = get_or_create_event_loop() while True: try: # Fetch autoscaler debug status autoscaler_status_json_bytes: Optional[bytes] = None autoscaler_v2_enabled = False if self._is_head_node: # Check autoscaler version once autoscaler_v2_enabled = is_autoscaler_v2( gcs_client=self._gcs_client ) # Autoscaler v1 writes DEBUG_AUTOSCALING_STATUS to the internal KV; v2 does not. if not autoscaler_v2_enabled: autoscaler_status_json_bytes = ( await self._gcs_client.async_internal_kv_get( DEBUG_AUTOSCALING_STATUS.encode(), None, timeout=GCS_RPC_TIMEOUT_SECONDS, ) ) self._gcs_pid = await self._gcs_client.async_internal_kv_get( GCS_PID_KEY.encode(), None, timeout=GCS_RPC_TIMEOUT_SECONDS, ) self._gcs_pid = ( int(self._gcs_pid.decode()) if self._gcs_pid else None ) # NOTE: Stats collection is executed inside the thread-pool # executor (TPE) to avoid blocking the Agent's event-loop json_payload = await loop.run_in_executor( self._executor, self._run_in_executor, autoscaler_status_json_bytes, autoscaler_v2_enabled, ) await self._gcs_client.async_publish_node_resource_usage( self._key, json_payload ) except Exception: logger.exception("Error publishing node physical stats.") await asyncio.sleep(reporter_consts.REPORTER_UPDATE_INTERVAL_MS / 1000) def _run_in_executor( self, cluster_autoscaling_stats_json: Optional[bytes], autoscaler_v2_enabled: bool, ) -> str: return asyncio.run( self._async_compose_stats_payload( cluster_autoscaling_stats_json, autoscaler_v2_enabled, ) ) async def _async_compose_stats_payload( self, cluster_autoscaling_stats_json: Optional[bytes], autoscaler_v2_enabled: bool, ) -> str: stats = await self._async_collect_stats() # Report stats only when metrics collection is enabled. if not self._metrics_collection_disabled: cluster_stats = ( json.loads(cluster_autoscaling_stats_json.decode()) if cluster_autoscaling_stats_json else {} ) # Autoscaler v2 only - get cluster_status from gcs via RPC(get_cluster_status()) if self._is_head_node and autoscaler_v2_enabled: cluster_stats = self._get_cluster_stats_v2() records = self._to_records(stats, cluster_stats) if RAY_ENABLE_OPEN_TELEMETRY: self._open_telemetry_metric_recorder.record_and_export( records, global_tags={ "Version": ray.__version__, "SessionName": self._session_name, }, ) else: self._metrics_agent.record_and_export( records, global_tags={ "Version": ray.__version__, "SessionName": self._session_name, }, ) self._metrics_agent.clean_all_dead_worker_metrics() return self._generate_stats_payload(stats) def _get_cluster_stats_v2(self) -> dict: # Get cluster_status from gcs via RPC(get_cluster_status()) cluster_status = get_cluster_status(self.gcs_address) # Aggregate node counts by ray_node_type_name active_nodes = _count_by(cluster_status.active_nodes, "ray_node_type_name") idle_nodes = _count_by(cluster_status.idle_nodes, "ray_node_type_name") # Keep tuple schemas expected by _to_records() pending_nodes = [ (n.ip_address, n.ray_node_type_name, str(n.details or "PENDING")) for n in cluster_status.pending_nodes ] failed_nodes = [ (n.ip_address, n.ray_node_type_name) for n in cluster_status.failed_nodes ] return { "autoscaler_report": { "active_nodes": dict(active_nodes), "idle_nodes": dict(idle_nodes), "pending_nodes": pending_nodes, "failed_nodes": failed_nodes, }, } def _generate_stats_payload(self, stats: dict) -> str: # Convert processes_pids back to a list of dictionaries to maintain backwards-compatibility for gpu in stats["gpus"]: if isinstance(gpu.get("processes_pids"), dict): gpu["processes_pids"] = list(gpu["processes_pids"].values()) if StatsPayload is not None: stats_dict = dashboard_utils.to_google_style(recursive_asdict(stats)) parsed_stats = StatsPayload.parse_obj(stats_dict) out = json.dumps(parsed_stats.dict()) return out else: # NOTE: This converts keys to "Google style", (e.g: "processes_pids" -> "processesPids") return jsonify_asdict(stats) async def run(self, server): if server: reporter_pb2_grpc.add_ReporterServiceServicer_to_server(self, server) if RAY_ENABLE_OPEN_TELEMETRY: metrics_service_pb2_grpc.add_MetricsServiceServicer_to_server( self, server ) # Initialize GPU metric provider when the agent starts self._gpu_metric_provider.initialize() await self._run_loop() @staticmethod def is_minimal_module(): return False