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