Skip to main content

monarch_rdma/backend/ibverbs/
manager_actor.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! # Ibverbs Manager
10//!
11//! Contains ibverbs-specific RDMA logic.
12//!
13//! Manages ibverbs resources including:
14//! - Memory registration (CPU and CUDA via dmabuf or segment scanning)
15//! - Queue pair creation and connection establishment
16//! - RDMA domain and protection domain management
17//! - Device selection and PCI-to-RDMA device mapping
18
19use std::collections::HashMap;
20use std::fmt::Write as _;
21use std::num::NonZeroUsize;
22use std::sync::Arc;
23use std::sync::OnceLock;
24use std::time::Duration;
25
26use anyhow::Result;
27use async_trait::async_trait;
28use dashmap::DashMap;
29use hyperactor::Actor;
30use hyperactor::ActorHandle;
31use hyperactor::ActorId;
32use hyperactor::ActorRef;
33use hyperactor::Context;
34use hyperactor::Endpoint as _;
35use hyperactor::HandleClient;
36use hyperactor::Handler;
37use hyperactor::Instance;
38use hyperactor::OncePortHandle;
39use hyperactor::OncePortRef;
40use hyperactor::actor::Referable;
41use rand::seq::SliceRandom;
42use serde::Deserialize;
43use serde::Serialize;
44use tokio::sync::mpsc;
45use typeuri::Named;
46
47use super::IbvOp;
48use super::cq_actor::CompletionQueueActor;
49use super::device::IbvDevice;
50use super::device::IbvDeviceImpl;
51use super::device_selection::PeerDeviceAffinityPolicy;
52use super::device_selection::configured_peer_device_affinity;
53use super::device_selection::resolve_target;
54use super::device_selection::select_optimal_ibv_devices;
55use super::domain::IbvDomain;
56use super::domain::IbvDomainImpl;
57use super::efa_device::EfaDevice;
58use super::memory_region::IbvMemoryRegionView;
59use super::memory_region::IbvRemoteMemoryRegionView;
60use super::mlx_device::MlxDevice;
61use super::primitives::IbvConfig;
62use super::primitives::IbvCq;
63use super::primitives::IbvQpInfo;
64use super::primitives::ibverbs_supported;
65use super::queue_pair::IbvQueuePair;
66use super::queue_pair::ProcessOps;
67use super::queue_pair::QpKey;
68use super::queue_pair::QueuePairActor;
69use super::queue_pair::QueuePairHandle;
70use super::queue_pair::QueuePairOp;
71use super::queue_pair::StripeId;
72use super::queue_pair::StripeResult;
73use super::queue_pair::legacy;
74use crate::RdmaOp;
75use crate::RdmaOpType;
76use crate::RdmaTransportLevel;
77use crate::backend::RdmaBackend;
78use crate::backend::RdmaConfig;
79use crate::backend::ResolveRemoteBackendContext;
80use crate::local_memory::KeepaliveLocalMemory;
81use crate::rdma_components::RdmaRemoteBuffer;
82use crate::rdma_manager_actor::RdmaManagerActor;
83use crate::validate_execution_context;
84
85const KIB: usize = 1024;
86// GB200 host-memory RDMA reaches full bandwidth only when each stripe starts
87// on a cache-line boundary.
88const STRIPE_ALIGNMENT: usize = 64;
89
90fn configured_min_stripe_size() -> NonZeroUsize {
91    let kilobytes = hyperactor_config::global::get(crate::config::RDMA_MIN_STRIPE_SIZE_KB).get();
92    NonZeroUsize::new(
93        kilobytes
94            .checked_mul(KIB)
95            .expect("RDMA_MIN_STRIPE_SIZE_KB fits in bytes"),
96    )
97    .expect("a non-zero KiB count produces a non-zero byte count")
98}
99
100/// Cross-proc message: the active side asks the peer's manager to
101/// create and connect a mirror QP for an in-flight [`QueuePairActor`].
102/// Generic over the manager actor type so test code can swap in a
103/// mock.
104#[derive(Debug, Serialize, Deserialize, Named)]
105#[serde(bound(serialize = "", deserialize = ""))]
106pub(super) struct CreatePeerQueuePair<M: Referable> {
107    /// The active side's manager.
108    pub(super) sender: ActorRef<M>,
109    /// Device the active side picked for its QP.
110    pub(super) sender_device: String,
111    /// Device the peer should create its mirror QP on.
112    pub(super) receiver_device: String,
113    /// Distinguishes parallel QPs for the same device route.
114    pub(super) qp_index: usize,
115    /// Active side's endpoint, captured right after QP creation.
116    pub(super) sender_info: IbvQpInfo,
117    /// One-shot reply carrying the peer's endpoint, or an error.
118    pub(super) reply: OncePortRef<Result<IbvQpInfo, String>>,
119}
120wirevalue::register_type!(CreatePeerQueuePair<IbvManagerActor<MlxDevice>>);
121wirevalue::register_type!(CreatePeerQueuePair<IbvManagerActor<EfaDevice>>);
122
123/// Local-only message: submit a batch of RDMA ops for end-to-end
124/// execution. The manager iterates the batch, resolves each op's
125/// local MRs via [`IbvManagerActor::resolve_local_mrs`], splits the operation
126/// across compatible registration pairs with [`QueuePairRouter::plan_ops`],
127/// and dispatches each stripe to the active-side [`QueuePairActor`] for its
128/// [`QpKey`].
129///
130/// Per-stripe completion notifications stream back on `reply` as
131/// [`StripeResult`] values.
132pub(super) struct SubmitOps<I: IbvDeviceImpl> {
133    ops: Vec<ManagerOp<I>>,
134    reply: mpsc::UnboundedSender<StripeResult>,
135}
136
137enum ManagerOp<I: IbvDeviceImpl> {
138    NeedsMemReg {
139        op_idx: usize,
140        op: IbvOp<IbvManagerActor<I>>,
141    },
142    NeedsQueuePair {
143        peer_manager: ActorRef<IbvManagerActor<I>>,
144        ops: Vec<QueuePairOp>,
145    },
146}
147
148#[derive(Clone, Debug, Eq, Hash, PartialEq)]
149struct QpRoute {
150    self_device: String,
151    other_id: ActorId,
152    other_device: String,
153}
154
155/// Shared state for selecting a compatible registration pair and dispatching
156/// operations directly to initialized queue-pair workers.
157///
158/// The manager installs a route after creating a queue pair and removes all
159/// routes before tearing its queue pairs down. Each [`IbvBackend`] shares this
160/// router with the manager so warm submissions can bypass the manager mailbox;
161/// operations without local registrations or a matching route fall back to
162/// [`SubmitOps`].
163#[derive(Debug)]
164struct QueuePairRouter {
165    peer_device_affinity: PeerDeviceAffinityPolicy,
166    qps_per_peer: NonZeroUsize,
167    next_qp_indices: DashMap<QpRoute, usize>,
168    queue_pairs: DashMap<QpKey, mpsc::UnboundedSender<ProcessOps>>,
169}
170
171impl QueuePairRouter {
172    fn new(peer_device_affinity: PeerDeviceAffinityPolicy, qps_per_peer: NonZeroUsize) -> Self {
173        Self {
174            peer_device_affinity,
175            qps_per_peer,
176            next_qp_indices: DashMap::new(),
177            queue_pairs: DashMap::new(),
178        }
179    }
180
181    fn next_qp_index(&self, route: QpRoute) -> usize {
182        let mut next = self.next_qp_indices.entry(route).or_insert(0);
183        let current = *next;
184        *next = (current + 1) % self.qps_per_peer.get();
185        current
186    }
187
188    /// Returns a disjoint list of registration pairings allowed by the affinity policy.
189    fn pair_registrations<'a>(
190        &self,
191        local: &'a [IbvMemoryRegionView],
192        remote: &'a [IbvRemoteMemoryRegionView],
193    ) -> Result<Vec<(&'a IbvMemoryRegionView, &'a IbvRemoteMemoryRegionView)>, anyhow::Error> {
194        // `PeerDeviceAffinityPolicy::pairs` is sensitive to input order. Sort
195        // both sides so selection depends only on the unordered registration
196        // sets and is consistent across processes.
197        let mut local: Vec<&IbvMemoryRegionView> = local.iter().collect();
198        local.sort_by(|a, b| a.device_name.cmp(&b.device_name));
199        let mut remote: Vec<&IbvRemoteMemoryRegionView> = remote.iter().collect();
200        remote.sort_by(|a, b| a.device_name.cmp(&b.device_name));
201
202        let local_names: Vec<String> = local.iter().map(|mr| mr.device_name.clone()).collect();
203        let remote_names: Vec<String> = remote.iter().map(|mr| mr.device_name.clone()).collect();
204        let pairs = self
205            .peer_device_affinity
206            .pairs(&local_names, &remote_names)
207            .into_iter()
208            .enumerate()
209            .filter_map(|(i, peer)| peer.map(|j| (local[i], remote[j])))
210            .collect::<Vec<_>>();
211        anyhow::ensure!(
212            !pairs.is_empty(),
213            "no NIC of {local_names:?} pairs with a peer NIC of {remote_names:?} under {:?}",
214            self.peer_device_affinity,
215        );
216        Ok(pairs)
217    }
218
219    /// Splits an operation into stripes over its compatible NIC pairs.
220    ///
221    /// An operation gets at least `qps_per_peer` stripes, rounded up to a
222    /// multiple of the pair count, as far as
223    /// [`RDMA_MIN_STRIPE_SIZE_KB`](crate::config::RDMA_MIN_STRIPE_SIZE_KB)
224    /// allows. One that is too small for a stripe on every pair uses a random
225    /// subset of the pairs, one stripe each. Otherwise the pairs take stripes
226    /// in turn, in device-name order. Each stripe uses the next queue pair on
227    /// its route.
228    fn plan_ops<I: IbvDeviceImpl>(
229        &self,
230        op_idx: usize,
231        op: &IbvOp<IbvManagerActor<I>>,
232    ) -> Result<Vec<QueuePairOp>, anyhow::Error> {
233        let local_mrs = op.local_memory.registered_mrs::<I>();
234        let mut pairs = self.pair_registrations(&local_mrs, &op.remote_buffers)?;
235        let other_id = op.remote_manager.actor_addr().id().clone();
236
237        let size = match op.op_type {
238            RdmaOpType::WriteFromLocal => op.local_memory.size(),
239            RdmaOpType::ReadIntoLocal => op
240                .remote_buffers
241                .first()
242                .map(|remote| remote.size)
243                .unwrap_or(0),
244        };
245        let max_stripes = (size / configured_min_stripe_size().get()).max(1);
246        let stripe_count = if max_stripes < pairs.len() {
247            pairs.shuffle(&mut rand::rng());
248            max_stripes
249        } else {
250            // Every pair takes the same number of stripes, so no NIC carries
251            // more than its share.
252            let wanted_per_pair = self.qps_per_peer.get().div_ceil(pairs.len());
253            let allowed_per_pair = max_stripes / pairs.len();
254            pairs.len() * wanted_per_pair.min(allowed_per_pair)
255        };
256
257        let stripe_size = size / stripe_count / STRIPE_ALIGNMENT * STRIPE_ALIGNMENT;
258        let mut offset = 0;
259        (0..stripe_count)
260            .map(|stripe_idx| {
261                let (local, remote) = pairs[stripe_idx % pairs.len()];
262                let size = if stripe_idx + 1 == stripe_count {
263                    size - offset
264                } else {
265                    stripe_size
266                };
267                let route = QpRoute {
268                    self_device: local.device_name.clone(),
269                    other_id: other_id.clone(),
270                    other_device: remote.device_name.clone(),
271                };
272                let op = QueuePairOp {
273                    stripe_id: StripeId {
274                        op_idx,
275                        stripe_idx,
276                        stripe_count,
277                    },
278                    qp_index: self.next_qp_index(route),
279                    op_type: op.op_type,
280                    local_memory: op.local_memory.clone(),
281                    local: local.try_slice(offset, size)?,
282                    remote: remote.try_slice(offset, size)?,
283                };
284                offset += size;
285                Ok(op)
286            })
287            .collect()
288    }
289
290    fn try_dispatch<I: IbvDeviceImpl>(
291        &self,
292        op_idx: usize,
293        op: IbvOp<IbvManagerActor<I>>,
294        reply: &mpsc::UnboundedSender<StripeResult>,
295    ) -> Option<ManagerOp<I>> {
296        if op.local_memory.registered_mrs::<I>().is_empty() {
297            return Some(ManagerOp::NeedsMemReg { op_idx, op });
298        }
299        let planned = match self.plan_ops(op_idx, &op) {
300            Ok(planned) => planned,
301            Err(error) => {
302                let _ = reply.send(StripeResult {
303                    stripe_id: StripeId {
304                        op_idx,
305                        stripe_idx: 0,
306                        stripe_count: 1,
307                    },
308                    result: Err(error.to_string()),
309                });
310                return None;
311            }
312        };
313        let senders = planned
314            .iter()
315            .map(|planned| {
316                let key = QpKey {
317                    self_device: planned.local.device_name.clone(),
318                    other_id: op.remote_manager.actor_addr().id().clone(),
319                    other_device: planned.remote.device_name.clone(),
320                    qp_index: planned.qp_index,
321                };
322                self.queue_pairs
323                    .get(&key)
324                    .map(|sender| sender.value().clone())
325            })
326            .collect::<Option<Vec<_>>>();
327        let Some(senders) = senders else {
328            return Some(ManagerOp::NeedsQueuePair {
329                peer_manager: op.remote_manager,
330                ops: planned,
331            });
332        };
333
334        for (sender, planned) in senders.into_iter().zip(planned) {
335            let stripe_id = planned.stripe_id;
336            if sender
337                .send(ProcessOps {
338                    items: vec![planned],
339                    reply: reply.clone(),
340                })
341                .is_err()
342            {
343                let _ = reply.send(StripeResult {
344                    stripe_id,
345                    result: Err("queue pair worker stopped".to_owned()),
346                });
347            }
348        }
349        None
350    }
351}
352
353/// Local-only message: create a fresh, unconnected legacy
354/// [`legacy::IbvQueuePair`] on `self_device` and return it. The caller drives
355/// the connection itself — exchange [`IbvQpInfo`] with the other endpoint's QP
356/// and call `connect` on each side. Lets doorbell tests and the
357/// `cuda_ping_pong` example poke a real QP without going through
358/// [`QueuePairActor`]; both want the legacy queue pair for its direct
359/// device-doorbell data path, independent of the backend's production
360/// [`IbvDomainImpl::QueuePair`].
361pub struct RawQueuePair {
362    pub self_device: String,
363    pub reply: OncePortHandle<Result<legacy::IbvQueuePair, String>>,
364}
365
366/// Local-only messages for [`IbvManagerActor`].
367#[derive(Handler, HandleClient, Debug)]
368pub enum IbvManagerLocalMessage {
369    /// Register `local`'s MRs and reply with one
370    /// [`IbvRemoteMemoryRegionView`] per registration. Called by
371    /// [`crate::rdma_manager_actor::RdmaManagerActor::request_buffer`]
372    /// at buffer-creation time.
373    ///
374    /// The registrations live on `local` itself, so they stay in force for as
375    /// long as any holder of that handle does.
376    RegisterRemoteBuffer {
377        local: KeepaliveLocalMemory,
378        #[reply]
379        reply: OncePortHandle<Result<Vec<IbvRemoteMemoryRegionView>, String>>,
380    },
381}
382
383/// Default key used for the per-device protection domain inside
384/// each [`IbvDevice<I>`] entry of [`IbvManagerActor::devices`].
385const DEFAULT_DOMAIN: &str = "default";
386
387/// Manages all ibverbs-specific RDMA resources and operations.
388///
389/// This struct handles memory registration, queue pair management,
390/// and connection establishment using the ibverbs API.
391///
392/// Generic over `I: IbvDeviceImpl` so the same actor implementation
393/// drives every concrete backend (`IbvManagerActor<MlxDevice>`,
394/// `IbvManagerActor<EfaDevice>`, ...).
395#[derive(Debug)]
396#[hyperactor::export(
397    handlers = [
398        CreatePeerQueuePair<IbvManagerActor<I>>,
399    ],
400)]
401pub struct IbvManagerActor<I: IbvDeviceImpl> {
402    owner: OnceLock<ActorHandle<RdmaManagerActor>>,
403
404    /// Active-side queue-pair workers and their supervising actor, keyed
405    /// from this manager's perspective. Lazily populated on the first
406    /// [`SubmitOps`] that targets a new `(self_device, peer,
407    /// other_device, qp_index)` tuple.
408    qp_handles: HashMap<
409        QpKey,
410        QueuePairHandle<IbvManagerActor<I>, <I::Domain as IbvDomainImpl>::QueuePair>,
411    >,
412
413    /// Passive-side mirror QPs, created in response to a peer's
414    /// [`CreatePeerQueuePair`]. The peer's [`QueuePairActor`] owns
415    /// the active side; we hold the connected mirror here so the
416    /// peer can read/write our memory. The map's `Drop` destroys
417    /// each QP via its own `Drop`.
418    peer_created_qps: HashMap<QpKey, <I::Domain as IbvDomainImpl>::QueuePair>,
419
420    /// Map of RDMA device names to their opened [`IbvDevice<I>`], each of
421    /// which owns the per-device `Arc<IbvContext>` and the `DEFAULT_DOMAIN`
422    /// `Arc<IbvDomain>`.
423    devices: HashMap<String, IbvDevice<I>>,
424
425    /// Which of a peer's NICs each of this manager's NICs may pair with, from
426    /// [`RDMA_PEER_DEVICE_AFFINITY`](crate::config::RDMA_PEER_DEVICE_AFFINITY).
427    /// Read once, when the manager starts.
428    peer_device_affinity: PeerDeviceAffinityPolicy,
429
430    /// Warm QP routes shared with [`IbvBackend`] for direct steady-state
431    /// submission without a manager-mailbox round trip.
432    queue_pair_router: Arc<QueuePairRouter>,
433
434    /// Completion pollers, keyed by device name when each device gets its own
435    /// poller and by the empty string when all devices share one.
436    cq_pollers: HashMap<String, ActorHandle<CompletionQueueActor<IbvCq>>>,
437
438    config: IbvConfig,
439}
440
441#[async_trait]
442impl<I: IbvDeviceImpl> Actor for IbvManagerActor<I> {
443    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
444        this.set_system();
445        let owner = if let Some(owner) = this.parent_handle() {
446            owner
447        } else {
448            anyhow::bail!("RdmaManagerActor not found as parent of IbvManagerActor");
449        };
450        self.owner
451            .set(owner)
452            .expect("owner should only be set once during init");
453        Ok(())
454    }
455
456    // This actor is implemented in Rust, but the RDMA registration path may enter
457    // Python and take the GIL. Run its loop on the dedicated rdma runtime rather
458    // than the shared control-plane runtime; see `crate::rdma_runtime`.
459    fn spawn_server_task<F>(future: F) -> tokio::task::JoinHandle<F::Output>
460    where
461        F: std::future::Future + Send + 'static,
462        F::Output: Send + 'static,
463    {
464        crate::rdma_runtime::spawn_on_rdma_runtime(future)
465    }
466}
467
468impl<I: IbvDeviceImpl> Drop for IbvManagerActor<I> {
469    fn drop(&mut self) {
470        self.queue_pair_router.queue_pairs.clear();
471
472        // Stop each supervising QP actor; its cleanup cancels and joins the
473        // data-plane worker that owns the QP.
474        for (_key, handle) in self.qp_handles.drain() {
475            let _ = handle.drain_and_stop("IbvManagerActor dropped");
476        }
477
478        for (_key, poller) in self.cq_pollers.drain() {
479            let _ = poller.drain_and_stop("IbvManagerActor dropped");
480        }
481
482        // The remaining fields (`peer_created_qps`, `devices`) free their FFI
483        // resources through their elements' `Drop`s when this struct is dropped.
484    }
485}
486
487impl<I: IbvDeviceImpl> IbvManagerActor<I> {
488    /// Create a new IbvManagerActor with the given configuration.
489    pub async fn new(params: Option<IbvConfig>) -> Result<Self, anyhow::Error> {
490        if !ibverbs_supported() {
491            return Err(anyhow::anyhow!(
492                "Cannot create IbvManagerActor because RDMA is not supported on this machine"
493            ));
494        }
495
496        // Use the caller's config; when none is given, start from the
497        // defaults and let the backend seed its own.
498        let mut config = match params {
499            Some(config) => config,
500            None => {
501                let mut config = IbvConfig::default();
502                I::apply_config_defaults(&mut config);
503                config
504            }
505        };
506
507        // Preserve an explicit per-manager target. Otherwise parse the process
508        // default from `RDMA_IBVERBS_TARGET`; empty preserves automatic selection.
509        if config.target.is_none() {
510            config.target = super::device_selection::configured_ibverbs_target()?;
511        }
512        tracing::debug!("rdma is enabled, config target: {:?}", config.target);
513
514        // check config and hardware support align
515        if config.use_gpu_direct {
516            match validate_execution_context().await {
517                Ok(_) => {
518                    tracing::info!("GPU Direct RDMA execution context validated successfully");
519                }
520                Err(e) => {
521                    tracing::warn!(
522                        "GPU Direct RDMA execution context validation failed: {}. Downgrading to standard ibverbs mode.",
523                        e
524                    );
525                    config.use_gpu_direct = false;
526                }
527            }
528        }
529
530        let peer_device_affinity = configured_peer_device_affinity()?;
531        let actor = Self {
532            owner: OnceLock::new(),
533            qp_handles: HashMap::new(),
534            peer_created_qps: HashMap::new(),
535            devices: HashMap::new(),
536            peer_device_affinity: peer_device_affinity.clone(),
537            queue_pair_router: Arc::new(QueuePairRouter::new(
538                peer_device_affinity,
539                hyperactor_config::global::get(crate::config::RDMA_QPS_PER_PEER).into_std(),
540            )),
541            cq_pollers: HashMap::new(),
542            config,
543        };
544
545        Ok(actor)
546    }
547
548    fn queue_pair_router(&self) -> Arc<QueuePairRouter> {
549        Arc::clone(&self.queue_pair_router)
550    }
551
552    /// Get or create the `DEFAULT_DOMAIN` for the named RDMA device, opening
553    /// the device on first use.
554    fn get_or_create_device_domain(
555        &mut self,
556        device_name: &str,
557    ) -> Result<&IbvDomain<I::Domain>, anyhow::Error> {
558        self.get_or_create_device(device_name)?
559            .get_or_create_domain(DEFAULT_DOMAIN)
560    }
561
562    /// Get the named RDMA device, opening it on first use.
563    fn get_or_create_device(
564        &mut self,
565        device_name: &str,
566    ) -> Result<&mut IbvDevice<I>, anyhow::Error> {
567        if !self.devices.contains_key(device_name) {
568            let device = IbvDevice::<I>::try_open(device_name, self.config.clone())?;
569            // Print device info if MONARCH_DEBUG_RDMA=1 is set.
570            crate::print_device_info_if_debug_enabled(device.context().as_ptr());
571            self.devices.insert(device_name.to_string(), device);
572        }
573        Ok(self
574            .devices
575            .get_mut(device_name)
576            .expect("device just inserted or already present"))
577    }
578
579    /// Chooses a set of NICs on which to register `mem`, then registers
580    /// `mem` on those NICs (caching the registrations inside the handle)
581    /// and returns the relevant [`IbvMemoryRegionView`]s. The call fails
582    /// only when there are no successful registrations.
583    ///
584    /// If an explicit `config.target` is set, then that is the NIC that is
585    /// chosen. Otherwise, NICs are chosen based on `pick_optimal_devices`.
586    ///
587    /// A region already registered on this backend keeps the NICs it has: the
588    /// set is chosen once, on the first call.
589    fn resolve_local_mrs(
590        &mut self,
591        mem: &KeepaliveLocalMemory,
592    ) -> Result<Vec<IbvMemoryRegionView>, anyhow::Error> {
593        let already_serving = mem.registered_mrs::<I>();
594        if !already_serving.is_empty() {
595            return Ok(already_serving);
596        }
597
598        let device_names = match &self.config.target {
599            Some(target) => vec![
600                resolve_target::<I>(target)?
601                    .ok_or_else(|| {
602                        anyhow::anyhow!("configured device target {:?} not found", target)
603                    })?
604                    .name()
605                    .clone(),
606            ],
607            None => self.pick_optimal_devices(mem)?,
608        };
609
610        let mut views = Vec::with_capacity(device_names.len());
611        let mut failure: Option<anyhow::Error> = None;
612        for device_name in &device_names {
613            match self.resolve_local_mr_on(mem, device_name) {
614                Ok(view) => views.push(view),
615                Err(error) => {
616                    tracing::warn!(
617                        "not serving [{:#x}, {:#x}) from {device_name}: {error:#}",
618                        mem.addr(),
619                        mem.addr() + mem.size(),
620                    );
621                    failure.get_or_insert(error);
622                }
623            }
624        }
625        if views.is_empty() {
626            return Err(failure.expect("`device_names` is never empty, so one of them failed"));
627        }
628        Ok(views)
629    }
630
631    /// Resolve `mem` to its [`IbvMemoryRegionView`] on `device_name`,
632    /// registering the region there on first use.
633    ///
634    /// The registration lives on `mem` itself, shared by every clone of that
635    /// handle, so one handle registers a given device at most once. Nothing
636    /// deduplicates separate handles over the same region, though: each
637    /// carries its own registrations, so a region covered by two handles is
638    /// registered once per handle. A failure is recorded on `mem` too and
639    /// returned to later callers rather than retried.
640    fn resolve_local_mr_on(
641        &mut self,
642        mem: &KeepaliveLocalMemory,
643        device_name: &str,
644    ) -> Result<IbvMemoryRegionView, anyhow::Error> {
645        if let Some(mrv) = mem.registered_mr::<I>(device_name)? {
646            return Ok(mrv);
647        }
648        tracing::debug!(
649            "Using RDMA device: {} for memory at 0x{:x}",
650            device_name,
651            mem.addr()
652        );
653
654        // The backend strategy handles host vs. device memory (standard MR,
655        // dmabuf MR, or a device-specific segment binding).
656        let registered = self
657            .get_or_create_device_domain(device_name)
658            .and_then(|domain| domain.register_mr(mem));
659        match registered {
660            Ok(mrv) => mem.install_mr::<I>(mrv),
661            Err(error) => {
662                mem.record_mr_failure::<I>(device_name, &error);
663                Err(error)
664            }
665        }
666    }
667
668    /// The NICs of backend `I` to serve `mem` from: up to
669    /// [`RDMA_MAX_NICS_PER_BUFFER`](crate::config::RDMA_MAX_NICS_PER_BUFFER)
670    /// of the optimal NICs returned by [`select_optimal_ibv_devices`], chosen
671    /// using [`PeerDeviceAffinityPolicy::choose`].
672    fn pick_optimal_devices(
673        &self,
674        mem: &KeepaliveLocalMemory,
675    ) -> Result<Vec<String>, anyhow::Error> {
676        let location = mem.location();
677        let devices = select_optimal_ibv_devices::<I>(location)?;
678        anyhow::ensure!(
679            !devices.is_empty(),
680            "no {} RDMA device has a path to {location:?}",
681            I::backend_name(),
682        );
683        let names: Vec<String> = devices.iter().map(|device| device.name().clone()).collect();
684        let max = hyperactor_config::global::get(crate::config::RDMA_MAX_NICS_PER_BUFFER);
685        // `names` is passed in the order returned by `select_optimal_ibv_devices`, which is
686        // lexicographic. `PeerDeviceAffinityPolicy::choose` is sensitive to input order,
687        // so ensuring `names` has the same order across procs is important for consistency.
688        Ok(self
689            .peer_device_affinity
690            .choose(&names, max.map(hyperactor_config::NonZeroUsize::into_std)))
691    }
692
693    /// Build a passive-side mirror QP for `qp_key`, connect it to
694    /// `sender_info`, and store it in [`Self::peer_created_qps`].
695    /// Returns the local endpoint the active side needs to finish
696    /// its own `connect`. Called from
697    /// [`Handler<CreatePeerQueuePair>`].
698    fn create_peer_qp(
699        &mut self,
700        qp_key: &QpKey,
701        sender_info: &IbvQpInfo,
702    ) -> Result<IbvQpInfo, anyhow::Error> {
703        if self.peer_created_qps.contains_key(qp_key) {
704            anyhow::bail!("peer queue pair already exists for {qp_key:?}");
705        }
706        let self_device = qp_key.self_device.clone();
707        let config = self.config.clone();
708        let mut qp = self
709            .get_or_create_device(&self_device)?
710            .create_non_posting_queue_pair(DEFAULT_DOMAIN, &config)
711            .map_err(|e| anyhow::anyhow!("could not create peer IbvQueuePair: {}", e))?;
712        let local_info = qp
713            .get_qp_info()
714            .map_err(|e| anyhow::anyhow!("could not extract peer QP info: {}", e))?;
715        qp.connect(sender_info)
716            .map_err(|e| anyhow::anyhow!("could not connect peer QP: {}", e))?;
717        self.peer_created_qps.insert(qp_key.clone(), qp);
718        Ok(local_info)
719    }
720
721    /// Returns the poller for `device`, spawning it on first use.
722    fn ensure_cq_poller(
723        &mut self,
724        cx: &Context<'_, Self>,
725        device: &str,
726    ) -> ActorHandle<CompletionQueueActor<IbvCq>> {
727        let key = if hyperactor_config::global::get(crate::config::RDMA_CQ_POLLER_PER_DEVICE) {
728            device.to_owned()
729        } else {
730            String::new()
731        };
732        self.cq_pollers
733            .entry(key)
734            .or_insert_with(|| cx.spawn(CompletionQueueActor::new()))
735            .clone()
736    }
737
738    /// Lazy active-side QP worker: if `qp_key` is absent from
739    /// [`Self::qp_handles`], create an [`IbvQueuePair`] on the
740    /// requested device and spawn a [`QueuePairActor`] to drive its handshake
741    /// and supervise its data-plane task. Returns a handle to the QP actor.
742    fn ensure_qp_actor(
743        &mut self,
744        cx: &Context<'_, Self>,
745        qp_key: &QpKey,
746        peer_manager: ActorRef<Self>,
747    ) -> Result<QueuePairHandle<Self, <I::Domain as IbvDomainImpl>::QueuePair>, anyhow::Error> {
748        if let Some(h) = self.qp_handles.get(qp_key) {
749            return Ok(h.clone());
750        }
751        let self_device = qp_key.self_device.clone();
752        let config = self.config.clone();
753        let cq_poller = self.ensure_cq_poller(cx, &self_device);
754        let (qp, cq_lease) = self
755            .get_or_create_device(&self_device)?
756            .create_queue_pair(DEFAULT_DOMAIN, &config)
757            .map_err(|e| anyhow::anyhow!("could not create IbvQueuePair for {qp_key:?}: {}", e))?;
758        let local_manager: ActorRef<Self> = cx.bind();
759        let is_loopback = local_manager.actor_addr() == peer_manager.actor_addr()
760            && qp_key.self_device == qp_key.other_device;
761        let (actor, sender) = QueuePairActor::new(
762            qp_key.clone(),
763            local_manager,
764            peer_manager,
765            qp,
766            cq_poller,
767            cq_lease,
768            is_loopback,
769            config.max_send_wr,
770        );
771        let handle = QueuePairHandle::new(cx.spawn(actor), sender);
772        self.queue_pair_router
773            .queue_pairs
774            .insert(qp_key.clone(), handle.sender());
775        self.qp_handles.insert(qp_key.clone(), handle.clone());
776        Ok(handle)
777    }
778
779    fn dispatch_ops(
780        &mut self,
781        cx: &Context<'_, Self>,
782        peer_manager: ActorRef<Self>,
783        ops: Vec<QueuePairOp>,
784        reply: &mpsc::UnboundedSender<StripeResult>,
785    ) {
786        for op in ops {
787            let stripe_id = op.stripe_id;
788            let qp_key = QpKey {
789                self_device: op.local.device_name.clone(),
790                other_id: peer_manager.actor_addr().id().clone(),
791                other_device: op.remote.device_name.clone(),
792                qp_index: op.qp_index,
793            };
794            let handle = match self.ensure_qp_actor(cx, &qp_key, peer_manager.clone()) {
795                Ok(handle) => handle,
796                Err(error) => {
797                    let _ = reply.send(StripeResult {
798                        stripe_id,
799                        result: Err(error.to_string()),
800                    });
801                    continue;
802                }
803            };
804            if let Err(error) = handle.send(ProcessOps {
805                items: vec![op],
806                reply: reply.clone(),
807            }) {
808                let _ = reply.send(StripeResult {
809                    stripe_id,
810                    result: Err(error.to_string()),
811                });
812            }
813        }
814    }
815}
816
817#[async_trait]
818impl<I: IbvDeviceImpl> Handler<SubmitOps<I>> for IbvManagerActor<I> {
819    async fn handle(&mut self, cx: &Context<Self>, msg: SubmitOps<I>) -> Result<(), anyhow::Error> {
820        let SubmitOps { ops, reply } = msg;
821
822        // Interleave MR resolution with QP dispatch: as soon as an op is
823        // planned, send its stripes to their queue-pair workers. They can post
824        // the stripes while the manager resolves the next op's registrations.
825        for op in ops {
826            let (peer_manager, planned) = match op {
827                ManagerOp::NeedsQueuePair { peer_manager, ops } => (peer_manager, ops),
828                ManagerOp::NeedsMemReg { op_idx, op } => {
829                    let whole_op = StripeId {
830                        op_idx,
831                        stripe_idx: 0,
832                        stripe_count: 1,
833                    };
834                    if let Err(error) = self.resolve_local_mrs(&op.local_memory) {
835                        let _ = reply.send(StripeResult {
836                            stripe_id: whole_op,
837                            result: Err(error.to_string()),
838                        });
839                        continue;
840                    }
841                    let planned = match self.queue_pair_router.plan_ops(op_idx, &op) {
842                        Ok(planned) => planned,
843                        Err(error) => {
844                            let _ = reply.send(StripeResult {
845                                stripe_id: whole_op,
846                                result: Err(error.to_string()),
847                            });
848                            continue;
849                        }
850                    };
851                    (op.remote_manager, planned)
852                }
853            };
854            self.dispatch_ops(cx, peer_manager, planned, &reply);
855        }
856        Ok(())
857    }
858}
859
860#[async_trait]
861impl<I: IbvDeviceImpl> Handler<RawQueuePair> for IbvManagerActor<I> {
862    async fn handle(&mut self, cx: &Context<Self>, msg: RawQueuePair) -> Result<(), anyhow::Error> {
863        let RawQueuePair { self_device, reply } = msg;
864        // Build a fresh, unconnected legacy QP on `self_device` and hand it
865        // back; the caller exchanges endpoint info and connects it.
866        let config = self.config.clone();
867        let result = self
868            .get_or_create_device_domain(&self_device)
869            .and_then(|domain| legacy::IbvQueuePair::new(domain, config))
870            .map_err(|e| e.to_string());
871        let _ = reply.try_post(cx, result);
872        Ok(())
873    }
874}
875
876#[async_trait]
877impl<I: IbvDeviceImpl> Handler<CreatePeerQueuePair<IbvManagerActor<I>>> for IbvManagerActor<I> {
878    async fn handle(
879        &mut self,
880        cx: &Context<Self>,
881        msg: CreatePeerQueuePair<IbvManagerActor<I>>,
882    ) -> Result<(), anyhow::Error> {
883        let CreatePeerQueuePair {
884            sender,
885            sender_device,
886            receiver_device,
887            qp_index,
888            sender_info,
889            reply,
890        } = msg;
891        let qp_key = QpKey {
892            self_device: receiver_device,
893            other_id: sender.actor_addr().id().clone(),
894            other_device: sender_device,
895            qp_index,
896        };
897        match self.create_peer_qp(&qp_key, &sender_info) {
898            Ok(local_info) => reply.post(cx, Ok(local_info)),
899            Err(e) => reply.post(cx, Err(e.to_string())),
900        }
901        Ok(())
902    }
903}
904
905#[async_trait]
906impl<I: IbvDeviceImpl> IbvManagerLocalMessageHandler for IbvManagerActor<I> {
907    async fn register_remote_buffer(
908        &mut self,
909        _cx: &Context<Self>,
910        local: KeepaliveLocalMemory,
911    ) -> Result<Result<Vec<IbvRemoteMemoryRegionView>, String>, anyhow::Error> {
912        // The registration is installed on `local`'s shared map, so every clone
913        // of this handle — including the one the caller holds — reuses it rather
914        // than registering the same region on that device again.
915        Ok(self
916            .resolve_local_mrs(&local)
917            .map(|mrs| mrs.iter().map(IbvRemoteMemoryRegionView::from).collect())
918            .map_err(|error| format!("{error:#}")))
919    }
920}
921
922// `#[hyperactor::handle(IbvManagerLocalMessage)]` would generate a non-generic
923// `impl Handler<...> for IbvManagerActor<I>` that can't see `I`; we write the
924// generic delegation by hand.
925#[async_trait]
926impl<I: IbvDeviceImpl> Handler<IbvManagerLocalMessage> for IbvManagerActor<I> {
927    async fn handle(
928        &mut self,
929        cx: &Context<Self>,
930        message: IbvManagerLocalMessage,
931    ) -> Result<(), anyhow::Error> {
932        <Self as IbvManagerLocalMessageHandler>::handle(self, cx, message).await
933    }
934}
935
936/// Wrapper around [`ActorHandle<IbvManagerActor<I>>`] that moves the RDMA
937/// data-plane (post send/recv, poll CQ) off the actor loop while keeping
938/// state-mutating operations (MR registration/deregistration, QP management)
939/// serialized through actor messages.
940#[derive(Debug)]
941pub struct IbvBackend<I: IbvDeviceImpl> {
942    manager: ActorHandle<IbvManagerActor<I>>,
943    queue_pair_router: Arc<QueuePairRouter>,
944}
945
946impl<I: IbvDeviceImpl> Clone for IbvBackend<I> {
947    fn clone(&self) -> Self {
948        Self {
949            manager: self.manager.clone(),
950            queue_pair_router: Arc::clone(&self.queue_pair_router),
951        }
952    }
953}
954
955impl<I: IbvDeviceImpl> std::ops::Deref for IbvBackend<I> {
956    type Target = ActorHandle<IbvManagerActor<I>>;
957    fn deref(&self) -> &Self::Target {
958        &self.manager
959    }
960}
961
962/// Serializable per-buffer context for an ibverbs backend: the manager to route
963/// ops through and the wire description of every MR the buffer is registered
964/// under, one per NIC serving it.
965#[derive(Serialize, Deserialize, Named)]
966#[serde(bound = "")]
967pub struct IbvRemoteBackendContext<I: IbvDeviceImpl> {
968    pub manager: ActorRef<IbvManagerActor<I>>,
969    pub buffers: Vec<IbvRemoteMemoryRegionView>,
970}
971
972// `Clone` and `Debug` are hand-rolled to avoid the spurious `I: Clone`
973// and `I: Debug` bounds the derives would impose; neither field depends
974// on `I` implementing them.
975impl<I: IbvDeviceImpl> Clone for IbvRemoteBackendContext<I> {
976    fn clone(&self) -> Self {
977        Self {
978            manager: self.manager.clone(),
979            buffers: self.buffers.clone(),
980        }
981    }
982}
983
984impl<I: IbvDeviceImpl> std::fmt::Debug for IbvRemoteBackendContext<I> {
985    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
986        f.debug_struct("IbvRemoteBackendContext")
987            .field("manager", &self.manager)
988            .field("buffers", &self.buffers)
989            .finish()
990    }
991}
992
993#[async_trait]
994impl<I: IbvDeviceImpl> RdmaBackend for IbvBackend<I>
995where
996    RdmaRemoteBuffer: ResolveRemoteBackendContext<IbvBackend<I>>,
997{
998    type RemoteBackendContext = IbvRemoteBackendContext<I>;
999    type TransportInfo = ();
1000
1001    fn available() -> bool {
1002        if IbvDevice::<I>::available() {
1003            if hyperactor_config::global::get(crate::config::RDMA_DISABLE_IBVERBS) {
1004                tracing::warn!(
1005                    "ibverbs ({}) is available, but it was disabled by configuration (RDMA_DISABLE_IBVERBS=true)",
1006                    I::backend_name()
1007                );
1008                return false;
1009            }
1010            return true;
1011        }
1012        false
1013    }
1014
1015    fn transport_level(&self) -> RdmaTransportLevel {
1016        RdmaTransportLevel::Nic
1017    }
1018
1019    fn transport_info(&self) -> Option<Self::TransportInfo> {
1020        None
1021    }
1022
1023    async fn spawn(
1024        cx: &(impl hyperactor::context::Actor + Send + Sync),
1025        config: &RdmaConfig,
1026    ) -> Result<Self> {
1027        let actor = IbvManagerActor::<I>::new(config.ibv.clone()).await?;
1028        let queue_pair_router = actor.queue_pair_router();
1029        Ok(Self {
1030            manager: cx.spawn(actor),
1031            queue_pair_router,
1032        })
1033    }
1034
1035    async fn register_remote_buffer(
1036        &self,
1037        cx: &(impl hyperactor::context::Actor + Send + Sync),
1038        _remote_buf_id: usize,
1039        local: KeepaliveLocalMemory,
1040    ) -> Result<IbvRemoteBackendContext<I>> {
1041        let buffers = self
1042            .manager
1043            .register_remote_buffer(cx, local)
1044            .await?
1045            .map_err(|e| anyhow::anyhow!(e))?;
1046        Ok(IbvRemoteBackendContext {
1047            manager: self.manager.bind(),
1048            buffers,
1049        })
1050    }
1051
1052    /// No-op: this backend holds no per-buffer state. A region's registrations
1053    /// live on the [`KeepaliveLocalMemory`] they were made for, so a buffer is
1054    /// released by its owner dropping that handle.
1055    async fn release_buffer(
1056        &self,
1057        _cx: &(impl hyperactor::context::Actor + Send + Sync),
1058        _remote_buf_id: usize,
1059    ) -> Result<()> {
1060        Ok(())
1061    }
1062
1063    /// Submit a batch of RDMA operations.
1064    ///
1065    /// Translates each op to an `IbvOp`, then forwards them directly to the
1066    /// relevant queue-pairs when possible; any ops that cannot be forwarded
1067    /// directly (memory registration needed, QPs don't exist yet) are shipped to
1068    /// [`IbvManagerActor`] via [`SubmitOps`]. The manager interleaves local-MR
1069    /// resolution with dispatch.
1070    ///
1071    /// Waits for every stripe of every operation. Every stripe failure is
1072    /// collected into a single error, grouped by operation.
1073    async fn submit(
1074        &self,
1075        cx: &(impl hyperactor::context::Actor + Send + Sync),
1076        ops: Vec<RdmaOp>,
1077        timeout: Duration,
1078    ) -> Result<(), anyhow::Error> {
1079        let mut ibv_ops = Vec::with_capacity(ops.len());
1080        for op in ops {
1081            let ctx = <RdmaRemoteBuffer as ResolveRemoteBackendContext<IbvBackend<I>>>::resolve(
1082                &op.remote,
1083            )
1084            .expect("op routed to incompatible backend");
1085            ibv_ops.push(IbvOp {
1086                op_type: op.op_type,
1087                local_memory: op.local.clone(),
1088                remote_buffers: ctx.buffers,
1089                remote_manager: ctx.manager,
1090            });
1091        }
1092        let n = ibv_ops.len();
1093        let (reply, mut reply_rx) = mpsc::unbounded_channel();
1094
1095        let mut manager_ops = Vec::new();
1096        for (op_idx, op) in ibv_ops.into_iter().enumerate() {
1097            if let Some(op) = self.queue_pair_router.try_dispatch::<I>(op_idx, op, &reply) {
1098                manager_ops.push(op);
1099            }
1100        }
1101        if !manager_ops.is_empty() {
1102            self.manager.try_post(
1103                cx,
1104                SubmitOps {
1105                    ops: manager_ops,
1106                    reply,
1107                },
1108            )?;
1109        }
1110
1111        let mut outstanding: HashMap<usize, usize> = HashMap::with_capacity(n);
1112        // Per operation, the index and failure message of each of its stripes
1113        // that failed.
1114        let mut failures: HashMap<usize, Vec<(usize, String)>> = HashMap::new();
1115        let mut completed = 0usize;
1116        let mut received_stripes = 0usize;
1117        let mut terminal: Option<String> = None;
1118        let deadline = tokio::time::Instant::now() + timeout;
1119        while completed < n {
1120            tokio::select! {
1121                () = tokio::time::sleep_until(deadline) => {
1122                    terminal = Some(format!(
1123                        "submit timed out after {completed}/{n} ops ({received_stripes} stripe replies) with {} failed ops",
1124                        failures.len()
1125                    ));
1126                    break;
1127                }
1128                recv = reply_rx.recv() => {
1129                    match recv {
1130                        Some(StripeResult { stripe_id, result }) => {
1131                            received_stripes += 1;
1132                            if let Err(error) = result {
1133                                failures
1134                                    .entry(stripe_id.op_idx)
1135                                    .or_default()
1136                                    .push((stripe_id.stripe_idx, error));
1137                            }
1138                            let remaining = outstanding
1139                                .entry(stripe_id.op_idx)
1140                                .or_insert(stripe_id.stripe_count);
1141                            *remaining = remaining
1142                                .checked_sub(1)
1143                                .expect("an operation reports no more than its stripe count");
1144                            if *remaining == 0 {
1145                                outstanding.remove(&stripe_id.op_idx);
1146                                completed += 1;
1147                            }
1148                        }
1149                        None => {
1150                            terminal = Some(format!(
1151                                "operation result channel closed after {completed}/{n} ops ({received_stripes} stripe replies) with {} failed ops: \
1152                                some built-in RDMA actor likely stopped or failed, see logs for more details",
1153                                failures.len()
1154                            ));
1155                            break;
1156                        }
1157                    }
1158                }
1159            }
1160        }
1161
1162        if terminal.is_none() && failures.is_empty() {
1163            return Ok(());
1164        }
1165
1166        let mut failures: Vec<_> = failures.into_iter().collect();
1167        failures.sort_by_key(|(idx, _)| *idx);
1168        let mut msg = terminal.unwrap_or_else(|| format!("{}/{n} ops failed", failures.len()));
1169        if !failures.is_empty() {
1170            msg.push(':');
1171            for (idx, op_failures) in &mut failures {
1172                op_failures.sort_by_key(|(stripe_idx, _)| *stripe_idx);
1173                write!(msg, "\n  op {idx}:").expect("infallible String write");
1174                for (stripe_idx, err) in op_failures {
1175                    write!(msg, "\n    stripe {stripe_idx}: {err}")
1176                        .expect("infallible String write");
1177                }
1178            }
1179        }
1180        Err(anyhow::anyhow!(msg))
1181    }
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    //! End-to-end coverage of the [`SubmitOps`] → [`ProcessOps`] →
1187    //! [`QueuePairActor`] data path.
1188    //!
1189    //! Each test stands up two RDMA participants in two
1190    //! [`Proc::direct`] procs in the test process. Each proc hosts an
1191    //! [`RdmaManagerActor`] and a [`BufferHelperActor`]. Tests
1192    //! allocate buffers on either side via the helpers, drive RDMA
1193    //! through [`IbvBackend::submit`] (called inside the helper
1194    //! actor), and verify by reading back local contents through
1195    //! [`BufferHelperMessage::ReadContents`]. The
1196    //! [`BufferHelperActor::cleanup`] impl releases any CUDA
1197    //! allocations when the actor stops; [`TestEnv::shutdown`]
1198    //! explicitly drains both procs.
1199
1200    use std::collections::BTreeMap;
1201    use std::collections::BTreeSet;
1202    use std::num::NonZeroUsize;
1203    use std::sync::Arc;
1204    use std::sync::atomic::AtomicUsize;
1205    use std::sync::atomic::Ordering;
1206    use std::time::Duration;
1207
1208    use async_trait::async_trait;
1209    use hyperactor::Actor;
1210    use hyperactor::ActorAddr;
1211    use hyperactor::ActorEnvironment;
1212    use hyperactor::ActorRef;
1213    use hyperactor::Context;
1214    use hyperactor::Handler;
1215    use hyperactor::Instance;
1216    use hyperactor::Label;
1217    use hyperactor::OncePortRef;
1218    use hyperactor::Proc;
1219    use hyperactor::RefClient;
1220    use hyperactor::RemoteSpawn;
1221    use hyperactor::Uid;
1222    use hyperactor::actor::ActorError;
1223    use hyperactor::channel::ChannelAddr;
1224    use hyperactor::channel::ChannelTransport;
1225    use serde::Deserialize;
1226    use serde::Serialize;
1227    use typeuri::Named;
1228
1229    use super::IbvManagerActor;
1230    use super::IbvOp;
1231    use super::KIB;
1232    use super::QpRoute;
1233    use super::QueuePairRouter;
1234    use crate::IbvConfig;
1235    use crate::RdmaManagerActor;
1236    use crate::RdmaManagerMessageClient;
1237    use crate::RdmaOp;
1238    use crate::RdmaOpType;
1239    use crate::RdmaRemoteBuffer;
1240    use crate::backend::RdmaBackendHandle;
1241    use crate::backend::cuda_test_utils::CudaAllocation;
1242    use crate::backend::cuda_test_utils::CudaAllocator;
1243    use crate::backend::ibverbs::device::list_all_devices;
1244    use crate::backend::ibverbs::device_selection::IbvDeviceTarget;
1245    use crate::backend::ibverbs::device_selection::PeerDeviceAffinityPolicy;
1246    use crate::backend::ibverbs::device_selection::resolve_target;
1247    use crate::backend::ibverbs::device_selection::select_optimal_ibv_devices;
1248    use crate::backend::ibverbs::memory_region::IbvMemoryRegionView;
1249    use crate::backend::ibverbs::memory_region::IbvRemoteMemoryRegionView;
1250    use crate::backend::ibverbs::mlx_device::MlxDevice;
1251    use crate::backend::ibverbs::primitives::IbvQpType;
1252    use crate::device_selection::MemoryLocation;
1253    use crate::local_memory::KeepaliveLocalMemory;
1254
1255    // ====================================================================
1256    // BufferHelperActor
1257    // ====================================================================
1258
1259    /// Device a test buffer is allocated on.
1260    #[derive(Debug, Clone, Copy, Serialize, Deserialize, Named)]
1261    pub enum BufferDevice {
1262        Cpu,
1263        Cuda(i32),
1264    }
1265
1266    /// One op for [`BufferHelperMessage::Submit`]. The helper looks up
1267    /// the local memory behind `local_buf` (registered earlier via
1268    /// `Allocate`) and pairs it with `remote_buf` to form an
1269    /// [`RdmaOp`].
1270    #[derive(Debug, Clone, Serialize, Deserialize, Named)]
1271    pub struct BufferHelperOp {
1272        op_type: RdmaOpType,
1273        local_buf: RdmaRemoteBuffer,
1274        remote_buf: RdmaRemoteBuffer,
1275    }
1276
1277    /// Test helper that owns local buffers (CPU or CUDA) and drives
1278    /// [`IbvBackend::submit`] against its own [`RdmaManagerActor`].
1279    #[hyperactor::export(handlers = [BufferHelperMessage])]
1280    #[hyperactor::spawnable]
1281    #[derive(Debug)]
1282    pub struct BufferHelperActor {
1283        rdma_manager: ActorRef<RdmaManagerActor>,
1284        /// CUDA allocations tracked for cleanup. Each is also held as
1285        /// `Keepalive` inside the registered `KeepaliveLocalMemory`;
1286        /// both clones must drop before the FFI memory is released.
1287        cuda_allocs: Vec<CudaAllocation>,
1288    }
1289
1290    #[async_trait]
1291    impl Actor for BufferHelperActor {
1292        async fn cleanup(
1293            &mut self,
1294            _this: &Instance<Self>,
1295            _err: Option<&ActorError>,
1296        ) -> Result<(), anyhow::Error> {
1297            for alloc in self.cuda_allocs.drain(..) {
1298                alloc.try_free();
1299            }
1300            Ok(())
1301        }
1302    }
1303
1304    #[async_trait]
1305    impl RemoteSpawn for BufferHelperActor {
1306        type Params = ActorRef<RdmaManagerActor>;
1307
1308        async fn new(
1309            rdma_manager: ActorRef<RdmaManagerActor>,
1310            _env: &ActorEnvironment,
1311        ) -> Result<Self, anyhow::Error> {
1312            Ok(Self {
1313                rdma_manager,
1314                cuda_allocs: Vec::new(),
1315            })
1316        }
1317    }
1318
1319    #[derive(Handler, RefClient, Named, Serialize, Deserialize, Debug)]
1320    pub enum BufferHelperMessage {
1321        /// Allocate `size` bytes on `device`, pre-fill with `pattern`,
1322        /// register with the local `RdmaManagerActor`, and reply with
1323        /// the resulting `RdmaRemoteBuffer`.
1324        Allocate {
1325            size: usize,
1326            device: BufferDevice,
1327            pattern: u8,
1328            #[reply]
1329            reply: OncePortRef<RdmaRemoteBuffer>,
1330        },
1331        /// Like `Allocate`, but fills byte `i` with [`positional_byte`]`(i)`.
1332        AllocatePositional {
1333            size: usize,
1334            device: BufferDevice,
1335            #[reply]
1336            reply: OncePortRef<RdmaRemoteBuffer>,
1337        },
1338        /// Look up the local memory behind `remote.id` and reply with
1339        /// the byte range `[offset, offset + len)`. Tests use this to
1340        /// sample buffers too large to ship over a single actor
1341        /// message in one piece.
1342        ReadContents {
1343            remote: Box<RdmaRemoteBuffer>,
1344            offset: usize,
1345            len: usize,
1346            #[reply]
1347            reply: OncePortRef<Vec<u8>>,
1348        },
1349        /// Drive a batch of RDMA ops through `IbvBackend::submit`.
1350        /// Each op's `local_buf` is resolved against this helper's
1351        /// `RdmaManagerActor`; `remote_buf` is shipped as-is to the
1352        /// peer.
1353        Submit {
1354            ops: Vec<BufferHelperOp>,
1355            timeout_secs: u64,
1356            #[reply]
1357            reply: OncePortRef<Result<(), String>>,
1358        },
1359    }
1360
1361    impl BufferHelperActor {
1362        async fn allocate_impl(
1363            &mut self,
1364            cx: &Context<'_, Self>,
1365            device: BufferDevice,
1366            contents: Vec<u8>,
1367        ) -> Result<RdmaRemoteBuffer, anyhow::Error> {
1368            let size = contents.len();
1369            let local = match device {
1370                BufferDevice::Cpu => {
1371                    KeepaliveLocalMemory::try_new(Arc::new(contents.into_boxed_slice()))?
1372                }
1373                BufferDevice::Cuda(device_id) => {
1374                    let alloc = CudaAllocator::get().allocate(device_id, size, size);
1375                    let local = KeepaliveLocalMemory::try_new(Arc::new(alloc.clone()))?;
1376                    self.cuda_allocs.push(alloc);
1377                    // SAFETY: `local` is freshly constructed; no other
1378                    // holder touches this CUDA range yet.
1379                    unsafe { local.write_at(0, &contents) }?;
1380                    local
1381                }
1382            };
1383            let handle = self
1384                .rdma_manager
1385                .downcast_handle(cx)
1386                .ok_or_else(|| anyhow::anyhow!("rdma_manager not local to BufferHelperActor"))?;
1387            handle.request_buffer(cx, local).await
1388        }
1389
1390        async fn read_contents_impl(
1391            &mut self,
1392            cx: &Context<'_, Self>,
1393            remote: RdmaRemoteBuffer,
1394            offset: usize,
1395            len: usize,
1396        ) -> Result<Vec<u8>, anyhow::Error> {
1397            let handle = self
1398                .rdma_manager
1399                .downcast_handle(cx)
1400                .ok_or_else(|| anyhow::anyhow!("rdma_manager not local"))?;
1401            let local = handle
1402                .request_local_memory(cx, remote.id)
1403                .await?
1404                .ok_or_else(|| {
1405                    anyhow::anyhow!(
1406                        "no local memory registered on this side for remote_buf_id={}",
1407                        remote.id,
1408                    )
1409                })?;
1410            let mut out = vec![0u8; len];
1411            // SAFETY: by convention the caller has ensured all RDMA
1412            // ops against this buffer have completed before invoking
1413            // ReadContents.
1414            unsafe { local.read_at(offset, &mut out)? };
1415            Ok(out)
1416        }
1417
1418        async fn submit_impl(
1419            &mut self,
1420            cx: &Context<'_, Self>,
1421            ops: Vec<BufferHelperOp>,
1422            timeout_secs: u64,
1423        ) -> Result<Result<(), String>, anyhow::Error> {
1424            let handle = self
1425                .rdma_manager
1426                .downcast_handle(cx)
1427                .ok_or_else(|| anyhow::anyhow!("rdma_manager not local"))?;
1428            let mut rdma_ops = Vec::with_capacity(ops.len());
1429            for (i, op) in ops.into_iter().enumerate() {
1430                let local = handle
1431                    .request_local_memory(cx, op.local_buf.id)
1432                    .await?
1433                    .ok_or_else(|| {
1434                        anyhow::anyhow!(
1435                            "op {i}: no local memory registered for remote_buf_id={}",
1436                            op.local_buf.id,
1437                        )
1438                    })?;
1439                rdma_ops.push(RdmaOp {
1440                    op_type: op.op_type,
1441                    local,
1442                    remote: op.remote_buf,
1443                });
1444            }
1445            let nic = RdmaManagerActor::local_handle(cx)
1446                .get_backend_handles(cx)
1447                .await?
1448                .into_iter()
1449                .find(|h| !matches!(h, RdmaBackendHandle::Tcp(_)))
1450                .ok_or_else(|| anyhow::anyhow!("no NIC backend on this proc"))?;
1451            let result = nic
1452                .submit(cx, rdma_ops, Duration::from_secs(timeout_secs))
1453                .await;
1454            Ok(result.map_err(|e| format!("{e}")))
1455        }
1456    }
1457
1458    #[async_trait]
1459    #[hyperactor::handle(BufferHelperMessage)]
1460    impl BufferHelperMessageHandler for BufferHelperActor {
1461        async fn allocate(
1462            &mut self,
1463            cx: &Context<Self>,
1464            size: usize,
1465            device: BufferDevice,
1466            pattern: u8,
1467        ) -> Result<RdmaRemoteBuffer, anyhow::Error> {
1468            self.allocate_impl(cx, device, vec![pattern; size]).await
1469        }
1470
1471        async fn allocate_positional(
1472            &mut self,
1473            cx: &Context<Self>,
1474            size: usize,
1475            device: BufferDevice,
1476        ) -> Result<RdmaRemoteBuffer, anyhow::Error> {
1477            self.allocate_impl(cx, device, positional_contents(size))
1478                .await
1479        }
1480
1481        async fn read_contents(
1482            &mut self,
1483            cx: &Context<Self>,
1484            remote: Box<RdmaRemoteBuffer>,
1485            offset: usize,
1486            len: usize,
1487        ) -> Result<Vec<u8>, anyhow::Error> {
1488            self.read_contents_impl(cx, *remote, offset, len).await
1489        }
1490
1491        async fn submit(
1492            &mut self,
1493            cx: &Context<Self>,
1494            ops: Vec<BufferHelperOp>,
1495            timeout_secs: u64,
1496        ) -> Result<Result<(), String>, anyhow::Error> {
1497            self.submit_impl(cx, ops, timeout_secs).await
1498        }
1499    }
1500
1501    // ====================================================================
1502    // TestEnv
1503    // ====================================================================
1504
1505    static COUNTER: AtomicUsize = AtomicUsize::new(0);
1506
1507    /// Two-sided test environment.
1508    ///
1509    /// Each side is a `Proc::direct` in the test process hosting its
1510    /// own `RdmaManagerActor` and `BufferHelperActor`. A client minted
1511    /// from `proc_b` drives both helpers through their `ActorRef`s.
1512    struct TestEnv {
1513        client: hyperactor::Client,
1514        proc_a: Proc,
1515        helper_a: ActorRef<BufferHelperActor>,
1516        proc_b: Proc,
1517        helper_b: ActorRef<BufferHelperActor>,
1518    }
1519
1520    impl TestEnv {
1521        /// Asymmetric setup: side A uses `config_a`, side B uses `config_b`.
1522        async fn new(config_a: IbvConfig, config_b: IbvConfig) -> Result<Self, anyhow::Error> {
1523            let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1524            let proc_a = Proc::direct(
1525                ChannelAddr::any(ChannelTransport::Unix),
1526                format!("rdma_side_a_{id}"),
1527            )?;
1528            let helper_a = Self::spawn_side(&proc_a, config_a).await?;
1529            let proc_b = Proc::direct(
1530                ChannelAddr::any(ChannelTransport::Unix),
1531                format!("rdma_side_b_{id}"),
1532            )?;
1533            let helper_b = Self::spawn_side(&proc_b, config_b).await?;
1534            let client = proc_b.client("test_client");
1535            Ok(Self {
1536                client,
1537                proc_a,
1538                helper_a,
1539                proc_b,
1540                helper_b,
1541            })
1542        }
1543
1544        /// Symmetric setup: both sides use `config`.
1545        async fn same_config(config: IbvConfig) -> Result<Self, anyhow::Error> {
1546            Self::new(config.clone(), config).await
1547        }
1548
1549        /// Spawn an `RdmaManagerActor` + `BufferHelperActor` on `proc`
1550        /// and return the helper's `ActorRef`.
1551        async fn spawn_side(
1552            proc: &Proc,
1553            config: IbvConfig,
1554        ) -> Result<ActorRef<BufferHelperActor>, anyhow::Error> {
1555            let rdma_actor =
1556                RdmaManagerActor::new(Some(config), &ActorEnvironment::default()).await?;
1557            // Must match `RdmaManagerActor::local_handle`'s singleton lookup of "rdma_manager".
1558            let rdma_handle =
1559                proc.spawn_with_uid(Uid::singleton(Label::strip("rdma_manager")), rdma_actor)?;
1560            let rdma: ActorRef<RdmaManagerActor> = rdma_handle.bind();
1561            let helper_actor = BufferHelperActor::new(rdma, &ActorEnvironment::default()).await?;
1562            let helper_handle = proc.spawn_with_label("helper", helper_actor);
1563            Ok(helper_handle.bind())
1564        }
1565
1566        async fn shutdown(mut self) -> Result<(), anyhow::Error> {
1567            let _ = self
1568                .proc_a
1569                .destroy_and_wait(Duration::from_secs(10), "TestEnv shutdown proc_a")
1570                .await?;
1571            let _ = self
1572                .proc_b
1573                .destroy_and_wait(Duration::from_secs(10), "TestEnv shutdown proc_b")
1574                .await?;
1575            Ok(())
1576        }
1577    }
1578
1579    // ====================================================================
1580    // Shared test bodies
1581    // ====================================================================
1582
1583    async fn assert_remote_pattern(
1584        helper: &ActorRef<BufferHelperActor>,
1585        cx: &hyperactor::Client,
1586        remote: RdmaRemoteBuffer,
1587        size: usize,
1588        pattern: u8,
1589    ) -> Result<(), anyhow::Error> {
1590        let got = helper.read_contents(cx, Box::new(remote), 0, size).await?;
1591        assert_eq!(got, vec![pattern; size]);
1592        Ok(())
1593    }
1594
1595    /// The byte at `offset` in a buffer filled by position. 251 is prime, so
1596    /// the sequence never lines up with a stripe or alignment boundary, and a
1597    /// range copied to the wrong offset reads back wrong.
1598    fn positional_byte(offset: usize) -> u8 {
1599        (offset % 251) as u8
1600    }
1601
1602    fn positional_contents(size: usize) -> Vec<u8> {
1603        (0..size).map(positional_byte).collect()
1604    }
1605
1606    /// Panics at the first byte of `got` that does not hold its own
1607    /// [`positional_byte`].
1608    fn assert_positional(got: &[u8], size: usize, what: &str) {
1609        assert_eq!(got.len(), size, "{what}: read back the wrong length");
1610        if let Some(offset) = (0..size).find(|&i| got[i] != positional_byte(i)) {
1611            panic!(
1612                "{what}: byte {offset} of {size} is {:#04x}, expected {:#04x}",
1613                got[offset],
1614                positional_byte(offset),
1615            );
1616        }
1617    }
1618
1619    /// Writes a buffer filled by position from side A into side B, reads one
1620    /// from side B into side A, and asserts that every byte lands at its own
1621    /// offset.
1622    async fn run_positional_write_and_read(
1623        env: &TestEnv,
1624        size: usize,
1625        timeout_secs: u64,
1626    ) -> Result<(), anyhow::Error> {
1627        let write_src = env
1628            .helper_a
1629            .allocate_positional(&env.client, size, BufferDevice::Cpu)
1630            .await?;
1631        let write_dst = env
1632            .helper_b
1633            .allocate(&env.client, size, BufferDevice::Cpu, 0)
1634            .await?;
1635        env.helper_a
1636            .submit(
1637                &env.client,
1638                vec![BufferHelperOp {
1639                    op_type: RdmaOpType::WriteFromLocal,
1640                    local_buf: write_src,
1641                    remote_buf: write_dst.clone(),
1642                }],
1643                timeout_secs,
1644            )
1645            .await?
1646            .map_err(|e| anyhow::anyhow!(e))?;
1647        let got = env
1648            .helper_b
1649            .read_contents(&env.client, Box::new(write_dst), 0, size)
1650            .await?;
1651        assert_positional(&got, size, "write");
1652
1653        let read_dst = env
1654            .helper_a
1655            .allocate(&env.client, size, BufferDevice::Cpu, 0)
1656            .await?;
1657        let read_src = env
1658            .helper_b
1659            .allocate_positional(&env.client, size, BufferDevice::Cpu)
1660            .await?;
1661        env.helper_a
1662            .submit(
1663                &env.client,
1664                vec![BufferHelperOp {
1665                    op_type: RdmaOpType::ReadIntoLocal,
1666                    local_buf: read_dst.clone(),
1667                    remote_buf: read_src,
1668                }],
1669                timeout_secs,
1670            )
1671            .await?
1672            .map_err(|e| anyhow::anyhow!(e))?;
1673        let got = env
1674            .helper_a
1675            .read_contents(&env.client, Box::new(read_dst), 0, size)
1676            .await?;
1677        assert_positional(&got, size, "read");
1678        Ok(())
1679    }
1680
1681    /// Drive a single write from side A's buffer into side B's buffer
1682    /// and assert the destination now matches the pattern.
1683    async fn run_cross_actor_write(
1684        env: &TestEnv,
1685        src_dev: BufferDevice,
1686        dst_dev: BufferDevice,
1687        size: usize,
1688        pattern: u8,
1689        timeout_secs: u64,
1690    ) -> Result<(), anyhow::Error> {
1691        let src = env
1692            .helper_a
1693            .allocate(&env.client, size, src_dev, pattern)
1694            .await?;
1695        let dst = env.helper_b.allocate(&env.client, size, dst_dev, 0).await?;
1696        env.helper_a
1697            .submit(
1698                &env.client,
1699                vec![BufferHelperOp {
1700                    op_type: RdmaOpType::WriteFromLocal,
1701                    local_buf: src,
1702                    remote_buf: dst.clone(),
1703                }],
1704                timeout_secs,
1705            )
1706            .await?
1707            .map_err(|e| anyhow::anyhow!(e))?;
1708        assert_remote_pattern(&env.helper_b, &env.client, dst, size, pattern).await
1709    }
1710
1711    /// Drive a single read from side B's buffer into side A's buffer
1712    /// and assert the destination now matches the pattern.
1713    async fn run_cross_actor_read(
1714        env: &TestEnv,
1715        dst_dev: BufferDevice,
1716        src_dev: BufferDevice,
1717        size: usize,
1718        pattern: u8,
1719        timeout_secs: u64,
1720    ) -> Result<(), anyhow::Error> {
1721        let dst = env.helper_a.allocate(&env.client, size, dst_dev, 0).await?;
1722        let src = env
1723            .helper_b
1724            .allocate(&env.client, size, src_dev, pattern)
1725            .await?;
1726        env.helper_a
1727            .submit(
1728                &env.client,
1729                vec![BufferHelperOp {
1730                    op_type: RdmaOpType::ReadIntoLocal,
1731                    local_buf: dst.clone(),
1732                    remote_buf: src,
1733                }],
1734                timeout_secs,
1735            )
1736            .await?
1737            .map_err(|e| anyhow::anyhow!(e))?;
1738        assert_remote_pattern(&env.helper_a, &env.client, dst, size, pattern).await
1739    }
1740
1741    /// Drive both a write and a read in a single
1742    /// `IbvBackend::submit` batch — both ops target the same peer
1743    /// QP and so resolve to a single `ProcessOps` group. After the
1744    /// batch completes, side B's `write_dst` and side A's `read_dst`
1745    /// both contain their respective patterns.
1746    async fn run_multi_op_same_qp(
1747        env: &TestEnv,
1748        dev_a: BufferDevice,
1749        dev_b: BufferDevice,
1750        size: usize,
1751        timeout_secs: u64,
1752    ) -> Result<(), anyhow::Error> {
1753        const WRITE_PATTERN: u8 = 0xa1;
1754        const READ_PATTERN: u8 = 0xb2;
1755        let write_src = env
1756            .helper_a
1757            .allocate(&env.client, size, dev_a, WRITE_PATTERN)
1758            .await?;
1759        let write_dst = env.helper_b.allocate(&env.client, size, dev_b, 0).await?;
1760        let read_dst = env.helper_a.allocate(&env.client, size, dev_a, 0).await?;
1761        let read_src = env
1762            .helper_b
1763            .allocate(&env.client, size, dev_b, READ_PATTERN)
1764            .await?;
1765        env.helper_a
1766            .submit(
1767                &env.client,
1768                vec![
1769                    BufferHelperOp {
1770                        op_type: RdmaOpType::WriteFromLocal,
1771                        local_buf: write_src,
1772                        remote_buf: write_dst.clone(),
1773                    },
1774                    BufferHelperOp {
1775                        op_type: RdmaOpType::ReadIntoLocal,
1776                        local_buf: read_dst.clone(),
1777                        remote_buf: read_src,
1778                    },
1779                ],
1780                timeout_secs,
1781            )
1782            .await?
1783            .map_err(|e| anyhow::anyhow!(e))?;
1784        assert_remote_pattern(&env.helper_b, &env.client, write_dst, size, WRITE_PATTERN).await?;
1785        assert_remote_pattern(&env.helper_a, &env.client, read_dst, size, READ_PATTERN).await
1786    }
1787
1788    /// Drive a write + read between two buffers registered with the
1789    /// *same* `RdmaManagerActor` on the *same* device. Exercises the
1790    /// loopback path (`is_loopback = true`) where the active actor
1791    /// connects its QP to its own endpoint and skips the
1792    /// `CreatePeerQueuePair` round trip.
1793    async fn run_true_loopback(
1794        env: &TestEnv,
1795        dev: BufferDevice,
1796        size: usize,
1797    ) -> Result<(), anyhow::Error> {
1798        const PATTERN: u8 = 0x5d;
1799        let src = env
1800            .helper_a
1801            .allocate(&env.client, size, dev, PATTERN)
1802            .await?;
1803        let dst = env.helper_a.allocate(&env.client, size, dev, 0).await?;
1804        env.helper_a
1805            .submit(
1806                &env.client,
1807                vec![BufferHelperOp {
1808                    op_type: RdmaOpType::WriteFromLocal,
1809                    local_buf: src,
1810                    remote_buf: dst.clone(),
1811                }],
1812                5,
1813            )
1814            .await?
1815            .map_err(|e| anyhow::anyhow!(e))?;
1816        assert_remote_pattern(&env.helper_a, &env.client, dst, size, PATTERN).await
1817    }
1818
1819    // ====================================================================
1820    // Helpers
1821    // ====================================================================
1822
1823    fn require_rdma() {
1824        if list_all_devices().is_empty() {
1825            panic!("SKIPPED: no RDMA devices available");
1826        }
1827    }
1828
1829    fn require_cuda() {
1830        if !crate::is_cuda_available() {
1831            panic!("SKIPPED: CUDA not available");
1832        }
1833    }
1834
1835    fn make_plan_op(size: usize, pair_count: usize) -> IbvOp<IbvManagerActor<MlxDevice>> {
1836        let allocation: Box<[u8]> = vec![0; size.max(1)].into_boxed_slice();
1837        let local_memory =
1838            KeepaliveLocalMemory::try_new(Arc::new(allocation)).expect("valid CPU allocation");
1839        let mut remote_buffers = Vec::with_capacity(pair_count);
1840        for pair in 0..pair_count {
1841            let device = format!("mlx5_{pair}");
1842            let mut local = IbvMemoryRegionView::for_test(&device, pair as u32);
1843            local.virtual_addr = local_memory.addr();
1844            local.rdma_addr = local_memory.addr();
1845            local.size = local_memory.size();
1846            local_memory
1847                .install_mr::<MlxDevice>(local)
1848                .expect("install test registration");
1849            remote_buffers.push(IbvRemoteMemoryRegionView {
1850                rkey: pair as u32,
1851                addr: 0x1000,
1852                size,
1853                device_name: device,
1854            });
1855        }
1856        let remote_manager = ActorRef::attest(
1857            "manager.proc@inproc://0"
1858                .parse::<ActorAddr>()
1859                .expect("valid test actor address"),
1860        );
1861        IbvOp {
1862            op_type: RdmaOpType::ReadIntoLocal,
1863            local_memory,
1864            remote_buffers,
1865            remote_manager,
1866        }
1867    }
1868
1869    fn plan_ranges(
1870        size: usize,
1871        pair_count: usize,
1872        min_stripe_kb: usize,
1873        qps_per_peer: usize,
1874    ) -> Vec<(usize, usize)> {
1875        let op = make_plan_op(size, pair_count);
1876        let local_addr = op.local_memory.addr();
1877        let lock = hyperactor_config::global::lock();
1878        let _stripe_guard = lock.override_key(
1879            crate::config::RDMA_MIN_STRIPE_SIZE_KB,
1880            hyperactor_config::NonZeroUsize::new(min_stripe_kb)
1881                .expect("minimum stripe size is non-zero"),
1882        );
1883        let router = QueuePairRouter::new(
1884            PeerDeviceAffinityPolicy::Any,
1885            NonZeroUsize::new(qps_per_peer).expect("qps_per_peer is non-zero"),
1886        );
1887        let mut ranges: Vec<_> = router
1888            .plan_ops::<MlxDevice>(0, &op)
1889            .expect("plan test operation")
1890            .into_iter()
1891            .map(|op| (op.local.virtual_addr - local_addr, op.local.size))
1892            .collect();
1893        ranges.sort_unstable();
1894        ranges
1895    }
1896
1897    #[test]
1898    fn plan_ops_balances_stripes_without_an_undersized_tail() {
1899        assert_eq!(plan_ranges(511 * KIB, 4, 512, 1), vec![(0, 511 * KIB)]);
1900        assert_eq!(plan_ranges(513 * KIB, 4, 512, 1), vec![(0, 513 * KIB)]);
1901        assert_eq!(
1902            plan_ranges(12 * KIB, 3, 1, 1),
1903            vec![(0, 4 * KIB), (4 * KIB, 4 * KIB), (8 * KIB, 4 * KIB)]
1904        );
1905        assert_eq!(
1906            plan_ranges(15 * KIB, 8, 4, 1),
1907            vec![(0, 5 * KIB), (5 * KIB, 5 * KIB), (10 * KIB, 5 * KIB)]
1908        );
1909        assert_eq!(
1910            plan_ranges(10 * KIB, 3, 1, 1),
1911            vec![(0, 3392), (3392, 3392), (6784, 3456)]
1912        );
1913        assert_eq!(
1914            plan_ranges(32_000_000, 4, 8192, 1),
1915            vec![
1916                (0, 10_666_624),
1917                (10_666_624, 10_666_624),
1918                (21_333_248, 10_666_752),
1919            ]
1920        );
1921        assert_eq!(plan_ranges(0, 4, 1, 1), vec![(0, 0)]);
1922    }
1923
1924    #[test]
1925    fn plan_ops_stripes_across_queue_pairs() {
1926        let even =
1927            |count: usize, size: usize| (0..count).map(|i| (i * size, size)).collect::<Vec<_>>();
1928        // One pair with four queue pairs takes one stripe per queue pair.
1929        assert_eq!(plan_ranges(4 * KIB, 1, 1, 4), even(4, KIB));
1930        // The minimum stripe size still caps the count: 2 KiB at a 1 KiB
1931        // minimum makes two stripes, not four.
1932        assert_eq!(plan_ranges(2 * KIB, 1, 1, 4), even(2, KIB));
1933        // A larger transfer on one pair still makes four stripes, not sixteen.
1934        assert_eq!(plan_ranges(16 * KIB, 1, 1, 4), even(4, 4 * KIB));
1935        // Two pairs make four stripes, two per pair.
1936        assert_eq!(plan_ranges(16 * KIB, 2, 1, 4), even(4, 4 * KIB));
1937        // Three pairs round four stripes up to six, two per pair.
1938        assert_eq!(plan_ranges(12 * KIB, 3, 1, 4), even(6, 2 * KIB));
1939        // With at least as many pairs as queue pairs, each pair takes one stripe.
1940        assert_eq!(plan_ranges(16 * KIB, 8, 1, 4), even(8, 2 * KIB));
1941        // Room for three stripes on two pairs rounds down to two, one per pair.
1942        assert_eq!(plan_ranges(3 * KIB, 2, 1, 4), even(2, 1536));
1943        // A transfer below the minimum stays one stripe.
1944        assert_eq!(plan_ranges(KIB - 1, 2, 1, 4), vec![(0, KIB - 1)]);
1945    }
1946
1947    /// Plans `op` and returns each stripe's `(device, qp_index)` in stripe order.
1948    fn plan_queue_pairs(
1949        router: &QueuePairRouter,
1950        op_idx: usize,
1951        op: &IbvOp<IbvManagerActor<MlxDevice>>,
1952    ) -> Vec<(String, usize)> {
1953        let mut planned = router
1954            .plan_ops::<MlxDevice>(op_idx, op)
1955            .expect("plan test operation");
1956        planned.sort_unstable_by_key(|planned| planned.stripe_id.stripe_idx);
1957        planned
1958            .into_iter()
1959            .map(|planned| (planned.local.device_name, planned.qp_index))
1960            .collect()
1961    }
1962
1963    #[test]
1964    fn plan_ops_spreads_one_op_over_distinct_queue_pairs() {
1965        let lock = hyperactor_config::global::lock();
1966        let _stripe_guard = lock.override_key(
1967            crate::config::RDMA_MIN_STRIPE_SIZE_KB,
1968            hyperactor_config::NonZeroUsize::new(1).expect("1 is non-zero"),
1969        );
1970
1971        let router = QueuePairRouter::new(
1972            PeerDeviceAffinityPolicy::Any,
1973            NonZeroUsize::new(3).expect("3 is non-zero"),
1974        );
1975        let qp_indices: Vec<usize> = plan_queue_pairs(&router, 0, &make_plan_op(3 * KIB, 1))
1976            .into_iter()
1977            .map(|(_, qp_index)| qp_index)
1978            .collect();
1979        assert_eq!(qp_indices, vec![0, 1, 2], "one stripe per queue pair");
1980
1981        // Three pairs with four queue pairs: two stripes per pair, on
1982        // different queue pairs.
1983        let router = QueuePairRouter::new(
1984            PeerDeviceAffinityPolicy::Any,
1985            NonZeroUsize::new(4).expect("4 is non-zero"),
1986        );
1987        assert_eq!(
1988            queue_pairs_by_device(&router, &make_plan_op(12 * KIB, 3)),
1989            vec![vec![0, 1]; 3]
1990        );
1991
1992        // Room for three stripes on two pairs: one stripe per pair, so neither
1993        // pair carries two thirds of the transfer.
1994        let router = QueuePairRouter::new(
1995            PeerDeviceAffinityPolicy::Any,
1996            NonZeroUsize::new(4).expect("4 is non-zero"),
1997        );
1998        assert_eq!(
1999            queue_pairs_by_device(&router, &make_plan_op(3 * KIB, 2)),
2000            vec![vec![0]; 2]
2001        );
2002    }
2003
2004    #[test]
2005    fn plan_ops_assigns_stripes_to_pairs_in_name_order() {
2006        let lock = hyperactor_config::global::lock();
2007        let _stripe_guard = lock.override_key(
2008            crate::config::RDMA_MIN_STRIPE_SIZE_KB,
2009            hyperactor_config::NonZeroUsize::new(1).expect("1 is non-zero"),
2010        );
2011        let router = QueuePairRouter::new(
2012            PeerDeviceAffinityPolicy::Any,
2013            NonZeroUsize::new(4).expect("4 is non-zero"),
2014        );
2015
2016        // Every op of this size takes the same pair for the same byte range.
2017        for op_idx in 0..3 {
2018            let devices: Vec<String> = plan_queue_pairs(&router, op_idx, &make_plan_op(4 * KIB, 2))
2019                .into_iter()
2020                .map(|(device, _)| device)
2021                .collect();
2022            assert_eq!(devices, vec!["mlx5_0", "mlx5_1", "mlx5_0", "mlx5_1"]);
2023        }
2024    }
2025
2026    /// Plans `op` and returns each device's queue-pair indices in stripe
2027    /// order, with devices sorted by name.
2028    fn queue_pairs_by_device(
2029        router: &QueuePairRouter,
2030        op: &IbvOp<IbvManagerActor<MlxDevice>>,
2031    ) -> Vec<Vec<usize>> {
2032        let mut by_device: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2033        for (device, qp_index) in plan_queue_pairs(router, 0, op) {
2034            by_device.entry(device).or_default().push(qp_index);
2035        }
2036        by_device.into_values().collect()
2037    }
2038
2039    #[test]
2040    fn plan_ops_continues_the_rotation_across_ops() {
2041        let lock = hyperactor_config::global::lock();
2042        let _stripe_guard = lock.override_key(
2043            crate::config::RDMA_MIN_STRIPE_SIZE_KB,
2044            hyperactor_config::NonZeroUsize::new(1).expect("1 is non-zero"),
2045        );
2046        let router = QueuePairRouter::new(
2047            PeerDeviceAffinityPolicy::Any,
2048            NonZeroUsize::new(3).expect("3 is non-zero"),
2049        );
2050        let op = make_plan_op(2 * KIB, 1);
2051
2052        // Two stripes per op on a route with three queue pairs.
2053        let qp_indices: Vec<Vec<usize>> = (0..3)
2054            .map(|op_idx| {
2055                plan_queue_pairs(&router, op_idx, &op)
2056                    .into_iter()
2057                    .map(|(_, qp_index)| qp_index)
2058                    .collect()
2059            })
2060            .collect();
2061        assert_eq!(qp_indices, vec![vec![0, 1], vec![2, 0], vec![1, 2]]);
2062    }
2063
2064    #[test]
2065    fn queue_pair_indices_advance_independently_per_route() {
2066        let router = QueuePairRouter::new(
2067            PeerDeviceAffinityPolicy::Any,
2068            NonZeroUsize::new(3).expect("3 is non-zero"),
2069        );
2070        let other_id = "manager.proc@inproc://0"
2071            .parse::<ActorAddr>()
2072            .expect("valid test actor address")
2073            .id()
2074            .clone();
2075        let route_a = QpRoute {
2076            self_device: "mlx5_0".into(),
2077            other_id: other_id.clone(),
2078            other_device: "mlx5_1".into(),
2079        };
2080        let route_b = QpRoute {
2081            self_device: "mlx5_2".into(),
2082            other_id,
2083            other_device: "mlx5_3".into(),
2084        };
2085
2086        assert_eq!(
2087            (0..4)
2088                .map(|_| router.next_qp_index(route_a.clone()))
2089                .collect::<Vec<_>>(),
2090            vec![0, 1, 2, 0],
2091        );
2092        assert_eq!(router.next_qp_index(route_b), 0);
2093        assert_eq!(router.next_qp_index(route_a), 1);
2094    }
2095
2096    #[test]
2097    fn plan_ops_round_robins_queue_pair_indices() {
2098        let router = QueuePairRouter::new(
2099            PeerDeviceAffinityPolicy::Any,
2100            NonZeroUsize::new(3).expect("3 is non-zero"),
2101        );
2102        let op = make_plan_op(1, 1);
2103
2104        let qp_indices = (0..4)
2105            .map(|op_idx| {
2106                let planned = router
2107                    .plan_ops::<MlxDevice>(op_idx, &op)
2108                    .expect("plan test operation");
2109                let [planned] = planned.as_slice() else {
2110                    panic!("one NIC pair should produce one stripe");
2111                };
2112                planned.qp_index
2113            })
2114            .collect::<Vec<_>>();
2115
2116        assert_eq!(qp_indices, vec![0, 1, 2, 0]);
2117    }
2118
2119    // ====================================================================
2120    // Tests
2121    // ====================================================================
2122
2123    /// `register_remote_buffer` must record its registration on the
2124    /// `KeepaliveLocalMemory` it is handed — under the name of the device it
2125    /// registered on — so that later `resolve_local_mrs` calls for that device
2126    /// reuse it instead of registering the same region again.
2127    #[timed_test::async_timed_test(timeout_secs = 60)]
2128    async fn test_host_buffers_use_per_allocation_mrs_by_default() -> Result<(), anyhow::Error> {
2129        require_rdma();
2130        let lock = hyperactor_config::global::lock();
2131        let _guard = lock.override_key(crate::config::RDMA_HOST_ODP, false);
2132        let target = IbvDeviceTarget::cpu(0);
2133        let device = resolve_target::<MlxDevice>(&target)?
2134            .expect("cpu:0 should resolve to a NIC")
2135            .name()
2136            .clone();
2137        let env = TestEnv::same_config(IbvConfig::targeting(target)).await?;
2138        let buf: Box<[u8]> = vec![0u8; 1024].into_boxed_slice();
2139        let local = KeepaliveLocalMemory::try_new(Arc::new(buf))?;
2140        assert!(
2141            local.registered_mr::<MlxDevice>(&device)?.is_none(),
2142            "the region should have no registration before it is registered",
2143        );
2144        RdmaManagerActor::local_handle(&env.client)
2145            .request_buffer(&env.client, local.clone())
2146            .await?;
2147        let first = local
2148            .registered_mr::<MlxDevice>(&device)?
2149            .expect("registration should be recorded under the pinned device");
2150
2151        let second_buf: Box<[u8]> = vec![0u8; 1024].into_boxed_slice();
2152        let second_local = KeepaliveLocalMemory::try_new(Arc::new(second_buf))?;
2153        RdmaManagerActor::local_handle(&env.client)
2154            .request_buffer(&env.client, second_local.clone())
2155            .await?;
2156        let second = second_local
2157            .registered_mr::<MlxDevice>(&device)?
2158            .expect("second registration should be recorded under the pinned device");
2159        assert_ne!(
2160            (first.lkey, first.rkey),
2161            (second.lkey, second.rkey),
2162            "the default path should retain per-allocation registrations"
2163        );
2164        env.shutdown().await
2165    }
2166
2167    #[timed_test::async_timed_test(timeout_secs = 60)]
2168    async fn test_host_odp_shares_a_key_and_advises_destinations() -> Result<(), anyhow::Error> {
2169        require_rdma();
2170        let lock = hyperactor_config::global::lock();
2171        let _guard = lock.override_key(crate::config::RDMA_HOST_ODP, true);
2172        let target = IbvDeviceTarget::cpu(0);
2173        let device = resolve_target::<MlxDevice>(&target)?
2174            .expect("cpu:0 should resolve to a NIC")
2175            .name()
2176            .clone();
2177        let env = TestEnv::same_config(IbvConfig::targeting(target)).await?;
2178
2179        let first_buf: Box<[u8]> = vec![0u8; 1024].into_boxed_slice();
2180        let first_local = KeepaliveLocalMemory::try_new(Arc::new(first_buf))?;
2181        RdmaManagerActor::local_handle(&env.client)
2182            .request_buffer(&env.client, first_local.clone())
2183            .await?;
2184        let first = first_local
2185            .registered_mr::<MlxDevice>(&device)?
2186            .expect("first ODP registration should be recorded");
2187
2188        let second_buf: Box<[u8]> = vec![0u8; 1024].into_boxed_slice();
2189        let second_local = KeepaliveLocalMemory::try_new(Arc::new(second_buf))?;
2190        RdmaManagerActor::local_handle(&env.client)
2191            .request_buffer(&env.client, second_local.clone())
2192            .await?;
2193        let second = second_local
2194            .registered_mr::<MlxDevice>(&device)?
2195            .expect("second ODP registration should be recorded");
2196
2197        assert_eq!(
2198            (first.lkey, first.rkey),
2199            (second.lkey, second.rkey),
2200            "host buffers on one device should share its implicit ODP MR"
2201        );
2202        run_cross_actor_write(
2203            &env,
2204            BufferDevice::Cpu,
2205            BufferDevice::Cpu,
2206            1024 * 1024,
2207            0xa5,
2208            10,
2209        )
2210        .await?;
2211        run_cross_actor_read(
2212            &env,
2213            BufferDevice::Cpu,
2214            BufferDevice::Cpu,
2215            1024 * 1024,
2216            0x5a,
2217            10,
2218        )
2219        .await?;
2220        env.shutdown().await
2221    }
2222
2223    /// With `rdma_max_nics_per_buffer` above 1, a buffer is registered on that
2224    /// many of its tied-for-best NICs. Under the `match_name` policy, a remote
2225    /// buffer must always be registered on the first N devices returned by
2226    /// `select_optimal_ibv_devices` -- otherwise, a mismatch could make a transfer
2227    /// impossible.
2228    #[timed_test::async_timed_test(timeout_secs = 120)]
2229    async fn test_match_name_nic_selection() -> Result<(), anyhow::Error> {
2230        require_rdma();
2231        const MAX_NICS: usize = 4;
2232        let lock = hyperactor_config::global::lock();
2233        let _max_guard = lock.override_key(
2234            crate::config::RDMA_MAX_NICS_PER_BUFFER,
2235            Some(hyperactor_config::NonZeroUsize::new(MAX_NICS).expect("MAX_NICS is 4")),
2236        );
2237        let _policy_guard = lock.override_key(
2238            crate::config::RDMA_PEER_DEVICE_AFFINITY,
2239            "match_name".to_string(),
2240        );
2241        let expected: BTreeSet<String> =
2242            select_optimal_ibv_devices::<MlxDevice>(MemoryLocation::Cpu(None))?
2243                .iter()
2244                .take(MAX_NICS)
2245                .map(|nic| nic.name().clone())
2246                .collect();
2247
2248        let env = TestEnv::same_config(IbvConfig::default()).await?;
2249        let buffer = env
2250            .helper_a
2251            .allocate(&env.client, 32, BufferDevice::Cpu, 0)
2252            .await?;
2253        let served_by: BTreeSet<String> = buffer
2254            .resolve_mlx()
2255            .expect("the buffer is registered on a Mellanox NIC")
2256            .buffers
2257            .iter()
2258            .map(|mr| mr.device_name.clone())
2259            .collect();
2260        assert_eq!(served_by, expected);
2261
2262        for pattern in 0..2 * MAX_NICS as u8 {
2263            run_cross_actor_write(&env, BufferDevice::Cpu, BufferDevice::Cpu, 32, pattern, 5)
2264                .await?;
2265        }
2266        env.shutdown().await
2267    }
2268
2269    #[timed_test::async_timed_test(timeout_secs = 120)]
2270    async fn test_striped_transfers_land_every_byte() -> Result<(), anyhow::Error> {
2271        require_rdma();
2272        const MAX_NICS: usize = 4;
2273        const MIN_STRIPE_KB: usize = 512;
2274        // The extra 17 bytes make the size prime, so the stripes split unevenly.
2275        const SIZE: usize = MAX_NICS * MIN_STRIPE_KB * KIB + 17;
2276        let tied = select_optimal_ibv_devices::<MlxDevice>(MemoryLocation::Cpu(None))?.len();
2277        if tied < 2 {
2278            panic!("SKIPPED: this host has fewer than two NICs tied for host memory");
2279        }
2280
2281        let lock = hyperactor_config::global::lock();
2282        let _max_guard = lock.override_key(
2283            crate::config::RDMA_MAX_NICS_PER_BUFFER,
2284            Some(hyperactor_config::NonZeroUsize::new(MAX_NICS).expect("MAX_NICS is non-zero")),
2285        );
2286        let _stripe_guard = lock.override_key(
2287            crate::config::RDMA_MIN_STRIPE_SIZE_KB,
2288            hyperactor_config::NonZeroUsize::new(MIN_STRIPE_KB).expect("MIN_STRIPE_KB is non-zero"),
2289        );
2290        let _policy_guard =
2291            lock.override_key(crate::config::RDMA_PEER_DEVICE_AFFINITY, "any".to_string());
2292
2293        let env = TestEnv::same_config(IbvConfig::default()).await?;
2294        run_positional_write_and_read(&env, SIZE, 20).await?;
2295        env.shutdown().await
2296    }
2297
2298    /// With a single NIC pair, a transfer is split across that pair's queue pairs.
2299    #[timed_test::async_timed_test(timeout_secs = 120)]
2300    async fn test_queue_pair_stripes_land_every_byte() -> Result<(), anyhow::Error> {
2301        require_rdma();
2302        const QPS_PER_PEER: usize = 4;
2303        const MIN_STRIPE_KB: usize = 512;
2304        // The extra 17 bytes make the size prime, so the stripes split unevenly.
2305        const SIZE: usize = QPS_PER_PEER * MIN_STRIPE_KB * KIB + 17;
2306
2307        let lock = hyperactor_config::global::lock();
2308        let _qps_guard = lock.override_key(
2309            crate::config::RDMA_QPS_PER_PEER,
2310            hyperactor_config::NonZeroUsize::new(QPS_PER_PEER).expect("QPS_PER_PEER is non-zero"),
2311        );
2312        let _stripe_guard = lock.override_key(
2313            crate::config::RDMA_MIN_STRIPE_SIZE_KB,
2314            hyperactor_config::NonZeroUsize::new(MIN_STRIPE_KB).expect("MIN_STRIPE_KB is non-zero"),
2315        );
2316
2317        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
2318        run_positional_write_and_read(&env, SIZE, 20).await?;
2319        env.shutdown().await
2320    }
2321
2322    /// Under `any` a buffer starts at a NIC drawn at random, so at one NIC per
2323    /// buffer the buffers spread over the tied NICs instead of all landing on
2324    /// the first.
2325    #[timed_test::async_timed_test(timeout_secs = 60)]
2326    async fn test_any_nic_selection() -> Result<(), anyhow::Error> {
2327        require_rdma();
2328        let lock = hyperactor_config::global::lock();
2329        let _max_guard = lock.override_key(
2330            crate::config::RDMA_MAX_NICS_PER_BUFFER,
2331            Some(hyperactor_config::NonZeroUsize::MIN),
2332        );
2333        let _policy_guard =
2334            lock.override_key(crate::config::RDMA_PEER_DEVICE_AFFINITY, "any".to_string());
2335        let tied = select_optimal_ibv_devices::<MlxDevice>(MemoryLocation::Cpu(None))?.len();
2336
2337        let env = TestEnv::same_config(IbvConfig::default()).await?;
2338        let mut served_by: BTreeSet<String> = BTreeSet::new();
2339        for _ in 0..16 {
2340            let buffer = env
2341                .helper_a
2342                .allocate(&env.client, 32, BufferDevice::Cpu, 0)
2343                .await?;
2344            let registrations = buffer
2345                .resolve_mlx()
2346                .expect("the buffer is registered on a Mellanox NIC")
2347                .buffers;
2348            let [mr] = registrations.as_slice() else {
2349                panic!("one NIC serves a buffer here, got {registrations:?}");
2350            };
2351            served_by.insert(mr.device_name.clone());
2352        }
2353        // Sixteen buffers all drawing the same one of several NICs would be a
2354        // one-in-billions coincidence.
2355        assert_eq!(
2356            served_by.len() > 1,
2357            tied > 1,
2358            "{tied} NICs tie for host memory, but the buffers landed on {served_by:?}",
2359        );
2360        env.shutdown().await
2361    }
2362
2363    /// Cross-actor RDMA write over a single device (both sides target
2364    /// `cpu:0`). The two `RdmaManagerActor`s differ — this exercises
2365    /// the asymmetric `CreatePeerQueuePair` handshake even though the
2366    /// underlying device is shared.
2367    #[timed_test::async_timed_test(timeout_secs = 60)]
2368    async fn test_cross_actor_same_device_write() -> Result<(), anyhow::Error> {
2369        require_rdma();
2370        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
2371        run_cross_actor_write(&env, BufferDevice::Cpu, BufferDevice::Cpu, 32, 0xa5, 5).await?;
2372        env.shutdown().await
2373    }
2374
2375    /// Cross-actor RDMA read over a single device.
2376    #[timed_test::async_timed_test(timeout_secs = 60)]
2377    async fn test_cross_actor_same_device_read() -> Result<(), anyhow::Error> {
2378        require_rdma();
2379        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
2380        run_cross_actor_read(&env, BufferDevice::Cpu, BufferDevice::Cpu, 32, 0x3c, 5).await?;
2381        env.shutdown().await
2382    }
2383
2384    /// True loopback write: both buffers registered with the same
2385    /// `RdmaManagerActor` on the same device. The `QueuePairActor`
2386    /// sees `is_loopback = true` and connects its QP to its own
2387    /// endpoint without going through `CreatePeerQueuePair`.
2388    #[timed_test::async_timed_test(timeout_secs = 60)]
2389    async fn test_loopback_write() -> Result<(), anyhow::Error> {
2390        require_rdma();
2391        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
2392        run_true_loopback(&env, BufferDevice::Cpu, 32).await?;
2393        env.shutdown().await
2394    }
2395
2396    /// Cross-device write (cpu:0 → cpu:1).
2397    #[timed_test::async_timed_test(timeout_secs = 60)]
2398    async fn test_cross_device_write() -> Result<(), anyhow::Error> {
2399        require_rdma();
2400        let env = TestEnv::new(
2401            IbvConfig::targeting(IbvDeviceTarget::cpu(0)),
2402            IbvConfig::targeting(IbvDeviceTarget::cpu(1)),
2403        )
2404        .await?;
2405        run_cross_actor_write(&env, BufferDevice::Cpu, BufferDevice::Cpu, 32, 0x77, 5).await?;
2406        env.shutdown().await
2407    }
2408
2409    /// Cross-device read (cpu:0 ← cpu:1).
2410    #[timed_test::async_timed_test(timeout_secs = 60)]
2411    async fn test_cross_device_read() -> Result<(), anyhow::Error> {
2412        require_rdma();
2413        let env = TestEnv::new(
2414            IbvConfig::targeting(IbvDeviceTarget::cpu(0)),
2415            IbvConfig::targeting(IbvDeviceTarget::cpu(1)),
2416        )
2417        .await?;
2418        run_cross_actor_read(&env, BufferDevice::Cpu, BufferDevice::Cpu, 32, 0x88, 5).await?;
2419        env.shutdown().await
2420    }
2421
2422    /// One write + one read in a single `IbvBackend::submit` batch.
2423    /// Both ops share the same `QpKey` so the manager groups them
2424    /// into a single `ProcessOps` dispatched to one `QueuePairActor`.
2425    #[timed_test::async_timed_test(timeout_secs = 60)]
2426    async fn test_multi_op_same_qp_cpu() -> Result<(), anyhow::Error> {
2427        require_rdma();
2428        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
2429        run_multi_op_same_qp(&env, BufferDevice::Cpu, BufferDevice::Cpu, 64, 5).await?;
2430        env.shutdown().await
2431    }
2432
2433    /// Same as `test_multi_op_same_qp_cpu` but with 2 MiB CUDA buffers,
2434    /// pulled apart into a separate test because the buffer-size +
2435    /// device split is the only thing that differs.
2436    #[timed_test::async_timed_test(timeout_secs = 120)]
2437    async fn test_multi_op_same_qp_cuda() -> Result<(), anyhow::Error> {
2438        require_rdma();
2439        require_cuda();
2440        const SIZE: usize = 2 * 1024 * 1024;
2441        let env = TestEnv::new(
2442            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
2443            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
2444        )
2445        .await?;
2446        run_multi_op_same_qp(&env, BufferDevice::Cuda(0), BufferDevice::Cuda(1), SIZE, 10).await?;
2447        env.shutdown().await
2448    }
2449
2450    /// CUDA → CPU write.
2451    #[timed_test::async_timed_test(timeout_secs = 60)]
2452    async fn test_cuda_to_cpu_write() -> Result<(), anyhow::Error> {
2453        require_rdma();
2454        require_cuda();
2455        const SIZE: usize = 2 * 1024 * 1024;
2456        let env = TestEnv::new(
2457            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
2458            IbvConfig::targeting(IbvDeviceTarget::cpu(1)),
2459        )
2460        .await?;
2461        run_cross_actor_write(
2462            &env,
2463            BufferDevice::Cuda(0),
2464            BufferDevice::Cpu,
2465            SIZE,
2466            0x9b,
2467            10,
2468        )
2469        .await?;
2470        env.shutdown().await
2471    }
2472
2473    /// CUDA → CPU read (source is the CUDA side).
2474    #[timed_test::async_timed_test(timeout_secs = 60)]
2475    async fn test_cuda_to_cpu_read() -> Result<(), anyhow::Error> {
2476        require_rdma();
2477        require_cuda();
2478        const SIZE: usize = 2 * 1024 * 1024;
2479        let env = TestEnv::new(
2480            IbvConfig::targeting(IbvDeviceTarget::cpu(0)),
2481            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
2482        )
2483        .await?;
2484        run_cross_actor_read(
2485            &env,
2486            BufferDevice::Cpu,
2487            BufferDevice::Cuda(1),
2488            SIZE,
2489            0x37,
2490            10,
2491        )
2492        .await?;
2493        env.shutdown().await
2494    }
2495
2496    /// CPU → CUDA write.
2497    #[timed_test::async_timed_test(timeout_secs = 60)]
2498    async fn test_cpu_to_cuda_write() -> Result<(), anyhow::Error> {
2499        require_rdma();
2500        require_cuda();
2501        const SIZE: usize = 2 * 1024 * 1024;
2502        let env = TestEnv::new(
2503            IbvConfig::targeting(IbvDeviceTarget::cpu(0)),
2504            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
2505        )
2506        .await?;
2507        run_cross_actor_write(
2508            &env,
2509            BufferDevice::Cpu,
2510            BufferDevice::Cuda(1),
2511            SIZE,
2512            0x5a,
2513            10,
2514        )
2515        .await?;
2516        env.shutdown().await
2517    }
2518
2519    /// CPU → CUDA read (source is the CPU side).
2520    #[timed_test::async_timed_test(timeout_secs = 60)]
2521    async fn test_cpu_to_cuda_read() -> Result<(), anyhow::Error> {
2522        require_rdma();
2523        require_cuda();
2524        const SIZE: usize = 2 * 1024 * 1024;
2525        let env = TestEnv::new(
2526            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
2527            IbvConfig::targeting(IbvDeviceTarget::cpu(1)),
2528        )
2529        .await?;
2530        run_cross_actor_read(
2531            &env,
2532            BufferDevice::Cuda(0),
2533            BufferDevice::Cpu,
2534            SIZE,
2535            0x4e,
2536            10,
2537        )
2538        .await?;
2539        env.shutdown().await
2540    }
2541
2542    /// CUDA → CUDA write.
2543    #[timed_test::async_timed_test(timeout_secs = 60)]
2544    async fn test_cuda_to_cuda_write() -> Result<(), anyhow::Error> {
2545        require_rdma();
2546        require_cuda();
2547        const SIZE: usize = 2 * 1024 * 1024;
2548        let env = TestEnv::new(
2549            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
2550            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
2551        )
2552        .await?;
2553        run_cross_actor_write(
2554            &env,
2555            BufferDevice::Cuda(0),
2556            BufferDevice::Cuda(1),
2557            SIZE,
2558            0xee,
2559            10,
2560        )
2561        .await?;
2562        env.shutdown().await
2563    }
2564
2565    /// CUDA → CUDA read.
2566    #[timed_test::async_timed_test(timeout_secs = 60)]
2567    async fn test_cuda_to_cuda_read() -> Result<(), anyhow::Error> {
2568        require_rdma();
2569        require_cuda();
2570        const SIZE: usize = 2 * 1024 * 1024;
2571        let env = TestEnv::new(
2572            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
2573            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
2574        )
2575        .await?;
2576        run_cross_actor_read(
2577            &env,
2578            BufferDevice::Cuda(0),
2579            BufferDevice::Cuda(1),
2580            SIZE,
2581            0x42,
2582            10,
2583        )
2584        .await?;
2585        env.shutdown().await
2586    }
2587
2588    /// CUDA buffers with `IbvQpType::Standard` (no mlx5dv).
2589    /// Exercises the per-buffer dmabuf MR-registration path: without
2590    /// mlx5dv the manager cannot use indirect mkeys via segment
2591    /// scanning and instead registers each buffer as a standalone
2592    /// dmabuf MR (`ibv_reg_dmabuf_mr`).
2593    #[timed_test::async_timed_test(timeout_secs = 60)]
2594    async fn test_standard_qp_cuda_dmabuf_fallback() -> Result<(), anyhow::Error> {
2595        require_rdma();
2596        require_cuda();
2597        const SIZE: usize = 16 * 1024 * 1024;
2598        let mut config_a = IbvConfig::targeting(IbvDeviceTarget::gpu(0));
2599        config_a.qp_type = IbvQpType::Standard;
2600        let mut config_b = IbvConfig::targeting(IbvDeviceTarget::gpu(1));
2601        config_b.qp_type = IbvQpType::Standard;
2602        let env = TestEnv::new(config_a, config_b).await?;
2603        run_cross_actor_write(
2604            &env,
2605            BufferDevice::Cuda(0),
2606            BufferDevice::Cuda(1),
2607            SIZE,
2608            0x33,
2609            10,
2610        )
2611        .await?;
2612        env.shutdown().await
2613    }
2614
2615    /// Two `IbvBackend::submit` calls back-to-back through the same
2616    /// helper. The second batch reuses the cached `QueuePairActor`
2617    /// from the first (the manager's `qp_handles` entry persists
2618    /// across submits).
2619    #[timed_test::async_timed_test(timeout_secs = 60)]
2620    async fn test_multi_batch_same_qp() -> Result<(), anyhow::Error> {
2621        require_rdma();
2622        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
2623        let src1 = env
2624            .helper_a
2625            .allocate(&env.client, 32, BufferDevice::Cpu, 0xa1)
2626            .await?;
2627        let dst1 = env
2628            .helper_b
2629            .allocate(&env.client, 32, BufferDevice::Cpu, 0)
2630            .await?;
2631        env.helper_a
2632            .submit(
2633                &env.client,
2634                vec![BufferHelperOp {
2635                    op_type: RdmaOpType::WriteFromLocal,
2636                    local_buf: src1,
2637                    remote_buf: dst1.clone(),
2638                }],
2639                5,
2640            )
2641            .await?
2642            .map_err(|e| anyhow::anyhow!(e))?;
2643        assert_remote_pattern(&env.helper_b, &env.client, dst1, 32, 0xa1).await?;
2644
2645        let src2 = env
2646            .helper_a
2647            .allocate(&env.client, 32, BufferDevice::Cpu, 0xb2)
2648            .await?;
2649        let dst2 = env
2650            .helper_b
2651            .allocate(&env.client, 32, BufferDevice::Cpu, 0)
2652            .await?;
2653        env.helper_a
2654            .submit(
2655                &env.client,
2656                vec![BufferHelperOp {
2657                    op_type: RdmaOpType::WriteFromLocal,
2658                    local_buf: src2,
2659                    remote_buf: dst2.clone(),
2660                }],
2661                5,
2662            )
2663            .await?
2664            .map_err(|e| anyhow::anyhow!(e))?;
2665        assert_remote_pattern(&env.helper_b, &env.client, dst2, 32, 0xb2).await?;
2666        env.shutdown().await
2667    }
2668
2669    /// Single submit batch with ops landing on multiple `QpKey`
2670    /// groups: loopback (helper_a → helper_a), cross-actor cpu↔cpu,
2671    /// cpu↔cuda, cuda↔cpu, cuda↔cuda. Exercises the manager's
2672    /// per-QP slicing and concurrent multi-QP dispatch.
2673    #[timed_test::async_timed_test(timeout_secs = 120)]
2674    async fn test_multi_op_multi_qp() -> Result<(), anyhow::Error> {
2675        require_rdma();
2676        require_cuda();
2677        const SIZE: usize = 2 * 1024 * 1024;
2678
2679        let env = TestEnv::same_config(IbvConfig::default()).await?;
2680
2681        const LOOPBACK_PAT: u8 = 0x11;
2682        let lb_src = env
2683            .helper_a
2684            .allocate(&env.client, SIZE, BufferDevice::Cpu, LOOPBACK_PAT)
2685            .await?;
2686        let lb_dst = env
2687            .helper_a
2688            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
2689            .await?;
2690
2691        const CC_PAT: u8 = 0x22;
2692        let cc_src = env
2693            .helper_a
2694            .allocate(&env.client, SIZE, BufferDevice::Cpu, CC_PAT)
2695            .await?;
2696        let cc_dst = env
2697            .helper_b
2698            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
2699            .await?;
2700
2701        const CG_PAT: u8 = 0x33;
2702        let cg_src = env
2703            .helper_a
2704            .allocate(&env.client, SIZE, BufferDevice::Cpu, CG_PAT)
2705            .await?;
2706        let cg_dst = env
2707            .helper_b
2708            .allocate(&env.client, SIZE, BufferDevice::Cuda(1), 0)
2709            .await?;
2710
2711        const GC_PAT: u8 = 0x44;
2712        let gc_src = env
2713            .helper_a
2714            .allocate(&env.client, SIZE, BufferDevice::Cuda(0), GC_PAT)
2715            .await?;
2716        let gc_dst = env
2717            .helper_b
2718            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
2719            .await?;
2720
2721        const GG_PAT: u8 = 0x55;
2722        let gg_src = env
2723            .helper_b
2724            .allocate(&env.client, SIZE, BufferDevice::Cuda(1), GG_PAT)
2725            .await?;
2726        let gg_dst = env
2727            .helper_a
2728            .allocate(&env.client, SIZE, BufferDevice::Cuda(0), 0)
2729            .await?;
2730
2731        env.helper_a
2732            .submit(
2733                &env.client,
2734                vec![
2735                    BufferHelperOp {
2736                        op_type: RdmaOpType::WriteFromLocal,
2737                        local_buf: lb_src,
2738                        remote_buf: lb_dst.clone(),
2739                    },
2740                    BufferHelperOp {
2741                        op_type: RdmaOpType::WriteFromLocal,
2742                        local_buf: cc_src,
2743                        remote_buf: cc_dst.clone(),
2744                    },
2745                    BufferHelperOp {
2746                        op_type: RdmaOpType::WriteFromLocal,
2747                        local_buf: cg_src,
2748                        remote_buf: cg_dst.clone(),
2749                    },
2750                    BufferHelperOp {
2751                        op_type: RdmaOpType::WriteFromLocal,
2752                        local_buf: gc_src,
2753                        remote_buf: gc_dst.clone(),
2754                    },
2755                    BufferHelperOp {
2756                        op_type: RdmaOpType::ReadIntoLocal,
2757                        local_buf: gg_dst.clone(),
2758                        remote_buf: gg_src,
2759                    },
2760                ],
2761                30,
2762            )
2763            .await?
2764            .map_err(|e| anyhow::anyhow!(e))?;
2765
2766        assert_remote_pattern(&env.helper_a, &env.client, lb_dst, SIZE, LOOPBACK_PAT).await?;
2767        assert_remote_pattern(&env.helper_b, &env.client, cc_dst, SIZE, CC_PAT).await?;
2768        assert_remote_pattern(&env.helper_b, &env.client, cg_dst, SIZE, CG_PAT).await?;
2769        assert_remote_pattern(&env.helper_b, &env.client, gc_dst, SIZE, GC_PAT).await?;
2770        assert_remote_pattern(&env.helper_a, &env.client, gg_dst, SIZE, GG_PAT).await?;
2771
2772        env.shutdown().await
2773    }
2774
2775    /// Force the timeout branch in `IbvBackend::submit`. A near-zero
2776    /// timeout fires before any per-op replies arrive, so the
2777    /// aggregated error reports the timeout terminal cause.
2778    #[timed_test::async_timed_test(timeout_secs = 60)]
2779    async fn test_submit_timeout() -> Result<(), anyhow::Error> {
2780        require_rdma();
2781        const SIZE: usize = 1024 * 1024;
2782        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
2783        let src = env
2784            .helper_a
2785            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0x77)
2786            .await?;
2787        let dst = env
2788            .helper_b
2789            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
2790            .await?;
2791        let result = env
2792            .helper_a
2793            .submit(
2794                &env.client,
2795                vec![BufferHelperOp {
2796                    op_type: RdmaOpType::WriteFromLocal,
2797                    local_buf: src,
2798                    remote_buf: dst,
2799                }],
2800                0,
2801            )
2802            .await?;
2803        let err = result.expect_err("expected submit to time out");
2804        assert!(
2805            err.contains("submit timed out"),
2806            "unexpected error message: {err}",
2807        );
2808        env.shutdown().await
2809    }
2810
2811    /// Submit a batch with a bogus op in the middle. RC
2812    /// completions fire in posting order, so the good op before it
2813    /// completes normally, the bogus op fails with `REM_ACCESS_ERR`
2814    /// (it puts the QP into error state), and the good op after it
2815    /// gets flushed with `WC_WR_FLUSH_ERR`. Verifies (a) op 0 is
2816    /// absent from the aggregated error and its bytes transferred,
2817    /// (b) ops 1 and 2 both appear in the error, and (c) op 2's
2818    /// destination was *not* written (the flush meant nothing was
2819    /// transferred).
2820    #[timed_test::async_timed_test(timeout_secs = 60)]
2821    async fn test_partial_failure_batch() -> Result<(), anyhow::Error> {
2822        require_rdma();
2823        let lock = hyperactor_config::global::lock();
2824        let _guard = lock.override_key(
2825            crate::config::RDMA_QPS_PER_PEER,
2826            hyperactor_config::NonZeroUsize::new(1).expect("1 is non-zero"),
2827        );
2828        const SIZE: usize = 32;
2829        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
2830
2831        const GOOD_PAT: u8 = 0xc3;
2832        const POST_FLUSH_PAT: u8 = 0xde;
2833
2834        let good_src_0 = env
2835            .helper_a
2836            .allocate(&env.client, SIZE, BufferDevice::Cpu, GOOD_PAT)
2837            .await?;
2838        let good_dst_0 = env
2839            .helper_b
2840            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
2841            .await?;
2842
2843        let bogus_src = env
2844            .helper_a
2845            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0xee)
2846            .await?;
2847        let real_remote = env
2848            .helper_b
2849            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
2850            .await?;
2851        let mut bogus_remote = real_remote.clone();
2852        let bufs = [
2853            bogus_remote
2854                .backends
2855                .mlx
2856                .as_mut()
2857                .map(|ctx| &mut ctx.buffers),
2858            bogus_remote
2859                .backends
2860                .efa
2861                .as_mut()
2862                .map(|ctx| &mut ctx.buffers),
2863        ];
2864        for buf in bufs.into_iter().flatten().flatten() {
2865            buf.rkey = 0xdead_beef;
2866            buf.addr = 0xdead_0000;
2867        }
2868
2869        let post_flush_src = env
2870            .helper_a
2871            .allocate(&env.client, SIZE, BufferDevice::Cpu, POST_FLUSH_PAT)
2872            .await?;
2873        let post_flush_dst = env
2874            .helper_b
2875            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
2876            .await?;
2877
2878        let result = env
2879            .helper_a
2880            .submit(
2881                &env.client,
2882                vec![
2883                    BufferHelperOp {
2884                        op_type: RdmaOpType::WriteFromLocal,
2885                        local_buf: good_src_0,
2886                        remote_buf: good_dst_0.clone(),
2887                    },
2888                    BufferHelperOp {
2889                        op_type: RdmaOpType::WriteFromLocal,
2890                        local_buf: bogus_src,
2891                        remote_buf: bogus_remote,
2892                    },
2893                    BufferHelperOp {
2894                        op_type: RdmaOpType::WriteFromLocal,
2895                        local_buf: post_flush_src,
2896                        remote_buf: post_flush_dst.clone(),
2897                    },
2898                ],
2899                10,
2900            )
2901            .await?;
2902        let err = result.expect_err("expected at least one op to fail");
2903        let rem_access = format!(
2904            "status={:?}",
2905            rdmaxcel_sys::ibv_wc_status::IBV_WC_REM_ACCESS_ERR,
2906        );
2907        let wr_flush = format!(
2908            "status={:?}",
2909            rdmaxcel_sys::ibv_wc_status::IBV_WC_WR_FLUSH_ERR,
2910        );
2911        /// Returns the stripe failure lines an error reports under `op
2912        /// {op_idx}:`.
2913        fn op_block(err: &str, op_idx: usize) -> &str {
2914            let header = format!("\n  op {op_idx}:\n");
2915            let start = err
2916                .find(&header)
2917                .unwrap_or_else(|| panic!("expected op {op_idx} in error: {err}"));
2918            let block = &err[start + header.len()..];
2919            &block[..block.find("\n  op ").unwrap_or(block.len())]
2920        }
2921
2922        assert!(
2923            !err.contains("op 0:"),
2924            "op 0 should not appear in error: {err}",
2925        );
2926        let op1 = op_block(&err, 1);
2927        assert!(
2928            op1.lines().all(|line| line.starts_with("    stripe ")),
2929            "expected op 1 to list one line per failed stripe: {op1}",
2930        );
2931        assert!(
2932            op1.contains("completion failed") && op1.contains(&rem_access),
2933            "expected op 1 to fail with REM_ACCESS_ERR: {op1}",
2934        );
2935        let op2 = op_block(&err, 2);
2936        assert!(
2937            op2.contains("completion failed") && op2.contains(&wr_flush),
2938            "expected op 2 to be flushed with WR_FLUSH_ERR: {op2}",
2939        );
2940
2941        assert_remote_pattern(&env.helper_b, &env.client, good_dst_0, SIZE, GOOD_PAT).await?;
2942        // The flushed op was never transferred; destination stays zero.
2943        assert_remote_pattern(&env.helper_b, &env.client, post_flush_dst, SIZE, 0).await?;
2944
2945        env.shutdown().await
2946    }
2947}