GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — gh-113141: Upgrade mimalloc to upstream v3 by DinoV · Pull Request #159718 · python/cpython

Conversation

@DinoV

@DinoV DinoV commented Oct 19, 2026 •
edited by bedevere-app Bot
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

This upgrades mimalloc to v3 - the initial work here was done by @daanx and this pushes it over the edge to getting all CI passing and conforming we're at parity with the existing implementation. v3 brings with it baked in qsbr support and it has a number of the CPython specific changes upstreamed now.

To maintain parity with v2 on the pyperformance benchmarks we set several options back to their v2 levels in pycore_mimalloc.h.

There's a new Github CI job added which took 15 seconds to run on this job that verifies we have now modified mimalloc. It is modeled after the expat refresh script / CI job. We sync to a specific mimalloc tag and have the hash recorded. It downloads and verifies that the mimalloc vendored code (which lives in Objects/mimalloc and Include/internal/mimalloc) is unchanged.

  • Issue: Mimalloc differences from upstream #113141

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@python-cla-bot

python-cla-bot Bot commented Oct 19, 2026

Copy link
Copy Markdown

The following commit authors need to sign the Contributor License Agreement:

  • [email protected]

CLA not signed

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@read-the-docs-community

read-the-docs-community Bot commented Oct 19, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

Documentation build overview

📚 cpython-previews | 🛠️ Build #35312361 | 📁 Comparing cff014b against main (415ebff)

  🔍 Preview build  

56 files changed · + 1 added · ± 55 modified

+ Added

  • c-api/pyexpat.html

± Modified

  • license.html
  • py-modindex.html
  • c-api/code.html
  • c-api/conversion.html
  • c-api/descriptor.html
  • c-api/dict.html
  • c-api/exceptions.html
  • c-api/extension-modules.html
  • c-api/file.html
  • c-api/function.html
  • and 45 more...
All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

github-advanced-security[bot]
github-advanced-security Bot found potential problems Oct 19, 2026
View reviewed changes
Comment thread Objects/mimalloc/util/libc.c Fixed

Uh oh!

There was an error while loading. Please reload this page.

@StanFromIreland

StanFromIreland commented Oct 19, 2026

Copy link
Copy Markdown
Member

@daanx can you please sign the CLA?

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@daanx

daanx commented Oct 19, 2026

Copy link
Copy Markdown

@daanx can you please sign the CLA?

Ah yes, but not really for the current PR as that includes the full mimalloc v3 vendored sources and that is a separate project under the MIT license.

I can of course sign off for the changes I made on the CPython side. The current PR contains both, maybe we can split it? I think commit cedd47b is the CPython part only, while the other commits are just importing mimalloc v3 as a vendored library.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

daanx
daanx reviewed Oct 19, 2026
View reviewed changes

@daanx daanx left a comment

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using MI_WIN_INIT_USE_FLS is not really encouraged for mimalloc. If we can use the default that would be best.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@DinoV

DinoV commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Using MI_WIN_INIT_USE_FLS is not really encouraged for mimalloc. If we can use the default that would be best.

There's a crash on the Windows builds w/o it currently (and it seems to show up basically at startup but the freeze programs make it through it). I was planning on digging into it more later but this is what Claude came up as a fix - I just don't have immediate access to a Windows machine to see where the actual crash is coming from. But that's why it's there for now.

Right now I'm just trying to get things to green CI and then I'll clean up the final result.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV

DinoV commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@corona10 Hey here it is!

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@corona10 corona10 assigned corona10, DinoV and itamaro Oct 20, 2026
@corona10

corona10 commented Oct 20, 2026

Copy link
Copy Markdown
Member

For TSAN fail see: 2599016

IIRC, it was already happened at

cpython/Objects/mimalloc/os.c

Lines 245 to 268 in 34fe59b

