YARV Maniacs 【第 13 回】 事前コンパイルへの道

書いた人:ささだ

はじめに

お久しぶりです。もう前回から 2 年弱たってますね…。気を取り直して、今回は RubyKaigi 2015 で発表した Compiling Ruby scripts という発表の話をしようと思います。 動画が公開されているようですので、もしよかったらどうぞ(リンク先から見ることができます)。

この発表は、Ruby の命令列へのコンパイラを作ったよ、という話でした。 といっても、この連載では、ずっと YARV という仮想マシンのコンパイル後の命令コードがどうしたこうした、 という話をしていたので、すでにあるじゃないか、という気がしますが、今回の話は、事前にコンパイル(プリコンパイル)しておく、 というものです。

このハックのモチベーションは、(1) 起動時間を短くする (2) 利用メモリを削減する、の二つでしたが、結論を先に述べてしまうと、まだあまりうまくいっていません。 今後、もう少し頑張らないとなぁ、というところです。こうご期待。

コンパイルとは

「コンパイル」という用語を再確認しておくと、計算機の分野では、(一般的には)プログラミング言語で記述されたプログラムを解釈し、 機械語など、より低レベルな表現へ変換する処理をいいます。 たとえば、C 言語で書かれたプログラムを、x86 CPU で動作するように、gcc でコンパイルする、といった感じで使います。 使いますよね? 使わないのかな、もしかして。まぁ、使います。 素直に機械語へ変換、と書かないのは、別に対象は機械語でなくてもよくて、 実際に C コンパイラでは、機械語(バイナリ表現)を直接出力するのではなく、 アセンブリ言語で書かれたプログラムを出力し、それから先に機械語を出力するためにはアセンブラ言語処理系によって変換する、といったことが行われています (アセンブラ言語で書かれたプログラムを機械語に変換する作業も、コンパイルと言いそうな気もしますが、あまりそう表現しているのは聞きません。 アセンブラ言語で書かれたプログラムと機械語は、とても素直に変換できるため、コンパイルという言葉は、複雑な変換であることを含意しているのかもしれません)。

話がそれましたが、コンパイルというのは、あるプログラムを別の表現に変換する、 という話で、たとえば、CoffeeScript を JavaScript へコンパイルする、なんて言いますね(あまりいわないのかな)。 ちなみに、コンパイル先は低レベルな表現と書きましたが、別にそういうわけでもなく、C から Java へのコンパイラ、 みたいな話もあります。

Ruby では、Ruby で書かれたプログラムを、YARV 仮想マシンの命令列へ変換する作業が、プログラムを読み込んで実行するたびに、毎回行われます。 実行時に行われるため、これを実行時コンパイル(Just in Time = JIT コンパイル)といったりします。 が、いわゆる JIT コンパイルというと、機械語まで変換する、という意味で使うことが多い気がします (たぶん、Java 仮想マシンの高速化技術、という文脈で、広く広がったんじゃないでしょうか)。

で、今回の話は、実行時コンパイルではなく、事前コンパイルです。 「実行時にコンパイルするのではなく、事前にコンパイルしておけば、 その分省力化が図れるんじゃない?」という発想で語られることが多いと思います (その他の文脈としては、ソースコードを読まれたくないので難読化とか)。 そして、元のプログラムは Ruby で記述されたプログラム、 コンパイル先は YARV 命令列です。

事前コンパイルの現状と課題

さて、Ruby で命令列を事前にコンパイルする、というと、 すでに、RubyVM::InstructionSequence オブジェクトを取得して (取得の方法には、いろいろあります。この話、どっかでまとめてたっけ?) RubyVM::InstructionSequence#to_a メソッドで取り出す、 という方法が 2006 年(もう 10 年前!)くらいからあるんですが、ロード時間が遅い、という問題がありました。

ロードの速さの問題

というのも、to_a で取り出した配列には Ruby のオブジェクト表現で入っており、 これを外部ファイルに書き出して保存するためには、Marshal.dump を使って、バイナリ表現へ変換し、ファイルへ書き出します。

 # 書き出し時
 iseq = RubyVM::InstructionSequence.compile('puts :Hello')
 open('compiled_code', 'wb'){|f| Marshal.dump(iseq.to_a, f)} # 'compiled_code' というファイルに書き出し

読み込む際には、逆にファイルから読み込んだデータを Marshal.load でオブジェクト表現に復帰させ、 それを命令列の表現として解釈し、 実行可能な命令列(RubyVM::InstructionSequence オブジェクト)を構築する必要があります。

 # 読み込み時
 data = Marshal.load(open('compiled_code', 'rb'))
 iseq = RubyVM::InstructionSequence.load(data) # ただし、デフォルトでは無効
 iseq.eval                                     # 読み込んだ命令列オブジェクトを実行

まぁ、ロード時間は、見るからに遅そうですが、もう少し細かく分析してみます。

まず、普通に実行時にスクリプトを読み込んで、命令列へコンパイルするのは、こんな感じになります。

  • ファイルから読み込む
  • スクリプトをパース・コンパイルし、命令列へ変換する

そして、to_a した結果に Marshal.dump を行い、その結果をロードするものは、こんな感じになります。

  • ファイルから読み込む
  • Marshal.load でバイナリデータを Ruby のオブジェクト(配列など)に変換する
  • RubyVM::InstructionSequence.load で、Ruby のオブジェクト(配列など)から命令列オブジェクトへ変換する

比べてみましょう。 ファイルから読み込むのは、まぁ時間は同じようなものだと考えます。 そうすると、パース・コンパイル時間と、ロードと命令列変換の時間が問題になります。さて、どっちが速いんでしょうか。

… なんとこの連載ですでに試していますね。YARV Maniacs 【第 8 回】 命令列のシリアライズ によると(若干、ここで紹介している内容と異なりますが)、

 ちなみに、これだけのオーバヘッドがあるのでロード時間短縮のために
 このコンパイラで先にコンパイルしておく、というのは意味がありませ
 ん (測りました)。というか、遅くなります。unpack のコスト、および
 Marshal.load のコストが大部分で、これに時間がかかります。後者の
 コストはシンボルを多用している現在の表現によります。

だそうです(いやしかし、懐かしい)。

まぁ、速く無さそうですよね。そのためのものではないし。というわけで、「起動速度を短縮することを目的とした事前コンパイルは、現状だと無理」ということがわかりました。

メモリの問題

メモリ使用量で考えてみると、通常のスクリプトから読み込むものと、何かしら事前コンパイルしたものを読み込むのでは、とくに違いは無さそうです。 最終的には命令列がメモリ上に残り、その他は(多分)GCされる、というところまで一緒じゃないかと思います。

さて、ではなぜメモリ消費量をトピックにあげているかというと、複数プロセスでの共有を考えています。

なんとなくわかると思うのですが、あるスクリプトから生成される命令列というのは、だいたい同じです (まったく同じというわけではないところが面倒くさいのですが)。 それらを複数プロセスでうまいこと共有できれば、複数プロセス全体で利用するメモリが減る、かも、 というのが、メモリの問題、というか、メモリ消費量削減のためのアイデアになります。

実際、簡単な Rails アプリで見てみると、命令列が 20MB くらい(全体の 15% くらい)、メモリを食っているそうです。 簡単なアプリでこういう状況なので、例えば沢山ライブラリを読み込むような複雑なアプリだと、 それなりに効くのかな、などと期待したくなります。

命令列のバイナリ列への変換

さて、そういう問題意識の上で、どんなものを作ったか、ということをご紹介します。

バイナリフォーマット

Java 仮想マシンでいうところの classfile みたいなものです。

言葉で説明するのが面倒くさいので、てきとーに記述しておきますが、今のところ、このバイナリフォーマットは、次の部分からなります。

  • ヘッダ(ファイルサイズとか)部
  • 命令列部
  • ID 部
  • オブジェクト部

