CPython allocator migration · decision report

mimalloc v2 → v3:
performance and memory trade-offs

Evidence from the October 21, 2026 benchmark investigation. Scope: the proposed vendored allocator upgrade, its memory-oriented configuration, and a targeted allocator optimization. GC-deferral experiments are excluded.

1. Executive summary

The tested v3 branch has major performance regressions, but most of the largest losses are avoidable.

Two interacting costs dominate: repeated scans of full allocator pages when page abandonment is disabled, and aggressive purging followed by page refaults. A prototype notification hint removes most of the scanning cost without relaxing the memory-oriented configuration. It does not solve the purge-policy trade-off.

These are large observed effects, not claims of formal statistical significance. The main comparison has only two repetitions per configuration on one machine; an earlier options sweep has one repetition.

2. Where the large regressions appear

The table below uses one consistent experiment: the two-repeat results4 comparison. All percentages are relative to v2. Positive elapsed-time changes and negative throughput changes are regressions. The benchmark is named deque, not “dequeue.” These are gcbench workloads, not results from the pyperformance suite.

Primary performance results — two repetitions per configuration
Benchmark / metricv2 absolutev3 branchv3 + hintv3 retain=2v3 + hint
+ purge 100 ms
deque runtime1.26 s+240%+8.7%+14%−1.9%
migrations runtime4.33 s+57%+3.6%+5.4%−4.2%
doctree runtime3.98 s+140%+1.6%+3.0%+1.2%
container build time0.43 s+123%+4.4%+3.2%+3.2%
ext_buffers_pymem throughput ↑301,987 ops/s−75%−58%−59%−17%

Interpretation: the full-queue hint recovers most of the lost time in the first four workloads while leaving page_full_retain=-1 and the 10 ms purge delay in place. The large-buffer case has both costs: removing scanning helps, but purging still dominates the remaining loss.

Earlier runs showed the same qualitative pattern: deque +208%, migrations +57%, doctree +137%, and container +123%. The precise size of the deque regression depends on run conditions; the initial profiling also found substantial sensitivity to one- versus two-CPU affinity.

3. Likely causes and supporting evidence

A. Full-page scanning when abandonment is disabled

The v3 branch sets mi_option_page_full_retain=-1. Rather than abandoning full pages for later reclamation, the allocator retains them in their owning thread’s full-page queue. Here “abandon” is an allocator ownership operation, not necessarily returning memory to the operating system.

With this configuration, mi_theap_collect_full_pages() walks that queue to discover pages made reusable by frees from other threads. Collection is reached when an allocation size class has no free page, and through periodic generic-allocation collection. A refill can therefore pay a cost proportional to the number of full pages in the live heap, even if none has become reusable.

The investigation attributes the v2/v3 difference to v3 dropping v2’s delayed-free list: without a suitable notification mechanism, the retained full queue is polled. The evidence supports a v3/configuration interaction, not the claim that v3 is intrinsically slower on all workloads.

The defaults comment in Objects/obmalloc.c says these memory settings have no measurable speed cost. That statement is contradicted by these workloads and should be revised if the configuration is retained.

B. Purge/refault churn with the memory-oriented settings

The branch sets purge_delay=10 ms, arena_purge_mult=1, and arena_purge_min_size=64 KiB. These aim to return unused memory promptly. If buffers are needed again soon, the allocator can instead pay repeated purge and refault costs, including kernel work and zero-filling pages.

For ext_buffers_pymem, the investigation recorded millions of minor faults and seconds of system CPU time. Changing purge policy dramatically reduces those costs. This is stronger evidence than a timing correlation alone.

Separate 5-second purge-policy experiment — v3, all with retain=2
Purge configurationThroughput ↑System CPUMinor faultsPeak RSS
Branch: 10 ms / mult 1 / min 64 KiB126k ops/s3.4 s3.1 million449 MB
Delay 100 ms; other branch settings245k ops/s0.95 s0.88 million484 MB
Min size 0; other branch settings189k ops/s2.2 s1.5 million457 MB
Upstream: 1000 ms / mult 4 / min 0293k ops/s0.06 s5.6 thousand494 MB
Purging disabled (delay −1)294k ops/s0.06 s1.2 thousand480 MB

v2 reference for this experiment: approximately 300k ops/s and 470–480 MB peak RSS. These short runs are diagnostic, not a reliable ordering of memory policies: disabling purging does not generally promise less RSS than a long delay. Do not combine their absolute RSS values with the separate two-repeat suite as if they were the same experiment.

4. The full-queue cross-thread-free hint

The mimalloc-full-xfree-hint experiment targets unnecessary work rather than accepting more memory retention to recover speed.

  1. Keep a shared notification counter (“epoch”) for a heap and a last-seen value for each thread-local heap.
  2. When another thread first adds a free block to an applicable full page’s cross-thread-free list, advance the counter.
  3. Skip the full-queue scan if the counter has not changed. If it has changed, scan normally.

Local frees already make a full page available directly. The hint therefore lets the owner avoid polling when there is no notification of cross-thread work. Synchronization around moving a page into the full queue is intended to ensure a racing free is either collected immediately or generates a notification.

Measured result: with the branch’s memory settings unchanged, deque falls from 4.29 s to 1.37 s, migrations from 6.81 s to 4.49 s, and doctree from 9.55 s to 4.05 s. These correspond to about 3.1×, 1.5×, and 2.4× speedups over the unmodified v3 branch. It is not a complete fix: large-buffer throughput and smaller regressions remain.

Validation reported so far: debug-build tests including test_free_threading, test_gc, test_threading, test_asyncio, test_concurrent_futures, and test_capi passed. An instrumented container run found no skipped scan that would have released a full page. A producer/consumer stress test had similar time and RSS to unmodified v3.

