Skip to content

Free-threaded build never returns freed memory to the OS on macOS, even with gc.collect() and MIMALLOC_PURGE_DELAY=0 #158972

Description

@dotdandotunderscore

Bug report

On the free-threaded build, memory freed after a large transient allocation is never returned to the OS. RSS stays at the peak permanently. The free-threading HOWTO says gc.collect() releases memory held by QSBR, and that MIMALLOC_PURGE_DELAY=0 reduces mimalloc's delay before returning freed memory to the OS. On macOS neither changes anything and RSS does not drop at all.

The default (GIL) build of the same version returns almost everything.

Reproduction script:

import gc
import os
import subprocess
import sys
import time


def rss_mib():
    out = subprocess.run(["ps", "-o", "rss=", "-p", str(os.getpid())], capture_output=True, text=True)
    return int(out.stdout) // 1024


free_threaded = not sys._is_gil_enabled()
print(sys.version.split()[0], "free-threaded" if free_threaded else "default (GIL)")
print(f"start                    {rss_mib():6d} MiB")

junk = [[i] * 30 for i in range(4_000_000)]
print(f"after allocating         {rss_mib():6d} MiB")

del junk
gc.collect()
gc.collect()
print(f"after del + gc.collect() {rss_mib():6d} MiB")

deadline = time.monotonic() + 5
while time.monotonic() < deadline:
    [str(i) for i in range(20_000)]
gc.collect()
print(f"after 5s more activity   {rss_mib():6d} MiB")

Output from macOS 26.7.1, arm64:

$ PYTHON_GIL=0 python3.14t repro.py
3.14.8 free-threaded
start                        21 MiB
after allocating           1466 MiB
after del + gc.collect()   1466 MiB
after 5s more activity     1466 MiB

$ MIMALLOC_PURGE_DELAY=0 PYTHON_GIL=0 python3.14t repro.py
3.14.8 free-threaded
start                        21 MiB
after allocating           1466 MiB
after del + gc.collect()   1466 MiB
after 5s more activity     1466 MiB

$ python3.14 repro.py
3.14.8 default (GIL)
start                        16 MiB
after allocating           1335 MiB
after del + gc.collect()     50 MiB
after 5s more activity       51 MiB

As far as I can tell, its the same behaviour on 3.13.16t and 3.15.0rc2t (measured with vmmap though). on the 3.15 version, MIMALLOC_PURGE_DELAY=0 makes no difference as well. This whole issue could be related to microsoft/mimalloc#1422 but im not entirely certain.

I have tried a few mimalloc options as well with no luck. It also doesn't seem to matter if the allocation and free-ing happen on the main thread or a worker thread that exits or a worker thread that stays alive for a long period.

This seems to peak out depending on what you are doing on the threads rather than leaking forever in an unbounded way, if I do a repeated 0.5GBish allocate-then-free several times (on the same or a new one every time) then RSS stays approximately flat at whatever its first peak hit.

vmmap is reporting the retained memory as belonging to IOAccelerator but I believe that is because the mi_options_os_tag defaults to 100 which maps to IOAccelerator on mac. It may be worth choosing a different value for this as it originally caused me to misdiagnose this as a GPU memory leak.

CPython versions tested on:

3.14, 3.15, 3.13

Operating systems tested on:

macOS

Activity

  1. picnixz commented on Oct 7, 2026

    @picnixz
    Member

    Another question: is the memory reclaimed at the very end of the interpreter? we are not leaking it at all right?

    The free-threading HOWTO says gc.collect() releases memory held by QSBR, and that MIMALLOC_PURGE_DELAY=0 reduces mimalloc's delay before returning freed memory to the OS

    The mimalloc docs (https://microsoft.github.io/mimalloc/environment.html) also say:

    MIMALLOC_PURGE_DECOMMITS=1: By default "purging" memory means unused memory is decommitted (MEM_DECOMMIT on Windows, MADV_DONTNEED (which decresease rss immediately) on mmap systems). Set this to 0 to instead "reset" unused memory on a purge (MEM_RESET on Windows, generally MADV_FREE (which does not decrease rss immediately) on mmap systems). Mimalloc generally does not "free" OS memory but only "purges" OS memory, in other words, it tries to keep virtual address ranges and decommits within those ranges (to make the underlying physical memory available to other processes).

    So the "which does not decrease rss immediately" may be the reason here. I don't know if 5s is within the "immediate" window or not. But maybe we are holding the memory for far too long (or maybe there is still a reference because of deferred reference counting?)

    cc @colesbury

  2. dotdandotunderscore commented on Oct 8, 2026

    @dotdandotunderscore
    Author

    Had a look, you are right its not a permanent leak and the memory gets freed at shutdown. There are no remaining references at all from what I can see, tracemalloc shows us going all the way back down to 0.0MiB after del and gc.collect(), with len(gc.get_objects()) also droping from ~4,000,000 to about 10,000.

    My MIMALLOC_VERBOSE=1 is reporting purge_decommits: 1 by default and setting MIMALLOC_PURGE_DECOMMITS=1 manually changes nothing so I don't think MADV_FREE is the problem.

    I'm seeing RSS after the free as 1529MiB and after another 60s idle it is at 1430MiB, which climbs back to 1529MiB after 60s more of light allocation work.

    My MIMALLOC_SHOW_STATS=1 on this has:

    • defaulg: purged: 32.0 MiB for the whole run against ~1.4GiB freed
    • MIMALLOC_PURGE_DELAY=0: purged: 0, and committed is showing 329.0GiB freed/ current -327.0 GiB, which looks like churn on small pages but with the large freed range never being purged.

    So i think the empty pages are never reaching the purge path rather than being cleaned up lazily. Is it possible that nothing is actually triggering the mi_arenas_try_purge during normal use?

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions