換掉 glibc malloc 真的能省 Rails 記憶體嗎?八條 allocator 實測

2026-09-13
換掉 glibc malloc 真的能省 Rails 記憶體嗎?八條 Allocator 實測

「Rails 要不要換 jemalloc」是個講了十年的老問題,標準答案是「換,我們省了 30% RSS」。但那個數字大多來自很舊的 Ruby,也很少有人交代當時開幾個執行緒,或有沒有先動過 glibc 自己的參數。

我原本只想確認一件事:在 Ruby 4.0 上,jemalloc 到底還剩多少效果。

不過量測環境架都架了,就順手把其他 allocator 一起拉進來 —— tcmalloc、mimalloc、snmalloc 在 Ruby 上幾乎找不到有人實測過的數字,多跑幾組設定的代價只是多等幾個小時。

ruby-alloc-bench 就是這個實驗:同一台機器、同一個 workload、八組 allocator 設定,只用 LD_PRELOAD 替換,跑滿 5 小時。

八組設定

標籤內容
glibc-defaultruby:4.0-slim-trixie 內建的 glibc malloc
glibc-arena2同上,加 MALLOC_ARENA_MAX=2
glibc-trimMALLOC_ARENA_MAX=2 + MALLOC_TRIM_THRESHOLD_=131072
jemalloc5.3.1,原始碼編譯
tcmallocgperftools 2.18.1,libtcmalloc_minimal.so
mimallocv3.5.1
mimalloc-v2v2.5.2
snmalloc0.7.5

會特別放 glibc-arena2 是有原因的:多數「換 jemalloc 省 30%」的案例,真正的變因很可能是 glibc 的 per-thread arena。glibc 的 arena 數量預設上限是 8 × 核心數,執行緒一多就各自開 arena,記憶體用量自然被撐開。如果一個環境變數就能追平差距,那答案可能就是那個變數,而不是多一個 C 函式庫的相依套件。

glibc-trim 則是另一個常被忽略的旋鈕。RSS 降不下來的 Rails process,有不少情況並不是碎片化,而是 glibc 把 free 掉的記憶體留著不還給 OS,MALLOC_TRIM_THRESHOLD_ 決定的就是它什麼時候放手。

結果

Vultr dedicated vCPU、2 核、Debian 13、glibc 2.41、Ruby 4.0.6、YJIT 開啟。1200 秒 × 3 輪 × 8 組設定,約 4800 萬次 request。全程 CPU steal 維持在 0.003%,確認機器的確是獨佔的。

Resident set size over 1204s — railsscoring window108 MB132 MB155 MB179 MB202 MB0s1204sy axis does not start at zeroglibc-default183.9 MBglibc-arena2177.0 MBglibc-trim176.4 MBjemalloc174.5 MBtcmalloc194.8 MBmimalloc190.7 MBmimalloc-v2174.6 MBsnmalloc195.5 MB
八組設定的 RSS 隨時間變化,灰色區塊是計分視窗
allocatorRSS 中位數vs glibcreq/sp99 msmajor GC三輪離散度
jemalloc174.5 MB-5.1%175117.79361.33%
mimalloc-v2174.6 MB-5.0%169517.52390.96%
glibc-trim176.4 MB-4.1%165118.86630.33%
glibc-arena2177.0 MB-3.7%163917.89490.45%
glibc-default183.9 MB+0.0%166218.04520.04%
mimalloc (v3)190.7 MB+3.7%168117.29330.77%
tcmalloc194.8 MB+5.9%172719.24380.65%
snmalloc195.5 MB+6.3%175016.65400.57%

三輪之間的離散度最大 1.33%,而名次之間的落差橫跨 11.4 個百分點。以本次量測來說,這個排序比較像是訊號,而不是隨機波動。

幾個可以直接帶走的結論。

前兩名並列。 jemalloc 和 mimalloc v2 差 0.1 MB,落在雜訊範圍內,記憶體上可視為平手,要分勝負得看其他欄位:

jemallocmimalloc-v2誰贏
RSS 中位數174.5 MB174.6 MB平手(差距 < 雜訊)
req/s17511695jemalloc(+3.3%)
p99 ms17.7917.52mimalloc-v2(差距很小)
major GC3639jemalloc
三輪離散度1.33%0.96%mimalloc-v2

tiebreaker 落在 jemalloc:吞吐量在本次量測中最高(比 glibc 多 5.4%),major GC 次數也偏低。

同一個名字,兩種 allocator。 mimalloc v3 重寫了 free-list sharding,結果和 v2 差 16 MB:

mimalloc-v2 (2.5.2)mimalloc (3.5.1)
RSS 中位數174.6 MB190.7 MB
vs glibc-5.0%+3.7%
major GC3933

差距不是版本新舊,而是設計不同。你用套件管理程式裝到的是哪一個,就決定了實際跑起來是哪一種行為 —— 請明確 pin 版本。

glibc 自己的旋鈕已經吃掉大半好處,而且其中一個是陷阱。 三組 glibc 設定的差別只在環境變數,沒有新增任何相依套件:

# glibc-arena2
MALLOC_ARENA_MAX=2
# glibc-trim
MALLOC_ARENA_MAX=2 MALLOC_TRIM_THRESHOLD_=131072
設定RSSvs glibcmajor GC評價
預設183.9 MB+0.0%52基準線
arena 上限設 2177.0 MB-3.7%49CP 值最高的一步
再加 trim 門檻 128 KB176.4 MB-4.1%63多 0.4% 換 11 次 major GC

把記憶體還給 OS 會推高 malloc_increase,Ruby 因此更常觸發回收。最後那 0.4% 等於是用 CPU 換來的。

