Skip to content

Free-threaded: per-thread refcount arrays cost 8 bytes x unique-id pool size of RSS in every thread #161998

Open

Description

@aryanj723

Bug report

In the free-threaded build (--disable-gil), heap types, code objects and some dicts get a unique id (_PyObject_AssignUniqueId, Python/uniqueid.c), and each thread keeps a Py_ssize_t reference-count array indexed by that id (_PyThreadStateImpl.refcounts). The first time a thread increfs an object whose id is beyond its array, resize_local_refcounts() grows the array to the size of the entire interpreter pool and memsets the new part:

Py_ssize_t size = _Py_atomic_load_ssize(&pool->size);
Py_ssize_t *refcnts = PyMem_Realloc(tstate->refcounts.values, size * sizeof(Py_ssize_t));
...
memset(refcnts + old_size, 0, (size - old_size) * sizeof(Py_ssize_t));

The pool doubles (resize_interp_type_id_pool), so with N ids alive, each thread that touches a recent id pays 8 * next_pow2(N) bytes, and all of it is resident because of the memset, even if the thread only ever increfs a handful of ids. A program that imports a lot of code passes 32,768 ids quickly (code objects alone), so every thread costs an extra 256 kB or 512 kB.

main has the same code.

Reproducer

Save as refcount_arrays.py. It creates N_IDS heap types (each one takes a unique id), then starts N_THREADS threads that each instantiate the newest class once and block. It prints the RssAnon added per thread.

import os
import sys
import threading

N_IDS = int(sys.argv[1]) if len(sys.argv) > 1 else 40000
N_THREADS = int(sys.argv[2]) if len(sys.argv) > 2 else 32

def rss_anon_kb():
    with open("/proc/self/status") as f:
        for line in f:
            if line.startswith("RssAnon:"):
                return int(line.split()[1])

classes = [type("C%d" % i, (), {}) for i in range(N_IDS)]
newest = classes[-1]
stop = threading.Event()
ready = threading.Barrier(N_THREADS + 1)

def worker():
    newest()
    ready.wait()
    stop.wait()

before = rss_anon_kb()
threads = [threading.Thread(target=worker) for _ in range(N_THREADS)]
for t in threads:
    t.start()
ready.wait()
after = rss_anon_kb()
stop.set()
for t in threads:
    t.join()
print("%s gil=%s ids=%d threads=%d: RssAnon +%d kB, %.0f kB per thread"
      % (sys.version.split()[0], sys._is_gil_enabled(), N_IDS, N_THREADS,
         after - before, (after - before) / N_THREADS))

Build 3.15.2 from the release tarball, once free-threaded and once with the GIL, and run the script with different id counts:

tar xf Python-3.15.2.tar.xz
mkdir obj-ft obj-gil
(cd obj-ft && ../Python-3.15.2/configure -q --disable-gil && make -j"$(nproc)")
(cd obj-gil && ../Python-3.15.2/configure -q && make -j"$(nproc)")

obj-ft/python refcount_arrays.py 1000
obj-ft/python refcount_arrays.py 40000
obj-ft/python refcount_arrays.py 70000
obj-gil/python refcount_arrays.py 70000

Results on x86_64 Linux (glibc):

build ids pool RssAnon per thread
3.15.2 free-threaded 1,000 1,024 234 kB
3.15.2 free-threaded 40,000 65,536 717 kB
3.15.2 free-threaded 70,000 131,072 1,218 kB
3.15.2 default (GIL) 70,000 n/a 21 kB

The growth over the 1,000-id row is the array: about 512 kB and 1 MB per thread.

Impact on a real application

I hit this in a long-running service with about 40 threads, running on free-threaded 3.15.2 (musl, x86_64 and ARM) on an embedded Linux system with 512 MB of RAM. After startup it holds about 22,600 code objects, 3,500 heap types and 900 module dicts, so the pool is 32,768 entries. An in-process heap census (walking the mimalloc heaps while the world is stopped) shows that 9.7 MB of the process's 92 MB RssAnon is these arrays (38 threads x 256 kB). When a few more modules are imported, the process crosses 32,768 ids, the pool becomes 65,536 and the arrays take up to 19.5 MB. On a 512 MB system that decides whether the process survives a memory stress test or gets OOM-killed (numbers below).

Expected

Per-thread memory proportional to the ids a thread actually uses, not to the number of ids in the interpreter.

Possible fixes

  1. Do not make untouched entries resident. On platforms with mmap/mremap, allocate the array as a private anonymous mapping and grow it with mremap(); pages the thread never writes stay unallocated (they read as zero). No memset needed. This is what I tried locally (below); it keeps the inline fast path in _Py_THREAD_INCREF_OBJECT unchanged.
  2. Portable alternative: a two-level table (for example 512-entry chunks allocated on first write). Costs one more load on the incref fast path.
  3. Size the array to cover the requested index (rounded up), not to the pool size. Cheap, but helps only threads that touch low ids.
  4. After _PyObject_MergePerThreadRefcounts() the array is all zeros; with (1), the GC's stop-the-world pause could madvise(MADV_DONTNEED) it to release pages a thread has stopped using. Trade-off: page faults on next use.

Local patch and result (option 1)

On Linux with HAVE_MMAP/HAVE_MREMAP, resize_local_refcounts() maps the array with mmap(MAP_PRIVATE|MAP_ANONYMOUS) (rounded up to whole pages) and grows it with mremap(MREMAP_MAYMOVE), without memset. madvise(MADV_NOHUGEPAGE) keeps a transparent huge page from making it resident, and finalization uses munmap. Other platforms keep PyMem_Realloc. The diff applies with patch -p1 to a 3.15.2 source tree.

