TkrzwのJavaインターフェイスがGoインターフェイスの半分の性能しか出ないというのは、さすがに遅すぎるだろうという話があった。JNIのオーバーヘッドが高いからというのを遅い理由として挙げていたが、うまく書けばそれを下げられることが分かったので、やってみた。結果として、JavaインターフェイスをGoインターフェイスと互角の性能に引き上げることができた。
以前の実験と同様に、1000万個のレコードの格納(Set)と検索(Get)のスループットを計測した。キーと値はそれぞれ8バイトのユニークな文字列である。データ構造にはハッシュデータベースを用い、ハッシュバケットの数はレコード数の2倍とする。マシンは私のノートPC(Core i7 8550U 1.8Ghz)である。単位はQPS(クエリ毎秒)である。
1 thread Set | 1 thread Get | 4 threads Set | 4 threads Get | |
Java (old) | 376,495 | 376,495 | 892,625 | 1,019,015 |
Java (new) | 672,515 | 747,235 | 1,331,996 | 2,061,242 |
Go | 641,006 | 772,380 | 1,330,526 | 1,724,950 |
Javaインターフェイスの以前のバージョンはGoの半分程度のスループットしか出なかったが、新バージョン(0.1.18)ではGoと同等の性能が出るようになった。Goもそうだが、マルチスレッド化すると順調にスループットが伸びるというのは本当に素晴らしい。
どうしてこんなに早くなったのかというと、以前のバージョンにあった非効率のいくつかを解消したからだ。最大の貢献は、クラスとメソッドとフィールドのアクセサをキャッシュしたことである。JNIでC/C++からオブジェクトを操作するには、そのオブジェクトが属するクラスのオブジェクト(jclass)をGetObjectClass関数かFindClass関数で調べた上で、アクセスするメソッドまたはフィールドの識別子(jmethodID、jfieldID)をGetMethodIDかGetFieldIDで調べる必要がある。以前のバージョンでは、毎回のクエリの度にそれらの関数を呼んでいたのが非効率であった。
毎回のクエリで、名前でクラスを調べたりクラスのメンバを調べたりするのは、明らかに効率が悪い。よって、ライブラリがロードした時にそれらの検索を行った上で、結果をキャッシュしておいて、毎回のクエリではそれを再利用すればよさそうだ。JNIはグローバル変数を全く使わないでクリーンなプログラムを書くことができるのだが、この際そのポリシーを無視してグローバル変数にキャッシュデータを置いてしまおう。単一プロセスで複数のJavaVMをロードできなくなるが、普通そんなことやらないだろうから、まあいいだろう。
アクセサをキャッシュすれば高速化するだろうことは以前から考えていたのだが、実際にやってみるとSegmentation Faultが出るので諦めていた。しかし、今回改めて調べてみたところ、解決策が書いてあるページを見つけた。要点は以下のものである。
- ライブラリのロード時に呼ばれるコールバックであるJNI_OnLoad関数にてアクセサのキャッシュを行う
- グローバル変数として保持するオブジェクトは、グローバル参照である必要がある。
- FindClass関数が返すクラスオブジェクトはローカル参照なので、NewGlobalRef関数でグローバル参照に変換せねばならない。
- jmethodIDとjfiledIDは単なるIDなので、そのままグローバル変数として保存してよい。
私が躓いていた原因は、クラスオブジェクトのローカル参照をグローバル変数で保持していることであった。GCが動くとクラスオブジェクトのアドレスが変わるので、ローカル参照を使い続けると死ぬのだ。改めて解説されると合点がいくが、JNIの公式文書を読んだだけでこれを理解するのは簡単ではないだろう。いや、負け惜しみだけども。まあともかく、JNIを使う際にはアクセサのキャッシュはめちゃ重要ということだ。
他にも細かい最適化を施したのだが、顕著に効果があったのは、空文字列の生成を省略して、同じオブジェクトを使い回すことだった。DBやファイルへのアクセスの結果をStatusオブジェクトとして返すのだが、そのエラーメッセージは成功時には空文字列になる。以前のバージョンでは愚直に空文字列のオブジェクトを生成していたのだが、空文字列のグローバル参照をキャッシュして使い回せることに気づいた。そんな工夫だけでも10%くらい高速化した。文字列変換周りのコードはもうちょい最適化できそうな気もする。Goインターフェイスでもその点は同じだけど。
まとめ。TkrzwのJavaインターフェイスの実装を見直して、高速化した。JNIの名前検索がボトルネックだったので、キャッシュした結果を使い回すようにしたところ、なんと2倍に早くなった。結果として、Goと同等のスループットが出るようになった。