墊底的三組在本次量測中並不是被 GC 拖累,而是單純佔用較多記憶體:

allocatorRSSvs glibcmajor GC是 GC 造成的嗎
mimalloc (v3)190.7 MB+3.7%33否,本次量測中最少
tcmalloc194.8 MB+5.9%38否,低於 glibc 的 52
snmalloc195.5 MB+6.3%40否,低於 glibc 的 52

比較可能的原因是這台機器的 THP 設成 always:這三者都大量使用 mmap,huge page 的粒度會把用量向上取整。在 madvise 的主機上結果可能不同,所以報告的 header 會記錄你量到的是哪一種。

為什麼是 5 個執行緒

這是本次實驗中最關鍵的變數之一。THREADS=5 是 Rails RAILS_MAX_THREADS 的預設值,也決定了 glibc arena 的比較看不看得出差異:glibc 會在每個執行緒第一次 malloc 時配一個 arena 給它,GVL 擋不住這件事 —— GVL 只是不讓它們並行執行而已。核心數只決定總量上限(8 × 核心),5 個執行緒並不會碰到那個上限。

執行緒數典型情境glibc arena 上限(2 核)會不會碰到上限
1單執行緒腳本16不會,這個比較看不出差異
5Rails RAILS_MAX_THREADS 預設16不會,本次實測的位置
25Sidekiq concurrency: 2516會,MALLOC_ARENA_MAX 才比較有發揮空間

所以如果 production 跑的是更寬的設定,就得自己掃一遍:

THREADS=25 ./bench/run.sh

那可能才是 MALLOC_ARENA_MAX=2 真正發揮價值的地方,5 執行緒的結果看不到。

量測設計上的幾個決定

決定為什麼
Workload 用 yjit-bench 的 railsbench(Rails 8.1 + sqlite,SHA 有 pin),透過 Rack::MockRequest 在 process 內驅動量到的是真實 Rails 的記憶體配置行為,不是自己模擬出來的形狀
5 個執行緒,刻意採用多執行緒單執行緒不會出現多個 arena 的情況,而那正是整個比較的主題
不用 Puma cluster modefork 之後 parent 和 child 共用 page,RSS 會重複計算,那得改量 PSS,屬於另一個 benchmark
主要指標是暖機後的 RSS 中位數Peak RSS、req/s、p99 都有記錄,但決策不掛在那上面
每次取樣都記 GC.stat才分得出「用比較少記憶體」和「讓 Ruby GC 更辛苦」
p99 用 pre-allocated 的 per-thread ring buffer,跑完才排序中途計算百分位數,等於在被量測的 process 裡面配記憶體
用 --cpuset-cpus=0-1 而不是 --cpus=2--cpus 是 CFS quota,不會改變 nproc,glibc 仍會照實體核心數計算 arena 上限
每組設定先 preflight,grep /proc/self/maps被默默忽略的 LD_PRELOAD 會產出八份一模一樣的 glibc 結果
開跑前先印 CPU steal超過 1% 通常代表是共享 vCPU,不管產品頁怎麼寫,timing 欄位都不可信
每輪獨立跑,中間有 cooldown避免前一輪的殘留狀態影響下一輪

跑起來

./bench/run.sh                          # 1200s × 3 rounds × 8 lines ≈ 5h
DURATION=120 ROUNDS=1 ./bench/run.sh    # 快速驗證
WORKLOAD=synth ./bench/run.sh           # 不跑 Rails,改用模擬的記憶體配置負載

# 補跑新的設定,不重跑已經量過的
ONLY="glibc-trim mimalloc-v2 snmalloc" ./bench/run.sh

WORKLOAD=synth 是不跑 Rails 的版本:自己產生大量短命的字串和 hash,再把其中一小部分留進固定大小的 pool,模擬出 Rails 那種「大量配置、少量長壽」的記憶體使用形狀。適合在沒辦法裝 Rails 的環境裡做比較。

執行結果會輸出到 results/:每輪一個 CSV,加上 REPORT.md 和 rss-<workload>.svg。原始 CSV 刻意不進 repo —— harness 本身才是產出物,懷疑數字的人可以在自己的硬體上跑一次,那比相信我這張表更有價值。

這份實測沒有說的事

限制可能的影響
只有一種 workload(railsbench)、5 執行緒、單一 processSidekiq 那種 concurrency: 25 的形狀會給 glibc arena 大得多的發揮空間,差距可能拉開
跑到 1200 秒,曲線還在非常緩慢地往上爬接近穩態但不是完全穩態;名次比絕對數字更早收斂
THP 設成 always在 madvise 的主機上,墊底那三組未必是這個名次
沒有測 Ruby 端的 GC 調校RUBY_GC_OLDMALLOC_LIMIT_MAX、compaction 可能比換 allocator 更划算,而且不用多一個相依套件

所以該換嗎

你的情況該做什麼拿到什麼
從沒動過 glibc 參數先設 MALLOC_ARENA_MAX=2-3.7%,不新增相依套件,本次量測中最划算的一步
已經設過 arena,還想再擠換 jemalloc再多 1.4 個百分點,加上本次量測中最高的吞吐量
打算用 mimalloc明確 pin v2不要讓套件管理程式替你決定跑的是 v2 還是 v3
想加 MALLOC_TRIM_THRESHOLD_先量 CPU0.4% 記憶體換 11 次 major GC,不一定划算
Sidekiq / 較寬的執行緒設定自己用 THREADS=25 跑一次5 執行緒的結論在那裡不適用

5 執行緒的 Rails 差距大約 5%,25 執行緒的 Sidekiq 是另一個故事,而那個故事得你自己跑一次才知道。

https://blog.2ac.io/posts/feed.xml