spark_storage/
weight_tier_rdma.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// RdmaWeightLoader — the client half of the RDMA weight-staging tier.
4//
5// A `spark_runtime::weights::WeightLoader` (the same trait the disk loaders
6// impl) whose source is a peer's RAM blade (`weight_peer`) over one-sided RDMA
7// instead of the local SSD. For FAST MODEL SWAPS: connect, request a model by
8// id/path, read the peer's manifest, then RDMA-READ every resident tensor's
9// bytes straight out of the peer's shard MRs into a pinned bounce and
10// `copy_h2d` to a freshly-alloc'd GPU buffer — one buffer per tensor, keyed by
11// the exact safetensors name, byte-identical to the disk path.
12//
13// It composes with expert streaming: `stream_all_experts` / EP filtering skip
14// the routed experts (served separately by `expert_peer`), so this loads only
15// the resident set (attention, router gate, shared expert, norms, embed,
16// lm_head, MTP) — the exact `should_skip_tensor` predicate the disk loaders use.
17//
18// Bounce path (option a): tensors are MB-sized and bandwidth-bound, so the
19// bounce + copy_h2d overhead is negligible (unlike the 8 KiB KV groups that
20// needed zero-copy). Reuses the dual-rail striping template (tensor % n_rails).
21//
22// Like `expert_tier_rdma`, the verbs data path is gated on `atlas_rdma_verbs`;
23// without rdma-core the loader compiles but `load` returns a clear runtime error
24// (the selection lives in the server's `load_weight_store`, keyed on
25// `$ATLAS_WEIGHT_PEER`).
26
27use anyhow::Result;
28use std::path::Path;
29
30use spark_runtime::gpu::GpuBackend;
31use spark_runtime::weights::{WeightLoader, WeightStore, parse_expert_index};
32
33use crate::weight_peer::WeightTensorRecord;
34
35/// Loads a model's resident weights from a `weight_peer` over one-sided RDMA.
36pub struct RdmaWeightLoader {
37    /// `host:port` of the weight peer (from `$ATLAS_WEIGHT_PEER`).
38    pub peer_addr: String,
39    /// Model id/path to request. When `None`, the loader sends the `model_dir`
40    /// path passed to `load` (so the client and peer agree on the local path).
41    pub model_id: Option<String>,
42    pub ep_rank: usize,
43    pub ep_world_size: usize,
44    pub num_experts: usize,
45    /// Expert streaming: skip ALL routed-expert tensors (served from the expert
46    /// peer). Set from `config.expert_streaming` at the call site.
47    pub stream_all_experts: bool,
48    /// Pre-flight OOM multiplier override (advisory; parity with disk loaders).
49    pub peak_memory_multiplier: Option<f64>,
50}
51
52impl RdmaWeightLoader {
53    pub fn new(peer_addr: String) -> Self {
54        Self {
55            peer_addr,
56            model_id: None,
57            ep_rank: 0,
58            ep_world_size: 1,
59            num_experts: 0,
60            stream_all_experts: false,
61            peak_memory_multiplier: None,
62        }
63    }
64
65    pub fn with_ep(
66        peer_addr: String,
67        ep_rank: usize,
68        ep_world_size: usize,
69        num_experts: usize,
70    ) -> Self {
71        Self {
72            peer_addr,
73            model_id: None,
74            ep_rank,
75            ep_world_size,
76            num_experts,
77            stream_all_experts: false,
78            peak_memory_multiplier: None,
79        }
80    }
81
82    /// The exact skip predicate the disk loaders use (`SafetensorsLoader` /
83    /// `FastSafetensorsLoader::should_skip_tensor`), applied to a manifest
84    /// record. `extra_weights` tensors (`rec.extra`) are NEVER skipped, matching
85    /// the disk path's no-skip pass for `extra_weights.safetensors`.
86    // Only reached from the `atlas_rdma_verbs` load path; on a cuda host without
87    // rdma-core the whole data path runtime-bails, leaving this unreferenced.
88    #[cfg_attr(not(atlas_rdma_verbs), allow(dead_code))]
89    fn should_skip_tensor(&self, rec: &WeightTensorRecord) -> bool {
90        if rec.extra {
91            return false;
92        }
93        // MTP head experts are small — always replicate, never skip.
94        if rec.name.starts_with("mtp.") {
95            return false;
96        }
97        // Expert streaming: routed experts stream from the expert peer.
98        if self.stream_all_experts && parse_expert_index(&rec.name).is_some() {
99            return true;
100        }
101        if self.ep_world_size <= 1 {
102            return false;
103        }
104        if let Some(idx) = parse_expert_index(&rec.name) {
105            let per_rank = self.num_experts / self.ep_world_size;
106            let local_start = self.ep_rank * per_rank;
107            let local_end = if self.ep_rank == self.ep_world_size - 1 {
108                self.num_experts
109            } else {
110                local_start + per_rank
111            };
112            idx < local_start || idx >= local_end
113        } else {
114            false
115        }
116    }
117}
118
119impl WeightLoader for RdmaWeightLoader {
120    fn load(
121        &self,
122        model_dir: &Path,
123        gpu: &dyn GpuBackend,
124        oom_reserve_bytes: usize,
125    ) -> Result<WeightStore> {
126        self.load_impl(model_dir, gpu, oom_reserve_bytes)
127    }
128}
129
130#[cfg(not(atlas_rdma_verbs))]
131impl RdmaWeightLoader {
132    fn load_impl(
133        &self,
134        _model_dir: &Path,
135        _gpu: &dyn GpuBackend,
136        _oom_reserve_bytes: usize,
137    ) -> Result<WeightStore> {
138        anyhow::bail!(
139            "$ATLAS_WEIGHT_PEER is set but this build has no rdma-core (atlas_rdma_verbs \
140             cfg); rebuild with rdma-core, or unset ATLAS_WEIGHT_PEER to load from disk"
141        )
142    }
143}
144
145#[cfg(atlas_rdma_verbs)]
146impl RdmaWeightLoader {
147    fn load_impl(
148        &self,
149        model_dir: &Path,
150        gpu: &dyn GpuBackend,
151        oom_reserve_bytes: usize,
152    ) -> Result<WeightStore> {
153        use std::collections::HashMap;
154        use std::ffi::c_void;
155        use std::io::Write;
156        use std::net::TcpStream;
157
158        use anyhow::{Context, bail};
159
160        use crate::expert_peer::MODE_VERBS;
161        use crate::weight_peer::{
162            rail_for_tensor, read_weight_manifest, tensor_remote_addr, write_model_request,
163        };
164        use atlas_rdma::env::{first_nonempty, first_set_u32};
165        use atlas_rdma::railset::{RailSet, RailSpec};
166        use atlas_rdma::verbs::Verbs;
167        use spark_runtime::weights::{WeightDtype, WeightTensor};
168
169        // 1. Connect + request the model + read the manifest.
170        let mut stream = TcpStream::connect(&self.peer_addr)
171            .with_context(|| format!("connect weight peer {}", self.peer_addr))?;
172        stream.set_nodelay(true).ok();
173        let model_id = self
174            .model_id
175            .clone()
176            .unwrap_or_else(|| model_dir.to_string_lossy().into_owned());
177        write_model_request(&mut stream, &model_id).context("send model request")?;
178        let manifest = read_weight_manifest(&mut stream).context("read weight manifest")?;
179        let num_shards = manifest.num_shards();
180
181        // 2. Filter to the resident set (skip streamed/EP experts). extra_weights
182        // tensors are always kept (should_skip_tensor honors rec.extra).
183        let retained: Vec<&WeightTensorRecord> = manifest
184            .tensors
185            .iter()
186            .filter(|t| !self.should_skip_tensor(t))
187            .collect();
188
189        // 3. Advisory OOM pre-flight (parity with the disk loaders — estimate
190        // from the manifest, not local headers).
191        {
192            let est: u64 = retained.iter().map(|t| t.len).sum();
193            let fp8: u64 = retained
194                .iter()
195                .filter(|t| t.dtype == "F8_E4M3")
196                .map(|t| t.len)
197                .sum();
198            let fp8_frac = if est > 0 {
199                fp8 as f64 / est as f64
200            } else {
201                0.0
202            };
203            let mult =
204                self.peak_memory_multiplier
205                    .unwrap_or(if fp8_frac > 0.5 { 1.5 } else { 1.3 });
206            let peak = (est as f64 * mult) as usize;
207            let free = gpu.free_memory()?;
208            let gib = |b: usize| b as f64 / (1024.0 * 1024.0 * 1024.0);
209            tracing::info!(
210                "RDMA weight load pre-flight: {:.2} GB manifest, {:.1}x = {:.2} GB peak, \
211                 {:.2} GB free, {:.1} GB reserve (FP8 {:.0}%)",
212                gib(est as usize),
213                mult,
214                gib(peak),
215                gib(free),
216                gib(oom_reserve_bytes),
217                fp8_frac * 100.0,
218            );
219            if peak + oom_reserve_bytes > free {
220                bail!(
221                    "OOM pre-flight (RDMA weight peer): peak {:.2} GB + {:.2} GB reserve > {:.2} GB free",
222                    gib(peak),
223                    gib(oom_reserve_bytes),
224                    gib(free),
225                );
226            }
227        }
228
229        // 4. Verbs handshake via RailSet. Rail 0 defaults to the shared expert
230        // CX7 link; dual-rail is opt-in (ATLAS_WEIGHT_DUAL_RAIL=1). ATLAS_WEIGHT_*
231        // overrides fall back to the ATLAS_EXPERT_* names so a single fabric
232        // config serves both tiers (weight semantics: an exported-but-EMPTY
233        // override is SKIPPED — `first_nonempty`). Fresh random 24-bit PSN/rail.
234        let spec =
235            |dev: String, gid: u32| RailSpec::new(dev, gid, rand::random::<u32>() & 0xff_ffff);
236        let rail0 = spec(
237            first_nonempty(
238                &["ATLAS_WEIGHT_RDMA_DEV", "ATLAS_EXPERT_RDMA_DEV"],
239                "roceP2p1s0f1",
240            ),
241            first_set_u32(&["ATLAS_WEIGHT_RDMA_GID", "ATLAS_EXPERT_RDMA_GID"], 3),
242        );
243        let dual = std::env::var("ATLAS_WEIGHT_DUAL_RAIL").ok().as_deref() == Some("1");
244        let specs: Vec<RailSpec> = if dual {
245            let rail1 = spec(
246                first_nonempty(
247                    &["ATLAS_WEIGHT_RAIL2_DEV", "ATLAS_EXPERT_RAIL2_DEV"],
248                    "rocep1s0f1",
249                ),
250                first_set_u32(&["ATLAS_WEIGHT_RAIL2_GID", "ATLAS_EXPERT_RAIL2_GID"], 3),
251            );
252            vec![rail0, rail1]
253        } else {
254            vec![rail0]
255        };
256        let n_rails = specs.len();
257
258        stream.write_all(&[MODE_VERBS]).context("send verbs mode")?;
259        // [u8 n_rails] + one QP per rail.
260        let mut rs = RailSet::begin(&mut stream, &specs)?;
261
262        // One pinned, registered bounce per rail, sized to the largest retained
263        // tensor. Tensors are processed serially per rail (post → poll), so one
264        // bounce per rail suffices; pipelining is deferred (bandwidth-bound).
265        let max_len = retained.iter().map(|t| t.len).max().unwrap_or(0);
266        if max_len > u32::MAX as u64 {
267            bail!(
268                "tensor of {} bytes exceeds the 4 GiB single-WR RDMA READ limit \
269                 (per-tensor chunking not implemented)",
270                max_len
271            );
272        }
273        let bounce_len = (max_len as usize).max(1);
274
275        // LOCAL_WRITE-only landing MRs (`remote_read == false`, invariant).
276        // Track pinned allocations to free AFTER the rails (MRs) are dropped.
277        let mut pinned: Vec<*mut u8> = Vec::with_capacity(n_rails);
278        let mut bounce_lkeys: Vec<u32> = Vec::with_capacity(n_rails);
279        for rail in &mut rs.rails {
280            let ptr = gpu
281                .alloc_host_pinned(bounce_len)
282                .context("alloc pinned RDMA landing bounce")?;
283            // SAFETY: ptr backs `bounce_len` pinned bytes that outlive the MR
284            // (freed after the rails are dropped below).
285            let keys = unsafe { rail.verbs.reg_mr(ptr as *mut c_void, bounce_len, false) }
286                .context("register RDMA landing bounce")?;
287            pinned.push(ptr);
288            bounce_lkeys.push(keys.lkey);
289        }
290
291        // Peer publishes per-rail per-SHARD (base, rkey). Validate shard counts
292        // BEFORE replying (a mismatch bails with no client params written).
293        let server = rs
294            .read_server_ro(&mut stream)
295            .context("read verbs server params")?;
296        for sp in &server {
297            if sp.layers.len() != num_shards {
298                bail!(
299                    "peer published {} shard MRs but manifest has {num_shards} shards",
300                    sp.layers.len()
301                );
302            }
303        }
304
305        // Reply with our QP params, connect each rail, await the ready ack.
306        rs.complete(&mut stream, &server, "weight peer")?;
307        struct Rail {
308            verbs: Verbs,
309            bounce_ptr: *mut u8,
310            bounce_lkey: u32,
311        }
312        let mut rails: Vec<Rail> = rs
313            .into_verbs()
314            .into_iter()
315            .zip(&pinned)
316            .zip(&bounce_lkeys)
317            .map(|((verbs, &bounce_ptr), &bounce_lkey)| Rail {
318                verbs,
319                bounce_ptr,
320                bounce_lkey,
321            })
322            .collect();
323        tracing::info!(
324            "RDMA weight loader connected to {} ({} shards, {} resident tensors, {n_rails} rail(s))",
325            manifest.model_id,
326            num_shards,
327            retained.len(),
328        );
329
330        // 5. RDMA-READ each resident tensor into its rail's bounce, then copy_h2d
331        // into a fresh per-tensor GPU buffer. Byte-identical: the manifest offset
332        // is absolute (shard_base + offset reads the raw data slice), `len` is
333        // authoritative, dtype/shape come from the header verbatim.
334        let mut weights: HashMap<String, WeightTensor> = HashMap::new();
335        let mut offload_logged = false;
336        for (idx, rec) in retained.iter().enumerate() {
337            let ri = rail_for_tensor(idx, n_rails);
338            let sp = &server[ri];
339            let (shard_base, rkey) = *sp
340                .layers
341                .get(rec.shard_index as usize)
342                .with_context(|| format!("no shard MR {} for {}", rec.shard_index, rec.name))?;
343            let remote_addr = tensor_remote_addr(shard_base, rec.offset_in_shard);
344            let len = rec.len as usize;
345            let wr_id = idx as u64;
346
347            let rail = &mut rails[ri];
348            // SAFETY: bounce_ptr backs `bounce_len >= len` pinned bytes in this
349            // rail's MR; remote_addr/rkey address the peer's shard MR on this
350            // same rail; len <= u32::MAX (checked above).
351            unsafe {
352                rail.verbs
353                    .post_read(
354                        rail.bounce_ptr as *mut c_void,
355                        rail.bounce_lkey,
356                        remote_addr,
357                        rkey,
358                        len as u32,
359                        wr_id,
360                    )
361                    .with_context(|| format!("post_read {}", rec.name))?;
362            }
363            match rail.verbs.poll() {
364                Ok(got) if got == wr_id => {}
365                Ok(got) => bail!(
366                    "completion wr_id {got:#x} != expected {wr_id:#x} ({})",
367                    rec.name
368                ),
369                Err(e) => return Err(e).with_context(|| format!("poll {}", rec.name)),
370            }
371
372            // SAFETY: the bounce now holds `len` valid bytes landed by the READ.
373            let src = unsafe { std::slice::from_raw_parts(rail.bounce_ptr, len) };
374            let dtype = WeightDtype::from_safetensors_str(&rec.dtype)
375                .with_context(|| format!("tensor {}", rec.name))?;
376            let shape: Vec<usize> = rec.shape.iter().map(|&d| d as usize).collect();
377
378            let ptr = match gpu.alloc(len) {
379                Ok(p) => {
380                    gpu.copy_h2d(src, p)?;
381                    p
382                }
383                Err(_) => {
384                    if !offload_logged {
385                        tracing::warn!(
386                            "GPU alloc failed for {} ({len} bytes) — switching to managed (UVM) memory",
387                            rec.name
388                        );
389                        offload_logged = true;
390                    }
391                    let p = gpu.alloc_managed(len)?;
392                    // SAFETY: managed ptr is host-addressable UVM of `len` bytes;
393                    // src is the pinned bounce of `len` bytes. Matches the disk
394                    // loaders' CPU-memcpy fallback.
395                    unsafe {
396                        std::ptr::copy_nonoverlapping(src.as_ptr(), p.0 as *mut u8, len);
397                    }
398                    p
399                }
400            };
401            weights.insert(rec.name.clone(), WeightTensor { ptr, shape, dtype });
402        }
403
404        // 6. Tear down: drop the rails (dereg MRs) BEFORE freeing the pinned
405        // bounces they registered, then release the pinned host memory.
406        drop(rails);
407        for ptr in pinned {
408            let _ = gpu.free_host_pinned(ptr, bounce_len);
409        }
410
411        tracing::info!("RDMA-loaded {} weight tensors", weights.len());
412        Ok(WeightStore::from_map(weights))
413    }
414}