ヘッダ部には、『どの情報がどこに入っている』、とかいった情報が書いてあります。 命令列部には、どんな命令列が入っているか、という情報が書いてあります。 ID 部には、命令列で利用する ID (識別子、例えば変数名とかメソッド名)についての情報が入っています。 オブジェクト部には、ID 部と同じように、同じく命令列で利用するオブジェクト(例えば Bignum とか、正規表現とか、文字列とか)についての情報が入っています。

命令列が ID やオブジェクトを必要とするとき、何番目の ID とか、何番目のオブジェクト、といった、インデックスでの指定になっています。 ヘッダ部に、『何番目の ID は、バイナリのどこからどこまで』、という情報が入っていますので、その情報から ID やオブジェクトを復元し、 それを利用可能にしておきます。ID やオブジェクトは、Marshal.dump したときと同じようなフォーマットで格納してあります。 同じような、と書いたのでわかると思いますが、同じではありません。 一度、Marshal.dump して得られたバイナリをそのまま突っ込んでみたのですが、 いろいろと余計なものが多くオーバースペックだったため、今回作り直してみました。

まぁ、誰かがやろう、と思った時に、やられるであろうデータ構造です。 いや、もっとうまく作れると思いますが、結構手抜きをしてあります。

ちなみに、この表現はポータブルなものではなく、コンパイルしたマシン(というか、コンパイルした Ruby インタプリタ) に強く依存するフォーマットになっています。つまり、例えば 32bit CPU 上の Ruby インタプリタで生成した命令列(バイナリ表現)は、 64bit CPU 上で動作する Ruby インタプリタではロードできない、つまり動かすことができません。 この辺が、Java 仮想マシンの classfile とは違うところです。

ちなみにちなみに、現在のバイナリ表現は、恐ろしく空間効率が悪く、例えば小さな整数にもワード長、つまり 64bit CPU だったら 8 バイトの容量を食ってしまいます。 なので、バイナリのサイズは、一般的には大きいです。

バイナリを生成・ロードするインターフェース

このバイナリは、どうやって作ることができて、どうやって読み込むのでしょうか。

読み込む元となるリポジトリは、どのようなものであるか考えてみると、 いろいろな選択肢があることがわかります。

例えば、1バイナリを1ファイルにしても、そのファイルをどこに置くか、という問題があります。 何らかの DB に突っ込んでも良いかもしれませんし、どこからともなくネットワーク上から取ってくる、といったことも可能です。

「Ruby 標準でこれを使って下さい」というような方法を用意しようとすると、関係各所などにネゴシエーションしなければならず、 なかなか決まらないので日の目も出ない、みたいな話になるかもしれません。

ということで、今回は

  • バイナリ表現を Ruby の文字列で出力する方法を提供する
  • その表現を読み込み命令列オブジェクトへ変換する方法を提供する
  • require 時にフックをかけられるようにする(任意の命令列オブジェクトに差し替えることができる)

というインターフェースをくっつける、ということにしました。 具体的には、下記の API を Ruby 2.3 につけておきました。

  • RubyVM::InstructionSequence#to_binary #=> String
  • RubyVM::InstructionSequnece.load_from_binary(binary) #=> ISeq
  • RubyVM::InstructionSequnece.load_iseq(fname)

前二つは自明でしょう。命令列オブジェクトをバイナリ表現へ変換するメソッドと、 バイナリ表現から命令列オブジェクトを復元するメソッドです。

RubyVM::InstructionSequnece.load_iseq(fname) だけ、(多分)面白いので、ちょっと説明します。

ふつう、require(fname) を実行したりすると何が起こるかというと、こんなことが起きます。

  • ファイル名 fname から、具体的なファイルの絶対パスを探す($LOAD_PATH にあるディレクトリから、適切なファイルを探す)
  • そのファイルを読み、パースし、コンパイルする

