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 <[email protected]>
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) <[email protected]>
---
 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?";

_______________________________________________
cfe-commits mailing list
[email protected]
https://lists.llvm.org/cgi-bin/mailman/listinfo/cfe-commits

Reply via email to