616 lines
22 KiB
Python
616 lines
22 KiB
Python
"""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
|