chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,326 @@
import asyncio
import functools
import logging
import os
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
from ray.dashboard.modules.reporter.profile_manager import (
_format_failed_profiler_command,
)
import psutil
logger = logging.getLogger(__name__)
class GpuProfilingManager:
"""GPU profiling manager for Ray Dashboard.
NOTE: The current implementation is based on the `dynolog` OSS project,
but these are mostly implementation details that can be changed in the future.
`dynolog` needs to be installed on the nodes where profiling is being done.
This only supports Torch training scripts with KINETO_USE_DAEMON=1 set.
It is not supported for other frameworks.
"""
# Port for the monitoring daemon.
# This port was chosen arbitrarily to a value to avoid conflicts.
_DYNOLOG_PORT = 65406
# Default timeout for the profiling operation.
_DEFAULT_TIMEOUT_S = 5 * 60
_NO_PROCESSES_MATCHED_ERROR_MESSAGE_PREFIX = "No processes were matched"
_DISABLED_ERROR_MESSAGE = (
"GPU profiling is not enabled on node {ip_address}. "
"This is the case if no GPUs are detected on the node or if "
"the profiling dependency `dynolog` is not installed on the node.\n"
"Please ensure that GPUs are available on the node and that "
"`dynolog` is installed."
)
_NO_PROCESSES_MATCHED_ERROR_MESSAGE = (
"The profiling command failed for pid={pid} on node {ip_address}. "
"There are a few potential reasons for this:\n"
"1. The `KINETO_USE_DAEMON=1 KINETO_DAEMON_INIT_DELAY_S=5` environment variables "
"are not set for the training worker processes.\n"
"2. The process requested for profiling is not running a "
"PyTorch training script. GPU profiling is only supported for "
"PyTorch training scripts, typically launched via "
"`ray.train.torch.TorchTrainer`."
)
_DEAD_PROCESS_ERROR_MESSAGE = (
"The requested process to profile with pid={pid} on node "
"{ip_address} is no longer running. "
"GPU profiling is not available for this process."
)
def __init__(self, profile_dir_path: str, *, ip_address: str):
# Dump trace files to: /tmp/ray/session_latest/logs/profiles/
self._root_log_dir = Path(profile_dir_path)
self._profile_dir_path = self._root_log_dir / "profiles"
self._daemon_log_file_path = (
self._profile_dir_path / f"dynolog_daemon_{os.getpid()}.log"
)
self._ip_address = ip_address
self._dynolog_bin = shutil.which("dynolog")
self._dyno_bin = shutil.which("dyno")
self._dynolog_daemon_process: Optional[subprocess.Popen] = None
if not self._dynolog_bin or not self._dyno_bin:
logger.warning(
"[GpuProfilingManager] `dynolog` is not installed, GPU profiling will not be available."
)
elif not self.node_has_gpus():
logger.warning(
"[GpuProfilingManager] No GPUs found on this node, GPU profiling will not be setup."
)
self._profile_dir_path.mkdir(parents=True, exist_ok=True)
@property
def enabled(self) -> bool:
return (
self._dynolog_bin is not None
and self._dyno_bin is not None
and self.node_has_gpus()
)
@property
def is_monitoring_daemon_running(self) -> bool:
return (
self._dynolog_daemon_process is not None
and self._dynolog_daemon_process.poll() is None
)
@classmethod
@functools.cache
def node_has_gpus(cls) -> bool:
try:
subprocess.check_output(
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
stderr=subprocess.DEVNULL,
timeout=10,
)
return True
except subprocess.TimeoutExpired:
logger.warning(
"[GpuProfilingManager] `nvidia-smi` command timed out after 10s. "
"GPU profiling may not function correctly."
)
return False
except Exception:
return False
@classmethod
def is_pid_alive(cls, pid: int) -> bool:
try:
return psutil.pid_exists(pid) and psutil.Process(pid).is_running()
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
return False
def start_monitoring_daemon(self):
"""Start the GPU profiling monitoring daemon if it's possible.
This must be called before profiling.
"""
if not self.enabled:
logger.warning(
"[GpuProfilingManager] GPU profiling is disabled, skipping daemon setup."
)
return
if self.is_monitoring_daemon_running:
logger.warning(
"[GpuProfilingManager] GPU profiling monitoring daemon is already running."
)
return
try:
with open(self._daemon_log_file_path, "ab") as log_file:
daemon = subprocess.Popen(
[
self._dynolog_bin,
"--enable_ipc_monitor",
"--port",
str(self._DYNOLOG_PORT),
],
stdout=log_file,
stderr=log_file,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
except (FileNotFoundError, PermissionError, OSError) as e:
logger.error(
f"[GpuProfilingManager] Failed to launch GPU profiling monitoring daemon: {e}\n"
f"Check error log for more details: {self._daemon_log_file_path}"
)
return
logger.info(
"[GpuProfilingManager] Launched GPU profiling monitoring daemon "
f"(pid={daemon.pid}, port={self._DYNOLOG_PORT})\n"
f"Redirecting logs to: {self._daemon_log_file_path}"
)
self._dynolog_daemon_process = daemon
def _get_trace_filename(self) -> str:
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
return f"gputrace_{self._ip_address}_{timestamp}.json"
async def gpu_profile(
self, pid: int, num_iterations: int, _timeout_s: int = _DEFAULT_TIMEOUT_S
) -> Tuple[bool, str]:
"""
Perform GPU profiling on a specified process.
Args:
pid: The process ID (PID) of the target process to be profiled.
num_iterations: The number of iterations to profile.
_timeout_s: Maximum time in seconds to wait for profiling to complete.
This is an advanced parameter that catches edge cases where the
profiling request never completes and hangs indefinitely.
Returns:
Tuple[bool, str]: A tuple containing a boolean indicating the success
of the profiling operation and a string with the
filepath of the trace file relative to the root log directory,
or an error message.
"""
if not self.enabled:
return False, self._DISABLED_ERROR_MESSAGE.format(
ip_address=self._ip_address
)
if not self._dynolog_daemon_process:
raise RuntimeError("Must call `start_monitoring_daemon` before profiling.")
if not self.is_monitoring_daemon_running:
error_msg = (
f"GPU monitoring daemon (pid={self._dynolog_daemon_process.pid}) "
f"is not running on node {self._ip_address}. "
f"See log for more details: {self._daemon_log_file_path}"
)
logger.error(f"[GpuProfilingManager] {error_msg}")
return False, error_msg
if not self.is_pid_alive(pid):
error_msg = self._DEAD_PROCESS_ERROR_MESSAGE.format(
pid=pid, ip_address=self._ip_address
)
logger.error(f"[GpuProfilingManager] {error_msg}")
return False, error_msg
trace_file_name = self._get_trace_filename()
trace_file_path = self._profile_dir_path / trace_file_name
cmd = [
self._dyno_bin,
"--port",
str(self._DYNOLOG_PORT),
"gputrace",
"--pids",
str(pid),
"--log-file",
str(trace_file_path),
"--process-limit",
str(1),
"--iterations",
str(num_iterations),
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
return False, _format_failed_profiler_command(cmd, "dyno", stdout, stderr)
stdout_str = stdout.decode("utf-8")
logger.info(f"[GpuProfilingManager] Launched profiling: {stdout_str}")
# The initial launch command returns immediately,
# so wait for the profiling to actually finish before returning.
# The indicator of the profiling finishing is the creation of the trace file,
# when the completed trace is moved from <prefix>.tmp.json -> <prefix>.json
# If the profiling request is invalid (e.g. "No processes were matched"),
# the trace file will not be created and this will hang indefinitely,
# up until the timeout is reached.
# TODO(ml-team): This logic is brittle, we should find a better way to do this.
if self._NO_PROCESSES_MATCHED_ERROR_MESSAGE_PREFIX in stdout_str:
error_msg = self._NO_PROCESSES_MATCHED_ERROR_MESSAGE.format(
pid=pid, ip_address=self._ip_address
)
logger.error(f"[GpuProfilingManager] {error_msg}")
return False, error_msg
# The actual trace file gets dumped with a suffix of `_{pid}.json
trace_file_name_pattern = trace_file_name.replace(".json", "*.json")
return await self._wait_for_trace_file(pid, trace_file_name_pattern, _timeout_s)
async def _wait_for_trace_file(
self,
pid: int,
trace_file_name_pattern: str,
timeout_s: int,
sleep_interval_s: float = 0.25,
) -> Tuple[bool, str]:
"""Wait for the trace file to be created.
Args:
pid: The target process to be profiled.
trace_file_name_pattern: The pattern of the trace file to be created
within the `<log_dir>/profiles` directory.
timeout_s: Maximum time in seconds to wait for profiling to complete.
sleep_interval_s: Time in seconds to sleep between checking for the trace file.
Returns:
Tuple[bool, str]: (success, trace file path relative to the *root* log directory)
"""
remaining_timeout_s = timeout_s
logger.info(
"[GpuProfilingManager] Waiting for trace file to be created "
f"with the pattern: {trace_file_name_pattern}"
)
while True:
dumped_trace_file_path = next(
self._profile_dir_path.glob(trace_file_name_pattern), None
)
if dumped_trace_file_path is not None:
break
await asyncio.sleep(sleep_interval_s)
remaining_timeout_s -= sleep_interval_s
if remaining_timeout_s <= 0:
return (
False,
f"GPU profiling timed out after {timeout_s} seconds, please try again.",
)
# If the process has already exited, return an error.
if not self.is_pid_alive(pid):
return (
False,
self._DEAD_PROCESS_ERROR_MESSAGE.format(
pid=pid, ip_address=self._ip_address
),
)
logger.info(
f"[GpuProfilingManager] GPU profiling finished, trace file: {dumped_trace_file_path}"
)
return True, str(dumped_trace_file_path.relative_to(self._root_log_dir))
@@ -0,0 +1,615 @@
"""GPU providers for monitoring GPU usage in Ray dashboard.
This module provides an object-oriented interface for different GPU providers
(NVIDIA, AMD) to collect GPU utilization information.
"""
import abc
import enum
import logging
import subprocess
import time
from typing import Dict, List, Optional, TypedDict, Union
try:
from typing import NotRequired
except ImportError:
from typing_extensions import NotRequired
from ray.util.debug import log_once
logger = logging.getLogger(__name__)
# Constants
MB = 1024 * 1024
# Types
Percentage = int
Megabytes = int
Bytes = int
class GpuProviderType(enum.Enum):
"""Enum for GPU provider types."""
NVIDIA = "nvidia"
AMD = "amd"
class ProcessGPUInfo(TypedDict):
"""Information about GPU usage for a single process."""
pid: int
gpu_memory_usage: Megabytes
gpu_utilization: Optional[Percentage]
class GpuUtilizationInfo(TypedDict):
"""GPU utilization information for a single GPU device."""
index: int
name: str
uuid: str
utilization_gpu: Optional[Percentage]
memory_used: Megabytes
memory_total: Megabytes
processes_pids: Optional[Dict[int, ProcessGPUInfo]]
# Optional: power in milliwatts, temperature in Celsius (e.g. from NVIDIA/AMD)
power_mw: NotRequired[Optional[int]]
temperature_c: NotRequired[Optional[int]]
# tpu utilization for google tpu
class TpuUtilizationInfo(TypedDict):
index: int
name: str
tpu_type: str
tpu_topology: str
tensorcore_utilization: Percentage
hbm_utilization: Percentage
duty_cycle: Percentage
memory_used: Bytes
memory_total: Bytes
class GpuProvider(abc.ABC):
"""Abstract base class for GPU providers."""
def __init__(self):
self._initialized = False
@abc.abstractmethod
def get_provider_name(self) -> GpuProviderType:
"""Return the type of the GPU provider."""
pass
@abc.abstractmethod
def is_available(self) -> bool:
"""Check if the GPU provider is available on this system."""
pass
@abc.abstractmethod
def _initialize(self) -> bool:
"""Initialize the GPU provider. Returns True if successful."""
pass
@abc.abstractmethod
def _shutdown(self):
"""Shutdown the GPU provider and clean up resources."""
pass
@abc.abstractmethod
def get_gpu_utilization(self) -> List[GpuUtilizationInfo]:
"""Get GPU utilization information for all available GPUs."""
pass
@staticmethod
def _decode(b: Union[str, bytes]) -> str:
"""Decode bytes to string for Python 3 compatibility."""
if isinstance(b, bytes):
return b.decode("utf-8")
return b
class NvidiaGpuProvider(GpuProvider):
"""NVIDIA GPU provider using pynvml."""
def __init__(self):
super().__init__()
self._pynvml = None
# Maintain per-GPU sampling timestamps when using process utilization API
self._gpu_process_last_sample_ts: Dict[int, int] = {}
def get_provider_name(self) -> GpuProviderType:
return GpuProviderType.NVIDIA
def is_available(self) -> bool:
"""Check if NVIDIA GPUs are available."""
try:
import ray._private.thirdparty.pynvml as pynvml
pynvml.nvmlInit()
pynvml.nvmlShutdown()
return True
except Exception as e:
logger.debug(f"NVIDIA GPU not available: {e}")
return False
def _initialize(self) -> bool:
"""Initialize the NVIDIA GPU provider."""
if self._initialized:
return True
try:
import ray._private.thirdparty.pynvml as pynvml
self._pynvml = pynvml
self._pynvml.nvmlInit()
self._initialized = True
return True
except Exception as e:
logger.debug(f"Failed to initialize NVIDIA GPU provider: {e}")
return False
def _shutdown(self):
"""Shutdown the NVIDIA GPU provider."""
if self._initialized and self._pynvml:
try:
self._pynvml.nvmlShutdown()
except Exception as e:
logger.debug(f"Error shutting down NVIDIA GPU provider: {e}")
finally:
self._initialized = False
def get_gpu_utilization(self) -> List[GpuUtilizationInfo]:
"""Get GPU utilization information for all NVIDIA GPUs and MIG devices."""
return self._get_pynvml_gpu_usage()
def _get_pynvml_gpu_usage(self) -> List[GpuUtilizationInfo]:
if not self._initialized:
if not self._initialize():
return []
gpu_utilizations = []
try:
num_gpus = self._pynvml.nvmlDeviceGetCount()
for i in range(num_gpus):
gpu_handle = self._pynvml.nvmlDeviceGetHandleByIndex(i)
# Check if MIG mode is enabled on this GPU
try:
mig_mode = self._pynvml.nvmlDeviceGetMigMode(gpu_handle)
if mig_mode[0]: # MIG mode is enabled
# Get MIG device instances
mig_devices = self._get_mig_devices(gpu_handle, i)
gpu_utilizations.extend(mig_devices)
continue
except (self._pynvml.NVMLError, AttributeError):
# MIG not supported or not enabled, continue with regular GPU
pass
# Process regular GPU (non-MIG)
gpu_info = self._get_gpu_info(gpu_handle, i)
if gpu_info:
gpu_utilizations.append(gpu_info)
except Exception as e:
logger.warning(f"Error getting NVIDIA GPU utilization: {e}")
finally:
self._shutdown()
return gpu_utilizations
def _get_mig_devices(self, gpu_handle, gpu_index: int) -> List[GpuUtilizationInfo]:
"""Get MIG device information for a GPU with MIG enabled."""
mig_devices = []
try:
# Get all MIG device instances
mig_count = self._pynvml.nvmlDeviceGetMaxMigDeviceCount(gpu_handle)
for mig_idx in range(mig_count):
try:
# Get MIG device handle
mig_handle = self._pynvml.nvmlDeviceGetMigDeviceHandleByIndex(
gpu_handle, mig_idx
)
# Get MIG device info
mig_info = self._get_mig_device_info(mig_handle, gpu_index, mig_idx)
if mig_info:
mig_devices.append(mig_info)
except self._pynvml.NVMLError:
# MIG device not available at this index
continue
except (self._pynvml.NVMLError, AttributeError) as e:
logger.debug(f"Error getting MIG devices: {e}")
return mig_devices
def _get_mig_device_info(
self, mig_handle, gpu_index: int, mig_index: int
) -> Optional[GpuUtilizationInfo]:
"""Get utilization info for a single MIG device."""
try:
memory_info = self._pynvml.nvmlDeviceGetMemoryInfo(mig_handle)
# Get MIG device utilization
utilization = -1
try:
utilization_info = self._pynvml.nvmlDeviceGetUtilizationRates(
mig_handle
)
utilization = int(utilization_info.gpu)
except self._pynvml.NVMLError as e:
logger.debug(f"Failed to retrieve MIG device utilization: {e}")
# Get running processes on MIG device
processes_pids = {}
try:
nv_comp_processes = self._pynvml.nvmlDeviceGetComputeRunningProcesses(
mig_handle
)
nv_graphics_processes = (
self._pynvml.nvmlDeviceGetGraphicsRunningProcesses(mig_handle)
)
for nv_process in nv_comp_processes + nv_graphics_processes:
processes_pids[int(nv_process.pid)] = ProcessGPUInfo(
pid=int(nv_process.pid),
gpu_memory_usage=(
int(nv_process.usedGpuMemory) // MB
if nv_process.usedGpuMemory
else 0
),
# NOTE: According to nvml, this is not currently available in MIG mode
gpu_utilization=None,
)
except self._pynvml.NVMLError as e:
logger.debug(f"Failed to retrieve MIG device processes: {e}")
# Get MIG device UUID and name
try:
mig_uuid = self._decode(self._pynvml.nvmlDeviceGetUUID(mig_handle))
mig_name = self._decode(self._pynvml.nvmlDeviceGetName(mig_handle))
except self._pynvml.NVMLError:
# Fallback for older drivers
try:
parent_name = self._decode(
self._pynvml.nvmlDeviceGetName(
self._pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
)
)
mig_name = f"{parent_name} MIG {mig_index}"
mig_uuid = f"MIG-GPU-{gpu_index}-{mig_index}"
except Exception:
mig_name = f"NVIDIA MIG Device {gpu_index}.{mig_index}"
mig_uuid = f"MIG-{gpu_index}-{mig_index}"
return GpuUtilizationInfo(
index=gpu_index * 1000 + mig_index, # Unique index for MIG devices
name=mig_name,
uuid=mig_uuid,
utilization_gpu=utilization,
memory_used=int(memory_info.used) // MB,
memory_total=int(memory_info.total) // MB,
processes_pids=processes_pids,
power_mw=None, # MIG devices don't expose per-slice power in NVML
temperature_c=None,
)
except Exception as e:
logger.debug(f"Error getting MIG device info: {e}")
return None
def _get_gpu_info(self, gpu_handle, gpu_index: int) -> Optional[GpuUtilizationInfo]:
"""Get utilization info for a regular (non-MIG) GPU."""
try:
memory_info = self._pynvml.nvmlDeviceGetMemoryInfo(gpu_handle)
# Get GPU utilization
utilization = -1
try:
utilization_info = self._pynvml.nvmlDeviceGetUtilizationRates(
gpu_handle
)
utilization = int(utilization_info.gpu)
except self._pynvml.NVMLError as e:
if log_once("gpu_utilization"):
logger.info(
f"Failed to retrieve GPU utilization via `nvmlDeviceGetUtilizationRates`: {e}"
)
# Get running processes
processes_pids = {}
# Get per-process memory usage from the running-processes APIs.
try:
nv_comp_processes = self._pynvml.nvmlDeviceGetComputeRunningProcesses(
gpu_handle
)
nv_graphics_processes = (
self._pynvml.nvmlDeviceGetGraphicsRunningProcesses(gpu_handle)
)
for nv_process in nv_comp_processes + nv_graphics_processes:
pid = int(nv_process.pid)
processes_pids[pid] = ProcessGPUInfo(
pid=pid,
gpu_memory_usage=int(nv_process.usedGpuMemory) // MB
if nv_process.usedGpuMemory
else 0,
gpu_utilization=None,
)
except self._pynvml.NVMLError as e:
if log_once("gpu_per_process_memory"):
logger.info(
"Failed to retrieve per-process GPU memory via `nvmlDeviceGetComputeRunningProcesses` "
f"and `nvmlDeviceGetGraphicsRunningProcesses` APIs: {e}"
)
# Use a newer API (driver 550+) to get per-process SM utilization, but the user
# may not always have the access to the newest API.
try:
current_ts_ms = int(time.time() * 1000)
last_ts_ms = self._gpu_process_last_sample_ts.get(gpu_index, 0)
nv_processes = self._pynvml.nvmlDeviceGetProcessesUtilizationInfo(
gpu_handle, last_ts_ms
)
self._gpu_process_last_sample_ts[gpu_index] = current_ts_ms
for nv_process in nv_processes:
pid = int(nv_process.pid)
if pid not in processes_pids:
# Note that it's pretty unlikely that nvmlDeviceGetProcessesUtilizationInfo
# will include a process that nvmlDeviceGetComputeRunningProcesses +
# nvmlDeviceGetGraphicsRunningProcesses didn't find, but doing this just in case.
processes_pids[pid] = ProcessGPUInfo(
pid=pid,
gpu_memory_usage=0,
gpu_utilization=int(nv_process.smUtil),
)
else:
processes_pids[pid]["gpu_utilization"] = int(nv_process.smUtil)
except self._pynvml.NVMLError as e:
if log_once("gpu_process_sm_utilization"):
logger.info(
f"Failed to retrieve GPU process SM utilization using `nvmlDeviceGetProcessesUtilizationInfo`, error: {e}"
)
# Optional: power (milliwatts) and temperature (Celsius)
power_mw = None
temperature_c = None
try:
power_mw = self._pynvml.nvmlDeviceGetPowerUsage(gpu_handle)
except (self._pynvml.NVMLError, AttributeError) as e:
if log_once("gpu_power"):
logger.info(f"Failed to retrieve GPU power: {e}")
try:
# NVML_TEMPERATURE_GPU = 0
temperature_c = self._pynvml.nvmlDeviceGetTemperature(
gpu_handle, self._pynvml.NVML_TEMPERATURE_GPU
)
except (self._pynvml.NVMLError, AttributeError) as e:
if log_once("gpu_temperature"):
logger.info(f"Failed to retrieve GPU temperature: {e}")
return GpuUtilizationInfo(
index=gpu_index,
name=self._decode(self._pynvml.nvmlDeviceGetName(gpu_handle)),
uuid=self._decode(self._pynvml.nvmlDeviceGetUUID(gpu_handle)),
utilization_gpu=utilization,
memory_used=int(memory_info.used) // MB,
memory_total=int(memory_info.total) // MB,
processes_pids=processes_pids,
power_mw=power_mw,
temperature_c=temperature_c,
)
except Exception as e:
logger.debug(f"Error getting GPU info: {e}")
return None
class AmdGpuProvider(GpuProvider):
"""AMD GPU provider using pyamdsmi."""
def __init__(self):
super().__init__()
self._pyamdsmi = None
def get_provider_name(self) -> GpuProviderType:
return GpuProviderType.AMD
def is_available(self) -> bool:
"""Check if AMD GPUs are available."""
try:
import ray._private.thirdparty.pyamdsmi as pyamdsmi
pyamdsmi.smi_initialize()
pyamdsmi.smi_shutdown()
return True
except Exception as e:
logger.debug(f"AMD GPU not available: {e}")
return False
def _initialize(self) -> bool:
"""Initialize the AMD GPU provider."""
if self._initialized:
return True
try:
import ray._private.thirdparty.pyamdsmi as pyamdsmi
self._pyamdsmi = pyamdsmi
self._pyamdsmi.smi_initialize()
self._initialized = True
return True
except Exception as e:
logger.debug(f"Failed to initialize AMD GPU provider: {e}")
return False
def _shutdown(self):
"""Shutdown the AMD GPU provider."""
if self._initialized and self._pyamdsmi:
try:
self._pyamdsmi.smi_shutdown()
except Exception as e:
logger.debug(f"Error shutting down AMD GPU provider: {e}")
finally:
self._initialized = False
def get_gpu_utilization(self) -> List[GpuUtilizationInfo]:
"""Get GPU utilization information for all AMD GPUs."""
if not self._initialized:
if not self._initialize():
return []
gpu_utilizations = []
try:
num_gpus = self._pyamdsmi.smi_get_device_count()
processes = self._pyamdsmi.smi_get_device_compute_process()
for i in range(num_gpus):
utilization = self._pyamdsmi.smi_get_device_utilization(i)
if utilization == -1:
utilization = -1
# Get running processes
processes_pids = {}
for process in self._pyamdsmi.smi_get_compute_process_info_by_device(
i, processes
):
if process.vram_usage:
processes_pids[int(process.process_id)] = ProcessGPUInfo(
pid=int(process.process_id),
gpu_memory_usage=int(process.vram_usage) // MB,
gpu_utilization=None,
)
# Optional: power in milliwatts (AMD returns watts)
power_mw = None
try:
power_watts = self._pyamdsmi.smi_get_device_average_power(i)
if power_watts >= 0:
power_mw = int(power_watts * 1000)
except Exception as e:
logger.debug(f"Failed to retrieve AMD GPU power: {e}")
info = GpuUtilizationInfo(
index=i,
name=self._decode(self._pyamdsmi.smi_get_device_name(i)),
uuid=self._pyamdsmi.smi_get_device_unique_id(i),
utilization_gpu=utilization,
memory_used=int(self._pyamdsmi.smi_get_device_memory_used(i)) // MB,
memory_total=int(self._pyamdsmi.smi_get_device_memory_total(i))
// MB,
processes_pids=processes_pids,
power_mw=power_mw,
temperature_c=None, # not exposed in vendored pyamdsmi
)
gpu_utilizations.append(info)
except Exception as e:
logger.warning(f"Error getting AMD GPU utilization: {e}")
finally:
self._shutdown()
return gpu_utilizations
class GpuMetricProvider:
"""Provider class for GPU metrics collection."""
def __init__(self):
self._provider: Optional[GpuProvider] = None
self._enable_metric_report = True
self._providers = [NvidiaGpuProvider(), AmdGpuProvider()]
self._initialized = False
def initialize(self) -> bool:
"""Initialize the GPU metric provider by detecting available GPU providers."""
if self._initialized:
return True
self._provider = self._detect_gpu_provider()
if self._provider is None:
# Check if we should disable GPU check entirely
try:
# Try NVIDIA first to check for the specific error condition
nvidia_provider = NvidiaGpuProvider()
nvidia_provider._initialize()
except Exception as e:
if self._should_disable_gpu_check(e):
self._enable_metric_report = False
else:
logger.info(f"Using GPU Provider: {type(self._provider).__name__}")
self._initialized = True
return self._provider is not None
def _detect_gpu_provider(self) -> Optional[GpuProvider]:
"""Detect and return the first available GPU provider."""
for provider in self._providers:
if provider.is_available():
return provider
return None
def _should_disable_gpu_check(self, nvidia_error: Exception) -> bool:
"""
Check if we should disable GPU usage check based on the error.
On machines without GPUs, pynvml.nvmlInit() can run subprocesses that
spew to stderr. Then with log_to_driver=True, we get log spew from every
single raylet. To avoid this, disable the GPU usage check on certain errors.
See: https://github.com/ray-project/ray/issues/14305
"""
if type(nvidia_error).__name__ != "NVMLError_DriverNotLoaded":
return False
try:
result = subprocess.check_output(
"cat /sys/module/amdgpu/initstate |grep live",
shell=True,
stderr=subprocess.DEVNULL,
)
# If AMD GPU module is not live and NVIDIA driver not loaded,
# disable GPU check
return len(str(result)) == 0
except Exception:
return False
def get_gpu_usage(self) -> List[GpuUtilizationInfo]:
"""Get GPU usage information from the available provider."""
if not self._enable_metric_report:
return []
if not self._initialized:
self.initialize()
if self._provider is None:
return []
try:
gpu_info_list = self._provider.get_gpu_utilization()
return gpu_info_list # Return TypedDict instances directly
except Exception as e:
logger.debug(
f"Error getting GPU usage from {self._provider.get_provider_name().value}: {e}"
)
return []
def get_provider_name(self) -> Optional[str]:
"""Get the name of the current GPU provider."""
return self._provider.get_provider_name().value if self._provider else None
def is_metric_report_enabled(self) -> bool:
"""Check if GPU metric reporting is enabled."""
return self._enable_metric_report
@@ -0,0 +1,103 @@
import asyncio
import logging
from aiohttp.web import Request, Response
import ray.dashboard.optional_utils as optional_utils
import ray.dashboard.utils as dashboard_utils
import ray.exceptions
from ray._raylet import NodeID
from ray.dashboard.modules.reporter.utils import HealthChecker
routes = optional_utils.DashboardAgentRouteTable
logger = logging.getLogger(__name__)
class HealthzAgent(dashboard_utils.DashboardAgentModule):
"""Health check in the agent.
This module adds health check related endpoint to the agent to check
local components' health.
"""
def __init__(self, dashboard_agent):
super().__init__(dashboard_agent)
node_id = (
NodeID.from_hex(dashboard_agent.node_id)
if dashboard_agent.node_id
else None
)
self._health_checker = HealthChecker(
dashboard_agent.gcs_client,
node_id,
)
@routes.get("/api/local_raylet_healthz")
async def health_check(self, req: Request) -> Response:
try:
await self.raylet_health()
except Exception as e:
return Response(status=503, text=str(e), content_type="application/text")
return Response(
text="success",
content_type="application/text",
)
async def raylet_health(self) -> str:
try:
alive = await self._health_checker.check_local_raylet_liveness()
if alive is False:
raise Exception("Local Raylet failed")
except ray.exceptions.RpcError as e:
# We only consider the error other than GCS unreachable as raylet failure
# to avoid false positive.
# In case of GCS failed, Raylet will crash eventually if GCS is not back
# within a given time and the check will fail since agent can't live
# without a local raylet.
if e.rpc_code not in (
ray._raylet.GRPC_STATUS_CODE_UNAVAILABLE,
ray._raylet.GRPC_STATUS_CODE_UNKNOWN,
ray._raylet.GRPC_STATUS_CODE_DEADLINE_EXCEEDED,
):
raise Exception(f"Health check failed due to: {e}")
return "success"
async def local_gcs_health(self) -> str:
# If GCS is not local, don't check its health.
if not self._dashboard_agent.is_head:
return "success (no local gcs)"
gcs_alive = await self._health_checker.check_gcs_liveness()
if not gcs_alive:
raise Exception("GCS health check failed.")
return "success"
@routes.get("/api/healthz")
async def unified_health(self, req: Request) -> Response:
[raylet_check, gcs_check] = await asyncio.gather(
self.raylet_health(),
self.local_gcs_health(),
return_exceptions=True,
)
checks = {"raylet": raylet_check, "gcs": gcs_check}
# Log failures.
status = 200
for name, result in checks.items():
if isinstance(result, Exception):
status = 503
logger.warning(f"health check {name} failed: {result}")
return Response(
status=status,
text="\n".join([f"{name}: {result}" for name, result in checks.items()]),
content_type="application/text",
)
async def run(self, server):
pass
@staticmethod
def is_minimal_module():
return False
@@ -0,0 +1,101 @@
import asyncio
import logging
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
class JaxProfilingManager:
"""JAX profiling manager for Ray Dashboard.
It connects to the JAX profiler server running on the worker
and captures a trace using TensorFlow's profiler client.
"""
def __init__(self, profile_dir_path: str):
self._root_log_dir = Path(profile_dir_path)
self._profile_dir_path = self._root_log_dir / "profiles"
self._profile_dir_path.mkdir(parents=True, exist_ok=True)
self._executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="jax_profiling_executor"
)
self._inflight: Optional[Future] = None
async def jax_profile(
self, pid: int, port: int, duration_s: int = 5
) -> Tuple[bool, str]:
"""Perform JAX profiling by connecting to the JAX server.
Args:
pid: The process ID of the target process (for logging/tracking).
port: The port where JAX profiler server is listening.
duration_s: Duration of the profiling in seconds.
Returns:
Tuple[bool, str]: (success, trace file path relative to root log dir)
"""
if self._inflight is not None and not self._inflight.done():
return (
False,
"Another JAX profiling session is already in progress on this node.",
)
try:
from tensorflow.python.profiler import profiler_client
except ImportError as e:
return (
False,
"TensorFlow is required to capture JAX profiles from the Dashboard. "
f"Please install `tensorflow` on the node. Error: {e}",
)
address = f"grpc://localhost:{port}"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
capture_dir = self._profile_dir_path / f"{pid}_{timestamp}"
logger.info(
f"Capturing JAX profile from {address} for pid {pid} "
f"for {duration_s} seconds..."
)
def _capture():
try:
# profiler_client.trace captures the trace and saves it to logdir
profiler_client.trace(
address,
logdir=str(capture_dir),
duration_ms=duration_s * 1000,
)
return True, ""
except Exception as e:
return False, f"Failed to capture trace: {e}"
# Run in executor because trace is blocking
future = self._executor.submit(_capture)
self._inflight = future
try:
success, error_msg = await asyncio.wait_for(
asyncio.wrap_future(future),
timeout=duration_s + 20,
)
except asyncio.TimeoutError:
logger.error(
f"JAX profiling timed out after {duration_s + 20} seconds. "
"The capture may still be finishing in the background."
)
return (
False,
f"JAX profiling timed out after {duration_s + 20} seconds. "
"The capture may still be finishing, retry shortly.",
)
if not success:
logger.error(f"JAX profiling failed: {error_msg}")
return False, error_msg
logger.info(f"JAX profiling finished. Files saved in {capture_dir}")
# Return the directory path relative to the root log directory
return True, str(capture_dir.relative_to(self._root_log_dir))
@@ -0,0 +1,378 @@
import asyncio
import logging
import os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Tuple, Union
logger = logging.getLogger(__name__)
DARWIN_SET_CHOWN_CMD = "sudo chown root: `which {profiler}`"
LINUX_SET_CHOWN_CMD = "sudo chown root:root `which {profiler}`"
PROFILER_PERMISSIONS_ERROR_MESSAGE = """
Note that this command requires `{profiler}` to be installed with root permissions. You
can install `{profiler}` and give it root permissions as follows:
$ pip install {profiler}
$ {set_chown_command}
$ sudo chmod u+s `which {profiler}`
Alternatively, you can start Ray with passwordless sudo / root permissions.
"""
def decode(string: Union[str, bytes]):
if isinstance(string, bytes):
return string.decode("utf-8")
return string
def _format_failed_profiler_command(cmd, profiler, stdout, stderr) -> str:
stderr_str = decode(stderr)
extra_message = ""
# If some sort of permission error returned, show a message about how
# to set up permissions correctly.
if "permission" in stderr_str.lower():
set_chown_command = (
DARWIN_SET_CHOWN_CMD.format(profiler=profiler)
if sys.platform == "darwin"
else LINUX_SET_CHOWN_CMD.format(profiler=profiler)
)
extra_message = PROFILER_PERMISSIONS_ERROR_MESSAGE.format(
profiler=profiler, set_chown_command=set_chown_command
)
return f"""Failed to execute `{cmd}`.
{extra_message}
=== stderr ===
{decode(stderr)}
=== stdout ===
{decode(stdout)}
"""
# If we can sudo, always try that. Otherwise, py-spy will only work if the user has
# root privileges or has configured setuid on the py-spy script.
async def _can_passwordless_sudo() -> bool:
try:
process = await asyncio.create_subprocess_exec(
"sudo",
"-n",
"true",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except FileNotFoundError:
return False
else:
_, _ = await process.communicate()
return process.returncode == 0
class CpuProfilingManager:
def __init__(self, profile_dir_path: str):
self.profile_dir_path = Path(profile_dir_path)
self.profile_dir_path.mkdir(exist_ok=True)
self.profiler_name = "py-spy"
async def trace_dump(
self, pid: int, native: bool = False, subprocesses: bool = False
) -> Tuple[bool, str]:
"""Capture and dump a trace for a specified process.
Args:
pid: The process ID (PID) of the target process for trace capture.
native: If True, includes native (C/C++) stack frames.
Default is False.
subprocesses: If True, also dumps stack traces for
child processes of the target process. Default is False.
Returns:
A tuple containing a boolean indicating the success of the trace
capture operation and a string with the trace data or an error message.
"""
pyspy = shutil.which(self.profiler_name)
if pyspy is None:
return False, "Failed to execute: py-spy is not installed"
cmd = [pyspy, "dump", "-p", str(pid)]
# We
if sys.platform == "linux" and native:
cmd.append("--native")
if subprocesses:
cmd.append("--subprocesses")
if await _can_passwordless_sudo():
cmd = ["sudo", "-n"] + cmd
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
return False, _format_failed_profiler_command(
cmd, self.profiler_name, stdout, stderr
)
else:
return True, decode(stdout)
async def cpu_profile(
self,
pid: int,
format: str = "flamegraph",
duration: float = 5,
native: bool = False,
idle: bool = False,
subprocesses: bool = False,
) -> Tuple[bool, str]:
"""Perform CPU profiling on a specified process.
Args:
pid: The process ID (PID) of the target process to be profiled.
format: The format of the CPU profile output. Default is "flamegraph".
duration: The duration of the profiling session in seconds.
Default is 5 seconds.
native: If True, includes native (C/C++) stack frames. Default is False.
idle: If True, includes off-CPU / sleeping threads (e.g. threads
blocked on locks, I/O, or CUDA syncs). Default is False.
subprocesses: If True, also profiles child
processes of the target process (e.g. data loader or
multiprocess inference workers). Default is False.
Returns:
A tuple containing a boolean indicating the success of the profiling
operation and a string with the profile data or an error message.
"""
pyspy = shutil.which(self.profiler_name)
if pyspy is None:
return False, "Failed to execute: py-spy is not installed"
if format not in ("flamegraph", "raw", "speedscope"):
return (
False,
f"Failed to execute: Invalid format {format}, "
+ "must be [flamegraph, raw, speedscope]",
)
if format == "flamegraph":
extension = "svg"
else:
extension = "txt"
profile_file_path = (
self.profile_dir_path / f"{format}_{pid}_cpu_profiling.{extension}"
)
cmd = [
pyspy,
"record",
"-o",
profile_file_path,
"-p",
str(pid),
"-d",
str(duration),
"-f",
format,
]
if sys.platform == "linux" and native:
cmd.append("--native")
if idle:
cmd.append("--idle")
if subprocesses:
cmd.append("--subprocesses")
if await _can_passwordless_sudo():
cmd = ["sudo", "-n"] + cmd
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
return False, _format_failed_profiler_command(
cmd, self.profiler_name, stdout, stderr
)
else:
return True, open(profile_file_path, "rb").read()
class MemoryProfilingManager:
def __init__(self, profile_dir_path: str):
self.profile_dir_path = Path(profile_dir_path) / "memray"
self.profile_dir_path.mkdir(exist_ok=True)
self.profiler_name = "memray"
async def get_profile_result(
self,
pid: int,
profiler_filename: str,
format: str = "flamegraph",
leaks: bool = False,
) -> Tuple[bool, str]:
"""Convert the Memray profile result to specified format.
Args:
pid: The process ID (PID) associated with the profiling operation.
profiler_filename: The filename of the profiler output to be processed.
format: The format of the profile result. Default is "flamegraph".
leaks: If True, include memory leak information in the profile result.
Returns:
A tuple containing a boolean indicating the success of the operation
and a string with the processed profile result or an error message.
"""
memray = shutil.which(self.profiler_name)
if memray is None:
return False, "Failed to execute: memray is not installed"
profile_file_path = self.profile_dir_path / profiler_filename
if not Path(profile_file_path).is_file():
return False, f"Failed to execute: process {pid} has not been profiled"
profiler_name, _ = os.path.splitext(profiler_filename)
profile_visualize_path = self.profile_dir_path / f"{profiler_name}.html"
if format == "flamegraph":
visualize_cmd = [
memray,
"flamegraph",
"-o",
profile_visualize_path,
"-f",
]
elif format == "table":
visualize_cmd = [
memray,
"table",
"-o",
profile_visualize_path,
"-f",
]
else:
return (
False,
f"Failed to execute: Report with format: {format} is not supported",
)
if leaks:
visualize_cmd.append("--leaks")
visualize_cmd.append(profile_file_path)
process = await asyncio.create_subprocess_exec(
*visualize_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
return False, _format_failed_profiler_command(
visualize_cmd, self.profiler_name, stdout, stderr
)
return True, open(profile_visualize_path, "rb").read()
async def attach_profiler(
self,
pid: int,
native: bool = False,
trace_python_allocators: bool = False,
verbose: bool = False,
) -> Tuple[bool, str]:
"""Attach a Memray profiler to a specified process.
Args:
pid: The process ID (PID) of the target process which
the profiler attached to.
native: If True, includes native (C/C++) stack frames. Default is False.
trace_python_allocators: If True, includes Python stack frames.
Default is False.
verbose: If True, enables verbose output. Default is False.
Returns:
A tuple containing a boolean indicating the success of the operation
and a string of a success message or an error message.
"""
memray = shutil.which(self.profiler_name)
if memray is None:
return False, None, "Failed to execute: memray is not installed"
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
profiler_filename = f"{pid}_memory_profiling_{timestamp}.bin"
profile_file_path = self.profile_dir_path / profiler_filename
cmd = [memray, "attach", str(pid), "-o", profile_file_path]
if native:
cmd.append("--native")
if trace_python_allocators:
cmd.append("--trace-python-allocators")
if verbose:
cmd.append("--verbose")
if await _can_passwordless_sudo():
cmd = ["sudo", "-n"] + cmd
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
return (
False,
None,
_format_failed_profiler_command(
cmd, self.profiler_name, stdout, stderr
),
)
else:
return (
True,
profiler_filename,
f"Success attaching memray to process {pid}",
)
async def detach_profiler(
self,
pid: int,
verbose: bool = False,
) -> Tuple[bool, str]:
"""Detach a profiler from a specified process.
Args:
pid: The process ID (PID) of the target process the
profiler detached from.
verbose: If True, enables verbose output. Default is False.
Returns:
A tuple containing a boolean indicating the success of the operation
and a string of a success message or an error message.
"""
memray = shutil.which(self.profiler_name)
if memray is None:
return False, "Failed to execute: memray is not installed"
cmd = [memray, "detach"]
if verbose:
cmd.append("--verbose")
cmd.append(str(pid))
if await _can_passwordless_sudo():
cmd = ["sudo", "-n"] + cmd
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
return False, _format_failed_profiler_command(
cmd, self.profiler_name, stdout, stderr
)
else:
return True, f"Success detaching memray from process {pid}"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
import ray._private.ray_constants as ray_constants
REPORTER_PREFIX = "RAY_REPORTER:"
# The reporter will report its statistics this often (milliseconds).
REPORTER_UPDATE_INTERVAL_MS = ray_constants.env_integer(
"REPORTER_UPDATE_INTERVAL_MS", 5000
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,198 @@
from typing import Dict, List, Optional, Tuple
from ray._common.pydantic_compat import PYDANTIC_INSTALLED, BaseModel
if PYDANTIC_INSTALLED:
from pydantic import field_validator
# TODO(aguo): Use these pydantic models in the dashboard API as well.
class ProcessGPUInfo(BaseModel):
"""
Information about GPU usage for a single process.
NOTE: Backwards compatibility for this model must be maintained.
If broken, the downstream dashboard API and UI code will break.
If you must make a backwards-incompatible change, you must make sure
to update the relevant code in the dashboard API and UI as well.
"""
pid: int
gpuMemoryUsage: int # in MB
gpuUtilization: Optional[int] = None # percentage
class GpuUtilizationInfo(BaseModel):
"""
GPU utilization information for a single GPU device.
NOTE: Backwards compatibility for this model must be maintained.
If broken, the downstream dashboard API and UI code will break.
If you must make a backwards-incompatible change, you must make sure
to update the relevant code in the dashboard API and UI as well.
"""
index: int
name: str
uuid: str
utilizationGpu: Optional[int] = None # percentage
memoryUsed: int # in MB
memoryTotal: int # in MB
processesPids: Optional[
List[ProcessGPUInfo]
] = None # converted to list in _compose_stats_payload
powerMw: Optional[int] = None # current power draw in milliwatts
temperatureC: Optional[int] = None # temperature in Celsius
class TpuUtilizationInfo(BaseModel):
"""
TPU utilization information for a single TPU device.
NOTE: Backwards compatibility for this model must be maintained.
If broken, the downstream dashboard API and UI code will break.
If you must make a backwards-incompatible change, you must make sure
to update the relevant code in the dashboard API and UI as well.
"""
index: int
name: str
tpuType: str
tpuTopology: str
tensorcoreUtilization: float # percentage
hbmUtilization: float # percentage
dutyCycle: float # percentage
memoryUsed: int # in bytes
memoryTotal: int # in bytes
class CpuTimes(BaseModel):
"""
CPU times information based on psutil.scputimes.
NOTE: Backwards compatibility for this model must be maintained.
If broken, the downstream dashboard API and UI code will break.
If you must make a backwards-incompatible change, you must make sure
to update the relevant code in the dashboard API and UI as well.
"""
user: float
system: float
childrenUser: float
childrenSystem: float
class MemoryInfo(BaseModel):
"""
Memory information based on psutil.svmem.
NOTE: Backwards compatibility for this model must be maintained.
If broken, the downstream dashboard API and UI code will break.
If you must make a backwards-incompatible change, you must make sure
to update the relevant code in the dashboard API and UI as well.
"""
rss: float
vms: float
pfaults: Optional[float] = None
pageins: Optional[float] = None
class MemoryFullInfo(MemoryInfo):
"""
Memory full information based on psutil.smem.
NOTE: Backwards compatibility for this model must be maintained.
If broken, the downstream dashboard API and UI code will break.
If you must make a backwards-incompatible change, you must make sure
to update the relevant code in the dashboard API and UI as well.
"""
uss: float
class ProcessInfo(BaseModel):
"""
Process information from psutil.
NOTE: Backwards compatibility for this model must be maintained.
If broken, the downstream dashboard API and UI code will break.
If you must make a backwards-incompatible change, you must make sure
to update the relevant code in the dashboard API and UI as well.
"""
pid: int
createTime: float
cpuPercent: float
cpuTimes: Optional[CpuTimes] = None # psutil._pslinux.scputimes object
cmdline: List[str]
memoryInfo: Optional[MemoryInfo] = None # psutil._pslinux.svmem object
memoryFullInfo: Optional[MemoryFullInfo] = None # psutil._pslinux.smem object
numFds: Optional[int] = None # Not available on Windows
gpuMemoryUsage: Optional[int] = None # in MB, added by _get_workers
gpuUtilization: Optional[int] = None # percentage, added by _get_workers
@field_validator("cmdline", mode="before")
@classmethod
def _normalize_cmdline(cls, value):
return [] if value is None else value
# Note: The actual data structure uses tuples for some fields, not structured objects
# These are type aliases to document the tuple structure
MemoryUsage = Tuple[
int, int, float, int
] # (total, available, percent, used) in bytes
LoadAverage = Tuple[
Tuple[float, float, float], Optional[Tuple[float, float, float]]
] # (load, perCpuLoad)
NetworkStats = Tuple[int, int] # (sent, received) in bytes
DiskIOStats = Tuple[
int, int, int, int
] # (readBytes, writeBytes, readCount, writeCount)
DiskIOSpeed = Tuple[
float, float, float, float
] # (readSpeed, writeSpeed, readIops, writeIops)
NetworkSpeed = Tuple[float, float] # (sendSpeed, receiveSpeed) in bytes/sec
class DiskUsage(BaseModel):
"""
Disk usage information based on psutil.diskusage.
NOTE: Backwards compatibility for this model must be maintained.
If broken, the downstream dashboard API and UI code will break.
If you must make a backwards-incompatible change, you must make sure
to update the relevant code in the dashboard API and UI as well.
"""
total: int
used: int
free: int
percent: float
class StatsPayload(BaseModel):
"""
Main stats payload returned by _compose_stats_payload.
NOTE: Backwards compatibility for this model must be maintained.
If broken, the downstream dashboard API and UI code will break.
If you must make a backwards-incompatible change, you must make sure
to update the relevant code in the dashboard API and UI as well.
"""
now: float # POSIX timestamp
hostname: str
ip: str
cpu: float # CPU usage percentage
cpus: Tuple[int, int] # (logicalCpuCount, physicalCpuCount)
mem: MemoryUsage # (total, available, percent, used) in bytes
hostMem: Tuple[int, int] # host physical memory (used, totall) in bytes
cgroupMem: Optional[
Tuple[int, int]
] = None # (used, total) from cgroup, or None
shm: Optional[int] = None # shared memory in bytes, None if not available
workers: List[ProcessInfo]
raylet: Optional[ProcessInfo] = None
agent: Optional[ProcessInfo] = None
bootTime: float # POSIX timestamp
loadAvg: LoadAverage # (load, perCpuLoad) where load is (1min, 5min, 15min)
disk: Dict[str, DiskUsage] # mount point -> psutil disk usage object
diskIo: DiskIOStats # (readBytes, writeBytes, readCount, writeCount)
diskIoSpeed: DiskIOSpeed # (readSpeed, writeSpeed, readIops, writeIops)
gpus: List[GpuUtilizationInfo]
tpus: List[TpuUtilizationInfo]
network: NetworkStats # (sent, received) in bytes
networkSpeed: NetworkSpeed # (sendSpeed, receiveSpeed) in bytes/sec
cmdline: List[str] # deprecated field from raylet
gcs: Optional[ProcessInfo] = None # only present on head node
@field_validator("cmdline", mode="before")
@classmethod
def _normalize_cmdline(cls, value):
return [] if value is None else value
else:
StatsPayload = None
@@ -0,0 +1,377 @@
"""Unit tests for the GPU profiler manager.
All GPU and dynolog dependencies are mocked out.
This test just verifies that commands are launched correctly and that
validations are correctly performed.
"""
import asyncio
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from ray.dashboard.modules.reporter.gpu_profile_manager import GpuProfilingManager
@pytest.fixture
def mock_node_has_gpus(monkeypatch):
monkeypatch.setattr(GpuProfilingManager, "node_has_gpus", lambda cls: True)
yield
@pytest.fixture
def mock_dynolog_binaries(monkeypatch):
monkeypatch.setattr("shutil.which", lambda cmd: f"/usr/bin/fake_{cmd}")
yield
@pytest.fixture
def mock_subprocess_popen(monkeypatch):
mock_popen = MagicMock()
mock_proc = MagicMock()
mock_popen.return_value = mock_proc
monkeypatch.setattr("subprocess.Popen", mock_popen)
yield (mock_popen, mock_proc)
LOCALHOST = "127.0.0.1"
@pytest.fixture
def mock_asyncio_create_subprocess_exec(monkeypatch):
mock_create_subprocess_exec = AsyncMock()
mock_async_proc = mock_create_subprocess_exec.return_value = AsyncMock()
mock_async_proc.communicate.return_value = b"mock stdout", b"mock stderr"
mock_async_proc.returncode = 0
monkeypatch.setattr("asyncio.create_subprocess_exec", mock_create_subprocess_exec)
yield (mock_create_subprocess_exec, mock_async_proc)
def test_node_has_gpus_uses_query_gpu_flag(tmp_path, monkeypatch):
"""node_has_gpus() must use --query-gpu to avoid FabricManager stalls."""
captured = {}
def fake_check_output(cmd, **kwargs):
captured["cmd"] = list(cmd)
return b""
monkeypatch.setattr("subprocess.check_output", fake_check_output)
# Clear the cache so the monkeypatched check_output is actually called.
GpuProfilingManager.node_has_gpus.cache_clear()
result = GpuProfilingManager.node_has_gpus()
GpuProfilingManager.node_has_gpus.cache_clear()
assert result is True
assert captured["cmd"] == [
"nvidia-smi",
"--query-gpu=name",
"--format=csv,noheader",
]
def test_enabled_does_not_call_node_has_gpus_when_dynolog_missing(
tmp_path, monkeypatch
):
"""enabled must short-circuit on missing dynolog bins before node_has_gpus()."""
node_has_gpus_called = []
def spy_node_has_gpus(self_or_cls):
node_has_gpus_called.append(True)
return True
monkeypatch.setattr(GpuProfilingManager, "node_has_gpus", spy_node_has_gpus)
# No dynolog binaries on PATH → shutil.which returns None for both.
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
assert not gpu_profiler.enabled
assert (
not node_has_gpus_called
), "node_has_gpus() must not be called when dynolog binaries are absent"
def test_enabled(tmp_path, mock_node_has_gpus, mock_dynolog_binaries):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
assert gpu_profiler.enabled
def test_disabled_no_gpus(tmp_path, monkeypatch):
monkeypatch.setattr(
GpuProfilingManager, "node_has_gpus", classmethod(lambda cls: False)
)
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
assert not gpu_profiler.enabled
def test_disabled_no_dynolog_bin(tmp_path, mock_node_has_gpus):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
assert not gpu_profiler.enabled
def test_start_monitoring_daemon(
tmp_path, mock_node_has_gpus, mock_dynolog_binaries, mock_subprocess_popen
):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
mocked_popen, mocked_proc = mock_subprocess_popen
mocked_proc.pid = 123
mocked_proc.poll.return_value = None
gpu_profiler.start_monitoring_daemon()
assert gpu_profiler.is_monitoring_daemon_running
assert mocked_popen.call_count == 1
assert mocked_popen.call_args[0][0] == [
"/usr/bin/fake_dynolog",
"--enable_ipc_monitor",
"--port",
str(gpu_profiler._DYNOLOG_PORT),
]
# "Terminate" the daemon
mocked_proc.poll.return_value = 0
assert not gpu_profiler.is_monitoring_daemon_running
@pytest.mark.asyncio
async def test_gpu_profile_disabled(tmp_path):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
assert not gpu_profiler.enabled
success, output = await gpu_profiler.gpu_profile(pid=123, num_iterations=1)
assert not success
assert output == gpu_profiler._DISABLED_ERROR_MESSAGE.format(
ip_address=gpu_profiler._ip_address
)
@pytest.mark.asyncio
async def test_gpu_profile_without_starting_daemon(
tmp_path, mock_node_has_gpus, mock_dynolog_binaries
):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
assert not gpu_profiler.is_monitoring_daemon_running
with pytest.raises(RuntimeError, match="start_monitoring_daemon"):
await gpu_profiler.gpu_profile(pid=123, num_iterations=1)
@pytest.mark.asyncio
async def test_gpu_profile_with_dead_daemon(
tmp_path, mock_node_has_gpus, mock_dynolog_binaries, mock_subprocess_popen
):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
gpu_profiler.start_monitoring_daemon()
mocked_popen, mocked_proc = mock_subprocess_popen
mocked_proc.pid = 123
# "Terminate" the daemon
mocked_proc.poll.return_value = 0
assert not gpu_profiler.is_monitoring_daemon_running
success, output = await gpu_profiler.gpu_profile(pid=456, num_iterations=1)
assert not success
print(output)
assert "GPU monitoring daemon" in output
@pytest.mark.asyncio
async def test_gpu_profile_on_dead_process(
tmp_path,
monkeypatch,
mock_node_has_gpus,
mock_dynolog_binaries,
mock_subprocess_popen,
):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
gpu_profiler.start_monitoring_daemon()
_, mocked_proc = mock_subprocess_popen
mocked_proc.pid = 123
mocked_proc.poll.return_value = None
monkeypatch.setattr(GpuProfilingManager, "is_pid_alive", lambda cls, pid: False)
success, output = await gpu_profiler.gpu_profile(pid=456, num_iterations=1)
assert not success
assert output == gpu_profiler._DEAD_PROCESS_ERROR_MESSAGE.format(
pid=456, ip_address=gpu_profiler._ip_address
)
@pytest.mark.asyncio
async def test_gpu_profile_no_matched_processes(
tmp_path,
monkeypatch,
mock_node_has_gpus,
mock_dynolog_binaries,
mock_subprocess_popen,
mock_asyncio_create_subprocess_exec,
):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
gpu_profiler.start_monitoring_daemon()
# Mock the daemon process
_, mocked_daemon_proc = mock_subprocess_popen
mocked_daemon_proc.pid = 123
mocked_daemon_proc.poll.return_value = None
monkeypatch.setattr(GpuProfilingManager, "is_pid_alive", lambda cls, pid: True)
# Mock the asyncio.create_subprocess_exec
(
mocked_create_subprocess_exec,
mocked_async_proc,
) = mock_asyncio_create_subprocess_exec
mocked_async_proc.communicate.return_value = (
f"{gpu_profiler._NO_PROCESSES_MATCHED_ERROR_MESSAGE_PREFIX}".encode(),
b"dummy stderr",
)
process_pid = 456
num_iterations = 1
success, output = await gpu_profiler.gpu_profile(
pid=process_pid, num_iterations=num_iterations
)
assert mocked_create_subprocess_exec.call_count == 1
assert not success
assert output == gpu_profiler._NO_PROCESSES_MATCHED_ERROR_MESSAGE.format(
pid=process_pid, ip_address=gpu_profiler._ip_address
)
@pytest.mark.asyncio
async def test_gpu_profile_timeout(
tmp_path,
monkeypatch,
mock_node_has_gpus,
mock_dynolog_binaries,
mock_subprocess_popen,
mock_asyncio_create_subprocess_exec,
):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
gpu_profiler.start_monitoring_daemon()
# Mock the daemon process
_, mocked_daemon_proc = mock_subprocess_popen
mocked_daemon_proc.pid = 123
mocked_daemon_proc.poll.return_value = None
monkeypatch.setattr(GpuProfilingManager, "is_pid_alive", lambda cls, pid: True)
process_pid = 456
num_iterations = 1
task = asyncio.create_task(
gpu_profiler.gpu_profile(
pid=process_pid, num_iterations=num_iterations, _timeout_s=0.1
)
)
await asyncio.sleep(0.2)
success, output = await task
assert not success
assert "timed out" in output
@pytest.mark.asyncio
async def test_gpu_profile_process_dies_during_profiling(
tmp_path,
monkeypatch,
mock_node_has_gpus,
mock_dynolog_binaries,
mock_subprocess_popen,
mock_asyncio_create_subprocess_exec,
):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
gpu_profiler.start_monitoring_daemon()
# Mock the daemon process
_, mocked_daemon_proc = mock_subprocess_popen
mocked_daemon_proc.pid = 123
mocked_daemon_proc.poll.return_value = None
monkeypatch.setattr(GpuProfilingManager, "is_pid_alive", lambda cls, pid: True)
process_pid = 456
num_iterations = 1
task = asyncio.create_task(
gpu_profiler.gpu_profile(pid=process_pid, num_iterations=num_iterations)
)
monkeypatch.setattr(GpuProfilingManager, "is_pid_alive", lambda cls, pid: False)
await asyncio.sleep(0.2)
success, output = await task
assert not success
assert output == gpu_profiler._DEAD_PROCESS_ERROR_MESSAGE.format(
pid=process_pid, ip_address=gpu_profiler._ip_address
)
@pytest.mark.asyncio
async def test_gpu_profile_success(
tmp_path,
monkeypatch,
mock_node_has_gpus,
mock_dynolog_binaries,
mock_subprocess_popen,
mock_asyncio_create_subprocess_exec,
):
gpu_profiler = GpuProfilingManager(tmp_path, ip_address=LOCALHOST)
gpu_profiler.start_monitoring_daemon()
# Mock the daemon process
_, mocked_daemon_proc = mock_subprocess_popen
mocked_daemon_proc.pid = 123
mocked_daemon_proc.poll.return_value = None
monkeypatch.setattr(GpuProfilingManager, "is_pid_alive", lambda cls, pid: True)
monkeypatch.setattr(
GpuProfilingManager, "_get_trace_filename", lambda cls: "dummy_trace.json"
)
dumped_trace_filepath = gpu_profiler._profile_dir_path / "dummy_trace.json"
dumped_trace_filepath.touch()
# Mock the asyncio.create_subprocess_exec
(
mocked_create_subprocess_exec,
mocked_async_proc,
) = mock_asyncio_create_subprocess_exec
process_pid = 456
num_iterations = 1
success, output = await gpu_profiler.gpu_profile(
pid=process_pid, num_iterations=num_iterations
)
# Verify the command was launched correctly
assert mocked_create_subprocess_exec.call_count == 1
profile_launch_args = list(mocked_create_subprocess_exec.call_args[0])
assert profile_launch_args[:6] == [
"/usr/bin/fake_dyno",
"--port",
str(gpu_profiler._DYNOLOG_PORT),
"gputrace",
"--pids",
str(process_pid),
]
assert "--log-file" in profile_launch_args
profile_log_file_arg = profile_launch_args[
profile_launch_args.index("--log-file") + 1
]
assert Path(profile_log_file_arg).is_relative_to(tmp_path)
assert "--iterations" in profile_launch_args
assert profile_launch_args[profile_launch_args.index("--iterations") + 1] == str(
num_iterations
)
assert success
assert output == str(dumped_trace_filepath.relative_to(tmp_path))
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,629 @@
"""Unit tests for GPU providers."""
import unittest
from unittest.mock import Mock, patch
from ray.dashboard.modules.reporter.gpu_providers import (
MB,
AmdGpuProvider,
GpuMetricProvider,
GpuProvider,
GpuProviderType,
GpuUtilizationInfo,
NvidiaGpuProvider,
ProcessGPUInfo,
)
class TestProcessGPUInfo(unittest.TestCase):
"""Test ProcessGPUInfo TypedDict."""
def test_creation(self):
"""Test ProcessGPUInfo creation."""
process_info = ProcessGPUInfo(
pid=1234, gpu_memory_usage=256, gpu_utilization=None
)
self.assertEqual(process_info["pid"], 1234)
self.assertEqual(process_info["gpu_memory_usage"], 256)
self.assertIsNone(process_info["gpu_utilization"])
class TestGpuUtilizationInfo(unittest.TestCase):
"""Test GpuUtilizationInfo TypedDict."""
def test_creation_with_processes(self):
"""Test GpuUtilizationInfo with process information."""
process1 = ProcessGPUInfo(pid=1234, gpu_memory_usage=256, gpu_utilization=None)
process2 = ProcessGPUInfo(pid=5678, gpu_memory_usage=512, gpu_utilization=None)
gpu_info = GpuUtilizationInfo(
index=0,
name="NVIDIA GeForce RTX 3080",
uuid="GPU-12345678-1234-1234-1234-123456789abc",
utilization_gpu=75,
memory_used=8192,
memory_total=10240,
processes_pids={1234: process1, 5678: process2},
)
self.assertEqual(gpu_info["index"], 0)
self.assertEqual(gpu_info["name"], "NVIDIA GeForce RTX 3080")
self.assertEqual(gpu_info["uuid"], "GPU-12345678-1234-1234-1234-123456789abc")
self.assertEqual(gpu_info["utilization_gpu"], 75)
self.assertEqual(gpu_info["memory_used"], 8192)
self.assertEqual(gpu_info["memory_total"], 10240)
self.assertEqual(len(gpu_info["processes_pids"]), 2)
self.assertIn(1234, gpu_info["processes_pids"])
self.assertIn(5678, gpu_info["processes_pids"])
self.assertEqual(gpu_info["processes_pids"][1234]["pid"], 1234)
self.assertEqual(gpu_info["processes_pids"][1234]["gpu_memory_usage"], 256)
self.assertEqual(gpu_info["processes_pids"][5678]["pid"], 5678)
self.assertEqual(gpu_info["processes_pids"][5678]["gpu_memory_usage"], 512)
def test_creation_without_processes(self):
"""Test GpuUtilizationInfo without process information."""
gpu_info = GpuUtilizationInfo(
index=1,
name="AMD Radeon RX 6800 XT",
uuid="GPU-87654321-4321-4321-4321-ba9876543210",
utilization_gpu=None,
memory_used=4096,
memory_total=16384,
processes_pids=None,
)
self.assertEqual(gpu_info["index"], 1)
self.assertEqual(gpu_info["name"], "AMD Radeon RX 6800 XT")
self.assertEqual(gpu_info["uuid"], "GPU-87654321-4321-4321-4321-ba9876543210")
self.assertIsNone(gpu_info["utilization_gpu"]) # Should be None, not -1
self.assertEqual(gpu_info["memory_used"], 4096)
self.assertEqual(gpu_info["memory_total"], 16384)
self.assertIsNone(gpu_info["processes_pids"]) # Should be None, not []
class TestGpuProvider(unittest.TestCase):
"""Test abstract GpuProvider class."""
def test_decode_bytes(self):
"""Test _decode method with bytes input."""
result = GpuProvider._decode(b"test string")
self.assertEqual(result, "test string")
def test_decode_string(self):
"""Test _decode method with string input."""
result = GpuProvider._decode("test string")
self.assertEqual(result, "test string")
def test_abstract_methods_not_implemented(self):
"""Test that abstract methods raise NotImplementedError."""
class IncompleteProvider(GpuProvider):
pass
with self.assertRaises(TypeError):
IncompleteProvider()
class TestNvidiaGpuProvider(unittest.TestCase):
"""Test NvidiaGpuProvider class."""
def setUp(self):
"""Set up test fixtures."""
self.provider = NvidiaGpuProvider()
def test_get_provider_name(self):
"""Test provider name."""
self.assertEqual(self.provider.get_provider_name(), GpuProviderType.NVIDIA)
@patch("ray._private.thirdparty.pynvml", create=True)
def test_is_available_success(self, mock_pynvml):
"""Test is_available when NVIDIA GPU is available."""
mock_pynvml.nvmlInit.return_value = None
mock_pynvml.nvmlShutdown.return_value = None
# Mock sys.modules to make the import work
import sys
original_modules = sys.modules.copy()
sys.modules["ray._private.thirdparty.pynvml"] = mock_pynvml
try:
self.assertTrue(self.provider.is_available())
mock_pynvml.nvmlInit.assert_called_once()
mock_pynvml.nvmlShutdown.assert_called_once()
finally:
# Restore original modules
sys.modules.clear()
sys.modules.update(original_modules)
@patch("ray._private.thirdparty.pynvml", create=True)
def test_is_available_failure(self, mock_pynvml):
"""Test is_available when NVIDIA GPU is not available."""
mock_pynvml.nvmlInit.side_effect = Exception("NVIDIA driver not found")
# Mock sys.modules to make the import work but nvmlInit fail
import sys
original_modules = sys.modules.copy()
sys.modules["ray._private.thirdparty.pynvml"] = mock_pynvml
try:
self.assertFalse(self.provider.is_available())
finally:
# Restore original modules
sys.modules.clear()
sys.modules.update(original_modules)
@patch("ray._private.thirdparty.pynvml", create=True)
def test_initialize_success(self, mock_pynvml):
"""Test successful initialization."""
# Ensure provider starts fresh
self.provider._initialized = False
mock_pynvml.nvmlInit.return_value = None
# Mock sys.modules to make the import work
import sys
original_modules = sys.modules.copy()
sys.modules["ray._private.thirdparty.pynvml"] = mock_pynvml
try:
self.assertTrue(self.provider._initialize())
self.assertTrue(self.provider._initialized)
mock_pynvml.nvmlInit.assert_called_once()
finally:
# Restore original modules
sys.modules.clear()
sys.modules.update(original_modules)
@patch("ray._private.thirdparty.pynvml", create=True)
def test_initialize_failure(self, mock_pynvml):
"""Test failed initialization."""
# Ensure provider starts fresh
self.provider._initialized = False
# Make nvmlInit fail
mock_pynvml.nvmlInit.side_effect = Exception("Initialization failed")
# Mock sys.modules to make the import work but nvmlInit fail
import sys
original_modules = sys.modules.copy()
sys.modules["ray._private.thirdparty.pynvml"] = mock_pynvml
try:
self.assertFalse(self.provider._initialize())
self.assertFalse(self.provider._initialized)
finally:
# Restore original modules
sys.modules.clear()
sys.modules.update(original_modules)
@patch("ray._private.thirdparty.pynvml", create=True)
def test_initialize_already_initialized(self, mock_pynvml):
"""Test initialization when already initialized."""
self.provider._initialized = True
self.assertTrue(self.provider._initialize())
mock_pynvml.nvmlInit.assert_not_called()
@patch("ray._private.thirdparty.pynvml", create=True)
def test_shutdown(self, mock_pynvml):
"""Test shutdown."""
self.provider._initialized = True
self.provider._pynvml = mock_pynvml
self.provider._shutdown()
self.assertFalse(self.provider._initialized)
mock_pynvml.nvmlShutdown.assert_called_once()
@patch("ray._private.thirdparty.pynvml", create=True)
def test_shutdown_not_initialized(self, mock_pynvml):
"""Test shutdown when not initialized."""
self.provider._shutdown()
mock_pynvml.nvmlShutdown.assert_not_called()
@patch("ray._private.thirdparty.pynvml", create=True)
def test_get_gpu_utilization_success(self, mock_pynvml):
"""Test successful GPU utilization retrieval."""
# Mock GPU device
mock_handle = Mock()
mock_memory_info = Mock()
mock_memory_info.used = 8 * MB * 1024 # 8GB used
mock_memory_info.total = 12 * MB * 1024 # 12GB total
mock_utilization_info = Mock()
mock_utilization_info.gpu = 75
mock_process = Mock()
mock_process.pid = 1234
mock_process.usedGpuMemory = 256 * MB
# Configure mocks
mock_pynvml.nvmlInit.return_value = None
mock_pynvml.nvmlDeviceGetCount.return_value = 1
mock_pynvml.nvmlDeviceGetHandleByIndex.return_value = mock_handle
mock_pynvml.nvmlDeviceGetMemoryInfo.return_value = mock_memory_info
mock_pynvml.nvmlDeviceGetUtilizationRates.return_value = mock_utilization_info
mock_pynvml.nvmlDeviceGetComputeRunningProcesses.return_value = [mock_process]
mock_pynvml.nvmlDeviceGetGraphicsRunningProcesses.return_value = []
mock_pynvml.nvmlDeviceGetName.return_value = b"NVIDIA GeForce RTX 3080"
mock_pynvml.nvmlDeviceGetUUID.return_value = (
b"GPU-12345678-1234-1234-1234-123456789abc"
)
mock_pynvml.nvmlShutdown.return_value = None
# Set up provider state
self.provider._pynvml = mock_pynvml
self.provider._initialized = True
result = self.provider.get_gpu_utilization()
self.assertEqual(len(result), 1)
gpu_info = result[0]
self.assertEqual(gpu_info["index"], 0)
self.assertEqual(gpu_info["name"], "NVIDIA GeForce RTX 3080")
self.assertEqual(gpu_info["uuid"], "GPU-12345678-1234-1234-1234-123456789abc")
self.assertEqual(gpu_info["utilization_gpu"], 75)
self.assertEqual(gpu_info["memory_used"], 8 * 1024) # 8GB in MB
self.assertEqual(gpu_info["memory_total"], 12 * 1024) # 12GB in MB
self.assertEqual(len(gpu_info["processes_pids"]), 1)
self.assertEqual(gpu_info["processes_pids"][1234]["pid"], 1234)
self.assertEqual(gpu_info["processes_pids"][1234]["gpu_memory_usage"], 256)
@patch("ray._private.thirdparty.pynvml", create=True)
def test_get_gpu_utilization_with_errors(self, mock_pynvml):
"""Test GPU utilization retrieval with partial errors."""
mock_handle = Mock()
mock_memory_info = Mock()
mock_memory_info.used = 4 * MB * 1024
mock_memory_info.total = 8 * MB * 1024
# Create mock NVML error class
class MockNVMLError(Exception):
pass
mock_pynvml.NVMLError = MockNVMLError
# Configure mocks with some failures
mock_pynvml.nvmlInit.return_value = None
mock_pynvml.nvmlDeviceGetCount.return_value = 1
mock_pynvml.nvmlDeviceGetHandleByIndex.return_value = mock_handle
mock_pynvml.nvmlDeviceGetMemoryInfo.return_value = mock_memory_info
mock_pynvml.nvmlDeviceGetUtilizationRates.side_effect = MockNVMLError(
"Utilization not available"
)
mock_pynvml.nvmlDeviceGetComputeRunningProcesses.side_effect = MockNVMLError(
"Process info not available"
)
mock_pynvml.nvmlDeviceGetGraphicsRunningProcesses.side_effect = MockNVMLError(
"Process info not available"
)
mock_pynvml.nvmlDeviceGetName.return_value = b"NVIDIA Tesla V100"
mock_pynvml.nvmlDeviceGetUUID.return_value = (
b"GPU-87654321-4321-4321-4321-ba9876543210"
)
mock_pynvml.nvmlShutdown.return_value = None
# Set up provider state
self.provider._pynvml = mock_pynvml
self.provider._initialized = True
result = self.provider.get_gpu_utilization()
self.assertEqual(len(result), 1)
gpu_info = result[0]
self.assertEqual(gpu_info["index"], 0)
self.assertEqual(gpu_info["name"], "NVIDIA Tesla V100")
self.assertEqual(gpu_info["utilization_gpu"], -1) # Should be -1 due to error
self.assertEqual(
gpu_info["processes_pids"], {}
) # Should be empty dict due to error
@patch("ray._private.thirdparty.pynvml", create=True)
def test_get_gpu_utilization_with_mig(self, mock_pynvml):
"""Test GPU utilization retrieval with MIG devices."""
# Mock regular GPU handle
mock_gpu_handle = Mock()
mock_memory_info = Mock()
mock_memory_info.used = 4 * MB * 1024
mock_memory_info.total = 8 * MB * 1024
# Mock MIG device handle and info
mock_mig_handle = Mock()
mock_mig_memory_info = Mock()
mock_mig_memory_info.used = 2 * MB * 1024
mock_mig_memory_info.total = 4 * MB * 1024
mock_mig_utilization_info = Mock()
mock_mig_utilization_info.gpu = 80
# Configure mocks for MIG-enabled GPU
mock_pynvml.nvmlInit.return_value = None
mock_pynvml.nvmlDeviceGetCount.return_value = 1
mock_pynvml.nvmlDeviceGetHandleByIndex.return_value = mock_gpu_handle
# MIG mode enabled
mock_pynvml.nvmlDeviceGetMigMode.return_value = (
True,
True,
) # (current, pending)
mock_pynvml.nvmlDeviceGetMaxMigDeviceCount.return_value = 1 # Only 1 MIG device
mock_pynvml.nvmlDeviceGetMigDeviceHandleByIndex.return_value = mock_mig_handle
# MIG device info
mock_pynvml.nvmlDeviceGetMemoryInfo.return_value = mock_mig_memory_info
mock_pynvml.nvmlDeviceGetUtilizationRates.return_value = (
mock_mig_utilization_info
)
mock_pynvml.nvmlDeviceGetComputeRunningProcesses.return_value = []
mock_pynvml.nvmlDeviceGetGraphicsRunningProcesses.return_value = []
mock_pynvml.nvmlDeviceGetName.return_value = b"NVIDIA A100-SXM4-40GB MIG 1g.5gb"
mock_pynvml.nvmlDeviceGetUUID.return_value = (
b"MIG-12345678-1234-1234-1234-123456789abc"
)
mock_pynvml.nvmlShutdown.return_value = None
# Set up provider state
self.provider._pynvml = mock_pynvml
self.provider._initialized = True
result = self.provider.get_gpu_utilization()
# Should return MIG device info instead of regular GPU
self.assertEqual(
len(result), 1
) # Only one MIG device due to exception handling
gpu_info = result[0]
self.assertEqual(gpu_info["index"], 0) # First MIG device (0 * 1000 + 0)
self.assertEqual(gpu_info["name"], "NVIDIA A100-SXM4-40GB MIG 1g.5gb")
self.assertEqual(gpu_info["uuid"], "MIG-12345678-1234-1234-1234-123456789abc")
self.assertEqual(gpu_info["utilization_gpu"], 80)
self.assertEqual(gpu_info["memory_used"], 2 * 1024) # 2GB in MB
self.assertEqual(gpu_info["memory_total"], 4 * 1024) # 4GB in MB
self.assertEqual(gpu_info["processes_pids"], {})
class TestAmdGpuProvider(unittest.TestCase):
"""Test AmdGpuProvider class."""
def setUp(self):
"""Set up test fixtures."""
self.provider = AmdGpuProvider()
def test_get_provider_name(self):
"""Test provider name."""
self.assertEqual(self.provider.get_provider_name(), GpuProviderType.AMD)
@patch("ray._private.thirdparty.pyamdsmi", create=True)
def test_is_available_success(self, mock_pyamdsmi):
"""Test is_available when AMD GPU is available."""
mock_pyamdsmi.smi_initialize.return_value = None
mock_pyamdsmi.smi_shutdown.return_value = None
self.assertTrue(self.provider.is_available())
mock_pyamdsmi.smi_initialize.assert_called_once()
mock_pyamdsmi.smi_shutdown.assert_called_once()
@patch("ray._private.thirdparty.pyamdsmi", create=True)
def test_is_available_failure(self, mock_pyamdsmi):
"""Test is_available when AMD GPU is not available."""
mock_pyamdsmi.smi_initialize.side_effect = Exception("AMD driver not found")
self.assertFalse(self.provider.is_available())
@patch("ray._private.thirdparty.pyamdsmi", create=True)
def test_initialize_success(self, mock_pyamdsmi):
"""Test successful initialization."""
mock_pyamdsmi.smi_initialize.return_value = None
self.assertTrue(self.provider._initialize())
self.assertTrue(self.provider._initialized)
mock_pyamdsmi.smi_initialize.assert_called_once()
@patch("ray._private.thirdparty.pyamdsmi", create=True)
def test_get_gpu_utilization_success(self, mock_pyamdsmi):
"""Test successful GPU utilization retrieval."""
mock_process = Mock()
mock_process.process_id = 5678
mock_process.vram_usage = 512 * MB
# Configure mocks
mock_pyamdsmi.smi_initialize.return_value = None
mock_pyamdsmi.smi_get_device_count.return_value = 1
mock_pyamdsmi.smi_get_device_id.return_value = "device_0"
mock_pyamdsmi.smi_get_device_utilization.return_value = 85
mock_pyamdsmi.smi_get_device_compute_process.return_value = [mock_process]
mock_pyamdsmi.smi_get_compute_process_info_by_device.return_value = [
mock_process
]
mock_pyamdsmi.smi_get_device_name.return_value = b"AMD Radeon RX 6800 XT"
mock_pyamdsmi.smi_get_device_unique_id.return_value = (
"GPU-13579bdf-9abc-def0-0000-000000000000"
)
mock_pyamdsmi.smi_get_device_memory_used.return_value = 6 * MB * 1024
mock_pyamdsmi.smi_get_device_memory_total.return_value = 16 * MB * 1024
mock_pyamdsmi.smi_shutdown.return_value = None
# Set up provider state
self.provider._pyamdsmi = mock_pyamdsmi
self.provider._initialized = True
result = self.provider.get_gpu_utilization()
self.assertEqual(len(result), 1)
gpu_info = result[0]
self.assertEqual(gpu_info["index"], 0)
self.assertEqual(gpu_info["name"], "AMD Radeon RX 6800 XT")
self.assertEqual(gpu_info["uuid"], "GPU-13579bdf-9abc-def0-0000-000000000000")
self.assertEqual(gpu_info["utilization_gpu"], 85)
self.assertEqual(gpu_info["memory_used"], 6 * 1024) # 6GB in MB
self.assertEqual(gpu_info["memory_total"], 16 * 1024) # 16GB in MB
self.assertEqual(len(gpu_info["processes_pids"]), 1)
self.assertEqual(gpu_info["processes_pids"][5678]["pid"], 5678)
self.assertEqual(gpu_info["processes_pids"][5678]["gpu_memory_usage"], 512)
class TestGpuMetricProvider(unittest.TestCase):
"""Test GpuMetricProvider class."""
def setUp(self):
"""Set up test fixtures."""
self.provider = GpuMetricProvider()
def test_init(self):
"""Test GpuMetricProvider initialization."""
self.assertIsNone(self.provider._provider)
self.assertTrue(self.provider._enable_metric_report)
self.assertEqual(len(self.provider._providers), 2)
self.assertFalse(self.provider._initialized)
@patch.object(NvidiaGpuProvider, "is_available", return_value=True)
@patch.object(AmdGpuProvider, "is_available", return_value=False)
def test_detect_gpu_provider_nvidia(
self, mock_amd_available, mock_nvidia_available
):
"""Test GPU provider detection when NVIDIA is available."""
provider = self.provider._detect_gpu_provider()
self.assertIsInstance(provider, NvidiaGpuProvider)
mock_nvidia_available.assert_called_once()
@patch.object(NvidiaGpuProvider, "is_available", return_value=False)
@patch.object(AmdGpuProvider, "is_available", return_value=True)
def test_detect_gpu_provider_amd(self, mock_amd_available, mock_nvidia_available):
"""Test GPU provider detection when AMD is available."""
provider = self.provider._detect_gpu_provider()
self.assertIsInstance(provider, AmdGpuProvider)
mock_nvidia_available.assert_called_once()
mock_amd_available.assert_called_once()
@patch.object(NvidiaGpuProvider, "is_available", return_value=False)
@patch.object(AmdGpuProvider, "is_available", return_value=False)
def test_detect_gpu_provider_none(self, mock_amd_available, mock_nvidia_available):
"""Test GPU provider detection when no GPUs are available."""
provider = self.provider._detect_gpu_provider()
self.assertIsNone(provider)
@patch("subprocess.check_output")
def test_should_disable_gpu_check_true(self, mock_subprocess):
"""Test should_disable_gpu_check returns True for specific conditions."""
mock_subprocess.return_value = "" # Empty result means AMD GPU module not live
class MockNVMLError(Exception):
pass
MockNVMLError.__name__ = "NVMLError_DriverNotLoaded"
error = MockNVMLError("NVIDIA driver not loaded")
result = self.provider._should_disable_gpu_check(error)
self.assertTrue(result)
@patch("subprocess.check_output")
def test_should_disable_gpu_check_false_wrong_error(self, mock_subprocess):
"""Test should_disable_gpu_check returns False for wrong error type."""
mock_subprocess.return_value = ""
error = Exception("Some other error")
result = self.provider._should_disable_gpu_check(error)
self.assertFalse(result)
@patch("subprocess.check_output")
def test_should_disable_gpu_check_false_amd_present(self, mock_subprocess):
"""Test should_disable_gpu_check returns False when AMD GPU is present."""
mock_subprocess.return_value = "live" # AMD GPU module is live
class MockNVMLError(Exception):
pass
MockNVMLError.__name__ = "NVMLError_DriverNotLoaded"
error = MockNVMLError("NVIDIA driver not loaded")
result = self.provider._should_disable_gpu_check(error)
self.assertFalse(result)
def test_get_gpu_usage_disabled(self):
"""Test get_gpu_usage when GPU usage check is disabled."""
self.provider._enable_metric_report = False
result = self.provider.get_gpu_usage()
self.assertEqual(result, [])
@patch.object(GpuMetricProvider, "_detect_gpu_provider")
def test_get_gpu_usage_no_provider(self, mock_detect):
"""Test get_gpu_usage when no GPU provider is available."""
mock_detect.return_value = None
with patch.object(
NvidiaGpuProvider, "_initialize", side_effect=Exception("No GPU")
):
result = self.provider.get_gpu_usage()
self.assertEqual(result, [])
self.provider._initialized = False # Reset for clean test
mock_detect.assert_called_once()
@patch.object(GpuMetricProvider, "_detect_gpu_provider")
def test_get_gpu_usage_success(self, mock_detect):
"""Test successful get_gpu_usage."""
mock_provider = Mock()
mock_provider.get_gpu_utilization.return_value = [
GpuUtilizationInfo(
index=0,
name="Test GPU",
uuid="test-uuid",
utilization_gpu=50,
memory_used=1024,
memory_total=2048,
processes_pids={
1234: ProcessGPUInfo(
pid=1234, gpu_memory_usage=1024, gpu_utilization=None
)
},
)
]
mock_detect.return_value = mock_provider
result = self.provider.get_gpu_usage()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["index"], 0)
self.assertEqual(result[0]["name"], "Test GPU")
mock_provider.get_gpu_utilization.assert_called_once()
def test_get_provider_name_no_provider(self):
"""Test get_provider_name when no provider is set."""
result = self.provider.get_provider_name()
self.assertIsNone(result)
def test_get_provider_name_with_provider(self):
"""Test get_provider_name when provider is set."""
mock_provider = Mock()
mock_provider.get_provider_name.return_value = GpuProviderType.NVIDIA
self.provider._provider = mock_provider
result = self.provider.get_provider_name()
self.assertEqual(result, "nvidia")
def test_is_metric_report_enabled(self):
"""Test is_metric_report_enabled."""
self.assertTrue(self.provider.is_metric_report_enabled())
self.provider._enable_metric_report = False
self.assertFalse(self.provider.is_metric_report_enabled())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,121 @@
import signal
import sys
import pytest
import requests
import ray._private.ray_constants as ray_constants
from ray._common.network_utils import find_free_port
from ray._common.test_utils import wait_for_condition
from ray.tests.conftest import * # noqa: F401 F403
def test_healthz_head(monkeypatch, ray_start_cluster):
dashboard_port = find_free_port()
h = ray_start_cluster.add_node(dashboard_port=dashboard_port)
uri = f"http://localhost:{dashboard_port}/api/gcs_healthz"
wait_for_condition(lambda: requests.get(uri).status_code == 200)
h.all_processes[ray_constants.PROCESS_TYPE_GCS_SERVER][0].process.kill()
# It'll either timeout or just return an error
try:
wait_for_condition(lambda: requests.get(uri, timeout=1) != 200, timeout=4)
except RuntimeError as e:
assert "Read timed out" in str(e)
def test_healthz_agent_1(monkeypatch, ray_start_cluster):
agent_port = find_free_port()
h = ray_start_cluster.add_node(dashboard_agent_listen_port=agent_port)
uri = f"http://{h.node_ip_address}:{agent_port}/api/local_raylet_healthz"
wait_for_condition(lambda: requests.get(uri).status_code == 200)
h.all_processes[ray_constants.PROCESS_TYPE_GCS_SERVER][0].process.kill()
# GCS's failure will not lead to healthz failure
assert requests.get(uri).status_code == 200
@pytest.mark.skipif(sys.platform == "win32", reason="SIGSTOP only on posix")
def test_healthz_agent_2(monkeypatch, ray_start_cluster):
monkeypatch.setenv("RAY_health_check_failure_threshold", "3")
monkeypatch.setenv("RAY_health_check_timeout_ms", "100")
monkeypatch.setenv("RAY_health_check_period_ms", "1000")
monkeypatch.setenv("RAY_health_check_initial_delay_ms", "0")
agent_port = find_free_port()
h = ray_start_cluster.add_node(dashboard_agent_listen_port=agent_port)
uri = f"http://{h.node_ip_address}:{agent_port}/api/local_raylet_healthz"
wait_for_condition(lambda: requests.get(uri).status_code == 200)
h.all_processes[ray_constants.PROCESS_TYPE_RAYLET][0].process.send_signal(
signal.SIGSTOP
)
# GCS still think raylet is alive.
assert requests.get(uri).status_code == 200
# But after heartbeat timeout, it'll think the raylet is down.
wait_for_condition(lambda: requests.get(uri).status_code != 200)
def test_unified_healthz_head(monkeypatch, ray_start_cluster):
agent_port = find_free_port()
h = ray_start_cluster.add_node(dashboard_agent_listen_port=agent_port)
uri = f"http://{h.node_ip_address}:{agent_port}/api/healthz"
wait_for_condition(lambda: requests.get(uri).status_code == 200)
resp = requests.get(uri)
assert "raylet: success" in resp.text
assert "gcs: success" in resp.text
h.all_processes[ray_constants.PROCESS_TYPE_GCS_SERVER][0].process.kill()
wait_for_condition(lambda: requests.get(uri).status_code == 503)
resp = requests.get(uri)
assert "gcs: " in resp.text
assert "gcs: success" not in resp.text
@pytest.mark.skipif(sys.platform == "win32", reason="SIGSTOP only on posix")
def test_unified_healthz_worker(monkeypatch, ray_start_cluster):
monkeypatch.setenv("RAY_health_check_failure_threshold", "3")
monkeypatch.setenv("RAY_health_check_timeout_ms", "100")
monkeypatch.setenv("RAY_health_check_period_ms", "1000")
monkeypatch.setenv("RAY_health_check_initial_delay_ms", "0")
ray_start_cluster.add_node()
agent_port = find_free_port()
h = ray_start_cluster.add_node(dashboard_agent_listen_port=agent_port)
uri = f"http://{h.node_ip_address}:{agent_port}/api/healthz"
wait_for_condition(lambda: requests.get(uri).status_code == 200)
resp = requests.get(uri)
assert "gcs: success (no local gcs)" in resp.text
# Stop local raylet and verify this makes /healthz fail.
h.all_processes[ray_constants.PROCESS_TYPE_RAYLET][0].process.send_signal(
signal.SIGSTOP
)
wait_for_condition(lambda: requests.get(uri).status_code == 503)
resp = requests.get(uri)
assert "raylet: Local Raylet failed" in resp.text
def test_unified_healthz_worker_gcs_down(monkeypatch, ray_start_cluster):
h_head = ray_start_cluster.add_node()
agent_port = find_free_port()
h_worker = ray_start_cluster.add_node(dashboard_agent_listen_port=agent_port)
uri = f"http://{h_worker.node_ip_address}:{agent_port}/api/healthz"
wait_for_condition(lambda: requests.get(uri).status_code == 200)
resp = requests.get(uri)
assert "gcs: success (no local gcs)" in resp.text
# Stop the head GCS server.
h_head.all_processes[ray_constants.PROCESS_TYPE_GCS_SERVER][0].process.kill()
# Worker health check should still succeed.
assert requests.get(uri).status_code == 200
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,106 @@
import sys
from unittest.mock import MagicMock, patch
import pytest
from ray.dashboard.modules.reporter.jax_profile_manager import JaxProfilingManager
from ray.util.tpu import init_jax_profiler
@pytest.fixture
def mock_profiler_client():
mock_client = MagicMock()
mock_profiler_module = MagicMock()
mock_profiler_module.profiler_client = mock_client
modules_to_patch = {
"tensorflow": MagicMock(),
"tensorflow.python": MagicMock(),
"tensorflow.python.profiler": mock_profiler_module,
}
with patch.dict("sys.modules", modules_to_patch):
yield mock_client
@pytest.mark.asyncio
async def test_jax_profile_success(tmp_path, mock_profiler_client):
manager = JaxProfilingManager(tmp_path)
# Mock success
mock_profiler_client.trace.return_value = None
success, output = await manager.jax_profile(pid=123, port=6000, duration_s=2)
assert success
assert output.startswith("profiles")
assert "123_" in output
mock_profiler_client.trace.assert_called_once()
call = mock_profiler_client.trace.call_args
assert call.args[0] == "grpc://localhost:6000"
assert call.kwargs["logdir"].startswith(str(tmp_path / "profiles"))
assert call.kwargs["duration_ms"] == 2000
@pytest.mark.asyncio
async def test_jax_profile_failure(tmp_path, mock_profiler_client):
manager = JaxProfilingManager(tmp_path)
# Mock failure
mock_profiler_client.trace.side_effect = Exception("Connection failed")
success, output = await manager.jax_profile(pid=123, port=6000, duration_s=2)
assert not success
assert "Failed to capture trace: Connection failed" in output
@pytest.mark.asyncio
async def test_jax_profile_no_tensorflow(tmp_path):
manager = JaxProfilingManager(tmp_path)
# Force ImportError on tensorflow
with patch.dict("sys.modules", {"tensorflow": None}):
success, output = await manager.jax_profile(pid=123, port=6000, duration_s=2)
assert not success
assert "TensorFlow is required" in output
@patch("ray.util.tpu.os.getpid")
@patch("ray.util.tpu.os.getenv")
def test_setup_jax_profiler_success(mock_getenv, mock_getpid):
mock_getenv.return_value = "9999"
mock_getpid.return_value = 12345
mock_jax = MagicMock()
mock_worker = MagicMock()
mock_worker.node.node_id = "mock_node_id_hex"
with (
patch.dict("sys.modules", {"jax": mock_jax}),
patch("ray._private.worker.global_worker", mock_worker),
patch("ray.experimental.internal_kv._internal_kv_put") as mock_kv_put,
):
init_jax_profiler()
mock_jax.profiler.start_server.assert_called_once_with(9999)
import ray
mock_kv_put.assert_called_once_with(
"jax_profiler_port:mock_node_id_hex:12345",
b"9999",
namespace=ray._private.ray_constants.KV_NAMESPACE_DASHBOARD,
)
def test_setup_jax_profiler_no_jax():
with patch.dict("sys.modules", {"jax": None}):
# Should skip starting profiler and not raise error
init_jax_profiler()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,270 @@
import os
import sys
import tempfile
import time
from unittest.mock import AsyncMock, patch
import pytest
import ray
from ray.dashboard.modules.reporter.profile_manager import (
CpuProfilingManager,
MemoryProfilingManager,
)
from ray.dashboard.tests.conftest import * # noqa
@pytest.fixture
def setup_memory_profiler():
with tempfile.TemporaryDirectory() as tmpdir:
memory_profiler = MemoryProfilingManager(tmpdir)
@ray.remote
class Actor:
def getpid(self):
return os.getpid()
def long_run(self):
print("Long-running task began.")
time.sleep(1000)
print("Long-running task completed.")
actor = Actor.remote()
yield actor, memory_profiler
@pytest.mark.asyncio
@pytest.mark.skipif(
os.environ.get("RAY_MINIMAL") == "1",
reason="This test is not supposed to work for minimal installation.",
)
@pytest.mark.skipif(sys.platform == "win32", reason="No memray on Windows.")
@pytest.mark.skipif(
sys.platform == "darwin",
reason="Fails on OSX, requires memray & lldb installed in osx image",
)
class TestMemoryProfiling:
async def test_basic_attach_profiler(self, setup_memory_profiler, shutdown_only):
# test basic attach profiler to running process
actor, memory_profiler = setup_memory_profiler
pid = ray.get(actor.getpid.remote())
actor.long_run.remote()
success, profiler_filename, message = await memory_profiler.attach_profiler(
pid, verbose=True
)
assert success, message
assert f"Success attaching memray to process {pid}" in message
assert profiler_filename in os.listdir(memory_profiler.profile_dir_path)
async def test_profiler_multiple_attach(self, setup_memory_profiler, shutdown_only):
# test multiple attaches
actor, memory_profiler = setup_memory_profiler
pid = ray.get(actor.getpid.remote())
actor.long_run.remote()
success, profiler_filename, message = await memory_profiler.attach_profiler(
pid, verbose=True
)
assert success, message
assert f"Success attaching memray to process {pid}" in message
assert profiler_filename in os.listdir(memory_profiler.profile_dir_path)
success, _, message = await memory_profiler.attach_profiler(pid)
assert success, message
assert f"Success attaching memray to process {pid}" in message
async def test_detach_profiler_successful(
self, setup_memory_profiler, shutdown_only
):
# test basic detach profiler
actor, memory_profiler = setup_memory_profiler
pid = ray.get(actor.getpid.remote())
actor.long_run.remote()
success, _, message = await memory_profiler.attach_profiler(pid, verbose=True)
assert success, message
success, message = await memory_profiler.detach_profiler(pid, verbose=True)
assert success, message
assert f"Success detaching memray from process {pid}" in message
async def test_detach_profiler_without_attach(
self, setup_memory_profiler, shutdown_only
):
# test detach profiler from unattached process
actor, memory_profiler = setup_memory_profiler
pid = ray.get(actor.getpid.remote())
success, message = await memory_profiler.detach_profiler(pid)
assert not success, message
assert "Failed to execute" in message
assert "no previous `memray attach`" in message
async def test_profiler_memray_not_installed(
self, setup_memory_profiler, shutdown_only
):
# test profiler when memray is not installed
actor, memory_profiler = setup_memory_profiler
pid = ray.get(actor.getpid.remote())
with patch("shutil.which", return_value=None):
success, _, message = await memory_profiler.attach_profiler(pid)
assert not success
assert "memray is not installed" in message
async def test_profiler_attach_process_not_found(
self, setup_memory_profiler, shutdown_only
):
# test basic attach profiler to non-existing process
_, memory_profiler = setup_memory_profiler
pid = 123456
success, _, message = await memory_profiler.attach_profiler(pid)
assert not success, message
assert "Failed to execute" in message
assert "The given process ID does not exist" in message
async def test_profiler_get_profiler_result(
self, setup_memory_profiler, shutdown_only
):
# test get profiler result from running process
actor, memory_profiler = setup_memory_profiler
pid = ray.get(actor.getpid.remote())
actor.long_run.remote()
success, profiler_filename, message = await memory_profiler.attach_profiler(
pid, verbose=True
)
assert success, message
assert f"Success attaching memray to process {pid}" in message
# get profiler result in flamegraph and table format
supported_formats = ["flamegraph", "table"]
unsupported_formats = ["json"]
for format in supported_formats + unsupported_formats:
success, message = await memory_profiler.get_profile_result(
pid, profiler_filename=profiler_filename, format=format
)
if format in supported_formats:
assert success, message
assert f"{format} report" in message.decode("utf-8")
else:
assert not success, message
assert f"{format} is not supported" in message
async def test_profiler_result_not_exist(
self, setup_memory_profiler, shutdown_only
):
# test get profiler result from unexisting process
_, memory_profiler = setup_memory_profiler
pid = 123456
profiler_filename = "non-existing-file"
success, message = await memory_profiler.get_profile_result(
pid, profiler_filename=profiler_filename, format=format
)
assert not success, message
assert f"process {pid} has not been profiled" in message
@pytest.mark.asyncio
@pytest.mark.skipif(sys.platform == "win32", reason="No py-spy on Windows.")
class TestCpuProfiling:
async def _capture_pyspy_cmd(self, **cpu_profile_kwargs):
"""Run cpu_profile with subprocess execution mocked out and return the
py-spy command that would have been executed.
We patch ``asyncio.create_subprocess_exec`` (the same primitive the
manager uses) and have the fake process exit non-zero so that
``cpu_profile`` short-circuits before attempting to read the (never
created) output file. The command is fully constructed before the
subprocess is spawned, so the captured args are valid regardless.
"""
with tempfile.TemporaryDirectory() as tmpdir:
cpu_profiler = CpuProfilingManager(tmpdir)
fake_process = AsyncMock()
fake_process.communicate.return_value = (b"", b"boom")
fake_process.returncode = 1
with patch(
"ray.dashboard.modules.reporter.profile_manager.shutil.which",
return_value="/fake/py-spy",
), patch(
"ray.dashboard.modules.reporter.profile_manager."
"_can_passwordless_sudo",
new=AsyncMock(return_value=False),
), patch(
"asyncio.create_subprocess_exec",
new=AsyncMock(return_value=fake_process),
) as mock_exec:
await cpu_profiler.cpu_profile(pid=12345, **cpu_profile_kwargs)
assert mock_exec.call_count == 1
# create_subprocess_exec(*cmd, ...) -> positional args are the cmd.
return list(mock_exec.call_args.args)
async def test_cpu_profile_idle_flag_added(self):
# idle=True should append `--idle` to the py-spy command.
cmd = await self._capture_pyspy_cmd(idle=True)
assert "--idle" in cmd
async def test_cpu_profile_idle_not_added_by_default(self):
# By default (idle=False) the `--idle` flag should be absent.
cmd = await self._capture_pyspy_cmd()
assert "--idle" not in cmd
async def test_cpu_profile_subprocesses_flag_added(self):
# subprocesses=True should append `--subprocesses` to the py-spy command.
cmd = await self._capture_pyspy_cmd(subprocesses=True)
assert "--subprocesses" in cmd
async def test_cpu_profile_subprocesses_not_added_by_default(self):
# By default the `--subprocesses` flag should be absent.
cmd = await self._capture_pyspy_cmd()
assert "--subprocesses" not in cmd
@pytest.mark.asyncio
@pytest.mark.skipif(sys.platform == "win32", reason="No py-spy on Windows.")
class TestTraceDump:
async def _capture_pyspy_cmd(self, **trace_dump_kwargs):
"""Run trace_dump with subprocess execution mocked out and return the
py-spy command that would have been executed.
"""
with tempfile.TemporaryDirectory() as tmpdir:
cpu_profiler = CpuProfilingManager(tmpdir)
fake_process = AsyncMock()
fake_process.communicate.return_value = (b"", b"boom")
fake_process.returncode = 1
with patch(
"ray.dashboard.modules.reporter.profile_manager.shutil.which",
return_value="/fake/py-spy",
), patch(
"ray.dashboard.modules.reporter.profile_manager."
"_can_passwordless_sudo",
new=AsyncMock(return_value=False),
), patch(
"asyncio.create_subprocess_exec",
new=AsyncMock(return_value=fake_process),
) as mock_exec:
await cpu_profiler.trace_dump(pid=12345, **trace_dump_kwargs)
assert mock_exec.call_count == 1
return list(mock_exec.call_args.args)
async def test_trace_dump_subprocesses_flag_added(self):
# subprocesses=True should append `--subprocesses` to the py-spy dump command.
cmd = await self._capture_pyspy_cmd(subprocesses=True)
assert "dump" in cmd
assert "--subprocesses" in cmd
async def test_trace_dump_subprocesses_not_added_by_default(self):
# By default the `--subprocesses` flag should be absent.
cmd = await self._capture_pyspy_cmd()
assert "--subprocesses" not in cmd
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
from typing import Optional
from ray._raylet import GcsClient, NodeID
class HealthChecker:
def __init__(self, gcs_client: GcsClient, local_node_id: Optional[NodeID] = None):
self._gcs_client = gcs_client
self._local_node_id = local_node_id
async def check_local_raylet_liveness(self) -> bool:
if self._local_node_id is None:
return False
liveness = await self._gcs_client.async_check_alive([self._local_node_id], 0.1)
return liveness[0]
async def check_gcs_liveness(self) -> bool:
await self._gcs_client.async_check_alive([], 0.1)
return True