2010年6月15日火曜日

JRubyのパフォーマンスの更なる向上を目指して

原文: チャールズ=オリバー=ナター

JVM上でJRubyが動く事の利点は折りに触れて述べてきました。JRubyのパフォーマンス数値はそこそこの結果を出しているのですが、多くの人々の期待に反して「抜群に素晴らしい」というものではありませんでした。詰まる所、他のRuby言語の実装に較べて良い結果を出したとしても、静的な型システムを用いる他のJVM言語には敵わないのでした。

しかし、それは今までの話し。

最近、JRubyの実行時に得られる情報に基づいた最適化をあれこれと試し始めました。ご存知の通り、JRubyは、Ruby言語の構造木をJVMのバイトコードにコンパイルするJITを搭載しています。その一方で、JITを使った他のシステムとは異なり、JRubyは最終的に良い結果をもたらすような情報をプログラムの作動時点で集めることはしなかったのです。今までにやっていた最適化と言えば、AOTコンパイラで安全に出来るような静的な最適化、そして最終的には、実行前にすべてをコンパイルすることによる起動コストを減らすための、遅延コンパイルJITモード(訳注:現在のデフォルトJITモード。以前はJITなしか、最初にすべての静的最適化を行うか、の2択だった)を追加することでした。

これは実用的な判断でした。JVMは沢山のことをやってくれるので、JRubyが実装しているような未熟なコンパイラと限られた静的な最適化でも、パフォーマンスの向上は見込めます。この為、JRubyのパフォーマンスは凡その用途には極めて満足の行くものでした。18ヶ月前のバージョン1.1.6以来、実行スピードの向上については野放しといったような具合でした。この間中はユーザからの要望を優先させていました。Javaとのより良い統合性、Rubyとの互換性の向上、メモリ消費(稀にリーク)の改善、jruby-rackやactiverecord-jdbc等周辺ライブラリの充実、そしてシステム一般の安定化などです。ユーザにパフォーマンスは?と訊くと、「あれば良い」という意見が一般的で、特に問題があるという意見は極々稀でした。JRubyのパフォーマンスは極めて満足の行くものだったと言えるでしょう。

とは言うものの、最近になって幾つかの避けられない真実に直面せざるを得なくなってきました。

  • 幾ら速くても速すぎる事はないし、スピードが上がっていかなければ相対的に遅くなっている印象を与えてしまう。
  • RubyのプログラムをCやJava並に速く動かしたいという要望は有るので、もし可能ならばそれを実現させるべき。
  • JRubyユーザは(当然の事ですが)Rubyでプログラムを書きたいので、出来るだけRubyで書けるようにするべき。その際、パフォーマンスで犠牲を払わずに済むようにするべき。
  • パフォーマンス向上にはとても時間が掛かるけれども、それが達成出来た時の悦びは一塩。

そう言った訳で、JRuby 1.6の俎上に上がっている項目に加えて、私はコンパイラをいじる事を決意しました。

取り掛かって間も無いので、その辺は了承して下さい。

JRubyのコールサイトについて

Rubyのコードで動的にメソッドが呼び出される場所それぞれに、JRubyはコールサイトと呼ばれるものを組み込みます。他のVMでは単にこの呼び出しが起こる場所の事をコールサイトと呼ぶ場合もありますが、JRubyではコールサイトはorg.jruby.runtime.CallSiteの実装を伴います。次のコードを例に挙げます。

def fib(a)
if a < 2
a
else
fib(a - 1) + fib(a - 2)
end
end

このコードには全部で六つのコールサイトがあります。a<2の中の<a-1a-2にある二つの-、二つのfibメソッドの呼び出し、そして最後にfib(a-1)+fib(a-2)に見られる+です。余りよく考えずに動的なメソッド呼び出しをしようとする方法としては、それぞれのオブジェクトに問題のメソッドが有るかどうかをその度に確認して、あればそれを実行するというのが考えられます。(これは最もてぬきなリフレクションコードがやるようなやり方です。)他の動的な言語のVMの例に外れる事なく、JRubyでもコールサイトのキャッシュを用いる事でメソッドの有無の確認に掛かる費用を軽減しています。実装の立場から言うと、キャッシュされるCallSiteは全てorg.jruby.runtime.callsite.CachingCallSiteのインスタンスであるという事です。簡単でしょう?

