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

Open

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

Linked PRs

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?

  3. self-assigned this
    on Oct 20, 2026
  4. mpage commented on Oct 21, 2026

    @mpage
    Contributor

    I spent a bit of time on this with claude and it looks like there are actually three separate issues:

    1. Empty pages parked for QSBR don't get released by gc.collect(). In the example, there's a really large number of empty pages that could be freed that sit on the thread's page list. That list is only freed by _PyMem_mi_heap_collect_qsbr(), which is only called when an allocation can't find a page with free space. We should probably have gc.collect() call _PyMem_mi_heap_collect_qsbr(). This affects Linux too.
    2. mimalloc's delayed purges never fire once the process goes quiet. Freed memory is only scheduled to be returned to the OS, after 10 ms per segment or 100 ms per arena. The scheduled return only happens on a later allocation from the same segment, a later arena free, or a forced mi_collect(true). After one burst of frees, none of those happen; the stats show purged: 0 the whole time. This is the problem described in Freed arena memory stays resident in an idle process; no targeted or size-threshold eager purge short of process-wide purge_delay=0 microsoft/mimalloc#1422.
    3. On macOS, the vendored mimalloc's "give memory back" call does nothing. _mi_prim_decommit uses madvise(MADV_DONTNEED). Upstream mimalloc fixed this in Apple friendly way to madvice on commit/decommit: MADV_FREE_REUSABLE/MADV_FREE_REUSE microsoft/mimalloc#1097; CPython's copy predates that fix.

    I think there are two fixes here:

    • Drain each thread's parked-page list in gc.collect().
    • Upgrade to the latest version of mimalloc, which includes the fix for madvise(MADV_DONTNEED) on macos. That work is already in progress (Upgrade mimalloc version from 2.1.2 to 2.5.2 #159111), so we can just wait until it lands.
  5. corona10 commented on Oct 21, 2026

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

Metadata

Metadata

Assignees

Labels

Projects

  • Status
    Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions