アラインメント問題とDebianパッケージ化

ID: 39
creation date: 2009/11/13 19:43
modification date: 2009/11/13 19:43
owner: mikio

知っている人は当然のように知っているが、知らない人は興味すら持っていなかったりするのが、CやC++におけるオブジェクトのアラインメントのルールである。TCのDebianパッケージを作ってもらう過程でアラインメントの問題と格闘したので、その経緯をメモっておく。

基礎の基礎

CとC++はオブジェクトに型がある言語で、その型はリテラルや変数宣言などの型表現で決められる。「10L」というリテラルはlong型で、「int a;」などとして宣言した変数はint型である。そして、型ごとに処理系(主にCPU)依存のアラインメントが定まっていて、その型のオブジェクトを定義した際には、そのアドレスは必ずアラインメント値の倍数に揃えられる。オブジェクトの値を評価する際にそのアドレスがアラインメントを守っていない場合、性能上のペナルティがあるか、バスエラーという例外が発生する。ペナルティで済むのかバスエラーが起こるのかも処理系によって異なり、i386やx86_64は前者でSPARCやARMは後者である。

変数を宣言した場合には、もちろん宣言した型のアラインメントに合わせてその変数のアドレスは揃えられる。mallocやnewで確保した領域は、全ての型のアラインメントの公倍数で揃えられる。よって、プログラマがアラインメントについて考慮する頻度はあまり多くない。しかし、自力でポインタ演算を行ってオブジェクト配置する場合には、アラインメントに最大限の注意を払わねばならない。特に、普段x86系の環境で作業しているがSPARC等の環境でも移植可能なプログラムを開発している人は、つまり多くのオープンソースソフトウェアの開発者は、アラインメント関連のテストを積極的に行うことが必要だ。

具体例

各種の環境でのアラインメントがどうなっているのかを手軽に知るために、TCの最新版(1.4.39)からは、「tcucodec conf」というコマンドを実行すると主な型のアラインメントの一覧が出るようになっている。いくつかの具体例を見てみよう。表の中で、sizeはその型のオブジェクトのサイズ(sizeof演算子の値)であり、alignは推奨アラインメント(GCC拡張の__alignof__演算子の値)であり、offsetは構造体に配置した場合の最小オフセット(offsetofマクロで判定)であり、maxはその型のオブジェクトの変域の最大値である。

i386(Pentium 4、Linux 2.6.15、GCC 4.1.1)

type size align offset max
bool 1 1 1 1
char 1 1 1 127
short 2 2 2 32767
int 4 4 4 2147483647
long 4 4 4 2147483647
long long 8 8 4 9223372036854775807
float 4 4 4 3.40282e+38
double 8 8 4 1.79769e+308
long double 12 4 4 1.18973e+4932
void * 4 4 4 -
intptr_t 4 4 4 2147483647
size_t 4 4 4 4294967295
ptrdiff_t 4 4 4 2147483647
wchar_t 4 4 4 2147483647
sig_atomic_t 4 4 4 2147483647
time_t 4 4 4 2147483647
off_t 4 4 4 2147483647
ino_t 4 4 4 4294967295

x86_64(Xeon E5345、Linux 2.6.28、GCC 4.3.3)

type size align offset max
bool 1 1 1 1
char 1 1 1 127
short 2 2 2 32767
int 4 4 4 2147483647
long 8 8 8 9223372036854775807
long long 8 8 8 9223372036854775807
float 4 4 4 3.40282e+38
double 8 8 8 1.79769e+308
long double 16 16 16 1.18973e+4932
void * 8 8 8 -
intptr_t 8 8 8 9223372036854775807
size_t 8 8 8 18446744073709551615
ptrdiff_t 8 8 8 9223372036854775807
wchar_t 4 4 4 2147483647
sig_atomic_t 4 4 4 2147483647
time_t 8 8 8 9223372036854775807
off_t 8 8 8 9223372036854775807
ino_t 8 8 8 18446744073709551615

考察

考察ってほどでもないが、個人的に興味深いと思う点を挙げておこう。第一に、少なくともi386だと、推奨アラインメントと構造体の最小オフセットが異なるということだ。オブジェクトのサイズと推奨アラインメントが同じになるのは直感的に理解できる(そうでないと配列がうまく扱えないから)が、構造体の中にメンバを配置する際には、推奨アラインメントよりも低いアラインメントでオブジェクトを詰めて配置することがあるということだ。つまり、i386では、8バイト幅であるlong longのオブジェクトを4の倍数のアドレスに配置しても正常に評価ができるということだ。理由はよくわからんが、メモリ使用量の節約のためとか後方互換性のためとかそんなところなのだろう。

第二に興味深いのは、i386だと、long doubleのサイズが12バイトで推奨アラインメントが4だということだ。doubleの推奨アラインメントは8なのに、サイズの大小関係とアラインメントの大小関係が逆転している。これも理由はわからんが、long doubleはどうせオーバーヘッドでかいからアラインメントでアクセスを効率化しなくてもいいみたいな割り切りがあるのだろうか。

第三に興味深いというか、ここがツボなのだが、x86_64だと、全ての型でサイズと推奨アラインメントと構造体内最小オフセットが同じだということだ。これまた理由はよくわからんが、i386のケチケチ詰め込んだ印象と打って変わって、空間効率よりも時間効率を追求してるんだろうなと勝手に推測してみる。メモリもCPUのキャッシュも大容量化してるという背景があるんじゃないかと。

ツボとはすなわち、x86_64で開発していると各種の型がわかりやすくアラインメントされすぎて、その他の処理系でのアラインメントの事情に気づきにくいということだ。なので、普段x86_64で作業している人も、たまには他の処理系を立ち上げて、アラインメントに勝手な仮定をしたプログラミングをしていないかどうかを調べた方がいいということだ。Intel系だとアラインメント違反しても落ちてくれないので、できればSPARCやARMでテストした方がいい(PPCがどうかは知らない)。

TCのDebianパッケージ化の物語

TCの最新版のDebianパッケージを作ってくれているPierreさんという有志の方から、「Intel系以外でmake checkが失敗する」というメールをいただいたのが発端だった。Debianの人々は安定性と移植性の確保にとても熱意を注いでいて、i386やx86_64だけでなく、すげーたくさんの環境で動くようにしている。そして、Debianパッケージを作る際にも、それら全ての環境でビルドと動作確認をすることが求められていて、今回TCのパッケージを作る際にもその作業が行われ、不具合が発覚したわけだ。

彼(彼女かも)の最初の報告で、「HPPA(PA-RISC)とARMとSPARCで落ちる。ARMとSPARCではバスエラーで落ちていることからアラインメント関係が怪しい」という具体的な指摘をいただいた。既にデバッガ上で動かしてスタックトレースまで送ってきている。感激した。こんな丁寧なバグ報告を送ってくれる人はそうはいない。挙げられたどの処理系も手元にはないので、こちらではぼーっとソースを眺めることしかできなず、彼の協力のみがデバッグのたよりである。

「ありがとう。こちらでも調査してみる」と返事したものの、なすすべなくソースを眺めているだけで時間が経過していたところ、彼からバグ追跡作業の経過メールが続々と送られて来た。最終的には「int64_tのオブジェクトをメンバに持つ構造体を直列化する際にその先頭アドレスがvoid*のアラインメントで揃えられているので、int64_tが8バイトアラインメントでかつvoid*が4バイトアラインメントの処理系で落ちる可能性がある」というコメントと、その修正方法が到着した。最初は何のことかよくわからなかったが、関連するソースと照らし合わせて考えてみるとだんだん理解できてきた。

こういうことだ。TCのB+木データベース(TCBDB)においてはレコードがページ単位で管理され、そのページキャッシュはオンメモリのハッシュマップ(TCMAP)で管理されている。TCMAPはキーと値を直列化して連続した領域に自力でアドレッシングして保存している。値に関しては、各種の型のオブジェクトを直接所望の型にキャストして扱えるように、void*のアラインメントに合わせて開始位置を調整すべくパディングしている。ここに誤りの本質があったのだ。void*のアラインメントがint64_tのアラインメントよりも小さいという可能性を考慮していなかったわけだ。int64_tのオブジェクトを値に入れていたなら以前の俺でも気づいていただろうが、構造体を入れていて、そのメンバーがint64_tであったという間接性が目を曇らせていた。

彼が言うには、移植性を保って構造体をアラインメントするには、その全てのメンバーを持つ共用体のアラインメントに合わせることが重要である。例えば、struct { int32 a; int64_t b; double c; } のアラインメントは union { int32 a; int64_t b; double c; } のアラインメントに合わせる必要があるとのこと。言われてみると確かにその通りだ。

アラインメントを得るには、GCC拡張の __alignof__ 演算子を使うのが簡単である。しかし、それは推奨アラインメントであって最小の必須アラインメントを返すものではなく、またそれを使うとGCC以外の環境でビルドできなくなってしまうので、今回は代替案を用いた。以下のマクロで任意の型の最小の必須アラインメントを返すことができる。

#define alignof(type) \
  ((int)offsetof(struct { int8_t top; type bot; }, bot))

つまり、int8_t(すなわちchar)を先頭に配した構造体の2番目のメンバに任意の型のオブジェクトを配して、その先頭からのオフセットをoffsetofマクロ(これはC89標準)で取得するというわけである。これを使って、TCMAPの値の前に詰めるパディングをキーの長さから算出するためのマクロを以下のように修正した。

typedef union { int32_t i; int64_t l; double d; void *p; TCCMP f; } tcgeneric_t;
#define TCALIGNPAD(ksiz) \
  (((ksiz | ~-alignof(tcgeneric_t)) + 1) - ksiz)

tcgeneric_tはint32_tとint64_tとdoubleとポインタと関数ポインタの共用体であり、そのアラインメントを使って値の位置を調整するのだ(long doubleが候補に入っていないが、当面はTC内部で使う予定が無いのでよしとする)。tcgeneric_tの具体的なアラインメント値は、i386だと4で、x86_64だと8である。

TCBDBのキャッシュ以外にもvoid*アラインメントの誤った仮定をした場所がいくつかあったが、それらもメールでやりとりしながら潰していったところ、HPPA以外の処理系では全てのテストをクリアできるようになった。HPPA上でTCを動かすと、Unified Buffer Cacheを仮定した処理でデータの整合性が失われるという謎の不具合があるのだが、HPPA上ではUBCスイッチをオフにすることで対処すれば、全ての処理系でパッケージを作れることになりそうだ(現在作業中)。Pierreさんには改めて感謝したい。

まとめ

CやC++では、アラインメントの制約を守らないプログラムは処理系によってクラッシュすることがある。x86_64上で開発作業を行っていると、アラインメントに起因する潜在的な問題に気づきにくい。よって、時には別の処理系でテストして問題を洗い出すことが必要である。

Debianのメンテナに目を掛けてもらってパッケージ化してもらうのは、その過程で様々な環境でテストしてもらえるので、品質を向上させる絶好の機会となる(もちろんそのためには製品自体の価値がそこそこあって、テストケースを充実させておくことが前提となる)。パッケージ化によって世の多くの人々に使ってもらえることになるのも素晴らしい。QDBMやH.E.の時にもDebian方面の方々には多大にお世話になっているが、今後ともパッケージ化に値する製品を世に送り出していきたいと思う。

0 comments
riddle for guest comment authorization:
Where is the capital city of Japan? ...