ランタイムで集める事の出来る情報を利用する最も簡単な方法は、このようにキャッシュされたコールサイトをヒントにして、それぞれのコールサイトでどのメソッドが呼び出されるのかを知ることです。上に挙げた+をの場合を見てみましょう。ここで、+メソッドはその都度Fixnumオブジェクトに対して呼び出され、ここではfibの値はBignumへと溢れ出るくらい大きくならないとして、Floatとも無関係という事にします。JRubyではFixnumオブジェクトはorg.jruby.RubyFixnumというクラスで実装されていて、+メソッドはRubyFixnum.op_plusとして実装されています。こんな具合です。

@JRubyMethod(name = "+")
public IRubyObject op_plus(ThreadContext context, IRubyObject other) {
if (other instanceof RubyFixnum) {
return addFixnum(context, (RubyFixnum)other);
}
return addOther(context, other);
}

addFixnumの詳細は宿題という事にします。

ここで@JRubyMethod(name = "+")というアノテーションがある事に注意して下さい。JRubyで実装されているRubyのコアクラスのメソッドには全てこのようなアノテーションが付随していて、引数の数、Ruby側から見たメソッドの名前(ここで見る+はJavaでは許されないメソッドの名前です)等、メソッド呼び出しの詳細を指定します。また、このメソッドは二つの引数を取る事に注目して下さい。ThreadContext(このメソッドを実行しているスレッド特有のランタイムの情報)オブジェクトと、今まさに足されようとしているother(引数)です。

JRubyをコンパイルする際、全てのソースファイルを読み込んでJRubyMethodのアノテーションを抽出し、コアクラスのメソッド一つ一つにインボーカを生成します。このインボーカはorg.jruby.internal.runtime.methods.DynamicMethodクラスのインスタンスで、メソッドについての詳細及び呼び出しの際のシグネチャ等の情報を含んでいます。このようにインボーカを生成する理由は簡単です。JVMの殆どは、多数のコードパスを持つコードはインライン展開しないので、たった一つのインボーカで全てのメソッドに対処しようとすると、インライン展開は全く行われない事になるのです。ここまでの話をまとめてみましょう。プログラムの実行の際、Rubyのコードにあるメソッド一つ一つにCachingCallSiteインスタンスがあり、例えば+メソッドが初めて呼び出された時これに対応するメソッドのオブジェクトを取ってきて、これ以降の呼び出しの為にキャッシュします。キャッシュされたメソッドはDynamicMethodのサブクラスで、最終的にどのようにRubyFixnum.op_plusメソッドを呼び出して、返り値をどう扱えばいいのかも知っているのです。至極簡単です!

初めの一歩

暫くプログラムを走らせていると、どのメソッドが呼び出されるのか大体見当がつきます。私の手元で試しているコードではこの点に漸く着目しています。そして、どのメソッドが呼び出されるのか事前に判っているのなら、前提が満たされる限り呼び出しは動的でなくて構いません。(ここで言う前提とは、例えば「このメソッドは常にFixnumについて呼び出される」とか「これのメソッドは"+"のコア実装である」といったものです。)つまり、JRubyはJVMバイトコードへのコンパイルを遅らせる事で、動的なメソッドの呼び出しを静的な呼び出しへと換える事が出来るかも知れない、と言う事です。

このプロセスは実はとても簡単です。私もつい二日前に始めたばかりですので、この過程を追って見てみましょう。

第一段階:動的呼び出しを静的呼び出しに置き換える

まず初めに必要なのは、生成されたメソッドに直接呼び出しを可能にするだけの情報を持たせることです。もっと詳しく言うと、どのJavaクラスをターゲットにしているのか(+の例で言うとターゲットはRubyFixnumです)、Javaメソッドとしての名前(op_plus)、返り値及び引数の数と型(IRubyObjectを返し、引数はThreadContextIRubyObject)、そしてメソッドがstatic宣言されているかどうか(モジュールメソッドの殆どはそうですが、op_plusはそうではありません)。これらの情報を一つに纏め上げてNativeCallという型に入れ込む事にし、これをこれを静的呼び出しが可能かもしれないすべてのインボーカに割り当てます。

