Add a seal_pages flag to gc.freeze() to finish the copy-on-write story

I’d like to discuss extending gc.freeze() with an opt-in keyword —
gc.freeze(seal_pages=True) (default False, fully backward compatible) — that,
in addition to the existing move-to-permanent-generation behaviour, “seals” the
object allocator’s current state: it removes every currently partially-filled pool
from the allocation free lists so that subsequent allocations start from fresh
pools/arenas, leaving the existing pages untouched by any future allocation.

The goal is to close the last remaining source of copy-on-write (CoW) page
dirtying for preforking servers, after gc.freeze() and immortalization have
dealt with the other two — and to do it at the call site people already use for
fork preparation.

Background: the three sources of CoW dirtying

Preforking servers (gunicorn --preload, uWSGI prefork, Celery’s prefork pool,
plain multiprocessing) load shared state in a parent and fork() workers,
relying on CoW to share the read-only-in-practice parent heap. Three things write
to pages holding long-lived parent objects and so break that sharing:

  1. GC header writes. Collection shuffles the generation linked lists whose
    PyGC_Head lives with the object, dirtying pages. Solved by gc.freeze()
    (3.7), which moves the objects to the permanent generation so collections skip
    them.

  2. Refcount writes. Every Py_INCREF/Py_DECREF writes ob_refcnt in the
    object header. Addressed by immortal objects (PEP 683), per-object
    PyUnstable_Object_EnableDeferredRefcount() (3.14), and PyUnstable_IsImmortal()
    for introspection; a general setter is in progress
    (gh-143300).

  3. Allocator reuse of free blocks co-located with long-lived data.
    Currently unaddressed, and the subject of this post.

The gap

obmalloc serves blocks from pools (one OS page) belonging to a single size
class, up to the 512-byte small-request threshold. At fork() time, any pool that
holds long-lived data and still has free blocks is a “mixed” page. After the
fork, the first allocation into one of those free blocks — in the parent or a
child — faults the whole page private, duplicating whatever long-lived (even
immortal, even frozen) data was riding along on it.

The gc.freeze() docs already gesture at this: they note that CoW-friendliness
“requires … avoiding creation of freed ‘holes’ in memory pages in the parent”
and recommend gc.collect() before the fork so holes can be released. But that is
about not fragmenting and releasing holes; it does nothing to stop post-fork
allocation from re-dirtying the partial pools that legitimately remain after a
load-everything-then-freeze sequence.

So even with gc.disable() + gc.collect() + gc.freeze() + full
immortalization, the partially-filled pools that interleave long-lived objects
with free blocks are still landmines.

Why this can’t be done from Python

The obvious user-space workaround — allocate filler objects to pack the free
blocks in each partial pool until obmalloc spills onto fresh pages — does not work
in practice, for three reasons:

  • It must be done per size class (each pool serves one class), not by
    allocating “the smallest thing”.
  • The only user-visible signal for “a new pool opened” is RSS growth, which is
    noisy and arena-coarse.
  • Fatally: the packing loop itself, and everything that runs afterwards up to
    and including the fork machinery (e.g. multiprocessing/billiard setup),
    keeps allocating into those same pools, re-introducing mixed pages in classes
    you just packed. You cannot freeze the allocator’s state from code that is
    itself running and allocating.

That last point is why this has to happen inside the runtime, as late as possible
before the fork — and why folding it into gc.freeze(), the call that already
marks “I’m done preparing the heap, now I’m going to fork”, is a natural fit.

Proposal: gc.freeze(seal_pages=True)

With seal_pages=True, after performing the existing freeze (moving tracked
objects to the permanent generation), gc.freeze() additionally, for the active
object allocator:

  • Removes every currently partially-used pool from usedpools so it is treated as
    full and no further blocks are handed out from it.
  • Routes subsequent small-object allocations to fresh pools (and fresh arenas as
    needed), which become private in children naturally and harmlessly.

