Skip to main content

mlx_native/
buffer_pool.rs

1//! [`MlxBufferPool`] — arena-style GPU buffer allocator with reuse.
2//!
3//! Buffers are bucketed by power-of-two sizes.  When a buffer is released back
4//! to the pool, it is added to the free list for its size bucket.  A subsequent
5//! `alloc` call will reuse a free buffer of compatible (>= requested) size
6//! rather than allocating new Metal memory.
7//!
8//! Two return-path patterns are supported and **must not be mixed within a
9//! single arena cycle**:
10//!
11//! * **Per-buffer** via [`release`](MlxBufferPool::release) — explicit return
12//!   of a single buffer to the free list, suitable for ad-hoc patterns where
13//!   the caller knows the precise lifetime of each buffer.
14//! * **Arena bulk** via [`reset`](MlxBufferPool::reset) — bulk-return of every
15//!   buffer handed out by [`alloc`](MlxBufferPool::alloc) since the previous
16//!   reset.  Suitable for per-inference / per-decode-token arena patterns
17//!   where no individual buffer's lifetime crosses the reset boundary.
18//!
19//! Internally, every `alloc` records an ARC-cloned `metal::Buffer` handle so
20//! that `reset` can bulk-recycle without requiring callers to enumerate every
21//! buffer individually.  ARC retain on `metal::Buffer` is cheap (refcount inc).
22
23use std::collections::HashMap;
24
25use crate::buffer::MlxBuffer;
26use crate::device::MlxDevice;
27use crate::dtypes::DType;
28use crate::error::{MlxError, Result};
29
30/// Arena-style buffer pool that reuses Metal buffer allocations.
31///
32/// # Design
33///
34/// * Buffers are bucketed by their allocated size rounded up to the nearest
35///   power of two.  This reduces fragmentation at the cost of occasionally
36///   over-allocating by up to 2x.
37/// * `release()` returns a single buffer; `reset()` returns all outstanding
38///   buffers handed out since the last reset.
39/// * The `MlxDevice` is passed in at every [`alloc`] call (rather than stored
40///   in the pool).  This keeps the pool free of lifetime parameters so it
41///   can be embedded in any owner struct (e.g. the per-decode-token
42///   `DecodeBuffers` cache in hf2q's qwen35 forward path).
43///
44/// # Why an arena reset matters
45///
46/// In the per-decode-token hot path, each token allocates ~1750 Metal buffers
47/// for scratch / intermediate / parameter storage across attention, FFN, and
48/// linear-attention layers.  Direct `MlxDevice::alloc_buffer()` calls hit
49/// Metal's allocator each time (5-30 µs each); pooling reuses the underlying
50/// `metal::Buffer` objects across token boundaries so steady-state allocation
51/// cost amortizes to near zero.  See ADR-012 §Optimize / Task #15 for the
52/// MoE dwq46 0.90× parity gap that motivated this work.
53pub struct MlxBufferPool {
54    /// Free buffers keyed by their power-of-two bucket size.
55    free: HashMap<usize, Vec<metal::Buffer>>,
56    /// Buffers handed out by [`alloc`] since the last [`reset`].  Each entry
57    /// holds an ARC-cloned `metal::Buffer` so the pool's reference keeps the
58    /// underlying GPU allocation alive even after the caller's `MlxBuffer`
59    /// goes out of scope.  [`reset`] drains this into [`free`].
60    in_use: Vec<(usize, metal::Buffer)>,
61    /// Residency set that owns the allocations registered by this pool.
62    residency_set: Option<crate::residency::ResidencySet>,
63    /// Unique Metal buffers this pool added to the residency set, keyed by
64    /// their stable contents pointer. This avoids double-removing buffers if
65    /// callers mix release/reset despite that pattern being unsupported.
66    resident_buffers: HashMap<usize, metal::Buffer>,
67}
68
69impl Default for MlxBufferPool {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl MlxBufferPool {
76    /// Create a new empty buffer pool.  The Metal device is passed to
77    /// [`alloc`] at every call site, so the pool itself is lifetime-free.
78    pub fn new() -> Self {
79        Self {
80            free: HashMap::new(),
81            in_use: Vec::new(),
82            residency_set: None,
83            resident_buffers: HashMap::new(),
84        }
85    }
86
87    /// Allocate a buffer from the pool.
88    ///
89    /// If a free buffer of compatible size exists in the pool, it is reused
90    /// (with updated dtype/shape metadata).  Otherwise a new Metal buffer is
91    /// allocated from `device` at the bucket size so future reuse is
92    /// possible for any request up to that bucket.
93    ///
94    /// Each successful `alloc` registers the buffer in the pool's in-use
95    /// list (ARC clone — cheap), so a subsequent [`reset`] returns it to
96    /// the free list automatically.
97    pub fn alloc(
98        &mut self,
99        device: &MlxDevice,
100        byte_len: usize,
101        dtype: DType,
102        shape: Vec<usize>,
103    ) -> Result<MlxBuffer> {
104        let (buffer, added_residency) = self.alloc_inner(device, byte_len, dtype, shape)?;
105        if added_residency {
106            if let Some(set) = self.residency_set.as_ref() {
107                set.commit();
108            }
109        }
110        Ok(buffer)
111    }
112
113    /// Allocate several buffers and commit residency-set updates once.
114    pub fn alloc_batch<I>(&mut self, device: &MlxDevice, requests: I) -> Result<Vec<MlxBuffer>>
115    where
116        I: IntoIterator<Item = (usize, DType, Vec<usize>)>,
117    {
118        let mut buffers = Vec::new();
119        let mut added_residency = false;
120
121        for (byte_len, dtype, shape) in requests {
122            let (buffer, added) = self.alloc_inner(device, byte_len, dtype, shape)?;
123            added_residency |= added;
124            buffers.push(buffer);
125        }
126
127        if added_residency {
128            if let Some(set) = self.residency_set.as_ref() {
129                set.commit();
130            }
131        }
132
133        Ok(buffers)
134    }
135
136    fn alloc_inner(
137        &mut self,
138        device: &MlxDevice,
139        byte_len: usize,
140        dtype: DType,
141        shape: Vec<usize>,
142    ) -> Result<(MlxBuffer, bool)> {
143        let bucket = bucket_size(byte_len);
144        let mut added_residency = false;
145
146        // Try to reuse a free buffer from this bucket.
147        let metal_buf = self
148            .free
149            .get_mut(&bucket)
150            .and_then(|free_list| free_list.pop());
151
152        let metal_buf = match metal_buf {
153            Some(b) => b,
154            None => {
155                // Fresh allocation at bucket size.
156                let raw = device
157                    .metal_device()
158                    .new_buffer(bucket as u64, metal::MTLResourceOptions::StorageModeShared);
159                if raw.contents().is_null() {
160                    return Err(MlxError::BufferAllocationError { bytes: bucket });
161                }
162                // ADR-015 iter61a-2 (broken-window B-W-1 residual fix): zero-init
163                // every fresh pool allocation. The same MTLResourceOptions::
164                // StorageModeShared recycling that affects MlxDevice::alloc_buffer
165                // (closed in iter61a, src/device.rs) ALSO affects the pool's
166                // fresh-allocation path. iter61a closed device-direct allocations
167                // but the per-decode-token / per-prefill-chunk arena pool grew its
168                // free list via this `new_buffer` call without zero-init — so on
169                // the FIRST cold-process prefill (where the pool is empty and
170                // every alloc takes the fresh path), kernels reading scratch /
171                // intermediate buffers before fully populating them propagated
172                // recycled-page garbage into logits. Empirically: 5/5 cold-run
173                // first-token logit dumps on 27B-dwq46 produced 5 distinct
174                // hashes, with max abs logit diff up to 5.06 across runs and
175                // 248044/248044 logits differing — far above kernel-reduction
176                // ULP noise, consistent with structural memory contamination.
177                //
178                // Cost: one memset per fresh allocation. Reused buffers (the
179                // steady-state hot path after warm-up) skip this entirely
180                // because their bytes are valid producer outputs from prior
181                // pool cycles.
182                //
183                // Safety: `raw.contents()` is non-null (verified above), points
184                // to exactly `bucket` bytes of `StorageModeShared` memory we
185                // just allocated and have exclusive access to. The buffer is
186                // not yet wrapped in `MlxBuffer` and not yet in `in_use` /
187                // residency set, so no other thread or GPU dispatch references
188                // it. Writing zero bytes is well-defined for any DType.
189                unsafe {
190                    std::ptr::write_bytes(raw.contents() as *mut u8, 0, bucket);
191                }
192                added_residency = self.register_residency_allocation(device, &raw)?;
193                raw
194            }
195        };
196
197        // Track the handout so reset() can recycle it.  ARC clone is cheap.
198        self.in_use.push((bucket, metal_buf.clone()));
199
200        Ok((MlxBuffer::from_raw(metal_buf, dtype, shape), added_residency))
201    }
202
203    /// Return a single buffer to the pool's free list for future reuse.
204    ///
205    /// The Metal memory is **not** deallocated — it stays resident on the GPU
206    /// for fast reuse.  `release` is the per-buffer alternative to [`reset`];
207    /// see the module docs for guidance on which to use.
208    ///
209    /// **Mixing `release` and `reset` within the same arena cycle is not
210    /// supported** — the pool's in-use list does not deduplicate, so a buffer
211    /// returned via `release` and then bulk-returned via `reset` would land in
212    /// the free list twice (each entry holds an ARC clone of the same Metal
213    /// buffer; the duplication wastes a free-list slot but is not a memory
214    /// leak — both clones drop together once popped).  Pick one pattern per
215    /// arena cycle.
216    pub fn release(&mut self, buffer: MlxBuffer) {
217        let bucket = bucket_size(buffer.byte_len());
218        let metal_buf = buffer.into_inner();
219        self.free.entry(bucket).or_default().push(metal_buf);
220    }
221
222    /// Bulk-return every buffer handed out by [`alloc`] since the last reset
223    /// to the pool's free list.
224    ///
225    /// # Caller contract
226    ///
227    /// All `MlxBuffer` values returned by `alloc` since the last reset must be
228    /// out-of-scope (dropped) at the time `reset` is called.  Reset transfers
229    /// the pool's ARC clones to the free list, where they become available to
230    /// subsequent [`alloc`] calls.  If a caller is still holding an `MlxBuffer`
231    /// and a later `alloc` re-issues the underlying buffer, the two callers
232    /// will share GPU memory (aliasing).  The Metal ARC keeps the storage
233    /// alive in either case, but writes from the new caller will be visible
234    /// to the stale caller — a correctness bug, not a memory error.
235    ///
236    /// In Rust's ownership model, locally-bound `MlxBuffer` values fall out of
237    /// scope at the end of their lexical block, making the per-decode-token
238    /// arena pattern safe by construction:
239    ///
240    /// ```ignore
241    /// loop {
242    ///     pool.reset();          // start of token — recycle previous token's buffers
243    ///     forward_pass(&pool);   // many alloc(), no explicit release
244    /// }                          // forward_pass returns; locals dropped
245    /// ```
246    pub fn reset(&mut self) {
247        for (bucket, metal_buf) in self.in_use.drain(..) {
248            self.free.entry(bucket).or_default().push(metal_buf);
249        }
250    }
251
252    /// Register an externally-allocated buffer with this pool's residency set
253    /// without taking ownership.
254    ///
255    /// # Why this exists
256    ///
257    /// [`alloc`](Self::alloc) bucket-rounds requests up to the next power of
258    /// two, which is acceptable for transient per-token scratch (the worst
259    /// case is ~2× over-allocation on a few megabytes) but unacceptable for
260    /// large static weight tensors.  hf2q's Qwen3.5-MoE weight set totals
261    /// ~17.26 GB; bucket-rounding would balloon that to ~25.55 GB
262    /// (+8.3 GB / +48% blowup) — unshippable on a 128 GB unified-memory
263    /// M5 Max once KV cache and intermediates are layered on top.
264    ///
265    /// `register_existing` provides a *residency-only* path: the caller
266    /// allocates the buffer at its exact size via
267    /// [`MlxDevice::alloc_buffer`](crate::MlxDevice::alloc_buffer) (or
268    /// loads it via [`GgufFile::load_tensor_into_pool`](crate::GgufFile::load_tensor_into_pool)),
269    /// retains the [`MlxBuffer`] handle, and asks the pool to add the
270    /// underlying Metal allocation to its residency set so it gets the
271    /// MTLResidencySet hint on the next dispatch.
272    ///
273    /// # Ownership semantics
274    ///
275    /// * The pool **does not** take ownership of the buffer.  The caller's
276    ///   `MlxBuffer` handle remains the canonical owner.
277    /// * The pool **does not** recycle this buffer on [`reset`](Self::reset)
278    ///   (it is not added to `in_use`).
279    /// * The pool **does** include this buffer in its residency set so it
280    ///   is hinted-resident on the next encoder dispatch.
281    /// * On pool [`Drop`], the residency-set membership is removed but the
282    ///   underlying Metal buffer is **not** freed — the caller's `MlxBuffer`
283    ///   handle keeps the ARC alive.
284    ///
285    /// # `HF2Q_NO_RESIDENCY=1` escape hatch
286    ///
287    /// When the environment variable `HF2Q_NO_RESIDENCY=1` is set, the
288    /// process boots its [`MlxDevice`](crate::MlxDevice) without any
289    /// residency set (see `device.rs`).  In that mode this method returns
290    /// `Ok(())` without touching anything — operators who suspect a
291    /// residency-induced regression can opt out without recompiling.
292    ///
293    /// # Idempotence
294    ///
295    /// Registering the same buffer twice (identified by its
296    /// `metal::Buffer.contents()` pointer) is a no-op on the second call —
297    /// the residency set membership is tracked in a `HashMap` keyed by
298    /// contents pointer.
299    ///
300    /// # Errors
301    ///
302    /// Returns `MlxError::InvalidArgument` if the buffer was allocated on a
303    /// different `MlxDevice` than any previously registered buffer.
304    pub fn register_existing(
305        &mut self,
306        device: &MlxDevice,
307        buffer: &MlxBuffer,
308    ) -> Result<()> {
309        // ADR-015 iter8e (Phase 3b): MlxDevice::alloc_buffer now
310        // auto-registers each new buffer with the device's residency set
311        // via Arc<MlxBufferStorage>. If this caller's buffer already owns
312        // its registration, short-circuit — re-registering would double-add
313        // and the pool's Drop would issue a stray removeAllocation: against
314        // a buffer the storage's RAII path will also remove.
315        if let Some(buffer_set) = buffer.residency_set() {
316            let Some(device_set) = device.residency_set() else {
317                return Err(MlxError::InvalidArgument(
318                    "MlxBuffer is registered with a residency set, but device has none".into(),
319                ));
320            };
321            if !buffer_set.same_owner(device_set) {
322                return Err(MlxError::InvalidArgument(
323                    "MlxBufferPool cannot register a buffer from a different residency-enabled device"
324                        .into(),
325                ));
326            }
327            // Adopt the buffer's residency set so the pool's same_owner
328            // checks downstream agree, but do NOT add the buffer — it's
329            // already in the set via its own Arc<MlxBufferStorage>.
330            match self.residency_set.as_ref() {
331                Some(pool_set) if !pool_set.same_owner(device_set) => {
332                    return Err(MlxError::InvalidArgument(
333                        "MlxBufferPool cannot mix residency-enabled devices".into(),
334                    ));
335                }
336                Some(_) => {}
337                None => {
338                    self.residency_set = Some(device_set.clone());
339                }
340            }
341            return Ok(());
342        }
343
344        let added = self.register_residency_allocation(device, buffer.metal_buffer())?;
345        if added {
346            if let Some(set) = self.residency_set.as_ref() {
347                // Batched-add path: explicit commit (counts in the
348                // commit-call counter) preserves the
349                // `commit_called_after_alloc_batch`-style semantics.
350                set.commit();
351            }
352        }
353        Ok(())
354    }
355
356    /// Return all free buffers' count (for diagnostics).
357    pub fn free_count(&self) -> usize {
358        self.free.values().map(|v| v.len()).sum()
359    }
360
361    /// Total number of bytes held in the free list.
362    pub fn free_bytes(&self) -> usize {
363        self.free
364            .iter()
365            .map(|(&bucket, bufs)| bucket * bufs.len())
366            .sum()
367    }
368
369    /// Number of buffers currently in-use (alloc'd but not yet reset).
370    pub fn in_use_count(&self) -> usize {
371        self.in_use.len()
372    }
373
374    /// Clear all free buffers, releasing Metal memory.  Does not affect
375    /// in-use tracking.
376    pub fn clear(&mut self) {
377        let mut removed_any = false;
378
379        if let Some(set) = self.residency_set.as_ref() {
380            for metal_buf in self.free.values().flatten() {
381                let key = buffer_key(metal_buf);
382                if let Some(resident_buf) = self.resident_buffers.remove(&key) {
383                    set.remove_allocation(&resident_buf);
384                    removed_any = true;
385                }
386            }
387
388            if removed_any {
389                set.commit();
390            }
391        }
392
393        self.free.clear();
394    }
395
396    fn register_residency_allocation(
397        &mut self,
398        device: &MlxDevice,
399        buffer: &metal::Buffer,
400    ) -> Result<bool> {
401        let Some(device_set) = device.residency_set() else {
402            return Ok(false);
403        };
404
405        match self.residency_set.as_ref() {
406            Some(pool_set) if !pool_set.same_owner(device_set) => {
407                return Err(MlxError::InvalidArgument(
408                    "MlxBufferPool cannot mix residency-enabled devices".into(),
409                ));
410            }
411            Some(_) => {}
412            None => {
413                self.residency_set = Some(device_set.clone());
414            }
415        }
416
417        let key = buffer_key(buffer);
418
419        // 2026-05-03 — HF2Q_PROFILE_RESIDENCY_ABORT instrumentation gate.
420        // Falsifies/confirms the host-pointer-collision hypothesis behind
421        // the SIGABRT inside `-[IOGPUMetalResidencySet addAllocation:]`
422        // (6 macOS DiagnosticReports captured 2026-05-02 22:30 → 2026-05-03 07:15,
423        // all identical stack: abort ← addAllocation ← register_residency_allocation
424        // ← MlxBufferPool::alloc ← qwen35 forward-pass alloc site after long
425        // decode). When set, prints one line per call:
426        //   [RESIDENCY] N=<resident_count> key=<host_ptr> mtl=<obj_ptr> dup=<bool>
427        // dup=true means the host_ptr (`buffer.contents() as usize`) collides
428        // with a previously-registered allocation, possibly representing a
429        // DIFFERENT MTLBuffer ARC (Apple recycled the host page). In that
430        // case the existing dedup HashMap returns "skip" and Apple sees the
431        // new MTLBuffer as never-added — which is fine. The interesting case
432        // is dup=false but Apple aborts on the addAllocation: that means the
433        // MTLBuffer is somehow already-known to Apple's set despite our
434        // HashMap saying it isn't. Logged BEFORE the addAllocation so the
435        // log line precedes any abort.
436        let mtl_ptr = (&**buffer as *const metal::BufferRef as *const std::ffi::c_void) as usize;
437        let dup = self.resident_buffers.contains_key(&key);
438        if std::env::var("HF2Q_PROFILE_RESIDENCY_ABORT").is_ok() {
439            eprintln!(
440                "[RESIDENCY] N={} key=0x{:x} mtl=0x{:x} dup={}",
441                self.resident_buffers.len(),
442                key,
443                mtl_ptr,
444                dup,
445            );
446        }
447
448        if !dup {
449            device_set.add_allocation(buffer);
450            self.resident_buffers.insert(key, buffer.clone());
451            return Ok(true);
452        }
453
454        Ok(false)
455    }
456
457    fn remove_all_residency_allocations(&mut self) {
458        let Some(set) = self.residency_set.as_ref() else {
459            return;
460        };
461
462        if self.resident_buffers.is_empty() {
463            return;
464        }
465
466        for buffer in self.resident_buffers.values() {
467            set.remove_allocation(buffer);
468        }
469        set.commit();
470        self.resident_buffers.clear();
471    }
472}
473
474impl Drop for MlxBufferPool {
475    fn drop(&mut self) {
476        self.remove_all_residency_allocations();
477    }
478}
479
480/// Round `n` up to the nearest power of two.
481///
482/// Returns 1 for n == 0 (though callers should never request 0 bytes).
483fn bucket_size(n: usize) -> usize {
484    if n <= 1 {
485        return 1;
486    }
487    n.next_power_of_two()
488}
489
490#[inline]
491fn buffer_key(buffer: &metal::Buffer) -> usize {
492    buffer.contents() as usize
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_bucket_size_powers() {
501        assert_eq!(bucket_size(0), 1);
502        assert_eq!(bucket_size(1), 1);
503        assert_eq!(bucket_size(2), 2);
504        assert_eq!(bucket_size(3), 4);
505        assert_eq!(bucket_size(4), 4);
506        assert_eq!(bucket_size(5), 8);
507        assert_eq!(bucket_size(1023), 1024);
508        assert_eq!(bucket_size(1024), 1024);
509        assert_eq!(bucket_size(1025), 2048);
510    }
511
512    #[test]
513    fn test_pool_arena_reset_recycles_in_use() {
514        // Per-decode-token arena pattern: alloc many, drop locals, reset, alloc again.
515        // Subsequent allocs must reuse the same Metal buffers (verified by ARC-cloned
516        // contents pointer).
517        let device = MlxDevice::new().expect("device");
518        let mut pool = MlxBufferPool::new();
519
520        // Cycle 1: allocate three buffers in different buckets, then drop them
521        // (locals fall out of scope at the end of the block).
522        let (ptr_a, ptr_b, ptr_c) = {
523            let buf_a = pool.alloc(&device, 1024, DType::F32, vec![256]).expect("alloc a");
524            let buf_b = pool.alloc(&device, 2048, DType::F32, vec![512]).expect("alloc b");
525            let buf_c = pool.alloc(&device, 1024, DType::F32, vec![256]).expect("alloc c");
526            (buf_a.contents_ptr(), buf_b.contents_ptr(), buf_c.contents_ptr())
527        };
528        assert_eq!(pool.in_use_count(), 3);
529        assert_eq!(pool.free_count(), 0);
530
531        // Reset returns all three to free.
532        pool.reset();
533        assert_eq!(pool.in_use_count(), 0);
534        assert_eq!(pool.free_count(), 3);
535
536        // Cycle 2: allocate compatible-bucket buffers, must reuse the same
537        // underlying Metal buffers (contents_ptr equal).
538        let buf_d = pool.alloc(&device, 1024, DType::F32, vec![256]).expect("alloc d");
539        let buf_e = pool.alloc(&device, 2048, DType::F32, vec![512]).expect("alloc e");
540        let ptr_d = buf_d.contents_ptr();
541        let ptr_e = buf_e.contents_ptr();
542
543        // Pointers must come from {a, b, c} — bucket 1024 reuse for d (matches a or c),
544        // bucket 2048 reuse for e (matches b).
545        assert!(
546            ptr_d == ptr_a || ptr_d == ptr_c,
547            "buf_d {:?} must reuse one of a {:?} / c {:?}",
548            ptr_d, ptr_a, ptr_c,
549        );
550        assert_eq!(ptr_e, ptr_b, "buf_e must reuse b (only 2048-bucket buffer)");
551
552        // After cycle-2 alloc, free has 1 (the unused 1024-bucket buffer) + in_use 2.
553        assert_eq!(pool.in_use_count(), 2);
554        assert_eq!(pool.free_count(), 1);
555    }
556
557    #[test]
558    fn test_pool_reset_with_no_alloc_is_idempotent() {
559        // Empty reset must be a no-op.  No MlxDevice required — pool
560        // operations on an empty pool don't touch the device; the
561        // smoke check used to live here was incidental and triggered
562        // the unused-variable warning since `device` was bound but
563        // never consumed.
564        let mut pool = MlxBufferPool::new();
565        pool.reset();
566        assert_eq!(pool.in_use_count(), 0);
567        assert_eq!(pool.free_count(), 0);
568        // Multiple resets without intervening alloc — still no-op.
569        pool.reset();
570        pool.reset();
571        assert_eq!(pool.in_use_count(), 0);
572    }
573
574    #[test]
575    fn test_register_existing_does_not_recycle_on_reset() {
576        // Externally-allocated buffer registered via register_existing must
577        // NOT be added to the in_use list — reset() should leave the caller's
578        // ownership intact and the buffer must remain valid after the pool
579        // is dropped.
580        let device = MlxDevice::new().expect("device");
581        let mut pool = MlxBufferPool::new();
582
583        // Allocate the buffer EXTERNALLY (via device.alloc_buffer, not
584        // pool.alloc) — this is the no-bucket-rounding path hf2q uses for
585        // static weight tensors.
586        let external = device
587            .alloc_buffer(4096, DType::U8, vec![4096])
588            .expect("alloc external");
589        let external_ptr = external.contents_ptr();
590
591        // Register with the pool's residency set.
592        pool.register_existing(&device, &external)
593            .expect("register_existing");
594
595        // in_use must remain empty (external buffer is not arena-recycled).
596        assert_eq!(pool.in_use_count(), 0);
597
598        // reset() must be a no-op for externally-registered buffers.
599        pool.reset();
600        assert_eq!(pool.in_use_count(), 0);
601        assert_eq!(pool.free_count(), 0);
602
603        // Drop the pool. The external MlxBuffer must still be valid — its
604        // metal::Buffer ARC is held by `external`, not by the pool.
605        drop(pool);
606        assert_eq!(external.contents_ptr(), external_ptr);
607        // Confirm the buffer is still accessible (no UAF).
608        let slice: &[u8] = external.as_slice().expect("slice still valid");
609        assert_eq!(slice.len(), 4096);
610    }
611
612    #[test]
613    fn test_register_existing_idempotent() {
614        // Registering the same buffer twice must not duplicate the residency
615        // membership (resident_buffers HashMap is keyed by contents pointer).
616        let device = MlxDevice::new().expect("device");
617        let mut pool = MlxBufferPool::new();
618
619        let external = device
620            .alloc_buffer(2048, DType::U8, vec![2048])
621            .expect("alloc external");
622
623        pool.register_existing(&device, &external)
624            .expect("register 1");
625        pool.register_existing(&device, &external)
626            .expect("register 2 (idempotent)");
627
628        // Drop the pool (Drop::drop runs remove_all_residency_allocations).
629        // No double-remove panic is the actual assertion here.
630        drop(pool);
631        // Buffer still valid.
632        let _slice: &[u8] = external.as_slice().expect("still valid");
633    }
634
635    #[test]
636    fn test_register_existing_no_residency_env_is_noop() {
637        // With HF2Q_NO_RESIDENCY=1 the device boots without a residency set,
638        // so register_existing has no set to register against and must
639        // return Ok(()) as a no-op without touching anything.
640        //
641        // This test runs serially with other residency-env tests via the
642        // shared TEST_LOCK in tests/test_residency_set.rs — but unit tests
643        // here run in the same process and could race with that integration
644        // test if both are running. We mitigate by:
645        //   1. Reading + restoring the original env value.
646        //   2. Resetting the residency env-cache flag before AND after.
647        //
648        // The unit-test name is uniquely keyed; cargo test by default
649        // single-threads tests within the same binary only when --test-threads=1
650        // is set. We accept that this test could flake under -j > 1 with
651        // the integration tests; in practice cargo test schedules unit and
652        // integration test binaries separately.
653        let prev = std::env::var("HF2Q_NO_RESIDENCY").ok();
654        crate::residency::reset_residency_env_cache_for_test();
655        std::env::set_var("HF2Q_NO_RESIDENCY", "1");
656
657        let device = MlxDevice::new().expect("device");
658        assert!(
659            !device.residency_sets_enabled(),
660            "device should boot without residency under HF2Q_NO_RESIDENCY=1",
661        );
662
663        let mut pool = MlxBufferPool::new();
664        let external = device
665            .alloc_buffer(1024, DType::U8, vec![1024])
666            .expect("alloc external");
667
668        // register_existing must succeed as a no-op.
669        pool.register_existing(&device, &external)
670            .expect("register_existing under HF2Q_NO_RESIDENCY=1 should succeed");
671
672        // Pool's internal residency_set must remain None.
673        assert!(pool.residency_set.is_none());
674        assert!(pool.resident_buffers.is_empty());
675
676        // Cleanup env.
677        match prev {
678            Some(v) => std::env::set_var("HF2Q_NO_RESIDENCY", v),
679            None => std::env::remove_var("HF2Q_NO_RESIDENCY"),
680        }
681        crate::residency::reset_residency_env_cache_for_test();
682    }
683
684    #[test]
685    fn test_pool_release_remains_supported_for_compat() {
686        // The existing per-buffer release() pattern still works.  Mixing
687        // release+reset within the same arena cycle is documented as
688        // unsupported but technically lands a duplicate clone in free —
689        // verify the duplicate is harmless (alloc still picks up a buffer).
690        let device = MlxDevice::new().expect("device");
691        let mut pool = MlxBufferPool::new();
692
693        let buf = pool.alloc(&device, 1024, DType::F32, vec![256]).expect("alloc");
694        assert_eq!(pool.in_use_count(), 1);
695        pool.release(buf);
696        // release() does NOT remove from in_use; that's acceptable per the
697        // documented contract (don't mix patterns).  Free has the released one.
698        assert_eq!(pool.free_count(), 1);
699        assert_eq!(pool.in_use_count(), 1);
700
701        // Allocating again pulls from free first.
702        let _buf2 = pool.alloc(&device, 1024, DType::F32, vec![256]).expect("alloc 2");
703        assert_eq!(pool.free_count(), 0);
704        assert_eq!(pool.in_use_count(), 2);
705    }
706}