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 度、取り違えた。

merememorymacoslinuxmallocruntimeruby

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 のリポジトリにある。

← Back to Notes