require高速化:基準となるベンチマーク

先日の記事でrequireの高速化をしたいと書いてから、ずいぶん時間が空いてしまいました。遅々とした進み具合で申し訳ないのですが、まずは高速化の基準となるベンチマークを取ってみました。基準点がなければ、具体的にどのくらい高速化したかわからないためです。

ベンチマークを行った環境

まずベンチマークを行った環境なのですが、「Windowsで遅い」と言われる問題の検証ですので、比較対象としてMacでも同じベンチマークを行うことにしました。マシンはいわゆるLate 2008 MacBook、スペックは以下の通りです。

CPU 2.4GHz Intel Core 2 Duo
メモリ 4GB 1067MHz DDR3
ハードディスク TOSHIBA MK3253GSX 320GB 5400RPM 8MB Buffer

また、WindowsのシステムはBootCampで起動したWindowsXP Home SP3、ファイルシステムはNTFSです。MacSnow Leopardファイルシステムは HFS+です。全く同じマシンで動作させていますので、システムの違いだけが出てくるはずです。

rubyのバージョンについては、基本的にはリリース済みの最新バージョンである1.8.7p249と1.9.1p378を用いています。Windowsではコンパイルの仕方がいろいろあるのですが、最もよく使われているmswin32(VC6でコンパイルされたもの)を用いています。ただしMacでは、MacPortsでインストールした1.8.7p249がうまく動作しなかったため、Snow Leopardに標準でインストールされている1.8.7p173を使用しました。

注意すべき点として、WindowsMacも、「ディスクキャッシュ」という仕組みがあります。これは一度ハードディスクから読み込んだファイルをメモリ上にキャッシュしておき、もう一度読み込んだときにはハードディスクからではなくメモリから読み出すものです。ディスクキャッシュがあるため、rubyスクリプトの起動は、1回目よりも2回目以降の方が断然早くなります。普通rubyで開発を行うときは、requireされるスクリプトのファイルはほぼディスクキャッシュに載った状態になっていると思われますので、今回はベンチマークを何度か実行し、ディスクキャッシュが有効になった状態で計測を行いました。

各ライブラリはどのくらいの数のファイルをrequireしているのか?

まず手始めに、時間を計るのではなくて、requireを行ったときにrubyのライブラリがどのくらいの数のファイルにアクセスするのかを調べました。これを調べておくことで、後からファイルアクセスに関して調査を行う際の基準になります。
これは、rubyのバージョンが同じならWindowsでもMacでも違いはないはずですので、Windowsでのみ調査しています。

調査に使用したスクリプトは以下のようなものです。requireがfalseを返した場合は、そのファイルを既にロードしているということなので数えていません。

alias :require_original :require

$require_success_files = []

def require(feature)
    ret = require_original(feature)
    if ret
        $require_success_files << feature
    end
    ret
end

require 'rexml/document'  # この行を調べたいライブラリに変更

p $require_success_files.size

ただし、rubygemsでインストールしたライブラリの場合は、rubygemsがrequireを書き換えますので、一番最初に先にrequire 'rubygems'を行っておく必要があります。

結果は以下のようになりました。(gem)と書かれているのは、rubygemsでインストールしたライブラリです。

ライブラリ名 1.8.7で読み込んだファイル数 1.9.1で読み込んだファイル数
rexml/document 35 35
rubygems 35 0
sqlite3-ruby(gem) 11 12
oauth(gem) 60 59
twitter(gem) 125 135

1.8.7と1.9.1では、rubygems以外はそれほど違いは出ていません。1.9.1でrubygemsが0になったのは、システムで組み込みになったためです。twitter gemの起動が重いというのは前から感じていたのですが、実際に100以上のファイルへのアクセスをしていたというのは驚きました。

各ライブラリをrequireするのにどのくらい時間がかかるか?

それでは次に、本題であるrequireの時間を計ってみます。

モジュール名 Windows 1.8.7 Mac 1.8.7 Windows 1.9.1 Mac 1.9.1
rexml/document 0.140625 0.029783 0.281250 0.058118
rubygems 0.171875 0.092663 0.000000 0.000113
sqlite3-ruby(gem) 0.031250 0.034831 0.187500 0.031970
oauth(gem) 0.187500 0.060517 0.406250 0.088203
twitter(gem) 0.546875 0.158961 0.921875 0.244374

1.8.7同士で比べると、ライブラリによってばらつきがあるのですが、確かにWindowsでは、Macの4倍以上の時間がかかることがあるようです。1.9.1では全体的に1.8.7より時間がかかるのですが、これはVM中間言語へのコンパイルなど、プログラムを実行する際の処理が複雑になったためで、全体的な速度向上とのトレードオフです。

