python / python/cpython

Increased memory usage in the free-threaded build with C extensions

未關閉
#156,159 4 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

還沒有人認領這個 Issue。

interpreter-core performance topic-free-threading type-bug
主要語言
Python
星號
77.2k
分支
35.9k
PR 合併指標
PR 指標待擷取

描述

Bug report

Bug description:

Summary

The free-threaded build splits allocation into two independent pools: Python
objects are served by mimalloc, while C extensions keep calling the system
allocator. On Linux the combination wastes a lot of memory, because:

  • the two pools never share memory. Python objects can no longer land in the
    space that C extensions have freed in the glibc arena, the way they do with
    the GIL, where obmalloc forwards everything above 512 bytes to the system
    allocator. In the free-threaded build _PyObject_MiMalloc() sends every
    object to mimalloc's own heaps, and the arena only ever sees C-extension
    traffic.
  • glibc does not hand that space back to the OS either. It only trims the
    top of the heap, so a freed region sitting below a live allocation stays
    resident — which is the normal steady state for a producer that frees the
    previous buffer while holding the latest one.

Neither condition is a problem on its own: with the GIL the freed arena space
keeps being recycled by Python objects, and with an allocator that returns
memory by itself (jemalloc, tcmalloc) there is nothing left to strand. It is
specifically free-threaded + glibc that ends up holding memory that is free,
resident and unusable — 2x RSS in the reproducer below, ~11 GiB in a real
training job.

gh-135898 collects the known reasons the free-threaded build uses more memory;
this mechanism is not among them. It is also distinct from gh-135153: the memory
in question sits in the glibc arena, and no mimalloc setting affects it. It
only appears when a workload mixes Python objects with C-extension malloc()
traffic, which is why pure-Python benchmarks do not surface it.

What we saw in production

A PyTorch training job with 32 DataLoader worker processes. Each worker loops:
read images from disk, decode and augment them with NumPy and OpenCV, collate
them into a batch, hand the batch to the main process, repeat. Once a batch has
been handed off the worker has no further use for it — the intermediate arrays
are dropped and only the most recent batch is briefly kept alive.

So a worker's live set is small and constant: nothing accumulates in Python
objects, the same amount of image data flows through in both builds, and the
native buffers really are freed after each batch. Same machine, same library
versions, switching only the interpreter:

GIL build free-threaded build
whole process tree PSS 34.6 GiB 45.6 GiB
DataLoader workers PSS 15.8 GiB 26.4 GiB

mallinfo2() inside a worker at steady state shows where it goes:

per worker GIL build free-threaded build
arena 1085 MB 1397 MB
uordblks (in use) 858 MB 703 MB
fordblks (free, retained) 227 MB 694 MB
arena utilisation 79% 50%

The workers free the same amount of native memory in both builds. The difference
is what happens to it afterwards: in the free-threaded build half of each
worker's arena sits free but resident and is never picked up again, ~467 MB per
worker. Nothing is leaking in the usual sense; the memory is free, it is simply
neither reused nor returned.

Both mitigations were then applied to the real training job, and both recover
most of it without touching mimalloc:

free-threaded build tree PSS worker PSS
as-is 45.6 GiB 26.4 GiB
malloc_trim(0) in the worker, once per batch 35.9 GiB 16.5 GiB
LD_PRELOAD=libjemalloc.so.2, MALLOC_CONF=narenas:2,background_thread:true,dirty_decay_ms:1000 36.0 GiB 15.8 GiB
(GIL build, for reference) 34.6 GiB 15.8 GiB

Step time was unchanged in both cases. MIMALLOC_PURGE_DELAY and the mimalloc
arena purge options had no reproducible effect.

Reproducer

The script below is modelled on the DataLoader worker above, reduced to the two
things that matter: a stream of native buffers that are allocated, used and
freed, and a set of Python objects that outlives them. It uses the standard
library only (ctypes for malloc/free/mallinfo2), no third-party packages
and no threads. Run it under two builds of the same CPython source; the
numbers below are CPython 3.14.7 (GIL and free-threaded builds), glibc 2.39,
Ubuntu 24.04, x86_64.

