1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
//! Long-lived `MlxBufferPool` for static weight residency-set registration.
//!
//! # Purpose
//!
//! ADR-005 Wave 5b.7 iter 2 — adopt mlx-native's
//! [`MlxBufferPool::register_existing`] residency-only registration path
//! across hf2q's weight-loading hot path so static weight tensors join the
//! device's `MTLResidencySet`. Without `MTLResidencySet` membership, Metal
//! treats every weight buffer as a candidate for compaction/eviction and
//! pays cold-page-fault costs on first dispatch. With residency hints,
//! the OS keeps pages wired and the cold first-forward pays the
//! ~17 GB DMA-from-disk cost only.
//!
//! # Why a separate pool from `decode_pool::DECODE_POOL`
//!
//! The decode pool is per-token: it bucket-rounds allocations to
//! `next_power_of_two`, bulk-recycles via [`reset`](MlxBufferPool::reset)
//! on every token, and serves transient activation buffers (~1750
//! allocs/token). Routing static weights through that bucketing path
//! would inflate the 17.26 GB Qwen3.6 27B DWQ46 weight set to ~25.55 GB
//! (+48% / +8.30 GB) — unshippable on a 128 GB unified-memory M5 Max
//! (Wave 5b.6 STOP report).
//!
//! Instead, weights are still allocated at their exact size via
//! [`MlxDevice::alloc_buffer`] (no rounding) and only their residency-set
//! membership is tracked through the pool via
//! [`MlxBufferPool::register_existing`]: the pool **does not** take
//! ownership and **does not** recycle these buffers. The caller's
//! `MlxBuffer` (held in `ForwardGpuCache`) remains the canonical owner.
//!
//! # Lifecycle
//!
//! * The pool is initialized lazily on first [`register_weight_buffer`]
//! call.
//! * Weight `MlxBuffer`s are allocated via `device.alloc_buffer(...)` (or
//! loaded via `gguf.load_tensor(...)`) **as before**, then registered
//! via [`register_weight_buffer`] before being stored in
//! `ForwardGpuCache`.
//! * The pool lives for the lifetime of the thread (`thread_local!`).
//! Because forward passes run on a single owning thread (per
//! `feedback_oom_prevention`: one model-loading inference at a time),
//! the pool effectively spans every forward call — the residency hint
//! stays in place across all dispatches.
//! * On thread teardown, the pool's `Drop` runs `remove_all_residency_allocations`;
//! the underlying `metal::Buffer` ARCs are still held by the caller's
//! `MlxBuffer` handles and are not freed.
//!
//! # `HF2Q_NO_RESIDENCY=1` escape hatch
//!
//! When `HF2Q_NO_RESIDENCY=1` is set in the environment, the
//! [`MlxDevice::new`] constructor in mlx-native returns a device with
//! `residency_set: None`. In that mode [`MlxBufferPool::register_existing`]
//! returns `Ok(())` as a no-op — operators who suspect a residency-induced
//! regression can opt out without recompiling.
//!
//! # Soundness contract
//!
//! No `MlxBuffer` whose underlying `metal::Buffer` was registered via
//! [`register_weight_buffer`] may be dropped before the
//! `ForwardGpuCache`'s pool reference goes away. In practice this is
//! trivial: both the buffers and the pool live for the program lifetime
//! (the cache is rebuilt only on model swap, and mlx-native's pool `Drop`
//! correctly cleans up residency-set membership before the device is
//! dropped).
use RefCell;
use ;
thread_local!
/// Register `buffer`'s underlying Metal allocation with the thread-local
/// weight pool's residency set.
///
/// API-compatible no-op when `HF2Q_NO_RESIDENCY=1` is set. Idempotent:
/// re-registering the same buffer is a HashMap lookup.
///
/// The pool does **not** take ownership of `buffer` — the caller retains
/// the `MlxBuffer` handle and is responsible for keeping it alive for as
/// long as the residency hint should stay active.
///
/// # Multi-device tolerance (W-5b.7 iter 2)
///
/// `MlxBufferPool::register_existing` enforces a single-`ResidencySet`
/// invariant — every buffer registered with one pool must come from
/// `MlxDevice` instances whose `ResidencySet` Arcs are pointer-equal.
/// hf2q's current architecture creates multiple `MlxDevice` instances
/// (one in `serve::gpu::GpuContext`, one in `forward_gpu`'s `GPU_CACHE`
/// init, one inside `in_memory_loader::quantize_f32_to_q8_0_buffer`'s
/// caller, …); each has its own `ResidencySet`. The first call to
/// `register_weight_buffer` claims the pool for its device's residency
/// set; subsequent calls from a different device fail mlx-native's
/// `same_owner` check with `MlxError::InvalidArgument("MlxBufferPool
/// cannot mix residency-enabled devices")`.
///
/// We treat that mismatch as a *tolerated soft fallback*: the buffer
/// stays unregistered (no residency hint) but loading continues
/// successfully. In practice the dominant ~14 GB MoE / dense weight
/// slice loaded inside `forward_gpu`'s cache init all uses **one**
/// device, so it claims the pool and gets full residency benefit; the
/// smaller cross-device slice (Q8_0 quantize, MTP norms, etc.) falls
/// back transparently. An iter-3 architectural refactor consolidating
/// hf2q on a single shared `MlxDevice` would eliminate the soft fallback
/// and let the remaining ~3 GB also join a residency set.
/// Diagnostic accessor: number of buffers tracked in the residency set
/// (i.e. number of distinct `register_weight_buffer` callers whose buffers
/// are still pointing at unique Metal allocations).
///
/// Note: this counts unique `metal::Buffer.contents()` pointers — re-registering
/// the same buffer does not increase the count.