Skip to content

[BUG] [Metal] Wired memory is released shortly after GPU idle (residency never renewed) #4609

Closed

Description

@namolnad

☑️ 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_locked makes one standing requestResidency(), and nothing renews it.
  • Workaround: sudo sysctl iogpu.disable_wired_collector=1 keeps 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

  1. zcbenz commented on Oct 7, 2026

    @zcbenz
    Member

    Closed by #4633

  2. rap7239 commented on Oct 14, 2026

    @rap7239

    Thanks for the quick fix in #4633. Adding a data point from a smaller machine, mainly because the sysctl workaround
    mentioned 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 sets
    the wired limit to 11.8 GB).

    Test: 12 min idle, then one streaming request; two runs with iogpu.disable_wired_collector=0 and two with =1,
    alternating.

    Run Collector disabled Wired before request Wired after TTFT
    A1 no 2.79 GiB 13.74 GiB 11.1 s
    B1 yes 3.79 GiB 13.71 GiB 10.8 s
    A2 no 2.48 GiB 13.14 GiB 9.0 s
    B2 yes 3.86 GiB 13.44 GiB 12.4 s
    • About 10 GiB is unwired during idle with or without iogpu.disable_wired_collector=1, so on 26.7 the sysctl
      doesn't seem to keep residency.
    • Swap grew 1–2.5 GB during each first request as that memory was re-wired; on 16 GB the re-wire competes with
      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's condition_variable::wait and GPU power flat until the
    last 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_MS from #4633; I'll report back once I do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions