msumimz's diary

RubyにJITコンパイラを実装する個人プロジェクトの情報発信ブログです。

最初のベンチマーク

これまでにif、while、メソッド呼び出し、ローカル変数のコンパイル処理を実装してきました。すでに簡単なプログラムが動くくらいになっていますので、この辺りでJITコンパイルされたコードの速度を確認してみたいと思います。

コードは以下の通りで、1から10000までの合計を計算する処理を100回繰り返すというものです。

# minirubyは$LOAD_PATHが空なので設定する
$LOAD_PATH << File.expand_path("../../lib", File.dirname(__FILE__))
require "benchmark"

# ベンチマーク対象のメソッド
def m1
  i = 1
  sum = 0
  while i <= 10000
    sum += i
    i += 1
  end
  sum
end

# JITコンパイル
precompile Object, :m1

# ベンチマークを取る
Benchmark.bm do |bm|
  bm.report("interpreted") { 100.times { m1_orig } }
  bm.report("precompiled") { 100.times { m1 } }
end

precomipleでメソッドをコンパイルすると、元のメソッドのエイリアスが_origをというサフィックスで作成されますので、それとJITコンパイルされたメソッドの実行時間を比較しています。

なおマシンはIntel Core i7-2600 3.40Ghz、メモリ8GB、OSはWindows 7 SP1です。電源プランの設定で「高パフォーマンス」を選択しています(CPU性能を100%にする効果を期待)。結果は以下の通りです。

[~/work/rbjit/ruby/rbjit]$vc10/Release/miniruby examples/perf_while.rb
               user     system      total        real
interpreted  0.063000   0.000000   0.063000 (  0.059003)
precompiled  0.124000   0.000000   0.124000 (  0.128008)

はい。JITコンパイルしたほうが遅いです。これは予想通りです。

これには以下の理由が考えられます。

  • 整数演算中心のプログラムの場合、演算自体にほとんど時間がかからないため、実行時間の大半をメソッドの検索及び呼び出し処理が占める。
  • MRIは、メソッド呼び出しごとに用意されたキャッシュ(いわゆるインライン・メソッド・キャッシュ)を実装しているが、JITコンパイラはグローバルキャッシュしか使っていない。
  • MRIは、Fixnum#+のための専用のインストラクションを用意しており、Fixnum#+に関する検索及び呼び出し処理がとても速い。一方JITコンパイラは、Fixnum#+も通常のメソッドとして呼び出している。

素朴なJITコンパイルでは、YARVのインストラクションを辿る時間は節約できますが、その効果は実はたいしたことはありません。それだけでは、メソッド呼び出しを最適化したインタープリタに負けてしまうわけです。JITコンパイラが性能で上回るには、こちらもJITコンパイラならではの最適化をかけなくてはいけません。

というわけで、今後は最適化を順次実装していくことにします。

whileを実装しました

こんなコードが動きます。

def m1
  i = 1
  sum = 0
  while i <= 10000
    sum += i
    i += 1
  end
  sum
end

precompile Object, :m1

puts m1 # => 50005000

たかがwhileにどれだけ時間かかってるんだという感じですが、機能追加に合わせて周辺コードを追加・変更しているためです。今回はSSA変換処理を修正しています。

これで、ループが書けるようになりましたので、まともなベンチマークを動かせます。次回はベンチマークを取ってJITの性能を確かめてみたいと思います。

メソッド呼び出しを実装しました

対応したのはMRIの内部でNODE_CALLと呼ばれる、レシーバを指定した形式です。そのうち、引数が単純でブロックを取らないもの限定です。足し算なども内部ではメソッド呼び出しですので、例えば以下のコードが動きます。

def m
  1 + 2
end

precompile Object, :m

puts m # => 3

後はwhileを実装すればフィボナッチ数の計算くらいはコンパイルできるようになりますので、そうしたら実行速度を測ってみたいと思います。

ローカル変数参照を実装しました

ローカル変数参照を実装しました。以下のコードが実行できます。

def m
  if true
    a = 10
  else
    a = 20
  end
  a
end

precompile Object, :m
puts m # => 10

LLVMは優秀なので、上のメソッドはこんなコードにコンパイルされます(x86の場合)。

mov eax, 15h
ret

15hは10のFixnum表記です。if文が消えて、単に定数を返すだけのメソッドになっているのがわかります。

https://github.com/msumimz/ruby/tree/rbjit

if文を実装しました

ついでにtrue/false/nilリテラルも実装しています。
以下のコードが動くようになりました。

def m
  if 1
    10
  else
    20
  end
end

precompiled Object, :m

puts m # => 10

実装した構文はifだけですが、内部でコントロールフローグラフをSSA形式というものに変換する処理を実装しています。他の制御構造を実装するために必要となるコードです。

https://github.com/msumimz/ruby/tree/rbjit

最初のJITコンパイラを実装しました

JITコンパイラの最初のバージョンが動くようになりました。

このバージョンのminirubyには、Object#precompileというメソッドが追加されています。これは

precompile <クラス>, <メソッド名(シンボル)>

という書式で呼び出すことで、<メソッド名>で指定されたメソッドの中身をコンパイルし、既存のメソッド定義を上書きします。既存のメソッド定義は<メソッド名>_origというエイリアスで残されます。

といっても今のところFixnumリテラルにしか対応していませんので、メソッドに書けるのはFixnumの範囲に収まる整数だけです。ひどい。

例えば

def m
  1
end

precompile Object, :m

puts m # => 1

この例を見ても何がなんだかわかりませんが、デバッガでステップ実行すると、メソッドmがネイティブコードとして実行されていることがわかります。生成されたコードは以下のようなものでした。

mov eax, 3
ret

そのまんまですね。

いずれはJITコンパイルすべきメソッドをヒューリスティックに判定して自動コンパイルする機能をつけることになりますが、それをするにはコンパイルできる文法範囲が狭すぎますので、しばらくは上記の方法で指定することになります。

githubのURLは以下の通りです。10ファイル以上を一度にコミットしてます。
https://www.github/msumimz/ruby/tree/rbjit

ビルド方法は後で書きます。ビルドしても何もできませんが。

clangのアセンブリをIntel形式で出力する

clangで-Sオプションをつけてアセンブリを出力する場合、既定でAT&T形式になります。これをIntel形式にするには以下のオプションを使えばよいようです。

clang -S -o - -mllvm -x86-asm-syntax=intel test.c

'-mllvm'というのは、clangのインフラであるLLVMライブラリでサポートされているオプションを適用するための記法です。clangにはx86アセンブラ構文を指定するオプションはありませんが、LLVMにはあるので、この記法で指定するわけです。ライブラリであるはずのLLVMコマンドラインオプションが定義されているのはよくわかりませんが、そういうものなので仕方ありません。

ちなみに、このオプションをつけてもディレクティブはgas形式のままなので、MASM/NASMでは使えません。