何故JVM(HotSpot)のUnsafe APIは速いのか
- 2014/05/04
- 16:55
デシリアライズ速度の比較 ByteBuffer vs DirectBuffer vs Unsafe vs C
上記blogを読んで非常に面白い結果だなーと思いまして、
え、Unsafeってこんなに速いの?と思い、いろいろ調べてみました。
...逆にいうとByteBufferを使うとCほどには速くならない。
(1) HotSpot Unsafeの仕組み(実装の話)
unsafe系のAPIは、HotSpotの組み込み関数(vmIntrinsics)に指定されています。
また以降のPrintInliningでも確認できます。
hotspot/src/share/vm/classfile/vmSymbol.hpp
そもそもvmIntrinsicsが何かというと、
HotSpotでは上記で登録済みのsignatureにパターンマッチして、
Java Bytecodeのinvokeの呼び出し先signatureとマッチするか判定します。
BytecodeをJITコンパイルする際に、通常はinvoke系のBytecodeをCall Nodeに置換しますが、
呼び出し先が通常のclassのsignatureではない場合、CallNodeのflagをintrinsicにセットします。
vmIntrinsicsへの呼び出しは、各JITコンパイラが命令列に展開しますが、
C2コンパイラであるoptoの対応箇所は以下になります。
hotspot/src/share/vm/opto/library_call.cpp
optoの中間表現はidealという名前で、Nodeクラスがグラフ構造でつながっています。
library_callではintrinsicsへのCall Nodeを、対応する別のNodeに置換しています。
上記のinline_unsafe_access()の中では、
getIntやgetLongをmake_laod()により、IdealのLoadNode系に置換します。
LoadNodeは、最終的にCodeGeneratorによりアセンブリに変換されます。。
詳しい仕組みは下記で説明しています。UnsafeのcompareAndSwapを例にしています。
http://nothingcosmos.github.io/OpenJDKOverview/src/intrinsics
上記のような仕組みであるため、vmIntrinsicsは
特定のsymbolの関数呼び出しに置換されるわけではないです。
多くの場合、opto内で対応するNodeにinline展開され、そのままアセンブリに変換されます。
(2) Oracle JVMでコンパイルログを参照する
Oracle JVMの隠しオプションをいくつか使ってコンパイルログを参照し、
どうなっているのかざっくり把握してみます。
こういうときによく使うオプションは
"-Xbatch -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining"
-Xbatchは、JITコンパイルと実行中のプログラムが並行して走らないようにします。
UnlockDiagnosticVMOptionsは-XXオプションを指定可能にします。
PrintCompilationはJITコンパイルしたログを出力します。
PrintInliningはInline展開のログを出力します。
ベンチマークプログラムの場合、上記でどんな傾向なのか分かります。
このオプションを指定し、冒頭のURLで公開されているソースコードをお借りし、ログを取得してみました。
全ログファイルはここを参照。
oracle_inlining.log
Oracle JVMのバージョンは以下になります。
まずはログの見方を解説
JITコンパイルの仕組みや、上記ログの読み方はJRubyを開発しているNutterさんの資料が詳しいです。
slideshare
1列目の5049とか5170って数字は、JVM起動からの経過時間(ms)
104-105ってのは、JITコンパイルの順番を表す数値です。
-XBatchを指定しているので、数字はシーケンシャルに増加していきます。
% :OnStackReplacementでJITコンパイルしたメソッドを表す
s :synchronized method
! :例外発生するもの。例外はJITコンパイルに悪影響与える可能性大なので、kernel loopからは取り除くべき
b :コンパイルがblockingしているのを表す。-Xbatchなのでbが必ず出力されている。
n :native method、大体はintrinsics
nの隣の数字 0-4に関して
最近のHotSpotのJITコンパイラは4Tier JIT Compilationなので、0-4はそのLevelを表す数値です。
C1(-client)とC2(-server)という分け方ではなく、両者を組み合わせています。
インタプリタ->C1+Profiled->C2と多数の階層に分かれており、
インタプリタでプロファイルしていた情報をC1コンパイル後も取得できるように、
プロファイル用の命令をC1コンパイル時に埋め込みます。
C2でコンパイルする際には、上記のプロファイル情報を元に最適化を行います。
0:インタプリタ
1:C1コンパイラでsimpleコンパイル
2:C1コンパイラでinvokeとbackedgeのカウントを埋め込み。
3:C1コンパイラでfull profile
4:C2コンパイラで最適化
数字が出ていない場合は、TierCompilationが無効なはず。
@ XX: osr_bci
(xx bytes) : methodのcode_size
以降は右端の補足で表示されているもの
inline (hot) :inline展開されたもの
(native) :nativeなシンボル。JNIとかもそう。
intrinsic :intrinsicへの呼び出しに展開したもの。
too big :callerのサイズが大きく、inline展開の閾値の関係で諦めたもの
callee is too large :展開のしすぎでcallee本体が閾値に達し、inline展開を諦めたもの
まずはByteBufferRunnable heap版のコンパイルログ
runの基本的なメソッド呼び出しは、getByte(), getInt(), getLong()の3つです。
これらのメソッドがどのようにInline展開されているかが分かります。
見どころは
(a) TypeProfile
抽象クラスのメソッドやinterfaceは、どの実装が呼び出されるのかわからないので、
どの実装が呼び出されているのかprofileしている。
この段階ではHeapByteBufferのみなので、ガードを挿入した上でHeapByteBufferのコードを展開している。
optoのinline展開は、静的にクラス階層解析するのではなく、
ログ取得したTypeProfile任せにinline展開先を決定する。
(b) intrinsicsが存在しない。
HeapByteBufferはintrinsicsをwrapしたものではないようです。
次に
ByteBufferRunnable direct版のコンパイルログ
見どころは
(c) TypeProfileが複数存在する。
getByte(), getInt(), getLong()の3つそれぞれにおいて、
DirectByteBufferとHeapByteBufferの2つの呼び出しが記録されています。
そのため、複数のガードを挿入した上で双方をInline展開しようとしています。
はっきりいうとHeapByteBufferが邪魔です。
(d) DirectByteBufferのgetByteがintrinsic
Unsafe::getByteをwrapしているらしい。
Heap版と比較すると、これが性能の差かもしれない。。
(e) too big
DirectByteBufferのgetIntとgetLongが重要なのに、
HeapByteBufferのTypeProfileが邪魔でinline展開できていない。。
@ 11 java.nio.DirectByteBuffer::getInt (39 bytes) too big
@ 12 java.nio.DirectByteBuffer::getLong (39 bytes) too big
これは、、と思ったのでコードを変更して測定しなおしてみました。
何を変えたかというと、最初のHeapByteBufferを呼び出さずに、
DirectBufferとUnsafeの呼び出しのみ行ってみます。
実行結果
Unsafeの性能は変わらないけど、ByteBuffer directの実行性能が大きく上がっている。
ログでも内容を確認してみる。
ByteBuffer directのコンパイルログ
見どころは
(f) TypeProfileはDirectByteBufferのみになった。
Profile結果1つだけなので、inline展開し易くなっている。
inline展開の閾値を操作するだけでも性能は上がったかもしれない。
(g) 最終的にUnsafeのintrinsicに展開されている。
too bigが解消されて、UnsafeのgetInt getLongまで展開されている。
DirectByteBufferはUnsafeをwrapしたクラスっぽい。
Unsafe heap
最後にUnsafeですが、、、
な… 何を言っているのか わからねーと思うが
おれも 何をされたのか わからなかった…
runの基本的なメソッド呼び出しは、getByte(), getInt(), getLong()の3つなので、
それらすべてにUnsafeを使用すると、すべてintrinsicになっていました。
ByteBufferのようにTypeProfileを気にしなくてもよいのはよいかもしれません。
これは確かにCと同等のコードが生成されますよね。。
===ここまでのまとめ
ByteBufferはやっぱり遅いらしい。
使い方に依存するが、TypeProfileが複数になると性能が落ちる。
allocateDirect()はUnsafeをWrapしただけのDirectByteBufferを使用するため非常に高速。
Unsafeは全部Intrinsicなので、TypeProfileに依存しないコードを書ける。IdealのNodeを直に操作するイメージ。
(3) OpenJDKでJITコンパイルしたコードを逆アセンブルする
Oracle JVMでは不可能ですが、OpenJDKをDebug版でビルドすれば、
JITコンパイルしたアセンブリを出力することができます。
JVMの生成するアセンブリに興味のある方は試してみてください。
オプションは、-XX:+PrintOptoAssembly
C2コンパイラでコンパイルしたアセンブリを出力するオプションです。
ここからはOpenJDK8をfastdebugモードにビルドして使用しました。
全ログファイルは下記にありますので、生のアセンブリを見たいかたはどうぞ。。
openjdk_asm_directonly.log
逆順に見ていきます。まずはUnsafeから。
アセンブリではなく、似非C言語風に表現してみます。
Unsafeのアセンブリはコンパクトなのでそのまま張り付けてみます。。
えっと、見どころは特に無いですが、
(h) 範囲チェックみたいの一切やってない。
当然といえば当然かも。だからこそ速いのかも。
(i) 範囲チェックからの復帰コードみたいのがない。
当然といえば当然ですが、JVMがそのままSEGVするでしょうね。。
//OSR版のコードでは、関数冒頭でUnsafeRunnableのチェックとnull checkは入ってました。
お次はByteBufferのDirectのみ呼び出した場合のアセンブリなんですが、
規模がでかかったので、似非C言語で記述しています。
見どころは、
(j) 範囲チェックやnull check
所々範囲チェック(0以上、limitを越えていないか)が挿入されていました。
範囲チェックに違反した場合、uncommon_trapを呼び出してVMに制御を戻します。
(k) 実装クラスのチェック
JITコンパイルしたコードはDirectByteBuffer向けに生成されているため、
冒頭でDirectByteBufferかどうかのチェックが入っていました。
もしDirectByteBufferでなかった場合、uncommon_trapを呼び出してVMに制御を戻します。
チェックが挿入されているのは関数の冒頭だけでした。
(l) bswapl
なんじゃこりゃーと思ったら、どうもDirectByteBufferにはByteOrderを変更できるフラグがあり、
もしそのフラグが立っていたらbswaplが実行されるようなコードになってました。
--感想--
Unsafeは速いけど範囲チェックしない。
UnsafeはHotSpot依存なので、他の実装で動かす場合には問題になりそう。
//上記プログラムのUnsafe + directをOpenJDKで動かすとSEGVしました。Oracle JVMは問題なし。
代替策としては、JNIにしちゃうか、DirectByteBufferを使うのが良いかもしれない。
上記blogを読んで非常に面白い結果だなーと思いまして、
え、Unsafeってこんなに速いの?と思い、いろいろ調べてみました。
...逆にいうとByteBufferを使うとCほどには速くならない。
(1) HotSpot Unsafeの仕組み(実装の話)
unsafe系のAPIは、HotSpotの組み込み関数(vmIntrinsics)に指定されています。
また以降のPrintInliningでも確認できます。
hotspot/src/share/vm/classfile/vmSymbol.hpp
do_intrinsic(_getObject, sun_misc_Unsafe, getObject_name, getObject_signature,
do_intrinsic(_getBoolean, sun_misc_Unsafe, getBoolean_name, getBoolean_signature,
do_intrinsic(_getByte, sun_misc_Unsafe, getByte_name, getByte_signature,
do_intrinsic(_getShort, sun_misc_Unsafe, getShort_name, getShort_signature,
do_intrinsic(_getChar, sun_misc_Unsafe, getChar_name, getChar_signature,
do_intrinsic(_getInt, sun_misc_Unsafe, getInt_name, getInt_signature,
do_intrinsic(_getLong, sun_misc_Unsafe, getLong_name, getLong_signature,
do_intrinsic(_getFloat, sun_misc_Unsafe, getFloat_name, getFloat_signature,
do_intrinsic(_getDouble, sun_misc_Unsafe, getDouble_name, getDouble_signature,
do_intrinsic(_putObject, sun_misc_Unsafe, putObject_name, putObject_signature,
do_intrinsic(_putBoolean, sun_misc_Unsafe, putBoolean_name, putBoolean_signature,
do_intrinsic(_putByte, sun_misc_Unsafe, putByte_name, putByte_signature,
do_intrinsic(_putShort, sun_misc_Unsafe, putShort_name, putShort_signature,
do_intrinsic(_putChar, sun_misc_Unsafe, putChar_name, putChar_signature,
do_intrinsic(_putInt, sun_misc_Unsafe, putInt_name, putInt_signature,
do_intrinsic(_putLong, sun_misc_Unsafe, putLong_name, putLong_signature,
do_intrinsic(_putFloat, sun_misc_Unsafe, putFloat_name, putFloat_signature,
do_intrinsic(_putDouble, sun_misc_Unsafe, putDouble_name, putDouble_signature,
そもそもvmIntrinsicsが何かというと、
HotSpotでは上記で登録済みのsignatureにパターンマッチして、
Java Bytecodeのinvokeの呼び出し先signatureとマッチするか判定します。
BytecodeをJITコンパイルする際に、通常はinvoke系のBytecodeをCall Nodeに置換しますが、
呼び出し先が通常のclassのsignatureではない場合、CallNodeのflagをintrinsicにセットします。
vmIntrinsicsへの呼び出しは、各JITコンパイラが命令列に展開しますが、
C2コンパイラであるoptoの対応箇所は以下になります。
hotspot/src/share/vm/opto/library_call.cpp
...
case vmIntrinsics::_getInt: return inline_unsafe_access(!is_native_ptr, !is_store, T_INT, !is_volatile);
case vmIntrinsics::_getLong: return inline_unsafe_access(!is_native_ptr, !is_store, T_LONG, !is_volatile);
...
optoの中間表現はidealという名前で、Nodeクラスがグラフ構造でつながっています。
library_callではintrinsicsへのCall Nodeを、対応する別のNodeに置換しています。
上記のinline_unsafe_access()の中では、
getIntやgetLongをmake_laod()により、IdealのLoadNode系に置換します。
LoadNodeは、最終的にCodeGeneratorによりアセンブリに変換されます。。
詳しい仕組みは下記で説明しています。UnsafeのcompareAndSwapを例にしています。
http://nothingcosmos.github.io/OpenJDKOverview/src/intrinsics
上記のような仕組みであるため、vmIntrinsicsは
特定のsymbolの関数呼び出しに置換されるわけではないです。
多くの場合、opto内で対応するNodeにinline展開され、そのままアセンブリに変換されます。
(2) Oracle JVMでコンパイルログを参照する
Oracle JVMの隠しオプションをいくつか使ってコンパイルログを参照し、
どうなっているのかざっくり把握してみます。
こういうときによく使うオプションは
"-Xbatch -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining"
-Xbatchは、JITコンパイルと実行中のプログラムが並行して走らないようにします。
UnlockDiagnosticVMOptionsは-XXオプションを指定可能にします。
PrintCompilationはJITコンパイルしたログを出力します。
PrintInliningはInline展開のログを出力します。
ベンチマークプログラムの場合、上記でどんな傾向なのか分かります。
このオプションを指定し、冒頭のURLで公開されているソースコードをお借りし、ログを取得してみました。
全ログファイルはここを参照。
oracle_inlining.log
Oracle JVMのバージョンは以下になります。
java version "1.7.0_51"
Java(TM) SE Runtime Environment (build 1.7.0_51-b13)
Java HotSpot(TM) 64-Bit Server VM (build 24.51-b03, mixed mode)
まずはログの見方を解説
5049 104 n 0 sun.misc.Unsafe::compareAndSwapObject (native)
5170 105 n 0 sun.misc.Unsafe::getByte (native)
5170 106 n 0 sun.misc.Unsafe::getInt (native)
5170 107 n 0 sun.misc.Unsafe::getLong (native)
5188 108 % b 3 DeserBenchmark$UnsafeRunnable::run @ 10 (98 bytes)
@ 29 sun.misc.Unsafe::getByte (0 bytes) intrinsic
@ 55 sun.misc.Unsafe::getInt (0 bytes) intrinsic
@ 82 sun.misc.Unsafe::getLong (0 bytes) intrinsic
JITコンパイルの仕組みや、上記ログの読み方はJRubyを開発しているNutterさんの資料が詳しいです。
slideshare
1列目の5049とか5170って数字は、JVM起動からの経過時間(ms)
104-105ってのは、JITコンパイルの順番を表す数値です。
-XBatchを指定しているので、数字はシーケンシャルに増加していきます。
% :OnStackReplacementでJITコンパイルしたメソッドを表す
s :synchronized method
! :例外発生するもの。例外はJITコンパイルに悪影響与える可能性大なので、kernel loopからは取り除くべき
b :コンパイルがblockingしているのを表す。-Xbatchなのでbが必ず出力されている。
n :native method、大体はintrinsics
nの隣の数字 0-4に関して
最近のHotSpotのJITコンパイラは4Tier JIT Compilationなので、0-4はそのLevelを表す数値です。
C1(-client)とC2(-server)という分け方ではなく、両者を組み合わせています。
インタプリタ->C1+Profiled->C2と多数の階層に分かれており、
インタプリタでプロファイルしていた情報をC1コンパイル後も取得できるように、
プロファイル用の命令をC1コンパイル時に埋め込みます。
C2でコンパイルする際には、上記のプロファイル情報を元に最適化を行います。
0:インタプリタ
1:C1コンパイラでsimpleコンパイル
2:C1コンパイラでinvokeとbackedgeのカウントを埋め込み。
3:C1コンパイラでfull profile
4:C2コンパイラで最適化
数字が出ていない場合は、TierCompilationが無効なはず。
@ XX: osr_bci
(xx bytes) : methodのcode_size
以降は右端の補足で表示されているもの
inline (hot) :inline展開されたもの
(native) :nativeなシンボル。JNIとかもそう。
intrinsic :intrinsicへの呼び出しに展開したもの。
too big :callerのサイズが大きく、inline展開の閾値の関係で諦めたもの
callee is too large :展開のしすぎでcallee本体が閾値に達し、inline展開を諦めたもの
まずはByteBufferRunnable heap版のコンパイルログ
140 12 % b DeserBenchmark$ByteBufferRunnable::run @ 13 (74 bytes)
@ 23 java.nio.HeapByteBuffer::get (15 bytes) inline (hot)
\-> TypeProfile (11264/11264 counts) = java/nio/HeapByteBuffer
@ 7 java.nio.Buffer::checkIndex (22 bytes) inline (hot)
@ 10 java.nio.HeapByteBuffer::ix (7 bytes) inline (hot)
@ 40 java.nio.HeapByteBuffer::getInt (19 bytes) inline (hot)
\-> TypeProfile (5724/5724 counts) = java/nio/HeapByteBuffer
@ 5 java.nio.Buffer::checkIndex (24 bytes) inline (hot)
@ 8 java.nio.HeapByteBuffer::ix (7 bytes) inline (hot)
@ 15 java.nio.Bits::getInt (18 bytes) inline (hot)
@ 6 java.nio.Bits::getIntB (30 bytes) inline (hot)
@ 2 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 9 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 16 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 23 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 26 java.nio.Bits::makeInt (29 bytes) inline (hot)
@ 14 java.nio.Bits::getIntL (30 bytes) never executed
@ 58 java.nio.HeapByteBuffer::getLong (20 bytes) inline (hot)
\-> TypeProfile (5540/5540 counts) = java/nio/HeapByteBuffer
@ 6 java.nio.Buffer::checkIndex (24 bytes) inline (hot)
@ 9 java.nio.HeapByteBuffer::ix (7 bytes) inline (hot)
@ 16 java.nio.Bits::getLong (18 bytes) inline (hot)
@ 6 java.nio.Bits::getLongB (60 bytes) inline (hot)
@ 2 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 9 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 16 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 23 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 30 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 37 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 45 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 53 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 56 java.nio.Bits::makeLong (77 bytes) inline (hot)
@ 14 java.nio.Bits::getLongL (60 bytes) too big
runの基本的なメソッド呼び出しは、getByte(), getInt(), getLong()の3つです。
これらのメソッドがどのようにInline展開されているかが分かります。
見どころは
(a) TypeProfile
抽象クラスのメソッドやinterfaceは、どの実装が呼び出されるのかわからないので、
どの実装が呼び出されているのかprofileしている。
この段階ではHeapByteBufferのみなので、ガードを挿入した上でHeapByteBufferのコードを展開している。
optoのinline展開は、静的にクラス階層解析するのではなく、
ログ取得したTypeProfile任せにinline展開先を決定する。
(b) intrinsicsが存在しない。
HeapByteBufferはintrinsicsをwrapしたものではないようです。
次に
ByteBufferRunnable direct版のコンパイルログ
2374 14 % b DeserBenchmark$ByteBufferRunnable::run @ 13 (74 bytes)
@ 23 java.nio.HeapByteBuffer::get (15 bytes) inline (hot)
@ 23 java.nio.DirectByteBuffer::get (16 bytes) inline (hot)
\-> TypeProfile (4096/16384 counts) = java/nio/DirectByteBuffer
\-> TypeProfile (12288/16384 counts) = java/nio/HeapByteBuffer
@ 6 java.nio.Buffer::checkIndex (22 bytes) inline (hot)
@ 9 java.nio.DirectByteBuffer::ix (10 bytes) inline (hot)
@ 12 sun.misc.Unsafe::getByte (0 bytes) (intrinsic)
@ 7 java.nio.Buffer::checkIndex (22 bytes) inline (hot)
@ 10 java.nio.HeapByteBuffer::ix (7 bytes) inline (hot)
@ 40 java.nio.HeapByteBuffer::getInt (19 bytes) inline (hot)
@ 40 java.nio.DirectByteBuffer::getInt (15 bytes) inline (hot)
\-> TypeProfile (2034/8273 counts) = java/nio/DirectByteBuffer
\-> TypeProfile (6239/8273 counts) = java/nio/HeapByteBuffer
@ 5 java.nio.Buffer::checkIndex (24 bytes) inline (hot)
@ 8 java.nio.DirectByteBuffer::ix (10 bytes) inline (hot)
@ 11 java.nio.DirectByteBuffer::getInt (39 bytes) too big
@ 5 java.nio.Buffer::checkIndex (24 bytes) inline (hot)
@ 8 java.nio.HeapByteBuffer::ix (7 bytes) inline (hot)
@ 15 java.nio.Bits::getInt (18 bytes) inline (hot)
@ 6 java.nio.Bits::getIntB (30 bytes) inline (hot)
@ 2 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 9 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 16 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 23 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 26 java.nio.Bits::makeInt (29 bytes) inline (hot)
@ 14 java.nio.Bits::getIntL (30 bytes) never executed
@ 58 java.nio.HeapByteBuffer::getLong (20 bytes) inline (hot)
@ 58 java.nio.DirectByteBuffer::getLong (16 bytes) inline (hot)
\-> TypeProfile (2062/8111 counts) = java/nio/DirectByteBuffer
\-> TypeProfile (6049/8111 counts) = java/nio/HeapByteBuffer
@ 6 java.nio.Buffer::checkIndex (24 bytes) inline (hot)
@ 9 java.nio.DirectByteBuffer::ix (10 bytes) inline (hot)
@ 12 java.nio.DirectByteBuffer::getLong (39 bytes) too big
@ 6 java.nio.Buffer::checkIndex (24 bytes) inline (hot)
@ 9 java.nio.HeapByteBuffer::ix (7 bytes) inline (hot)
@ 16 java.nio.Bits::getLong (18 bytes) inline (hot)
@ 6 java.nio.Bits::getLongB (60 bytes) inline (hot)
@ 2 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 9 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 16 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 23 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 30 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 37 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 45 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 53 java.nio.HeapByteBuffer::_get (7 bytes) inline (hot)
@ 56 java.nio.Bits::makeLong (77 bytes) inline (hot)
@ 14 java.nio.Bits::getLongL (60 bytes) too big
見どころは
(c) TypeProfileが複数存在する。
getByte(), getInt(), getLong()の3つそれぞれにおいて、
DirectByteBufferとHeapByteBufferの2つの呼び出しが記録されています。
そのため、複数のガードを挿入した上で双方をInline展開しようとしています。
はっきりいうとHeapByteBufferが邪魔です。
(d) DirectByteBufferのgetByteがintrinsic
Unsafe::getByteをwrapしているらしい。
Heap版と比較すると、これが性能の差かもしれない。。
(e) too big
DirectByteBufferのgetIntとgetLongが重要なのに、
HeapByteBufferのTypeProfileが邪魔でinline展開できていない。。
@ 11 java.nio.DirectByteBuffer::getInt (39 bytes) too big
@ 12 java.nio.DirectByteBuffer::getLong (39 bytes) too big
これは、、と思ったのでコードを変更して測定しなおしてみました。
何を変えたかというと、最初のHeapByteBufferを呼び出さずに、
DirectBufferとUnsafeの呼び出しのみ行ってみます。
実行結果
変更前
-- ByteBuffer heap
11.23 msec/loop
849.22 MB/s
-- ByteBuffer direct
9.18 msec/loop
1038.86 MB/s
-- Unsafe heap
6.26 msec/loop
1523.44 MB/s
-- Unsafe direct
6.30 msec/loop
1513.77 MB/s
変更後
-- ByteBuffer direct
6.89 msec/loop
1384.14 MB/s <-- 1000MB/sから1380MB/sに大幅あっぷ
-- Unsafe heap
6.24 msec/loop
1528.32 MB/s
-- Unsafe direct
6.23 msec/loop
1530.78 MB/s
Unsafeの性能は変わらないけど、ByteBuffer directの実行性能が大きく上がっている。
ログでも内容を確認してみる。
ByteBuffer directのコンパイルログ
264 12 % b DeserBenchmark$ByteBufferRunnable::run @ 13 (74 bytes)
@ 23 java.nio.DirectByteBuffer::get (16 bytes) inline (hot)
\-> TypeProfile (11264/11264 counts) = java/nio/DirectByteBuffer
@ 6 java.nio.Buffer::checkIndex (22 bytes) inline (hot)
@ 9 java.nio.DirectByteBuffer::ix (10 bytes) inline (hot)
@ 12 sun.misc.Unsafe::getByte (0 bytes) (intrinsic)
@ 40 java.nio.DirectByteBuffer::getInt (15 bytes) inline (hot)
\-> TypeProfile (5724/5724 counts) = java/nio/DirectByteBuffer
@ 5 java.nio.Buffer::checkIndex (24 bytes) inline (hot)
@ 8 java.nio.DirectByteBuffer::ix (10 bytes) inline (hot)
@ 11 java.nio.DirectByteBuffer::getInt (39 bytes) inline (hot)
@ 10 sun.misc.Unsafe::getInt (0 bytes) (intrinsic)
@ 26 java.nio.Bits::swap (5 bytes) inline (hot)
@ 1 java.lang.Integer::reverseBytes (26 bytes) (intrinsic)
@ 58 java.nio.DirectByteBuffer::getLong (16 bytes) inline (hot)
\-> TypeProfile (5540/5540 counts) = java/nio/DirectByteBuffer
@ 6 java.nio.Buffer::checkIndex (24 bytes) inline (hot)
@ 9 java.nio.DirectByteBuffer::ix (10 bytes) inline (hot)
@ 12 java.nio.DirectByteBuffer::getLong (39 bytes) inline (hot)
@ 10 sun.misc.Unsafe::getLong (0 bytes) (intrinsic)
@ 26 java.nio.Bits::swap (5 bytes) inline (hot)
@ 1 java.lang.Long::reverseBytes (46 bytes) (intrinsic)
見どころは
(f) TypeProfileはDirectByteBufferのみになった。
Profile結果1つだけなので、inline展開し易くなっている。
inline展開の閾値を操作するだけでも性能は上がったかもしれない。
(g) 最終的にUnsafeのintrinsicに展開されている。
too bigが解消されて、UnsafeのgetInt getLongまで展開されている。
DirectByteBufferはUnsafeをwrapしたクラスっぽい。
Unsafe heap
4262 29 % b DeserBenchmark$UnsafeRunnable::run @ 10 (98 bytes)
@ 29 sun.misc.Unsafe::getByte (0 bytes) (intrinsic)
@ 55 sun.misc.Unsafe::getInt (0 bytes) (intrinsic)
@ 82 sun.misc.Unsafe::getLong (0 bytes) (intrinsic)
最後にUnsafeですが、、、
な… 何を言っているのか わからねーと思うが
おれも 何をされたのか わからなかった…
runの基本的なメソッド呼び出しは、getByte(), getInt(), getLong()の3つなので、
それらすべてにUnsafeを使用すると、すべてintrinsicになっていました。
ByteBufferのようにTypeProfileを気にしなくてもよいのはよいかもしれません。
これは確かにCと同等のコードが生成されますよね。。
===ここまでのまとめ
ByteBufferはやっぱり遅いらしい。
使い方に依存するが、TypeProfileが複数になると性能が落ちる。
allocateDirect()はUnsafeをWrapしただけのDirectByteBufferを使用するため非常に高速。
Unsafeは全部Intrinsicなので、TypeProfileに依存しないコードを書ける。IdealのNodeを直に操作するイメージ。
(3) OpenJDKでJITコンパイルしたコードを逆アセンブルする
Oracle JVMでは不可能ですが、OpenJDKをDebug版でビルドすれば、
JITコンパイルしたアセンブリを出力することができます。
JVMの生成するアセンブリに興味のある方は試してみてください。
オプションは、-XX:+PrintOptoAssembly
C2コンパイラでコンパイルしたアセンブリを出力するオプションです。
ここからはOpenJDK8をfastdebugモードにビルドして使用しました。
openjdk version "1.8.0-internal-fastdebug"
OpenJDK Runtime Environment (build 1.8.0-internal-fastdebug-elise_2014_04_09_00_03-b00)
OpenJDK 64-Bit Server VM (build 25.0-b70-fastdebug, mixed mode)
全ログファイルは下記にありますので、生のアセンブリを見たいかたはどうぞ。。
openjdk_asm_directonly.log
逆順に見ていきます。まずはUnsafeから。
アセンブリではなく、似非C言語風に表現してみます。
//元ソース
public void run() {
int last = length - 9;
for(int i=0; i < last; i++) {
byte b = unsafe.getByte(base, address + i);
i++;
if(b < 0) {
v32 = unsafe.getInt(base, address + i);
i += 4;
} else {
v64 = unsafe.getLong(base, address + i);
i += 8;
}
}
}
//アセンブリは下記のような感じになってます。
int last = length - 9;
do {
R9 = base
R11 = address + i
R9 = movslq [R9 + R11] //byte load
R11 = base
PCX = address + i + 1
R11 = R11 + PCX
if (R9 < 0) {
R9 = movl [R11];
v32 = R9
i += 5
} else {
R11 = movq [R11];
v64 = R11
i += 9
}
i++;
} while(i
Unsafeのアセンブリはコンパクトなのでそのまま張り付けてみます。。
#r018 rsi:rsi : parm 0: DeserBenchmark$UnsafeRunnable:NotNull *
# -- Old rsp -- Framesize: 32 --
#r191 rsp+28: in_preserve
#r190 rsp+24: return address
#r189 rsp+20: in_preserve
#r188 rsp+16: saved fp register
#r187 rsp+12: pad2, stack alignment
#r186 rsp+ 8: pad2, stack alignment
#r185 rsp+ 4: Fixed slot 1
#r184 rsp+ 0: Fixed slot 0
020 B1: # B7 B2 <- BLOCK HEAD IS JUNK Freq: 1
020 subq rsp, #24 # Create frame
movq [rsp + #16], rbp # Save rbp
02c movl R10, #-9 # int
032 addl R10, [RSI + #32 (8-bit)] # int
036 testl R10, R10
039 jle B7 P=0.000000 C=49151.000000
039
03f B2: # B5 <- B1 Freq: 1
03f xorl R8, R8 # int
042 jmp,s B5
042
044 B3: # B4 <- B5 top-of-loop Freq: 500925
044
044 movl R9, [R11] # int
047
047 movl [RSI + #12 (8-bit)], R9 # int ! Field: DeserBenchmark$UnsafeRunnable.v32
04b addl R8, #5 # int
04f
04f B4: # B7 B5 <- B6 B3 top-of-loop Freq: 1e+06
04f incl R8 # int
052 testl rax, [rip + #offset_to_poll_page] # Safepoint: poll for GC # DeserBenchmark$U
# OopMap{rsi=Oop off=82}
058 cmpl R8, R10
05b jge B7 P=0.000000 C=49150.000000
05b
061 B5: # B3 B6 <- B2 B4 Loop: B5-B4 inner Freq: 1e+06
061 movl R9, [RSI + #36 (8-bit)] # compressed ptr ! Field: DeserBenchmark$UnsafeRunnable.base
065 movslq R11, R8 # i2l
068 addq R11, [RSI + #24 (8-bit)] # long
06c
06c movsbl R9, [R9 + R11] # byte
071
071 movl R11, [RSI + #36 (8-bit)] # compressed ptr ! Field: DeserBenchmark$UnsafeRunnable.ba
075 movl RBX, R8 # spill
078 incl RBX # int
07a decode_heap_oop R11,R11
102 movslq RCX, RBX # i2l
105 addq RCX, [RSI + #24 (8-bit)] # long
109 addq R11, RCX # ptr
10c testl R9, R9
10f jl B3 P=0.500926 C=49151.000000
10f
115 B6: # B4 <- B5 Freq: 499074
115
115 movq R11, [R11] # long
118
118 movq [RSI + #16 (8-bit)], R11 # long ! Field: DeserBenchmark$UnsafeRunnable.v64
11c addl R8, #9 # int
120 jmp B4
125 B7: # N105 <- B4 B1 Freq: 1
125 addq rsp, 16 # Destroy frame
popq rbp
testl rax, [rip + #offset_to_poll_page] # Safepoint: poll for GC
130 ret
えっと、見どころは特に無いですが、
(h) 範囲チェックみたいの一切やってない。
当然といえば当然かも。だからこそ速いのかも。
(i) 範囲チェックからの復帰コードみたいのがない。
当然といえば当然ですが、JVMがそのままSEGVするでしょうね。。
//OSR版のコードでは、関数冒頭でUnsafeRunnableのチェックとnull checkは入ってました。
お次はByteBufferのDirectのみ呼び出した場合のアセンブリなんですが、
規模がでかかったので、似非C言語で記述しています。
public void run() {
int last = src.limit() - 9;
for(int i=0; i < last; i++) {
byte b = src.get(i);
i++;
if(b < 0) {
v32 = src.getInt(i);
i += 4;
} else {
v64 = src.getLong(i);
i += 8;
}
}
}
大体下記のようなアセンブリが生成されていました。
R8 = this
if R8 != DirectByteBuffer goto deopt;
int last = src.limit() - 9;
do {
//B8 checkIndex 0<
//B9 checkIndex < limit
//B10
R13 = base
R8 = address + i
RSI = movslq [R13 + R8] //byte load
R11 = base
PCX = address + i + 1
RAX = R11 + PCX
if (RSI < 0) {
//B16 checkIndex 0<
//B17 checkIndex <4
//B18 nativeByteOrder flagチェックして必要ならばbswapl
R9 = movl [RAX];
v32 = R9
i += 5
} else {
//B11 checkIndex 0<
//B12 checkIndex <8
//B13 nativeByteOrder flagチェックして必要ならばbswapl
v64 = movq [RAX];
i += 9
}
//B7
i++;
} while(i
見どころは、
(j) 範囲チェックやnull check
所々範囲チェック(0以上、limitを越えていないか)が挿入されていました。
範囲チェックに違反した場合、uncommon_trapを呼び出してVMに制御を戻します。
(k) 実装クラスのチェック
JITコンパイルしたコードはDirectByteBuffer向けに生成されているため、
冒頭でDirectByteBufferかどうかのチェックが入っていました。
もしDirectByteBufferでなかった場合、uncommon_trapを呼び出してVMに制御を戻します。
チェックが挿入されているのは関数の冒頭だけでした。
(l) bswapl
なんじゃこりゃーと思ったら、どうもDirectByteBufferにはByteOrderを変更できるフラグがあり、
もしそのフラグが立っていたらbswaplが実行されるようなコードになってました。
--感想--
Unsafeは速いけど範囲チェックしない。
UnsafeはHotSpot依存なので、他の実装で動かす場合には問題になりそう。
//上記プログラムのUnsafe + directをOpenJDKで動かすとSEGVしました。Oracle JVMは問題なし。
代替策としては、JNIにしちゃうか、DirectByteBufferを使うのが良いかもしれない。
スポンサーサイト