この手順を、次のようにするようにしました。Ruby っぽいコードのほうが良いかな。

 # ファイル名 fname から、具体的なファイルの絶対パスを探す($LOAD_PATH にあるディレクトリから、適切なファイルを探す)
 file_path = find_path(fname)
 unless defined?(RubyVM::InstructionSequnece.load_iseq) && iseq = RubyVM::InstructionSequnece.load_iseq(file_path)
   iseq = ReadParseCompile(file_path)
 end

読めばわかりますが、このメソッドが定義されていれば、呼び出して、その返値が nil でなければ、 それを命令列で利用する、ということですね。

つまり、自分でローダーが書けるわけです。ファイル名をキーにして、そのファイル名に相当するもの (ファイルだけにかぎらず、ファイル以外の変換された結果)を、読んで命令列を作れば良い、ということです。

例えばこんなプログラムを仕込んでおくと、テンション高いプログラムになります。

require 'fiddle'
require 'pp'

class RubyVM::InstructionSequence
  address = Fiddle::Handle::DEFAULT['rb_iseq_load']
  func = Fiddle::Function.new(address, [Fiddle::TYPE_VOIDP] * 3, Fiddle::TYPE_VOIDP)
  define_singleton_method(:load_from_array) do |data, parent = nil, opt = nil|
    func.call(Fiddle.dlwrap(data), parent, opt).to_value
  end

  def self.translate iseq
    ary = iseq.to_a
    ary[13].each{|insn|
      if Array === insn && insn[0] == :putstring
        insn[1] = "#{insn[1]}!!"
      end
    }
    load_from_array(ary)
  end
end

## こういうプログラムを実行してみると...?
eval("puts 'Hello World'")

yomikomu gem

さて、インターフェースは用意しましたので、いろいろ作って試して貰えるといいのですが、 まぁ、普通はこういうのは標準で用意して貰いたいもの。なので、gem を作ってみました。

yomikomu / https://github.com/ko1/yomikomu

yomikomu gem を使う

使い方は簡単。まずはふつーにインストールします。そしてお手元で、

 $ kakidasu [file or dir] ...

というコマンドを叩くと、ファイル等を書き出します。

アプリ内で require ‘yomikomu’ としておけば、事前コンパイルされ、出力されたバイナリを読み込んで試してくれます。

kakidasu 先

「ファイル等を書き出します」と書きましたが、どこに書き出すのでしょうか。

  • 案1: 1対1に対応するファイルに出力する
    • 案1-1: 対象スクリプトと同じディレクトリに出力する
    • 案1-2: ~/.ruby_binaries/ とか、そういう特定のディレクトに集める
  • 案2: DB に突っ込む(例えば、dbm)

で、yomikomu gem では、この案1-1、1-2、2 のどういうものを利用するか、 というのを選ぶことができます。 ドキュメントに書いてあるように、環境変数で指定することができます。

 YOMIKOMU_STORAGE (default: fs): choose storage type.
   'fs' (default) stores binaries in the same directory (for examlple, x.rb will be compiled to x.rb.yarb in the same directory).
   'fs2' stores binaries in specific directory.
   'dbm' stores binaries using dbm

まぁ今は色々手抜きですが、yomikomu gem を色々改造してもらって、面白くて便利なものを作ってくれるといいなぁ、と思います。

高速化への道

さて、最初に目標は性能であり、それがうまくいっていない、と書きました。細かい分析を書くのは面倒なので省略しますが(RubyKaigi の発表に、比較的きちんと書いていますが)、まぁ、そんなに性能は変わりません。

また、消費メモリ削減という目標も、まだ複数プロセスでのメモリ共有というものにアプローチできていないので、評価できていない、という状況です。

いろんな高速化・軽量化のアプローチがあると思うんで、我こそは、という人がいらっしゃいましたら、是非とも挑戦してみてください。

おわりに

今回は、事前コンパイルで性能改善! ってのを目指して、色々用意してみたけど、今のところあまり速くならなかったね! という記事でした。これからも頑張ります。

著者について

ささだこういち。転職して、はや4年。もう 2016 年なんですね…。いつまでプログラム書いていられるんだろう。でも、プログラム以外の仕事ができる気がしない。まぁ、いいか。

バックナンバー