[clang-tools-extra] [clangd] Add --malloc-arena-max to bound glibc arena growth (PR #213350)
via cfe-commits
cfe-commits at lists.llvm.org
Fri Jul 31 13:54:41 PDT 2026
https://github.com/StefanoD updated https://github.com/llvm/llvm-project/pull/213350
>From 921db4fff509fe69a7aebe45ac7a25be3c3dfc72 Mon Sep 17 00:00:00 2001
From: Stefano Di Martino <stefano.d at posteo.de>
Date: Fri, 31 Jul 2026 22:15:38 +0200
Subject: [PATCH] [clangd] Add --malloc-arena-max to bound glibc arena growth
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
D93452 identified glibc malloc arena growth as the reason clangd's RSS far
exceeds the memory it actually holds, but it only landed a mitigation
(periodic malloc_trim, now --malloc-trim). The arena count itself was never
bounded. This adds the knob to do that.
Measured on the llvm-project monorepo: background-indexing a 200-TU stratified
subset (median preprocessed size 3.8 MB, matching the full-corpus median) with
-j 8, clangd's own $/memoryUsage reports ~500 MB of accounted memory while peak
RSS is ~4.1 GB -- a factor of 8. The accounted figure is flat across every
allocator configuration below, so the entire spread is allocator behaviour, not
data clangd retains.
The mechanism, via bpftrace counting 64 MiB PROT_NONE mmaps (how glibc reserves
a non-main arena), grouped by thread over one indexing run:
@arena_reservations_by_thread[ground-worker-6]: 1
@arena_reservations_by_thread[ground-worker-1]: 3
@arena_reservations_by_thread[IndexStdlib]: 3
@arena_reservations_by_thread[ground-worker-2]: 4
@arena_reservations_by_thread[ground-worker-5]: 4
@arena_reservations_by_thread[ground-worker-4]: 5
@arena_reservations_by_thread[ground-worker-3]: 5
@arena_reservations_by_thread[ground-worker-7]: 5
@arena_reservations_by_thread[ground-worker-8]: 6
36 arenas x 64 MiB for 8 worker threads. glibc's default limit is 8 arenas per
core, so on a many-core machine nothing bounds this but demand.
A/B of the flag, same binary, 3 runs per arm, medians (AMD Ryzen 7 1700,
performance governor, boost disabled, --malloc-trim=0 so the peak is not
perturbed by trim timing):
arm n wall_s median peak_rss_MB anon_MB acct_MB d_wall d_rss
default 3 77.4 (±1.5) 4144 3983 500 +0.0% +0.0%
arena8 3 78.9 (±0.3) 3799 3608 503 +1.9% -8.3%
arena4 3 80.6 (±0.2) 3130 2873 502 +4.1% -24.5%
arena2 3 84.1 (±0.5) 3003 2682 503 +8.7% -27.5%
raw runs (wall/peak_rss):
default 80.2s/4144MB | 77.2s/3740MB | 77.4s/4162MB
arena8 78.5s/4022MB | 79.1s/3740MB | 78.9s/3799MB
arena4 80.6s/2865MB | 81.0s/3130MB | 80.6s/3445MB
arena2 84.1s/2441MB | 83.8s/3030MB | 84.8s/3003MB
This is a genuine trade-off, not a free win: fewer arenas means less allocator
concurrency, and the throughput cost rises monotonically as the limit drops.
That is why the flag defaults to 0 (keep glibc's default) rather than picking a
value for everyone -- the right point on this curve depends on how
memory-constrained the machine is, and the data above is one workload on one
machine.
For reference, replacing the allocator outright does better on both axes on the
same benchmark (3 runs each, medians), which is worth knowing for packagers even
though it is not something clangd can adopt in-tree without a new dependency:
arm wall_s median peak_rss_MB anon_MB d_wall d_rss
glibc default 79.5 (±1.1) 4073 3890 +0.0% +0.0%
jemalloc 78.0 (±0.8) 2698 2278 -1.9% -33.8%
tcmalloc 75.6 (±1.9) 2703 1004 -4.9% -33.6%
Not unit-tested: the flag's only effect is a process-global mallopt(3) call at
startup, which has no seam a clangd unittest can observe. Flag parsing follows
the existing --malloc-trim pattern and is exercised by --help-hidden.
Co-Authored-By: Claude Opus 5 (1M context) <noreply at anthropic.com>
---
clang-tools-extra/clangd/tool/ClangdMain.cpp | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/clang-tools-extra/clangd/tool/ClangdMain.cpp b/clang-tools-extra/clangd/tool/ClangdMain.cpp
index 13fe4d3911731..75836294cd34f 100644
--- a/clang-tools-extra/clangd/tool/ClangdMain.cpp
+++ b/clang-tools-extra/clangd/tool/ClangdMain.cpp
@@ -556,6 +556,19 @@ std::function<void()> getMemoryCleanupFunction() {
std::function<void()> getMemoryCleanupFunction() { return nullptr; }
#endif
+#ifdef __GLIBC__
+opt<unsigned> MallocArenaMax{
+ "malloc-arena-max",
+ cat(Misc),
+ desc("Maximum number of glibc malloc arenas (0 uses the glibc default of "
+ "8 per CPU core). Each arena retains freed memory independently, so "
+ "on many-core machines peak RSS can greatly exceed the memory clangd "
+ "actually holds. Lower values trade some allocator concurrency for a "
+ "substantially smaller footprint."),
+ init(0),
+};
+#endif
+
#if CLANGD_ENABLE_REMOTE
opt<std::string> RemoteIndexAddress{
"remote-index-address",
@@ -823,6 +836,13 @@ clangd accepts flags on the commandline, and in the CLANGD_FLAGS environment var
if (CrashPragmas)
allowCrashPragmasForTest();
+#ifdef __GLIBC__
+ // Must happen before the worker threads start allocating: glibc assigns
+ // arenas to threads on their first allocation.
+ if (MallocArenaMax > 0 && !mallopt(M_ARENA_MAX, MallocArenaMax))
+ elog("Failed to set malloc arena limit to {0}", MallocArenaMax);
+#endif
+
if (!Sync && WorkerThreadsCount == 0) {
llvm::errs() << "A number of worker threads cannot be 0. Did you mean to "
"specify -sync?";
More information about the cfe-commits
mailing list