Prototype, not a correctness proof. The synchronization and lifetime assumptions need allocator-level review, especially under sustained cross-thread frees. A heap-wide counter can still provoke scans in multiple thread-local heaps; this is a scan-suppression hint, not a per-page work queue. The stress test also left an unexplained v3 memory gap: 61–73 MB versus 45 MB for v2.

Branch provenance: in this clone, origin/mimalloc-full-xfree-hint and origin/mimallocv3 both resolve to 7c4bdf4fcb9. The tested change is the saved fullhint.diff / modified worktree described in the investigation, not a committed difference between those remote refs.

5. Memory is a constraint, not an incidental metric

Changing retention or purge settings can move the cost from CPU to memory rather than eliminate it. The two-repeat suite shows why retaining the memory policy while optimizing scans is worth pursuing.

RSS changes versus v2 — same two-repeat experiment as the primary performance table
Workload / RSS metricv2v3 branchv3 + hintv3 retain=2Hint + 100 ms
c_globals peak196 MB+1.0%+0.8%+8.6%+5.9%
async_server peak78.1 MB+5.1%+4.5%+1.1%+10%
web_service 8t peak550 MB+3.8%+4.8%+4.1%+5.7%
orm_session mixed 4t peak173 MB−8.2%−4.9%+6.6%+2.8%
ext_buffers_pymem peak470 MB−4.4%−4.4%−4.4%+6.6%
migrations peak323 MB+2.6%+2.1%+1.9%+3.0%
container post-build*291 MB+0.5%+8.2%+8.2%+16%

*Container RSS was sampled immediately after a much faster build, giving purging less wall-clock time. With MIMALLOC_PURGE_DELAY=0, v3 branch and hint measured 285 MB and 283 MB. This supports a purge-timing explanation for that comparison, not a general guarantee of equal memory use.

The earlier one-repeat options sweep found that upstream defaults largely recovered the major performance losses, but increased RSS versus v2 by approximately 11% for web_service 8t, 12% for migrations, 13% for c_globals, and 12% for gen_frag churn. These are material costs for a memory-sensitive default.

There are also gains: in the two-repeat suite, bigbuffers 200KB peak RSS was about 9.5% lower with v3 at 10 ms, and single-threaded orm_session mixed about 14% lower. Decisions should use workload-level results, not just an aggregate average.

6. Choices for CPython main

OptionBenefitCost / open issue
Merge dinov’s v3 branch as testedPreserves the selected memory-oriented settings; simplest migration.Accepts 2–3.4× elapsed times in several workloads and about 75% lower large-buffer throughput. Does not meet the stated goal.
Adjust configurationRetain=2 removes most scanning losses; longer purge delays recover more throughput without an allocator patch.Can increase RSS. Even a 100 ms delay leaves the buffer case well behind v2. Revalidate the original memory targets.
Add allocator optimizationsThe hint addresses the scanning mechanism while preserving retain=−1 and 10 ms purging.Requires concurrency review, broader testing, and preferably upstream coordination. Purge/refault behavior needs separate work.
Expose a Python policy settingLets applications choose a memory-oriented or throughput-oriented policy, for example through a future -X option.A new supported interface needs startup, embedding, override, and compatibility semantics. It does not remove the need for a sound default.
Recommended next step: validate the hint with the existing memory settings first. In parallel, isolate and investigate v3’s purge/refault behavior under a v2-like policy. Treat configuration relaxation as an explicit measured trade-off, not as evidence that the migration is regression-free.

Useful allocator work could include avoiding futile full-page scans, reducing purge/refault churn, and making purge batching or reuse decisions better under short delays. The latter are investigation directions, not optimizations demonstrated by these measurements. Engaging mimalloc upstream would reduce the long-term cost of carrying a CPython-only patch.

A Python-level tuning interface could expose stable policy names rather than freeze individual mimalloc internals into a public API. Existing MIMALLOC_* environment overrides already allow experimentation in the tested branch. Any new interface should specify when policy is applied and how environment variables interact with it; no such Python option is implemented by this experiment.

7. What to establish before merging

8. Experiment definitions and provenance

Machine: Intel i7-14700K, 28 logical CPUs (0–15 P-core hyperthreads, 16–27 E-cores), Linux 6.12, 62 GB RAM. Single-threaded suite cases were pinned to CPUs 2,3; N-thread cases to CPUs 0 through N−1. Builds used PGO, LTO, and the tail-call interpreter (py-build nogil-tail, lld).

LabelDefinition
v2basea13d6dbe037, the main-branch baseline at the last merge.
v3base / v3 branch7c4bdf4fcb9, mimallocv3; includes a8e2a422fb3 (“More options”): delay 10 ms, arena multiplier 1, minimum 64 KiB, full retain −1.
fullhint / v3 + hintv3base plus the saved full-queue cross-thread-free epoch patch; same memory settings.
v3-retain2v3base with MIMALLOC_PAGE_FULL_RETAIN=2; other branch settings unchanged.
fullhint-purge100Hint build with MIMALLOC_PURGE_DELAY=100; retain −1, multiplier 1, minimum 64 KiB unchanged.

Recorded artifacts (local source paths for audit; not needed to read this standalone document):

The report summarizes existing experiments; it does not report new benchmark runs. Main performance and RSS tables were checked against results4/compare.md; profiling, purge-policy micro-experiments, and prior correctness checks are reported from the investigation notes. Comparison commits are not an allocator-only controlled pair, so smaller residual differences should not automatically be attributed to mimalloc.