else {
// if not aligned, free it, overallocate, and unmap around it
// NOTE(sgross): this warning causes issues in Python tests
// _mi_warning_message("unable to allocate aligned OS memory directly, fall back to over-allocation (size: 0x%zx bytes, address: %p, alignment: 0x%zx, commit: %d)\n", size, p, alignment, commit);
mi_os_prim_free(p, size, commit, stats);
if (size >= (SIZE_MAX - alignment)) return NULL; // overflow
const size_t over_size = size + alignment;
if (mi_os_mem_config.must_free_whole) { // win32 virtualAlloc cannot free parts of an allocate block
// over-allocate uncommitted (virtual) memory
p = mi_os_prim_alloc(over_size, 1 /*alignment*/, false /* commit? */, false /* allow_large */, is_large, is_zero, stats);
if (p == NULL) return NULL;
// set p to the aligned part in the full region
// note: this is dangerous on Windows as VirtualFree needs the actual base pointer
// this is handled though by having the `base` field in the memid's
*base = p; // remember the base
p = mi_align_up_ptr(p, alignment);
// explicitly commit only the aligned part
if (commit) {
_mi_os_commit(p, size, NULL, stats);
}
}

DinoV reacted with heart emoji
All reactions
  • ❤️ 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@corona10

corona10 commented Oct 20, 2026

Copy link
Copy Markdown
Member

About guardrail for memory pressure

any changes expected to increase memory above 15% (with a hard limit of 20%) should be discussed with the SC in advance.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV

DinoV commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Perf run is here waiting for results: https://github.com/facebookexperimental/free-threading-benchmarking/actions/runs/39346123598

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV

DinoV commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@daanx can you please sign the CLA?

Ah yes, but not really for the current PR as that includes the full mimalloc v3 vendored sources and that is a separate project under the MIT license.

I can of course sign off for the changes I made on the CPython side. The current PR contains both, maybe we can split it? I think commit cedd47b is the CPython part only, while the other commits are just importing mimalloc v3 as a vendored library.

FWIW the docs due already call out mimalloc license: https://docs.python.org/3/license.html#mimalloc although we should probably update the date range w/ the v3 update.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@corona10

corona10 commented Oct 20, 2026

Copy link
Copy Markdown
Member

perf run is here waiting for results: https://github.com/facebookexperimental/free-threading-benchmarking/actions/runs/39346123598

2x to 1.14x but still higher than mimalloc v2

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

corona10
corona10 reviewed Oct 20, 2026
View reviewed changes
Comment thread Objects/obmalloc.c Outdated
}
}
// Hooks can run under allocator locks, so only consult cached progress.

@corona10 corona10 Oct 20, 2026

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Hooks can run under allocator locks, so only consult cached progress.
if (qsbr != NULL) {
// Scan thread sequences like the v2 integration did.
return !_Py_qsbr_poll(qsbr, page->goal);
}
// Hooks can run under allocator locks, so only consult cached progress.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

DinoV reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

@corona10 corona10 Oct 20, 2026

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar logic with

cpython/Objects/obmalloc.c

Line 249 in 4c73f50

