Repository navigation
Fix leak when _updateInternal replaces an array with siblings - #501
Conversation
_updateInternal assigned the new array over the old one with mlx_array_set, a C++ copy assignment. That skips array::~array(), which is where the cycle between sibling outputs of a multi-output primitive is broken. Module.update(parameters:) goes through _updateInternal, so loading weights into a freshly built QuantizedLinear (whose random init weight is an unevaluated output of a multi-output Quantize) orphaned the whole init graph on every model load. Free the old handle instead, so the destructor runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
davidkoski
left a comment
There was a problem hiding this comment.
Nice find -- that has been there roughly forever! Thank you!
|
Just waiting on CI. |
Clearing self.ctx before copying from array.ctx emptied the array on a._updateInternal(a), which the sharding path reaches through Module.update(parameters:) (DistributedNNTests.testShardPredicate aborted with "expected a non-empty mlx_array"). Copy into a new handle, then free the old one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The macOS failure was this change: |
| var new = mlx_array_new() | ||
| mlx_array_set(&new, array.ctx) | ||
| mlx_array_free(self.ctx) | ||
| self.ctx = new |
There was a problem hiding this comment.
testShardPredicate() reveals an issue here: a._updateInternal(a) crashes.
Maybe something like this:
var new = mlx_array_new()
mlx_array_set(&new, array.ctx)
let old = self.ctx
self.ctx = new
mlx_array_free(old) or maybe just check for pointer equality (we are tightly coupled to mlx so this is ok):
if self.ctx.ctx == array.ctx.ctx {
return
}
Cool, your solution looks good -- I made a comment on this same thing. CI running again. |
MLXArray._updateInternalputs the new array over the old one withmlx_array_set, which is a C++ copy assignment. That never runsarray::~array(), and the destructor is what breaks the reference cycle between sibling outputs of a multi-output primitive (mlx/array.cpp,array::~array). If the replaced array is an unevaluated sibling, its whole graph is orphaned.Module.update(parameters:)goes through_updateInternal, so this happens on every quantized model load: a freshly builtQuantizedLinearholds random-init weights that are unevaluated outputs of a multi-outputQuantize, and loading the checkpoint replaces them.This change frees the old handle instead of assigning over it, so the destructor runs.
Reproduction
I loaded and unloaded a 4-bit model 10 times in one process (Cohere Transcribe through mlx-audio-swift, which loads with
fromDirectory→update(parameters:)), then counted live objects withheap <pid> -s:ArrayDescafter 0 reloadsThat's about 7–12 MB leaked per load. The leftover objects are
ArrayDesc,Quantize,RandomBits,AsTypeandBroadcastnodes plus Metal buffers. Inference speed is unchanged.Calling
MLX.quantized()on its own and dropping, replacing or evaluating the results leaks nothing, so the leak comes from the assignment, not fromQuantize.(Separately, model construction draws ~900 keys from
MLXRandom.globalStatelazily; apps that load repeatedly shouldeval(MLXRandom.globalState)afterwards or the key chain grows too. That one isn't a bug, so this PR doesn't change it.)🤖 Generated with Claude Code