From 6ec94f82193f1dbbd1711a6ec16664162ba22f4c Mon Sep 17 00:00:00 2001 From: Alexandru Tudorica Date: Wed, 2 Sep 2026 20:10:59 +0300 Subject: [PATCH 1/6] Break the sibling cycle when an array is released by assignment The outputs of a multi-output primitive hold each other in `siblings`, a reference cycle that `~array()` breaks once it sees the last external reference go. Assignment did not run that check: it replaced the descriptor directly, so an array whose last external reference went away by assignment left the cycle intact, and with it every input below it in the graph. `compile_dfs` does exactly that. It rewires the outputs' inputs to the copied tape by assignment, which drops the trace's own consumers of a Split (or any multi-output primitive), and then rewrites the parents map by assignment, which drops the last reference to the trace's outputs of that Split. From then on the cycle owns the whole trace beneath it, captured constants included, and nothing ever frees it: `compile_erase` and `compile_clear_cache` never reach it. Compiling a function that splits a matmul of a captured weight leaked that weight once per compiled function. In mlx-swift-lm, whose Qwen3.5 decode step is compiled and splits the fused q/k/v projection in every layer, that was 0.5-1.7 GB of a 4B model left resident after the model was dropped, which killed an app that loaded another model next. Route copy assignment, move assignment and `overwrite_descriptor` through the destructor by moving the previous value into a temporary, so the cycle check runs on every way an array reference can end. `a[...] = v` on the last surviving output of a lazy split reaches the same bug with no compile involved: it goes through `overwrite_descriptor`, and with the other output already gone the two held only each other, and the split's input with them. There is a test for each path. --- mlx/array.h | 18 +++++++++++++++++- python/tests/test_array.py | 23 +++++++++++++++++++++++ python/tests/test_compile.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/mlx/array.h b/mlx/array.h index 3f45e9cb9d..f73ea38125 100644 --- a/mlx/array.h +++ b/mlx/array.h @@ -80,12 +80,27 @@ class MLX_API array { array& operator=(array&& other) && = delete; /** Default copy and move constructors otherwise. */ - array& operator=(array&& other) & = default; array(const array& other) = default; array(array&& other) = default; + /** + * Assignment releases the previous descriptor through the destructor so + * that the sibling reference cycle of a multi-output primitive is broken + * when the last external reference to it goes away by assignment rather + * than by destruction. Otherwise a cycle can outlive everything that used + * it and keep its inputs (including captured constants) alive forever. + */ + array& operator=(array&& other) & noexcept { + if (this != &other) { + array previous(std::move(*this)); + this->array_desc_ = std::move(other.array_desc_); + } + return *this; + } + array& operator=(const array& other) & { if (this->id() != other.id()) { + array previous(std::move(*this)); this->array_desc_ = other.array_desc_; } return *this; @@ -466,6 +481,7 @@ class MLX_API array { void copy_shared_buffer(const array& other); void overwrite_descriptor(const array& other) { + array previous(std::move(*this)); array_desc_ = other.array_desc_; } diff --git a/python/tests/test_array.py b/python/tests/test_array.py index ab79b5563b..8bd5ff3a6e 100644 --- a/python/tests/test_array.py +++ b/python/tests/test_array.py @@ -1,5 +1,6 @@ # Copyright © 2023-2024 Apple Inc. +import gc import operator import os import pickle @@ -2745,6 +2746,28 @@ def test_setitem_with_boolean_mask(self): with self.assertRaises(ValueError): mx.arange(1000).reshape(10, 10, 10)[mask_np] = 0 + def test_setitem_releases_multi_output_sibling(self): + # The outputs of a lazy multi-output primitive hold each other as + # siblings. Replacing the last surviving one in place drops that + # output by assignment rather than destruction; the cycle used to + # survive it, and with it the primitive's input. + gc.collect() + mem_pre = mx.get_active_memory() + + def replace_survivor(): + src = mx.ones((256, 1024)) + mx.eval(src) + a, b = mx.split(src, 2) + del b + a[...] = mx.zeros((128, 1024)) + mx.eval(a) + + for _ in range(5): + replace_survivor() + gc.collect() + + self.assertEqual(mem_pre, mx.get_active_memory()) + def test_array_namespace(self): a = mx.array(1.0) api = a.__array_namespace__() diff --git a/python/tests/test_compile.py b/python/tests/test_compile.py index 03d0027d7e..54376c8e9f 100644 --- a/python/tests/test_compile.py +++ b/python/tests/test_compile.py @@ -1307,6 +1307,39 @@ def f(x): self.assertEqual(mem_pre, mem_post) + def test_multi_output_primitive_leak(self): + # The outputs of a multi-output primitive hold each other as + # siblings. Compiling a function that uses one used to leave that + # cycle behind, and with it everything below it in the trace, + # including the captured constants. + gc.collect() + if mx.metal.is_available(): + mem_pre = mx.get_active_memory() + else: + mem_pre = 0 + + def outer(): + w = mx.ones((512, 512)) + mx.eval(w) + + def f(x): + a, b = mx.split(x @ w, 2, axis=-1) + return a * b + + cf = mx.compile(f) + mx.eval(cf(mx.ones((1, 512)))) + + for _ in range(5): + outer() + gc.collect() + + if mx.metal.is_available(): + mem_post = mx.get_active_memory() + else: + mem_post = 0 + + self.assertEqual(mem_pre, mem_post) + def test_double_constant(self): with mx.stream(mx.cpu): x = mx.array(1.0, dtype=mx.float64) From 4a013949133c8027efef64fdad562d11b7168fd2 Mon Sep 17 00:00:00 2001 From: Alexandru Tudorica Date: Thu, 3 Sep 2026 08:57:12 +0300 Subject: [PATCH 2/6] Replace the Python leak tests with C++ lifetime tests get_active_memory() and gc.collect() are unreliable in a test and have caused flakes, so track the ArrayDesc's lifetime with a weak_ptr to its data instead, in the style of the existing "test siblings circular references without eval" case. One test drops both outputs of a lazy split by assignment, covering the copy and the move operator; the other leaves a single output alive and replaces it through overwrite_descriptor, which is the path __setitem__ takes and where no compile is involved at all. Both fail on main and pass with the fix, and neither measures memory or touches the garbage collector. --- python/tests/test_array.py | 23 ------------------- python/tests/test_compile.py | 33 --------------------------- tests/array_tests.cpp | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 56 deletions(-) diff --git a/python/tests/test_array.py b/python/tests/test_array.py index 8bd5ff3a6e..ab79b5563b 100644 --- a/python/tests/test_array.py +++ b/python/tests/test_array.py @@ -1,6 +1,5 @@ # Copyright © 2023-2024 Apple Inc. -import gc import operator import os import pickle @@ -2746,28 +2745,6 @@ def test_setitem_with_boolean_mask(self): with self.assertRaises(ValueError): mx.arange(1000).reshape(10, 10, 10)[mask_np] = 0 - def test_setitem_releases_multi_output_sibling(self): - # The outputs of a lazy multi-output primitive hold each other as - # siblings. Replacing the last surviving one in place drops that - # output by assignment rather than destruction; the cycle used to - # survive it, and with it the primitive's input. - gc.collect() - mem_pre = mx.get_active_memory() - - def replace_survivor(): - src = mx.ones((256, 1024)) - mx.eval(src) - a, b = mx.split(src, 2) - del b - a[...] = mx.zeros((128, 1024)) - mx.eval(a) - - for _ in range(5): - replace_survivor() - gc.collect() - - self.assertEqual(mem_pre, mx.get_active_memory()) - def test_array_namespace(self): a = mx.array(1.0) api = a.__array_namespace__() diff --git a/python/tests/test_compile.py b/python/tests/test_compile.py index 54376c8e9f..03d0027d7e 100644 --- a/python/tests/test_compile.py +++ b/python/tests/test_compile.py @@ -1307,39 +1307,6 @@ def f(x): self.assertEqual(mem_pre, mem_post) - def test_multi_output_primitive_leak(self): - # The outputs of a multi-output primitive hold each other as - # siblings. Compiling a function that uses one used to leave that - # cycle behind, and with it everything below it in the trace, - # including the captured constants. - gc.collect() - if mx.metal.is_available(): - mem_pre = mx.get_active_memory() - else: - mem_pre = 0 - - def outer(): - w = mx.ones((512, 512)) - mx.eval(w) - - def f(x): - a, b = mx.split(x @ w, 2, axis=-1) - return a * b - - cf = mx.compile(f) - mx.eval(cf(mx.ones((1, 512)))) - - for _ in range(5): - outer() - gc.collect() - - if mx.metal.is_available(): - mem_post = mx.get_active_memory() - else: - mem_post = 0 - - self.assertEqual(mem_pre, mem_post) - def test_double_constant(self): with mx.stream(mx.cpu): x = mx.array(1.0, dtype=mx.float64) diff --git a/tests/array_tests.cpp b/tests/array_tests.cpp index 68a4bed3a8..f84b94572b 100644 --- a/tests/array_tests.cpp +++ b/tests/array_tests.cpp @@ -690,3 +690,46 @@ TEST_CASE("test siblings circular references without eval") { fun(); CHECK(tracker.expired()); } + +// https://github.com/ml-explore/mlx/pull/4453 +TEST_CASE("test siblings circular references released by assignment") { + std::weak_ptr tracker; + auto fun = [&]() { + array sink({3, 4}); + array key({1, 2}); + auto splits = split(key, 2); + { + // Set fake data as a tracker for ArrayDesc's lifetime. + splits[0].set_data(allocator::malloc(0)); + tracker = splits[0].data_shared_ptr(); + } + // Drop both outputs by assigning over them rather than destroying + // them. The siblings hold each other, so unless assignment runs the + // same check the destructor does, nothing ever releases them. + splits[0] = sink; // copy assignment + splits[1] = array({5, 6}); // move assignment + }; + fun(); + CHECK(tracker.expired()); +} + +// https://github.com/ml-explore/mlx/pull/4453 +TEST_CASE("test siblings circular references released by overwrite") { + std::weak_ptr tracker; + auto fun = [&]() { + array key({1, 2}); + auto splits = split(key, 2); + { + // Set fake data as a tracker for ArrayDesc's lifetime. + splits[0].set_data(allocator::malloc(0)); + tracker = splits[0].data_shared_ptr(); + } + // Leave one output as the last reference, then replace it in place. + // This is the path __setitem__ takes, with no compile involved. + array last = splits[0]; + splits.clear(); + last.overwrite_descriptor(array({7, 8})); + }; + fun(); + CHECK(tracker.expired()); +} From a5f3be9652f055d7fec7f2aa77f888c44292f5be Mon Sep 17 00:00:00 2001 From: Alexandru Tudorica Date: Thu, 3 Sep 2026 09:55:53 +0300 Subject: [PATCH 3/6] Move the sibling-cycle check into a private reset() The destructor body becomes array::reset(desc): it swaps desc into array_desc_ and runs the check on the descriptor it lets go of. The destructor, both assignment operators and overwrite_descriptor call it instead of releasing through a temporary array. reset() holds the new descriptor before checking the old one. The order matters when a sibling is assigned over the last outside reference to its own cycle, because the sibling is only reachable through that reference: checked first, the pair looks unreferenced, the cycle is broken and the sibling list the new value lives in is freed before the assignment reads it. Held first, the new descriptor counts as an outside reference and the cycle stays intact, as it did when the check ran after the assignment. The added test segfaults with the other order. --- mlx/array.cpp | 23 ++++++++++++++--------- mlx/array.h | 33 +++++++++++++++++++++------------ tests/array_tests.cpp | 16 ++++++++++++++++ 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/mlx/array.cpp b/mlx/array.cpp index 07d3acd616..89da90ca4c 100644 --- a/mlx/array.cpp +++ b/mlx/array.cpp @@ -208,33 +208,38 @@ void array::copy_shared_buffer(const array& other) { copy_shared_buffer(other, other.strides(), other.flags(), other.data_size()); } -array::~array() { - if (array_desc_ == nullptr) { +void array::reset(std::shared_ptr desc) { + // Hold the new descriptor first, so that a cycle it belongs to has an + // outside reference while the old one is checked below + auto previous = std::move(array_desc_); + array_desc_ = std::move(desc); + + if (previous == nullptr) { return; } // Detached/detaching - if (array_desc_->primitive == nullptr) { + if (previous->primitive == nullptr) { return; } // Break circular reference for non-detached arrays with siblings - if (auto n = siblings().size(); n > 0) { + if (auto n = previous->siblings.size(); n > 0) { bool do_detach = true; // If all siblings have siblings.size() references except - // the one we are currently destroying (which has siblings.size() + 1) + // the one we are currently releasing (which has siblings.size() + 1) // then there are no more external references - do_detach &= (array_desc_.use_count() == (n + 1)); - for (auto& s : siblings()) { + do_detach &= (previous.use_count() == (n + 1)); + for (auto& s : previous->siblings) { do_detach &= (s.array_desc_.use_count() == n); if (!do_detach) { break; } } if (do_detach) { - for (auto& s : siblings()) { + for (auto& s : previous->siblings) { for (auto& ss : s.siblings()) { - // Set to null here to avoid descending into array destructor + // Set to null here to avoid descending into reset() // for siblings ss.array_desc_ = nullptr; } diff --git a/mlx/array.h b/mlx/array.h index f73ea38125..de4a4a5965 100644 --- a/mlx/array.h +++ b/mlx/array.h @@ -84,24 +84,22 @@ class MLX_API array { array(array&& other) = default; /** - * Assignment releases the previous descriptor through the destructor so - * that the sibling reference cycle of a multi-output primitive is broken - * when the last external reference to it goes away by assignment rather - * than by destruction. Otherwise a cycle can outlive everything that used - * it and keep its inputs (including captured constants) alive forever. + * Assignment goes through reset() so that the sibling reference cycle of a + * multi-output primitive is broken when the last external reference to it + * goes away by assignment rather than by destruction. Otherwise a cycle can + * outlive everything that used it and keep its inputs (including captured + * constants) alive forever. */ array& operator=(array&& other) & noexcept { if (this != &other) { - array previous(std::move(*this)); - this->array_desc_ = std::move(other.array_desc_); + reset(std::move(other.array_desc_)); } return *this; } array& operator=(const array& other) & { if (this->id() != other.id()) { - array previous(std::move(*this)); - this->array_desc_ = other.array_desc_; + reset(other.array_desc_); } return *this; } @@ -481,11 +479,12 @@ class MLX_API array { void copy_shared_buffer(const array& other); void overwrite_descriptor(const array& other) { - array previous(std::move(*this)); - array_desc_ = other.array_desc_; + reset(other.array_desc_); } - ~array(); + ~array() { + reset(); + } private: // Initialize the arrays data @@ -548,6 +547,16 @@ class MLX_API array { // the primitive which knows how to compute the array's data from its inputs // and the list of array's inputs for the primitive. std::shared_ptr array_desc_; + + /** + * Like shared_ptr::reset: drop the current descriptor and hold desc instead + * (null when destroying). Dropping it breaks the sibling reference cycle of + * a multi-output primitive if this was the last reference to it from outside + * the cycle. The new descriptor is held before the old one is checked, so a + * sibling assigned over the last outside reference to its own cycle does not + * have that cycle broken under it. + */ + void reset(std::shared_ptr desc = nullptr); }; template diff --git a/tests/array_tests.cpp b/tests/array_tests.cpp index f84b94572b..9019ef7d38 100644 --- a/tests/array_tests.cpp +++ b/tests/array_tests.cpp @@ -733,3 +733,19 @@ TEST_CASE("test siblings circular references released by overwrite") { fun(); CHECK(tracker.expired()); } + +// https://github.com/ml-explore/mlx/pull/4453 +TEST_CASE("test assigning a sibling over the last reference keeps the cycle") { + array key({1, 2}); + auto splits = split(key, 2); + // Leave one output as the last outside reference to the pair, then assign + // its sibling over it. The sibling is reachable only through the array + // being replaced, so it has to be held before the old descriptor is + // checked: checked first, the pair looks unreferenced, its cycle is broken + // and the sibling list the new value lives in is freed before it is read. + array last = splits[0]; + splits.clear(); + last = last.siblings()[0]; + REQUIRE_EQ(last.siblings().size(), 1); + CHECK(array_equal(last, array({2})).item()); +} From 80582cf405373dcfd00ae4ea3324f926ceab95eb Mon Sep 17 00:00:00 2001 From: Alexandru Tudorica Date: Mon, 7 Sep 2026 20:59:43 +0300 Subject: [PATCH 4/6] Release the old descriptor instead of resetting into the new one reset(desc) becomes a static release(desc): it takes the descriptor the array is letting go of and runs the sibling-cycle check on it, touching nothing else. Callers hand it the old descriptor with std::exchange, which is also what takes the new one first: release(std::exchange(array_desc_, other.array_desc_)); The order is the fix, not the naming. A reset() reading array_desc_ cannot provide it: the member still holds the old descriptor while the check runs, so nothing holds the new one, and when the new one is a sibling of the old the pair looks unreferenced. The cycle is then broken and the sibling list the new value lives in is cleared out from under it, leaving an output with no siblings for outputs() to index and for Split::eval to write. Comparing array_desc_ instead of id() does not catch this, since the two descriptors genuinely differ: they are different outputs of the same split. Self-assignment is a separate case and both guards already cover it. std::exchange copies the new descriptor once and moves the old one out, so this is the single atomic increment a plain copy-assign already does. The assignment operators also use the array_desc_ comparison now, which is cheaper and clearer than comparing id(). --- mlx/array.cpp | 7 +------ mlx/array.h | 34 ++++++++++++++++++++-------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/mlx/array.cpp b/mlx/array.cpp index 89da90ca4c..b985acc464 100644 --- a/mlx/array.cpp +++ b/mlx/array.cpp @@ -208,12 +208,7 @@ void array::copy_shared_buffer(const array& other) { copy_shared_buffer(other, other.strides(), other.flags(), other.data_size()); } -void array::reset(std::shared_ptr desc) { - // Hold the new descriptor first, so that a cycle it belongs to has an - // outside reference while the old one is checked below - auto previous = std::move(array_desc_); - array_desc_ = std::move(desc); - +void array::release(std::shared_ptr previous) { if (previous == nullptr) { return; } diff --git a/mlx/array.h b/mlx/array.h index de4a4a5965..75390e01ee 100644 --- a/mlx/array.h +++ b/mlx/array.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "mlx/allocator.h" @@ -84,22 +85,24 @@ class MLX_API array { array(array&& other) = default; /** - * Assignment goes through reset() so that the sibling reference cycle of a + * Assignment goes through release() so that the sibling reference cycle of a * multi-output primitive is broken when the last external reference to it * goes away by assignment rather than by destruction. Otherwise a cycle can * outlive everything that used it and keep its inputs (including captured * constants) alive forever. */ array& operator=(array&& other) & noexcept { - if (this != &other) { - reset(std::move(other.array_desc_)); + if (array_desc_ != other.array_desc_) { + // Take the new descriptor before releasing the old one: see release() + release(std::exchange(array_desc_, std::move(other.array_desc_))); } return *this; } array& operator=(const array& other) & { - if (this->id() != other.id()) { - reset(other.array_desc_); + if (array_desc_ != other.array_desc_) { + // Take the new descriptor before releasing the old one: see release() + release(std::exchange(array_desc_, other.array_desc_)); } return *this; } @@ -479,11 +482,11 @@ class MLX_API array { void copy_shared_buffer(const array& other); void overwrite_descriptor(const array& other) { - reset(other.array_desc_); + release(std::exchange(array_desc_, other.array_desc_)); } ~array() { - reset(); + release(std::move(array_desc_)); } private: @@ -549,14 +552,17 @@ class MLX_API array { std::shared_ptr array_desc_; /** - * Like shared_ptr::reset: drop the current descriptor and hold desc instead - * (null when destroying). Dropping it breaks the sibling reference cycle of - * a multi-output primitive if this was the last reference to it from outside - * the cycle. The new descriptor is held before the old one is checked, so a - * sibling assigned over the last outside reference to its own cycle does not - * have that cycle broken under it. + * Lets go of a descriptor, breaking the sibling reference cycle of a + * multi-output primitive if this was the last reference to it from outside + * that cycle. + * + * Callers take their new descriptor first (the std::exchange above), + * because the array being assigned may itself be a sibling of the one + * being released, reachable only through it. Released first, that cycle + * looks unreferenced, and the sibling list the new value lives in is + * cleared out from under it. */ - void reset(std::shared_ptr desc = nullptr); + static void release(std::shared_ptr desc); }; template From e1259cd99d28e4eeddf0c0008b3ebbb3de32cf6a Mon Sep 17 00:00:00 2001 From: Cheng Date: Fri, 11 Sep 2026 08:29:13 +0900 Subject: [PATCH 5/6] nit --- mlx/array.cpp | 17 ++++++++--------- mlx/array.h | 31 +++++++++---------------------- tests/array_tests.cpp | 2 -- 3 files changed, 17 insertions(+), 33 deletions(-) diff --git a/mlx/array.cpp b/mlx/array.cpp index b985acc464..e6dbbe7851 100644 --- a/mlx/array.cpp +++ b/mlx/array.cpp @@ -208,34 +208,33 @@ void array::copy_shared_buffer(const array& other) { copy_shared_buffer(other, other.strides(), other.flags(), other.data_size()); } -void array::release(std::shared_ptr previous) { - if (previous == nullptr) { +void array::release(std::shared_ptr desc) { + if (desc == nullptr) { return; } // Detached/detaching - if (previous->primitive == nullptr) { + if (desc->primitive == nullptr) { return; } // Break circular reference for non-detached arrays with siblings - if (auto n = previous->siblings.size(); n > 0) { + if (auto n = desc->siblings.size(); n > 0) { bool do_detach = true; // If all siblings have siblings.size() references except // the one we are currently releasing (which has siblings.size() + 1) // then there are no more external references - do_detach &= (previous.use_count() == (n + 1)); - for (auto& s : previous->siblings) { + do_detach &= (desc.use_count() == (n + 1)); + for (auto& s : desc->siblings) { do_detach &= (s.array_desc_.use_count() == n); if (!do_detach) { break; } } if (do_detach) { - for (auto& s : previous->siblings) { + for (auto& s : desc->siblings) { for (auto& ss : s.siblings()) { - // Set to null here to avoid descending into reset() - // for siblings + // Set to null here to avoid descending into reset() for siblings ss.array_desc_ = nullptr; } s.array_desc_->siblings.clear(); diff --git a/mlx/array.h b/mlx/array.h index 75390e01ee..cc29290d2c 100644 --- a/mlx/array.h +++ b/mlx/array.h @@ -84,25 +84,19 @@ class MLX_API array { array(const array& other) = default; array(array&& other) = default; - /** - * Assignment goes through release() so that the sibling reference cycle of a - * multi-output primitive is broken when the last external reference to it - * goes away by assignment rather than by destruction. Otherwise a cycle can - * outlive everything that used it and keep its inputs (including captured - * constants) alive forever. - */ - array& operator=(array&& other) & noexcept { + /** Assignment goes through release() to break sibling reference cycle. */ + array& operator=(const array& other) & { if (array_desc_ != other.array_desc_) { - // Take the new descriptor before releasing the old one: see release() - release(std::exchange(array_desc_, std::move(other.array_desc_))); + // Hold the old descriptor while assigning, and then release it. Otherwise + // when |this| and |other| are siblings they would be both destroyed. + release(std::exchange(array_desc_, other.array_desc_)); } return *this; } - array& operator=(const array& other) & { + array& operator=(array&& other) & noexcept { if (array_desc_ != other.array_desc_) { - // Take the new descriptor before releasing the old one: see release() - release(std::exchange(array_desc_, other.array_desc_)); + release(std::exchange(array_desc_, std::move(other.array_desc_))); } return *this; } @@ -552,15 +546,8 @@ class MLX_API array { std::shared_ptr array_desc_; /** - * Lets go of a descriptor, breaking the sibling reference cycle of a - * multi-output primitive if this was the last reference to it from outside - * that cycle. - * - * Callers take their new descriptor first (the std::exchange above), - * because the array being assigned may itself be a sibling of the one - * being released, reachable only through it. Released first, that cycle - * looks unreferenced, and the sibling list the new value lives in is - * cleared out from under it. + * Lets go of an ArrayDesc, breaking the sibling reference cycle of a + * multi-output primitive. */ static void release(std::shared_ptr desc); }; diff --git a/tests/array_tests.cpp b/tests/array_tests.cpp index 9019ef7d38..cc0cf52395 100644 --- a/tests/array_tests.cpp +++ b/tests/array_tests.cpp @@ -713,7 +713,6 @@ TEST_CASE("test siblings circular references released by assignment") { CHECK(tracker.expired()); } -// https://github.com/ml-explore/mlx/pull/4453 TEST_CASE("test siblings circular references released by overwrite") { std::weak_ptr tracker; auto fun = [&]() { @@ -734,7 +733,6 @@ TEST_CASE("test siblings circular references released by overwrite") { CHECK(tracker.expired()); } -// https://github.com/ml-explore/mlx/pull/4453 TEST_CASE("test assigning a sibling over the last reference keeps the cycle") { array key({1, 2}); auto splits = split(key, 2); From 123cd15825ace83e19f4dca0277064dd08b100a6 Mon Sep 17 00:00:00 2001 From: Cheng Date: Fri, 11 Sep 2026 02:03:40 +0100 Subject: [PATCH 6/6] Fix crash in test --- tests/autograd_tests.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/autograd_tests.cpp b/tests/autograd_tests.cpp index 9fad4376fc..1ff203e1e9 100644 --- a/tests/autograd_tests.cpp +++ b/tests/autograd_tests.cpp @@ -749,7 +749,7 @@ TEST_CASE("test op vjps") { TEST_CASE("test gather and take grads") { // Check linear takes auto linear_f = [](array indices) { - auto fun_linear = [&indices](array input) { return take(input, indices); }; + auto fun_linear = [indices](array input) { return take(input, indices); }; return fun_linear; };