if (!_Py_qsbr_poll(tstate->qsbr, page->qsbr_goal)) {

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@DinoV

DinoV commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

New benchmark run: https://github.com/facebookexperimental/free-threading-benchmarking/actions/runs/39373144391

corona10 reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV

DinoV commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

New benchmark run: https://github.com/facebookexperimental/free-threading-benchmarking/actions/runs/39373144391

That was still a 6% regression on memory (but things are headed in the right direction, THP tweak about it from 2x to 1.15, and then the _Py_qsbr_poll fix got us here), here's another run https://github.com/facebookexperimental/free-threading-benchmarking/actions/runs/39476892845 which has commit 3c8be4f that sets mi_option_purge_delay and mi_option_arena_purge_mult to their v2 values.

corona10 reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV

DinoV commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Down to a 4% regression now: https://github.com/facebookexperimental/free-threading-benchmarking/tree/main/results/bm-20261020-3.16.0a1+dev-3c8be4f-NOGIL

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV

DinoV commented Oct 20, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor Author

Another run with new options for mi_option_arena_purge_min_size, and mi_option_page_full_retain and a tweaked mi_option_arena_purge_mult: https://github.com/facebookexperimental/free-threading-benchmarking/actions/runs/39497633255

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV DinoV changed the title Mimallocv3 gh-113141: Mimallocv3 Oct 20, 2026
@bedevere-app bedevere-app Bot mentioned this pull request Oct 20, 2026
Mimalloc differences from upstream #113141
Open
@corona10 corona10 mentioned this pull request Oct 20, 2026
gh-131999: Add support for tvOS, visionOS and watchOS. #159944
Merged
@DinoV

DinoV commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Down to 3%

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV

DinoV commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

This is now neutral: https://github.com/facebookexperimental/free-threading-benchmarking/tree/main/results/bm-20261020-3.16.0a1+dev-a36efb6-NOGIL

It includes one more upstream fix which is a36efb6 that re-enables the mi_arenas_try_purge call which tries to purge expired decommits. This was commented out but not removed in the 3x branch.

@daanx Thanks for merging all of the other upstream PRs I sent. I can send another one to re-enable this but I can also throw it under another ifdef if you're concerned about having it on in the common case. But I think this basically brings us to parity where we could merge this. I'd still want to bring back the latest commits you have upstream but my previous run had that as neutral. I also am assuming your dev3 branch isn't yet released?

corona10 reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV

DinoV commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Another benchmark run w/ the latest and greatest mimalloc/dev3 branch merged in: https://github.com/facebookexperimental/free-threading-benchmarking/actions/runs/39651451425

We're now down to 4 vendored in changes which I've opened upstream PRs for (the last being the most questionable I think):
microsoft/mimalloc#1445
microsoft/mimalloc#1446
microsoft/mimalloc#1447
microsoft/mimalloc#1448

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV

DinoV commented Oct 21, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor Author

This should also resolve:
#118072
#134070
#113141
#159111
#134070
#133571

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV DinoV changed the title gh-113141: Mimallocv3 gh-113141: Upgrade mimalloc to upstream v3 Oct 21, 2026
@DinoV DinoV added the 🔨 test-with-refleak-buildbots Test PR w/ refleak buildbots; report in status section label Oct 21, 2026
DinoV added 14 commits November 3, 2026 09:58
@DinoV
More options
745da31
@DinoV
Update vendored mimalloc to upstream dev3
1444adc
Sync Objects/mimalloc and Include/internal/mimalloc with
microsoft/mimalloc dev3 (1ff2382, "fix profile timestamps"). Most of
the local edits previously carried in the vendored copy have been
merged upstream (forceinline declaration order, WASI page size,
quoted includes, MSan syscalls, Emscripten builtin allocator,
MI_NO_ALIGN_FALLBACK_WARNING, MI_NO_CXX_NEW_HANDLER, __ImageBase,
trailing whitespace). The FLS init assertion fix is dropped as CPython
no longer uses MI_WIN_INIT_USE_FLS.

Changes still carried on top of dev3 (proposed upstream):

- Fix build with MI_NO_THP after the MADV_NOHUGEPAGE change
- Fix -Wsign-compare warning when reading hpage_pmd_size
- Fix data race on the thread-done pthread key at process exit

CPython-specific change:

- Purge expired ranges on arena free, as in mimalloc v2, to keep the
  peak memory of short-running processes at v2 levels.

Define MI_NO_ALIGN_FALLBACK_WARNING in pycore_mimalloc.h, since the
vendored copy now has the upstream opt-out instead of the warning being
commented out.
@DinoV
Add blurbit
a568f42
@DinoV
Experiment
6cd3695
@DinoV
Update mimalloc commit
6e6f915
@DinoV
Add verify mimalloc workflow
39e9148
@DinoV
Revert "Experiment"
c0ae33b
This reverts commit 8e6401e.
@DinoV
Update vendored mimalloc to caaf3bc41494e6b58ab32ed9c851b3c8b61a8f82
6c4d3ee
Vendor mimalloc v3 (dev3 branch) into Objects/mimalloc and
Include/internal/mimalloc.
@DinoV
Update to mimallc v3.6.0
57b42c1
@DinoV
Only do set defaults in the main interpreter
b35359d
@DinoV
Update to mimalloc 3.6.1
48b8c0f
@DinoV
Test mi_option_page_full_retain=0
dd22466
@DinoV
Revert mi_option_page_full_retain
efd6b5b
@DinoV
Update slice shift define
80f2c43
@DinoV
DinoV force-pushed the mimallocv3 branch from 2934fd1 to 80f2c43 Compare November 3, 2026 17:59
@DinoV

DinoV commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

One more run after merging against and updating the define for 3.6 #160360

https://github.com/facebookexperimental/free-threading-benchmarking/actions/runs/41690003577

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@DinoV
Run again w/o slice shift
48883a2
@DinoV

DinoV commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

It looks like the slice shift tweak hits assertions, doing another perf run w/o it and with the base having it: https://github.com/facebookexperimental/free-threading-benchmarking/actions/runs/41693317724

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@maikschulze maikschulze mentioned this pull request Nov 5, 2026
Free-threaded 3.14.10 and 3.15.2 keep memory freed by other threads: 10 GB peaks since the mimalloc 2.5.2 update #161420
Closed
@maikschulze

maikschulze commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

While looking into #161420, I tried this PR. In #161420, worker threads allocate 16 MiB bytes objects that the main thread frees, a few at a time. That needs about 150 MiB, but mimalloc 2.5.2 holds back the blocks other threads freed, up to about 10 GB.

The v3 code in this PR would fix that, but one of the option defaults the PR sets for CPython turns the fix off.

Why v3 fixes it. With its default options, v3 abandons a page that becomes full: the page no longer belongs to the allocating thread (_mi_page_to_full() calls _mi_page_abandon()). When another thread frees a block on an abandoned page, that free collects the page right away (mi_free_try_collect_mt()), so the memory can be reused at once.

Why it comes back here. This PR sets page_full_retain to -1 in set_mimalloc_defaults(), and that turns abandoning off (allow_page_abandon = (page_full_retain >= 0)). Full pages then stay with the allocating thread, and frees from other threads are only collected during that thread's housekeeping, every 1,000 generic allocations.

Peak memory, with about 150 MiB needed:

v3 default options page_full_retain = -1
upstream mimalloc v3, a C version of the example 146 MiB 11.9 GB
this PR's build, the script from #161420 1.1 GB (MIMALLOC_PAGE_FULL_RETAIN=2) 10.7–17.2 GB (as configured)

Could abandoning stay enabled, at least for pages of large blocks? The C program and all numbers are in this comment on microsoft/mimalloc#1117.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

daanx added a commit to microsoft/mimalloc that referenced this pull request Nov 8, 2026
@daanx
with no-abandon heaps (page_full_retain=-1) ensure huge pages are mor…
6a7e041
…e aggressively collected (see also python/cpython#159718)
daanx added a commit to microsoft/mimalloc that referenced this pull request Nov 8, 2026
@daanx
clear the qsbr owner thread id of pages when a theap abandons all its…
c0ee872
… pages; see also python/cpython#159718
@daanx

daanx commented Nov 9, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

Hi @maikschulze , @DinoV : I agree that using page_full_retain = -1 is not a good setting in general; however, I just pushed fixes to the mimalloc dev3 branch that collect the large allocations more aggressively (and a small extra fix for the mi_thread_id_of to clear it on thread exit). That should fix the issue above; let me know.

However, we should try again also with the default page_full_retain (2). I think there is a mistake in the current PR:

op->ob_tid = mi_page_is_abandoned(page) ? 0 : mi_page_thread_id(page);
if (op->ob_tid == 0) merge_refcount(op, 0);

this should become:

op->ob_tid = mi_thread_id_of(op);   // this gets a CPython thread id (not a mimalloc abandoned page tid) (todo: make this more efficient by avoiding overwriting the tid in the object header (and avoiding this call)?
if (op->ob_tid == 0) merge_refcount(op, 0);

edit: actually, it would be even better to call the internal mi_page_qsbr_thread_id(page) as we have the page anyways (instead of mi_thread_id_of(op)) -- but not sure if that is visible there?

I think with this change we don't need the page_full_retain and can use abandoned pages (which should improve matters). Fingers crossed :-)

(not sure if you can easily pull from the latest dev3 ? but nice to try if we are getting to the point of being a drop-in update. I will make a versioned release later this week).

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@maikschulze

maikschulze commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Thank you, @daanx! I tried the C version of the example (8 threads, 16 MiB blocks freed by the main thread, about 144 MiB in flight) on the latest dev3: with page_full_retain = -1 ("-1" below), the peak now stays at what the blocks in flight need, and a run is as fast as with the default options again. Before the fix, it spent most of its CPU time in the kernel.

dev3, macOS arm64, median of 5 runs peak RSS, default peak RSS, -1 time, default time, -1
36e29b80a4, before the fix 146 MiB 9,396 MiB 0.12 s 1.97 s
c0ee872fbf 146 MiB 154 MiB 0.12 s 0.11 s
90d604b8d5 (latest) 146 MiB 158 MiB 0.11 s 0.11 s
daanx reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@daanx

daanx commented Nov 10, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

Good see that @maikschulze; @DinoV , I tested the latest mimalloc dev3 with the fix I proposed:

--- a/Python/gc_free_threading.c
+++ b/Python/gc_free_threading.c
@@ -311,8 +311,8 @@ gc_restore_tid(PyObject *op)
         op->ob_tid = 0;
     }
     else {
-        // Reclaimed pages may have a new owner; abandoned pages have none.
-        op->ob_tid = mi_page_is_abandoned(page) ? 0 : mi_page_thread_id(page);
+        // Full-page abandonment preserves the QSBR thread ownership.
+        op->ob_tid = mi_page_qsbr_thread_id(page);
         if (op->ob_tid == 0) {
             merge_refcount(op, 0);
         }

and it all seems to work now with default mimalloc settings, i.e. page_full_retain=2 and page_reclaim_on_free=0.

I used the following (agent generated) test script that tries to probe threads creating abandoned pages that get picked up by other threads and gc'd:
import argparse
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
import ctypes
import faulthandler
import gc
import threading
import sys
import weakref

parser = argparse.ArgumentParser()
parser.add_argument("--reclaim", action="store_true")
args = parser.parse_args()

if sys.implementation.name != "cpython" or sys._is_gil_enabled():
    raise SystemExit("Requires free-threaded CPython with the GIL disabled")

TIMEOUT = 30
COUNT = 20_000

faulthandler.dump_traceback_later(TIMEOUT, exit=True)
gc.disable()


def object_thread_id(obj):
    # ob_tid is the first word of a free-threaded CPython object.
    return ctypes.c_size_t.from_address(id(obj)).value


class Node:
    __slots__ = ("value", "link", "__weakref__")

    def __init__(self, value):
        self.value = value
        self.link = None


state = {}
ready = threading.Event()
reclaimed = threading.Event()
finish = threading.Event()


def allocate():
    objects = [Node(i) for i in range(COUNT)]
    state["owner"] = object_thread_id(objects[0])
    state["freed"] = {id(objects[i]) for i in range(0, COUNT, 2)}
    state["garbage"] = [
        weakref.ref(objects[i]) for i in range(0, COUNT, 2)
    ]

    # Half become unreachable cycles, to be freed by GC on another thread.
    for i in range(0, COUNT, 2):
        objects[i].link = objects[i]
        objects[i] = None

    state["objects"] = objects
    ready.set()
    assert finish.wait(TIMEOUT)
    return sum(obj.value for obj in objects if obj is not None)


def reclaim_pages():
    objects = [Node(i) for i in range(COUNT)]
    state["reclaimer"] = object_thread_id(objects[0])
    state["reused"] = sum(
        id(obj) in state["freed"] for obj in objects
    )
    state["new"] = objects
    reclaimed.set()
    assert finish.wait(TIMEOUT)
    return sum(
        obj.value for obj in state["objects"] if obj is not None
    )


expected_sum = sum(range(1, COUNT, 2))

with ThreadPoolExecutor(max_workers=2) as pool:
    owner = pool.submit(allocate)
    other = None
    try:
        assert ready.wait(TIMEOUT)
        objects = state["objects"]
        refs = [
            weakref.ref(obj) for obj in objects if obj is not None
        ]
        assert {
            object_thread_id(obj)
            for obj in objects if obj is not None
        } == {state["owner"]}

        # Free cycles outside their allocating thread, avoiding owner reclaim.
        gc.collect()
        assert all(ref() is None for ref in state["garbage"])

        expected_owners = {state["owner"]}
        if args.reclaim:
            other = pool.submit(reclaim_pages)
            assert reclaimed.wait(TIMEOUT)
            assert state["reused"] > 0, "No freed slots were reused"
            expected_owners.add(state["reclaimer"])

        for _ in range(2):
            gc.collect()
            owners = Counter(
                object_thread_id(obj)
                for obj in objects if obj is not None
            )
            assert owners.keys() <= expected_owners, owners
            if args.reclaim:
                assert owners[state["reclaimer"]] > 0, owners
            assert sum(
                obj.value for obj in objects if obj is not None
            ) == expected_sum
    finally:
        finish.set()

    assert owner.result() == expected_sum
    if other is not None:
        assert other.result() == expected_sum

del objects
state.clear()
gc.collect()
assert all(ref() is None for ref in refs)

faulthandler.cancel_dump_traceback_later()
print("PASS:", "reclaimed pages" if args.reclaim else "abandoned pages")

To run both scenarios with your free-threaded interpreter:

for retain in 0 2; do
    MIMALLOC_PAGE_FULL_RETAIN=$retain MIMALLOC_PAGE_RECLAIM_ON_FREE=0 \
        ./python -X gil=0 qsbr_gc_test.py

    MIMALLOC_PAGE_FULL_RETAIN=$retain MIMALLOC_PAGE_RECLAIM_ON_FREE=0 \
        ./python -X gil=0 qsbr_gc_test.py --reclaim
done
All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@maikschulze

maikschulze commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

Thanks, I reran the #161420 pattern against this PR, in both directions, in case it helps with the page_full_retain choice. In free-threaded builds of main (e8c0584877), large blocks that one thread makes and another frees are much cheaper with this PR's mimalloc v3 (48883a281d merged in, with dev3 6a7e041 vendored instead of v3.6.1) than with mimalloc 2.5.2. On memory, with -1 both directions stay close to main's level; only when worker threads exit does about one block per thread wait until the next garbage collection. With 2, the blocks seem to wait in the thread that frees them, and when that is a pool worker, a large part of what was processed stays in memory. I'm not sure what's best here: I only looked at large blocks passed between threads, which is a small part of the picture.

On speed: 2.5.2 makes an madvise call on every free of a block above 4 MiB from another thread; v3 doesn't. Workers make a bytearray(n) and the main thread frees it, 8 in flight, with the PR's -1 (fastest of 5 rounds of 1 GiB, median of 3 processes, PGO + LTO builds, macOS arm64), GiB/s:

main v3
4 MiB 74 185
8 MiB 75 158
16 MiB 76 145

On memory, the pattern: 16 MiB bytes objects, 8 in flight, 10 GiB in total (10 rounds of 64). Either the workers make them and the main thread frees them, as in #161420, or the main thread reads them from a file and the workers checksum and free them; with one ThreadPoolExecutor kept, or a new one per round, so that the workers exit. "With collections" means gc.collect() after every round; on its own, the pattern triggers 4 or 5 collections. Release builds without PGO, macOS arm64 and Linux arm64 (Docker on the same machine); page_full_retain set through MIMALLOC_PAGE_FULL_RETAIN, the other options as the PR sets them (page_reclaim_on_free is 0). Peak RSS in MiB, medians of 5:

main v3, -1 v3, 2 v3, -1, with collections v3, 2, with collections
macOS, workers make, main frees, one pool kept 426 421 1,016 421 867
macOS, main reads, workers free, one pool kept 84 103 4,851 103 5,493
macOS, workers make, main frees, new pool per round 785 1,452 975 406 790
Linux, workers make, main frees, one pool kept 431 415 1,127 419 861
Linux, main reads, workers free, one pool kept 184 241 11,205 241 11,059
Linux, workers make, main frees, new pool per round 607 1,631 1,026 432 811
  • With -1 and one pool kept, both directions stay within 60 MiB of main. When the workers exit, about one block per exited worker waits (1.4, 2.6 and 5.1 GiB after 10, 20 and 40 rounds on macOS, against 0.7 to 0.8 GiB on main; 2 is lower in this case) until a collection, where _PyMem_ProcessDelayedNoDealloc() collects the abandoned pages. As far as I can tell from counters in an instrumented build, the worker's last page is empty but still delayed when the thread exits; _mi_page_abandon() unlinks it from the thread's QSBR list, and _mi_arenas_page_abandon() leaves a singleton on no list that an allocation or a free would look at (71 to 77 pages, 1.2 to 1.3 GiB, for 80 exited workers).
  • With 2, as far as I can tell, each full singleton page is abandoned right away, and the thread that frees the block reclaims the empty, delayed page into its own QSBR list. When the main thread frees, up to 0.4 GiB waits in its list. When the workers free, about 525 of the 640 pages go to their lists, which they collect only 9 to 18 times in the run, so up to 3.8 GiB waits there, and it grows with the amount processed (5.2, 9.7 and 13.7 GiB for 10, 20 and 40 GiB on macOS, medians of 3). A collection on the main thread doesn't release it, since it only collects its own thread's lists and the abandoned pages. With -1, the main thread keeps these pages and collects its list at every large allocation.
  • How much waits with 2 depends on timing (macOS, single runs): if the main thread copies the blocks in memory instead of reading them from a file, the second row stays at 0.1 GiB; if the workers sleep for 3 ms instead of checksumming, it reaches 1.2 GiB with the copy and 7.9 GiB with the file.
  • Your gc_restore_tid change gives about the same numbers for 2 (in the table's row order: 731, 4,659, 1,036, 945, 11,205 and 785 MiB).
Reproducer (standard library only)
# Large blocks made on one thread and freed on another, 8 in flight: 10 rounds of 64 blocks of 16 MiB.
# usage: python pool_repro.py produce|consume new|keep [gc]
#   produce: workers make the blocks, the main thread frees them (as in gh-161420)
#   consume: the main thread reads the blocks from a file, workers checksum and free them
#   new: a new ThreadPoolExecutor per round (the workers exit); keep: one pool for all rounds
#   gc: call gc.collect() after every round
import gc, resource, sys, zlib
from collections import deque
from concurrent.futures import ThreadPoolExecutor

direction, mode, collect = sys.argv[1], sys.argv[2], sys.argv[3:] == ["gc"]
BLOCK, COUNT, THREADS, ROUNDS = 16 << 20, 64, 8, 10
pattern = bytes(range(256)) * (BLOCK // 256)
source = open("/dev/zero", "rb", buffering=0)

def make():
    return memoryview(pattern).tobytes()  # a new bytes object, as decompress() returns

kept = ThreadPoolExecutor(THREADS) if mode == "keep" else None
for _ in range(ROUNDS):
    pool = kept or ThreadPoolExecutor(THREADS)
    pending = deque()
    for _ in range(COUNT):
        if direction == "produce":
            pending.append(pool.submit(make))  # the block is freed on the main thread
        else:
            pending.append(pool.submit(zlib.crc32, source.read(BLOCK)))  # freed on the worker
        if len(pending) >= THREADS:
            pending.popleft().result()
    while pending:
        pending.popleft().result()
    if kept is None:
        pool.shutdown()  # the workers exit
    if collect:
        gc.collect()

peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
peak = peak >> 20 if sys.platform == "darwin" else peak >> 10
print(f"{direction} {mode}{' gc' if collect else ''}: peak RSS {peak} MiB")
for retain in -1 2; do
  for args in "produce keep" "consume keep" "produce new" "produce new gc" "consume keep gc"; do
    MIMALLOC_PAGE_FULL_RETAIN=$retain ./python pool_repro.py $args
  done
done

A probe that suggests the waiting pages are the whole difference (for tracing only, since it lets memory be reused while lock-free readers may still look at it): returning false at the top of _mi_page_qsbr_should_delay() keeps every row of the table at 0.1 to 0.3 GiB, with either setting.

   if (!mi_heap_qsbr_is_enabled(heap)) return false;
+  return false;  // PROBE ONLY: never delay empty pages for QSBR (unsafe in general)
   const bool delayed = mi_page_qsbr_should_delay(&page->qsbr, &heap->qsbr, page->capacity * mi_page_block_size(page));

The counters are a small patch I can share if useful.

Separately: in v3, a block above 31 MiB seems to be purged on every free. As far as I can see, _mi_os_good_alloc_size() rounds such a request up to at least 32 MiB, which reaches the default arena_purge_immediate_size (microsoft/mimalloc#1422, raised to 32 MiB in bbe184ae). When each thread allocates and frees its own block, v3 runs at 54 GiB/s at 31 MiB against 229 with 2.5.2 on macOS (PGO + LTO, medians of 5), and at 105 against 207 on Linux (release builds, medians of 3); with MIMALLOC_ARENA_PURGE_IMMEDIATE_SIZE=1GiB it is back at 188 and 180, and at 30 MiB all of them run at about 195 to 230. A bytearray that grows to 30 MiB reaches it too, as its last step asks for 33.75 MiB.

I used an AI assistant to run these measurements and draft this comment; I reviewed the results myself.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

daanx added a commit to microsoft/mimalloc that referenced this pull request Nov 11, 2026
@daanx
do not reclaim empty abandoned QSBR pages to avoid retaining memory f…
7f615b5
…or too long, issue python/cpython#159718
daanx added a commit to microsoft/mimalloc that referenced this pull request Nov 11, 2026
@daanx
Retain QSBR-protected singleton pages in the owning theap's huge queu…
07db29a
…e, see issue python/cpython#159718
@daanx

daanx commented Nov 11, 2026

Copy link
Copy Markdown

Thanks @maikschulze -- I pushed an update to the latest dev3 branch; I think the main issue is/was that foreign threads should not reclaim abandoned empty qsbr pages (as that can lead to them being not collected for a while). Hope that fixes the differences seen in your experiment. (of course, note that the benchmark is synthetic and tests a worst-case scenario with pure allocators and free'ers).

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@maikschulze

maikschulze commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Thanks, that was quick! With dev3 at 80899607d2 vendored (otherwise the same builds as above), 2 now comes out where -1 does, in both directions. Peak RSS in MiB, medians of 5, old and new build run side by side:

main 6a7e041, 2 80899607d2, 2 80899607d2, -1
macOS, workers make, main frees, one pool kept 409 1,007 410 422
macOS, main reads, workers free, one pool kept 84 4,547 102 104
Linux, workers make, main frees, one pool kept 499 1,024 440 420
Linux, main reads, workers free, one pool kept 179 9,153 242 242

You're right that the benchmark is synthetic, so, with an AI assistant's help, I put the same builds under a real program: a package manager that extracts .tar.zst archives on 8 threads, one decompress() call per frame, a new pool per archive. With a single extraction, 2 is now level with -1 there too (an archive holding 14 GB: 553 MiB, 829 before). Repeated extractions in one process hit the case that remains, where workers exit and their pages wait for the next collection. That's where the garbage collector's role became clear to me: in my script I had treated it as a side note, but in the real program it decides the outcome. 20 extractions of an archive holding 1.2 GB in one process, no gc.collect(), release builds, macOS arm64, medians of 3:

GIL, 2.5.2 GIL, v3 free-threaded, 2.5.2 free-threaded, v3, -1 free-threaded, v3, 2
peak RSS in MiB 460 468 609 3,294 3,277
collections during the run 21 21 0 0 0

(2.5.2 is main, v3 is 80899607d2; GIL builds with PYTHONMALLOC=mimalloc. With frames in 1 MiB pieces, the program's default on free-threaded builds, the picture is the same: 339 against about 2,000 MiB.) So v3 seems to reveal a gap that reaches user code: on the free-threaded build, this program would need its own gc.collect() calls, which it didn't with 2.5.2. As far as I can tell from counters in instrumented builds and from the code:

  • With v3, about two pages per exited worker end up empty but still delayed by QSBR, most of them already when the thread exits (about 350 singletons, 3.4 GiB, by the end of the run). _mi_arenas_page_abandon() keeps them out of the abandoned maps, so no allocation picks them up; only _mi_heap_collect_abandoned() frees them, and CPython calls that only during a collection.
  • With 2.5.2, allocations that need a new segment free them in mi_segment_try_reclaim(): 3.3 GiB over the same run, no collection needed.
  • And the free-threaded GC never runs here: gc_should_collect() waits until new objects reach a quarter of the long-lived ones (about 8,700 here), and extracting mostly makes bytes.

As an experiment only, I let gc_should_collect() return true once exiting threads had abandoned 64 MiB of such pages (just scheduling a collection wasn't enough, the re-check drops it). The run above then peaks at 663 MiB with 20 collections of about 2 ms each, and my reproducer with a new pool per round (pool_repro.py produce new) at about 400 MiB instead of about 1,500.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

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

Reviewers

@corona10 corona10 corona10 left review comments
@ericsnowcurrently ericsnowcurrently Awaiting requested review from ericsnowcurrently ericsnowcurrently will be requested when the pull request is marked ready for review ericsnowcurrently is a code owner
@ZeroIntensity ZeroIntensity Awaiting requested review from ZeroIntensity ZeroIntensity will be requested when the pull request is marked ready for review ZeroIntensity is a code owner
@FFY00 FFY00 Awaiting requested review from FFY00 FFY00 will be requested when the pull request is marked ready for review FFY00 is a code owner
@AA-Turner AA-Turner Awaiting requested review from AA-Turner AA-Turner will be requested when the pull request is marked ready for review AA-Turner is a code owner
@erlend-aasland erlend-aasland Awaiting requested review from erlend-aasland erlend-aasland will be requested when the pull request is marked ready for review erlend-aasland is a code owner
@emmatyping emmatyping Awaiting requested review from emmatyping emmatyping will be requested when the pull request is marked ready for review emmatyping is a code owner
@itamaro itamaro Awaiting requested review from itamaro itamaro will be requested when the pull request is marked ready for review itamaro is a code owner
@ezio-melotti ezio-melotti Awaiting requested review from ezio-melotti ezio-melotti will be requested when the pull request is marked ready for review ezio-melotti is a code owner
@hugovk hugovk Awaiting requested review from hugovk hugovk will be requested when the pull request is marked ready for review hugovk is a code owner
@webknjaz webknjaz Awaiting requested review from webknjaz webknjaz will be requested when the pull request is marked ready for review webknjaz is a code owner
@JacobCoffee JacobCoffee Awaiting requested review from JacobCoffee JacobCoffee will be requested when the pull request is marked ready for review JacobCoffee is a code owner
@StanFromIreland StanFromIreland Awaiting requested review from StanFromIreland StanFromIreland will be requested when the pull request is marked ready for review StanFromIreland is a code owner
+2 more reviewers
@daanx daanx daanx left review comments
@github-advanced-security github-advanced-security[bot] github-advanced-security[bot] left review comments
Reviewers whose approvals may not affect merge requirements

Uh oh!

There was an error while loading. Please reload this page.

Assignees

@itamaro itamaro

@DinoV DinoV

@corona10 corona10

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

8 participants

@DinoV @StanFromIreland @daanx @corona10 @bedevere-bot @maikschulze @github-advanced-security @itamaro
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.