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}