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;