The recipe becomes a one-line change to the documented sequence:

gc.disable()
# ... load all shared state, immortalize it ...
gc.collect()
gc.freeze(seal_pages=True)   # was: gc.freeze()
# fork workers

Why a flag on gc.freeze() rather than a standalone primitive

I expect the main objection to be layering: gc.freeze() is a collector
operation, while sealing is an allocator (obmalloc / mimalloc) operation, and
strictly these are different subsystems. I think the flag is still the right home:

  • gc.freeze() is already the documented fork-preparation entry point. Its own
    docs discuss page holes and prescribe the disable/collect/freeze sequence, so it
    already straddles the GC/allocator line for precisely this purpose.
  • It’s where users already reach for CoW-friendliness; discoverability is high and
    the mental model (“freeze the heap before fork”) is unchanged.
  • Sealing is only ever useful in combination with freezing, immediately before a
    fork — the exact situation gc.freeze() exists for. A separate primitive would
    almost always be called on the next line anyway.

The counter-view — that this is really an allocator concern and should live behind
a PyUnstable_-prefixed allocator API, with gc.freeze() left purely about GC
generations — is worth airing, and I’ve listed it as an open question below.

Semantics that need pinning down

  • Wasted free blocks. The free blocks in sealed pools are stranded (internal
    fragmentation) until/unless un-sealed. This is the primary cost and needs to be
    quantified against the CoW it saves.
  • Frees into sealed pools. If a block in a sealed pool is later freed (e.g. a
    long-lived-but-not-immortal object), does obmalloc re-link the pool into
    usedpools (re-opening the page to allocation and defeating the seal), or keep
    it out and strand the block? For the immortalize-everything case this is moot,
    but the API must specify it.
  • gc.unfreeze() interaction. Should gc.unfreeze() also un-seal, or are the
    two independent? Un-sealing cleanly is harder than unfreezing, so they may need
    to be decoupled even though they share an entry point.
  • Scope. Small objects only; allocations above the small-request threshold go
    to the system allocator and are out of scope — document the boundary.
  • Free-threaded build. The free-threaded build uses mimalloc, not obmalloc,
    with a different heap/page structure. Does seal_pages=True need a mimalloc
    backend for parity, or raise / no-op (with a warning) there initially? (Honest
    question — I haven’t dug into whether mimalloc exposes a clean equivalent of
    “retire current pages”.)
  • Idempotency. Repeated gc.freeze(seal_pages=True) calls should be safe and
    cheap.

Open questions / what I’m really asking

  1. Has anyone measured the obmalloc residual? This is the crux. Instagram’s
    published CoW work stopped at gc.freeze(); PEP 683 added immortality; I can’t
    find anyone who has quantified how much CoW the partial-pool sharing actually
    costs after freeze + immortalization in a real deployment. If the residual is
    small, this flag isn’t worth it. If someone has numbers (or a repro I can
    borrow), that would settle whether this is worth pursuing.
  2. Flag vs standalone primitive: is gc.freeze(seal_pages=True) the right
    home, or should sealing be a separate PyUnstable_-namespaced allocator API
    with gc.freeze() kept purely about GC generations?
  3. obmalloc vs mimalloc: one behaviour over both allocators, or
    obmalloc-first with mimalloc raising/no-opping until a backend exists?
  4. Seal vs compact: is freezing the current layout the right model, or is the
    better primitive an active defragmentation that relocates long-lived objects
    into a minimal set of fully-packed pages before fork? (Relocation is obviously
    far harder given that object addresses are stable and exposed.)

Motivation / context

This came out of trying to fit a preloaded Celery prefork worker into a
memory-limited host. gc.freeze() plus immortalizing the preloaded structures
gets most of the way, but the leftover mixed pages are the visible remaining
cost, and there is currently no supported lever for them — only fragile
user-space hacks that don’t survive the fork machinery’s own allocations.

References