spark_runtime/weights/
adapter.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! PEFT adapter loader: `adapter_model.safetensors` → [`WeightStore`].
4//!
5//! Not `SafetensorsLoader` because (a) that loader only probes
6//! `model.safetensors*` names (weights/loader.rs) and (b)
7//! `WeightDtype::from_safetensors` rejects F16 (weights.rs), the PEFT
8//! default save dtype. F16 is converted to BF16 on the host here so no
9//! F16 ever reaches a kernel or the WeightDtype whitelist.
10//!
11//! NOTE: the device copies made here become garbage once the adapter is
12//! packed into the fixed-address LoRA pool and are never freed (no weight
13//! dealloc anywhere in Atlas). Accepted leak at adapter scale (~MBs).
14
15use std::borrow::Cow;
16use std::collections::HashMap;
17use std::path::Path;
18
19use anyhow::{Context, Result, bail};
20use half::{bf16, f16};
21
22use super::{WeightDtype, WeightStore, WeightTensor, evict_page_cache};
23use crate::gpu::GpuBackend;
24
25/// Load a PEFT adapter's `adapter_model.safetensors` from `adapter_dir`
26/// onto the GPU. Mirrors the single-file path of `SafetensorsLoader`
27/// (mmap → per-tensor alloc + copy_h2d → page-cache evict) with a
28/// host-side F16→BF16 conversion branch added.
29pub fn load_adapter_safetensors(
30    adapter_dir: &Path,
31    gpu: &dyn GpuBackend,
32    oom_reserve_bytes: usize,
33) -> Result<WeightStore> {
34    let path = adapter_dir.join("adapter_model.safetensors");
35    if !path.exists() {
36        if adapter_dir.join("adapter_model.bin").exists() {
37            bail!(
38                "REJECT[pickle-adapter]: {} ships adapter_model.bin (torch pickle); \
39                 re-save with safe_serialization=True",
40                adapter_dir.display()
41            );
42        }
43        bail!("No adapter_model.safetensors in {}", adapter_dir.display());
44    }
45
46    // Header-only preflight (no mmap): F16 counts 2 B/elem — identical to
47    // its post-conversion BF16 footprint.
48    let estimated = super::estimate_load_bytes(std::slice::from_ref(&path), &|_| false)?;
49    let free = gpu.free_memory()?;
50    if estimated + oom_reserve_bytes > free {
51        bail!(
52            "OOM pre-flight (LoRA adapter): {estimated} B adapter tensors + \
53             {oom_reserve_bytes} B reserve exceeds {free} B free"
54        );
55    }
56
57    let file = std::fs::File::open(&path)?;
58    let mmap = unsafe { memmap2::MmapOptions::new().map(&file)? };
59    let tensors = safetensors::SafeTensors::deserialize(&mmap)?;
60
61    let mut weights = HashMap::new();
62    for (name, view) in tensors.tensors() {
63        let shape: Vec<usize> = view.shape().to_vec();
64        let data = view.data();
65        let (bytes, dtype): (Cow<'_, [u8]>, WeightDtype) = match view.dtype() {
66            safetensors::Dtype::F16 => {
67                // Host-side F16 -> BF16 (locked decision; `half = "2"` is
68                // already a spark-runtime dep).
69                let conv: Vec<u8> = data
70                    .chunks_exact(2)
71                    .flat_map(|c| {
72                        bf16::from_f32(f16::from_le_bytes([c[0], c[1]]).to_f32()).to_le_bytes()
73                    })
74                    .collect();
75                (Cow::Owned(conv), WeightDtype::BF16)
76            }
77            safetensors::Dtype::F32 => {
78                // Host-side F32 -> BF16. PEFT's DEFAULT LoRA save dtype is F32
79                // (the trainable adapter params stay fp32), so most real
80                // adapters land here. The pool pack (`lora/mod.rs`, BF16_BYTES)
81                // and `dense_gemv_bf16` assume BF16 unconditionally, so an F32
82                // tensor left as-is is read at half stride = garbage delta
83                // (silent: load succeeds, output corrupts). Convert here,
84                // mirroring the F16 branch. The BF16 fixture never exercised
85                // this path, which is why it hid.
86                let conv: Vec<u8> = data
87                    .chunks_exact(4)
88                    .flat_map(|c| {
89                        bf16::from_f32(f32::from_le_bytes([c[0], c[1], c[2], c[3]])).to_le_bytes()
90                    })
91                    .collect();
92                (Cow::Owned(conv), WeightDtype::BF16)
93            }
94            other => (
95                Cow::Borrowed(data),
96                WeightDtype::from_safetensors(other)
97                    .with_context(|| format!("LoRA adapter tensor '{name}'"))?,
98            ),
99        };
100        let ptr = gpu.alloc(bytes.len())?;
101        gpu.copy_h2d(&bytes, ptr)?;
102        weights.insert(name, WeightTensor { ptr, shape, dtype });
103    }
104
105    // Drop mmap before evicting page cache (GB10 unified memory).
106    drop(tensors);
107    drop(mmap);
108    evict_page_cache(&file);
109
110    Ok(WeightStore::from_map(weights))
111}
112
113// Gated on `feature = "cuda"`: the test constructs a real `AtlasCudaBackend`
114// (a CUDA-only module), so the metal / no-CUDA build must not compile it.
115#[cfg(all(test, feature = "cuda"))]
116mod tests {
117    use super::load_adapter_safetensors;
118    use crate::cuda_backend::AtlasCudaBackend;
119    use crate::gpu::GpuBackend; // brings copy_d2h into scope
120    use crate::weights::WeightDtype;
121    use half::bf16;
122    use safetensors::Dtype;
123    use safetensors::serialize_to_file;
124    use safetensors::tensor::TensorView;
125    use std::collections::HashMap;
126
127    /// Regression test for the PEFT-default-F32 → BF16 fix.
128    ///
129    /// PEFT saves LoRA adapters as F32 by default; before the fix
130    /// `load_adapter_safetensors` left them F32 while the pool pack +
131    /// `dense_gemv_bf16` read at BF16 stride = silent garbage delta. This
132    /// builds a real F32 `adapter_model.safetensors` (two PEFT-style tensors,
133    /// `lora_A` + `lora_B`), loads it on a live CUDA device, and asserts every
134    /// returned tensor is BF16 and round-trips bit-exact.
135    ///
136    /// Gated `#[ignore]` (Atlas convention) because `AtlasCudaBackend::new`
137    /// touches the CUDA driver; a GPU-less `cargo test` skips it. Opt in with
138    /// `-- --ignored`.
139    #[test]
140    #[ignore = "requires a free CUDA device (GB10)"]
141    fn f32_peft_adapter_loads_and_round_trips_as_bf16() {
142        // Values all exactly representable in bf16 (≤8-bit mantissa), so the
143        // F32 → BF16 conversion must round-trip bit-exact.
144        let a_vals: [f32; 8] = [1.0, -2.0, 0.5, 0.25, 3.0, -1.5, 0.0, 8.0];
145        let b_vals: [f32; 8] = [4.0, -0.75, 0.125, 16.0, -6.0, 2.0, 0.0625, -1.0];
146        // lora_A [r=2, in=4]; lora_B [out=4, r=2]. Raw little-endian F32 bytes.
147        let a_shape = vec![2usize, 4usize];
148        let b_shape = vec![4usize, 2usize];
149        let a_bytes: Vec<u8> = a_vals.iter().flat_map(|v| v.to_le_bytes()).collect();
150        let b_bytes: Vec<u8> = b_vals.iter().flat_map(|v| v.to_le_bytes()).collect();
151
152        // Realistic PEFT keys (the loader does not parse names — the layer
153        // allow-list lives downstream in spark-model — but keep them real).
154        let a_key = "base_model.model.model.layers.3.self_attn.k_proj.lora_A.weight";
155        let b_key = "base_model.model.model.layers.3.self_attn.k_proj.lora_B.weight";
156
157        // Unique tempdir with no extra dep (spark-runtime has no tempfile
158        // dev-dep): per-pid + per-thread subdir.
159        let dir = std::env::temp_dir().join(format!(
160            "atlas_adapter_test_{}_{:?}",
161            std::process::id(),
162            std::thread::current().id()
163        ));
164        std::fs::create_dir_all(&dir).unwrap();
165        let path = dir.join("adapter_model.safetensors");
166
167        let a_view = TensorView::new(Dtype::F32, a_shape.clone(), &a_bytes).unwrap();
168        let b_view = TensorView::new(Dtype::F32, b_shape.clone(), &b_bytes).unwrap();
169        let mut map: HashMap<String, TensorView> = HashMap::new();
170        map.insert(a_key.to_string(), a_view);
171        map.insert(b_key.to_string(), b_view);
172        serialize_to_file(map, None, &path).unwrap();
173
174        // Real GPU backend. The loader only allocs + copies (launches no
175        // kernel), but pass the codegen'd PTX set to mirror prod init.
176        let gpu = AtlasCudaBackend::new(0, &atlas_kernels::ptx_modules()).unwrap();
177
178        let store = load_adapter_safetensors(&dir, &gpu, 0).unwrap();
179
180        // (1) dtype: the F32 adapter must load as BF16 — the core of the fix.
181        for (key, shape, vals) in [(a_key, &a_shape, &a_vals), (b_key, &b_shape, &b_vals)] {
182            let t = store.get(key).unwrap();
183            assert_eq!(
184                t.dtype,
185                WeightDtype::BF16,
186                "F32 adapter tensor must load as BF16"
187            );
188            assert_eq!(&t.shape, shape);
189
190            // (2) values round-trip: read the 2-byte/elem BF16 back off device.
191            let mut back = vec![0u8; vals.len() * 2];
192            gpu.copy_d2h(t.ptr, &mut back).unwrap();
193            for (i, chunk) in back.chunks_exact(2).enumerate() {
194                let got = bf16::from_bits(u16::from_le_bytes([chunk[0], chunk[1]])).to_f32();
195                assert_eq!(got, vals[i], "tensor '{key}' elem {i} F32->BF16 round-trip");
196            }
197        }
198
199        let _ = std::fs::remove_dir_all(&dir);
200    }
201}