ruby3.3のProcess.warmupがなぜメモリ削減に効くのかを解説する
メモリ食い虫のマルチプロセスrubyアプリケーションを運用している方に向けた記事です。
https://docs.ruby-lang.org/ja/3.4/method/Process/m/warmup.html を読んで、何を言っているかわかる方は読まなくてもいいかもしれません。
また、簡単な解説にしているため知っている人からすると微妙な記述があるかもしれません。やさしい目で見守ってください。明らかに間違っているところがあったらマサカリを投げてください。
Process.warmupを使うと何ができるか
アプリケーションが起動してプロセスがフォークする直前でProcess.warmup
を呼ぶと、いい感じにメモリを最適化してくれてメモリ使用量を抑えることができます。
railsの非同期処理に使われているSidekiqのv7.2.2以降ではデフォルトで使われるようになっています。RailsのデフォルトサーバーであるPumaでは有効化しようというIssueが立ちましたが、下にも書いた懸念がありデフォルトで有効化されることはなさそうです。
注意点としては、お行儀のよろしくないnative extensionを使っているGemが入っているとProcess.warmup
内部で行う「ヒープの圧縮」時にうまくメモリの整理ができずにセグフォを起こす可能性があります。何か問題が起きても私は責任を負いません。使うときはよく確認してから使うようにしましょう。
Process.warmupとは
Process.warmup
はRuby3.3で追加された、「アプリケーションの起動が完了したので最適化よろしく」とRubyVMに通知するためのメソッドです。RubyのProcessにwarmupさせるというより、アプリケーションのwarmupが終わったことを知らせるという意味だと思っています。
現時点で最新版のRuby3.4のCRuby[1]では以下を実行します。
- ヒープを圧縮
- GCされなかった全ての新世代オブジェクトを古い世代に昇格します
- 全ての文字列のcoderange([m:String#valid_encoding]などで使われる文字列の内容とエンコーディングとの整合性の情報)を事前計算します
- すべての空のヒープページを解放し、解放したページ数だけ割当可能なページカウンター(heap_allocatable_pages)を増分します
- 空のmallocページを解放するためにmalloc_trimを呼び出します
https://docs.ruby-lang.org/ja/3.4/method/Process/m/warmup.html
早い話プロセスをフォークする前にメモリをデフラグした上で、いらないものは捨てておくという意味です。
この一連の処理でなぜメモリ消費を抑えることができるのかについては、前提としてプロセスフォーク時のCopy-on-Writeと、Rubyのメモリ管理戦略について知っておく必要があります。
Copy-on-Writeとは
Linux等のUnix系OSにおいて、プロセスがフォークしたときそのプロセスのメモリはそのままコピーされて子プロセスに渡されます。ただフォーク直後には実メモリ上ではコピーされておらず、親子共に同じ実メモリを見ています。その後プログラムが続行して親子いずれかがメモリを書き換えようとしたタイミングで初めて、書き換えようとした範囲の実メモリ領域がコピーされます。
図にすると以下のようになります。
フォーク直後は親子共に同じ物理メモリを見ていますが、
子プロセスの2番地を書き換えると、
ここで物理メモリ2の内容が物理メモリ3にコピーされ、以後子プロセスが仮想アドレス2番地のメモリを読み書きするときは物理メモリ3番地を使うことになります。
この実際に書き込むまで実メモリ上のコピーを遅延させる仕組みをCopy-on-Writeと呼びます。
これにより、プロセスをフォークして仮想上のメモリ領域がプロセスをフォークした分だけ増えても、書き込みが発生するまでは実メモリ上は同じ領域を見るのでそれだけメモリ消費量を抑えることができます。
Rubyのメモリ管理
あまり詳しく解説するとそれだけで情報量が大変なことになってしまうのでここでは簡単に解説します。
Rubyのメモリ管理ではスロットとページという2つの大事な概念があります。
スロットというのはRubyの"hoge"
, 42
, []
などのオブジェクトを1つ格納することができる箱で、ページはスロットを408個ほど入れることができる一回り大きい箱です。
Rubyは必要に応じてヒープ領域と呼ばれる場所にOSからメモリを割り当ててもらったり返したりしますが、そのときはこのページを一つの単位としてやりとりします。つまり、ページの中のスロットが1つだけ使われていようが全部埋まっていようが必要なメモリ量は同じになります。
メモリ配置が乱雑だったときの問題点
プロセスをフォークする直前のメモリ配置が乱雑=空ではないけどスカスカなページが多いと困ることがいくつかあります。
- 必要なページが多くなるので必要なメモリ量が膨らむ
- プロセスがフォークした後に、スカスカなページの中の空スロットに新しいオブジェクトを割り当てようとするとCopy-on-Writeでページごと物理メモリ上にコピーされる
- 何なら空スロットを使おうとしなくても、ガベージコレクションのタイミングでオブジェクトを解放しようとしてスロットを操作するとCopy-on-Writeでページがコピーされる
Process.warmupは何をするのか
ここからようやく中身の解説に入ります。
- ヒープを圧縮
- GCされなかった全ての新世代オブジェクトを古い世代に昇格します
- 全ての文字列のcoderange([m:String#valid_encoding]などで使われる文字列の内容とエンコーディングとの整合性の情報)を事前計算します
- すべての空のヒープページを解放し、解放したページ数だけ割当可能なページカウンター(heap_allocatable_pages)を増分します
- 空のmallocページを解放するためにmalloc_trimを呼び出します
https://docs.ruby-lang.org/ja/3.4/method/Process/m/warmup.html
ヒープを圧縮
上で説明した通り空ではないけどスカスカなページが多いと無駄にメモリを消費してしまうので、ここで圧縮をします。
ページ群を後ろから見ていって、使っているスロットを見つけたら前の方のページの空いているスロットに移動してあげるという作業を繰り返していくと、前の方のスカスカだったページはパンパンになり、後ろのほうのページはすっからかんになります。これにより必要なスロット数は同じでも、必要なページ数が減るのでメモリ使用量を抑えることができます。
ちなみに、GC.compact
というメソッドを使うとこのヒープ圧縮だけを実行することもできます。
GCされなかった全ての新世代オブジェクトを古い世代に昇格
RubyのGC(ガベージコレクション、いらなくなったオブジェクトを消してスロットを空にする作業)ではGCにかかる時間をなるべく短くするため、世代別GCという手法が取られています。
世代別GCにはメジャーGCとマイナーGCの2種類があり、メジャーGCがすべてのオブジェクトを対象としたものであるのに対して、マイナーGCでは比較的最近作られたオブジェクトである新世代オブジェクトのみを対象とした省略版のGCを行います。大抵のオブジェクトは寿命が短いのでマイナーGCで回収されますが、寿命の長いオブジェクトは何度GCによるチェックをされてもどうせ生きているのだからたまに実行するメジャーGCで見るだけでいいだろうという作戦です。
通常は3回GCによるチェックを受けて生き延びないと旧世代に昇格しませんが、明示的にProcess.warmup
を打つプロセスフォークの直前ではほぼ寿命の長いオブジェクトしか残っていないはずなので一気に旧世代まで昇格させます。旧世代になることでマイナーGCではスキップしてもらえるようになることと、チェックを受けた回数を記録するためのフィールドを書き換えなくてよくなるのでCopy-on-Write観点でもメモリを節約できるのが嬉しいところです。
全ての文字列のcoderangeを事前計算
RubyのStringは文字列が読み込まれるまでエンコーディング情報を確定させない仕組みがあるらしく、フォーク前に文字列の割り当てだけをしてそれをフォーク後に読み込んでしまうとそこでエンコーディング情報が書き込まれてしまうのでCopy-on-Writeで物理メモリがコピーされてしまうおそれがあるようです。
そのため先にすべての文字列のエンコーディングを確定させることで、フォーク後に文字列を読み込んでもメモリ書き込みが起こらずに済みます。
すべての空のヒープページを解放
通常時Rubyのメモリ管理では空になったページをすぐにOSには返さず、後で使うことに備えて空になったページを取っておこうとします。ただProcess.warmup
をするときはフォークをする直前なのでなるべく小さくしておくに越したことはありません。積極的に空のページを返却してもらいます。
空のmallocページを解放するためにmalloc_trimを呼び出す
RubyがOSとメモリをやり取りするとき、デフォルトではglibcというC言語のライブラリを通じてやり取りしています。このライブラリの中でメモリを確保したり解放したりするシステムコールを発行して実際にOSがメモリを増やしたり減らしたりしています。実はこのライブラリも独自にRubyのページ管理のようなことをしており、Rubyがメモリを解放するように依頼してもこのライブラリが解放するよう依頼されたはずのメモリを後で使うことに備えて取っておくことがあります。これによっていらないはずのメモリを確保し続けることがあるので、強制的にメモリを返却させるmalloc_trimを呼び出すことでメモリ量を減らします。
余談ですが、このメモリを確保するためのライブラリはいくつかあります。デフォルトのglibcの実装があまり性能がよくないという問題があり、Ruby界隈で有名な旧FacebookことMetaが開発しているjemallocを始め、Microsoftが開発しているmimalloc、Googleが開発しているtcmallocなどいろいろあるので試してみるとよいかもしれません。
まとめ
Process.warmup
はCopy-on-Writeの恩恵を最大限受けられるように準備をすることで、メモリ消費の抑制に効果を発揮していることを解説しました。メモリに効くメカニズムについても簡単に説明したので、どうすればよりその効果を引き出せるかのヒントにもなるかと思っています。
例えばプロセスフォークをする前にRailsのアプリコードをメモリ上にロードしておく、リクエストを捌くときによく使うものは先にロードしておくなどが考えられます。その他なにか思いついたものがあれば実際に入れてメモリ消費量を計測してみて、いい感じの結果が出た手法、もしくはうまくいかなかった手法を公開してもらえると嬉しいです。
参考文献
-
Rubyのデフォルト処理系。Ruby作者のMatzさんが作ったのでMRI(Matz Ruby Interpreter)とも呼ばれる ↩︎
Discussion