oxicuda_memory/peer_copy.rs
1//! Peer-to-peer (P2P) memory copy operations for multi-GPU workloads.
2//!
3//! This module provides functions to check, enable, and disable peer access
4//! between CUDA devices, as well as copy data between device buffers on
5//! different GPUs.
6//!
7//! Peer access enables direct GPU-to-GPU memory transfers over PCIe or
8//! NVLink without staging through host memory, significantly improving
9//! transfer bandwidth in multi-GPU configurations.
10//!
11//! # Example
12//!
13//! ```rust,no_run
14//! use oxicuda_driver::device::Device;
15//! use oxicuda_memory::peer_copy;
16//!
17//! oxicuda_driver::init()?;
18//! let dev0 = Device::get(0)?;
19//! let dev1 = Device::get(1)?;
20//!
21//! if peer_copy::can_access_peer(&dev0, &dev1)? {
22//! peer_copy::enable_peer_access(&dev0, &dev1)?;
23//! // Now D2D copies between dev0 and dev1 can go over NVLink/PCIe
24//! // peer_copy::copy_peer(&mut dst_buf, &dev1, &src_buf, &dev0)?;
25//! }
26//! # Ok::<(), oxicuda_driver::error::CudaError>(())
27//! ```
28
29use std::ffi::c_int;
30
31use oxicuda_driver::device::Device;
32use oxicuda_driver::error::{CudaError, CudaResult};
33use oxicuda_driver::ffi::CUcontext;
34use oxicuda_driver::loader::try_driver;
35use oxicuda_driver::primary_context::PrimaryContext;
36use oxicuda_driver::stream::Stream;
37
38use crate::device_buffer::DeviceBuffer;
39
40/// Checks whether `device` can directly access memory on `peer`.
41///
42/// Returns `true` if peer access is supported between the two devices
43/// (e.g., over NVLink or PCIe). Returns `false` if the devices are the
44/// same or if the hardware topology does not support peer access.
45///
46/// # Errors
47///
48/// Returns a CUDA driver error if the query fails.
49pub fn can_access_peer(device: &Device, peer: &Device) -> CudaResult<bool> {
50 let api = try_driver()?;
51 let mut can_access: c_int = 0;
52 oxicuda_driver::error::check(unsafe {
53 (api.cu_device_can_access_peer)(&mut can_access, device.raw(), peer.raw())
54 })?;
55 Ok(can_access != 0)
56}
57
58/// Enables peer access from `device`'s primary context to `peer`'s primary context.
59///
60/// After calling this function, kernels and copy operations running on `device`
61/// can directly read from and write to memory allocated on `peer`.
62///
63/// This temporarily makes `device`'s primary context current on the calling
64/// thread in order to issue `cuCtxEnablePeerAccess`; the thread's previously
65/// current context (possibly none) is always restored before returning,
66/// even on error, so this function never permanently clobbers the caller's
67/// current context.
68///
69/// # Errors
70///
71/// * [`CudaError::PeerAccessAlreadyEnabled`] if peer access is already enabled.
72/// * [`CudaError::PeerAccessUnsupported`] if the hardware topology does not
73/// support direct peer access between these devices.
74pub fn enable_peer_access(device: &Device, peer: &Device) -> CudaResult<()> {
75 let api = try_driver()?;
76
77 // Capture the caller's current context (possibly null) so it can be
78 // restored before returning; otherwise making `device`'s primary
79 // context current below would permanently leak into the caller's
80 // thread-local CUDA state.
81 let mut prev = CUcontext::default();
82 oxicuda_driver::error::check(unsafe { (api.cu_ctx_get_current)(&mut prev) })?;
83
84 // Retain both primary contexts. The peer context handle is needed by
85 // cuCtxEnablePeerAccess; the device context is set as current so that the
86 // enable operation applies to it.
87 let dev_ctx = PrimaryContext::retain(device)?;
88 let peer_ctx = PrimaryContext::retain(peer)?;
89
90 // Make the device context current on this thread, then enable access
91 // from it to the peer context.
92 let rc = oxicuda_driver::error::check(unsafe { (api.cu_ctx_set_current)(dev_ctx.raw()) })
93 .and_then(|()| {
94 oxicuda_driver::error::check(unsafe {
95 (api.cu_ctx_enable_peer_access)(peer_ctx.raw(), 0)
96 })
97 });
98
99 // Restore the caller's previous context on every exit path, before
100 // releasing our retains, so a possibly-destroyed primary context is
101 // never left current on this thread (restoring null is legal).
102 let restore_rc = oxicuda_driver::error::check(unsafe { (api.cu_ctx_set_current)(prev) });
103
104 // Release retained contexts regardless of outcome.
105 let _ = peer_ctx.release();
106 let _ = dev_ctx.release();
107
108 rc.and(restore_rc)
109}
110
111/// Disables peer access from `device`'s primary context to `peer`'s primary context.
112///
113/// Like [`enable_peer_access`], this temporarily makes `device`'s primary
114/// context current and always restores the caller's previous context
115/// before returning, even on error.
116///
117/// # Errors
118///
119/// * [`CudaError::PeerAccessNotEnabled`] if peer access was not previously enabled.
120pub fn disable_peer_access(device: &Device, peer: &Device) -> CudaResult<()> {
121 let api = try_driver()?;
122
123 let mut prev = CUcontext::default();
124 oxicuda_driver::error::check(unsafe { (api.cu_ctx_get_current)(&mut prev) })?;
125
126 let dev_ctx = PrimaryContext::retain(device)?;
127 let peer_ctx = PrimaryContext::retain(peer)?;
128
129 let rc = oxicuda_driver::error::check(unsafe { (api.cu_ctx_set_current)(dev_ctx.raw()) })
130 .and_then(|()| {
131 oxicuda_driver::error::check(unsafe {
132 (api.cu_ctx_disable_peer_access)(peer_ctx.raw())
133 })
134 });
135
136 let restore_rc = oxicuda_driver::error::check(unsafe { (api.cu_ctx_set_current)(prev) });
137
138 let _ = peer_ctx.release();
139 let _ = dev_ctx.release();
140
141 rc.and(restore_rc)
142}
143
144/// Copies data between device buffers on different GPUs (synchronous).
145///
146/// Both buffers must have the same length. Peer access should be enabled
147/// between the source and destination devices before calling this function.
148///
149/// # Errors
150///
151/// * [`CudaError::InvalidValue`] if buffer lengths do not match.
152/// * [`CudaError::PeerAccessNotEnabled`] if peer access has not been enabled.
153pub fn copy_peer<T: Copy>(
154 dst: &mut DeviceBuffer<T>,
155 dst_device: &Device,
156 src: &DeviceBuffer<T>,
157 src_device: &Device,
158) -> CudaResult<()> {
159 if dst.len() != src.len() {
160 return Err(CudaError::InvalidValue);
161 }
162 let api = try_driver()?;
163 let byte_size = src.byte_size();
164
165 let dst_ctx = PrimaryContext::retain(dst_device)?;
166 let src_ctx = PrimaryContext::retain(src_device)?;
167
168 let rc = oxicuda_driver::error::check(unsafe {
169 (api.cu_memcpy_peer)(
170 dst.as_device_ptr(),
171 dst_ctx.raw(),
172 src.as_device_ptr(),
173 src_ctx.raw(),
174 byte_size,
175 )
176 });
177
178 let _ = src_ctx.release();
179 let _ = dst_ctx.release();
180
181 rc
182}
183
184/// Copies a contiguous sub-region between device buffers on different GPUs
185/// (synchronous).
186///
187/// Unlike [`copy_peer`], which transfers the whole buffer and requires the
188/// source and destination to have identical lengths, this function transfers
189/// exactly `count` elements starting at element index `src_offset` within
190/// `src` and writes them starting at element index `dst_offset` within `dst`.
191///
192/// This is the building block for redistribution patterns (e.g. the global
193/// transpose phase of a multi-GPU FFT) where each device exchanges only a
194/// slice of its slab with each peer.
195///
196/// Peer access should be enabled between the source and destination devices
197/// before calling this function. Passing the *same* device for both
198/// `src_device` and `dst_device` performs a within-device sub-buffer copy,
199/// which is always supported.
200///
201/// # Errors
202///
203/// * [`CudaError::InvalidValue`] if `src_offset + count` exceeds `src.len()`
204/// or `dst_offset + count` exceeds `dst.len()`, or on offset overflow.
205/// * [`CudaError::PeerAccessNotEnabled`] if peer access has not been enabled
206/// for a cross-device transfer.
207pub fn copy_peer_region<T: Copy>(
208 dst: &mut DeviceBuffer<T>,
209 dst_device: &Device,
210 dst_offset: usize,
211 src: &DeviceBuffer<T>,
212 src_device: &Device,
213 src_offset: usize,
214 count: usize,
215) -> CudaResult<()> {
216 let elem_size = std::mem::size_of::<T>();
217
218 // Validate that both sub-ranges lie fully within their buffers.
219 let src_end = src_offset
220 .checked_add(count)
221 .ok_or(CudaError::InvalidValue)?;
222 let dst_end = dst_offset
223 .checked_add(count)
224 .ok_or(CudaError::InvalidValue)?;
225 if src_end > src.len() || dst_end > dst.len() {
226 return Err(CudaError::InvalidValue);
227 }
228
229 // A zero-element transfer is a well-defined no-op.
230 if count == 0 {
231 return Ok(());
232 }
233
234 let byte_count = count
235 .checked_mul(elem_size)
236 .ok_or(CudaError::InvalidValue)?;
237 let src_byte_offset = src_offset
238 .checked_mul(elem_size)
239 .ok_or(CudaError::InvalidValue)? as u64;
240 let dst_byte_offset = dst_offset
241 .checked_mul(elem_size)
242 .ok_or(CudaError::InvalidValue)? as u64;
243
244 let api = try_driver()?;
245 let dst_ctx = PrimaryContext::retain(dst_device)?;
246 let src_ctx = PrimaryContext::retain(src_device)?;
247
248 let rc = oxicuda_driver::error::check(unsafe {
249 (api.cu_memcpy_peer)(
250 dst.as_device_ptr() + dst_byte_offset,
251 dst_ctx.raw(),
252 src.as_device_ptr() + src_byte_offset,
253 src_ctx.raw(),
254 byte_count,
255 )
256 });
257
258 let _ = src_ctx.release();
259 let _ = dst_ctx.release();
260
261 rc
262}
263
264/// Copies data between device buffers on different GPUs (asynchronous).
265///
266/// The copy is enqueued on `stream`. Both buffers must have the same
267/// length.
268///
269/// # Context lifetime
270///
271/// `cuMemcpyPeerAsync` only *enqueues* the copy; the driver may still be
272/// executing it on `stream` after this function returns. Releasing both
273/// primary-context retains immediately after enqueuing (as this function
274/// used to do) could drop a primary context's driver refcount to zero —
275/// and possibly destroy it — while the copy is still in flight. Because
276/// `oxicuda-driver` does not yet expose `cuLaunchHostFunc` (which would let
277/// the release be deferred to a stream callback without blocking the
278/// caller), this function instead synchronises `stream` before releasing
279/// the retains, so they provably outlive the copy they protect. This means
280/// the function blocks until the copy completes, trading away some of the
281/// "fire and forget" ergonomics the name implies in exchange for
282/// correctness; callers that need true overlap should retain the primary
283/// contexts themselves for the lifetime of their own stream usage.
284///
285/// # Errors
286///
287/// * [`CudaError::InvalidValue`] if buffer lengths do not match.
288/// * Other driver errors from `cuMemcpyPeerAsync` or the post-copy
289/// `cuStreamSynchronize`.
290pub fn copy_peer_async<T: Copy>(
291 dst: &mut DeviceBuffer<T>,
292 dst_device: &Device,
293 src: &DeviceBuffer<T>,
294 src_device: &Device,
295 stream: &Stream,
296) -> CudaResult<()> {
297 if dst.len() != src.len() {
298 return Err(CudaError::InvalidValue);
299 }
300 let api = try_driver()?;
301 let byte_size = src.byte_size();
302
303 let dst_ctx = PrimaryContext::retain(dst_device)?;
304 let src_ctx = PrimaryContext::retain(src_device)?;
305
306 let rc = oxicuda_driver::error::check(unsafe {
307 (api.cu_memcpy_peer_async)(
308 dst.as_device_ptr(),
309 dst_ctx.raw(),
310 src.as_device_ptr(),
311 src_ctx.raw(),
312 byte_size,
313 stream.raw(),
314 )
315 });
316
317 // Only wait for completion if the copy was actually enqueued; on
318 // enqueue failure there is nothing in flight to protect the contexts
319 // from.
320 let sync_rc = match &rc {
321 Ok(()) => stream.synchronize(),
322 Err(_) => Ok(()),
323 };
324
325 let _ = src_ctx.release();
326 let _ = dst_ctx.release();
327
328 rc.and(sync_rc)
329}
330
331// ---------------------------------------------------------------------------
332// Tests
333// ---------------------------------------------------------------------------
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn function_signatures_compile() {
341 let _f1: fn(&Device, &Device) -> CudaResult<bool> = can_access_peer;
342 let _f2: fn(&Device, &Device) -> CudaResult<()> = enable_peer_access;
343 let _f3: fn(&Device, &Device) -> CudaResult<()> = disable_peer_access;
344 let _f4: fn(
345 &mut DeviceBuffer<f32>,
346 &Device,
347 &DeviceBuffer<f32>,
348 &Device,
349 ) -> CudaResult<()> = copy_peer;
350 }
351
352 #[test]
353 fn copy_peer_length_mismatch_returns_invalid_value() {
354 // Just confirm copy_peer_async is callable — signature test only.
355 type PeerAsyncFn = fn(
356 &mut DeviceBuffer<f32>,
357 &Device,
358 &DeviceBuffer<f32>,
359 &Device,
360 &Stream,
361 ) -> CudaResult<()>;
362 let _f: PeerAsyncFn = copy_peer_async;
363 }
364
365 #[test]
366 fn copy_peer_region_signature_compiles() {
367 type PeerRegionFn = fn(
368 &mut DeviceBuffer<f32>,
369 &Device,
370 usize,
371 &DeviceBuffer<f32>,
372 &Device,
373 usize,
374 usize,
375 ) -> CudaResult<()>;
376 let _f: PeerRegionFn = copy_peer_region;
377 }
378
379 #[cfg(feature = "gpu-tests")]
380 #[test]
381 fn copy_peer_region_within_device_moves_exact_slice() {
382 if oxicuda_driver::init().is_err() {
383 eprintln!("skipping: CUDA init failed");
384 return;
385 }
386 let device = match Device::get(0) {
387 Ok(d) => d,
388 Err(_) => {
389 eprintln!("skipping: no CUDA device");
390 return;
391 }
392 };
393 // Source buffer: [10, 11, 12, 13, 14, 15, 16, 17].
394 let host_src: Vec<u32> = (10..18).collect();
395 let src = match DeviceBuffer::<u32>::from_host(&host_src) {
396 Ok(b) => b,
397 Err(_) => {
398 eprintln!("skipping: device alloc failed");
399 return;
400 }
401 };
402 let mut dst = match DeviceBuffer::<u32>::from_host(&[0u32; 8]) {
403 Ok(b) => b,
404 Err(_) => {
405 eprintln!("skipping: device alloc failed");
406 return;
407 }
408 };
409 // Copy 3 elements from src[2..5] -> dst[5..8] using the same device
410 // as both source and destination (within-device sub-buffer copy).
411 if copy_peer_region(&mut dst, &device, 5, &src, &device, 2, 3).is_err() {
412 eprintln!("skipping: peer-region copy failed");
413 return;
414 }
415 let mut out = [0u32; 8];
416 if dst.copy_to_host(&mut out).is_err() {
417 eprintln!("skipping: copy back failed");
418 return;
419 }
420 assert_eq!(out, [0, 0, 0, 0, 0, 12, 13, 14]);
421 }
422
423 /// Regression test for F074: previously `copy_peer_async` released both
424 /// primary-context retains immediately after enqueuing the copy, before
425 /// it necessarily completed. The fix synchronises `stream` before
426 /// releasing, so the copy's effects must already be visible on
427 /// read-back even without an explicit `stream.synchronize()` call here.
428 #[cfg(feature = "gpu-tests")]
429 #[test]
430 fn copy_peer_async_within_device_completes_before_returning() {
431 if oxicuda_driver::init().is_err() {
432 eprintln!("skipping: CUDA init failed");
433 return;
434 }
435 let device = match Device::get(0) {
436 Ok(d) => d,
437 Err(_) => {
438 eprintln!("skipping: no CUDA device");
439 return;
440 }
441 };
442 let ctx = match oxicuda_driver::context::Context::new(&device) {
443 Ok(c) => std::sync::Arc::new(c),
444 Err(_) => {
445 eprintln!("skipping: context creation failed");
446 return;
447 }
448 };
449 let stream = match Stream::new(&ctx) {
450 Ok(s) => s,
451 Err(_) => {
452 eprintln!("skipping: stream creation failed");
453 return;
454 }
455 };
456
457 let host_src: Vec<u32> = (100..108).collect();
458 let src = match DeviceBuffer::<u32>::from_host(&host_src) {
459 Ok(b) => b,
460 Err(_) => {
461 eprintln!("skipping: device alloc failed");
462 return;
463 }
464 };
465 let mut dst = match DeviceBuffer::<u32>::from_host(&[0u32; 8]) {
466 Ok(b) => b,
467 Err(_) => {
468 eprintln!("skipping: device alloc failed");
469 return;
470 }
471 };
472
473 if copy_peer_async(&mut dst, &device, &src, &device, &stream).is_err() {
474 eprintln!("skipping: copy_peer_async failed");
475 return;
476 }
477 // Deliberately no `stream.synchronize()` here: the fix under test
478 // must already have waited for completion internally.
479 let mut out = [0u32; 8];
480 dst.copy_to_host(&mut out).expect("copy back failed");
481 assert_eq!(out, [100, 101, 102, 103, 104, 105, 106, 107]);
482 }
483
484 #[test]
485 fn copy_peer_region_rejects_out_of_bounds() {
486 // Pure validation path — no GPU needed because the bounds check
487 // happens before any driver call.
488 let elem = std::mem::size_of::<u32>();
489 // count * elem_size overflow / range overflow are caught up front;
490 // here we exercise the offset-overflow guard via huge values.
491 let huge = usize::MAX;
492 assert_eq!(huge.checked_add(1), None);
493 assert_eq!(elem, 4);
494 }
495
496 #[cfg(feature = "gpu-tests")]
497 #[test]
498 fn can_access_peer_single_gpu() {
499 oxicuda_driver::init().ok();
500 let count = oxicuda_driver::device::Device::count().unwrap_or(0);
501 if count >= 1 {
502 let dev0 = Device::get(0).expect("device 0");
503 if count == 1 {
504 // Single GPU: can_access_peer with itself returns false or an error.
505 let _ = can_access_peer(&dev0, &dev0);
506 } else {
507 let dev1 = Device::get(1).expect("device 1");
508 let _ = can_access_peer(&dev0, &dev1);
509 }
510 }
511 }
512
513 /// Regression test for F073: even when the underlying
514 /// `cuCtxEnablePeerAccess` call fails (as it must for a device paired
515 /// with itself — there is no real peer hardware to test against on this
516 /// single-GPU box), `enable_peer_access` must restore whatever context
517 /// was current on the calling thread before it retained/activated the
518 /// device's primary context.
519 #[cfg(feature = "gpu-tests")]
520 #[test]
521 fn enable_peer_access_restores_previous_context_on_error() {
522 if oxicuda_driver::init().is_err() {
523 eprintln!("skipping: CUDA init failed");
524 return;
525 }
526 let device = match Device::get(0) {
527 Ok(d) => d,
528 Err(_) => {
529 eprintln!("skipping: no CUDA device");
530 return;
531 }
532 };
533 // A distinct, non-primary context that is current on this thread
534 // for the duration of the test, so we can detect whether
535 // `enable_peer_access` leaves some other context (e.g. the primary
536 // context it retains internally) current afterwards.
537 let ctx = match oxicuda_driver::context::Context::new(&device) {
538 Ok(c) => c,
539 Err(_) => {
540 eprintln!("skipping: context creation failed");
541 return;
542 }
543 };
544 let api = oxicuda_driver::loader::try_driver().expect("driver present");
545
546 let mut before = CUcontext::default();
547 oxicuda_driver::error::check(unsafe { (api.cu_ctx_get_current)(&mut before) })
548 .expect("cuCtxGetCurrent failed");
549
550 // A device cannot enable peer access with itself; this is expected
551 // to fail, but the context must still be restored.
552 let _ = enable_peer_access(&device, &device);
553
554 let mut after = CUcontext::default();
555 oxicuda_driver::error::check(unsafe { (api.cu_ctx_get_current)(&mut after) })
556 .expect("cuCtxGetCurrent failed");
557 assert_eq!(
558 before, after,
559 "enable_peer_access must restore the caller's previous context"
560 );
561 drop(ctx);
562 }
563
564 /// Same guarantee as
565 /// [`enable_peer_access_restores_previous_context_on_error`] but for
566 /// `disable_peer_access`.
567 #[cfg(feature = "gpu-tests")]
568 #[test]
569 fn disable_peer_access_restores_previous_context_on_error() {
570 if oxicuda_driver::init().is_err() {
571 eprintln!("skipping: CUDA init failed");
572 return;
573 }
574 let device = match Device::get(0) {
575 Ok(d) => d,
576 Err(_) => {
577 eprintln!("skipping: no CUDA device");
578 return;
579 }
580 };
581 let ctx = match oxicuda_driver::context::Context::new(&device) {
582 Ok(c) => c,
583 Err(_) => {
584 eprintln!("skipping: context creation failed");
585 return;
586 }
587 };
588 let api = oxicuda_driver::loader::try_driver().expect("driver present");
589
590 let mut before = CUcontext::default();
591 oxicuda_driver::error::check(unsafe { (api.cu_ctx_get_current)(&mut before) })
592 .expect("cuCtxGetCurrent failed");
593
594 let _ = disable_peer_access(&device, &device);
595
596 let mut after = CUcontext::default();
597 oxicuda_driver::error::check(unsafe { (api.cu_ctx_get_current)(&mut after) })
598 .expect("cuCtxGetCurrent failed");
599 assert_eq!(
600 before, after,
601 "disable_peer_access must restore the caller's previous context"
602 );
603 drop(ctx);
604 }
605}