RubyのJIT開発でやろうと思ってることが大体 @_ko1 さんの作業待ちでブロックしていて暇なので何かを書こうと思い、JVMを書くことにした。 まだその辺のアプリを気軽に動かせるレベルでは全然ないが、別に秘密裏に開発する必要もないと思ったので公開した。
これの紹介と、現時点で学べたことをこの記事に記録しておく。
何故JVMなのか
仕事でJVM言語を使っている
僕が所属しているTreasure Dataでは、大雑把に言うと本番サーバーのサービスは大体Ruby, Java, Scala, Kotlinで書かれている*1ので、既にRubyのVMはある程度わかる*2ことを考えると、JVMさえ理解してしまえば社内の主要な言語評価系を抑えたことになり、運用面で活躍の機会が増える気がしている。
また、自分が最近一番書いているのはKotlinなのだが、JVMで動かしていることに由来した問題が垣間見えることがあるので、JVMに詳しいとその背景を理解したり問題に対処したりするのに役に立つと思っている。
OpenJDKの良いところを今後CRubyで真似したい
Ruby 2.6で導入されたJust-In-Timeコンパイラの開発をしているので、なんか遅くて困った、という時に他の処理系が同様の問題をどう解決しているか参考にしたいことがある。
その上で、Rubyと同じくスタックマシンであるHotSpot VMは、既に長く運用され洗練されたJITを持っているのですごく参考になりそうだし、CRubyと性能を競うJRubyやTruffleRubyもJVMで動くので、JVMの挙動が解析できると他のRuby処理系の挙動理解にも役に立つ感じがする。
何故Javaで実装したのか
昔C言語のコンパイラをCで書いてセルフホストしようとして結局途中でやめた、ということがあり、今回こそセルフホストにこぎつけようと思いJVM言語で書くことにした。
JVM言語の中でもJavaなのは、仕事で使うのでJavaのコードを割と読む機会があるが、書いてるのは大体Kotlinなので、Javaを書く方の経験の足しにしておこうと思ったため。*3
JJVMは今どこまで実装してあるか
testディレクトリに置いてある奴は動くが、VM命令と一対一対応みたいなものしか置いてないのであまり参考にならなそう。テストを書くのが面倒くさいので、セルフホストを主な結合テストとして利用しようとしている。
それから、Systemクラスの初期化に10秒くらいかかるので起動がめちゃくちゃ遅いというのと、割と自明に実装できるところもテストを用意していないうちはRuntimeExceptionで落としてわざと動かさないでいる場所が結構ある (nop命令すらコメントアウトしている) ので、読んでいる皆さんが過去に書いたアプリケーションを動かしてみる、とかはおすすめできない状態にある。
BytecodeInterpreter.javaを眺めたり、-Xjjvmtrace
というオプションの出力を眺めると簡単に雰囲気がわかると思う。
実装スコープ
以下のものを実装している:
Javaを使うと本来クラスファイルパーサーはimportするだけでも使えるが、Javaバイトコードそのものに多少興味があったので自前で書いている。クラスファイルパーサーを書く時の雰囲気は他に最近JVMを趣味で書いてる人たちが 書い てた ので省略する。
どこでインチキをしているか
クラスファイルパーサーでは異常な真面目さを発揮しているが、それ以外の場所は手抜き感に溢れている。
GCがある言語でインタプリタを実装すると、一般に、GCはホスト言語のものをそのまま使えることになる。最近JITばかり書いていたのでJITはお休み。
上述した "ホストJVMのbootclasspathの利用" により、ホストのランタイムライブラリに依存している*4。
また、とりあえず達成感を得るためにセルフホストまで行きたいが、何か面倒っぽい細かいところの呼び出しをスタブして逃げたりしている。が、 System.out
の初期化とか PrintStream.print()
の実装は可能な限り下のレイヤーまで本来の挙動を再現するよう実装してあって、Hello Worldにも結構な労力をかけている。
一番インチキっぽいのはpublicなネイティブメソッドをJavaで実装するパートで、例えば System.arraycopy
の実装はJavaで System.arraycopy
を呼ぶだけなのである。セルフホストする都合Javaで書かなければならないことを考えると保守性も性能も共に最高の実装のはずだが、勉強にはならない。どうか NativeMethod.java
ファイルは探さないでほしい。
セルフホストの進捗
java
コマンド相当のCLIで -help
が動くようになった。…以上である。
System.out.println()
を完全にスタブした状態で -help
でのセルフ起動・終了が達成できた時も結構な達成感があったものの、それ以降はセルフホストらしい進捗はまだない。というか、肝心のインタプリタ部分の実行に至っていないので普通はセルフホストとは呼ばない状態にあると思う。
その次のマイルストーンとして、上述した通り System.out.println()
をなるべくスタブを避けて実装する、というのをがんばった*5。これでも結構動いててがんばっている感じがするが、次はやはり評価系をセルフホストできる状態にしたい。
これまでの開発でJVMについて学べたこと
(このセクションは、Javaのモヒカンの方々は多分皆知ってる内容なので読み飛ばして欲しい)
Java バイトコードの読み方
公式ドキュメントのThe class File FormatとThe Java Virtual Machine Instruction Setを読めばわかる話なので、印象に残ったことだけ触れておく。
クラスファイルパーサーを書くと、descriptorと呼ばれる ([Ljava/lang/String;)V
みたいな初見では奇妙なフォーマットが自然に理解できるようになる。これはクラスファイルに対し javap -s
すると出てくるが、普通に生活してても、Kotlinを書いてる時に same JVM signature というエラーとかでdescriptorにお目にかかることはある。
あと、深淵な理由により.javaが得られず.classだけが渡された時に、 javap -v
で挙動を解析したりバイナリエディタで勝手にハックしたりできて便利そうな感じがする。まあIDEAのJava Decompilerとかあれば、無理してjavapの出力を読まなくてもいいこともあるかもしれないけど。
Kotlinコンパイラが生成するクラスファイルも読んで違いを見てみたいと思っているが、それは未着手。
標準のクラスのソースやjarの場所
OpenJDKの公式リポジトリはMercurialらしいが、僕はGitしか使い方がわからない*6ので、 https://github.com/openjdk/jdk のミラーをcloneして使っている。仕事では openjdk-8-jre 上でKotlinを動かしていることもあり、少し古いがJDK 8系のタグ(jdk8-b120
とか)をチェックアウトして読んでいる。
主要なクラスは大体 jdk/src/share/classes
に入っているのでそこを探すと様々なクラスの実装が読める。まあ openjdk/jdk
にある全ファイルをまるごと unite.vim でフィルタして探してるので僕は jdk/src/share/classes
とかは記憶してないけど。
また、JDKを動かしている時などに使われる実際のクラスファイルは、JDK 8系だとJDKのディレクトリ内の jre/lib/rt.jar
を jar -xvf
で展開すると出現する。Java 8を使っているのでJJVMの実装は System.getProperty("sun.boot.class.path")
してこれを探しにいっているが、より新しいバージョンではこれらは違うパス / プロパティ名になっているらしい。
クラスやフィールドがどのように初期化されるか
The Structure of the Java Virtual Machineを読むと、JVMの型にはPrimitive TypeとReference Typeがあり、前者のデフォルト値は0、後者はnullになることが書かれている。
クラスのstaticなfieldの初期値は、何も指定されていない場合は最初から上記のルールの通りの値になる。そうでない場合、内部的には、初期化するクラスの <clinit>
*7 を呼び出して putstatic
命令で初期化されている。初期値が定数な場合はクラスファイルのfield部分にConstantValueというattributeがついていてそこにconstant poolへのindexがあり静的に初期値が解析できるが、動的な初期値だと <clinit>
のCode以外ではクラスファイルレベルを見ても初期値がわからないように見える。
OpenJDKには Threads::create_vm
という名前からしてVMの初期化に使いそうな関数があり、いくつかのクラスはそこで既に initialize_class
されるが、Systemクラスに関しては更に call_initializeSystemClass
から System.initializeSystemClass()
というメソッドが呼ばれていて、皆さんがよく使う System.out
等はそこで初期化されている。
オブジェクトの生成はバイトコードでは new
命令の後 invokespecial
命令で <init>
(コンストラクタの内部的な名前) というメソッドを呼び出して行なわれるが、new
命令のドキュメントには上記の通りのデフォルト値にインスタンス変数を初期化することが明示されているので、コンストラクタに入った時点でPrimitive Typeのインスタンス変数は0になっていることが信頼できる、といったことがわかる。
JVMがRuby VMと違うところ
Rubyだとパーサやコンパイラみたいなフロントエンドが評価系と同居している、みたいな当たり前の話は省略する。
バイトコード省サイズ化の努力が見られる
.jarや.classで配布することを見越してか、単にJVMの内部実装の都合に合わせたのかはわからないが、クラスファイル内で出現するあらゆる名前はcontant poolで必ず共有されている。*8
VM命令やオペランドが1ワード使うRubyとは異なり、Javaバイトコードでは名前の通り命令のopcodeやオペランドの基本サイズは1バイトになっている。オペランドがconstant poolの参照の時はインデックスの指定に必要なバイト数に応じて別のopcodeが使われていたり(ldc
, ldc_w
)、頻出オペランドがopcodeにエンコードされてたりする(iconst_0
, iconst_1
, ...)。
頻出オペランドで命令が特殊化されるのはRubyにもあるが、それは命令の実装におけるオペランドのインライン化(による最適化)が目的なので、それも兼ねているかもしれない。
Primitive Typeごとに命令が細分化され、ネイティブメソッドも少ない
Ruby VMだとPrimitiveっぽいクラスの処理のほとんどはネイティブなメソッドかその最適化用のVM命令でCで記述されているが、静的に型が定まらないことがほとんどなRubyの特性上、その最適化命令の実装はあるクラスに特化したものにしておく、ということが難しい。
なので Integer#+
用の命令があるわけではなく、その最適化命令はどんな #+
でも動く作りになっていて、例えば Array#+
の呼び出しは Integer
, Float
, String
など他の様々な型のチェックの後 Array
かどうかがチェックされやっと動く、という作りになっている。
JVMの命令でその命令に相当するのは iadd
, ladd
, fadd
, dadd
で、ちゃんとPrimitive Typeごとにバラバラになっている。配列の操作はそれぞれまた別の命令があるし、 String
というのは所詮 char[] value
というfieldを持つ普通のオブジェクトに過ぎないので、 iadd
および配列操作の命令まで落とすことができる。
今ではどちらもJITを持つ処理系だが、JITをする上でどちらが最適化に都合がいいかというと、圧倒的にJVMの状態の方が良い。そもそも1つの命令が様々なクラスに対応してて分岐が多かったりすると、命令単位で色々こねくりまわしてミクロな最適化をするのが難しくなる。ネイティブなメソッドは、インライン化してVM命令と混ぜて最適化する、ということが難しい *9 ので、全てがVM命令の組合せで定義され数値演算や配列操作の小さな命令たちに落ちるのが理想である。
ところでRubyでも Integer#+
専用の命令を使うようにすることはできないことはなくて、「Integer が来たら Integer#+、それ以外なら命令を別のに書き換えつつフォールバック」というような命令を含む全命令セットのリプレース提案をした人がいた。これで命令内の型分岐は1つに絞れるが、全命令リプレースはリスクが高いので現状のVMにその特化命令アイデアだけ入れるパッチを書いてみたことがあるが、ベンチマークでの結果があまりよくならずまだ入れていない。
所感
手を動かす時間が長すぎてOpenJDKの実装を読む時間がそれほど取れてないんだけど、これで割と読む準備ができてきた気がするので、気になっていたところを読むのをそろそろ進めていきたい。
*1:コンソールとかは例えばJavaScriptで書かれているし、TDで使われている言語がこれだけという主張ではない。若干本番から離れたところではPythonが使われているが、まあ雑に捉えると大体Rubyと同じと思って生活している。
*2:単にRubyと書く時はCRubyのことを意図している。JRubyを使っている箇所もあったが、社内のJRubyコミッターの人数よりCRubyコミッターの人数の方が多いので…というのは冗談だけど今は大体CRubyで動いている。
*3:同様の目的でJavaだけでAtCoder水色にしたり、趣味でも現職でもJavaで一からサービスを書いたりしてはいるが、長くメンテしているものが少ない
*4:もちろんJJVMの方で評価しているのでこれはそこまでインチキではないが、例えばAOTコンパイルで動かそうとすると壊れるリスクがある
*5:というかそれをがんばったせいで、1秒もかかっていなかった初期化が10秒になってしまった。
*6:念のため補足すると、Rubyの公式リポジトリのSubversion→Git移行に僕が結構貢献していた、という前回の記事にかけたジョーク
*7:javap -v だと static {} になってしまうのがやや不満。<init> も同様
*8:RubyのInstruction Sequenceをシリアライズした時のフォーマットがどうなってるか知らないというか、Rubyではそれはそれほど重要ではなくあまり興味もないので、この部分に関して公平な比較は特に書いてなくて申し訳ない、が言及したかった
*9:GraalのSulongではそういうことをやれると記憶してるけど、同じことを一人で実装・メンテしたいかというと、やりたくない…