Patch: Python/uniqueid.c
--- a/Python/uniqueid.c
+++ b/Python/uniqueid.c
@@ -20,6 +20,63 @@
 #define LOCK_POOL(pool) PyMutex_LockFlags(&pool->mutex, _Py_LOCK_DONT_DETACH)
 #define UNLOCK_POOL(pool) PyMutex_Unlock(&pool->mutex)
 
+#if defined(__linux__) && defined(HAVE_MMAP) && defined(HAVE_MREMAP)
+#  include <sys/mman.h>
+#  include <unistd.h>
+#  define REFCOUNTS_MMAP 1
+
+static size_t
+refcounts_mapped_size(Py_ssize_t entries)
+{
+    static size_t page_size;
+    if (page_size == 0) {
+        long n = sysconf(_SC_PAGESIZE);
+        page_size = n > 0 ? (size_t)n : 4096;
+    }
+    size_t bytes = (size_t)entries * sizeof(Py_ssize_t);
+    return (bytes + page_size - 1) & ~(page_size - 1);
+}
+
+static Py_ssize_t *
+refcounts_resize(Py_ssize_t *values, Py_ssize_t old_entries,
+                 Py_ssize_t new_entries)
+{
+    size_t new_bytes = refcounts_mapped_size(new_entries);
+    void *p;
+    if (values == NULL) {
+        p = mmap(NULL, new_bytes, PROT_READ | PROT_WRITE,
+                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+    }
+    else {
+        p = mremap(values, refcounts_mapped_size(old_entries), new_bytes,
+                   MREMAP_MAYMOVE);
+    }
+    if (p == MAP_FAILED) {
+        return NULL;
+    }
+#  ifdef MADV_NOHUGEPAGE
+    (void)madvise(p, new_bytes, MADV_NOHUGEPAGE);
+#  endif
+    return (Py_ssize_t *)p;
+}
+
+static void
+refcounts_free(Py_ssize_t *values, Py_ssize_t entries)
+{
+    if (values != NULL) {
+        (void)munmap(values, refcounts_mapped_size(entries));
+    }
+}
+#endif
+
 static int
 resize_interp_type_id_pool(struct _Py_unique_id_pool *pool)
 {
@@ -60,6 +117,17 @@
     struct _Py_unique_id_pool *pool = &tstate->base.interp->unique_ids;
     Py_ssize_t size = _Py_atomic_load_ssize(&pool->size);
 
+#ifdef REFCOUNTS_MMAP
+    Py_ssize_t old_size = tstate->refcounts.size;
+    if (size <= old_size) {
+        return 0;
+    }
+    Py_ssize_t *refcnts = refcounts_resize(tstate->refcounts.values,
+                                           old_size, size);
+    if (refcnts == NULL) {
+        return -1;
+    }
+#else
     Py_ssize_t *refcnts = PyMem_Realloc(tstate->refcounts.values,
                                         size * sizeof(Py_ssize_t));
     if (refcnts == NULL) {
@@ -70,6 +138,7 @@
     if (old_size < size) {
         memset(refcnts + old_size, 0, (size - old_size) * sizeof(Py_ssize_t));
     }
+#endif
 
     tstate->refcounts.values = refcnts;
     tstate->refcounts.size = size;
@@ -194,7 +263,11 @@
 {
     _PyObject_MergePerThreadRefcounts(tstate);
 
+#ifdef REFCOUNTS_MMAP
+    refcounts_free(tstate->refcounts.values, tstate->refcounts.size);
+#else
     PyMem_Free(tstate->refcounts.values);
+#endif
     tstate->refcounts.values = NULL;
     tstate->refcounts.size = 0;
     tstate->refcounts.is_finalized = 1;

Reproducer with the patch (same machine):

build ids RssAnon per thread
3.15.2 + patch 1,000 220 kB
3.15.2 + patch 40,000 213 kB
3.15.2 + patch 70,000 197 kB

With extra modules imported (about 32,900 ids, pool 65,536):

3.15.2 3.15.2 + patch
refcount arrays 11.0 MB in 38 threads, all resident 11.3 MB in 39 threads mapped, 1.8 MB written
process RssAnon 109,396 kB 99,628 kB
MemAvailable before starting two 50 MB stress --vm workers 98,296 kB 108,144 kB
result of the stress process OOM-killed survives

Default module set (about 27,000 ids, pool 32,768, 39-40 threads):

3.15.2 3.15.2 + patch
process RssAnon, idle 92,412 kB 84,160 kB
MemAvailable, idle 108,760 kB 116,204 kB
300 s HTTP load: peak process RSS 132,660 kB 120,632 kB
300 s HTTP load: requests/s, GET p95 260.9, 144.4 ms 272.2, 136.7 ms

mincore() reports every page of the patched arrays resident after a GC, because _PyObject_MergePerThreadRefcounts() reads all entries and maps the shared zero page; RssAnon does not count those pages.

I'm happy to turn this into a PR if this approach is acceptable.

Output from python -VV: Python 3.15.2 free-threading build (main, Nov 2 2026, 15:11:15) [GCC 9.4.0]

CPython versions tested on:

3.15, CPython main branch

Operating systems tested on:

Linux

Activity

  1. corona10 commented on Nov 15, 2026

    @corona10
    Member

    Just for the record, this issue is not triggered by the mimalloc upgrade.

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions