Rubyプロセスを起動した直後のメモリフットプリント(メモリ消費量)を、Ruby 1.8.7〜3.1.0の各バージョンごとに調べました。バージョンが上がるにつれフットプリントが肥大化する様子がよく分かります。
各バージョンごとのメモリフットプリント
Rubyプロセス起動直後のメモリフットプリントを、次のようにして調べました。
$ ruby -e '$stdin.read' &
$ ps aux | awk 'NR==1||/rub[y]/'
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
ubuntu 158555 2.8 2.2 78716 22324 pts/0 T 04:04 0:00 ruby -e $stdin.read
psコマンドの出力のうち、RSSの値をプロセスのフットプリントとしました。
また比較のために、ruby --disable=gems
や ruby --enable=jit
をしたときの値も調べました。
以下が調べた結果です(単位:KB、環境:Ubuntu 20.04 LTS x86_64)。
ruby version `ruby` `ruby --disable=gems` `ruby --enable=jit`
------------------------------------------------------------------------
1.8.7-p358 3,368 - -
1.9.3-p551 6,900 - -
2.0.0-p648 8,672 6,164 -
2.1.10 17,116 14,572 -
2.2.10 17,808 14,524 -
2.3.8 19,084 14,272 -
2.4.10 17,856 14,252 -
2.5.9 17,124 14,224 -
2.6.9 21,728 18,792 22,180
2.7.5 21,512 18,772 21,584
3.0.3 21,916 19,260 22,008
3.1.0-preview1 22,420 19,624 22,452
Ruby 1.8.7 の頃はたった 3.3MB ぐらいだったのに、3.1.0 だと 22MB を超えています。起動するだけで 22MB・・・。ちょっといただけないですね。
メモリフットプリント肥大化の理由
メモリフットプリントが肥大化した理由を、Rubyのバージョンごとに推察してみます。
- Ruby 1.8: 3,368(kb)
-
今となっては信じられないくらいフットプリントが小さいです。このときは構文木をたどって実行するシンプルな方式だったので、そのおかげでしょう。
- Ruby 1.9: 6,900(kb)
-
1.8 と比べてフットプリントが倍増しました。1.9 では高速化のために実行エンジンが仮想マシン方式になったので、その影響でしょう。
- Ruby 2.0: 8,672(kb)
-
1.9 よりフットプリントが少し増えています。
ruby --disable=gems
だと増えてないので、増えたのは rubygems を標準で読み込むようになったことが理由でしょう。
- Ruby 2.1: 17,116(kb)
-
フットプリントがまた倍増しました。Ruby プロセスのメモリ大食らいが決定的になったバージョンです。2.1 では世代別GCが導入されたので、そのせいだと思います。
- Ruby 2.2: 17,808(kb)
-
特には変化なし。2.2でオブジェクトサイズが増えたりインクリメンタルGCになりましたが、その影響は起動直後のフットプリントには見られませんでした。
- Ruby 2.3: 19,084(kb)
-
少し増えました。
ruby --disable=did_you_min
とするとフットプリントが少し減るので、例外時に「Did you mean?」とヒントを出してくれるDid you mean機能が入った影響だと思います。
- Ruby 2.4: 17,856(kb)
-
少しですがフットプリントが減りました!
ruby --disable=did_you_mean
をつけてもフットプリントが減らなかったので、例外が発生するまでDid you mean機能を読み込まなくなったのだろうと思われます。
- Ruby 2.5: 17,124(kb)
-
若干減っている気がするけど、大きな変化はなし。
- Ruby 2.6: 21,728(kb)
-
またフットプリントが大きく増えました。このバージョンからJITが導入されたので、そのせいでしょう。
ruby --disable=jit
をつけてもフットプリントは変わらないので、JITを無効化してもJIT機能はエンジンに組み込まれたままなのでしょう。
- Ruby 2.7: 21,512(kb)
-
特には変化なし。
- Ruby 3.0: 21,916(kb)
-
特には変化なし。
- Ruby 3.1: 22,420(kb)
少しだけ増えています。2つ目のJIT機能であるYJITが導入されたので、その影響でしょう。
Rubyプロセスのメモリフットプリントを減らす方法
起動直後のRubyプロセスのフットプリントを少しでも減らすにはどうしたらいいでしょうか。
-
--disable=gems
オプションをつける。
Gemsパッケージに頼らずに済むなら、この方法でメモリサイズが少し減らせます。 -
rubygems.rb
を改造し、「パッケージの読み込み機能」と「パッケージの作成機能」とに分ける。
起動時に必要なのは前者だけであり、後者は必要ありません。両者を分離して起動時には前者だけを読み込むようにすれば、フットプリントを減らして起動時間も短くできるはずです。ただし現在のrubygems.rbと関連ファイルは約3万7千行もあるので、全体を理解して改造するのは大きな手間がかかります。 -
JIT機能を有効化して起動した場合だけ実行エンジンにJIT機能を読み込むよう、Rubyを改修する。
この方法は技術的な難易度が高く、Ruby内部に詳しい人でないとできません。またRuby内部に詳しい人は、高速化には興味があってもフットプリントの減少にはあまり興味なさそうです。
2番目はなんとか実現したいですね。
また起動直後ではなく、実行中のRubyプロセスのメモリ消費量を減らすにはどうしたらいいでしょうか。
-
大量のデータを処理するときは、子プロセスを起動してそっちで処理をする。
処理が終わったら子プロセスを終了させれば、もとのプロセスには影響がありません。 -
コードサイズの大きいライブラリを使わず、コードサイズが小さくてコンパクトな設計のライブラリを使う。
本質的な解決策とは言えませんが、結局はこれがいちばん効きそうです。たとえばO/Rマッパーを使わずSQLで頑張るとか。RailsやめてSinatra使うとか。RSpecやめてOktest.rb使うとか(Oktest.rb の紹介記事)。 -
mrubyに乗り換える。
Rubyからmrubyへの乗り換えは、今なら十分検討対象でしょう。全体を乗り換えなくても、処理を切り出して部分的にmrubyに任せるのはアリよりのアリです。
参考:他言語のメモリフットプリント
起動直後のメモリフットプリントを、他の言語でも調べてみました。当然ですがスクリプト言語だけが対象です(例外としてJVMは対象とします)。
language version RSS (kb)
------------------------------------
perl 5.30.0 5,104
python 3.8.10 9,004
php 7.4.3 15,748
nodejs 10.19.0 34,364
openjdk 17 32,036
erlang 22.2.7 20,544
guile 3.0.1 9,420
gauche 0.6.9 9,296
gforth 0.7.3 2,988
tcl 8.6.9 4,172
## シェル系
bash 5.0.17 3,096
zsh 5.8 3,544
dash 0.5.10 608
tcsh 6.21.00 672
yash 2.49 1,188 https://yash.osdn.jp/
gawk 5.0.1 3,292
mawk 1.3.4 1,064
## 組み込み向け
lua 5.3.3 1,276
squirrel 3.1 1,828
mruby 2.0.0 2,296
mruby 3.0.0 2,640
micropython 1.12 1,424
lily 2.0.0 1,124 http://lily-lang.org/
wren 0.4.0 3,172 https://wren.io/
gravity 0.8.5 2,216 http://gravity-lang.org/
pocketlang 0.1.0 864 https://thakeenathees.github.io/pocketlang/
所感です。
- メモリ喰いは、Node.js と JVM (OpenJDK) がツートップ。第2グループは Erlang と PHP(と Ruby)。
- 最近の Python は起動直後のフットプリントが徐々に減っており、Python 3.8 では 9MB で済んでいる(以前は 15MB ぐらいあった)。よい傾向なので今後もキープしてほしい。
- Perl が 5MB しか消費していないのは称賛されるべき。
- 組み込み向け言語はどれもフットプリントが小さい(Bash や Zsh よりも小さい)。どの言語も VM 方式なので、フットプリントが大きいのを VM のせいにしてはいけないことがわかる。
- dash と tcsh と pocketlang が極端に小さい。たぶん libreadline など余計なものを使ってないのだろう。
- GC の方式による違いは分からなかった。たとえば世代別GCを採用するとフットプリントが増えるとか、そういうのがあるなら知りたい。
上の一覧にはないけど、将来は WebAssembly 用 VM がインタプリタ言語界を席巻するでしょう。Server-side Kotlin は JVM よりも WASM VM 上で動かすことが多くなり、JVM と CLR のシェアはぐっと下がるでしょう。
まとめ
Railsプロセスなら仕方ないにしても、そうではないRubyプロセスが起動直後で 20MB 以上も消費するのは勘弁してほしいです(最近のPythonを見習おう)。AWS EC2 t3.nano のメモリ量が 512MB であり、OSや他プロセスによる使用分などを除いて実質的に使えるのが 300MB だとすると、20MB は 7% になります。Rubyを起動するだけで使用可能メモリ量の 7% も消費すると考えれば、あまり歓迎したくない数字です。今はまだJVMやV8よりましとはいえ、Rubyは「軽量」な言語であってほしいと思います。
おまけ:Rubyプロセスの起動時間
Ruby 3.1.0-preview1 における起動〜終了までの時間を、rubygems が有効なときと無効にしたときとで比較しました。前者が 98ms、後者が 18ms で、5倍以上の違いがあることが分かります。メモリフットプリントだけでなく起動速度の観点からも、rubygems の軽量化が望まれます。
$ time ruby -e 'nil' # rubygems 有効
real 0m0.098s
user 0m0.078s
sys 0m0.020s
$ time ruby -e 'nil' --disable=gems # rubygems 無効
real 0m0.018s
user 0m0.005s
sys 0m0.013s
おまけ:プロセスデーモン化ツール「reraise」
もともと本記事の発端は、Python 製の Supervisor というプロセスデーモン化ツールが起動するだけで23MBもメモリを消費したり、Ruby 製の God が33MBも消費することに嫌気が差し、この手のツールをもし書き直すならどの言語がいいだろうかと検討したことがきっかけです。最終的にはシェルスクリプトを使って「reraise」というツールを作ったので、よければ使ってみてください。メモリフットプリントは2MB未満で済んでおり1、また root 権限なしで利用できます。
-
reraise ・・・ a service process supervisor tool, like runit, dameontools, Supervisor (python), or God (ruby).
https://github.com/kwatch/reraise/
-
メモリフットプリントに関してはC言語製のツールがいちばん優れており、daemontoolsやrunitはメモリフットプリントが1MB未満です。ただどちらもCLIでの操作がいまいちであり、ドキュメントもinitとして使う方法に多くを割いているのであまり分かりやすくはないと思っています。 ↩