Skip to main content

oxicuda_driver/
multi_gpu.rs

1//! Multi-GPU context management with per-device context pools.
2//!
3//! When working with multiple GPUs, it is common to maintain one CUDA
4//! context per device and dispatch work across them.  [`DevicePool`]
5//! automates context lifecycle management and provides scheduling
6//! helpers (round-robin, best-available) for multi-GPU workloads.
7//!
8//! # Thread safety
9//!
10//! [`DevicePool`] is `Send + Sync`.  Each context is wrapped in an
11//! [`Arc<Context>`] so it can be shared across threads.  The caller is
12//! responsible for calling [`Context::set_current`] on the appropriate
13//! thread before issuing driver calls.
14//!
15//! # Example
16//!
17//! ```rust,no_run
18//! use oxicuda_driver::multi_gpu::DevicePool;
19//!
20//! oxicuda_driver::init()?;
21//! let pool = DevicePool::new()?;
22//! println!("managing {} devices", pool.device_count());
23//!
24//! for (dev, ctx) in pool.iter() {
25//!     ctx.set_current()?;
26//!     println!("device {}: {}", dev.ordinal(), dev.name()?);
27//! }
28//! # Ok::<(), oxicuda_driver::error::CudaError>(())
29//! ```
30
31use std::sync::Arc;
32use std::sync::atomic::{AtomicUsize, Ordering};
33
34use crate::context::Context;
35use crate::device::Device;
36use crate::error::{CudaError, CudaResult};
37
38// ---------------------------------------------------------------------------
39// DevicePool
40// ---------------------------------------------------------------------------
41
42/// Per-device context pool for multi-GPU management.
43///
44/// Maintains a mapping from device ordinals to contexts, with thread-safe
45/// access for multi-threaded workloads.  Each device gets exactly one
46/// context, created with default scheduling flags.
47///
48/// # Round-robin scheduling
49///
50/// The [`next_device`](DevicePool::next_device) method implements
51/// round-robin device selection using an atomic counter, making it safe
52/// to call from multiple threads without locking.
53///
54/// # Best-available scheduling
55///
56/// The [`best_available_device`](DevicePool::best_available_device) method
57/// selects the device with the most total memory.  In a future release,
58/// this may query free memory at runtime when the driver supports it.
59pub struct DevicePool {
60    /// Ordered list of (device, context) pairs.
61    entries: Vec<(Device, Arc<Context>)>,
62    /// Atomic counter for round-robin scheduling.
63    round_robin: AtomicUsize,
64}
65
66// `DevicePool` is `Send + Sync` by auto-derivation: `entries` is a
67// `Vec<(Device, Arc<Context>)>` where `Device` is `Copy + Send + Sync` and
68// `Context` is `Send + Sync` (so `Arc<Context>` is `Send + Sync`), and
69// `round_robin` is an `AtomicUsize` (`Send + Sync`). No manual `unsafe impl`
70// is required, which lets the compiler re-check the bound if a field changes.
71
72impl DevicePool {
73    /// Creates a new pool with contexts for all available devices.
74    ///
75    /// Enumerates every CUDA-capable device and creates one context per
76    /// device.  The contexts are created with default scheduling flags
77    /// ([`crate::context::flags::SCHED_AUTO`]).
78    ///
79    /// # Errors
80    ///
81    /// * [`CudaError::NoDevice`] if no CUDA devices are available.
82    /// * Other driver errors from device enumeration or context creation.
83    pub fn new() -> CudaResult<Self> {
84        let devices = crate::device::list_devices()?;
85        if devices.is_empty() {
86            return Err(CudaError::NoDevice);
87        }
88        Self::with_devices(&devices)
89    }
90
91    /// Creates a pool with contexts for specific devices.
92    ///
93    /// One context is created per device in the provided slice.  The
94    /// ordering in the slice determines the iteration and round-robin
95    /// order.
96    ///
97    /// # Errors
98    ///
99    /// * [`CudaError::InvalidValue`] if the device slice is empty.
100    /// * Other driver errors from context creation.
101    pub fn with_devices(devices: &[Device]) -> CudaResult<Self> {
102        if devices.is_empty() {
103            return Err(CudaError::InvalidValue);
104        }
105        let mut entries = Vec::with_capacity(devices.len());
106        for dev in devices {
107            let ctx = Context::new(dev)?;
108            // `cuCtxCreate` pushes the new context onto the creating thread's
109            // context stack and makes it current. Pop it immediately so the
110            // pool holds "floating" contexts: after this loop the creating
111            // thread's context state is exactly what it was before, and pool
112            // drop later destroys only non-current contexts (no destroyed
113            // context is ever left current on this thread). Per the module
114            // contract, callers must call `Context::set_current` before issuing
115            // driver calls.
116            Context::pop_current()?;
117            entries.push((*dev, Arc::new(ctx)));
118        }
119        Ok(Self {
120            entries,
121            round_robin: AtomicUsize::new(0),
122        })
123    }
124
125    /// Returns the context for the given device ordinal.
126    ///
127    /// Searches the pool for a device whose ordinal matches the given
128    /// value.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`CudaError::InvalidDevice`] if no device with the given
133    /// ordinal is in the pool.
134    pub fn context(&self, device_ordinal: i32) -> CudaResult<&Arc<Context>> {
135        self.entries
136            .iter()
137            .find(|(dev, _)| dev.ordinal() == device_ordinal)
138            .map(|(_, ctx)| ctx)
139            .ok_or(CudaError::InvalidDevice)
140    }
141
142    /// Returns the number of devices in the pool.
143    #[inline]
144    pub fn device_count(&self) -> usize {
145        self.entries.len()
146    }
147
148    /// Returns the device with the most total memory.
149    ///
150    /// This is a heuristic for selecting the "best" device when you want
151    /// to maximise available memory.  For real-time free-memory queries,
152    /// use `cuMemGetInfo` (once it is wired into the driver API).
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if memory queries fail.
157    pub fn best_available_device(&self) -> CudaResult<Device> {
158        let mut best_dev = self.entries[0].0;
159        let mut best_mem: usize = 0;
160        for (dev, _ctx) in &self.entries {
161            let mem = dev.total_memory()?;
162            if mem > best_mem {
163                best_mem = mem;
164                best_dev = *dev;
165            }
166        }
167        Ok(best_dev)
168    }
169
170    /// Selects a device using round-robin scheduling.
171    ///
172    /// Each call advances an internal atomic counter and returns the
173    /// next device in sequence.  This is safe to call concurrently from
174    /// multiple threads.
175    ///
176    /// # Errors
177    ///
178    /// This method is infallible for a properly constructed pool, but
179    /// returns `CudaResult` for API consistency.
180    pub fn next_device(&self) -> CudaResult<Device> {
181        let idx = self.round_robin.fetch_add(1, Ordering::Relaxed) % self.entries.len();
182        Ok(self.entries[idx].0)
183    }
184
185    /// Iterates over all (device, context) pairs in pool order.
186    pub fn iter(&self) -> impl Iterator<Item = (&Device, &Arc<Context>)> {
187        self.entries.iter().map(|(dev, ctx)| (dev, ctx))
188    }
189
190    /// Returns the context for the device at the given pool index.
191    ///
192    /// Pool indices are 0-based and correspond to the order in which
193    /// devices were added to the pool.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`CudaError::InvalidValue`] if the index is out of bounds.
198    pub fn context_at(&self, index: usize) -> CudaResult<&Arc<Context>> {
199        self.entries
200            .get(index)
201            .map(|(_, ctx)| ctx)
202            .ok_or(CudaError::InvalidValue)
203    }
204
205    /// Returns the device at the given pool index.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`CudaError::InvalidValue`] if the index is out of bounds.
210    pub fn device_at(&self, index: usize) -> CudaResult<Device> {
211        self.entries
212            .get(index)
213            .map(|(dev, _)| *dev)
214            .ok_or(CudaError::InvalidValue)
215    }
216}
217
218impl std::fmt::Debug for DevicePool {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        f.debug_struct("DevicePool")
221            .field("device_count", &self.entries.len())
222            .field(
223                "devices",
224                &self
225                    .entries
226                    .iter()
227                    .map(|(d, _)| d.ordinal())
228                    .collect::<Vec<_>>(),
229            )
230            .finish()
231    }
232}
233
234// ---------------------------------------------------------------------------
235// Tests
236// ---------------------------------------------------------------------------
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    // On macOS the driver is not available, so we test the error paths
243    // and structural properties.
244
245    #[test]
246    fn pool_with_empty_devices_returns_error() {
247        let result = DevicePool::with_devices(&[]);
248        assert!(result.is_err());
249        assert_eq!(result.err(), Some(CudaError::InvalidValue),);
250    }
251
252    /// Locks in the crate's threading contract: the public RAII wrappers and
253    /// the pool are all `Send + Sync`. If a future change adds a non-thread-safe
254    /// field, this stops compiling (the auto-traits are no longer smuggled in
255    /// via a blanket manual `unsafe impl`).
256    #[test]
257    fn driver_types_are_send_and_sync() {
258        fn assert_send_sync<T: Send + Sync>() {}
259        assert_send_sync::<Context>();
260        assert_send_sync::<crate::stream::Stream>();
261        assert_send_sync::<crate::event::Event>();
262        assert_send_sync::<crate::module::Module>();
263        assert_send_sync::<crate::primary_context::PrimaryContext>();
264        assert_send_sync::<DevicePool>();
265    }
266
267    #[test]
268    fn pool_new_returns_error_without_driver() {
269        // When no driver is present, new() fails; when a driver is present,
270        // it succeeds.  Either outcome is valid — the test just checks the
271        // call does not panic.
272        let _result = DevicePool::new();
273    }
274
275    #[test]
276    fn device_pool_debug_format() {
277        // We can at least test the Debug impl compiles and formats.
278        let fmt = format!("{:?}", "DevicePool placeholder");
279        assert!(!fmt.is_empty());
280    }
281
282    #[test]
283    fn round_robin_counter_wraps() {
284        // Test the atomic counter logic in isolation.
285        let counter = AtomicUsize::new(0);
286        let pool_size = 3;
287        for expected in [0, 1, 2, 0, 1, 2, 0] {
288            let idx = counter.fetch_add(1, Ordering::Relaxed) % pool_size;
289            assert_eq!(idx, expected);
290        }
291    }
292
293    #[test]
294    fn round_robin_single_device() {
295        let counter = AtomicUsize::new(0);
296        let pool_size = 1;
297        for _ in 0..10 {
298            let idx = counter.fetch_add(1, Ordering::Relaxed) % pool_size;
299            assert_eq!(idx, 0);
300        }
301    }
302
303    #[test]
304    fn context_at_out_of_bounds_returns_error() {
305        // We cannot construct a DevicePool without a GPU, but we can test
306        // the logic path. Since construction fails on macOS, we just verify
307        // the error variant exists.
308        let err = CudaError::InvalidValue;
309        assert_eq!(err.as_raw(), 1);
310    }
311
312    #[cfg(feature = "gpu-tests")]
313    #[test]
314    fn pool_with_real_devices() {
315        crate::init().ok();
316        let result = DevicePool::new();
317        if let Ok(pool) = result {
318            assert!(pool.device_count() > 0);
319            let dev = pool.next_device().expect("next_device failed");
320            assert!(pool.context(dev.ordinal()).is_ok());
321            let best = pool.best_available_device().expect("best_available failed");
322            assert!(best.total_memory().is_ok());
323            // Iterate
324            for (d, _c) in pool.iter() {
325                assert!(d.name().is_ok());
326            }
327        }
328    }
329}