日記というよりは過去を振り返るときのための単なる備忘録この広告は、90日以上更新していないブログに表示しています。2015-11-14jmhマイクロベンチマークツールを使ったByteBufferのバイトオーダによるパフォーマンスの違いの比較javaTweet(ヒープおよびダイレクトバッファによるByteBufferの性能差、およびUnsafeとのパフォーマンスも比較するよ!)(とりあえず計測結果を先に知りたい人は結論を見てね!)(ファイルのシーケンシャルリードを含むダイレクトバッファの使い方やパフォーマンス比較については次の日記に書きました。思うところあって、ヒープ上のbyte[]をラップしたByteBufferと、ダイレクトバッファを確保しているByteBufferの場合とのint型としての連続アクセスした場合のパフォーマンスの違いについて調べてみることにした。私の認識では、ダイレクトバッファへのアクセスは非ネイティブ側(つまりJavaコード側)からのアクセスではオーバヘッドが大きいため、特殊なシチュエーション以外では遅くなってしまうため、あまり使いどころのない機能だというものだったのだが、では、その使いどころとなる「特殊なシチュエーション」とは何か、ということについては、いまいちわからなかった。ByteBufferを連続的にintアクセスするような用途には、ヒープ上のバッファよりも、むしろ遅くなるのではないか?という漠然とした印象があった。実際にためしたところ、予想外にも、"ダイレクトバッファはリトルエンディアンのint型をバイト列から読み取るにはヒープ上のバッファよりもものすごく良いパフォーマンスが得られるかも?"という、個人的には非常に興味深い示唆に富む結果を得ることができた。そこで、ベンチマークツールの使い方の備忘録も兼ねて、ここに記録しておくことにする。JAVAのベンチマークは条件が少しでも変われば結果が大きく変わったりするので、あくまでも、「私が試したケース」ではという前提であり、また、どうして、このような結果になったかについては納得のゆく確証が得られていないため、もし、この記事を参考にすることがあるのならば、これを鵜呑みにせず、実際には個々のケースに応じてベンチマークをとるべきだろう。(その際には、ここで紹介するベンチマークツールjmhが有益だと思われる。"jmh"を久しぶりに使うことにした。このツールは、「まともに作るのが案外難しい」ベンチマークコードの作成をアシストして、さらにベンチマーク結果の統計情報(標準偏差や信頼区間)も計算して出してくれるようにしてくれる、大変便利なベンチマークジェネレータツールである。1年以上つかってなかったのだが、久しぶりに使ってみたら、ちょっと変化があって、古いコードとは、ほんの少し書き方が変わっているようだ。ネット上の資料でも古い書き方をしているものがあるので、その点は最新の書き方に改める必要がある。いえ、基本的な考え方や使い方などは変わっていない。jmhの概要や使い方については、少し古い記事だが以下のページが大変参考になる。Topics::Javaのマイクロベンチマークツール「JMH」(2013/11/07)CLOVER::マイクロベンチマークツール、JMHを試す(2014/01/02)(あとは公式ページと、沢山ある付属サンプルから、なんとかする。)jmhとは?jmhは、openjdkで開発されている、java用のマイクロベンチマークツールである。http://openjdk.java.net/projects/code-tools/jmh/JAVAではコンパイル時最適化や実行時最適化によってソースコード上から予想される動きよりも、はるかに最適化された挙動を示すことが多々ある。パフォーマンス比較するつもりで書いた単純なコードが実運用ではありえない形にアグレッシブに最適化されたり、あるいは逆に、通常ではJITによる最適化が行われるべきところをベンチマークの実行が浅くて最適化されてない状態で比較する、もしくは、評価したい部分とは関係ない部分でのパフォーマンスを計測に含んでしまうなど、頓珍漢なパフォーマンス比較になっていたりしたら、大変な判断ミスを犯すことになるだろう。このような最適化がされるような状況でベンチマークをとるのは、めんどくさいことは容易に想像できる。これらを比較的容易にベンチマークをとれるようにお膳立てしてくれるのが、jmhである。Guideただし、jmhはjunitのようなテストライブラリではない。jmhは、マイクロベンチマークを行うためのソースコードを自動生成して、その自動生成されたテストコードの上から、ユーザーが定義したベンチマークコードを実行するための、実行可能jarを生成する、いうなれば「テストベンチジェネレータ」ツールなのである。ベンチマークプロジェクトの準備jmhを使うのは簡単で、mavenから新規アーキタイプとして"jmh-java-benchmark-archetype"を指定してプロジェクトを作成すれば、必要なものは自動的に手に入るようになっている。<dependency><groupId>org.openjdk.jmh</groupId><artifactId>jmh-java-benchmark-archetype</artifactId><version>1.11.2</version></dependency>最近のLunaとかMarsあたりのEclipseであればmavenは初めから使えるようになっている。*1初期状態では、このアーキタイプはローカルマシン上のMavenリポジトリには登録されていないと思われるので、まずは、アーキタイプを登録しておく。(登録後、一度ダイアログを閉じて再度新規プロジェクト作成を開きなおすと、一覧に出てくるようになる。ここでは現時点の最新であるバージョン1.11.2を使っている。(最新はリポジトリを確認のこと。)こうしてプロジェクトが作成されるので、まずはpom.xmlを修正して、java8をターゲットに変えておく。当然ながら、javaのバージョンが違えばパフォーマンスも変わってくるだろう。初期状態で生成されたクラスは空のベンチマークであるが、ビルドすればパフォーマンスの計測は可能である。}}プロジェクト内にあるクラスがスキャンされ、@Benchmarkアノテーションがついているすべてのメソッドがベンチマークメソッドとして認識される。ちなみに、以前は@Benchmarkではなく、@GenerateMicroBenchmarkというアノテーションが使われていたように思う。)ただし、このベンチマークを実行するためには、まずはmavenによるビルドが必要である。先に説明したとおり、jmhはライブラリではなく、テストベンチジェネレータであり、mavenでビルドすることによりさまざまなファイルを自動生成する。これらのファイルがないとベンチマークを動かすことができない。package(画面キャプチャを取り損ねたので素のEclipseで撮りなおしています。)ビルドが完了すると、targetフォルダ上に実行可能なjar"benchmarks.jar"が生成されている。コマンドプロンプトを開き、これを実行することができる。以下はコマンド実行と、その結果である。ops/s>オプション引数は"-h"で確認できる。フォークx1、という指定である。(いうまでもなく、これは単なる動作確認で、計測するには不十分な回数である。)また、テストするクラス・メソッド名は正規表現で判断されるので、この場合、たとえば、"MyBenchmark"としても、それにマッチするすべてのベンチマークが実行されることになる。(何も指定しないと、すべてのベンチマークメソッドが実行される。ops/s」というのは、デフォルトのテストモードが「スループット計測モード」であるため、これは1秒間に何回テストベンチを処理できたかの回数を表すものである。(1イテレーションのベンチマークはデフォルトでは1秒間となっている。)(ベンチマークメソッドが空なので、ものすごい回数が回っている。99.9%信頼区間などの数値が計算される。一度mavenでビルドすると、テストベンチ用のソースコードも生成されているのでEclipseからも実行できるようになっている。Mainメソッドとして、ベンチマークを起動するために、以下のようなものを追加する。(少なくともクラスまたはメソッドのシグネチャを変更追加するたびにmvnでビルドする必要がある。}これで、このクラスを実行クラスに選択すれば、ベンチマークを実行することができるようになる。ベンチマーククラスまたはメソッド(およびメソッドの引数等のシグネチャ)を追加削除変更した場合には、再度、mavenによるビルドが必要となる。(その後、Eclipseのパッケージエクスプローラのリフレッシュを忘れずに。)※OptionsBuilderのincludeメソッドで実行するベンチマークの「クラス名.メソッド名」を正規表現で絞りこめるので、ベンチマークコードを試行錯誤している間は、メソッド名まで指定すると良いだろう。ByteBufferのパフォーマンス比較コードの作成ByteArrayには、ヒープ上のbyte[]配列をラップしたものと、ヒープ外に確保したダイレクトバッファによるものの2種類がある。ダイレクトバッファはヒープ外にあるため、OSなどネイティブなヒープ外のコードからアクセスする場合にはオーバヘッドがないかわりに、逆に、Javaのコードからアクセスするにはオーバヘッドが生ずる。このトレードオフを見極めるのが難しく、ダイレクトバッファを使えばなんでも速くなるけではない。(むしろ、普通のバイトバッファのように扱えば、大抵遅くなるだろう。)ただし、この2種類はインターフェイスは共通であり、同じようなコードで扱うことができる。今回は、バイトバッファからint値を連続して読み込むために以下のようなコードを用いる。}このコードは単純に、ByteBufferのバッファ上から、すべてのint値を読み込んでtotal変数に足しこんでいるだけである。最後に、total変数を8589869056Lという謎の数値と照合させているが、これはテストデータとして用意したバイト列を正しく読み出した場合に計算される値である。ヒープ上のbyte[]配列をByteBufferにラップしたものと、ダイレクトバッファの双方で、このコードを使って計測する。テストデータは以下のようにして生成している。(バッファサイズは今回は512KiBとしている。}テストの状態を保持する@Stateアノテーションの利用このテストデータを生成するタイミングをコントロールするために、jmhの@Stateという仕組みを用いる。前述のとおり、テストベンチメソッドは繰り返し大量に呼び出されるものなので、都度、テストデータを作成していては計測がおぼつかない。このため、テストベンチに対してテストの状態を保持するインスタンスを持たせることができるようになっている。@Stateというアノテーションがつけられたクラスを、テストベンチメソッドの引数にすることで、jmhテストベンチによってベンチマークメソッドが呼び出されるときに自動的に、そのインスタンスが引き渡されてくるようになる。ベンチマークメソッドの呼び出し毎の3種類のタイミングでメソッドを呼びすように設定できる。(前述のとおり、テストベンチメソッドは繰り返し大量に呼び出されるものなので、Invocationの使用には注意が必要となる。純粋にByteBufferをint値でアクセスする部分だけを大量に繰り返しテストできるようになる。テストベンチはマルチスレッドでテストすることもできるので、その場合、@StateアノテーションにScope.Threadを指定することにより、スレッドスコープとしてスレッド間の衝突なく使えるようにすることができる。Groupのスコープがある。(ウォームアップイテレーション、および計測イテレーション中には呼び出されない。}今回は、ByteBufferにつめるデータ形式として、JavaのデフォルトであるBigEndianと、Intel系CPUのデフォルトであるLittleEndianの2種類のデータを作成できるようにしている。(上記コード中のbyteOrder変数が、それにあたる。}Unsafeによるbyte[]へのint値としての直接アクセスの実験もしてみる今回はByteBufferでのアクセスの計測の他に、sun.misc.Unsafeというネイティブ的な動作をするAPI群があり、この中にオブジェクト上の任意のバッファをint値として読み取ることのできる機能があるらしいので、これも、ためしに使って計測してみる。Cもちろん、sun.misc.Unsafeは、非公開、非推奨のベンダ固有APIである。OpenJDKとSun系のJDKにはとりあえず現在は存在しており、各種有名どころのライブラリも使っていたりするらしい。(また、JavaVMまわりの制御を行えるので、特にロックまわりではsun.misc.Unsafeを使わないとどうしても実現できないようなものもあるといううわさも聞いた。}このコードはbyteを4バイト飛ばしでint値として無理やり読み込んでいるものである。byte配列の読み出しには、オフセットとしてsun.misc.Unsafe.ARRAY_BYTE_BASE_OFFSETが必要になるらしい。*3また、そもそもUnsafeを扱うためには、直接、Unsafeを取り出そうとするとヌルクラスローダ上のクラスからの呼び出しであるか判定して、システム外クラスからの呼び出しはセキュリティエラーにする実装となっているため、リフレクションを経由して無理やり取ってくる必要がある。Endianのint値をアクセスするコードのベンチマークもとってみることにする。https://www.jpcert.or.jp/java-rules/fio12-j.htmlベンチマークメソッドの定義これで、ヒープ/ダイレクトのByteBufferBigEndian/LittleEndianUnsafe#getIntによるintアクセスbyte[]の自力でのintアクセスの準備が整った。このテストベンチは以下のようになる。}ベンチマークの実行結果これでベンチマークを実行したところ、以下のような結果が得られた。E3-1270v2@3.5GHzの環境である。i7-3770@3.40GHzでも、ほぼ同じ結果となった。このベンチマークは512kibのバッファの読み込みを繰り返すものであり、上記スコア値はops/sの平均、つまり1秒間あたりの実行回数の平均なので、簡単に処理速度を求めるなら、単純に512kibをかければ良い。他の計測モードjmhでは計測モードとして、デフォルトのスループットの他に、平均時間、サンプルタイム、シングルショットタイムを選択できる。スループット(ops/s)はベンチマークを1秒間に何回実行したかを示すものである。平均時間は、これを時間に換算したものである。(ops/sで1を割れば時間が出てくる。)サンプルタイムは、実行時間の分散を調べるものであり、たとえば実行したベンチマークのうち、50%は最大で、どのぐらいの所要時間が必要だったか?また、実行したベンチマークの90%は最大で、どのくらい所要時間が必要だったか?といった値を見ることができる。結果としては最大、最小、平均の所要時間と標準偏差、サンプル総数などが得られるので、時間を計測するならばサンプルタイムのほうが良いかもしれない。シングルショットタイムは、イテレーションではなくバッチサイズで指定し、バッチサイズ分実行したトータルタイムを出す。(単純なパフォーマンス計測に向く?)以下はサンプルタイムで計測しなおした結果である。計測モードとして、サンプルタイムを指定し、単位はマイクロ秒とする。(コマンドラインから実行する場合は、オプションとして-bmオプションでモード指定する。us/op結果int型の場合まず、(驚いたことに)DirectBufferでのint型(32bit)のLittleEndianのデコードが、かなり速い。Endian)も、Unsafeとかわらないぐらい速い。これはバッファサイズが512kibの場合だが、16kibなど小さいバッファで比較しても同様である。(このくらい速ければ、わざわざ非推奨なベンダ依存のAPIを使う必要もなさそうである。)正直、この速さは意外だった。して速いのかは、よく分からない。Endianと同じだから変換不要で速くなるとかかな?と思ってみたものの、同じDirectByteBufferのBigEndianのケースでも、byteをラップしたヒープによるByteBufferよりも速いので、そうゆう問題ではないのかもしれない。Endianのintとしてアクセスするベンチマークとも比較してみたが、似たような性能となったので、特別にByteBufferの仕組みに問題があるわけではなさそう。StackOverflowで見つけた記事によると、ヒープのByteBufferとダイレクトのByteBufferでは、以下のように読み取り方法が違うらしい。単純にUnsafe#getIntで読み取れれば最速である。DirectBufferでは、もし、エンディアンが一致しない場合には、後続にswap処理が入る分、遅くなる。これがDirectBufferでのintアクセス時のリトルエンディアンとビッグエンディアンとのパフォーマンスの違いのようだ。Double型の場合ついでに、Double(8バイト)型の場合も計測してみた。ops/sBigEndianとLittleEndianとでのパフォーマンスの違いが見られなくなっているが、UnsafeとDirectBufferのパフォーマンスが同等であることはintの場合と同じようである。(BE/LEの違いが見られない理由はわからないのだが...)バッファサイズは512kibのままであり、double型は8バイト表現となるので、データの読み取り回数としてはintの場合に対して半分になるが、パフォーマンスとしてはダイレクトバッファを使うことにより、intの場合と遜色ない高速な読み取りができるように見える。ヒープ上のバッファからのdouble読み取りは、やはり遅いようだ。Float型の場合intと同様に4バイト表現であるfloatでは、以下のような結果になった。ops/sBigEndianとLittleEndianとでのパフォーマンスの違いが見られなくなっているが、UnsafeとDirectBufferのパフォーマンスが同等である傾向は同じようである。double同様に、ヒープからfloatを取得するよりも、ダイレクトバッファから直接floatを読み取ったほうが速い。doubleと比較すると、ダイレクトバッファの場合は、floatのほうがパフォーマンスが低いように見える。これはJavaでは内部的には実数がdouble型として扱われているところに起因するのだろうか?(根拠はないが...。get回数が効いているのかもしれない。ヒープ同士の比較では、doubleよりはfloatのほうがパフォーマンスは良いようである。Long型の場合long値(8バイト)の場合は、以下のようになった。ops/s(intよりもパフォーマンスが高くなったのでグラフの横幅が変わっていることに注意。)long型は8バイトなのでdouble型のケースと同様に、同じ512kibバッファサイズではintやfloatと比較して半分の項目数しかない。メソッドの呼び出し回数がパフォーマンスの大きな因子のような感じである。結論これらの結果をみるかぎり、整数、実数ともに連続的に大量の項目を読みとる必要がある場合は、ダイレクトバッファを使うのが良さそうである。(というより、このグラフを見てしまうとダイレクトバッファを使わないのは、もはや間違いといえるレベルかも...。)とりあえず、ファイル等から直接データを読み取る場合には、一旦バイト配列としてヒープにもってきてからint等に展開するよりも*4直接、ダイレクトバッファ上からint値などを取得したほうが速くなりそうな結果が得られたように思える。(ただし、本当にアテにしていいのかは、実際にコードを書いて比較するべきだろう。今後のI/Oまわりのパフォーマンスチューニングに使えそうな、とても有意義な勉強になったと思う。今回のソースコードは、以下のgistに置いた。(pom.xmlと、ベンチマークのjavaクラスのみ。それ以外のソースはmavenで自動生成されたものなので手は入れていない。追試する場合は、上記手順に従ってmavenからjmhのアーキタイプを指定してプロジェクトを構築してから、本ソースを追加すれば良い。)https://gist.github.com/seraphy/6276dbae1eb4ee96325d以上、メモ終了。http://openjdk.java.net/jeps/260*3:http://www.docjar.com/docs/api/sun/misc/Unsafe.html#objectFieldOffset*4:FileChannelからByteBuffer/MappedByteBufferなどを使ってダイレクトバッファを取得できる。パフォーマンス計測を追加し内容も少し訂正しました。(1)はてなブログをはじめよう!seraphyさんは、はてなブログを使っています。ブログを報告するスターをつけました引用をストックしましたストック一覧を見る閉じる引用するにはまずログインしてくださいログイン閉じる引用をストックできませんでした。再度お試しください閉じる限定公開記事のため引用できません。