Skip to main content

oxicuda_memory/
managed_hints.rs

1//! Ergonomic managed memory hints API.
2//!
3//! This module builds on the raw [`crate::memory_info::mem_advise`]
4//! and [`crate::memory_info::mem_prefetch`] functions to provide
5//! a higher-level, builder-style API for controlling unified memory migration
6//! behaviour.
7//!
8//! # Key types
9//!
10//! - [`MigrationPolicy`] — declarative policy for common migration patterns.
11//! - [`ManagedMemoryHints`] — builder for applying hints to a memory region.
12//! - [`PrefetchPlan`] — batch multiple prefetch operations into one plan.
13//!
14//! # Example
15//!
16//! ```rust,no_run
17//! # use oxicuda_memory::managed_hints::*;
18//! # use oxicuda_driver::device::Device;
19//! # use oxicuda_driver::stream::Stream;
20//! // Assume `buf` is a UnifiedBuffer<f32> and `dev`/`stream` are valid.
21//! // let hints = ManagedMemoryHints::from_unified(&buf);
22//! // hints.set_read_mostly(&dev)?;
23//! // hints.prefetch_to(&dev, &stream)?;
24//! # Ok::<(), oxicuda_driver::error::CudaError>(())
25//! ```
26
27use oxicuda_driver::device::Device;
28use oxicuda_driver::error::{CudaError, CudaResult};
29use oxicuda_driver::stream::Stream;
30
31use crate::memory_info::{MemAdvice, mem_advise, mem_advise_host, mem_prefetch};
32use crate::unified::UnifiedBuffer;
33
34// ---------------------------------------------------------------------------
35// MigrationPolicy
36// ---------------------------------------------------------------------------
37
38/// Declarative migration policy for unified memory regions.
39///
40/// Each variant encodes a common access pattern that can be translated into
41/// one or more [`MemAdvice`] hints via [`to_advice_pairs`](MigrationPolicy::to_advice_pairs).
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub enum MigrationPolicy {
44    /// No special migration policy. Uses CUDA defaults.
45    Default,
46    /// Mark the region as read-mostly, enabling read-replica creation on
47    /// accessing devices to reduce migration overhead.
48    ReadMostly,
49    /// Prefer that the data resides on the device with the given ordinal.
50    PreferDevice(i32),
51    /// Prefer that the data resides in host (CPU) memory.
52    PreferHost,
53}
54
55impl MigrationPolicy {
56    /// Converts this policy into the corresponding [`MemAdvice`] values
57    /// that should be applied.
58    ///
59    /// For [`Default`](MigrationPolicy::Default) the returned vec is empty
60    /// (no advice to set). For compound policies the vec contains all advice
61    /// hints that need to be issued.
62    pub fn to_advice_pairs(&self) -> Vec<MemAdvice> {
63        match self {
64            Self::Default => Vec::new(),
65            Self::ReadMostly => vec![MemAdvice::SetReadMostly],
66            Self::PreferDevice(_) => vec![MemAdvice::SetPreferredLocation],
67            Self::PreferHost => vec![MemAdvice::SetPreferredLocation],
68        }
69    }
70
71    /// Returns whether this is the [`Default`](MigrationPolicy::Default) variant.
72    #[inline]
73    pub fn is_default(&self) -> bool {
74        matches!(self, Self::Default)
75    }
76}
77
78impl std::fmt::Display for MigrationPolicy {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            Self::Default => write!(f, "MigrationPolicy::Default"),
82            Self::ReadMostly => write!(f, "MigrationPolicy::ReadMostly"),
83            Self::PreferDevice(ord) => write!(f, "MigrationPolicy::PreferDevice({ord})"),
84            Self::PreferHost => write!(f, "MigrationPolicy::PreferHost"),
85        }
86    }
87}
88
89// ---------------------------------------------------------------------------
90// ManagedMemoryHints
91// ---------------------------------------------------------------------------
92
93/// Builder-style API for applying memory hints to a unified memory region.
94///
95/// Wraps a raw pointer + byte size and exposes methods that issue the
96/// appropriate [`mem_advise`] / [`mem_prefetch`] driver calls.
97///
98/// # Construction
99///
100/// Use [`for_buffer`](Self::for_buffer) for raw pointers or
101/// [`from_unified`](Self::from_unified) for [`UnifiedBuffer`] references.
102#[derive(Debug, Clone)]
103pub struct ManagedMemoryHints {
104    /// Device pointer to the start of the managed region.
105    ptr: u64,
106    /// Total size of the region in bytes.
107    byte_size: usize,
108}
109
110impl ManagedMemoryHints {
111    /// Creates a `ManagedMemoryHints` from a raw device pointer and byte size.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`CudaError::InvalidValue`] if `byte_size` is zero.
116    pub fn for_buffer(ptr: u64, byte_size: usize) -> CudaResult<Self> {
117        if byte_size == 0 {
118            return Err(CudaError::InvalidValue);
119        }
120        Ok(Self { ptr, byte_size })
121    }
122
123    /// Creates a `ManagedMemoryHints` from a [`UnifiedBuffer`] reference.
124    ///
125    /// The pointer and byte size are extracted from the buffer.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`CudaError::InvalidValue`] if the buffer reports zero bytes
130    /// (should not happen for a validly constructed buffer).
131    pub fn from_unified<T: Copy>(buf: &UnifiedBuffer<T>) -> CudaResult<Self> {
132        Self::for_buffer(buf.as_device_ptr(), buf.byte_size())
133    }
134
135    /// Returns the device pointer this hint set targets.
136    #[inline]
137    pub fn ptr(&self) -> u64 {
138        self.ptr
139    }
140
141    /// Returns the byte size of the targeted region.
142    #[inline]
143    pub fn byte_size(&self) -> usize {
144        self.byte_size
145    }
146
147    // -- Individual advice methods ------------------------------------------
148
149    /// Marks the region as read-mostly on `device`, enabling read replicas.
150    pub fn set_read_mostly(&self, device: &Device) -> CudaResult<()> {
151        mem_advise(self.ptr, self.byte_size, MemAdvice::SetReadMostly, device)
152    }
153
154    /// Removes the read-mostly hint for `device`.
155    pub fn unset_read_mostly(&self, device: &Device) -> CudaResult<()> {
156        mem_advise(self.ptr, self.byte_size, MemAdvice::UnsetReadMostly, device)
157    }
158
159    /// Sets the preferred location to `device` for this region.
160    pub fn set_preferred_location(&self, device: &Device) -> CudaResult<()> {
161        mem_advise(
162            self.ptr,
163            self.byte_size,
164            MemAdvice::SetPreferredLocation,
165            device,
166        )
167    }
168
169    /// Removes the preferred-location hint for `device`.
170    pub fn unset_preferred_location(&self, device: &Device) -> CudaResult<()> {
171        mem_advise(
172            self.ptr,
173            self.byte_size,
174            MemAdvice::UnsetPreferredLocation,
175            device,
176        )
177    }
178
179    /// Indicates that `device` will access this memory region.
180    pub fn set_accessed_by(&self, device: &Device) -> CudaResult<()> {
181        mem_advise(self.ptr, self.byte_size, MemAdvice::SetAccessedBy, device)
182    }
183
184    /// Removes the accessed-by hint for `device`.
185    pub fn unset_accessed_by(&self, device: &Device) -> CudaResult<()> {
186        mem_advise(self.ptr, self.byte_size, MemAdvice::UnsetAccessedBy, device)
187    }
188
189    // -- Prefetch methods ---------------------------------------------------
190
191    /// Prefetches the entire region to `device` on `stream`.
192    pub fn prefetch_to(&self, device: &Device, stream: &Stream) -> CudaResult<()> {
193        mem_prefetch(self.ptr, self.byte_size, device, stream)
194    }
195
196    /// Prefetches a sub-range of the region to `device`.
197    ///
198    /// # Parameters
199    ///
200    /// * `offset_bytes` — byte offset from the start of the region.
201    /// * `count_bytes` — number of bytes to prefetch.
202    ///
203    /// # Errors
204    ///
205    /// Returns [`CudaError::InvalidValue`] if the range
206    /// `[offset_bytes, offset_bytes + count_bytes)` exceeds the buffer, or
207    /// if `count_bytes` is zero.
208    pub fn prefetch_range(
209        &self,
210        offset_bytes: usize,
211        count_bytes: usize,
212        device: &Device,
213        stream: &Stream,
214    ) -> CudaResult<()> {
215        if count_bytes == 0 {
216            return Err(CudaError::InvalidValue);
217        }
218        let end = offset_bytes
219            .checked_add(count_bytes)
220            .ok_or(CudaError::InvalidValue)?;
221        if end > self.byte_size {
222            return Err(CudaError::InvalidValue);
223        }
224        let range_ptr = self
225            .ptr
226            .checked_add(offset_bytes as u64)
227            .ok_or(CudaError::InvalidValue)?;
228        mem_prefetch(range_ptr, count_bytes, device, stream)
229    }
230
231    // -- Policy convenience -------------------------------------------------
232
233    /// Applies a [`MigrationPolicy`] to this memory region.
234    ///
235    /// For [`MigrationPolicy::Default`] this is a no-op.
236    /// For other variants the corresponding advice hint(s) are issued.
237    pub fn apply_policy(&self, policy: &MigrationPolicy, device: &Device) -> CudaResult<()> {
238        apply_migration_policy(self.ptr, self.byte_size, policy, device)
239    }
240}
241
242// ---------------------------------------------------------------------------
243// PrefetchPlan
244// ---------------------------------------------------------------------------
245
246/// An entry in a [`PrefetchPlan`] recording a single prefetch operation.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub struct PrefetchEntry {
249    /// Device pointer to the start of the region.
250    pub ptr: u64,
251    /// Size of the region in bytes.
252    pub byte_size: usize,
253    /// Target device ordinal.
254    pub device_ordinal: i32,
255}
256
257/// Batch multiple prefetch operations into a single plan.
258///
259/// Operations are recorded first, then executed together on a single stream
260/// via [`execute`](PrefetchPlan::execute).
261///
262/// # Example
263///
264/// ```rust,no_run
265/// # use oxicuda_memory::managed_hints::PrefetchPlan;
266/// # use oxicuda_driver::stream::Stream;
267/// let mut plan = PrefetchPlan::new();
268/// plan.add(0x1000, 4096, 0)
269///     .add(0x2000, 8192, 0);
270/// assert_eq!(plan.len(), 2);
271/// // plan.execute(&stream)?;
272/// # Ok::<(), oxicuda_driver::error::CudaError>(())
273/// ```
274#[derive(Debug, Clone)]
275pub struct PrefetchPlan {
276    entries: Vec<PrefetchEntry>,
277}
278
279impl PrefetchPlan {
280    /// Creates an empty prefetch plan.
281    pub fn new() -> Self {
282        Self {
283            entries: Vec::new(),
284        }
285    }
286
287    /// Records a prefetch operation.
288    ///
289    /// The actual prefetch is deferred until [`execute`](Self::execute).
290    pub fn add(&mut self, ptr: u64, byte_size: usize, device_ordinal: i32) -> &mut Self {
291        self.entries.push(PrefetchEntry {
292            ptr,
293            byte_size,
294            device_ordinal,
295        });
296        self
297    }
298
299    /// Returns the number of recorded prefetch operations.
300    #[inline]
301    pub fn len(&self) -> usize {
302        self.entries.len()
303    }
304
305    /// Returns `true` if no operations have been recorded.
306    #[inline]
307    pub fn is_empty(&self) -> bool {
308        self.entries.is_empty()
309    }
310
311    /// Returns a slice of all recorded entries.
312    #[inline]
313    pub fn entries(&self) -> &[PrefetchEntry] {
314        &self.entries
315    }
316
317    /// Executes all recorded prefetch operations on `stream`.
318    ///
319    /// Each entry is issued as a separate `mem_prefetch` call targeting the
320    /// device identified by the entry's `device_ordinal`. Operations are
321    /// enqueued in the order they were added.
322    ///
323    /// # Errors
324    ///
325    /// Returns the first error encountered. Entries before the failing one
326    /// will already have been enqueued.
327    pub fn execute(&self, stream: &Stream) -> CudaResult<()> {
328        for entry in &self.entries {
329            let device = Device::get(entry.device_ordinal)?;
330            mem_prefetch(entry.ptr, entry.byte_size, &device, stream)?;
331        }
332        Ok(())
333    }
334}
335
336impl Default for PrefetchPlan {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342// ---------------------------------------------------------------------------
343// Free function convenience
344// ---------------------------------------------------------------------------
345
346/// Applies a [`MigrationPolicy`] to a raw unified memory region.
347///
348/// This is a convenience function that translates the high-level policy into
349/// the appropriate [`mem_advise`] calls.
350///
351/// # Parameters
352///
353/// * `ptr` — device pointer to the managed allocation.
354/// * `byte_size` — size of the region in bytes.
355/// * `policy` — the migration policy to apply.
356/// * `device` — the device to which hints are directed for
357///   [`MigrationPolicy::ReadMostly`]. Ignored by
358///   [`MigrationPolicy::PreferHost`] (which always targets the CPU) and by
359///   [`MigrationPolicy::PreferDevice`] (which resolves its own ordinal via
360///   [`Device::get`] instead, so the advice always targets the ordinal the
361///   caller declared in the policy rather than whatever `device` happens to
362///   be).
363///
364/// # Errors
365///
366/// Forwards any error from the underlying driver call, including
367/// [`CudaError::InvalidDevice`] if [`MigrationPolicy::PreferDevice`]'s
368/// ordinal does not name a valid device.
369/// Returns [`CudaError::InvalidValue`] if `byte_size` is zero (when
370/// policy is not `Default`).
371pub fn apply_migration_policy(
372    ptr: u64,
373    byte_size: usize,
374    policy: &MigrationPolicy,
375    device: &Device,
376) -> CudaResult<()> {
377    match policy {
378        MigrationPolicy::Default => Ok(()),
379        MigrationPolicy::ReadMostly => mem_advise(ptr, byte_size, MemAdvice::SetReadMostly, device),
380        MigrationPolicy::PreferDevice(ordinal) => {
381            // Honour the ordinal declared in the policy itself rather than
382            // whatever `device` the caller happened to pass in, so the
383            // advice always targets the documented device.
384            let target = Device::get(*ordinal)?;
385            mem_advise(ptr, byte_size, MemAdvice::SetPreferredLocation, &target)
386        }
387        MigrationPolicy::PreferHost => {
388            // Host-preferred data has no `Device` handle to target — issue
389            // the advice against the driver's `CU_DEVICE_CPU` sentinel
390            // directly instead of silently redirecting it at `device` (a
391            // GPU).
392            mem_advise_host(ptr, byte_size, MemAdvice::SetPreferredLocation)
393        }
394    }
395}
396
397// ---------------------------------------------------------------------------
398// Tests
399// ---------------------------------------------------------------------------
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    // -- MigrationPolicy tests ----------------------------------------------
406
407    #[test]
408    fn migration_policy_default_produces_empty_advice() {
409        let pairs = MigrationPolicy::Default.to_advice_pairs();
410        assert!(pairs.is_empty());
411    }
412
413    #[test]
414    fn migration_policy_read_mostly_advice() {
415        let pairs = MigrationPolicy::ReadMostly.to_advice_pairs();
416        assert_eq!(pairs.len(), 1);
417        assert_eq!(pairs[0], MemAdvice::SetReadMostly);
418    }
419
420    #[test]
421    fn migration_policy_prefer_device_advice() {
422        let pairs = MigrationPolicy::PreferDevice(0).to_advice_pairs();
423        assert_eq!(pairs.len(), 1);
424        assert_eq!(pairs[0], MemAdvice::SetPreferredLocation);
425    }
426
427    #[test]
428    fn migration_policy_prefer_host_advice() {
429        let pairs = MigrationPolicy::PreferHost.to_advice_pairs();
430        assert_eq!(pairs.len(), 1);
431        assert_eq!(pairs[0], MemAdvice::SetPreferredLocation);
432    }
433
434    #[test]
435    fn migration_policy_is_default() {
436        assert!(MigrationPolicy::Default.is_default());
437        assert!(!MigrationPolicy::ReadMostly.is_default());
438        assert!(!MigrationPolicy::PreferDevice(0).is_default());
439        assert!(!MigrationPolicy::PreferHost.is_default());
440    }
441
442    #[test]
443    fn migration_policy_display() {
444        let s = format!("{}", MigrationPolicy::PreferDevice(2));
445        assert!(s.contains("PreferDevice(2)"));
446
447        let s2 = format!("{}", MigrationPolicy::Default);
448        assert!(s2.contains("Default"));
449    }
450
451    // -- ManagedMemoryHints construction tests ------------------------------
452
453    #[test]
454    fn hints_for_buffer_rejects_zero_size() {
455        let result = ManagedMemoryHints::for_buffer(0x1000, 0);
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn hints_for_buffer_valid() {
461        let hints = ManagedMemoryHints::for_buffer(0x1000, 4096);
462        assert!(hints.is_ok());
463        let hints = hints.ok();
464        assert!(hints.is_some());
465        let hints = hints.map(|h| {
466            assert_eq!(h.ptr(), 0x1000);
467            assert_eq!(h.byte_size(), 4096);
468        });
469        let _ = hints;
470    }
471
472    #[test]
473    fn hints_accessors() {
474        let hints = ManagedMemoryHints::for_buffer(0xDEAD, 512);
475        if let Ok(h) = hints {
476            assert_eq!(h.ptr(), 0xDEAD);
477            assert_eq!(h.byte_size(), 512);
478        }
479    }
480
481    // -- PrefetchPlan tests -------------------------------------------------
482
483    #[test]
484    fn prefetch_plan_new_is_empty() {
485        let plan = PrefetchPlan::new();
486        assert!(plan.is_empty());
487        assert_eq!(plan.len(), 0);
488    }
489
490    #[test]
491    fn prefetch_plan_default_is_empty() {
492        let plan = PrefetchPlan::default();
493        assert!(plan.is_empty());
494    }
495
496    #[test]
497    fn prefetch_plan_add_and_len() {
498        let mut plan = PrefetchPlan::new();
499        plan.add(0x1000, 4096, 0).add(0x2000, 8192, 1);
500        assert_eq!(plan.len(), 2);
501        assert!(!plan.is_empty());
502
503        let entries = plan.entries();
504        assert_eq!(entries[0].ptr, 0x1000);
505        assert_eq!(entries[0].byte_size, 4096);
506        assert_eq!(entries[0].device_ordinal, 0);
507        assert_eq!(entries[1].ptr, 0x2000);
508        assert_eq!(entries[1].byte_size, 8192);
509        assert_eq!(entries[1].device_ordinal, 1);
510    }
511
512    #[test]
513    fn prefetch_plan_chaining() {
514        let mut plan = PrefetchPlan::new();
515        plan.add(0x100, 100, 0)
516            .add(0x200, 200, 0)
517            .add(0x300, 300, 0);
518        assert_eq!(plan.len(), 3);
519    }
520
521    // -- prefetch_range validation tests ------------------------------------
522
523    #[test]
524    fn prefetch_range_rejects_zero_count() {
525        // We need a Device and Stream for prefetch_range, but we can test
526        // the zero-count path only if Device::get succeeds.
527        if let Ok(dev) = Device::get(0) {
528            // We cannot construct a Stream without a context, so just verify
529            // the hints struct validates before calling the driver.
530            let hints = ManagedMemoryHints::for_buffer(0x1000, 4096);
531            // The zero-count check happens in prefetch_range before the
532            // driver call, so we test the function signature compiles.
533            let _ = (hints, dev);
534        }
535        // Compile-time signature check
536        let _: fn(&ManagedMemoryHints, usize, usize, &Device, &Stream) -> CudaResult<()> =
537            ManagedMemoryHints::prefetch_range;
538    }
539
540    #[test]
541    fn prefetch_range_out_of_bounds_detected() {
542        // Verify the bounds checking logic without needing a GPU.
543        // We replicate the internal check manually.
544        let byte_size: usize = 4096;
545        let offset: usize = 4000;
546        let count: usize = 200;
547        let end = offset.checked_add(count);
548        assert!(end.is_some());
549        let end = end.map(|e| e > byte_size);
550        // 4000 + 200 = 4200 > 4096
551        assert_eq!(end, Some(true));
552    }
553
554    // -- apply_migration_policy tests ---------------------------------------
555
556    #[test]
557    fn apply_policy_default_is_noop() {
558        // Default policy should return Ok without calling the driver.
559        let fake_dev: Device = unsafe { std::mem::zeroed() };
560        let result = apply_migration_policy(0x1000, 4096, &MigrationPolicy::Default, &fake_dev);
561        assert!(result.is_ok());
562    }
563
564    // -- Compile-time signature checks for GPU-requiring functions ----------
565
566    #[test]
567    fn signature_set_read_mostly() {
568        let _: fn(&ManagedMemoryHints, &Device) -> CudaResult<()> =
569            ManagedMemoryHints::set_read_mostly;
570    }
571
572    #[test]
573    fn signature_unset_read_mostly() {
574        let _: fn(&ManagedMemoryHints, &Device) -> CudaResult<()> =
575            ManagedMemoryHints::unset_read_mostly;
576    }
577
578    #[test]
579    fn signature_prefetch_to() {
580        let _: fn(&ManagedMemoryHints, &Device, &Stream) -> CudaResult<()> =
581            ManagedMemoryHints::prefetch_to;
582    }
583
584    #[test]
585    fn signature_apply_policy() {
586        let _: fn(&ManagedMemoryHints, &MigrationPolicy, &Device) -> CudaResult<()> =
587            ManagedMemoryHints::apply_policy;
588    }
589
590    #[test]
591    fn signature_execute_plan() {
592        let _: fn(&PrefetchPlan, &Stream) -> CudaResult<()> = PrefetchPlan::execute;
593    }
594
595    // -- Regression tests for F072 -------------------------------------------
596
597    #[cfg(feature = "gpu-tests")]
598    mod gpu_tests {
599        use super::*;
600        use crate::unified::UnifiedBuffer;
601
602        /// Establishes a real CUDA context on device 0. Returns `None` if no
603        /// driver/GPU is available so tests can skip gracefully.
604        fn real_context() -> Option<oxicuda_driver::context::Context> {
605            if oxicuda_driver::init().is_err() || Device::count().unwrap_or(0) == 0 {
606                return None;
607            }
608            let dev = Device::get(0).ok()?;
609            oxicuda_driver::context::Context::new(&dev).ok()
610        }
611
612        /// `MigrationPolicy::PreferHost` must direct the advice at the
613        /// driver's `CU_DEVICE_CPU` sentinel, not at whatever `Device` the
614        /// caller happens to pass in — so this must succeed on a real
615        /// managed allocation even though the passed-in device is
616        /// unrelated to "host memory".
617        #[test]
618        fn prefer_host_advises_cpu_not_passed_device() {
619            let Some(_ctx) = real_context() else {
620                eprintln!("skipping: no CUDA driver/device");
621                return;
622            };
623            let Ok(buf) = UnifiedBuffer::<f32>::alloc(256) else {
624                eprintln!("skipping: managed alloc failed");
625                return;
626            };
627            let Ok(dev) = Device::get(0) else {
628                eprintln!("skipping: no device 0");
629                return;
630            };
631            let result = apply_migration_policy(
632                buf.as_device_ptr(),
633                buf.byte_size(),
634                &MigrationPolicy::PreferHost,
635                &dev,
636            );
637            assert!(result.is_ok(), "PreferHost advice failed: {result:?}");
638        }
639
640        /// `MigrationPolicy::PreferDevice(ordinal)` must resolve and honour
641        /// its own declared ordinal rather than silently using whichever
642        /// `Device` the caller passed in: requesting an out-of-range
643        /// ordinal must fail even when a valid `device` argument is
644        /// supplied.
645        #[test]
646        fn prefer_device_honors_its_own_ordinal_not_passed_device() {
647            let Some(_ctx) = real_context() else {
648                eprintln!("skipping: no CUDA driver/device");
649                return;
650            };
651            let Ok(buf) = UnifiedBuffer::<f32>::alloc(256) else {
652                eprintln!("skipping: managed alloc failed");
653                return;
654            };
655            let Ok(dev0) = Device::get(0) else {
656                eprintln!("skipping: no device 0");
657                return;
658            };
659
660            // A valid ordinal (0) must succeed even though `dev0` is passed
661            // as the (now-ignored for this variant) `device` argument.
662            let ok_result = apply_migration_policy(
663                buf.as_device_ptr(),
664                buf.byte_size(),
665                &MigrationPolicy::PreferDevice(0),
666                &dev0,
667            );
668            assert!(ok_result.is_ok(), "PreferDevice(0) failed: {ok_result:?}");
669
670            // An out-of-range ordinal must fail — proving the policy's own
671            // ordinal is actually being resolved and used, not silently
672            // ignored in favour of `dev0`.
673            let bad_result = apply_migration_policy(
674                buf.as_device_ptr(),
675                buf.byte_size(),
676                &MigrationPolicy::PreferDevice(9999),
677                &dev0,
678            );
679            assert!(
680                bad_result.is_err(),
681                "PreferDevice(9999) unexpectedly succeeded; ordinal was not honored"
682            );
683        }
684    }
685}