free しても返らない —— Mere の膨張と、macOS の mmap、Linux の閾値
自作言語 Mere で書いた Ruby 処理系を、50 safepoint ごとに collection を走らせる試験にかけると、6 GB を越えて止まらなかった。原因は 3 つあった。1 つは runtime の二乗。もう 1 つは macOS の malloc で、今の macOS は free した領域を RSS にも footprint にも残したまま、頼んでも返さない。region の block のうち 16 KB 以上を mmap で取って munmap で返すようにした。Linux の glibc は大きいものを返すから変えなくてよい、と最初は判断したが、測ると Linux でも 17 秒で 6 GB を越えた。glibc の mmap の閾値は、一度 free すると上がる。起動時に閾値を固定して直した。解放の時点も、ふつうの program の速さも変わらず、膨らむ program では RSS が 2〜3 割減った。途中で 2 度、取り違えた。
Mere は私が自分のために作っている小さな言語だ。C を出すバックエンドの runtime は、メモリを region(arena)で管理する。 region は block を malloc で取って並べ、region を出るときにまとめて free する。GC は無い。 メモリが小さく、解放の時点が決まっていることが、この言語の取り柄の 1 つだ。
その上に、Mere で書いた Ruby の処理系 mere-ruby がある。こちらは Ruby の値のために自前の collector を持ち、 Fiber は Mere のコルーチンで作っている。
この記事は、mere-ruby の 1 つの試験が 6 GB を越えて止まらなかった話だ。原因は、runtime の二乗と、malloc だった。 malloc の問題は最初、macOS だけのものだと思った。後で Linux でも同じだと分かり、OS ごとに違う直し方をした。 数字は Apple Silicon(RAM 32 GB)の macOS 26.7.1 と、Ubuntu 24.04(glibc 2.39、kernel 6.8、arm64)の container で測った。
先に表を
| 直す前 | 直した後 | |
|---|---|---|
| 試験(Fiber を千単位で止めたまま、50 safepoint ごとに collection) | 300 秒で前半のループを抜けられない。二乗を直した後も 6 GB 超 | 181 秒で最後まで、最大 3.7 GB |
| 同じ program を普通に走らせたときの最大 RSS | 1.08 GB | 0.74 GB(−31%) |
| mere-ruby の corpus 472 本の CPU 時間 | 75.3〜77.8 秒 | 75.7〜79.1 秒(振れの範囲) |
| 同じ 472 本の最大 RSS の合計 | 79.7 GB | 78.4 GB(−1.6%) |
ruby の test_kwarg_eval_memory_leak(伸びの上限 1.2) |
1.209 で落ちる | 通る |
| 400 MB の region を出た直後の RSS | 841 MB | 1 MB |
| Linux:同じ試験 | 17 秒で 6 GB 超 | 244 秒で最後まで、最大 3.6 GB |
| Linux:普通に走らせたときの最大 RSS | 788 MB | 598 MB(−24%) |
| 8,000 個の map を compact した region を出た直後の RSS(macOS) | 991 MB | 42 MB |
表の「直した後」の macOS の試験は、malloc の直し(v0.1.686)だけのときの数字だ。後の map の直し(v0.1.688)も入ると、最大は 2.5 GB に下がる。
試験が終わらない
mere-ruby には、collector に穴が無いかを炙り出すための mode がある。MERE_RUBY_GC_STRESS=50 で、
50 safepoint ごとに必ず collection を走らせる。472 本の corpus の 1 本が、この mode で終わらなかった。
Enumerator を作っては 1 歩だけ進めて捨てる、を数千回繰り返す program だ。Enumerator は 1 つずつ Fiber、
つまり止まったコルーチンを持つ。
sample で見ると、時間の 95% が runtime の 1 つの関数にあった。region を退かせるとき、止まったコルーチンの
スタックがその region を指していないかを調べる関数だ。指していれば解放を保留する。解放すると、
コルーチンが再び走ったときに解放済みの領域を読むことになる。
ここに二乗があった。region を 1 つ退かせるたびに、保留中の region を全部調べ直し、1 つごとに 止まったコルーチン全部のスタックを歩いていた。止まった Fiber が千単位あると、とても遅い。
止まったスタックの中身は、そのコルーチンが走るまで変わらない。そこで保留中の region に「最後に調べた時刻」を持たせ、 pin していたコルーチンがどれもその後走っていなければ、歩かずに保留のままにした。答えが「まだ pin されている」に 決まっている仕事を省くだけなので、region が解放される時点は変わらない。これは OS に関係のない直しで、v0.1.685 にした。 3000 回のループは 300 秒超から 46 秒になった。
それでも試験は終わらなかった。後半で 6 GB を越え、番人(RSS を見張って殺す script)に殺された。
見立てを 2 つ外した
最初の見立ては Fiber の数だった。止まった Fiber は、1 つの文の中で 1024 個溜まると回収される。 GC_STRESS の下でそれが効かず、倍々に溜まっているのではないか。印字を入れて測ると、回収は 1024 個から 85 個へと 毎回効いていた。外れ。
次の見立ては、保留中の region だった。止まった Fiber が古い region を pin し続け、積もっているのではないか。 退避のたびに保留の数と量を出すと、多いときでも数十 MB だった。これも外れ。
vmmap で、6 GB に近づく途中のメモリの内訳を見た。
MALLOC_SMALL resident 446 MB
MALLOC_SMALL (empty) resident 2.6 GB
MALLOC_LARGE resident 650 MB
VM_ALLOCATE resident 112 MB ← Fiber のスタック
いちばん大きいのは MALLOC_SMALL (empty) だった。解放済みで、malloc が抱えている領域だ。
runtime は region を確かに free していた。返ってこないのは、その先だった。
free しても macOS は返さない
小さな C の program で確かめた。決まった大きさの領域を malloc で取って全部書き、全部 free して、その直後の量を読む。
macOS 26.7.1(free の直後)
| 確保して free | 分類 | RSS | phys_footprint |
|---|---|---|---|
| 64 KB × 1000 | MALLOC_SMALL | 64 MB のまま | 64 MB のまま |
| 1 MB × 200 | MALLOC_SMALL | 202 MB のまま | 202 MB のまま |
| 16 MB × 12 | MALLOC_LARGE | 193 MB のまま | 193 MB のまま |
| 64 KB × 1000 を mmap/munmap | — | 1 MB | 1 MB |
どの大きさでも、free しただけでは 1 byte も減らなかった。phys_footprint は、Activity Monitor のメモリ欄に出る、
メモリ不足の判定に使われる値だ。それも減らない。malloc_zone_pressure_relief(NULL, 0)、
つまり「メモリが逼迫しているので返せるだけ返して」という頼みにも、0 byte と答えた。
今の macOS の既定の割り当て器は xzone malloc で、解放した block を手元に置いて、次の割り当てで使い回す方針らしい。 同じ現象を、別のプロジェクトも macOS 26.3.1 で報告している(16 KB〜4 MB の block が footprint に残り、 pressure relief は 0 を返す)。
Linux(glibc 2.39、free の直後)
| 確保して free | free の直後の RSS | malloc_trim(0) の後 |
|---|---|---|
| 64 KB × 1000 | 63 MB のまま | 1 MB |
| 256 KB × 400 | 1 MB | 1 MB |
| 1 MB × 200 | 1 MB | 1 MB |
| 20 KB × 4000 を 1 つおきに free | 77 MB | 47 MB |
glibc は 128 KiB 以上(その後、動的に最大 32 MiB まで上がる)の割り当てを最初から mmap で取るので、free ですぐ返る。
小さいものは heap に残るが、malloc_trim(0) を呼べば、heap の途中の丸ごと空いたページまで返す。
方針が逆に見えた。glibc は大きいものをすぐ返し、macOS は返さずに抱えて使い回す。 Mere の region は、確保と解放を大きな単位で繰り返す。抱えられた分が、そのまま膨らんでいった —— と、このときは macOS だけの問題だと思っていた。
直し方:16 KB 以上を自分で mmap する
macOS でだけ、region の block のうち 16 KB 以上を malloc ではなく mmap で取り、解放するときは munmap で返すようにした。
16 KB 未満は今までどおり malloc だ。
- 16 KB にした理由: Apple Silicon のページの大きさだからだ。mmap の最小単位は 1 ページなので、これより小さい block を 1 つずつ mmap すると 16 KB に切り上がる。
- このとき macOS だけにした理由は 2 つあった(1 つ目は後で誤りと分かる)。
- glibc はもともと大きいものを mmap で返しているので、Linux で変える理由が薄い。
- Linux には 1 process あたりの mapping 数の上限
vm.max_map_countがある。kernel の既定は 65,530 で、block ごとに mmap すれば当たりうる(Ubuntu 24.04 や Fedora 39 は 1,048,576 に引き上げているが、古い既定の機械はまだ多い)。
- 伸ばすとき: block を伸ばす
reallocは、macOS にmremapが無いので、新しく取って写して古いほうを返す。
手元に取っておいて使い回す cache も試した。返した mapping を 32 MB まで残し、同じ大きさの次の確保に回す。 かえって遅くなった。大きさがぴったり合うものしか使えず、探す手間が増えただけだった。入れなかった。
Mere の良さは削れないか
「この変更は Mere の良さを失わないか」と訊かれて、測った。
- 解放の時点は変わらない。 region を出たときに block を返すのは前と同じで、返し先が malloc の手元から OS に変わっただけだ。
- 速さはほぼ変わらない。 corpus 472 本の CPU 時間は、振れの幅(約 3%)の中だった。確保と解放の激しい 1 本では、system call の分だけ遅くなった(2.88 → 3.35 秒の回があった。ただし同じ binary でも 2.81〜3.26 秒の幅がある)。
- メモリは小さくなる。 ふつうの program では少し、膨らむ program では大きく。
変更を確かめる gate は、400 MB まで育てた region を出た直後に、外から ps で RSS を読み、64 MiB 未満を求める。
直した runtime では 1 MB で、malloc に戻す毒(poison)を入れると 841 MB になって赤くなる。
物差しを取り違えた
1 度、間違った結論を出している。
malloc の振る舞いは環境変数でも変えられる。MallocSpaceEfficient=1 などを付けて試験を走らせ、最大 RSS が変わらないのを見て、
「どの設定でも返らない」と結論した。Mere の changelog にも、いったんそう書いた。
後でこの記事のために測り直すと、違った。
| free の直後 | 64 KB × 1000 | 1 MB × 200 | 16 MB × 12 |
|---|---|---|---|
| 既定 | RSS 64 / footprint 64 MB | 202 / 202 MB | 194 / 193 MB |
MallocSpaceEfficient=1 |
RSS 64 / footprint 1 MB | 202 / 2 MB | 1 / 1 MB |
MallocLargeCache=0 |
RSS 64 / footprint 1 MB | 202 / 2 MB | 2 / 1 MB |
これらの設定では、解放した領域に「再利用可」の印が付く。footprint からはすぐ外れ、RSS には kernel が実際に回収するまで残る。 私は RSS の、しかも最大値しか見ていなかった。最大値は「解放した後に返ったか」を何も語らない。 解放する前に積み上がった量が、そのまま最大値として残るからだ。
結論は変わらなかった。既定の設定ではどちらの物差しでも返らない。環境変数は process を起動するときの設定で、 runtime が内側から確実に変えられるものではない。それでも、changelog の文面は push する前に直した。
もう 1 つの取り違え:Linux でも膨らんでいた
最初の版のこの記事は、課題に「Linux で膨らむ試験は測っていない」と書いていた。次の日に測った。 同じ試験を Linux で走らせると、17 秒で 6 GB を越えた。
glibc の mmap の閾値は動的だ。最初は 128 KiB 以上を mmap で取るが、mmap で取った block を一度 free すると、 閾値をその大きさまで引き上げる。以後、その大きさまでの確保は heap から取られ、free しても heap に残る。 上の小さな C の program は、確保と解放を 1 回しかしていなかった。見ていたのは最初の 1 回の振る舞いだけだった。 Mere の region は、同じくらいの大きさの確保と解放を何度も繰り返す。
| 方法(Linux) | 普通の実行の最大 RSS | 試験 |
|---|---|---|
| 既定 | 788 MB | 17 秒で 6 GB 超 |
64 MB 解放するごとに malloc_trim(0) |
608 MB | 105 秒で 6 GB 超 |
| 閾値を 64 KiB に固定 | 574 MB | 207 秒で最後まで、最大 3.6 GB |
| 16 KB 以上を自分で mmap | 555〜564 MB | 258 秒で最後まで、最大 3.6 GB |
ときどき malloc_trim を呼ぶだけでは追いつかなかった。v0.1.688 で、glibc のときは program の起動時に
mallopt(M_MMAP_THRESHOLD, 64 KiB) で閾値を固定するようにした。利用者が MALLOC_MMAP_THRESHOLD_ を設定していれば
何もしない。library として build したときもしない。library が process 全体の設定を勝手に変えないためだ。
自分で mmap するより速く、mmap が失敗したときや個数の上限に達したときは glibc が自分で heap に戻すので、
mapping 数の上限の心配も無い。
gate も Linux で答えるようにした。region を出た直後の RSS が、直した runtime で 5 MB、閾値を動的なままにする毒で 405 MB になる。
同じ版で、確かめる途中で見つかった別の漏れも直した。map や Vec は、compact すると自分専用の arena を持つ。 その arena が、map を作った region を出ても解放されていなかった。map の構造体は region と一緒に消えるのに、 arena は誰とも一緒に消えなかったのだ。arena を、構造体が置かれた region に結び付けて、一緒に退かせるようにした。 8,000 個の map を作った region を出た直後の RSS は、macOS で 991 MB から 42 MB になった。
課題
- 16 KB 未満の block は、今も malloc が抱える。 小さい region が多数ある program では、まだ膨らみうる。
- block ごとの mmap には system call の費用がある。 普通の program では見えないが、確保と解放が激しいと出る。費用を上げずに使い回す仕組み(使い回す block の大きさの合わせ方)は、まだ見つけていない。
- 試験は終わるようになったが、最大は 3.7 GB ある。 千単位の止まった Fiber と、50 safepoint ごとの collection という条件は、もともと重い。
- Linux では、process 全体の malloc の閾値を変えている。 Mere の program が FFI で使う C のライブラリの、64 KiB 以上の割り当ても mmap になる。library として build したときは変えない。
始める前の自分に言うこと
- runtime が free しているなら、次は malloc を疑う。 free と「OS に返る」の間には割り当て器があり、OS ごとに方針が違う。
- 最大 RSS で「返ったか」は分からない。 解放の直後の量を読む。macOS では RSS と footprint の両方を読む。
- 見立ては測って外す。 Fiber の数も、保留中の region も、印字 1 つで外れた。
vmmapの 1 行が答えだった。 - macOS の数字は版で変わる。 割り当て器の中身ごと替わっている。手元で測ってから決める。
- 1 回の確保と解放で、割り当て器の方針を判断しない。 glibc は 1 回目は返し、2 回目からは返さなかった。実際の program の形で、繰り返して測る。
- 番人を付ける。 この調査の前日、番人の無い試験が 43 GB まで伸びて、macOS は圧縮と swap で粘った末に watchdog の kernel panic で止まった。Linux なら OOM killer がその 1 process を殺して終わる場面だ。
二乗の直しは Mere v0.1.685、macOS の mmap は v0.1.686、Linux の閾値と map の arena は v0.1.688 に入っている。 どれも、確かめる gate と、それを外したときに赤くなる毒が、Mere のリポジトリにある。