public static class NativeCall {
private final Class nativeTarget;
private final String nativeName;
private final Class nativeReturn;
private final Class[] nativeSignature;
private final boolean statik;
...

コンパイラにも似通ったロジックを付けました。動的な呼び出しをコンパイルする際、まずコールサイトが何らかのメソッドを既にキャッシュしているかを調べます。もしキャッシュしているのならば、そのメソッドに相応のNativeCallが有るかどうかを確認します。有るのなら、いつもの様に動的にメソッドの呼び出しをするのではなく、JVMのごく普通のメソッドのようにコンパイルします。こうすると、今まで

INVOKEVIRTUAL org/jruby/runtime/CallSite.call

だったものが

INVOKEVIRTUAL org/jruby/RubyFixnum.op_plus

となり、"+"メソッドの呼び出しはRubyFixnumクラスを扱うJavaコードを書いているのと実質同じになります。

これで第一段階(コンパイラに、ここまでに見たような動的なメソッド呼び出しを認識させ、静的呼び出しにする)は終了です。この試験的な手法はコアクラスのメソッドの殆どに今すぐ使えますし、あらゆる呼び出しに使えるようにするのは難しくないでしょう。

鋭いVM実装者は既にお気付きかも知れませんが、後に実際は別のメソッドを呼び出す必要がある時のために備えるためのガードの挿入や事前のテストに、ここまでは言及していません。これは意図したものです。この話は後で触れます。

第二段階:Fixnumの使用を抑える

次に私がやったのは、ボックス化されたRubyFixnumではなくプリミティブ型の使用を許す事でした。JRubyではRubyFixnumは常に64ビットlongですが、メソッドの呼び出しの時にはIRubyObjectを使わなければならず、動的メソッドの呼び出しの際にはRubyFixnumオブジェクトを新たに作り出す必要が有ります。この時に作り出されるオブジェクトはとても小さく、直ぐに要らなくなるのでGCへの影響は問題では有りません。でもアロケートする時の速度には勿論影響があります。RubyFixnum一つ一つにメモリを確保しなければならず、その為にメモリ帯域幅を食い潰してしまいます。

このようなオブジェクトの割当を無くしたり軽減させる手段としては、スタックアロケーション、値型、即値、エスケープ解析等があります。このうち現存するJVMで使えるものはエスケープ解析だけで、しかも非常に扱い辛いです。と言うのも、あるオブジェクトを利用するすべてのパスがインライン展開されていなければならず、さもなくばそのオブジェクトを(エスケープ解析によってはにより)省略できません。

そこで、JVMへの負担を減らす為、ボックス化されたlongまたはdouble(Rubyで言うところのRubyFixnum及びRubyFloat)を一つだけ渡しているのが確実な場所に、プリミティブのlongまたはdoubleを受け取る呼び出し先を追加します。つまり、第二段階と言うのは、このようなメソッドをRubyFixnumについて幾つか実装する事でした。op_pluslongに対応させたのがこれです。

public IRubyObject op_plus(ThreadContext context, long other) {
return addFixnum(context, other);
}

ここまででは、或るオブジェクトが常にFixnumであるという事を証明するような手段は未実装なので、コンパイラには今の所100とか12.5といったようなリテラルを引数にしてメソッドの呼び出しをしているようにしか見えません。幸運にも、上のfibの例では丁度そのような場面が三つありました。一つは<、そしてもう二つは-です。「どのメソッドが場面場面で実際に使われているか」という知識と「RubyFixnumを介さずにメソッドをどう呼び出すか」とを合わせる事により、実際のバイトコードはこのように簡単になります。

INVOKEVIRTUAL org/jruby/RubyFixnum.op_minus (Lorg/jruby/runtime/ThreadContext;J)

(この表記に不慣れな読者の為に添え書きしておきますと、引数の最後のJは、この呼び出しが第二引数でプリミティブであるlongを要求している事を示しています。)

これでメソッド呼び出しを効率良く行う為の準備が出来ました。勿論、この例はとても単純ですが、RubyからJavaで書かれた恣意のコードの呼び出しに使える可能性を示しています。RubyからJavaのコードを呼び出す時、独自の複雑なマルチメソッド呼び出しロジック及び何層もあるJavaのリフレクションAPIを経る事なく、直接Javaのコードを呼ぶ事が出来るという事です。中間に有るコードがなければJVMはJavaのコードをRubyのコードの中にインライン展開する事が出来、全体を最適化するのです。ワクワクしてきたでしょう?

第三段階:極小最適化をちょこちょこと

さて、fibには後二つ厄介なメソッド呼び出しが残っています。fibの再帰呼び出しです。これを上手く片付けるやり方としては、既にjitコンパイルされているRubyコードから呼び出される、未だjitコンパイルされていないRubyコードが存在することをコンパイラに教えると言う事が考えられます。例えば、そうしたメソッドを強制的にコンパイルさせて、そのコンパイルされたメソッドから呼び出されるメソッドをもコンパイルさせて…という風にやっていって、ホットなメソッドから順々に或る程度の閾値まで最適化を行っていく訳です。「例え」とは言いましたが、これが多分実装になると思います。JRubyでRubyメソッドをjitコンパイルするのは容易い事なので、これをコンパイラに実装するには数分で充分です。でも、木曜日から考え続けて、もう一捻りやる事にしました。

コンパイラに「入れ知恵」する替わりに、非常に簡単な単一の最適化の方法を取り入れる事にしました。つまり自己再帰を検出し、その場合に限って最適化をする訳です。JRubyの場合で言うと、fibに相応するCachingCallSiteオブジェクトが、今まさにコンパイルしようとしている同じfibメソッドを呼び出していることを割り出すという事。このメソッドを動的メソッド呼び出しのパイプラインに流し込むのではなく、直接の再帰呼び出しに換えてしまう訳です。丁度、コアメソッドである<-+を換えた様に、です。バイトコードレベルではfibの呼び出しはこのようになります。

INVOKESTATIC ruby/jit/fib_ruby_B52DB2843EB6D56226F26C559F5510210E9E330D.__file__

ここで、B52DB28云々という所はfibメソッドのSHA1ハッシュ、そして__file__というのはjitコンパイルされたメソッドのデフォルトのエントリーポイントです。

ここだけの話ですが…

実験的なコードには付き物の話ですが、ここでは、互換性を多少犠牲にすればどれくらいのパフォーマンス向上が謀れるかを示しています。
ですから、ここで挙げた最適化の手段には幾つかの注意点が有ります。

まず、インボーカを素通りするのでRuby独自のバックトレース情報(現在のメソッド名、ファイル名、行番号など)をヒープで保持する事が無くなり、Rubyのバックトレースが滅茶苦茶になってしまう、という点。この点はJRubyが、JavaとRuby両方のバックトレース情報を保持する為にパフォーマンス劣化を余儀なくされる所です。JavaのバックトレースでRubyの関係のある情報を保持出来るようにするか、またはRubyのバックトレースをJavaのバックトレース情報から掘り出す(現存する--fastオプションでこれは可能です)ようにするにはまだまだ課題は山積しています。

次に、ここではbackreflastlineに当たるもの($~$_に相当するもの)を扱う為の情報を管理していません。つまり、正規表現や行単位の処理などでは機能の低下が有る事になります。(とは言うものの、これらの値を使うのは推奨されていないので、この事は余り苦にならないかも知れません。)スレッド固有のスタックを上手く使う(つまり、このような変数を扱うかも知れないメソッドに出くわした時にだけスタックに変数を押し込む)事で、この機能は取り戻せるでしょう。理想的には、或るコールサイトが$~$_を読み書きするかは判るので、実行時にプロファイリングする事で賢くこういった判断を行える筈です。

最後に、直接呼び出しするか、普通の動的なメソッド呼び出しをするかで枝分かれする所にガードを置いていません。これは実験を素早く進める為に執った実利的な決断です…と同時に興味深い問題を提示するものでも有ります。もしも、このメソッドが見得る全ての型とメソッドを見てしまったと解るのならどうでしょうか。「死ぬほど最適化して」と言い放つ事が出来れば、そして、実際JVMがそうしていると知ることが出来ればと思いませんか。個人的にはそのようなガード(理想的には、パフォーマンスに余り影響のない簡単な「メソッドID」の照合)無しにJRubyがリリースされるとは到底思えませんが、そうした「静的化」による最適化は、ユーザの意思でスイッチを入れることが出来るようになると思います。更に、もしもJRubyの実装のより多くをRubyで書かれたコードに移行する事になれば、こうした「完全な」最適化を利用するであろうと思われます。

さて、こうした注意点に留意しながら実測値を見てみる事にしましょう。

それでは、フィボナッチ数の計算のためにJRubyを利用されている皆様以外には無意味な数値をご覧にいれましょう。

fibメソッドはベンチマークにおいて恐ろしく乱用されています。ほとんどの呼び出しが少なくとも2つの再帰的呼び出しと、数値演算によるその他いくつかの呼び出しを生じさせるため、主にメソッド呼び出しのベンチマークになります。またJRubyでは処理時間の大半が、Fixnumオブジェクトの生成か、呼び出し毎の実行時データ構造の更新に取られるため、このベンチマークは、メモリ割り当て率またはメモリ帯域幅のベンチマークを兼ねることになります。

しかしベンチマークとしては結果が解り易いので、今一度これを乱用することにします。どうか、以下の数値は、互いの数値を比較するのでない限り無意味であることを肝に銘じておいてください。JRubyは未だに、即値や値型での実装よりも遙かに多くのオブジェクトを生成しており、その為、JVMはこのベンチマークの一部分を最適化し過ぎていると思われます。しかしもちろん、そこがポイントの一つです。我々はJVMを加速させ、潜在的に不必要な計算の排除を実現しようとしており、また実行時コンパイルの改良に実行時データを利用することで、それらが可能になってきています。

使うのは、2.66GHzのCore 2 Duoプロセッサを搭載したMacBook Pro。OS XとJava 6 (1.6.0_20) 64-bit Server VMで動作する、JRuby 1.6.dev (masterに私的なハックを入れたもの)です。

まず、フル機能のRubyバックトレースと、動的呼び出しでの、基本的なJRubyでのfib測定値です。

0.357000   0.000000   0.357000 (  0.290000)
0.176000 0.000000 0.176000 ( 0.176000)
0.174000 0.000000 0.174000 ( 0.174000)
0.175000 0.000000 0.175000 ( 0.175000)
0.174000 0.000000 0.174000 ( 0.174000)

次に、Javaバックトレースに基づくRubyバックトレースと、若干インライン化し易くした、しかしまだインボーカ経由での動的呼び出しと、限定的なプリミティブ型呼び出し最適化によるものです。本質的には、完全な互換性を損なわずに(しかし実際には多少損ないつつ)我々が得られるJRuby 1.5としては最速です。

0.383000   0.000000   0.383000 (  0.330000)
0.109000 0.000000 0.109000 ( 0.108000)
0.111000 0.000000 0.111000 ( 0.112000)
0.101000 0.000000 0.101000 ( 0.101000)
0.101000 0.000000 0.101000 ( 0.101000)

次に、ここまで述べたすべての実行時最適化を行ったものです。これらの数値は、適切なガードにより低下するだろうことを覚えておいてください(それでもまだ非常に素晴らしいですが):

0.443000   0.000000   0.443000 (  0.376000)
0.035000 0.000000 0.035000 ( 0.035000)
0.036000 0.000000 0.036000 ( 0.036000)
0.036000 0.000000 0.036000 ( 0.036000)
0.036000 0.000000 0.036000 ( 0.036000)

そして、主に再帰を用いた、非常に乱用されているもう一つのベンチマーク: tak関数の比較です。

静的な最適化のみで、可能な限りの高速化:

1.750000   0.000000   1.750000 (  1.695000)
0.779000 0.000000 0.779000 ( 0.779000)
0.764000 0.000000 0.764000 ( 0.764000)
0.775000 0.000000 0.775000 ( 0.775000)
0.763000 0.000000 0.763000 ( 0.763000)

そして実行時最適化:

0.899000   0.000000   0.899000 (  0.832000)
0.331000 0.000000 0.331000 ( 0.331000)
0.332000 0.000000 0.332000 ( 0.332000)
0.329000 0.000000 0.329000 ( 0.329000)
0.331000 0.000000 0.331000 ( 0.332000)

以上のように、これらの小さなベンチマークでは、JRubyコンパイラの2~3時間のハックにより、これまでに達成した最も速いJRuby性能値よりも2~3倍速い数値を達成しました。

頑張れJVM

まだ説明していないトリックがあります。JVMは今も、大部分の仕事をこなしています。

古き良き動的呼出しの事例と、静的に最適化された事例では、JVMは従順に、与えられたすべてのものを最適化します。それはRubyのメソッド呼び出しを受け取り、インボーカとその最終的なメソッド呼び出し群をインライン化し、コアクラスメソッドをインライン化し(そうです。これは、JRubyがいつでも、RubyからRuby、JavaからRuby、またはRubyからJavaへ、適切な調整を加えてインライン展開できることを表しています)、これらを非常によく最適化します。しかしここに問題があります。JVMのデフォルトの設定は、Rubyではなく「Java」を最適化するのに調整されています。Javaでは、あるメソッドから他のメソッドへの呼び出しは確実に1ホップです。JRubyの動的呼び出しでは、最終的に目標に到達するまでにCallSiteとDynamicMethodをあちこち移動するため、3、4ホップかかるかもしれません。Hotspot(訳注: SunのJVM)はデフォルトでは高々9レベルまでの呼び出ししかインライン展開しないので、JRubyはその割当量をあっという間に消費してしまうことがわかるでしょう。それに加え、JavaからJavaへのメソッド呼び出しは中間コードを持たないため、バイトコードサイズのしきい値(訳注: HotSpotがメソッドのインライン化を始めるバイトコードサイズ)を消費しません。我々は本質的に、Javaのようなよりシンプルな言語に比べて、Hotspotの最適化能力のほんの一部しか利用していないのです。

次に、動的に最適化される事例を考えてください。今後多少のガードコードを追加しなければならないとしても、既に多くのメソッド呼び出しからCallSiteとDynamicMethodの両者を取り除きました。それは、9レベルのRubyメソッド呼び出し、またはRubyからコアメソッドまたはJavaを利用する9レベルのロジックがインライン展開できることを意味します。Hotspotに対して、最適化のための遙かによい全体像を与え、また滅多に利用されないデータ構造の更新のような、Rubyメソッド呼び出しを行う際の背景雑音を取り除きました。今後バックトレースを有効な形で取り出せるようにする必要がありますが、少なくとも二重のトラッキングはしていません。

つまり、我々がしなければならない事といえば、インライン化のためのすべての困難な作業をJRubyのコンパイラにさせるのではなく、JVMにそれをさせ、現在のJVMの最適化コンパイラに蓄積された何年分もの仕事から利益を得ることだけです。難問を解決するための一番良い方法は、誰かにそれを解かせることであり、JVMはここに示した難問を解くために素晴らしい仕事をしてくれます。我々は、JVMに対して魚を与えたり魚釣りを教えるのではなく、湖の地図を与えています。JVMは既に一流の漁師だからです。

これらの最適化には別の側面があります。我々の継続的なインタプリタ整備が、上手く行き始めているということです。インタプリタモードを持たない他のJVM言語は、いかなる動的最適化をも行おうとすると遙かに苦労します。端的に言えば、そのバイトコードが最初にどのように生成されたかを明示的に取り出しておかない限り、既に読み込んでしまったバイトコードを置き換えるのは大変困難です。JRubyではメソッドは実行時にいつでもインタプリタモードとJITモードを切り替わらなければならないため、最適化の過程ではるかに自由に行ったり来たりできます。

今はここまでです。数カ月のうちにJRuby 1.6をダウンロードしたらすべてが3倍(もしくは10倍!さらに100倍)高速化されている、ということは期待しないでください。いつこれらの最適化が安全に行われ得るか、必要であればユーザがどのようにすべての最適化を選択できるか、またコアコード(訳注: JRubyにおいてJavaで実装されたRubyのコード)をRubyに置き換え始めるのに十分な高速化ができるかどうかについて、まだまだやらなければいけないことの細かい部分が沢山あります。しかし、初期の結果は非常に有望であり、一部をすぐにリリースするだろうことは確実です。

謝辞

今回の記事ではnahiさんにご協力を頂きました。文責は訳者にあります。


0 件のコメント:

コメントを投稿