From 3491a8e83b4dcb22ca1a5d8d3d408250c321b885 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 16 Aug 2026 03:23:05 +0200 Subject: [PATCH] fix(mem): restore mimalloc's Linux arena-commit default (#1654) Since #1360 routed ordinary malloc/new through mimalloc on Linux, the arena policy governs every allocation in the process rather than just the bound sqlite/tree_sitter populations. cbm sets arena_eager_commit=0, so mimalloc commits sub-ranges with mprotect(PROT_READ|PROT_WRITE) over a PROT_NONE reservation, and each partial commit SPLITS the reserved VMA. Measured on the Go corpus, Linux arm64, shipped binaries: v0.9.0 10 mappings, at ANY worker count v0.10.5 ~22k mappings, peak; the count tracks CONCURRENCY (999 at 1 worker, 8460 at 4, 11965 at 18) Two consequences, both of which #1654 reported from a 96-CPU/376 GB host: the mmap/mprotect churn serialises on the kernel's per-process mmap_lock, and the VMA count climbs toward vm.max_map_count, after which mmap fails for ANY size -- so mimalloc reported it could not allocate 10 KB while `free -g` still showed 246 GB available. mimalloc's own default for this option is 2, meaning "eager-commit arenas only on an OS that overcommits (i.e. linux)", precisely because commit is free there until pages are touched. Overriding it to 0 opted Linux out of the default written for Linux. Restore it on Linux only; every other platform keeps the lazy setting, where commit is NOT free and the upfront-memory reason still holds (Windows especially, #581). Measured effect, same corpus and host, baseline build vs this build: mappings 22450 -> 17312 (-23%) wall 92.4s -> 92.6s (unchanged) peak RSS 19.14 -> 19.22 GB (unchanged) This is a partial mitigation, not a cure: the remaining ~17k mappings are individual 64 KB-3 MB extraction buffers, each taking its own mmap (the worker reserves ~40 GB of address space for ~19 GB of RSS). Pooling those is the durable fix and is deliberately left out of this change. Guard: mem_arena_eager_commit_follows_platform_commit_cost pins the platform split so the Linux default cannot be silently opted out again. Reproduction and controlled 2x2 (only vm.max_map_count varied) are recorded on #1654. Signed-off-by: Martin Vogel --- src/foundation/mem.c | 29 +++++++++++++++++++++++++++++ tests/test_mem.c | 27 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/foundation/mem.c b/src/foundation/mem.c index c3462b11a..65f57b1a8 100644 --- a/src/foundation/mem.c +++ b/src/foundation/mem.c @@ -291,7 +291,36 @@ void cbm_mem_init_with_cap(double ram_fraction, size_t hard_cap_bytes) { } #endif + /* Lazy arena commit costs address-space FRAGMENTATION, and on Linux we now + * pay it for every allocation in the process. mimalloc commits a range with + * mprotect(PROT_READ|PROT_WRITE) over a sub-range of a PROT_NONE reservation + * (prim/unix/prim.c), and each partial commit SPLITS the reserved VMA. While + * mimalloc only served the bound populations (sqlite, tree_sitter) that was a + * handful of mappings. Since #1360 routed ordinary malloc/new through + * mimalloc on Linux, an index worker peaked at ~22k mappings against + * v0.9.0's 10 (Go corpus, 18 workers). The count tracks CONCURRENCY, not + * corpus size — sampled mid-run it was 999 at 1 worker, 8460 at 4 and 11965 + * at 18, while v0.9.0 stayed at 10 regardless — so every worker thread + * fragments the address space independently. + * + * Two consequences, both reported as #1654 on a 96-CPU/376 GB host: the + * mmap/mprotect churn serialises on the kernel's per-process mmap_lock (the + * reporter saw 1.2% of extraction in 45 minutes, where v0.9.0 finished the + * tree in ~13), and the VMA count climbs toward vm.max_map_count, after + * which mmap fails for ANY size — hence mimalloc reporting it "cannot + * allocate" 10 KB while `free -g` still showed 246 GB available. + * + * mimalloc's own default is 2, meaning "eager-commit arenas only on an OS + * with overcommit (i.e. linux)" — precisely because commit is free there + * until the pages are touched. Overriding it to 0 opted Linux out of the + * default written for it. Restore the default on Linux; keep the lazy + * setting everywhere else, where commit is NOT free and the upfront-memory + * reason for it still holds (Windows especially — see #581). */ +#if defined(__linux__) + mem_option_set_verified(mi_option_arena_eager_commit, 2, "arena_eager_commit"); +#else mem_option_set_verified(mi_option_arena_eager_commit, 0, "arena_eager_commit"); +#endif mem_option_set_verified(mi_option_purge_decommits, SKIP_ONE, "purge_decommits"); mem_option_set_verified(mi_option_purge_delay, 0, "purge_delay"); /* immediate */ /* v3 (#832): reclaim abandoned pages on ANY thread's free (=1), restoring the diff --git a/tests/test_mem.c b/tests/test_mem.c index 7b0e73d01..31c5a686f 100644 --- a/tests/test_mem.c +++ b/tests/test_mem.c @@ -41,6 +41,32 @@ /* ── mem basic tests ──────────────────────────────────────────── */ +/* Since #1360 routed ordinary malloc/new through mimalloc on Linux, the arena + * policy governs EVERY allocation in the process, not just the bound + * sqlite/tree_sitter populations. With lazy arena commit (0), mimalloc commits + * sub-ranges via mprotect(PROT_READ|PROT_WRITE) over a PROT_NONE reservation, + * and each partial commit SPLITS the reserved VMA: an index worker on the Go + * corpus held ~22k mappings where v0.9.0 held 10, growing with worker count. + * That is how #1654's 96-CPU host reached vm.max_map_count, after which mmap + * fails for ANY size — 10 KB allocations failing while `free -g` still showed + * 246 GB available. mimalloc's own default is 2, meaning "eager-commit arenas + * only on an OS that overcommits (i.e. linux)", where commit is free until the + * pages are touched; overriding it to 0 opted Linux out of the default written + * for Linux. Measured: 22450 -> 17312 mappings, wall time and peak RSS + * unchanged. Pin the platform split so the Linux default cannot be silently + * opted out again — and keep the lazy setting where commit is NOT free + * (Windows especially, see #581). */ +TEST(mem_arena_eager_commit_follows_platform_commit_cost) { + cbm_mem_init(0.5); + long eager = mi_option_get(mi_option_arena_eager_commit); +#if defined(__linux__) + ASSERT_EQ(eager, 2); +#else + ASSERT_EQ(eager, 0); +#endif + PASS(); +} + TEST(mem_rss_tracking) { cbm_mem_init(0.5); @@ -1253,6 +1279,7 @@ TEST(mem_map_attributes_a_known_allocation) { SUITE(mem) { /* mem API */ + RUN_TEST(mem_arena_eager_commit_follows_platform_commit_cost); RUN_TEST(mem_map_attributes_a_known_allocation); RUN_TEST(mem_rss_tracking); RUN_TEST(mem_collect_reclaims);