ただ、さらによく見ると、1.8.7と1.9.1を比較したときに、Windowsの方がより遅くなる傾向があることがわかります。Macの場合は1〜2倍というところですが、Windowsの場合は6倍以上遅くなっているものもあります。これがなぜなのかも調べる必要がありそうです。

requireの個別のプロセスを計測する

requireがWindowsで遅いのはわかったのですが、requireはいろいろなプロセスを含んでいますので、Windowsでなぜ遅いのか調べるには、その個別のプロセスについて計測を行う必要があります。

requireのプロセスは、大まかに言って以下のとおりです。

  1. $LOAD_PATHに入っているパスのリストの中に、指定されたライブラリ名に.rbか.soを付けたファイルがないか調べる
  2. ファイルを見つけたらそれを読み込む
  3. 読み込んだプログラムを解析し、ロードする

遅い原因はこの中にあるはずです。さらに問題を分割すると、以下のような可能性があります。

  • ファイルが存在するかどうかのチェックが遅い。これはファイルを探す処理のスピードに効いてくる。
  • ファイルをオープンし、読み込むのが遅い。ディスクキャッシュが効いているはずだが、うまく効かないことがあるかもしれない。
  • プログラムの解析・ロードが重い。ただ、これはOSとほぼ関係がない、純粋なプログラム処理なので、これが遅いというのは、普通にプログラムの実行が遅いということになる。コンパイラの違いの可能性もある。

この推測を元に、requireに関するベンチマークプログラムを作成しました。プログラムはgithubで公開しています。

http://github.com/ashel/ruby-require-benchmark

使い方は、git cloneした後に

% ruby create_testdata.rb
% ruby run_benchmarks.rb

とします。今のところ11個のベンチマークがあり、それぞれbenchmark_*というファイルに記述されています。run_benchmarks.rbはそれらをまとめて実行するためのランチャです。なお、run_benchmarks.rbに引数を渡すと、その引数のコマンドでベンチマークを起動します。例えばrubyの1.9系をruby1.9というコマンド名で実行したい場合は、

% ruby1.9 create_testdata.rb
% ruby1.9 run_benchmarks.rb ruby1.9

とすればOKです。

今回作成したベンチマークは以下の通りです。

require_empty
空のファイル(実際にはmagic commentのみ含まれます)を100個requireします。空のファイル1つをrequireするのにかかる時間を求めます。
require_class
require_emptyと似ていますが、空のファイルではなく、よくあるクラスの定義1つ(数十程度のメソッド、定数を含むクラス)を含んだファイル100個をrequireします。equire_emptyと比較すると、ファイルの読み込みやプログラムのロードにかかる時間を推測できます。
require_class_onefile
require_classでrequireしたのと同じ構成のクラス100個が全て含まれるファイル1つをrequireします。require_classと比較することにより、多くのファイルを扱うのがどのくらい処理負荷になるのかを推測できます。
read_empty
require_emptyと似ていますが、requireするのではなく単に文字列としてreadします。requireのうちの「ファイルから読み込む」部分のみのコストを計測するためのベンチマークです。
read_class
require_classと似ていますが、requireするのではなく単に文字列としてreadします。
read_class_onefile
require_class_onefileと似ていますが、requireするのではなく単に文字列としてreadします。
read_class_onefile_binmode
read_class_onefileとほぼ同じですが、文字列で読み込む際にバイナリモードで読み込みます。Macでは違いはありませんが、Windowsでは文字列としての処理をするか否かで違いが出ます。
read_class_onefile_windows31j
read_class_onefileとほぼ同じですが、文字列で読み込む際のエンコーディングWindows-31Jを指定します。これはruby1.9のみのテストです。Macでの文字列のエンコーディング処理をWindowsと揃えるために作りました。
file_exist
ファイルが存在するかどうかを調べるメソッド(File.exist?)の速度を調べるためのベンチマークです。100個の別々のファイルに対するFile.exist?を20セット繰り返します。
compile_class_onefile
これはruby1.9のみのテストです。require_class_onefileで使ったのと同じファイルを使用して、RubyVM::InstructionSequence.compileでのコンパイルのみを計測します。これはほぼOSの機能とは関係ないプログラム処理なので、これに違いが出るということは、コンパイラの違い等の理由から「単純にプログラムの実行速度が遅い」ということになります。
file_exist_load_path
$LOAD_PATH(requireする際に検索すべきパスが入ったグローバル変数)からファイルを検索するところまでを100回行うベンチマークです。$LOAD_PATHは環境によって異なりますので、プラットフォーム毎の比較は行えません。参考として行いました。

