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
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.
- Do not treat upstream v3 defaults as a free fix. The 10 ms delay and related settings were chosen to control memory use, informed by earlier testing including pyperformance. Relaxing them recovers speed but can increase RSS materially.
- The strongest next candidate is v3 plus the full-queue hint, initially keeping the current memory policy. It reduces
dequefrom 3.4× v2 runtime to 1.09×, andmigrationsfrom +57% to +3.6%. This is a candidate for review and validation, not a demonstrated regression-free replacement. - Large-buffer performance remains unresolved. With the hint and a 10 ms purge delay,
ext_buffers_pymemthroughput is still 58% below v2. A 100 ms delay narrows that loss to 17%, at a memory cost. - No tested configuration establishes “no performance or memory regressions.” Smaller throughput, RSS, and pause-time differences remain. Broader and longer testing is needed before selecting a merge configuration.
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.
| Benchmark / metric | v2 absolute | v3 branch | v3 + hint | v3 retain=2 | v3 + hint + purge 100 ms |
|---|---|---|---|---|---|
deque runtime | 1.26 s | +240% | +8.7% | +14% | −1.9% |
migrations runtime | 4.33 s | +57% | +3.6% | +5.4% | −4.2% |
doctree runtime | 3.98 s | +140% | +1.6% | +3.0% | +1.2% |
container build time | 0.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.
- Profile evidence: approximately 67% of sampled time in the v3
dequecase was inmi_theap_collect_full_pages(), through the allocation/refill path. - Workload sensitivity: this
dequebenchmark uses 10 KBbytespayloads, with few blocks per page and frequent refills. - Intervention evidence: allowing abandonment with
MIMALLOC_PAGE_FULL_RETAIN=2, or suppressing unnecessary scans with the hint, removes most of the four large elapsed-time regressions.
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.
| Purge configuration | Throughput ↑ | System CPU | Minor faults | Peak RSS |
|---|---|---|---|---|
| Branch: 10 ms / mult 1 / min 64 KiB | 126k ops/s | 3.4 s | 3.1 million | 449 MB |
| Delay 100 ms; other branch settings | 245k ops/s | 0.95 s | 0.88 million | 484 MB |
| Min size 0; other branch settings | 189k ops/s | 2.2 s | 1.5 million | 457 MB |
| Upstream: 1000 ms / mult 4 / min 0 | 293k ops/s | 0.06 s | 5.6 thousand | 494 MB |
| Purging disabled (delay −1) | 294k ops/s | 0.06 s | 1.2 thousand | 480 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.
- Keep a shared notification counter (“epoch”) for a heap and a last-seen value for each thread-local heap.
- When another thread first adds a free block to an applicable full page’s cross-thread-free list, advance the counter.
- 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.
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.
| Workload / RSS metric | v2 | v3 branch | v3 + hint | v3 retain=2 | Hint + 100 ms |
|---|---|---|---|---|---|
c_globals peak | 196 MB | +1.0% | +0.8% | +8.6% | +5.9% |
async_server peak | 78.1 MB | +5.1% | +4.5% | +1.1% | +10% |
web_service 8t peak | 550 MB | +3.8% | +4.8% | +4.1% | +5.7% |
orm_session mixed 4t peak | 173 MB | −8.2% | −4.9% | +6.6% | +2.8% |
ext_buffers_pymem peak | 470 MB | −4.4% | −4.4% | −4.4% | +6.6% |
migrations peak | 323 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
| Option | Benefit | Cost / open issue |
|---|---|---|
| Merge dinov’s v3 branch as tested | Preserves 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 configuration | Retain=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 optimizations | The 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 setting | Lets 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. |
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
- Repeat the original acceptance tests. Run pyperformance and the workloads used to choose 10 ms, with both runtime and memory measurements. No fresh pyperformance comparison is included here.
- Broaden coverage. These measurements use free-threaded, PGO + LTO + tail-call builds on Linux. They do not establish GIL-build, other-platform, or other-hardware performance.
- Use more repetitions and controlled ordering. The existing runs repeat a fixed configuration order, not a randomized one. Include warmup, longer runs, clean environments, and matched CPU affinity; investigate the one-/two-CPU
dequesensitivity. - Measure memory over time. Record peak and steady-state RSS, post-burst decay at fixed times, and preferably RSS-time integrals. Keep post-build samples separate from true peaks; compare both fixed work and fixed-duration load.
- Measure kernel costs. Track minor faults, user/system CPU, purge activity, and allocation profiles across buffer sizes and thread counts.
- Review remaining gaps. With the hint at 10 ms,
hidden_refsthroughput is −7.7%,async_server−5.1%, andweb_service 8t−4.6%; their causes are not established. The 8-thread service’s longest collection remains about +17% (250 → 292 ms) without any GC-deferral change. - Check tails and correctness. The two-repeat table includes large longest-collection outliers for
orm_session mixed 4twith retain=2 and hint + 100 ms. Investigate rather than extrapolating from two samples. Stress notification races, cross-thread frees, heap/thread teardown, and memory retention. - Set explicit acceptance criteria. Agree on tolerable per-workload CPU, peak/steady-state memory, and latency changes. Do not hide a severe regression behind a suite-wide mean.
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).
| Label | Definition |
|---|---|
| v2base | a13d6dbe037, the main-branch baseline at the last merge. |
| v3base / v3 branch | 7c4bdf4fcb9, mimallocv3; includes a8e2a422fb3 (“More options”): delay 10 ms, arena multiplier 1, minimum 64 KiB, full retain −1. |
| fullhint / v3 + hint | v3base plus the saved full-queue cross-thread-free epoch patch; same memory settings. |
| v3-retain2 | v3base with MIMALLOC_PAGE_FULL_RETAIN=2; other branch settings unchanged. |
| fullhint-purge100 | Hint 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):
- Investigation narrative:
/home/nas/src/cpython/mimalloc-v2-v3-compare-ft-defer.md, Parts 2–3. - Main tables and runner:
/home/nas/agent-home/src/bench/gc-mimalloc-v3-fast/results4/compare.mdandrun4.sh. Seventeen short suite cases; two repetitions in the order v2base, v3base, fullhint, retain2, fullhint-purge100. - Earlier options sweep:
/home/nas/agent-home/src/bench/gc-mimalloc-v3-fast/results3/compare.md; one repetition per configuration, as summarized in the investigation. - Prototype patch:
/home/nas/agent-home/src/fullhint.diff. Producer/consumer test:/home/nas/agent-home/src/bench/fullhint/xfree_stress.py. - Workload source:
/home/nas/agent-home/src/ftgc-coop/Tools/gcbench, from Yhg1s/cpython’sftgc-coopbranch.
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.