換掉 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-default | ruby:4.0-slim-trixie 內建的 glibc malloc |
glibc-arena2 | 同上,加 MALLOC_ARENA_MAX=2 |
glibc-trim | MALLOC_ARENA_MAX=2 + MALLOC_TRIM_THRESHOLD_=131072 |
jemalloc | 5.3.1,原始碼編譯 |
tcmalloc | gperftools 2.18.1,libtcmalloc_minimal.so |
mimalloc | v3.5.1 |
mimalloc-v2 | v2.5.2 |
snmalloc | 0.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%,確認機器的確是獨佔的。
| allocator | RSS 中位數 | vs glibc | req/s | p99 ms | major GC | 三輪離散度 |
|---|---|---|---|---|---|---|
| jemalloc | 174.5 MB | -5.1% | 1751 | 17.79 | 36 | 1.33% |
| mimalloc-v2 | 174.6 MB | -5.0% | 1695 | 17.52 | 39 | 0.96% |
| glibc-trim | 176.4 MB | -4.1% | 1651 | 18.86 | 63 | 0.33% |
| glibc-arena2 | 177.0 MB | -3.7% | 1639 | 17.89 | 49 | 0.45% |
| glibc-default | 183.9 MB | +0.0% | 1662 | 18.04 | 52 | 0.04% |
| mimalloc (v3) | 190.7 MB | +3.7% | 1681 | 17.29 | 33 | 0.77% |
| tcmalloc | 194.8 MB | +5.9% | 1727 | 19.24 | 38 | 0.65% |
| snmalloc | 195.5 MB | +6.3% | 1750 | 16.65 | 40 | 0.57% |
三輪之間的離散度最大 1.33%,而名次之間的落差橫跨 11.4 個百分點。以本次量測來說,這個排序比較像是訊號,而不是隨機波動。
幾個可以直接帶走的結論。
前兩名並列。 jemalloc 和 mimalloc v2 差 0.1 MB,落在雜訊範圍內,記憶體上可視為平手,要分勝負得看其他欄位:
| jemalloc | mimalloc-v2 | 誰贏 | |
|---|---|---|---|
| RSS 中位數 | 174.5 MB | 174.6 MB | 平手(差距 < 雜訊) |
| req/s | 1751 | 1695 | jemalloc(+3.3%) |
| p99 ms | 17.79 | 17.52 | mimalloc-v2(差距很小) |
| major GC | 36 | 39 | jemalloc |
| 三輪離散度 | 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 MB | 190.7 MB |
| vs glibc | -5.0% | +3.7% |
| major GC | 39 | 33 |
差距不是版本新舊,而是設計不同。你用套件管理程式裝到的是哪一個,就決定了實際跑起來是哪一種行為 —— 請明確 pin 版本。
glibc 自己的旋鈕已經吃掉大半好處,而且其中一個是陷阱。 三組 glibc 設定的差別只在環境變數,沒有新增任何相依套件:
# glibc-arena2
MALLOC_ARENA_MAX=2
# glibc-trim
MALLOC_ARENA_MAX=2 MALLOC_TRIM_THRESHOLD_=131072| 設定 | RSS | vs glibc | major GC | 評價 |
|---|---|---|---|---|
| 預設 | 183.9 MB | +0.0% | 52 | 基準線 |
| arena 上限設 2 | 177.0 MB | -3.7% | 49 | CP 值最高的一步 |
| 再加 trim 門檻 128 KB | 176.4 MB | -4.1% | 63 | 多 0.4% 換 11 次 major GC |
把記憶體還給 OS 會推高 malloc_increase,Ruby 因此更常觸發回收。最後那 0.4% 等於是用 CPU 換來的。
墊底的三組在本次量測中並不是被 GC 拖累,而是單純佔用較多記憶體:
| allocator | RSS | vs glibc | major GC | 是 GC 造成的嗎 |
|---|---|---|---|---|
| mimalloc (v3) | 190.7 MB | +3.7% | 33 | 否,本次量測中最少 |
| tcmalloc | 194.8 MB | +5.9% | 38 | 否,低於 glibc 的 52 |
| snmalloc | 195.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 | 不會,這個比較看不出差異 |
| 5 | Rails RAILS_MAX_THREADS 預設 | 16 | 不會,本次實測的位置 |
| 25 | Sidekiq concurrency: 25 | 16 | 會,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 mode | fork 之後 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 執行緒、單一 process | Sidekiq 那種 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_ | 先量 CPU | 0.4% 記憶體換 11 次 major GC,不一定划算 |
| Sidekiq / 較寬的執行緒設定 | 自己用 THREADS=25 跑一次 | 5 執行緒的結論在那裡不適用 |
5 執行緒的 Rails 差距大約 5%,25 執行緒的 Sidekiq 是另一個故事,而那個故事得你自己跑一次才知道。