Repository navigation
Free-threaded build never returns freed memory to the OS on macOS, even with gc.collect() and MIMALLOC_PURGE_DELAY=0 #158972
Description
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
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
picnixz commentedon Oct 7, 2026
Another question: is the memory reclaimed at the very end of the interpreter? we are not leaking it at all right?
The mimalloc docs (https://microsoft.github.io/mimalloc/environment.html) also say:
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
dotdandotunderscore commentedon Oct 8, 2026
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
delandgc.collect(), withlen(gc.get_objects())also droping from ~4,000,000 to about 10,000.My
MIMALLOC_VERBOSE=1is reportingpurge_decommits: 1by default and settingMIMALLOC_PURGE_DECOMMITS=1manually 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=1on this has: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_purgeduring normal use?mpage commentedon Oct 21, 2026
I spent a bit of time on this with claude and it looks like there are actually three separate issues:
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 havegc.collect()call_PyMem_mi_heap_collect_qsbr(). This affects Linux too._mi_prim_decommitusesmadvise(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:
gc.collect().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.corona10 commentedon Oct 21, 2026
cc @DinoV