Skip to main content

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}