データの保存のために、バイナリフォーマットは広く使われている。なのでそれを読まなければならないケースが多い。
C++におけるバイナリの読み方の一番の基本はstream
の関数std::basic_istream::read(char_type*, streamsize)
だろう。
この関数は第二引数で示された文字数だけ読み込み、第一引数で示された位置へその内容を書き込む。
使い方は以下だ。
std::ifstream ifs("example.bin", std::ios::in | std::ios::binary); std::int32_t i; ifs.read(reinterpret_cast<char*>(std::addressof(i)), sizeof(std::int32_t)/* or simply 4 */);
なので、基本的に以下のような関数を用意することで、ストリームから簡単にバイナリ値を読み込むことができる。
template<typename T> T read_binary_as(std::istream& is) { T val; is.read(reinterpret_cast<char*>(std::addressof(val)), sizeof(T)); return val; } const auto i = read_binary_as<std::int32_t>(ifs);
書き出しの時は同様にbasic_ostream::write(const char_type*, streamsize);
が使える。
std::ofstream ofs("example.bin", std::ios::out | std::ios::binary); const std::int32_t i = 42; ofs.write(reinterpret_cast<const char*>(std::addressof(i)), sizeof(std::int32_t))
ラッパーを書くとすれば
template<typename T> std::ostream& write_as_binary(std::ostream& os, const T& v) { is.write(reinterpret_cast<const char*>(std::addressof(val)), sizeof(T)); return val; }
という感じだろうか。
大抵のバイナリフォーマットはある程度のブロックごとにその内容がわけられている。 最も単純な例は、最初にヘッダーがあり、その後データが続くというものだ。 このような場合、使いやすさとわかりやすさのため、そのフォーマットを読んだ結果は以下のような構造体になって返ってきてほしいだろう。
struct Header { std::uint64_t signeture; std::uint8_t month; std::uint8_t day; std::uint32_t data_size; }; struct Data { std::vector<double> values; }; struct FileData { Header header; Data data; };
さて。
先の関数を使って素直に読む場合、以下のようになる。
Header read_header(std::istream& is) { Header header; header.signeture = read_binary_as<std::uint64_t>(is); header.month = read_binary_as<std::uint8_t>(is); header.day = read_binary_as<std::uint8_t>(is); header.data_size = read_binary_as<std::uint32_t>(is); return header; } Data read_data(std::istream& is, std::size_t N) { Data data; data.values.resize(N); for(std::size_t i=0; i<N; ++i) { data.values[i] = read_binary_as<double>(is); } return data; } FileData read_file(std::istream& is) { FileData f; f.header = read_header(is); f.data = read_data(is, f.header.data_size); return f; }
速度の面で一つ修正すべき点がある。vector
では各要素は隣接して配置されるので、バイナリファイル中でも要素が隣接しているなら、一気に全体を読み込むことが可能だろう。
Data read_data(std::istream& is, std::size_t N) { Data data; data.values.resize(N); is.read(data.values.data(), N * sizeof(double)); return data; }
こうすることでファイル読み込みの回数が圧倒的に減り、高速化が見込まれる。
適当にGCCの-O2
と-O3
で、10000要素のvector<double>
を使って試したところ20~100倍程度高速化した。
要素と試行回数を増やして統計を取ればもう少しまともなことが言えるが、コードを書き換える手間が小さい割には明らかに速くなることがわかったのでそこまでする気にはなれなかった。
では同様のことがヘッダーでも出来ないだろうか、と考えるのがパフォーマンスフリークの性というものだ。
ここで問題になるのがpaddingである。基本的に、CPUはメモリを読むときにbyte単位では読まない。大抵は4か8byteの、word単位で読む。 よって、あるデータがこのword境界をまたいで存在する場合、その一つのデータを読みだすためだけに2度メモリアクセスが発生しかねない。これは無駄だ。 あるいは最悪の場合、エラーで停止する。 例えば、4byteのデータがメモリの14番地から14,15,16,17と入っている場合、先に14,15番地を読んだ後に16,17番地に再度アクセスする、という形になる。 低速になるのも無理はないし、エラーになると言われても納得できるだろう。
Wikipediaのこのページなどが詳しい。
以上から、数byte分の領域を無駄にしても、次のデータがこのword境界に沿う形で置いた方が効率的なプログラムになる。
そして、コンパイラはこれを我々の代わりにやってくれているというわけだ。適切な位置にパディングを入れて。
例えば先のHeader
構造体は
|00|01|02|03|04|05|06|07|08|09|0A|0B|0C|0D|0E|0F| |signeture |mo|da|xx|xx|data_size | |Header |
のような形に配置される(もちろん常にこうではない。これは一例だ)。2byte分あるxx
がパディングで、ここの値は未使用だ。
そして、どこにPaddingを入れるべきかというのは、アーキテクチャがわからないと判断できない。
先のHeader
クラスのサイズは未規定である。予測不可能だ。アーキテクチャが違えば、最も高速になるアライメントが異なる場合があり得る。
コンパイラとアーキテクチャの組み合わせによっては、メモリ使用量を重視するものがあってもよく、そもそもパディングが入るかどうかすらわからない。
つまり、Header
クラスの中で変数がどう配置されるかは誰にもわからないのだ。
細かいことを言うと、ある程度詳しいプログラマは自分の使う、あるいはターゲットのアーキテクチャでどのように配置されるか予測可能だろう。 だが、どのような状況で使われるかわからない段階ではその値を一般に予測するのは無理だ。
なので、バイナリファイル中では連続しているであろう値をそのまま連続して構造体のある領域にその先頭からロードしてしまうと、データは壊れてしまう(可能性がある)。
よってこのままではdata
と同様の高速化は無理だ。
一応、回避策は存在する。例えば、コンパイラのattributeを使う方法だ。
// GCC struct __attribute__((__packed__)) Header { std::uint64_t signeture; std::uint8_t month; std::uint8_t day; std::uint32_t data_size; };
このようにするとパディングが入らない。
ただ、これはC++標準の機能ではない。コードがコンパイラに依存してしまう。複数のコンパイラをサポートしようとすると、マクロをゴリゴリ書いていくしかなくなる。
#ifdef __GNUC__ #define FORCE_PACKING __attribute__((__packed__)) #elif defined(__clang__) ... #endif
あまりこういうことをしたことがないのでどのコンパイラでどのPredefined Macroがどの値になっているとかはよく知らないが、まあ原理的にはこうやれば可能だ。
標準の機能には似たようなものにalignas()
があるが、これはパッキングを目的としたものではなく、特定の(大抵はより大きな)バイト境界へのアライメントを強制するものだ。
例えば、SIMD命令を使うためにアライメントを大きめの値にしなければならない場合などがあり、そのような場合にはこれが活躍する。
これを使って無理に詰めようとするとill-formedになってしまう。
そもそもアーキテクチャによっては正しくバイト境界へアラインしていないと落ちるので、無理に詰めるようなコードはそのようなアーキテクチャでは必ずエラー終了(あるいは未定義動作?)するコードになってしまう。 そのような意味ではここにそこまで情熱を注ぐのはあまり正しいことではないだろう。
このような話をしたところ、後輩によって「全てをバイト列として保存したあと、それぞれの変数に対応するsigneture()
のようなメソッドを定義して読み込みを高速化できないか」という意見が出た。
それは可能だ。このアイデアでは、アクセサは以下のようになる。
std::uint64_t Header::signeture() const { std::uint64_t sign; // this->data is a pointer // to the front of a byte array in a Header information. std::copy(this->data, this->data + sizeof(std::uint64_t), reinterpret_cast<std::int8_t*>(std::addressof(sign))); return sign; } std::uint8_t Header::month() const { std::uint8_t mon; std::copy(this->data + sizeof(std::uint64_t), this->data + sizeof(std::uint64_t) + sizeof(std::uint8_t), reinterpret_cast<std::int8_t*>(std::addressof(mon))); return mon; } { Header header; std::cout << header.month() << std::endl; }
このアイデアは、読み込みを高速化する代わりに、毎回の呼び出し時に読み書きで回避した分の処理を肩代わりさせ、まあこう言うと何だがオーバーヘッドを入れるというものだ。 単純な代入は不可能(先に述べたようにデータアライメントの観点からも参照にキャストして返すのは一般のアーキテクチャには実装出来ない)なので、getter/setterを用意するか、 専用のプロキシクラスを用意して参照の代わりにそれを返し、そのクラスにバイト列への書き込みや後述するエンディアンの処理をさせる、ということになる。 おそらくgetter/setterよりもプロキシクラスの方が普通に使う上で読みやすそうなので(これは私の主観だが)、プロキシについて考えてみる。 実装はこんな感じになるだろう(エンディアンの処理は省略した。詳しくは後述する)。
template<typename T> struct binary_proxy { // a pointer to the front of the byte array that contains data std::int8_t* ptr; // operator overload to write a value into the byte array binary_proxy<T>& operator=(const T& rhs) { std::copy(reinterpret_cast<const std::int8_t*>(std::addressof(rhs)), reinterpret_cast<const std::int8_t*>(std::addressof(rhs)) + sizeof(T), ptr); return *this; } // implicit conversion to type T for convenience operator T() const { T val; std::copy(ptr, ptr + sizeof(T), reinterpret_cast<const std::int8_t*>(std::addressof(val))); return val; } }; binary_proxy<std::uint8_t> Header::month() { return binary_proxy<std::uint8_t>{this->data + month_offset}; } { Header header; header.month() = 5u; std::uint8_t mon = header.month(); // "5" }
Header::month
にアクセスすると、値そのものではなくbinary_proxy
が返る。
その場では何事も起きない。データであるバイト列中のmonth
情報の先頭を指すポインタが入っているだけだ。
しかしこれに書き込みを行う(operator=
が呼ばれる)と、内部的にバイナリ値への変換が発生し、ポインタの指す位置へ情報が書き込まれる。
また、プロキシではなく実際の型へ代入しようとすると、用意した型変換演算子(operator T()
)によって暗黙の型変換が発生し、バイナリが読み込まれて実値が返ってくる。
読み書きが実際に発生した時にだけ、変換が行われるというわけだ。
ファイルの読み込みと書き込みを非常に頻繁に行うが、読み込んだデータへのアクセスはそれに比べると少ない回数しかしない、という場合はこの方法が高速になる。 が、実際の運用を考えた時、大抵読み込みと書き込みは最初の1回ずつか、やって数回だろう。しかしアクセスは通常のアプリケーションでも100回やら100万回にもなり得る。 それを考えると、アクセスと読み書きのどちらにこの処理を任せるかというと、読み書き時がよいだろう、ということになる。
加えて、このやり方は少しテクニカルではないか。普通のユーザーは、値にアクセスしたときに謎のプロキシクラスが返ってくるとは予想しない。 Blitz++のExpression Templateのように、計算量を大きく軽減するなら、そして大抵の場合そのクラスがユーザーからは見えないなら、まあ問題はなかろう。 しかしコードのリーダビリティとユーザーにとっての利便性を考えるなら、ホットスポットを除いて高度なことをするのは避けたほうがよいとはよく言われる。 よいライブラリは、普通のユーザーからは自身の内部構造を隠しきるライブラリだ、というヤツだ。 ではヘッダ読み込みはホットスポットか。まあそうではないだろう。ほとんどのファイルではヘッダ領域のサイズはデータ領域のそれに比べて無視できる程度だ。 その部分を危ない端を渡って高速化したところで、十分なリターンがあるとは思えない。 また、このプロキシクラスは十分に隠れてはいるだろうが、だからこそ、ユーザーが毎回変換が起きているとは思わずに謎の低速化に悩まされる、という可能性もあるので、計算量が減る状況でないなら避けたほうが無難だろう。
というわけで、アイデアは面白かったが、ここはおとなしく素直なコードで妥当に読もうという話になった。 特殊なファイルやアーキテクチャ依存で高速化するような場合は、このような扱いが重要になってくることもあるだろうから、このようなことが可能だということは覚えておくべきかもしれない。
ところで、以前RyouEzoe氏のcpp17book
を読んでいて気付いたのだが、C++17ではキャッシュライン最適化のための機能が入るらしい。
std::hardware_destructive_interference_size
だ。
これはパディングを入れることによって、2つのデータの距離を十分に離し、キャッシュの同期が起きるタイミングをずらすために使われる。
詳しくは以下。
GitHub - EzoeRyou/cpp17book: textbook for C++17
後回しにしてきたが、一番やっかいな問題になるのはエンディアンだ。
エンディアンはデータのバイト列をどういう順番に置くかという話で、大まかに分けて大きい桁をメモリの前側に持ってくるもの、小さい桁を持ってくるものの2種類ある。例外はあるが面倒なので触れない。
エンディアンが違うと、当然バイナリをそのまま読むと意味不明な値になる。バイト単位で逆順になっているのだから、例えば0xDEADBEEF
は0xEFBEADDE
になってしまう。
バイナリファイルの読み書きではこれがかなり面倒なことになっている。
例えば先のvector<double>
に入れる内容を一気に読む、というような方法は、ファイルフォーマットのエンディアンと自分のマシンのエンディアンが一致していないと、後でdouble
を一つ一つ丁寧に真心込めてバイトオーダーを反転させないといけなくなる。
具体的には、std::int8_t[8]
にreinterpret_cast
した後、std::reverse
などをかます必要がある。全要素に。
単にreverse
するだけで変換は可能なので、例えばbool値で反転させるかどうかを持たせてread_binary_as
を改良することは可能だ。
template<typename T> T read_binary_as(std::istream& is, const bool must_reverse = false) { if(must_reverse) { std::array<char, sizeof(T)> buffer; is.read(buffer.data(), sizeof(T)); T val; std::copy(buffer.crbegin(), buffer.crend(), reinterpret_cast<char*>(std::addressof(val))); return val; } else { T val; is.read(reinterpret_cast<char*>(std::addressof(val)), sizeof(T)); return val; } }
タグディスパッチの方がそれっぽいが本題はそこでないので理解しやすさを優先した。
変換はできるが、肝心の自分のマシンのエンディアンをどうやって検出するのか。
パッと思いつくのは、数バイトの型をchar[]
に読み替えて最初のバイトを見ることだろう。
リトルエンディアンなら下1byteが先頭に来ている。ビッグエンディアンなら上1byteが先頭に来ている。
なので例えば、uint16_t i = 1
はリトルエンディアンなら01 00
になるがビッグエンディアンなら00 01
になるので、
bool is_little_endian() { const std::uint16_t s(1); return *(reinterpret_cast<const std::uint8_t>(std::addressof(s))) == 0x1; }
のように無理やりバイト列にして最初のbyteを読むことで検出可能だ。
ところで、アーキテクチャは基本的にプログラムの実行中に変化するようなものではなく、一度コンパイルしたバイナリを使うそれ以降のいつの時点でも通常同じであるはずだ。 もしバイナリを別のマシンに移した場合、エンディアンから違うようなアーキテクチャに移動させたら通常そのバイナリは動かないのではないだろうか。
なのでこの判定はコンパイル時になされて然るべきだ。
ネイティブエンディアンがコンパイル時に判断されていれば、エンディアンに関する分岐は半分以上消し飛ぶ。 ファイルを読んで始めてわかるファイルのエンディアンは実行時分岐が必要だが、その時でも自身のエンディアンと同じか確認する手間は省ける。 また、エンディアン指定で出力する場合は実行時分岐は一切必要なくなる。
ただ、先のコードは結構アレな操作(reinterpret_cast)をするのでconstexpr
にできない。ではどうするか。
少し調べたところ、どうやらC++20ではstd::endian
が入るようだ。
c++ - コンパイル時にネイティブエンディアンを判定するには? - スタック・オーバーフロー
endian - cpprefjp C++日本語リファレンス
しかし先の長い話だ。未だに一部の業界ではC++11サポートが進行中というレベルなのに、C++20が使えるようになるまで私の寿命は持つだろうか。
もしBoostが使えるなら、Boost.PredefにBOOST_ENDIAN_*
なるマクロがある。Boost.Configにあるかと思ったがPredefらしい。どういう基準でわかれているのかはよくわからない。
今のところある程度ポータブルにやっていこうと思うとこの辺りしかないのかもしれない。
もちろん、自分が使うであろうコンパイラが定義するマクロを調べて、それぞれのコンパイラの場合にifdef-define-endif
でやっていく方法もあるだろうが、いちいちそれを書くのも面倒だ。
或はその辺りをconfigure
などで調べてからmake
でマクロ定義を渡す、とかもあり得る。
一番困るのは、使われるべきエンディアンが定められていないフォーマットだ。
intelのx86がリトルエンディアンだし、最近のアーキテクチャはたいていの場合リトルエンディアンが採用されているので、フォーマットが何も言っていなければまあリトルエンディアンだろうとは思うが、当然断定は出来ない。
そのような場合にどうすればいいかは未だにわからない。
値が予測可能なフィールド(常に127以下の数が入るint型フィールドなど)を探して、上記is_little_endian
と同様のことをするしかないような気がする。
しかし運悪くそのようなフィールドがなかったなら、とりあえずネイティブエンディアンで読んだ後、ヘッダ情報が矛盾していないかなどを確かめるしかないのだろうか。