Repository navigation
Description
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 70000Results 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
- Do not make untouched entries resident. On platforms with
mmap/mremap, allocate the array as a private anonymous mapping and grow it withmremap(); pages the thread never writes stay unallocated (they read as zero). Nomemsetneeded. This is what I tried locally (below); it keeps the inline fast path in_Py_THREAD_INCREF_OBJECTunchanged. - Portable alternative: a two-level table (for example 512-entry chunks allocated on first write). Costs one more load on the incref fast path.
- Size the array to cover the requested index (rounded up), not to the pool size. Cheap, but helps only threads that touch low ids.
- After
_PyObject_MergePerThreadRefcounts()the array is all zeros; with (1), the GC's stop-the-world pause couldmadvise(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
corona10 commentedon Nov 15, 2026
Just for the record, this issue is not triggered by the mimalloc upgrade.