metrale_model_weights/
weight_lora_rdma.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
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// (`metrale-gpu-runtime .../adapter.rs`) and the SAME B row-repack (stride r →
15// max_rank) the pool pack does (`metrale-model-layers .../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// metrale-model-layers (`lora::rdma_stage`, the only place `classify_key` + slot offsets
21// live) and passed in as `&[LoraLandTarget]`, keeping this crate free of any
22// metrale-model-layers dependency (metrale-model-layers → metrale-model-weights is the acyclic direction).
23//
24// Like `weight_tier_rdma`, the verbs data path is gated on `metrale_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 `$METRALE_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(metrale_rdma_verbs)))]
130impl RdmaLoraLoader {
131    /// Stub: no rdma-core in this build.
132    pub fn stage_into_slot(
133        &self,
134        _gpu: &dyn metrale_gpu_runtime::gpu::GpuBackend,
135        _targets: &[LoraLandTarget],
136    ) -> Result<()> {
137        anyhow::bail!(
138            "$METRALE_LORA_PEER is set but this build has no rdma-core (metrale_rdma_verbs \
139             cfg); rebuild with rdma-core, or unset METRALE_LORA_PEER to rotate from disk"
140        )
141    }
142}
143
144#[cfg(all(feature = "cuda", metrale_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 metrale_gpu_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 metrale_gpu_sys::env::{first_set, first_set_u32};
162        use metrale_gpu_sys::railset::{RailSet, RailSpec};
163        use metrale_storage::expert_peer::MODE_VERBS;
164        use metrale_storage::weight_peer::{
165            read_weight_manifest, tensor_remote_addr, write_model_request,
166        };
167
168        let by_name: HashMap<&str, &LoraLandTarget> = targets
169            .iter()
170            .map(|t| (t.tensor_name.as_str(), t))
171            .collect();
172
173        // 1. Connect + request the adapter + read the manifest.
174        let mut stream = TcpStream::connect(&self.peer_addr)
175            .with_context(|| format!("connect lora peer {}", self.peer_addr))?;
176        stream.set_nodelay(true).ok();
177        write_model_request(&mut stream, &self.adapter_id).context("send adapter request")?;
178        let manifest = read_weight_manifest(&mut stream).context("read adapter manifest")?;
179        let num_shards = manifest.num_shards();
180
181        // Only the tensors we have a landing target for (all lora_A/lora_B).
182        let retained: Vec<&metrale_storage::weight_peer::WeightTensorRecord> = manifest
183            .tensors
184            .iter()
185            .filter(|t| by_name.contains_key(t.name.as_str()))
186            .collect();
187        if retained.is_empty() {
188            bail!(
189                "lora peer manifest matched none of the {} land targets",
190                targets.len()
191            );
192        }
193
194        // 2. Single-rail verbs handshake via RailSet (adapter = one shard, few
195        // MB). LoRA env: DEV chains LORA→WEIGHT→EXPERT (an exported-but-EMPTY
196        // var counts as set — `first_set`); GID reads ONLY METRALE_LORA_RDMA_GID
197        // (no chain, deliberately). Always 1 rail; fresh random 24-bit PSN.
198        let specs = vec![RailSpec::new(
199            first_set(
200                &[
201                    "METRALE_LORA_RDMA_DEV",
202                    "METRALE_WEIGHT_RDMA_DEV",
203                    "METRALE_EXPERT_RDMA_DEV",
204                ],
205                "roceP2p1s0f1",
206            ),
207            first_set_u32(&["METRALE_LORA_RDMA_GID"], 3),
208            rand::random::<u32>() & 0xff_ffff,
209        )];
210
211        stream.write_all(&[MODE_VERBS]).context("send verbs mode")?;
212        let mut rs = RailSet::begin(&mut stream, &specs)?;
213
214        let max_len = retained.iter().map(|t| t.len).max().unwrap_or(0);
215        if max_len > u32::MAX as u64 {
216            bail!("adapter tensor {max_len} bytes exceeds single-WR RDMA READ limit");
217        }
218        let bounce_len = (max_len as usize).max(1);
219
220        let bounce = gpu
221            .alloc_host_pinned(bounce_len)
222            .context("alloc pinned RDMA landing bounce")?;
223        // LOCAL_WRITE-only landing MR (`remote_read == false`, invariant).
224        // SAFETY: `bounce` backs `bounce_len` pinned bytes that outlive the MR.
225        let keys = unsafe {
226            rs.rails[0]
227                .verbs
228                .reg_mr(bounce as *mut c_void, bounce_len, false)
229        }
230        .context("register RDMA landing bounce")?;
231
232        // Validate the shard table BEFORE replying (bail = no client params).
233        let server = rs
234            .read_server_ro(&mut stream)
235            .context("read verbs server params")?;
236        let sp = server[0].clone();
237        if sp.layers.len() != num_shards {
238            bail!(
239                "peer published {} shard MRs but manifest has {num_shards}",
240                sp.layers.len()
241            );
242        }
243
244        rs.complete(&mut stream, &server, "lora peer")?;
245        let mut verbs = rs
246            .into_verbs()
247            .into_iter()
248            .next()
249            .expect("single lora rail");
250
251        // 3. Pull each tensor into the bounce, convert/repack, land into slot.
252        for (idx, rec) in retained.iter().enumerate() {
253            let (shard_base, rkey) = *sp
254                .layers
255                .get(rec.shard_index as usize)
256                .with_context(|| format!("no shard MR {} for {}", rec.shard_index, rec.name))?;
257            let remote_addr = tensor_remote_addr(shard_base, rec.offset_in_shard);
258            let len = rec.len as usize;
259            let wr_id = idx as u64;
260            // SAFETY: bounce backs >= len pinned bytes in this MR; remote_addr/
261            // rkey address the peer's shard MR; len <= u32::MAX.
262            unsafe {
263                verbs
264                    .post_read(
265                        bounce as *mut c_void,
266                        keys.lkey,
267                        remote_addr,
268                        rkey,
269                        len as u32,
270                        wr_id,
271                    )
272                    .with_context(|| format!("post_read {}", rec.name))?;
273            }
274            match verbs.poll() {
275                Ok(got) if got == wr_id => {}
276                Ok(got) => bail!("completion wr_id {got:#x} != {wr_id:#x} ({})", rec.name),
277                Err(e) => return Err(e).with_context(|| format!("poll {}", rec.name)),
278            }
279            // SAFETY: bounce now holds `len` valid bytes from the READ.
280            let raw = unsafe { std::slice::from_raw_parts(bounce, len) };
281            let target = by_name[rec.name.as_str()];
282            let host = land_bytes_for_target(target, raw, &rec.dtype)?;
283            gpu.copy_h2d(&host, metrale_gpu_runtime::gpu::DevicePtr(target.dst))?;
284        }
285
286        drop(verbs);
287        let _ = gpu.free_host_pinned(bounce, bounce_len);
288        tracing::info!(
289            "RDMA-staged adapter '{}' into {} slot targets",
290            manifest.model_id,
291            retained.len(),
292        );
293        Ok(())
294    }
295}
296
297#[cfg(test)]
298#[path = "weight_lora_rdma_tests.rs"]
299mod tests;