Skip to content

Increased memory usage with mimalloc #135153

Description

@nascheme

This was originally going to be titled "with free-threaded build". However, based on some investigation, the increased memory usage is seemingly largely due to the use of mimalloc rather than free-threading specific behaviors. This issue will focus on mimalloc causing increased memory usage.

Here are some techniques I used to investigate this, which might be useful to others. Note that these instructions are specific for running on Linux.

  • In Include/internal/mimalloc/mimalloc/types.h, uncomment the MI_TRACK_VALGRIND define. This allows the "massif" tool of Valgrind to trace the mimalloc allocations and deallocations. Also note that you must compile with GCC, not Clang.
  • In Objects/mimalloc/alloc.c, comment out the line containing the "reallocation still fits and not more than 50% waste". This makes mimalloc always resize down an object, even if the decrease is less than 50% of the original size. This is not a major cause of extra memory usage but reduces the gap between pymalloc and mimalloc. pymalloc always resizes downwards.
  • Your benchmark script can be run with valgrind --tool=massif ./python <script>. This will create a massif.out.NNN file as a result. That is a text file report of the allocations and where they happened.

As an alternative to "massif", you can also use memray. To make it run with the free-threaded build, you need to uncomment one line (the definition of Py_LIMITED_API in src/memray/_memray/inject.cpp). You do have to rely on --trace-python-allocators as well as --native because mimalloc allocates a 1Gb region right at process startup, but you really want to see the results of tracing python allocations anyway.

Here is a run of pyperformance done by Thomas Wouters, showing memory usage of benchmarks compared to a baseline.
pyperformance results

Based on the pyperformance memory usage statistics, the bm_tomli_loads benchmark is a benchmark that uses quite a lot of extra memory under the free-threaded build vs the default build. To make it easier to run, I wrote a simpler version of a benchmark that uses the builtin tomllib module instead. It also shows a similar amount of extra memory usage.

bench_tomllib_mem.py.txt

Some additional scripts, to dump out memory usage stats:

memory_stats.py.txt
count_used_pages.py.txt

Statistics on RSS (resident set size) when running this script under various Python builds:

Build RSS (kB) Increase
Default build, pymalloc 131,192 -
Default build, mimalloc 196,796 1.5x
FT build, mimalloc 213,408 1.6x

Note that to run the default build with mimalloc, you must set PYTHONMALLOC=mimalloc in the environment. Otherwise, pymalloc is used, even if mimalloc is available in the build. The FT build always uses mimalloc.

Using the massif reports, we can examine where the memory is being allocated from. Below is a summary for three different builds.

Build Mem (MB)
default, pymalloc:
67.5 PyUnicode_New, _PyUnicodeWriter_PrepareInternal
0.2 PyUnicode_New, others
16.8 _PyBytes_Resize
3.9 new_keys_object
88.5 sub-total
90.3 total heap usage reported by massif
default, mimalloc:
67.5 PyUnicode_New _PyUnicodeWriter_PrepareInternal
16.8 _PyBytes_FromSize
37.8 PyUnicode_New unicode_subscript
8.5 new_keys_object
130.6 sub-total
136.5 total heap usage reported by massif
free-threaded, mimalloc:
67.5 PyUnicode_New _PyUnicodeWriter_PrepareInternal
16.8 _PyBytes_FromSize
47.7 PyUnicode_New unicode_subscript
8.7 new_keys_object
140.7 sub-total
147.0 total heap usage reported by massif

