spark_model/layers/ops/
derived_weights.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GPU allocations derived from a model's weights, memoized for that model's
4//! lifetime.
5//!
6//! Several projection paths need a re-encoded copy of a weight — block-scaled
7//! FP8 dequantized to BF16, re-quantized to row-wise FP8, an NVFP4 weight
8//! transposed into the CUTLASS byte layout. Producing one costs a kernel launch
9//! and an allocation, and the source weights are immutable after load, so the
10//! result is memoized by source pointer.
11//!
12//! **The memo used to live in four `static OnceLock<Mutex<HashMap<u64, …>>>`,
13//! and the key is a raw device pointer.** That is the worst possible key for a
14//! global. Free a model's weights, load another, and the allocator can hand
15//! back the same addresses — at which point a lookup does not miss, it *hits*,
16//! and returns a pointer to a re-encoding of a weight that no longer exists.
17//! Not a crash: a plausible pointer to the wrong numbers.
18//!
19//! Owning the cache alongside the weights removes the failure mode rather than
20//! guarding it. The map is dropped when the model is, so an entry cannot
21//! outlive the allocation it describes, and no key can be recycled into it.
22
23use std::collections::HashMap;
24// parking_lot: no poisoning, so teardown cannot be blocked by a panic that
25// happened somewhere else entirely.
26use parking_lot::Mutex;
27
28/// Per-model memo of derived weight encodings.
29///
30/// One map per derivation rather than one keyed by `(ptr, kind)`: the value
31/// types differ, and a wrong-kind hit would be exactly the class of bug this
32/// type exists to remove.
33#[derive(Default)]
34pub struct DerivedWeights {
35    /// FP8 weight ptr → `(row-wise FP8 ptr, per-row scale ptr)`.
36    rowwise_fp8: Mutex<HashMap<u64, (u64, u64)>>,
37    /// FP8 weight ptr → BF16 ptr.
38    bf16: Mutex<HashMap<u64, u64>>,
39    /// NVFP4 weight ptr → CUTLASS-layout transposed ptr.
40    cutlass_nvfp4_t: Mutex<HashMap<u64, u64>>,
41    /// FP8 weight ptr → `(CUTLASS NVFP4 ptr, scale ptr)`.
42    cutlass_nvfp4_from_fp8: Mutex<HashMap<u64, (u64, u64)>>,
43}
44
45/// Which derivation a lookup is for.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum Derivation {
48    RowwiseFp8,
49    Bf16,
50    CutlassNvfp4Transposed,
51    CutlassNvfp4FromFp8,
52}
53
54impl DerivedWeights {
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Raw memo read. `get_or_build_ptr` is the form to prefer; this pair
60    /// exists because the dispatch helpers allocate and launch between the
61    /// lookup and the store, and expressing that as a closure would mean
62    /// indenting several hundred lines of kernel-launch code for no gain.
63    pub fn get_ptr(&self, kind: Derivation, key: u64) -> Option<u64> {
64        let map = match kind {
65            Derivation::Bf16 => &self.bf16,
66            Derivation::CutlassNvfp4Transposed => &self.cutlass_nvfp4_t,
67            _ => return None,
68        };
69        map.lock().get(&key).copied()
70    }
71
72    pub fn insert_ptr(&self, kind: Derivation, key: u64, value: u64) {
73        let map = match kind {
74            Derivation::Bf16 => &self.bf16,
75            Derivation::CutlassNvfp4Transposed => &self.cutlass_nvfp4_t,
76            _ => return,
77        };
78        map.lock().entry(key).or_insert(value);
79    }
80
81    pub fn get_pair(&self, kind: Derivation, key: u64) -> Option<(u64, u64)> {
82        let map = match kind {
83            Derivation::RowwiseFp8 => &self.rowwise_fp8,
84            Derivation::CutlassNvfp4FromFp8 => &self.cutlass_nvfp4_from_fp8,
85            _ => return None,
86        };
87        map.lock().get(&key).copied()
88    }
89
90    pub fn insert_pair(&self, kind: Derivation, key: u64, value: (u64, u64)) {
91        let map = match kind {
92            Derivation::RowwiseFp8 => &self.rowwise_fp8,
93            Derivation::CutlassNvfp4FromFp8 => &self.cutlass_nvfp4_from_fp8,
94            _ => return,
95        };
96        map.lock().entry(key).or_insert(value);
97    }
98
99    /// Look up a single-pointer derivation, computing it on a miss.
100    ///
101    /// `build` runs outside the lock: it launches kernels and allocates, and
102    /// holding a `Mutex` across that would serialise every layer's first touch
103    /// of a weight behind one another. A duplicate build under a race wastes an
104    /// allocation, which is why the loser's value is kept rather than swapped —
105    /// the first writer's pointer is the one other threads may already hold.
106    pub fn get_or_build_ptr(
107        &self,
108        kind: Derivation,
109        key: u64,
110        build: impl FnOnce() -> anyhow::Result<u64>,
111    ) -> anyhow::Result<u64> {
112        let map = match kind {
113            Derivation::Bf16 => &self.bf16,
114            Derivation::CutlassNvfp4Transposed => &self.cutlass_nvfp4_t,
115            _ => unreachable!("pair-valued derivation routed to get_or_build_ptr"),
116        };
117        if let Some(&hit) = map.lock().get(&key) {
118            return Ok(hit);
119        }
120        let built = build()?;
121        Ok(*map.lock().entry(key).or_insert(built))
122    }
123
124    /// Look up a pair-valued derivation, computing it on a miss.
125    pub fn get_or_build_pair(
126        &self,
127        kind: Derivation,
128        key: u64,
129        build: impl FnOnce() -> anyhow::Result<(u64, u64)>,
130    ) -> anyhow::Result<(u64, u64)> {
131        let map = match kind {
132            Derivation::RowwiseFp8 => &self.rowwise_fp8,
133            Derivation::CutlassNvfp4FromFp8 => &self.cutlass_nvfp4_from_fp8,
134            _ => unreachable!("single-valued derivation routed to get_or_build_pair"),
135        };
136        if let Some(&hit) = map.lock().get(&key) {
137            return Ok(hit);
138        }
139        let built = build()?;
140        Ok(*map.lock().entry(key).or_insert(built))
141    }
142
143    /// Total memoized entries, for diagnostics and for asserting in tests that
144    /// a fresh model starts empty.
145    pub fn len(&self) -> usize {
146        self.rowwise_fp8.lock().len()
147            + self.bf16.lock().len()
148            + self.cutlass_nvfp4_t.lock().len()
149            + self.cutlass_nvfp4_from_fp8.lock().len()
150    }
151
152    pub fn is_empty(&self) -> bool {
153        self.len() == 0
154    }
155}
156
157impl std::fmt::Debug for DerivedWeights {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("DerivedWeights")
160            .field("entries", &self.len())
161            .finish()
162    }
163}
164
165/// Release every derived allocation.
166///
167/// The KEYS are the original weight pointers — owned by the `WeightStore` and
168/// freed by it. Only the VALUES are derivations this cache allocated, so only
169/// those are freed here. Freeing a key would be a double-free of a weight.
170impl atlas_core::scope::ModelResource<dyn spark_runtime::gpu::GpuBackend> for DerivedWeights {
171    fn label(&self) -> &'static str {
172        "derived weights"
173    }
174
175    fn release(&mut self, gpu: &dyn spark_runtime::gpu::GpuBackend) -> anyhow::Result<()> {
176        let mut owned: Vec<u64> = Vec::new();
177        // Drain so a later lookup cannot hit a pointer into freed memory —
178        // the exact failure this cache was restructured to make impossible.
179        for (_, (a, b)) in self.rowwise_fp8.lock().drain() {
180            owned.push(a);
181            owned.push(b);
182        }
183        owned.extend(self.bf16.lock().drain().map(|(_, v)| v));
184        owned.extend(self.cutlass_nvfp4_t.lock().drain().map(|(_, v)| v));
185        for (_, (a, b)) in self.cutlass_nvfp4_from_fp8.lock().drain() {
186            owned.push(a);
187            owned.push(b);
188        }
189        let mut first_error = None;
190        for raw in owned {
191            if let Err(e) = gpu.free(spark_runtime::gpu::DevicePtr(raw))
192                && first_error.is_none()
193            {
194                first_error = Some(e);
195            }
196        }
197        match first_error {
198            Some(e) => Err(e),
199            None => Ok(()),
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use atlas_core::scope::ModelResource;
208    use spark_runtime::gpu::GpuBackend;
209    use spark_runtime::gpu::mock::MockGpuBackend;
210    use std::sync::atomic::{AtomicUsize, Ordering};
211
212    #[test]
213    fn a_fresh_cache_is_empty() {
214        assert!(DerivedWeights::new().is_empty());
215    }
216
217    #[test]
218    fn a_derivation_is_built_once_per_key() {
219        let d = DerivedWeights::new();
220        let builds = AtomicUsize::new(0);
221        let build_for = |ptr: u64| {
222            d.get_or_build_ptr(Derivation::Bf16, ptr, || {
223                builds.fetch_add(1, Ordering::Relaxed);
224                Ok(ptr + 1000)
225            })
226            .unwrap()
227        };
228        assert_eq!(build_for(10), 1010);
229        assert_eq!(build_for(10), 1010);
230        assert_eq!(builds.load(Ordering::Relaxed), 1);
231        assert_eq!(build_for(20), 1020);
232        assert_eq!(builds.load(Ordering::Relaxed), 2);
233        assert_eq!(d.len(), 2);
234    }
235
236    #[test]
237    fn the_derivations_do_not_share_a_keyspace() {
238        // Same source pointer, two different encodings. A single map keyed by
239        // pointer alone would return one for the other.
240        let d = DerivedWeights::new();
241        let bf16 = d
242            .get_or_build_ptr(Derivation::Bf16, 0x1000, || Ok(0xB16))
243            .unwrap();
244        let nvfp4 = d
245            .get_or_build_ptr(Derivation::CutlassNvfp4Transposed, 0x1000, || Ok(0x4444))
246            .unwrap();
247        assert_eq!(bf16, 0xB16);
248        assert_eq!(nvfp4, 0x4444);
249        assert_eq!(d.len(), 2);
250    }
251
252    #[test]
253    fn a_failed_build_is_not_memoized() {
254        let d = DerivedWeights::new();
255        assert!(
256            d.get_or_build_ptr(Derivation::Bf16, 7, || anyhow::bail!("oom"))
257                .is_err()
258        );
259        assert!(d.is_empty(), "a failure must not poison the key");
260        assert_eq!(
261            d.get_or_build_ptr(Derivation::Bf16, 7, || Ok(99)).unwrap(),
262            99
263        );
264    }
265
266    #[test]
267    fn two_models_memoize_independently_even_on_a_recycled_pointer() {
268        // The property the four statics could not have. Model A caches a
269        // derivation at 0x7f00_0000; A is released; B's allocator hands back
270        // the same address for a DIFFERENT weight.
271        let a = DerivedWeights::new();
272        let recycled = 0x7f00_0000u64;
273        assert_eq!(
274            a.get_or_build_ptr(Derivation::Bf16, recycled, || Ok(0xAAAA))
275                .unwrap(),
276            0xAAAA
277        );
278        drop(a);
279
280        let b = DerivedWeights::new();
281        assert_eq!(
282            b.get_or_build_ptr(Derivation::Bf16, recycled, || Ok(0xBBBB))
283                .unwrap(),
284            0xBBBB,
285            "the same address must resolve to the NEW model's derivation"
286        );
287    }
288
289    #[test]
290    fn pair_derivations_round_trip_without_sharing_a_keyspace() {
291        let d = DerivedWeights::new();
292        let got = d
293            .get_or_build_pair(Derivation::RowwiseFp8, 5, || Ok((11, 22)))
294            .unwrap();
295        assert_eq!(got, (11, 22));
296        assert_eq!(
297            d.get_or_build_pair(Derivation::CutlassNvfp4FromFp8, 5, || Ok((33, 44)))
298                .unwrap(),
299            (33, 44)
300        );
301        assert_eq!(
302            d.get_or_build_pair(Derivation::RowwiseFp8, 5, || Ok((99, 99)))
303                .unwrap(),
304            (11, 22),
305            "cached, not rebuilt"
306        );
307        assert_eq!(d.len(), 2);
308    }
309
310    #[test]
311    fn release_frees_only_derived_values_and_drains_every_map() {
312        let gpu = MockGpuBackend::new();
313        let mut d = DerivedWeights::new();
314        let keys: Vec<u64> = (0..4).map(|_| gpu.alloc(1).unwrap().0).collect();
315        let values: Vec<u64> = (0..6).map(|_| gpu.alloc(1).unwrap().0).collect();
316
317        d.insert_pair(Derivation::RowwiseFp8, keys[0], (values[0], values[1]));
318        d.insert_ptr(Derivation::Bf16, keys[1], values[2]);
319        d.insert_ptr(Derivation::CutlassNvfp4Transposed, keys[2], values[3]);
320        d.insert_pair(
321            Derivation::CutlassNvfp4FromFp8,
322            keys[3],
323            (values[4], values[5]),
324        );
325        assert_eq!(gpu.alloc_count(), 10);
326        assert_eq!(d.len(), 4);
327
328        d.release(&gpu).unwrap();
329
330        assert!(d.is_empty(), "freed pointers must not remain memoized");
331        assert_eq!(gpu.alloc_count(), 4, "source-weight keys remain GPU-owned");
332        for key in keys {
333            gpu.free(spark_runtime::gpu::DevicePtr(key)).unwrap();
334        }
335    }
336}