spark_storage/
weight_lora_rdma.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// RdmaLoraLoader — RDMA-stage a PEFT adapter's A/B tensors straight into a
4// resident LoRA pool SLOT for fast rotation (vs a disk reload).
5//
6// This is the client half for LoRA rotation over the RDMA weight tier. It
7// reuses the SAME `weight_peer` wire protocol + verbs stack as
8// `weight_tier_rdma`: connect, request an adapter dir by id/path, read the
9// manifest, then one-sided RDMA-READ each `lora_A/lora_B` tensor's bytes into a
10// pinned bounce. The ONLY difference from `weight_tier_rdma` is the landing:
11// instead of a fresh per-tensor GPU buffer, each tensor lands into a caller-
12// computed pool-slot SUB-REGION (`LoraLandTarget.dst`) after the SAME host
13// F16/F32→BF16 conversion the disk adapter loader does
14// (`spark-runtime .../adapter.rs`) and the SAME B row-repack (stride r →
15// max_rank) the pool pack does (`spark-model .../lora/mod.rs`). Landing bytes
16// are therefore byte-identical to the disk pack — post_read-into-bounce simply
17// replaces the disk loader's copy_d2h.
18//
19// The plan (which tensor lands where, with what geometry) is computed by
20// spark-model (`lora::rdma_stage`, the only place `classify_key` + slot offsets
21// live) and passed in as `&[LoraLandTarget]`, keeping this crate free of any
22// spark-model dependency (spark-model → spark-storage is the acyclic direction).
23//
24// Like `weight_tier_rdma`, the verbs data path is gated on `atlas_rdma_verbs`;
25// without rdma-core the loader compiles but `stage_into_slot` returns a clear
26// runtime error.
27
28use anyhow::Result;
29
30/// Which half of a LoRA pair a target lands. A is copied contiguous into the
31/// head of the padded `[max_rank, in]` region; B is row-repacked from stride
32/// `r` to stride `max_rank` into `[out, max_rank]`.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum LoraAbKind {
35    A,
36    B,
37}
38
39/// One landing instruction: the adapter tensor `tensor_name` (a manifest key),
40/// which half it is, the device destination address (a pool-slot sub-region
41/// base, already `pool + slot*slot_bytes + a_off|b_off`), and the geometry the
42/// convert/repack needs. `rank` is the adapter's real r; `max_rank` the pool's
43/// padded rank.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct LoraLandTarget {
46    pub tensor_name: String,
47    pub kind: LoraAbKind,
48    /// Destination device address (`DevicePtr.0`) of the slot sub-region.
49    pub dst: u64,
50    /// Output dim (B rows). Unused for A.
51    pub out_dim: usize,
52    /// Input dim (A cols). Unused for B.
53    pub in_dim: usize,
54    /// Adapter real rank r (A rows / B cols).
55    pub rank: usize,
56    /// Pool padded rank (B destination row stride).
57    pub max_rank: usize,
58}
59
60const BF16_BYTES: usize = 2;
61
62/// Host F16/F32/BF16 → BF16, byte-for-byte matching the disk adapter loader
63/// (`load_adapter_safetensors`): `half::bf16::from_f32` (round-to-nearest-even)
64/// for the float conversions. Any other dtype is a hard error — a PEFT adapter
65/// is only ever F32 (default), F16, or BF16.
66pub fn convert_to_bf16(raw: &[u8], dtype: &str) -> Result<Vec<u8>> {
67    use half::{bf16, f16};
68    Ok(match dtype {
69        "BF16" => raw.to_vec(),
70        "F16" => raw
71            .chunks_exact(2)
72            .flat_map(|c| bf16::from_f32(f16::from_le_bytes([c[0], c[1]]).to_f32()).to_le_bytes())
73            .collect(),
74        "F32" => raw
75            .chunks_exact(4)
76            .flat_map(|c| {
77                bf16::from_f32(f32::from_le_bytes([c[0], c[1], c[2], c[3]])).to_le_bytes()
78            })
79            .collect(),
80        other => anyhow::bail!(
81            "REJECT[lora-rdma-dtype]: adapter tensor dtype '{other}' (want F32/F16/BF16)"
82        ),
83    })
84}
85
86/// Row-repack a BF16 B tensor `[out_dim, r]` into the pool's padded
87/// `[out_dim, max_rank]` layout: per row, `r` BF16 elements copied from stride
88/// `r` to stride `max_rank`; pad columns stay zero. Byte-identical to the disk
89/// pack's B repack. Returns `out_dim * max_rank * 2` bytes.
90pub fn repack_b_to_padded(src_bf16: &[u8], out_dim: usize, r: usize, max_rank: usize) -> Vec<u8> {
91    let mut dst = vec![0u8; out_dim * max_rank * BF16_BYTES];
92    for row in 0..out_dim {
93        let d = row * max_rank * BF16_BYTES;
94        let s = row * r * BF16_BYTES;
95        dst[d..d + r * BF16_BYTES].copy_from_slice(&src_bf16[s..s + r * BF16_BYTES]);
96    }
97    dst
98}
99
100/// The final host bytes to `copy_h2d` for a target, given the raw on-wire
101/// tensor bytes (as landed in the bounce). A: convert only. B: convert + repack.
102/// Factored out (un-gated) so the byte-identity logic is unit-testable off the
103/// RDMA path — the verbs loop calls this so tested and shipped logic agree.
104pub fn land_bytes_for_target(target: &LoraLandTarget, raw: &[u8], dtype: &str) -> Result<Vec<u8>> {
105    let bf16 = convert_to_bf16(raw, dtype)?;
106    Ok(match target.kind {
107        LoraAbKind::A => bf16, // [r, in] contiguous → head of padded region
108        LoraAbKind::B => repack_b_to_padded(&bf16, target.out_dim, target.rank, target.max_rank),
109    })
110}
111
112/// RDMA-stage a named adapter's A/B into pre-computed pool-slot sub-regions.
113pub struct RdmaLoraLoader {
114    /// `host:port` of the weight peer (from `$ATLAS_LORA_PEER`).
115    pub peer_addr: String,
116    /// Adapter dir id/path the peer staged (its `adapter_model.safetensors`).
117    pub adapter_id: String,
118}
119
120impl RdmaLoraLoader {
121    pub fn new(peer_addr: String, adapter_id: String) -> Self {
122        Self {
123            peer_addr,
124            adapter_id,
125        }
126    }
127}
128
129#[cfg(all(feature = "cuda", not(atlas_rdma_verbs)))]
130impl RdmaLoraLoader {
131    /// Stub: no rdma-core in this build.
132    pub fn stage_into_slot(
133        &self,
134        _gpu: &dyn spark_runtime::gpu::GpuBackend,
135        _targets: &[LoraLandTarget],
136    ) -> Result<()> {
137        anyhow::bail!(
138            "$ATLAS_LORA_PEER is set but this build has no rdma-core (atlas_rdma_verbs \
139             cfg); rebuild with rdma-core, or unset ATLAS_LORA_PEER to rotate from disk"
140        )
141    }
142}
143
144#[cfg(all(feature = "cuda", atlas_rdma_verbs))]
145impl RdmaLoraLoader {
146    /// Connect, request the adapter, and RDMA-READ each `lora_A/lora_B` tensor
147    /// into its pool-slot sub-region (single rail — an adapter is one small
148    /// shard). Convert + (B) repack land bytes byte-identical to the disk pack.
149    pub fn stage_into_slot(
150        &self,
151        gpu: &dyn spark_runtime::gpu::GpuBackend,
152        targets: &[LoraLandTarget],
153    ) -> Result<()> {
154        use std::collections::HashMap;
155        use std::ffi::c_void;
156        use std::io::Write;
157        use std::net::TcpStream;
158
159        use anyhow::{Context, bail};
160
161        use crate::expert_peer::MODE_VERBS;
162        use crate::weight_peer::{read_weight_manifest, tensor_remote_addr, write_model_request};
163        use atlas_rdma::env::{first_set, first_set_u32};
164        use atlas_rdma::railset::{RailSet, RailSpec};
165
166        let by_name: HashMap<&str, &LoraLandTarget> = targets
167            .iter()
168            .map(|t| (t.tensor_name.as_str(), t))
169            .collect();
170
171        // 1. Connect + request the adapter + read the manifest.
172        let mut stream = TcpStream::connect(&self.peer_addr)
173            .with_context(|| format!("connect lora peer {}", self.peer_addr))?;
174        stream.set_nodelay(true).ok();
175        write_model_request(&mut stream, &self.adapter_id).context("send adapter request")?;
176        let manifest = read_weight_manifest(&mut stream).context("read adapter manifest")?;
177        let num_shards = manifest.num_shards();
178
179        // Only the tensors we have a landing target for (all lora_A/lora_B).
180        let retained: Vec<&crate::weight_peer::WeightTensorRecord> = manifest
181            .tensors
182            .iter()
183            .filter(|t| by_name.contains_key(t.name.as_str()))
184            .collect();
185        if retained.is_empty() {
186            bail!(
187                "lora peer manifest matched none of the {} land targets",
188                targets.len()
189            );
190        }
191
192        // 2. Single-rail verbs handshake via RailSet (adapter = one shard, few
193        // MB). LoRA env: DEV chains LORA→WEIGHT→EXPERT (an exported-but-EMPTY
194        // var counts as set — `first_set`); GID reads ONLY ATLAS_LORA_RDMA_GID
195        // (no chain, deliberately). Always 1 rail; fresh random 24-bit PSN.
196        let specs = vec![RailSpec::new(
197            first_set(
198                &[
199                    "ATLAS_LORA_RDMA_DEV",
200                    "ATLAS_WEIGHT_RDMA_DEV",
201                    "ATLAS_EXPERT_RDMA_DEV",
202                ],
203                "roceP2p1s0f1",
204            ),
205            first_set_u32(&["ATLAS_LORA_RDMA_GID"], 3),
206            rand::random::<u32>() & 0xff_ffff,
207        )];
208
209        stream.write_all(&[MODE_VERBS]).context("send verbs mode")?;
210        let mut rs = RailSet::begin(&mut stream, &specs)?;
211
212        let max_len = retained.iter().map(|t| t.len).max().unwrap_or(0);
213        if max_len > u32::MAX as u64 {
214            bail!("adapter tensor {max_len} bytes exceeds single-WR RDMA READ limit");
215        }
216        let bounce_len = (max_len as usize).max(1);
217
218        let bounce = gpu
219            .alloc_host_pinned(bounce_len)
220            .context("alloc pinned RDMA landing bounce")?;
221        // LOCAL_WRITE-only landing MR (`remote_read == false`, invariant).
222        // SAFETY: `bounce` backs `bounce_len` pinned bytes that outlive the MR.
223        let keys = unsafe {
224            rs.rails[0]
225                .verbs
226                .reg_mr(bounce as *mut c_void, bounce_len, false)
227        }
228        .context("register RDMA landing bounce")?;
229
230        // Validate the shard table BEFORE replying (bail = no client params).
231        let server = rs
232            .read_server_ro(&mut stream)
233            .context("read verbs server params")?;
234        let sp = server[0].clone();
235        if sp.layers.len() != num_shards {
236            bail!(
237                "peer published {} shard MRs but manifest has {num_shards}",
238                sp.layers.len()
239            );
240        }
241
242        rs.complete(&mut stream, &server, "lora peer")?;
243        let mut verbs = rs
244            .into_verbs()
245            .into_iter()
246            .next()
247            .expect("single lora rail");
248
249        // 3. Pull each tensor into the bounce, convert/repack, land into slot.
250        for (idx, rec) in retained.iter().enumerate() {
251            let (shard_base, rkey) = *sp
252                .layers
253                .get(rec.shard_index as usize)
254                .with_context(|| format!("no shard MR {} for {}", rec.shard_index, rec.name))?;
255            let remote_addr = tensor_remote_addr(shard_base, rec.offset_in_shard);
256            let len = rec.len as usize;
257            let wr_id = idx as u64;
258            // SAFETY: bounce backs >= len pinned bytes in this MR; remote_addr/
259            // rkey address the peer's shard MR; len <= u32::MAX.
260            unsafe {
261                verbs
262                    .post_read(
263                        bounce as *mut c_void,
264                        keys.lkey,
265                        remote_addr,
266                        rkey,
267                        len as u32,
268                        wr_id,
269                    )
270                    .with_context(|| format!("post_read {}", rec.name))?;
271            }
272            match verbs.poll() {
273                Ok(got) if got == wr_id => {}
274                Ok(got) => bail!("completion wr_id {got:#x} != {wr_id:#x} ({})", rec.name),
275                Err(e) => return Err(e).with_context(|| format!("poll {}", rec.name)),
276            }
277            // SAFETY: bounce now holds `len` valid bytes from the READ.
278            let raw = unsafe { std::slice::from_raw_parts(bounce, len) };
279            let target = by_name[rec.name.as_str()];
280            let host = land_bytes_for_target(target, raw, &rec.dtype)?;
281            gpu.copy_h2d(&host, spark_runtime::gpu::DevicePtr(target.dst))?;
282        }
283
284        drop(verbs);
285        let _ = gpu.free_host_pinned(bounce, bounce_len);
286        tracing::info!(
287            "RDMA-staged adapter '{}' into {} slot targets",
288            manifest.model_id,
289            retained.len(),
290        );
291        Ok(())
292    }
293}
294
295#[cfg(test)]
296#[path = "weight_lora_rdma_tests.rs"]
297mod tests;