#!/usr/bin/env python3
"""Free-threaded CPython does not reuse the system allocator's free memory.

Phase 1: churn medium buffers through glibc malloc/free (a C extension stand-in),
         keeping one live block at the top of the arena so glibc cannot trim.
         -> ~STRAND_MB of resident, free-but-retained arena space.
Phase 2: allocate PYOBJ_MB of long-lived Python objects above the 512-byte
         obmalloc threshold.
         GIL build: served by glibc, reuses the stranded space -> RSS flat.
         -t  build: served by mimalloc, asks the OS for new memory -> RSS +2 GB.

TRIM=1 calls malloc_trim(0) after phase 1.
"""
import ctypes
import gc
import os
import sys

libc = ctypes.CDLL(None)
libc.malloc.restype = ctypes.c_void_p
libc.malloc.argtypes = [ctypes.c_size_t]
libc.free.argtypes = [ctypes.c_void_p]
libc.memset.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_size_t]
libc.malloc_trim.argtypes = [ctypes.c_size_t]


class Mallinfo2(ctypes.Structure):
    _fields_ = [(n, ctypes.c_size_t) for n in (
        "arena", "ordblks", "smblks", "hblks", "hblkhd",
        "usmblks", "fsmblks", "uordblks", "fordblks", "keepcost",
    )]


libc.mallinfo2.restype = Mallinfo2

MB = 1024 * 1024
STRAND_MB = int(os.environ.get("STRAND_MB", "2048"))
CHUNK_KB = int(os.environ.get("CHUNK_KB", "104"))  # under glibc's mmap threshold
PYOBJ_MB = int(os.environ.get("PYOBJ_MB", "2048"))
# 4000 + bytes header stays under 4 KiB in both builds (header is 33 vs 49), so
# neither allocator jumps to a bigger size class.
PYOBJ_PAYLOAD = int(os.environ.get("PYOBJ_PAYLOAD", "4000"))
TRIM = os.environ.get("TRIM", "0") == "1"


def rss_mb():
    for line in open("/proc/self/status"):
        if line.startswith("VmRSS:"):
            return int(line.split()[1]) / 1024


def report(label):
    info = libc.mallinfo2()
    print(f"  {label:26s}"
          f"  process RSS={rss_mb():7.1f}MB"
          f"  | glibc arena: total={info.arena / MB:7.1f}MB"
          f"  in-use={info.uordblks / MB:7.1f}MB"
          f"  free={info.fordblks / MB:7.1f}MB")


