"""GPU detection and slot allocation for parallel test execution.""" from __future__ import annotations import logging import os import socket import threading import time from contextlib import contextmanager from dataclasses import dataclass logger = logging.getLogger(__name__) # Try to import nvidia-ml-py for GPU detection try: import pynvml NVML_AVAILABLE = True except ImportError: NVML_AVAILABLE = False logger.debug("nvidia-ml-py not available, GPU detection will be limited") @contextmanager def nvml_context(): """Context manager for NVML initialization/shutdown. Usage: with nvml_context(): handle = pynvml.nvmlDeviceGetHandleByIndex(0) ... """ if not NVML_AVAILABLE: yield return try: pynvml.nvmlInit() yield finally: pynvml.nvmlShutdown() @dataclass class GPUInfo: """Information about a single GPU.""" id: int name: str memory_mb: int @property def memory_gb(self) -> float: return self.memory_mb / 1024 @dataclass class GPUSlot: """A slot representing one or more GPUs allocated for a model.""" gpu_ids: list[int] total_memory_mb: int assigned_model: str | None = None port: int | None = None @property def total_memory_gb(self) -> float: return self.total_memory_mb / 1024 def cuda_visible_devices(self) -> str: """Return CUDA_VISIBLE_DEVICES string for this slot.""" return ",".join(str(g) for g in self.gpu_ids) def get_open_port() -> int: """Get an available port by binding to port 0 and reading the assigned port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("", 0)) s.listen(1) port = s.getsockname()[1] return port def get_physical_device_indices(devices: list[int]) -> list[int]: """Map logical device indices to physical indices based on CUDA_VISIBLE_DEVICES.""" visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES") if visible_devices is None: return devices visible_indices = [int(x) for x in visible_devices.split(",")] index_mapping = {i: physical for i, physical in enumerate(visible_indices)} return [index_mapping[i] for i in devices if i in index_mapping] def get_gpu_memory_usage(device_id: int) -> tuple[float, float]: """Get GPU memory usage in GB (used, total). Args: device_id: Physical GPU device ID Returns: Tuple of (used_gb, total_gb) """ if not NVML_AVAILABLE: return (0.0, 0.0) with nvml_context(): handle = pynvml.nvmlDeviceGetHandleByIndex(device_id) mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle) return (mem_info.used / (1024**3), mem_info.total / (1024**3)) def wait_for_gpu_memory_to_clear( *, devices: list[int], threshold_bytes: int | None = None, threshold_ratio: float | None = None, timeout_s: float = 120, ) -> None: """Wait for GPU memory to be freed below a threshold. Args: devices: List of logical GPU device IDs to check threshold_bytes: Memory threshold in bytes (used <= threshold) threshold_ratio: Memory threshold as ratio (used/total <= ratio) timeout_s: Timeout in seconds Raises: ValueError: If memory doesn't clear within timeout """ if not NVML_AVAILABLE: logger.warning("nvidia-ml-py not available, skipping memory wait") return if threshold_bytes is None and threshold_ratio is None: raise ValueError("Must specify threshold_bytes or threshold_ratio") physical_devices = get_physical_device_indices(devices) start_time = time.time() with nvml_context(): while True: output: dict[int, str] = {} output_raw: dict[int, tuple[float, float]] = {} for device in physical_devices: handle = pynvml.nvmlDeviceGetHandleByIndex(device) mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle) gb_used = mem_info.used / (1024**3) gb_total = mem_info.total / (1024**3) output_raw[device] = (gb_used, gb_total) output[device] = f"{gb_used:.02f}/{gb_total:.02f}" logger.debug( "GPU memory used/total (GiB): %s", " ".join(f"{k}={v}" for k, v in output.items()), ) if threshold_bytes is not None: def is_free(used: float, total: float) -> bool: return used <= threshold_bytes / (1024**3) threshold_desc = f"{threshold_bytes / (1024**3):.1f} GiB" else: def is_free(used: float, total: float) -> bool: return used / total <= threshold_ratio # type: ignore[operator] threshold_desc = f"{threshold_ratio:.2%}" # type: ignore[str-format] dur_s = time.time() - start_time if all(is_free(used, total) for used, total in output_raw.values()): logger.info( "GPU memory cleared on devices %s (threshold=%s) in %.1fs", devices, threshold_desc, dur_s, ) return if dur_s >= timeout_s: raise ValueError( f"GPU memory on devices {devices} not freed after {dur_s:.1f}s " f"(threshold={threshold_desc})" ) time.sleep(5) class GPUAllocator: """Detects GPUs and assigns them to model slots using bin-packing.""" def __init__(self, gpus: list[GPUInfo] | None = None): """Initialize the allocator. Args: gpus: Optional list of GPUs. If None, auto-detects via nvidia-ml-py. """ self.gpus = gpus if gpus is not None else self._detect_gpus() self.slots: list[GPUSlot] = [] self._used_gpus: set[int] = set() # Track GPUs used across all allocations self._lock = threading.RLock() # Protects slots and _used_gpus def _detect_gpus(self) -> list[GPUInfo]: """Auto-detect available GPUs via nvidia-ml-py (NVML).""" if not NVML_AVAILABLE: logger.warning("nvidia-ml-py not available - no GPUs detected") return [] # Check for CUDA_VISIBLE_DEVICES restriction visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES") allowed_ids: set[int] | None = None if visible_devices: allowed_ids = set(int(x) for x in visible_devices.split(",") if x.strip()) try: with nvml_context(): device_count = pynvml.nvmlDeviceGetCount() gpus = [] for idx in range(device_count): # Skip GPUs not in CUDA_VISIBLE_DEVICES if set if allowed_ids is not None and idx not in allowed_ids: continue handle = pynvml.nvmlDeviceGetHandleByIndex(idx) name = pynvml.nvmlDeviceGetName(handle) # Handle bytes vs string return type (varies by pynvml version) if isinstance(name, bytes): name = name.decode("utf-8", errors="replace") mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle) # Convert bytes to MB memory_mb = mem_info.total // (1024 * 1024) gpus.append(GPUInfo(idx, name, memory_mb)) logger.info("Detected %d GPUs: %s", len(gpus), [g.name for g in gpus]) return gpus except pynvml.NVMLError as e: logger.warning("NVML error during GPU detection: %s", e) return [] except Exception as e: logger.warning("Failed to detect GPUs: %s", e) return [] def allocate_slots( self, model_specs: dict[str, dict], preserve_order: bool = False ) -> list[GPUSlot]: """Allocate GPU slots based on model memory requirements. Uses a first-fit decreasing bin-packing algorithm by default: 1. Sort models by memory requirement (largest first) 2. For each model, find the first GPU(s) that can fit it 3. For multi-GPU models, find consecutive GPUs When preserve_order=True, processes models in dict insertion order (test collection order) instead of sorting by memory. This ensures models needed by earlier tests are allocated first. Note: This method tracks used GPUs across multiple calls, so subsequent allocations will use different GPUs than previous ones. Thread-safe: Protected by internal lock. Args: model_specs: Dict of model_id -> spec dict with 'memory_gb' and 'tp' keys preserve_order: If True, allocate in dict order (test order) instead of sorting by memory size. Default False. Returns: List of GPUSlots with assigned models (only the newly allocated slots) """ with self._lock: return self._allocate_slots_unlocked(model_specs, preserve_order) def _allocate_slots_unlocked( self, model_specs: dict[str, dict], preserve_order: bool = False ) -> list[GPUSlot]: """Internal allocation logic. Caller must hold _lock.""" if not self.gpus: logger.warning("No GPUs available for allocation") return [] if preserve_order: # Process in dict insertion order (test collection order) ordered_models = list(model_specs.items()) else: # Sort models by memory requirement (largest first for better packing) ordered_models = sorted( model_specs.items(), key=lambda x: x[1].get("memory_gb", 0), reverse=True, ) # Track new slots allocated in this call new_slots: list[GPUSlot] = [] for model_id, spec in ordered_models: memory_gb = spec.get("memory_gb", 16) tp_size = spec.get("tp", 1) # Find available GPUs (not used by any previous allocation) available = [g for g in self.gpus if g.id not in self._used_gpus] if tp_size == 1: # Single GPU - find one with enough memory for gpu in available: if gpu.memory_gb >= memory_gb: slot = GPUSlot( gpu_ids=[gpu.id], total_memory_mb=gpu.memory_mb, assigned_model=model_id, port=get_open_port(), ) new_slots.append(slot) self._used_gpus.add(gpu.id) logger.info( "Allocated GPU %d (%s, %.1fGB) for %s", gpu.id, gpu.name, gpu.memory_gb, model_id, ) break else: logger.warning( "No GPU with %.1fGB available for %s (used: %s)", memory_gb, model_id, self._used_gpus, ) else: # Multi-GPU - find consecutive GPUs with enough total memory # Sort available by ID for consecutive allocation available_sorted = sorted(available, key=lambda g: g.id) for i in range(len(available_sorted) - tp_size + 1): candidate_gpus = available_sorted[i : i + tp_size] total_mem = sum(g.memory_mb for g in candidate_gpus) if total_mem >= memory_gb * 1024: gpu_ids = [g.id for g in candidate_gpus] slot = GPUSlot( gpu_ids=gpu_ids, total_memory_mb=total_mem, assigned_model=model_id, port=get_open_port(), ) new_slots.append(slot) self._used_gpus.update(gpu_ids) logger.info( "Allocated GPUs %s (%.1fGB total) for %s (tp=%d)", gpu_ids, total_mem / 1024, model_id, tp_size, ) break else: logger.warning( "No %d consecutive GPUs with %.1fGB available for %s (used: %s)", tp_size, memory_gb, model_id, self._used_gpus, ) # Add new slots to existing slots list self.slots.extend(new_slots) return new_slots def get_slot_for_model(self, model_id: str) -> GPUSlot | None: """Get the slot assigned to a specific model. Thread-safe: Protected by internal lock. """ with self._lock: for slot in self.slots: if slot.assigned_model == model_id: return slot return None def release_gpus(self, gpu_ids: list[int]) -> None: """Release GPUs back to the available pool. Thread-safe: Protected by internal lock. Args: gpu_ids: List of GPU IDs to release. """ with self._lock: for gpu_id in gpu_ids: self._used_gpus.discard(gpu_id) # Remove slots that used these GPUs self.slots = [ s for s in self.slots if not any(g in gpu_ids for g in s.gpu_ids) ] logger.info("Released GPUs %s, now used: %s", gpu_ids, self._used_gpus) def release_slot(self, slot: GPUSlot) -> None: """Release a GPU slot back to the available pool. Args: slot: The GPUSlot to release. """ self.release_gpus(slot.gpu_ids) def available_gpus(self) -> list[int]: """Get list of available (unused) GPU IDs. Thread-safe: Protected by internal lock. Returns: List of GPU IDs that are not currently allocated. """ with self._lock: return [g.id for g in self.gpus if g.id not in self._used_gpus] def summary(self) -> str: """Return a summary of GPU allocations. Thread-safe: Protected by internal lock. """ with self._lock: lines = ["GPU Allocation Summary:"] lines.append(f" Total GPUs: {len(self.gpus)}") lines.append(f" Used GPUs: {sorted(self._used_gpus)}") lines.append(f" Allocated Slots: {len(self.slots)}") for slot in self.slots: lines.append( f" - {slot.assigned_model}: GPUs {slot.gpu_ids} " f"({slot.total_memory_gb:.1f}GB) port={slot.port}" ) return "\n".join(lines)