double数値のテキストシリアライズとバイナリシリアライズの速度比較

uchiumikさんが、CRFの拡張のようなものを作成しているのだが、パラメーターが多くなりすぎて、モデルデータの読み込みに時間がかかっているという。何百万、何千万個のdouble値をファイルから読み込む処理をしているという。

そのせいで実験がしんどくては、結局手を抜いても特をしないので、そこは是非改善すべきだと思うのだが、説得力を出すために実験してみた。(本当は自分がやってみたかっただけだったりする)

本当は共有メモリをオススメしたいのだが、シリアライズをテキストからバイナリに変更するだけでもそれなりに効果があるはずだ。
次のコードで実験

#include <stdio.h>
#include <list>

void write_bin1(FILE* fp, double* data, int size){
	fwrite(data, 1, sizeof(double)*size, fp);
}

void read_bin1(FILE* fp, double* data, int size){
	fread(data, 1, sizeof(double)*size, fp);
}

void write_text(FILE* fp, double* data, int size){
	for(int i = 0; i < size; i++){
		fprintf(fp, "%e\n", data[i]);
	}
}

void write_text_prec(FILE* fp, double* data, int size){
	for(int i = 0; i < size; i++){
		fprintf(fp, "%.15e\n", data[i]);
	}
}

void read_text(FILE* fp, double* data, int size){
	const int BUF_SIZE = 1000;
	char buf[BUF_SIZE];

	for(int i = 0; i < size; i++){
		fgets(buf, BUF_SIZE, fp);
		data[i] = atof(buf);
	}
}

void read_text_scan(FILE* fp, double* data, int size){
	for(int i = 0; i < size; i++){
		fscanf(fp, "%lf", data + i);
	}
}

int main(){
	const int test_size = 1000000;
	FILE* fp;
	double* data = (double*)malloc(sizeof(double)*test_size);
	for(int i = 0; i < test_size; i++){
		data[i] = rand() * (1.0 / RAND_MAX);
	}

//*
	fp = fopen("test.dat", "wb");
	write_text_prec(fp, data, test_size);
	fclose(fp);
/*/
	fp = fopen("test.dat", "rb");
	read_text(fp, data, test_size);
	fclose(fp);
//*/
	free(data);
	return 0;
}

(コンパイラgcc version 4.0.1、コンパイル方法は g++ -O2 -DNDEBUG binary_io.cpp)

結果

方法writeread
テキスト16桁2.282sec1.026sec(2.189sec)
テキスト7桁0.739sec0.248sec(0.441sec)
バイナリ0.098sec0.046sec

double精度を保持するためには、仮数部は十進で16桁程度必要とのことで、16桁で実験した。

7.826369259425611e-06
1.315377881431662e-01
7.556053221950332e-01
4.586501319234493e-01

のような形で保存される。精度を指定しなかった場合は7桁で保存されるようだ。
もちろん、バイナリの場合はこのような心配は不要。
実験の結果、バイナリの方が約20倍以上高速ということになった。(データの初期化のみの処理時間は0.028secなので、その分を差し引いた方がより正確になるか)

もちろん、デメリットにも注意する必要はあると思う。
データファイルがエンディアン依存、doubleの形式に依存してしてしまうことと、ファイルを人間が読むのがつらいというのが主な点だろうか。
表のカッコの中は、fscanf()を使った場合の結果で、テキストファイルを扱う場合でも、fgets() + atof()を使ったほうが良いことが分かる。

ファイルがキャッシュされているかで結果が変わるかもしれないが、それは同等の条件ということにしておこう。

もう一つ、この部分にこだわっても、ボトルネックじゃなかったりするとあまり嬉しくないので、その点を確認しておく。
ファイルから読み取ったあと、std::listに突っ込まなければならないとしよう。特にlistを選んだことに深い理由はない。

void write_bin1(FILE* fp, double* data, int size){
	fwrite(data, 1, sizeof(double)*size, fp);
}

void read_bin1(FILE* fp, double* data, int size){
	fread(data, 1, sizeof(double)*size, fp);
}

void write_bin2(FILE* fp, const std::list<double> data){
	for(std::list<double>::const_iterator it = data.begin(); it != data.end(); it++){
		fwrite(&(*it), 1, sizeof(double), fp);
	}
}

void read_bin2(FILE* fp, std::list<double>* data, int size){
	data->resize(0);
	for(int i = 0; i < size; i++){
		double val;
		fread(&val, 1, sizeof(double), fp);
		data->push_back(val);
	}
}

void write_bin3(FILE* fp, const std::list<double> data){
	double* buf = (double*)malloc(sizeof(double) * data.size());
	int i = 0;
	for(std::list<double>::const_iterator it = data.begin(); it != data.end(); i++, it++){
		buf[i] = *it;
	}
	write_bin1(fp, buf, data.size());
	free(buf);
}

void read_bin3(FILE* fp, std::list<double>* data, int size){
	double* buf = (double*)malloc(sizeof(double) * size);
	read_bin1(fp, buf, size);
	data->resize(0);
	for(int i = 0; i < size; i++){
		data->push_back(buf[i]);
	}
	free(buf);
}

結果は

方法writeread
bin10.098sec0.046sec
bin20.551sec0.492sec
bin30.530sec0.474sec
ということで、listの処理の方がボトルネックになってしまった。
bin3の方法は、freadやfwriteの呼び出し回数を減らす工夫をしたのだが、bin2と殆ど変わらなかった。
もちろん、このlistのコストは、テキストファイルの場合も追加で発生する。
そこから予測すると、listに入れる場合はバイナリ化しても2〜4倍程度しか高速化は期待できない。


蛇足だが、perlpythonでもバイナリを読み込んでみた

open(IN, '<', 'test.dat');
binmode(IN);
read(IN, $indata, 1000000*8);
@data = unpack('d*', $bindata);

0.291sec (perl 5.8.8)

import struct
fp = open('test.dat', 'rb')
bindata = fp.read(1000000*8)
data = struct.unpack('1000000d', bindata)

0.202sec (python 2.5.1)

結構はやい

でも実験結果を考えると、使い方によっては、それほどdouble値のテキストがボトルネックにはならないこともありそうだ(このままいくと1000万個でも23秒程度だ)。バイナリにすることで不便になる点もあるので、慎重に検討したい。