Rubyのメモリフットプリント肥大化の歴史

Rubyプロセスを起動した直後のメモリフットプリント（メモリ消費量）を、Ruby 1.8.7〜3.1.0の各バージョンごとに調べました。バージョンが上がるにつれフットプリントが肥大化する様子がよく分かります。

## 各バージョンごとのメモリフットプリント

Rubyプロセス起動直後のメモリフットプリントを、次のようにして調べました。

```terminal
$ 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のバージョンごとに推察してみます。

<dl>
<dt>Ruby 1.8: 3,368(kb)</dt>
<dd><p>今となっては信じられないくらいフットプリントが小さいです。このときは構文木をたどって実行するシンプルな方式だったので、そのおかげでしょう。</p><br></dd>
<dt>Ruby 1.9: 6,900(kb)</dt>
<dd><p>1.8 と比べてフットプリントが倍増しました。1.9 では高速化のために実行エンジンが仮想マシン方式になったので、その影響でしょう。</p><br></dd>
<dt>Ruby 2.0: 8,672(kb)</dt>
<dd><p>1.9 よりフットプリントが少し増えています。<code>ruby --disable=gems</code> だと増えてないので、増えたのは rubygems を標準で読み込むようになったことが理由でしょう。</p><br></dd>
<dt>Ruby 2.1: 17,116(kb)</dt>
<dd><p>フットプリントがまた倍増しました。Ruby プロセスのメモリ大食らいが決定的になったバージョンです。2.1 では世代別GCが導入されたので、そのせいだと思います。</p><br></dd>
<dt>Ruby 2.2: 17,808(kb)</dt>
<dd><p>特には変化なし。2.2でオブジェクトサイズが増えたりインクリメンタルGCになりましたが、その影響は起動直後のフットプリントには見られませんでした。</p><br></dd>
<dt>Ruby 2.3: 19,084(kb)</dt>
<dd><p>少し増えました。<code>ruby --disable=did_you_min</code> とするとフットプリントが少し減るので、例外時に「Did you mean?」とヒントを出してくれるDid you mean機能が入った影響だと思います。</p><br></dd>
<dt>Ruby 2.4: 17,856(kb)</dt>
<dd><p>少しですがフットプリントが減りました！<code>ruby --disable=did_you_mean</code> をつけてもフットプリントが減らなかったので、例外が発生するまでDid you mean機能を読み込まなくなったのだろうと思われます。</p><br></dd>
<dt>Ruby 2.5: 17,124(kb)</dt>
<dd><p>若干減っている気がするけど、大きな変化はなし。</p><br></dd>
<dt>Ruby 2.6: 21,728(kb)</dt>
<dd><p>またフットプリントが大きく増えました。このバージョンからJITが導入されたので、そのせいでしょう。<code>ruby --disable=jit</code> をつけてもフットプリントは変わらないので、JITを無効化してもJIT機能はエンジンに組み込まれたままなのでしょう。</p><br></dd>
<dt>Ruby 2.7: 21,512(kb)</dt>
<dd><p>特には変化なし。</p><br></dd>
<dt>Ruby 3.0: 21,916(kb)</dt>
<dd><p>特には変化なし。</p><br></dd>
<dt>Ruby 3.1: 22,420(kb)</dt>
<dd><p>少しだけ増えています。2つ目のJIT機能であるYJITが導入されたので、その影響でしょう。</p></dd>
</dl>


## 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 の紹介記事](https://qiita.com/kwatch/private/f3a2f042915fd7e0b27a)）。
* **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](http://supervisord.org/) というプロセスデーモン化ツールが起動するだけで23MBもメモリを消費したり、Ruby 製の [God](http://godrb.com/) が33MBも消費することに嫌気が差し、この手のツールをもし書き直すならどの言語がいいだろうかと検討したことがきっかけです。最終的にはシェルスクリプトを使って「[reraise]()」というツールを作ったので、よければ使ってみてください。メモリフットプリントは2MB未満で済んでおり[^3922]、また root 権限なしで利用できます。

* [reraise](https://github.com/kwatch/reraise/) ・・・ a service process supervisor tool, like runit, dameontools, Supervisor (python), or God (ruby).<br>
  <https://github.com/kwatch/reraise/>

[^3922]: メモリフットプリントに関してはC言語製のツールがいちばん優れており、daemontoolsやrunitはメモリフットプリントが1MB未満です。ただどちらもCLIでの操作がいまいちであり、ドキュメントもinitとして使う方法に多くを割いているのであまり分かりやすくはないと思っています。