Javaのダイレクトバッファの利用上の注意点と、各種バッファを使った読み込みパフォーマンスの比較
概要
前回では、ヒープ上に確保したByteBufferと、ダイレクトバッファとして作成したByteBufferでのデータの読み取りパフォーマンスを比較した結果、圧倒的にダイレクトバッファが速いことが分かった。
しかし上記ベンチマークは、あえてByteBuffer単体でのパフォーマンスの比較を行ったものであり、実際のファイル入出力処理を含んだ比較ではない。
いわば、純粋なByteBufferの性能比較である。
では、実際にファイルの読み取りを含んだ場合でも、はたしてダイレクトバッファを使ったほうが速いのであろうか?
今回は、実際の利用方法を意識して、このあたりを調べてみることとする。
ヒープバッファとダイレクトバッファの違い
ByteBufferには大きく分けて2種類がある。
- ヒープ上の作成されるヒープバッファ
- ネイティブ領域に作成されるダイレクトバッファ
ヒープバッファは以下のように作成される。
ByteBuffer buf = ByteBuffer.alloc(siz); // または ByteBuffer buf = ByteBuffer.wrap(new byte[siz]);
(allocは単純に内部でnew byte[]を実行しているだけである。)
これは、当然、Javaのヒープ領域からバッファが割り当てられる。
バッファとして確保した分、利用可能なヒープサイズは減ることになる。
その特性はJavaの一般的なオブジェクトと何ら変わることは無い。
これに対して、ダイレクトバッファは以下のように作成される。
ByteBuffer buf = ByteBuffer.allocDirect(siz);
(そのほか、RandomAccessFileから直接MappedByteBufferを取得する方法もある。)
こちらはJavaのヒープではなく、ネイティブメモリ上から割り当てられる。
ダイレクトバッファはヒープ上にバッファは作らないので、ヒープはほとんど減らない。
そのかわり、ネイティブメモリ、つまりOS側からJavaVMが借りているコミットメモリが増える。
ネイティブメモリはガベージコレクタが管理しているメモリの範囲外であるため、ガベージコレクトなどのメカニズムが効かない領域である。
実験コード
public class BufferAllocExample { /** * バイトバッファをallocする方法 */ public enum ByteBufferAllocStrategy { /** * ヒープのByteBufferを構築する. */ heap() { @Override public ByteBuffer alloc(int siz) { return ByteBuffer.allocate(siz); } }, /** * ダイレクトのByteBufferを構築する. */ direct() { @Override public ByteBuffer alloc(int siz) { return ByteBuffer.allocateDirect(siz); } }; public abstract ByteBuffer alloc(int siz); } /** * エントリポイント. * 第一引数にheapかdirectを指定する. * @param args * @throws Exception */ public static void main(String... args) throws Exception { ByteBufferAllocStrategy allocator = ByteBufferAllocStrategy .valueOf(args[0]); Runtime rt = Runtime.getRuntime(); List<ByteBuffer> buffers = new ArrayList<>(); // 10 MiBの割り当てを10回繰り返す for (int idx = 0; idx < 10; idx++) { System.gc(); System.out.println(rt.freeMemory() + "/" + rt.totalMemory()); ByteBuffer buf = allocator.alloc(1024 * 1024 * 10); buffers.add(buf); } // 100 MiB割り当て後のヒープの残量 System.gc(); System.out.println(rt.freeMemory() + "/" + rt.totalMemory()); // OSのコミットサイズ、プライベートワーキングセットのサイズを // タスクマネージャで見られるように、アプリ終了前に一旦停止する。 System.console().readLine(); } }
第一引数にallocかallocDirectを指定して実行すると、10 MiBごとのバッファを確保しながらヒープの残量を表示し、最後に入力待ちになって停止するアプリである。
ヒープバッファ確保の場合
>java -Xms128m -Xmx128m -cp classes jp.seraphyware.jmhexample.BufferAllocExample heap 127831384/128974848 118026720/128974848 107540896/128974848 97055072/128974848 86569248/128974848 76083424/128974848 65597600/128974848 55111776/128974848 44625952/128974848 34140128/128974848 23654304/128974848
バッファを確保するたびにヒープがどんどん減ってゆく順当な動きである。
このときのタスクマネージャで取得したOSから予約されたコミットサイズは、
- コミットサイズ = 184,444 k
となっていた。
ダイレクトバッファの場合
>java -Xms128m -Xmx128m -cp classes jp.seraphyware.jmhexample.BufferAllocExample direct 127831360/128974848 128512256/128974848 128512120/128974848 128511984/128974848 128511848/128974848 128511712/128974848 128511576/128974848 128511440/128974848 128511304/128974848 128511168/128974848 128511032/128974848
ヒープはぜんぜん減っていない。
このときのタスクマネージャで取得したOSから予約されたコミットサイズは、
- コミットサイズ = 286,676 k
となっている。
ヒープバッファのときと比較して、コミットサイズ = OSからJavaVMが予約しているメモリとしては確実に100 MB(102,232 k)ほど増えている。
ダイレクトバッファの最大サイズの指定方法
ちなみに、ダイレクトバッファの最大値はJVMのオプション"-XX:MaxDirectMemorySize "のように明示的に設定することができる。
省略した場合は自動(≒ 制限なし)で、既定ではヒープの最大値と同じようになるため、通常は気にしなくてもよいらしい。
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
ただし、最大ヒープサイズを超えてダイレクトバッファサイズを指定することもできるらしく、以下のように最大ヒープサイズを64 MBにしても、それを超える100 MBのダイレクトバッファを確保することが可能であった。(Java8u60で確認。)
>java -Xms64m -Xmx64m -XX:MaxDirectMemorySize=512m -cp classes jp.seraphyware.jmhexample.BufferAllocExample direct 63689944/64487424 64020424/64487424 63684664/64487424 63684528/64487424 63684392/64487424 63684256/64487424 63684120/64487424 63683984/64487424 63683848/64487424 63683712/64487424 63683656/64487424
バッファ確保のパフォーマンス比較
ヒープ上のByteBufferは、単に普通にヒープ上でnew byte[…]するだけのオーバーヘッドと同等である。
ヒープメモリは事前にJavaVMがOSよりメモリをもらっており、そこでJavaVMが領域を割り当てるだけである。
これに対して、ダイレクトバッファはネイティブ領域に都度、OSよりバッファを確保してもらっているような動きになっている。
(実際は、もう少し効率は良いかもしれないが、前述の実験からすると、要求されてから都度、OSからメモリをもらっている感じである。)
両者はメモリの確保方法が異なるため、確保するまでにかかる処理時間も異なると予想される。
ベンチマーク
そこで、以下のようなベンチマークコードを書いて計測してみた。
public class BufferAllocBenchmark { @State(Scope.Thread) public static class BufferAllocContext { private ByteBuffer buf; @Setup(Level.Trial) public void setup() { } public void setByteBuffer(ByteBuffer buf) { this.buf = buf; } public ByteBuffer getButeBuffer() { return this.buf; } } @Benchmark public void testAllocDirect8(BufferAllocContext ctx) { ctx.setByteBuffer(ByteBuffer.allocateDirect(8 * 1024)); } @Benchmark public void testAllocHeap8(BufferAllocContext ctx) { ctx.setByteBuffer(ByteBuffer.allocate(8 * 1024)); } … (中略) … }
ByteBuffer.alloc()と、ByteBuffer.allocDirect()をバッファサイズを変えて計測している。
計測するバッファサイズは、8, 16, 32, 64, 128, 256, 512, 1024 kibである。
(過度な最適化が起きないように、得られたByteBufferは状態インスタンスに1世代のみ保存している。)
Javaはjdk1.8u60で実行し、オプションとして-Xmx128m -Xmx128m -XX:MaxDirectMemorySize=256m
を指定したものである。
計測結果
size | Direct | Heap | Direct Error | Heap Error |
---|---|---|---|---|
8 | 183090.216 | 2018841.709 | 320.863 | 49630.037 |
16 | 93116.245 | 1096969.848 | 191.754 | 8257.477 |
32 | 47854.178 | 547637.302 | 82.986 | 13006.356 |
64 | 22689.129 | 266632.138 | 224.02 | 5163.652 |
128 | 10988.465 | 69412.152 | 518.684 | 864.918 |
256 | 5536.215 | 33852.752 | 223.108 | 84.935 |
512 | 2897.219 | 28651.803 | 24.236 | 406.872 |
1024 | 1574.254 | 14013.506 | 51.023 | 104.658 |
数値は、1秒あたりのベンチマークメソッドの実行回数の平均である。
激しくガベージコレクタが動作することが予想されるので、gcによる揺らぎを考慮して測定回数(イテレーション)は50回まわすことにした。
これをチャート化すると、以下のようになる。
チャート化するにあたり、数値は実行回数ではなく確保したバイト数に換算している。
(= 実行した回数 x 確保したバイト数)
これにより、バッファ確保の効率を比較できるようにしている。
これを見ると、明らかにダイレクトバッファはヒープバッファよりも時間がかかっている。
差が10倍ほど開いてしまったので、縦軸は対数表示している。
ダイレクトバッファは、確保するまでの時間が、むちゃくちゃ遅い。
この点については、そもそもJavaDocに書かれている。
ダイレクト byte バッファーは、このクラスのファクトリメソッド allocateDirect を呼び出すと作成されます。通常は、こちらのバッファーのほうが、非ダイレクトバッファーよりも割り当ておよび解放コストがやや高くなります。ダイレクトバッファーの内容が標準のガベージコレクトされたヒープの外部にあるなら、アプリケーションのメモリーフットプリントに対する影響はわずかです。このことから、ダイレクトバッファーには、基本となるシステム固有の入出力操作に従属する、寿命が長く容量の大きいバッファーを指定することをお勧めします。一般に、ダイレクトバッファーの割り当ては、プログラムの性能を十分に改善できる見込みがある場合にのみ行うべきです。
http://docs.oracle.com/javase/jp/8/docs/api/java/nio/ByteBuffer.html
チャートを見ると揺らぎがあるが、だいたいメモリ確保のスピードは確保するバッファサイズに係わらず、ほぼ一定のように思われるので、バッファが小さければ、あるいは、大きければバッファ確保が速くなるとか、そうゆうことはなさそうである。
いずれにしても、ヒープとのダイレクトバッファの確保時のパフォーマンスの差はかなり大きい。
DirerctByteBufferの連続確保と破棄の問題点
ヒープ上のbyte[]配列であるヒープバッファは連続確保・破棄したとしても、その動きは通常のJavaオブジェクトと変わらないことが予想される。
また、OSから見た使用メモリには増減は発生しないはずである。
(すでに確保済みのヒープ内で処理されるため。)
これに対して、ダイレクトバッファはOSより都度メモリを借り、そのメモリを解放するためには、そのダイレクトバッファのハンドルをもつヒープ上のByteBufferオブジェクトがgcされるまで待たなければならないはずである。
(ByteBufferのメソッドにはI/O処理につきものの「close」のようなメソッドがないため、バッファが使用中であるかどうかを判断するにはgcによるしかない。)
また、OSから見た使用メモリは、ダイレクトバッファは確保されるたびにコミットサイズが増加し、gcが発生するたびに解放されるような動きを示すはずである。
実験
そこで、以下のようなコードを書いて試してみた。
public class BufferStressExample2 { /** * 最新のバイトバッファを保持するスレッドローカル */ public static ThreadLocal<ByteBuffer> lastBufTLS = new ThreadLocal<>(); /** * バイトバッファをallocする方法 */ public enum ByteBufferAllocStrategy { /** * ヒープのByteBufferを構築する. */ heap() { @Override public ByteBuffer alloc(int siz) { return ByteBuffer.allocate(siz); } }, /** * ダイレクトのByteBufferを構築する. */ direct() { @Override public ByteBuffer alloc(int siz) { return ByteBuffer.allocateDirect(siz); } }; public abstract ByteBuffer alloc(int siz); } /** * バイトバッファの破棄方法. */ public enum ByteBufferDeallocStrategy { /** * 何もしない.システムにお任せ. */ none() { @Override public void dealloc(ByteBuffer buf) { } }, /** * 明示的にgcを呼び出す. */ gc() { @Override public void dealloc(ByteBuffer buf) { System.gc(); } }, /** * 明示的にcleanerを呼び出す. */ cleaner() { @Override public void dealloc(ByteBuffer buf) { if (buf != null && buf.isDirect()) { destroyDirectByteBuffer(buf); } } }; public abstract void dealloc(ByteBuffer buf); } /** * エントリポイント * @param args * @throws Exception */ public static void main(String... args) throws Exception { ByteBufferAllocStrategy allocator = ByteBufferAllocStrategy.valueOf(args[0]); ByteBufferDeallocStrategy deallocator; if (args.length > 1) { deallocator = ByteBufferDeallocStrategy.valueOf(args[1]); } else { deallocator = ByteBufferDeallocStrategy.none; } int mxthread = (args.length > 2) ? Integer.parseInt(args[2]) : 2; // 10 MiBの割り当てを永久に繰り返すジョブ Runnable job = () -> { for (;;) { try { // ByteBufferの構築 ByteBuffer buf = allocator.alloc(10 * 1024 * 1024); // 10 MiB // ByteBufferの破棄 deallocator.dealloc(lastBufTLS.get()); // 最後に確保したByteBufferの保存 lastBufTLS.set(buf); } catch (Exception ex) { throw new RuntimeException(ex); } } }; IntFunction<Thread> makeThread = (idx) -> { Thread t = new Thread(job); t.setDaemon(false); t.setName("bufferStress:" + idx); return t; }; IntStream.range(0, mxthread) .mapToObj(makeThread) .forEach(Thread::start); } }
JDK1.8u60で実行した。
テストコードでは2スレッドの同時バッファ確保を行っている。
またコマンドは以下のオプションで実行した。
java -cp classes -Xms128m -Xmx128m -XX:MaxDirectMemorySize=256m jp.seraphyware.jmhexample.BufferStressExample2 heap
ヒープによるByteBufferの連続確保と破棄
以下はWindowsのパフォーマンスモニタによるPage File Bytes(≒コミットサイズ)と、ワーキングセットサイズの推移グラフである。
(縦軸の単位は10 MiBである。以下のパフォーマンスモニタのチャートはすべて同様である。)
new byte[]の破棄と解放はJavaVMの中の出来事であり、OSから見てメモリ消費の増減はないのが分かる。
ダイレクトバッファによるByteBufferの連続確保と破棄と、OutOfMemory問題
このテストコードでは、ダイレクトバッファは1回につき10 Mbytesを割り当て、直近の1つのByteBufferだけを保存し、それ以前のByteBufferは参照がなくなりガベージコレクト対象としている。
これが2スレッドで動いているので、理想的にはダイレクトバッファは1世代前の10 Mbytes x 2と、作成されたばかりの10 Mbytes x 2の、計40 Mbytesのバッファ以外は不要のはずである。
しかし、パフォーマンスモニタによると、瞬間的には450 Mbytesほどのコミットサイズになっている。(ヒープサイズが128MB、これに加えてMaxDirectMemorySizeが256 MBなので、384 MB+α ぐらいが最大値になっていると思われる。)
つまり、不要になったネイティブメモリは、すぐには消えていない。
また、このテストでは、MaxDirectMemorySizeで十分なサイズを指定しておかないと、「OutOfMemoryError: Direct buffer memory」とメモリ不足エラーが発生しやすくなる。
また、スレッド数を増やすと、如実にOutOfMemoryErrorが発生するようになる。
実際には10 Mbytesしか要求しておらず、最大バッファサイズが256 MBもあり、十分に空きがあると考えられるにも係わらず、である。
おそらく確保するスピードが解放するスピードを上回っているのだろう。
ダイレクトバッファでOutOfMemoryを避ける方法
原理的には、ByteBufferがgcされるまでネイティブに確保されたダイレクトバッファも解放されないであろうため、最大ダイレクトメモリサイズよりヒープが大きい場合は、場合によってはヒープのgcが発生する前にダイレクトメモリが枯渇する可能性があるように思われる。(まさか、そんなナイーブな実装ではないとは思うし、OutOfMemoryをあげる前にgcは試行されるはずだが…。)
現実に、Windows7/8.1上のjdk1.8u60では、MaxDirectMemorySizeが十分に大きくないと、このテストでは、すぐにOutOfMemoryが発生する状況である。
System.gc()によるOutOfMemoryの回避
ダイレクトバッファを確保する直前にSystem.gc()をしてあげると、この事象はでてこない。
ByteBufferがgcされ、ネイティブの解放も逐次行われて、パフォーマンスモニタの波形も比較的穏やかなものになる。
ただし、はたして実運用コードの中に「System.gc()」のようなコードを書いて良いものか?
(あるいは、-XX:+DisableExplicitGC オプションによりgcが無視される可能性もある。)
、というような課題はある。
DirectBuffer内部のcleanerを直接使用した早期解放を使う場合
実は、どうやらダイレクトバッファの破棄が遅延されてOutOfMemoryErrorが発生する問題は、かなりメジャーな問題のようで、Stackoverflowあたりを検索すると沢山出てくる。
System.gc()を使うのが、おそらくもっとも正式な方法だが、DirectByteBufferが内部で持っているcleanerメソッドを呼び出して、それに対してcleanを実行することで、ネイティブのメモリ破棄処理を強制的に早期に実行させる、という方法もあるようである。
以下に引用する、ApacheのHadoopのソースにも、ダイレクトバッファを破棄するためのコードが書かれている。
(および、どうしてダイレクトバッファの破棄が遅延するのかの理由も。)
/** * DirectByteBuffers are garbage collected by using a phantom reference and a * reference queue. Every once a while, the JVM checks the reference queue and * cleans the DirectByteBuffers. However, as this doesn't happen * immediately after discarding all references to a DirectByteBuffer, it's * easy to OutOfMemoryError yourself using DirectByteBuffers. This function * explicitly calls the Cleaner method of a DirectByteBuffer. * * @param toBeDestroyed * The DirectByteBuffer that will be "cleaned". Utilizes reflection. * */ public static void destroyDirectByteBuffer(ByteBuffer toBeDestroyed) throws IllegalArgumentException, IllegalAccessException, InvocationTargetException, SecurityException, NoSuchMethodException { Preconditions.checkArgument(toBeDestroyed.isDirect(), "toBeDestroyed isn't direct!"); Method cleanerMethod = toBeDestroyed.getClass().getMethod("cleaner"); cleanerMethod.setAccessible(true); Object cleaner = cleanerMethod.invoke(toBeDestroyed); Method cleanMethod = cleaner.getClass().getMethod("clean"); cleanMethod.setAccessible(true); cleanMethod.invoke(cleaner); }
このコメントによると、参照がすべて切れたからといって、ただちにネイティブメモリが解放されるわけではなく、ダイレクトバッファはファントムリファレンスによって保持され、そのリファレンスキューをJavaVMがチェックして、gcによりキューに入れられたバッファがあれば、そのネイティブメモリを解放しているようである。
このコメントから察するに、タイミングは不明だが、ともかくリファレンスキューを見に行く必要のあるパッシブな動きになっているのだろう。
ためしに、このcleanerを呼び出すコードを使ってみると、パフォーマンスモニタの波形は、大変穏やかなものとなり、スレッド数を増やしてもOutOfMemoryも発生しなくなる。
ただし、はたして実運用コードの中にJavaVMの特定の実装にべったり依存するかのようなコードを書いてよいものかどうか、そのあたりの課題は残る。
ヒープバッファとダイレクトバッファのファイルの読み込み速度の比較
以上の考察から、ダイレクトバッファを使うには2つ注意点があることが分かった。
- ダイレクトバッファは確保するのが遅い。
- ダイレクトバッファは破棄するのが難しい。
つまり、ファイルを開くたびに、ダイレクトバッファを毎回確保するような方法では、バッファ確保時のオーバーヘッドと、バッファ破棄のトラブルによって、あまり良いパフォーマンスは得られない可能性がある。
しかし、「単にファイルをシーケンシャルに読み込みたい」という、ありふれたニーズであれば、これを対策するのは比較的容易である。
単に、ダイレクトバッファは確保したらキャッシュし、破棄しなければ良いだけである。
(同時アクセスするファイルの最大数分だけダイレクトバッファをキャッシュすれば良い。)
*1
これを実装した上で、ダイレクトバッファとヒープバッファでのファイル読み取りのパフォーマンスを比較してみたいと思う。
実験内容
- テストデータはテンポラリ上に作成される10 Mbytesのファイルである。
- テストデータはLittle EndianとBig Endianの2種類作成する
- データはIntとDoubleの2種類作成する。
- バッファサイズは、8, 16, 32, 64, 128, 256, 512, 1024 KiB
- 読み取り方式として、
- ウォームアップとして10回、計測として10回のイテレーションを行う
なお、ウォームアップを10回も繰り返すと、10 MBytes程度のファイルならOSのファイルキャッシュに確実に乗っかることになる。
しかし、今回はディスクの性能を比較するわけではないので、これで良いものと考える。
ある意味、理想的なディスクを使った場合の読み取り方式の違いによるパフォーマンスの違いを計測するようなものといえるだろう。
データ作成コード
(Intデータ部のみ。Doubleデータのソースコードの記載は省略)
データ共通部
/** * テスト中にスレッドごとベンチマークごとの状態を保持するインスタンス. * * @author seraphy */ @State(Scope.Thread) public abstract class AbstractFileBenchContext { protected final ByteOrder byteOrder; protected final int bufsiz; private Path tempFile; private ByteBuffer bufHeap; private ByteBuffer bufDirect; protected AbstractFileBenchContext(ByteOrder byteOrder, int bufsiz) { this.byteOrder = byteOrder; this.bufsiz = bufsiz; } /** * データの初期化. * Trialの場合、スレッドごとベンチマークの開始ごとに一度呼び出される.<br> * (ウォームアップイテレーション、および計測イテレーション中には呼び出されない。) * (Trialがデフォルト、そのほかにIteration, Invocationが指定可能.) */ @Setup(Level.Trial) public void setup() throws IOException { // バッファの確保 (ヒープ) byte[] data = new byte[bufsiz]; bufHeap = ByteBuffer.wrap(data); bufHeap.order(byteOrder); // バッファの確保 (ダイレクト) bufDirect = ByteBuffer.allocateDirect(data.length); bufDirect.order(byteOrder); // テストデータファイルの作成 tempFile = initTempFile(); } protected abstract Path initTempFile() throws IOException; /** * スレッドごとベンチマーク終了ごとに呼び出される. */ @TearDown public void teardown() { try { //System.out.println("★delete tempFile=" + tempFile); Files.deleteIfExists(tempFile); } catch (Exception ex) { ex.printStackTrace(); } } public Path getTempFile() { return tempFile; } public abstract void verify(Object actual); public ByteBuffer getBufDirect() { return bufDirect; } public ByteBuffer getBufHeap() { return bufHeap; } }
テストごとのデータ部(一部)
public static class BenchContextBase extends AbstractFileBenchContext { public static final int reqSize = 1024 * 1024 * 10; // 10 MiBytes private long total; protected BenchContextBase(ByteOrder byteOrder, int bufsiz) { super(byteOrder, bufsiz); } /** * テストデータファイルの作成 * @throws IOException */ @Override protected Path initTempFile() throws IOException { Path tempFile = Files.createTempFile("filebufferbench", ".tmp"); //System.out.println("★create tempFile=" + tempFile); ByteBuffer buf = ByteBuffer.allocate(bufsiz); buf.order(byteOrder); total = 0; int capacity = buf.capacity(); long siz; try (FileChannel channel = (FileChannel) Files .newByteChannel(tempFile, CREATE, TRUNCATE_EXISTING, WRITE)) { int idx = 0; int mx = reqSize / capacity; for (int loop = 0; loop < mx; loop++) { buf.clear(); while (buf.position() < capacity) { buf.putInt(idx); total += idx; idx += 1; } buf.flip(); channel.write(buf); } siz = channel.size(); } if (reqSize != siz) { throw new RuntimeException(); } //System.out.println("★total=" + total + "/size=" + siz); return tempFile; } @Override public void verify(Object actual) { if ((Long) actual != total) { throw new RuntimeException("actual=" + actual); } } } public static class BenchContextLE8k extends BenchContextBase { public BenchContextLE8k() { super(ByteOrder.LITTLE_ENDIAN, 1024 * 8); } } public static class BenchContextBE8k extends BenchContextBase { public BenchContextBE8k() { super(ByteOrder.BIG_ENDIAN, 1024 * 8); } }
※ このあと、バッファサイズ、LE/BEごとのコンテキストクラスが延々と続く。
テスト実行部
FileChannelを使ってByteBufferでファイルの中身を取り出しテストする部分。
/** * バイトバッファからint値を読み出すテスト * @param buf */ private static long runByteBuffer(FileChannel channel, ByteBuffer buf) throws IOException { long total = 0; for (;;) { buf.clear(); int len = channel.read(buf); if (len < 0) { break; } buf.flip(); int limit = buf.limit(); while (buf.position() < limit) { total += buf.getInt(); } } return total; }
データファイルを読み取り、ファイルチャネルを取り出して中身を走査する。
private static void doBench(AbstractFileBenchContext ctx, ByteBuffer buf) throws IOException { try (FileChannel channel = (FileChannel) Files.newByteChannel( ctx.getTempFile(), READ)) { ctx.verify(runByteBuffer(channel, buf)); } }
ヒープバッファ、ダイレクトバッファによるテスト
@Benchmark public void testHeapBufferLE8k(BenchContextLE8k ctx) throws IOException { doBench(ctx, ctx.getBufHeap()); } @Benchmark public void testDirectBufferLE8k(BenchContextLE8k ctx) throws IOException { doBench(ctx, ctx.getBufDirect()); }
旧式のストリーム系APIによる読み取り
FileInputStream → BufferedInputStream → DataInputStream の連携でデータを読み取るテスト。
DataInputStreamはBigEndian専用なので、BigEndianのデータのみテストする。
DataInputStreamは一般的にいって使用頻度が高いとはいえないと思うが、byte[]からint値を取り出すための、昔からある、由緒正しい方法の1つなので、とりあえず、これで計測してみる。
private static long readInts(InputStream is, int cnt) throws IOException { long total = 0; try (DataInputStream dis = new DataInputStream(is)) { for (int idx = 0; idx < cnt; idx++) { total += dis.readInt(); } } return total; } @Benchmark public void testOldBufferedIO8k(BenchContextBE1m ctx) throws IOException { long total; try (BufferedInputStream bis = new BufferedInputStream( new FileInputStream(ctx.getTempFile().toFile()))) { total = readInts(bis, BenchContextBase.reqSize / 4); } ctx.verify(total); } @Benchmark public void testOldBufferedNIO8k(BenchContextBE1m ctx) throws IOException { long total; try (BufferedInputStream bis = new BufferedInputStream( Files.newInputStream(ctx.getTempFile()))) { total = readInts(bis, BenchContextBase.reqSize / 4); } ctx.verify(total); }
なお、Java7から、FilesクラスのnewInputStreamメソッドを使って、簡易にファイルを開くことができるようになったが、従来のFileInputStreamのコンテストラクタで開く場合と変わりないのか気になったので、そのテストも追加している。
また、FileInputStreamをBufferedInputStreamでラップしなかった場合、パフォーマンスの低下がどの程度なものなのか気になったので、こちらもテストを追加している。
計測結果
Intデータの読み取り
Benchmark Mode Cnt Score Error Units FileBufferAccessIntBenchmark.testDirectBufferBE128 thrpt 10 312.589 ± 4.157 ops/s FileBufferAccessIntBenchmark.testDirectBufferBE16 thrpt 10 242.295 ± 3.186 ops/s FileBufferAccessIntBenchmark.testDirectBufferBE1m thrpt 10 317.480 ± 2.527 ops/s FileBufferAccessIntBenchmark.testDirectBufferBE256 thrpt 10 313.295 ± 8.739 ops/s FileBufferAccessIntBenchmark.testDirectBufferBE32 thrpt 10 294.236 ± 1.012 ops/s FileBufferAccessIntBenchmark.testDirectBufferBE512 thrpt 10 312.779 ± 5.954 ops/s FileBufferAccessIntBenchmark.testDirectBufferBE64 thrpt 10 307.159 ± 4.509 ops/s FileBufferAccessIntBenchmark.testDirectBufferBE8k thrpt 10 202.106 ± 0.781 ops/s FileBufferAccessIntBenchmark.testDirectBufferLE128 thrpt 10 364.260 ± 10.193 ops/s FileBufferAccessIntBenchmark.testDirectBufferLE16 thrpt 10 264.034 ± 0.895 ops/s FileBufferAccessIntBenchmark.testDirectBufferLE1m thrpt 10 360.495 ± 13.502 ops/s FileBufferAccessIntBenchmark.testDirectBufferLE256 thrpt 10 362.586 ± 9.963 ops/s FileBufferAccessIntBenchmark.testDirectBufferLE32 thrpt 10 327.563 ± 4.147 ops/s FileBufferAccessIntBenchmark.testDirectBufferLE512 thrpt 10 359.784 ± 15.424 ops/s FileBufferAccessIntBenchmark.testDirectBufferLE64 thrpt 10 355.960 ± 9.823 ops/s FileBufferAccessIntBenchmark.testDirectBufferLE8k thrpt 10 221.521 ± 0.949 ops/s FileBufferAccessIntBenchmark.testHeapBufferBE128 thrpt 10 144.247 ± 0.341 ops/s FileBufferAccessIntBenchmark.testHeapBufferBE16 thrpt 10 132.583 ± 0.299 ops/s FileBufferAccessIntBenchmark.testHeapBufferBE1m thrpt 10 162.469 ± 0.401 ops/s FileBufferAccessIntBenchmark.testHeapBufferBE256 thrpt 10 162.569 ± 0.869 ops/s FileBufferAccessIntBenchmark.testHeapBufferBE32 thrpt 10 135.383 ± 8.522 ops/s FileBufferAccessIntBenchmark.testHeapBufferBE512 thrpt 10 158.935 ± 1.510 ops/s FileBufferAccessIntBenchmark.testHeapBufferBE64 thrpt 10 136.175 ± 1.539 ops/s FileBufferAccessIntBenchmark.testHeapBufferBE8k thrpt 10 110.557 ± 1.032 ops/s FileBufferAccessIntBenchmark.testHeapBufferLE128 thrpt 10 139.430 ± 0.641 ops/s FileBufferAccessIntBenchmark.testHeapBufferLE16 thrpt 10 141.571 ± 0.264 ops/s FileBufferAccessIntBenchmark.testHeapBufferLE1m thrpt 10 161.376 ± 1.468 ops/s FileBufferAccessIntBenchmark.testHeapBufferLE256 thrpt 10 164.129 ± 0.593 ops/s FileBufferAccessIntBenchmark.testHeapBufferLE32 thrpt 10 139.513 ± 1.686 ops/s FileBufferAccessIntBenchmark.testHeapBufferLE512 thrpt 10 164.563 ± 0.675 ops/s FileBufferAccessIntBenchmark.testHeapBufferLE64 thrpt 10 142.430 ± 0.715 ops/s FileBufferAccessIntBenchmark.testHeapBufferLE8k thrpt 10 115.688 ± 0.349 ops/s FileBufferAccessIntBenchmark.testOldBufferedIO128k thrpt 10 21.981 ± 0.201 ops/s FileBufferAccessIntBenchmark.testOldBufferedIO16k thrpt 10 21.746 ± 0.176 ops/s FileBufferAccessIntBenchmark.testOldBufferedIO32k thrpt 10 21.734 ± 0.249 ops/s FileBufferAccessIntBenchmark.testOldBufferedIO64k thrpt 10 21.992 ± 0.109 ops/s FileBufferAccessIntBenchmark.testOldBufferedIO8k thrpt 10 21.298 ± 0.168 ops/s FileBufferAccessIntBenchmark.testOldBufferedNIO128k thrpt 10 22.111 ± 0.117 ops/s FileBufferAccessIntBenchmark.testOldBufferedNIO16k thrpt 10 21.814 ± 0.188 ops/s FileBufferAccessIntBenchmark.testOldBufferedNIO32k thrpt 10 22.020 ± 0.252 ops/s FileBufferAccessIntBenchmark.testOldBufferedNIO64k thrpt 10 22.072 ± 0.300 ops/s FileBufferAccessIntBenchmark.testOldBufferedNIO8k thrpt 10 21.420 ± 0.185 ops/s FileBufferAccessIntBenchmark.testOldPlainIO thrpt 10 0.077 ± 0.001 ops/s FileBufferAccessIntBenchmark.testOldPlainNIO thrpt 10 0.072 ± 0.001 ops/s
Doubleデータの読み取り
Benchmark Mode Cnt Score Error Units FileBufferAccessDoubleBenchmark.testDirectBufferBE128 thrpt 10 350.916 ± 2.222 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferBE16 thrpt 10 273.792 ± 2.641 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferBE1m thrpt 10 350.331 ± 2.718 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferBE256 thrpt 10 351.351 ± 3.570 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferBE32 thrpt 10 322.476 ± 1.697 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferBE512 thrpt 10 347.777 ± 5.238 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferBE64 thrpt 10 347.016 ± 2.038 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferBE8k thrpt 10 220.529 ± 0.839 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferLE128 thrpt 10 346.273 ± 7.606 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferLE16 thrpt 10 275.622 ± 1.436 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferLE1m thrpt 10 345.511 ± 6.223 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferLE256 thrpt 10 348.193 ± 14.641 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferLE32 thrpt 10 321.897 ± 2.498 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferLE512 thrpt 10 354.947 ± 2.515 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferLE64 thrpt 10 339.376 ± 5.021 ops/s FileBufferAccessDoubleBenchmark.testDirectBufferLE8k thrpt 10 218.614 ± 2.389 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferBE128 thrpt 10 135.477 ± 0.477 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferBE16 thrpt 10 128.826 ± 0.493 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferBE1m thrpt 10 133.019 ± 1.100 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferBE256 thrpt 10 135.249 ± 0.382 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferBE32 thrpt 10 138.792 ± 0.382 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferBE512 thrpt 10 133.881 ± 1.120 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferBE64 thrpt 10 135.730 ± 0.320 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferBE8k thrpt 10 110.442 ± 0.632 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferLE128 thrpt 10 134.690 ± 0.560 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferLE16 thrpt 10 128.883 ± 0.583 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferLE1m thrpt 10 132.606 ± 1.049 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferLE256 thrpt 10 134.812 ± 0.587 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferLE32 thrpt 10 138.150 ± 0.572 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferLE512 thrpt 10 134.951 ± 0.408 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferLE64 thrpt 10 135.497 ± 0.874 ops/s FileBufferAccessDoubleBenchmark.testHeapBufferLE8k thrpt 10 111.449 ± 0.286 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedIO128k thrpt 10 46.183 ± 0.480 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedIO16 thrpt 10 42.791 ± 0.701 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedIO32 thrpt 10 45.116 ± 0.071 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedIO64 thrpt 10 45.764 ± 0.452 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedIO8k thrpt 10 43.101 ± 0.208 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedNIO128k thrpt 10 46.364 ± 0.373 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedNIO16 thrpt 10 44.852 ± 0.131 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedNIO32 thrpt 10 46.075 ± 0.125 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedNIO64 thrpt 10 46.895 ± 0.301 ops/s FileBufferAccessDoubleBenchmark.testOldBufferedNIO8k thrpt 10 43.116 ± 0.320 ops/s FileBufferAccessDoubleBenchmark.testOldIO thrpt 10 0.560 ± 0.006 ops/s FileBufferAccessDoubleBenchmark.testOldNIO thrpt 10 0.563 ± 0.008 ops/s
パフォーマンスの比較
10MbytesのファイルのInt値の連続読み取りパフォーマンスのチャート。
縦軸はテストメソッド(10 MBytesファイルの読み取り)の1秒間あたりの実行回数の平均を示す。
やはり、ダイレクトバッファはヒープバッファよりも2倍以上性能が高い。
またバッファサイズとしては256 KiB以上確保したほうが読み込み性能は良いように見える。しかし、256 KiB以上確保しても、それ以上は性能は変わらないようでもある。
10 MbytesのファイルのDouble値の連続読み取りパフォーマンスのチャート。
縦軸はテストメソッド(10 MBytesファイルの読み取り)の1秒間あたり実行回数の平均を示す。
やはり、ダイレクトバッファはヒープバッファよりも2倍以上性能が高い。
また、こちらもバッファサイズとしては256 KiB以上確保したほうが読み込み性能は良いように見える。しかし、256 KiB以上確保しても、やはり、それ以上は性能は変わらないようでもある。
OldBufferedIOの性能が思った以上に低い
今回のベンチマークで思い知ったことは、Javaの古典的なストリーム系のBufferedInputStreamとDataInputStreamの組み合わせが、相当にパフォーマンスが低い、ということだった。
本当に予想外に性能がかなり悪い。
ヒープ上のByteBufferと比較しても、ここまでパフォーマンスが悪いとは思わなかった。
また、うっかりBufferedInputStreamで包むのを忘れてしまったFileInputStreamの性能が大変悲惨であることをチャートで見るとよく分かる。
(これは想定されていたことだが、実際にチャートで比較してみると"悲惨"という言葉に尽きる性能である。)
ただ、Java7で追加されたFiles#newInputStream()
はPathで示されるファイルをオープンしてInputStreamを返すコンビニエンスなメソッドだが、コンビニエンスメソッドとして用意されたのであれば、何らかのパフォーマンスのために最低限の構成ができたものが返ってきている可能性もあるのでは?と思ってみたのだが、ぜんぜんそうゆうことはなくて、ただのnew FileInputStream()の代わりにすぎなかったようだ。
(ドキュメントにかかれてないので、まあ、そうだろう、とは思っていたけれど、全然コンビニエンスじゃないところが違和感があるというか。)
また、BufferedInputStreamはデフォルトで8 KiBのバッファサイズをもっているが、これを増やしても、ほとんど性能は向上しなさそうだということも判明した。
(なので、たぶん、大多数の人が引数でバッファサイズを指定していないのだろう。)
結論
今回の実験により、以下のことが分かった。
- ダイレクトバッファは確保と破棄に注意が必要である。
- ダイレクトメモリサイズはヒープとは別のJavaVMオプションで設定できる。
- 確保と破棄が連続する場合では解放が追い付かずOutOfMemoryErrorの発生の恐れがある。
- System.gc()または内部クラスのCleanerを利用する対策がある。
- ダイレクトバッファは逐次構築するのではなくキャッシュすると効率が良い。
- ダイレクトバッファ > ヒープバッファ >> 古典的なストリームI/O という性能差のようである。
実のところ、ダイレクトバッファのベンチマークをとるのは、これで2〜3回目ぐらいである。
しかし、過去のテストでは、ダイレクトバッファは使いどころが難しい、うまくパフォーマンスを発揮させられない、という結論を出してしまっていた。
(今思えばベンチマークとして適切でない部分の時間も含んでいたような気もする。)
また、ヒープ上のByteBufferについても、同じくヒープ上で動作する古典的ストリームI/Oとパフォーマンスに大きな差はないだろう、という先入観のようなものもあったのかもしれない。
だが、今回のテスト結果をみると、どうも過去のテストは明らかに何かを間違えていたかのように思われる。
今回、確実に、かなり高速でデータの読み込みができることの確証を得た。
今後は、ByteBufferがうまくマッチする場合は、優先的にByteBufferによるアクセスに切り替えて行く方が良さそうだ、という教訓が得られたと思う。
今思うことは、どうして、もっと早く再評価してみようと思わなかったのか、ということだろうか。
※ 今回のベンチマーク等のソースコードは、https://gist.github.com/seraphy/c173e3d82afd1ad3f81c にあります。
以上、メモ終了。