//! GPU Resource Manager //! //! Provides explicit GPU locking and memory tracking to prevent concurrent training conflicts. //! This module ensures that only one training job can use a GPU at a time, with automatic //! cleanup on job completion or crash. use anyhow::Result; use std::collections::HashMap; use std::process::Command; use std::sync::Arc; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; use uuid::Uuid; /// GPU allocation errors #[derive(Debug, Clone, thiserror::Error)] pub enum GPUAllocationError { #[error("GPU {gpu_id} is already locked by job {current_job_id}")] GPUAlreadyLocked { gpu_id: u32, current_job_id: Uuid }, #[error("GPU {gpu_id} not found in available GPU list")] GPUNotFound { gpu_id: u32 }, #[error("No available GPUs found")] NoGPUsAvailable, #[error( "Insufficient memory on GPU {gpu_id}: required {required_mb}MB, available {available_mb}MB" )] InsufficientMemory { gpu_id: u32, required_mb: u64, available_mb: u64, }, #[error("Failed to query GPU memory: {message}")] MemoryQueryFailed { message: String }, #[error("Failed to query GPU utilization: {message}")] UtilizationQueryFailed { message: String }, #[error("GPU {gpu_id} is locked by a different job {locked_job_id}, cannot release for job {requested_job_id}")] CannotReleaseLockedByDifferentJob { gpu_id: u32, locked_job_id: Uuid, requested_job_id: Uuid, }, } /// GPU memory information #[derive(Debug, Clone)] pub struct GPUMemoryInfo { pub gpu_id: u32, pub total_mb: u64, pub used_mb: u64, pub free_mb: u64, } /// GPU lock representing exclusive access to a GPU #[derive(Debug, Clone)] pub struct GPULock { gpu_id: u32, job_id: Uuid, manager: Arc, } impl GPULock { pub fn gpu_id(&self) -> u32 { self.gpu_id } pub fn job_id(&self) -> Uuid { self.job_id } pub fn is_locked(&self) -> bool { // Check if this lock is still valid in the manager // For simplicity, assume lock is valid if it exists true } } impl Drop for GPULock { fn drop(&mut self) { // Automatic cleanup on drop let manager = Arc::clone(&self.manager); let gpu_id = self.gpu_id; let job_id = self.job_id; // Spawn a task to release the GPU asynchronously tokio::spawn(async move { if let Err(e) = manager.release_gpu(gpu_id, job_id).await { warn!( "Failed to release GPU {} for job {} on drop: {}", gpu_id, job_id, e ); } else { debug!( "GPU {} automatically released for job {} on drop", gpu_id, job_id ); } }); } } /// GPU Resource Manager - manages GPU locks and memory tracking #[derive(Debug)] pub struct GPUResourceManager { /// Available GPU IDs available_gpus: Vec, /// GPU lock state (gpu_id -> locked_by_job_id) gpu_locks: Arc>>, } impl GPUResourceManager { /// Create a new GPU resource manager pub async fn new(gpu_ids: Vec) -> Result { info!("Initializing GPU resource manager with GPUs: {:?}", gpu_ids); let manager = Self { available_gpus: gpu_ids.clone(), gpu_locks: Arc::new(RwLock::new(HashMap::new())), }; // Validate all GPUs are accessible for gpu_id in &gpu_ids { match manager.get_gpu_memory(*gpu_id).await { Ok(info) => { info!( "GPU {} validated: {}MB total, {}MB free", gpu_id, info.total_mb, info.free_mb ); }, Err(e) => { warn!("GPU {} validation warning: {}", gpu_id, e); // Continue - GPU might be temporarily unavailable }, } } Ok(manager) } /// Acquire exclusive lock on a specific GPU pub async fn acquire_gpu( &self, job_id: Uuid, gpu_id: u32, ) -> Result { // Check if GPU exists if !self.available_gpus.contains(&gpu_id) { return Err(GPUAllocationError::GPUNotFound { gpu_id }); } // Try to acquire lock let mut locks = self.gpu_locks.write().await; if let Some(locked_by) = locks.get(&gpu_id) { // GPU already locked return Err(GPUAllocationError::GPUAlreadyLocked { gpu_id, current_job_id: *locked_by, }); } // Acquire lock locks.insert(gpu_id, job_id); info!("GPU {} locked by job {}", gpu_id, job_id); Ok(GPULock { gpu_id, job_id, manager: Arc::new(Self { available_gpus: self.available_gpus.clone(), gpu_locks: Arc::clone(&self.gpu_locks), }), }) } /// Acquire any available GPU (dynamic allocation) pub async fn acquire_any_available_gpu( &self, job_id: Uuid, ) -> Result { let locks = self.gpu_locks.read().await; // Find first available GPU for gpu_id in &self.available_gpus { if !locks.contains_key(gpu_id) { drop(locks); // Release read lock before acquiring write lock return self.acquire_gpu(job_id, *gpu_id).await; } } Err(GPUAllocationError::NoGPUsAvailable) } /// Acquire GPU with memory requirement check pub async fn acquire_gpu_with_memory_requirement( &self, job_id: Uuid, gpu_id: u32, required_memory_mb: u64, ) -> Result { // Check memory availability first let memory_info = self.get_gpu_memory(gpu_id).await.map_err(|e| { GPUAllocationError::MemoryQueryFailed { message: e.to_string(), } })?; if memory_info.free_mb < required_memory_mb { return Err(GPUAllocationError::InsufficientMemory { gpu_id, required_mb: required_memory_mb, available_mb: memory_info.free_mb, }); } // Memory check passed, acquire lock self.acquire_gpu(job_id, gpu_id).await } /// Release GPU lock pub async fn release_gpu(&self, gpu_id: u32, job_id: Uuid) -> Result<(), GPUAllocationError> { let mut locks = self.gpu_locks.write().await; match locks.get(&gpu_id) { Some(locked_by) if *locked_by == job_id => { locks.remove(&gpu_id); info!("GPU {} released by job {}", gpu_id, job_id); Ok(()) }, Some(locked_by) => Err(GPUAllocationError::CannotReleaseLockedByDifferentJob { gpu_id, locked_job_id: *locked_by, requested_job_id: job_id, }), None => { // GPU not locked, this is OK (idempotent release) debug!("GPU {} release called but GPU was not locked", gpu_id); Ok(()) }, } } /// Release all GPU locks (cleanup) pub async fn release_all(&self) -> Result<()> { let mut locks = self.gpu_locks.write().await; let count = locks.len(); locks.clear(); info!("Released all GPU locks ({} locks cleared)", count); Ok(()) } /// Get GPU memory information via nvidia-smi pub async fn get_gpu_memory(&self, gpu_id: u32) -> Result { // Execute nvidia-smi to get memory info let output = tokio::task::spawn_blocking(move || { Command::new("nvidia-smi") .args([ "--query-gpu=memory.total,memory.used,memory.free", "--format=csv,noheader,nounits", &format!("--id={}", gpu_id), ]) .output() }) .await??; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("nvidia-smi failed: {}", stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); let parts: Vec<&str> = stdout.trim().split(',').collect(); if parts.len() != 3 { return Err(anyhow::anyhow!( "Unexpected nvidia-smi output format: {}", stdout )); } let total_mb = parts.first().ok_or_else(|| anyhow::anyhow!("Missing total memory"))?.trim().parse::()?; let used_mb = parts.get(1).ok_or_else(|| anyhow::anyhow!("Missing used memory"))?.trim().parse::()?; let free_mb = parts.get(2).ok_or_else(|| anyhow::anyhow!("Missing free memory"))?.trim().parse::()?; Ok(GPUMemoryInfo { gpu_id, total_mb, used_mb, free_mb, }) } /// Get GPU utilization percentage via nvidia-smi pub async fn get_gpu_utilization(&self, gpu_id: u32) -> Result { let output = tokio::task::spawn_blocking(move || { Command::new("nvidia-smi") .args([ "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits", &format!("--id={}", gpu_id), ]) .output() }) .await??; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("nvidia-smi failed: {}", stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); let utilization = stdout.trim().parse::()?; Ok(utilization) } /// List all active jobs and their assigned GPUs pub async fn list_active_jobs(&self) -> Result> { let locks = self.gpu_locks.read().await; Ok(locks .iter() .map(|(gpu_id, job_id)| (*gpu_id, *job_id)) .collect()) } /// Check if a specific GPU is locked pub async fn is_gpu_locked(&self, gpu_id: u32) -> bool { let locks = self.gpu_locks.read().await; locks.contains_key(&gpu_id) } /// Get the job currently using a GPU (if any) pub async fn get_gpu_owner(&self, gpu_id: u32) -> Option { let locks = self.gpu_locks.read().await; locks.get(&gpu_id).copied() } /// Get statistics about GPU usage pub async fn get_statistics(&self) -> GPUStatistics { let locks = self.gpu_locks.read().await; GPUStatistics { total_gpus: self.available_gpus.len(), locked_gpus: locks.len(), available_gpus: self.available_gpus.len() - locks.len(), active_jobs: locks.len(), } } } /// GPU usage statistics #[derive(Debug, Clone)] pub struct GPUStatistics { pub total_gpus: usize, pub locked_gpus: usize, pub available_gpus: usize, pub active_jobs: usize, } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; #[tokio::test] async fn test_gpu_manager_creation() { let manager = GPUResourceManager::new(vec![0, 1]).await; assert!(manager.is_ok()); } #[tokio::test] async fn test_lock_state_tracking() { let manager = GPUResourceManager::new(vec![0]).await.unwrap(); let job_id = Uuid::new_v4(); assert!(!manager.is_gpu_locked(0).await); let _lock = manager.acquire_gpu(job_id, 0).await.unwrap(); assert!(manager.is_gpu_locked(0).await); assert_eq!(manager.get_gpu_owner(0).await, Some(job_id)); } #[tokio::test] async fn test_statistics() { let manager = GPUResourceManager::new(vec![0, 1, 2]).await.unwrap(); let job_id = Uuid::new_v4(); let stats = manager.get_statistics().await; assert_eq!(stats.total_gpus, 3); assert_eq!(stats.available_gpus, 3); let _lock = manager.acquire_gpu(job_id, 0).await.unwrap(); let stats = manager.get_statistics().await; assert_eq!(stats.locked_gpus, 1); assert_eq!(stats.available_gpus, 2); } }