Repository navigation
[BUG] [Metal] Wired memory is released shortly after GPU idle (residency never renewed) #4609
Description
☑️ I understand it is strictly prohibited to use AI to write issues.
Describe the bug
When MLX uses set_wired_limit to wire memory on macOS the system drops it from residency after only ~2 seconds since last GPU work, so the next request has to make it resident again and is far slower. I'm seeing ~4-5x increases when reproducing and multiple-second compute times for Ollama during real usage.
To Reproduce
Run the below script which makes memory resident and measures memory in residence, first compute, and second compute after 12s of idle, with 3 different behaviors (no GPU work, tiny op every 1s, tiny op every 3s). You'll likely see results like this (perhaps worse on a busier machine):
mlx 0.32.3
iogpu.disable_wired_collector: 0
warm compute 62.6 ms, wired 8.1 GiB
idle 12 s lowest wired 3.2 GiB first compute 342.4 ms second 58.3 ms
idle 12 s, op every 1 s lowest wired 7.3 GiB first compute 68.6 ms second 59.4 ms
idle 12 s, op every 3 s lowest wired 3.5 GiB first compute 271.5 ms second 62.5 ms
import subprocess
import time
import mlx.core as mx
def wired_gib():
page = int(subprocess.check_output(["sysctl", "-n", "hw.pagesize"]))
out = subprocess.check_output(["vm_stat"]).decode()
line = next(l for l in out.splitlines() if "wired down" in l)
return int(line.split(":")[1].strip(" .")) * page / 2**30
def timed_sum(a):
start = time.perf_counter()
mx.eval(a.sum())
return (time.perf_counter() - start) * 1000
def idle(seconds, beat=None):
# Wait, running a tiny GPU op every `beat` seconds if given.
# Return the lowest wired figure seen, sampled every 0.5 s.
one = mx.zeros((1,))
lowest = wired_gib()
start = last = time.monotonic()
while time.monotonic() - start < seconds:
if beat and time.monotonic() - last >= beat:
mx.eval(one + 1)
last = time.monotonic()
lowest = min(lowest, wired_gib())
time.sleep(0.5)
return lowest
print("mlx", mx.__version__)
print(subprocess.check_output(["sysctl", "iogpu.disable_wired_collector"]).decode().strip())
a = mx.random.uniform(shape=(2**30,)) # 4 GiB of float32
mx.eval(a)
mx.set_wired_limit(mx.get_active_memory())
warm = timed_sum(a)
print(f"warm compute {warm:.1f} ms, wired {wired_gib():.1f} GiB")
for beat in (None, 1, 3):
timed_sum(a)
lowest = idle(12, beat)
first = timed_sum(a)
second = timed_sum(a)
label = "idle 12 s" if beat is None else f"idle 12 s, op every {beat} s"
print(f"{label:27} lowest wired {lowest:4.1f} GiB "
f"first compute {first:7.1f} ms second {second:6.1f} ms")Expected behavior
The set_wired_limit docstring says "The wired limit is the total size in bytes of memory that will be kept resident.", so I'd expect that memory to stay resident so subsequent requests are as fast as immediately after initial residency.
Desktop (please complete the following information):
| OS Version | Version |
|---|---|
| macOS 27.0 Mac mini M4 Pro 64 GB | 0.32.2 bundled with Ollama 0.34.2 |
| macOS 27.2 MacBook Air M2 24 GB | 0.32.3 (pip) |
Additional Context
- Cause: in
mlx/backend/metal/resident.cpp,add_set_lockedmakes one standingrequestResidency(), and nothing renews it. - Workaround:
sudo sysctl iogpu.disable_wired_collector=1keeps it wired. 37.3 GiB stayed wired for 3 minutes on the mini. - Precedent: llama.cpp hit the same thing after macOS 26 and fixed it with a heartbeat that re-requests residency (metal : add residency sets keep-alive heartbeat ggml-org/llama.cpp#17766, 180 s default).
- Impact: Ollama's MLX runner hits this. From Ollama 0.40 (in release candidate), MLX is the default on Apple Silicon.
Activity
zcbenz commentedon Oct 7, 2026
Closed by #4633
rap7239 commentedon Oct 14, 2026
Thanks for the quick fix in #4633. Adding a data point from a smaller machine, mainly because the
sysctlworkaroundmentioned above didn't help here.
Setup: Mac mini M4, 16 GB, macOS 26.7, mlx 0.32.0 via vllm-metal (
Mistral-7B-Instruct-v0.3-4bit; vllm-metal setsthe wired limit to 11.8 GB).
Test: 12 min idle, then one streaming request; two runs with
iogpu.disable_wired_collector=0and two with=1,alternating.
iogpu.disable_wired_collector=1, so on 26.7 the sysctldoesn't seem to keep residency.
everything else.
Back in August, on the same machine and versions, the same pattern showed up as much worse first-token stalls after
idle (30 s to 6 min), with the main thread in
eval_impl'scondition_variable::waitand GPU power flat until thelast 1–3 s. A warmup request worked around it. I can't confirm that was the same mechanism.
I haven't yet tried
MLX_METAL_RESIDENCY_REFRESH_INTERVAL_MSfrom #4633; I'll report back once I do.Archive the async_eval stall report instead of filing it