def strand_arena():
    chunk = CHUNK_KB * 1024
    ptrs = []
    for _ in range((STRAND_MB * MB) // chunk):
        ptr = libc.malloc(chunk)
        libc.memset(ptr, 1, chunk)
        ptrs.append(ptr)
    pin = libc.malloc(chunk)  # allocated last -> pins the top of the arena
    libc.memset(pin, 1, chunk)
    for ptr in ptrs:
        libc.free(ptr)
    return pin


def main():
    build = "free-threaded" if not sys._is_gil_enabled() else "GIL"
    print(f"Python {sys.version.split()[0]} ({build})   "
          f"malloc_trim={'on' if TRIM else 'off'}")
    pin = strand_arena()
    gc.collect()
    report("after phase1 (churn)")

    if TRIM:
        libc.malloc_trim(0)
        report("after malloc_trim(0)")

    count = (PYOBJ_MB * MB) // (PYOBJ_PAYLOAD + sys.getsizeof(b""))
    objects = [bytes(PYOBJ_PAYLOAD) for _ in range(count)]
    gc.collect()
    report(f"after phase2 ({PYOBJ_MB}MB objs)")

    libc.free(pin)
    del objects


main()
Results
$ python3.14 repro.py
Python 3.14.7 (GIL)   malloc_trim=off
  after phase1 (churn)        process RSS= 2064.6MB  | glibc arena: total= 2051.2MB  in-use=    1.8MB  free= 2049.4MB
  after phase2 (2048MB objs)  process RSS= 2075.6MB  | glibc arena: total= 2062.2MB  in-use= 2061.9MB  free=    0.2MB

$ python3.14t repro.py
Python 3.14.7 (free-threaded)   malloc_trim=off
  after phase1 (churn)        process RSS= 2071.6MB  | glibc arena: total= 2048.5MB  in-use=    0.2MB  free= 2048.2MB
  after phase2 (2048MB objs)  process RSS= 4152.2MB  | glibc arena: total= 2048.5MB  in-use=    0.2MB  free= 2048.2MB

Both builds enter phase 2 with ~2 GB of resident, free arena space. The GIL
build consumes it (free 2049 → 0.2 MB) and RSS grows by 11 MB for 2 GB of
objects. The free-threaded build never touches it (free stays at 2048 MB) and
RSS grows by the full 2081 MB — 2075 MB vs 4152 MB, +100% for the same work.

Both mitigations behave as the mechanism predicts. malloc_trim(0), called once
after phase 1, gives the stranded pages back so phase 2 starts from a clean
slate:

$ TRIM=1 python3.14t repro.py
Python 3.14.7 (free-threaded)   malloc_trim=on
  after phase1 (churn)        process RSS= 2071.5MB  | glibc arena: total= 2048.5MB  in-use=    0.2MB  free= 2048.2MB
  after malloc_trim(0)        process RSS=   23.6MB  | glibc arena: total= 2048.4MB  in-use=    0.2MB  free= 2048.2MB
  after phase2 (2048MB objs)  process RSS= 2103.9MB  | glibc arena: total= 2048.4MB  in-use=    0.2MB  free= 2048.2MB

With jemalloc preloaded there is nothing to strand in the first place — phase 1
ends at 220 MB instead of 2071 MB — and both builds then behave the same:

$ LD_PRELOAD=libjemalloc.so.2 python3.14t repro.py
Python 3.14.7 (free-threaded)   malloc_trim=off
  after phase1 (churn)        process RSS=  219.8MB  | glibc arena: total=    0.0MB  in-use=    0.0MB  free=    0.0MB
  after phase2 (2048MB objs)  process RSS= 2298.2MB  | glibc arena: total=    0.0MB  in-use=    0.0MB  free=    0.0MB

$ LD_PRELOAD=libjemalloc.so.2 python3.14 repro.py
Python 3.14.7 (GIL)   malloc_trim=off
  after phase1 (churn)        process RSS=  223.4MB  | glibc arena: total=    0.0MB  in-use=    0.0MB  free=    0.0MB
  after phase2 (2048MB objs)  process RSS= 2365.6MB  | glibc arena: total=    0.0MB  in-use=    0.0MB  free=    0.0MB

Final RSS across configurations:

build allocator final RSS
GIL glibc 2075 MB
free-threaded glibc 4152 MB
free-threaded glibc + malloc_trim(0) 2104 MB
free-threaded jemalloc 2298 MB
GIL jemalloc 2366 MB

Two observations:

  • malloc_trim(0) releases the stranded pages even though the top of the arena
    is pinned — modern glibc walks interior free chunks and MADV_DONTNEEDs them.
    The memory is therefore not irrecoverably fragmented; glibc just never does
    this on its own. Note fordblks does not change, as it accounts for free
    chunks rather than resident pages; only RSS shows the effect.
  • under jemalloc both builds behave the same (2298 vs 2366 MB), because its
    decay returns the freed pages anyway and there is nothing left to reuse. The
    penalty is specific to free-threaded + a retaining allocator, glibc being
    the default one on Linux.
CPython versions tested on:

3.14

Operating systems tested on:

Linux

貢獻指南

開啟貢獻指南

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

研究方向

從報告中指出的 free-threaded allocator 路徑開始,尤其是 _PyObject_MiMalloc(),並將其與 C 擴充功能使用的 system allocator 行為進行比較。在帶有 GIL 和 free-threaded 的 Linux 建置中執行 repro.py,包括 TRIM=1,以確認保留的 glibc 記憶體。應將達成一項既能避免所演示的 RSS 增長、又不會使 allocator 行為發生回歸的修正作為完成標準。

由索引模型根據 Issue 內容生成。

評估

技術堆疊
c, linux, python
領域
operating-systems, performance
Issue 類型
缺陷
難度
5/5
預估耗時
一週以上
活躍度
活躍
描述清晰度
需要釐清
新手友好度
35/100

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。