spark_runtime/weights/derived.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Load-time derived weight buffers, and who frees them.
4//!
5//! **WHY (#736, #915).** A loader that re-encodes a checkpoint tensor — a
6//! transposed twin, a row-concatenation of two projections, an NVFP4 requant —
7//! allocates a buffer that lives in a *layer struct*. Layer structs have no
8//! `Drop` that reaches the GPU and no `ModelResource`, so nothing frees them:
9//! `TransformerModel::release_pools` walks buffers, KV cache, SSM pools,
10//! `DerivedWeights` and the store, and every one of these falls through to
11//! `AtlasCudaBackend::sweep_unreleased`. On 1xH100, 2026-09-11,
12//! `Qwen/Qwen3.8-27B-FP8`, that sweep reclaimed **28.01 GB across 1,980
13//! allocations** and warned that each was "memory whose owner is unaccounted
14//! for". The sweep is a backstop, not an owner: it cannot run before the
15//! backend is torn down, so those bytes are unreclaimable for the life of the
16//! model even when the layer that holds them has been replaced.
17//!
18//! This is the owner. The [`WeightStore`](super::WeightStore) is the right home
19//! for it because these buffers are *derived from* store tensors, have exactly
20//! the store's lifetime (the layers read both until teardown), and the store is
21//! already the last `ModelResource` released before the sweep — so adopting a
22//! derived buffer here moves it from "swept" to "released", which is the
23//! difference the ledger reports.
24//!
25//! Interior mutability because loaders take `&WeightStore`: threading a `&mut`
26//! through `ModelWeightLoader::load_layers` would change the signature of every
27//! architecture's loader to fix a bookkeeping gap in one of them.
28
29use parking_lot::Mutex;
30
31use crate::gpu::{DevicePtr, GpuBackend};
32
33/// One adopted buffer: the pointer to free, its size, and a label for the
34/// residency report.
35#[derive(Clone, Copy, Debug)]
36struct Derived {
37 label: &'static str,
38 ptr: DevicePtr,
39 bytes: usize,
40}
41
42/// Device buffers a loader derived from this store's tensors.
43#[derive(Default)]
44pub struct DerivedStore {
45 // parking_lot: no poisoning, so a panic elsewhere cannot block teardown.
46 owned: Mutex<Vec<Derived>>,
47}
48
49impl DerivedStore {
50 /// Take ownership of a derived buffer.
51 ///
52 /// `label` is a static category ("ssm qkvz fp8 concat", "attn fp8 twin"),
53 /// not a per-tensor name: the report aggregates, and a `String` per buffer
54 /// would allocate once per layer per twin for text nobody reads per entry.
55 pub fn adopt(&self, label: &'static str, ptr: DevicePtr, bytes: usize) {
56 if ptr.0 == 0 {
57 return;
58 }
59 self.owned.lock().push(Derived { label, ptr, bytes });
60 }
61
62 /// Give up ownership of a buffer the caller is about to free itself.
63 ///
64 /// The transient half of a fused concat is adopted by the generic loader
65 /// that produced it and then freed by the loader that consumed it
66 /// (`qwen35_dense.rs`, the GDN `[QKV|Z]` arm). Without this the pointer
67 /// would be freed twice — once there, once at teardown. Returns the bytes
68 /// that left the ledger, or `None` if this store never held it.
69 pub fn disown(&self, ptr: DevicePtr) -> Option<usize> {
70 let mut owned = self.owned.lock();
71 let i = owned.iter().position(|d| d.ptr == ptr)?;
72 Some(owned.swap_remove(i).bytes)
73 }
74
75 /// Total adopted bytes still live.
76 pub fn bytes(&self) -> usize {
77 self.owned.lock().iter().map(|d| d.bytes).sum()
78 }
79
80 pub fn len(&self) -> usize {
81 self.owned.lock().len()
82 }
83
84 pub fn is_empty(&self) -> bool {
85 self.len() == 0
86 }
87
88 /// `label -> (bytes, count)`, biggest first, for the residency summary.
89 pub fn by_label(&self) -> Vec<(&'static str, usize, usize)> {
90 let mut rows: Vec<(&'static str, usize, usize)> = Vec::new();
91 for d in self.owned.lock().iter() {
92 match rows.iter_mut().find(|r| r.0 == d.label) {
93 Some(r) => {
94 r.1 += d.bytes;
95 r.2 += 1;
96 }
97 None => rows.push((d.label, d.bytes, 1)),
98 }
99 }
100 rows.sort_by(|a, b| b.1.cmp(&a.1));
101 rows
102 }
103
104 /// Free everything adopted. Drains first, so a failure part-way through
105 /// cannot leave a freed pointer in the list to be freed again.
106 pub fn release(&self, gpu: &dyn GpuBackend) -> anyhow::Result<()> {
107 let doomed: Vec<Derived> = self.owned.lock().drain(..).collect();
108 let mut first_error = None;
109 for d in doomed {
110 if let Err(e) = gpu.free(d.ptr)
111 && first_error.is_none()
112 {
113 first_error = Some(e.context(format!("freeing derived weight ({})", d.label)));
114 }
115 }
116 match first_error {
117 Some(e) => Err(e),
118 None => Ok(()),
119 }
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::gpu::mock::MockGpuBackend;
127
128 #[test]
129 fn an_adopted_buffer_is_freed_by_release() {
130 let gpu = MockGpuBackend::new();
131 let d = DerivedStore::default();
132 let a = gpu.alloc(1024).unwrap();
133 let b = gpu.alloc(2048).unwrap();
134 d.adopt("twin", a, 1024);
135 d.adopt("twin", b, 2048);
136 assert_eq!(d.len(), 2);
137 assert_eq!(d.bytes(), 3072);
138 d.release(&gpu).unwrap();
139 assert!(d.is_empty(), "release must drain, not merely free");
140 assert_eq!(d.bytes(), 0);
141 }
142
143 #[test]
144 fn release_is_idempotent() {
145 let gpu = MockGpuBackend::new();
146 let d = DerivedStore::default();
147 d.adopt("twin", gpu.alloc(64).unwrap(), 64);
148 d.release(&gpu).unwrap();
149 // A second release must not double-free: the list is already drained.
150 d.release(&gpu).unwrap();
151 }
152
153 #[test]
154 fn a_null_pointer_is_not_adopted() {
155 let d = DerivedStore::default();
156 d.adopt("twin", DevicePtr::NULL, 0);
157 assert!(d.is_empty(), "a NULL twin is 'not built', not 'owned'");
158 }
159
160 #[test]
161 fn disown_removes_the_buffer_so_release_cannot_double_free() {
162 let gpu = MockGpuBackend::new();
163 let d = DerivedStore::default();
164 let transient = gpu.alloc(512).unwrap();
165 d.adopt("transient", transient, 512);
166 assert_eq!(d.disown(transient), Some(512));
167 assert!(d.is_empty());
168 // A pointer this store never held is not silently "disowned".
169 assert_eq!(d.disown(transient), None);
170 }
171
172 #[test]
173 fn by_label_aggregates_biggest_first() {
174 let gpu = MockGpuBackend::new();
175 let d = DerivedStore::default();
176 d.adopt("small", gpu.alloc(16).unwrap(), 16);
177 d.adopt("big", gpu.alloc(1000).unwrap(), 1000);
178 d.adopt("big", gpu.alloc(1000).unwrap(), 1000);
179 assert_eq!(d.by_label(), vec![("big", 2000, 2), ("small", 16, 1)]);
180 }
181}