Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

DeflaterOutputStreamの内部実装とBufferedOutputStreamと組み合わせる際の注意点

前回の実験で、DeflaterOutputStreamとBufferedOutputStreamの組み合わせ方によってパフォーマンスが大きく異なることがわかりました。今回はこの遅延の原因がDeflaterOutputStreamの実装に起因することを確認し、なぜBufferedOutputStreamでデコレートすべきなのかを考えます。

遅延の正体はI/O待ちではない

まず遅延の正体が何であるかを切り分けましょう。入出力処理でボトルネックになりがちなのはI/O待ちですので、これを疑ってみます。


最も簡単な検証は、/dev/null にリダイレクトするイメージで「ファイルに書き込まない」ストリームを作成し、I/O待ちを完全に省いてしまうことです。これで速くなればI/O待ちが遅延の正体であると言えます。
必要なコードは以下のようなストリームクラスだけ。手軽に確認できます。

// 受け取ったデータをすべて無視するOutputStream
class NullOutputStream extends FilterOutputStream {
	public NullOutputStream(OutputStream out) {
		super(out);
	}
	@Override
	public void write(int i) throws IOException { /* なにもしない */ }
}

このインスタンスを2つのOutputStreamでデコレートした状態で処理速度を測った結果、高速化は認められませんでした。少なくとも遅延の原因はI/O待ちではないことが確認できます。

No. デコレート内容 書き込みあり[ミリ秒] 書き込みなし[ミリ秒]
0 デコレートなし
3,316
7
1 BufferedOutputStreamでデコレート
53
10
2 DeflaterOutputStreamでデコレート
882
772
3 BufferedOutputStreamでデコレートし、さらにDeflaterOutputStreamでデコレート
748
752
4 DeflaterOutputStreamでデコレートし、さらにBufferedOutputStreamでデコレート
20
18

No.3同様、No.2も高速化していない点に注目。これらは高速化の工夫を施していないNo.0よりも遅くなっています。この結果から「DeflaterOutputStreamはI/O削減により高速化を実現するが、代償に何らかの重い処理を行っている」ことが見えてきます。

DeflaterOutputStreamはwriteするたびにDeflaterによる暗号化を施す

I/Oでないとすると、何が遅いのでしょうか?次に怪しいのはCPUによるデータ圧縮でしょう*1。圧縮そのものが遅いならば改善の余地はありませんが、圧縮の使い方が悪いだけの可能性も残っています。DeflaterOutputStreamがどのタイミングでどのようにデータを圧縮するのか、コードを読んでみましょう。


OpenJDKにあるDeflaterOutputStreamの実装を読むと、すべてのwriteメソッドがwrite(byte[],int,int)に集約されていることに気づきます。そこでDeflaterによる圧縮を行うdeflate()メソッドを呼び出しているようです。

/**
 * Writes next block of compressed data to the output stream.
 * @throws IOException if an I/O error has occurred
 */
protected void deflate() throws IOException {
    int len = def.deflate(buf, 0, buf.length);
    if (len > 0) {
        out.write(buf, 0, len);
    }
}

Deflater#deflate()がnativeメソッドに依存しているので、これ以上コードからパフォーマンスを推定するのは難しいでしょう。しかしwriteするごとにデータ圧縮を試みるという実装は、直感的に遅そうだという印象を受けます。

No.4が速い理由――まとめて圧縮すれば速くなる

圧縮回数が多すぎて遅いという仮説を検証するには、圧縮回数を減らせば速くなることを示せば良いでしょう。writeごとに圧縮するというDeflaterOutputStreamの実装を変えることはできませんので、DeflaterOutputStreamにwriteする回数を減らすことを考えます。
そこで2,000[KB]のデータを特定バイト数ごとに圧縮するコードを書いて検証しました。


結論から言うと、1バイトずつDeflaterOutputStreamにwriteする場合と256バイトずつwriteする場合で約57倍の速度差を確認できました。
No.4が速かった理由も、バッファによって8KBずつwriteしていたためと結論づけられます。

// DeflaterOutputStreamにまとめてデータを渡すことで
// 同じデータ量でも高速に圧縮できることを証明するコード

import java.io.IOException;
import java.io.OutputStream;
import java.util.zip.DeflaterOutputStream;


public class DeflateTest {
	private static final int TEST_SIZE = 2000 * 1024;
	
	public static void main(String[] args) {
		try {
			for (int writeBytes : new int[] {1, 16, 32, 64, 256, 512, 1024}) {
				// 512bytesごとと1024bytesごとでさほど高速化効果が違わないのは、DeflaterOutputStream内のバッファが512bytesだから
				System.out.printf("%dbytesずつwriteした結果、%d[ms]で完了%n", writeBytes, new DeflateTest().test(writeBytes));
			}
		} catch (Throwable t) {
			t.printStackTrace();
		}
	}

	/**
	 * TEST_SIZEバイトの書き込みに必要な時間を計測する
	 * @param writeBytes 1度に書き込むバイト数
	 * @return 経過時間[ms]
	 */
	private long test(final int writeBytes) throws IOException {
		final OutputStream o = new DeflaterOutputStream(new NullOutputStream());
		final long startTime = System.currentTimeMillis();
		final byte[] bytes = new byte[writeBytes];

		try {
			for (int i = 0; j < TEST_SIZE / writeBytes; ++i) {
				o.write(bytes);
			}
		} finally {
			o.close();
		}

		return System.currentTimeMillis() - startTime;
	}

	private static class NullOutputStream extends OutputStream {
		@Override
		public void write(int i) throws IOException {}
	}
}

まとめ

一連の検証により

  • 入出力の高速化にはBufferedOutputStreamなどによるI/O回数削減が有効であること
  • DeflaterOutputStreamによる圧縮も効果的だが、圧縮回数を減らすためBufferedOutputStreamでデコレートすること

が高速化において重要であることがわかりました。各ストリームのバッファサイズを調整するなどさらに上を目指す余地はありますが、この2点と根拠となる原理さえ理解すれば大半のケースに対処できるでしょう。


いかに技術が発展しようとも、パフォーマンスにおける入出力の重要性は変わりません。むしろ昨今の技術は入出力の総量を増やす方向に進んでいる印象も受けます。この手の基本をしっかり理解して臨みたいものです。

*1:GCの可能性も残っていますが、verbosegcなどで否定できます