各プラットフォームでの結果は以下のようになりました。

テスト内容 Win 1.8.7 Mac 1.8.7 Win 1.9.1 Mac 1.9.1
require_empty 0.156250 0.015123 0.234375 0.041115
require_class 0.218750 0.049868 0.406250 0.095429
require_class_onefile 0.062500 0.033033 0.171875 0.054280
read_empty 0.015625 0.002333 0.015625 0.003199
read_class 0.015625 0.002755 0.109375 0.003580
read_class_onefile 0.000000 0.000480 0.093750 0.000375
read_class_onefile_binmode 0.000000 0.000468 0.000000 0.000364
read_class_onefile_windows31j 0.093750 0.001068
file_exist 0.093750 0.012620 0.062500 0.014248
compile_class_onefile 0.062500 0.045660
file_exist_load_path 0.031250 0.005939 0.078125 0.019137

各プラットフォームの違いで目に付くのは、まず1.8.7で比較すると、WindowsMacでは、空のファイル一つをrequireするためにかかる時間に10倍の差があるということです。一方、require_class_onefileのような、ファイルアクセスがほぼ関係しない処理ではそこまでの差はありません。(それでも2倍近く差が付いていますが…。)require_classのように実際のrequireに近い処理では、これらが混ざった結果、WindowsMacの4倍時間がかかるという結果に至っています。これは、上で行った実際のライブラリをrequireしたときの差とほとんど同程度です。

それではなぜ、require_emptyでそこまでの差が出てしまうのでしょうか?read_emptyの結果を見ると、WindowsMacでは差があるのですが、かかる時間が相対的に少ないことがわかります。よって、ファイル1個の読み出しにそれほど時間がかかるわけではありません。file_existやfile_exist_load_pathの結果を見ると、むしろ時間がかかっているのは、requireすべきファイルを探す処理であることがわかります。

さらに1.9.1に目を移すと、不自然な点が見つかります。Windowsの1.8.7では、read_emptyとread_classにほとんど差がないのですが、1.9.1では明らかにread_classが遅いです。さらに、read_class_onefileでも同じくらいの時間がかかってしまいます。つまりWindowsの1.9.1では、ファイルの内容に比例して時間がかかる現象が起こっていることになります。Macではこのような違いはありません。

このような現象が起こるのは一体なぜなのでしょうか?理由は、read_class_onefile_binmodeを見るとすぐに分かります。バイナリモードでは、Windowsの1.8.7と1.9.1は違いがないのです。バイナリモードとテキストモードの違いは、文字列のエンコード関係の処理を行うか否かですので、これにより、Windowsではファイル読み込み時のエンコード処理に時間がかかることがわかります。

なお、この処理コストは、Windowsでファイルを読み込むときに標準で行われる、Windows-31Jへのエンコードが原因ではないかと思い、read_class_onefile_windows31jでMacでファイルをWindows-31Jとして読み込むベンチマークを行ってみたのですが、特に遅くありませんでした。Windowsでは、はやり何か特殊な事情があるようです。

なお、compile_class_onefileを見ると、単なるプログラム処理でもMacの方が1.37倍速いことがわかります。VC6はかなり昔のコンパイラですので、最新のgccと比較するとやはり歩が悪いようです。

まとめ

以上の計測から以下のようなことがわかります。

  • 1.8.7では、Windowsのrequireは、Macと比較して2〜4倍時間がかかる。
  • Windowsが遅い原因は、ファイルが存在するかどうか判定する処理(File.exist?)とファイルを一つ読み混むための処理(おそらくopen,read,closeの一連の処理)に時間がかかることに起因する。
  • さらにWindowsの1.9.1では、ファイルを読み込む際のエンコード処理にも時間がかかる。
  • 1.9でプログラムをVMの命令列へコンパイルする処理のみを比較しても、WindowsMacの1.4倍時間がかかる。これは主にコンパイラの違いに起因すると思われる。

今後のこと

今回わかったことを見ると、「RubyVMの機能を利用してプリコンパイルしたソースをrequireする」という手法はrequireが遅い原因をある程度解消できそうです。ただし、「requireすべきファイルを探すのに時間がかかる」ということを考えると、単にプリコンパイルするだけではなくて、requireしたファイルからさらにrequireしているファイルをまとめて一つのファイルにする処理も行った方が良いように思います。

完成まではまだ遠そうですが、少しずつやっていきたいと思います。