Based on these results, my suspicion is that mimalloc can waste quite a lot of memory compared to pymalloc when many small allocations are made. I suspect so because it seems most of the extra memory usage is due to the unicode_subscript calls. For the pymalloc build, that memory must be accounted under the "PyUnicode_New, others" category (massif doesn't show the unicode_subscript call explicitly). More work is needed to confirm this theory. Another theory is that mimalloc is not releasing the freed memory back to the OS as aggressively as pymalloc does.

Activity

  1. added
    type-bugAn unexpected behavior, bug, or error
    interpreter-core(Objects, Python, Grammar, and Parser dirs)
    on Jun 7, 2025
  2. nascheme commented on Jun 20, 2025

    @nascheme
    MemberAuthor

    Some additional information about this. Using the MIMALLOC_PURGE_DELAY setting makes a big difference on the RSS when mimalloc is used. This controls how quickly mimalloc will release unused memory back to the OS. On Linux, by default, it will use MADV_DONTNEED on the memory-mapped pages it no longer needs. By default, mimalloc will wait in case the program needs more memory. This is a kind of classic memory use vs speed tradeoff. When using the malloc built-in to glibc combined with pymalloc, unused memory is generally returned to the OS quickly.

    Comparisons of RSS with different options running the bench_tomllib_mem.py.txt script:

    Config RSS [MB] Command-line
    default, pymalloc 70 uv run --python=3.14 python
    default, mimalloc 94 PYTHONMALLOC=mimalloc uv run --python=3.14 python
    defauilt, mimalloc, purge 72 PYTHONMALLOC=mimalloc MIMALLOC_PURGE_DELAY=0 uv run --python=3.14 python
    free-threaded 106 uv run --python=3.14t python
    free-threaded, purge 88 MIMALLOC_PURGE_DELAY=0 uv run --python=3.14t python
  3. tim-one commented on Apr 13, 2026

    @tim-one
    Member

    May be largely unavoidable? I don't know mimalloc internals, but my understanding is that mimalloc's "ssgments" are substantially larger than pymalloc's "arenas", and that's the granularity at which they request/free RAM from/to the OS.

    pymslloc used to return an arena to the OS as soon as it became empty. Then it changed to save at most one empty arena on an internal free list, to prevent pathological cases of thrashing. in loops that systematically used and freed one arena per iteration. That increased "committed memory", but only by the size of one arena (currently 1 MiB). With larger segments, and/or less eager freeing, it would get worse.

  4. pitrou commented on Apr 13, 2026

    @pitrou
    Member

    As @nascheme said above, this is probably mostly the outcome of mimalloc not returning memory to the OS eagerly. This is the behavior of most modern allocators, because returning memory to the OS (sometimes only to ask for more memory microseconds later) is expensive.

    In general, "memory usage" has become a tricky thing to diagnose and understand, due to the multiple layers of caching that can exist (for good reasons).

  5. nascheme commented on Apr 13, 2026

    @nascheme
    MemberAuthor

    Perhaps we should just close this? It seems to me there is nothing really to fix here. mimalloc being less eager to return memory to the OS is most of the issue. There are some additional factors involved with the free-threaded build. Biased reference counting and QSBR (essentially a delayed memory free mechanism) makes it so that with the free-threaded build it's possible there are even more delaying in freeing memory.

    There as an issue with QSBR that was fixed. You could set MIMALLOC_PURGE_DELAY=0 but I don't think that should be the default.

    Should we do something about realloc? mimalloc currently only downsizes if the new size is <= 50% of the old. If we don't change that, perhaps the logic on objects like dicts and lists needs to be adjusted. E.g. no use resizing a list down to 70% of it's original size if mimalloc gives you the same block back.

  6. tim-one commented on Apr 13, 2026

    @tim-one
    Member

    Agreed that MIMALLOC_PURGE_DELAY=0 would be a bad idea - and at least for the same reasons that pymalloc returning empty arenas ASAP turned out to be a bad idea 😉.

    I don't think complicating list/dict/set resizing would help either. It's already the case that lists don't shrink unless the new size is strictly less than half of list's idea of the current maximum capacity. Don't recall dict/set details, but they always request room for a power of 2 number of slots, So it requires "big shrinkage" for them too to try a realloc.

    I'd be happier if mimalloc's delay triggered on a measure of how much RAM could be freed rather than on elapsed time. But not happier enough to suggest changing that for real 😉,

    In the absence of something "actionable", ya, closing this would be best. Thanks for digging into it!

  7. nascheme commented on Apr 14, 2026

    @nascheme
    MemberAuthor

    Closing as there doesn't seem any specific action to take on this. The higher memory use of bm_tomli_loads was much improved by the QSBR changes in #135473.

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

    interpreter-core(Objects, Python, Grammar, and Parser dirs)performancePerformance or resource usagetype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions