型のアライメント
Essential前提知識
型のサイズを知ることで、値が何バイトを占めるかがわかる。しかしメモリ内のどこにその値を置けるかは別の問題だ — その答えが型のアライメント(alignment)だ。
アライメントとは何か
型のアライメントは2の冪乗のバイト数だ。その型の値は、アライメントの倍数のアドレスに格納しなければならない。i32 のアライメントは4なので、0x1000、0x1004、0x10AC のようなアドレスに置かなければならない — 0x1001 は不可だ。
std::mem::align_of::<T>() はアライメント要件を usize として返す。
use std::mem;
fn main() {
println!("{}", mem::align_of::<u8>()); // 1
println!("{}", mem::align_of::<u16>()); // 2
println!("{}", mem::align_of::<u32>()); // 4
println!("{}", mem::align_of::<u64>()); // 8
println!("{}", mem::align_of::<f64>()); // 8
println!("{}", mem::align_of::<bool>()); // 1
println!("{}", mem::align_of::<char>()); // 4
}
値を手元に持っている場合は mem::align_of_val(&x) を使う — size_of_val に対応する形だ。
ハードウェアがアライメントを必要とする理由
現代のCPUは、値のアドレスがそのサイズの倍数であるときに最も効率的に動作する。具体的な理由が三つある。
- キャッシュライン:キャッシュラインは通常64バイトだ。アライメントされた8バイト値は常に一つのキャッシュライン内に収まる。ミスアライメントされた値は二つのキャッシュラインにまたがり、二回目のキャッシュフェッチを強いる。
- アトミック操作:ほとんどのアーキテクチャはアライメントされたアクセスについてのみアトミック性を保証する。ミスアライメントされたアトミックなロード/ストアはフォールトするか、暗黙的にアトミック性の保証を破る。
- SIMD命令:SSE/AVXのようなベクタ命令は、オペランドが16バイトまたは32バイトアライメントされていることを要求することが多い。
Rustの型システムはアライメントを強制するため、セーフなRustの範囲内にいる限り上記のハードウェアの落とし穴を心配する必要がない。
構造体のパディング
コンパイラは各フィールドのアライメント要件を満たすために、そのフィールドの前に不可視のパディングバイトを挿入する。具体例を示す。
use std::mem;
struct Foo {
a: u8, // アライメント 1、サイズ 1
b: u32, // アライメント 4、サイズ 4
c: u16, // アライメント 2、サイズ 2
}
fn main() {
println!("size: {}", mem::size_of::<Foo>()); // 12
println!("align: {}", mem::align_of::<Foo>()); // 4
}
メモリ上のレイアウトは次のようになる。
offset 0: a (1 バイト)
offset 1: padding (3 バイト — b がオフセット 4 から始まるように)
offset 4: b (4 バイト)
offset 8: c (2 バイト)
offset 10: padding (2 バイト — 構造体のサイズが 4 の倍数になるように)
合計:12バイト。生のフィールドを合計すると7バイトだが、残りの5バイトはパディングだ。
構造体自身のアライメントは、フィールドの中で最大のアライメント — ここでは b: u32 の4 — だ。構造体の合計サイズもアライメントの倍数でなければならない(配列が正しく動くために)、そのため末尾に2バイトのパディングが追加される。
フィールドの並べ替えによるパディングの削減
アライメントの降順(大きいものから)にフィールドを並べると、無駄なスペースを最小化できる。
use std::mem;
struct FooOptimal {
b: u32, // アライメント 4、サイズ 4 — オフセット 0
c: u16, // アライメント 2、サイズ 2 — オフセット 4
a: u8, // アライメント 1、サイズ 1 — オフセット 6
// サイズを 8 に切り上げるための 1 バイト末尾パディング
}
fn main() {
println!("{}", mem::size_of::<FooOptimal>()); // 12 から 8 に削減
}
Rustのデフォルトレイアウト(repr(Rust))はフィールドの特定の順序を保証せず、コンパイラが独自に並べ替えることがある。実際にコンパイラがフィールドを並べ替えることは多いが、それに依存することはできない。repr(C) 構造体を制御する場合は、プロファイルに基づく手動の並べ替えが依然として有用だ。
#[repr(C)] — C互換レイアウト
デフォルトではRustはフィールドを自由に並べ替えてパディングを挿入できる。#[repr(C)] はその自由度を無効にする。フィールドは宣言順に、C互換のパディング規則でレイアウトされる。
#[repr(C)]
struct Header {
version: u8,
length: u32,
flags: u16,
}
#[repr(C)] を使う場面:
- FFI境界を越えてCコードに構造体を渡すとき
- 既知のオフセットに依存する
unsafeコードで構造体を使うとき - ネットワークやファイルフォーマットの仕様に合わせるとき
レイアウトは予測可能だが、コンパイラのデフォルトより多くのスペースを無駄にすることがある。
#[repr(packed)] — パディングなし
#[repr(packed)] はすべてのパディングを取り除き、フィールドをできる限り密に詰め込む。
#[repr(packed)]
struct Packed {
a: u8,
b: u32,
c: u16,
}
size_of::<Packed>() は7になる — フィールドサイズの合計に等しい。
重大な危険がある。パックされた構造体のフィールドへの参照を取ると、ミスアライメントされた参照が生成され、Rustでは未定義動作(undefined behaviour)になる。コンパイラは警告を出すが、コードはコンパイルされてしまうことがある。
#[repr(packed)]
struct Packed {
a: u8,
b: u32,
}
fn main() {
let p = Packed { a: 1, b: 2 };
let b = p.b; // OK — 値をコピーアウトする
// let r = &p.b; // UB: ミスアライメントされた参照を生成する
}
#[repr(packed)] はバイナリサイズやワイヤーフォーマットが絶対に必要とする場面に限定し、フィールドへの参照は決して保持しないこと。
#[repr(align(N))] — アライメントの引き上げ
#[repr(align(N))](N は2の冪)で、型のアライメントを自然な値より引き上げられる。
use std::mem;
#[repr(align(64))]
struct CacheAligned {
data: u32,
}
fn main() {
println!("{}", mem::align_of::<CacheAligned>()); // 64
println!("{}", mem::size_of::<CacheAligned>()); // 64 (切り上げられる)
}
これは構造体が独自のキャッシュラインを占有することを保証するのに有用で、スレッド間のフォールスシェアリング(false sharing)を避けるためのテクニックだ。#[repr(packed)] でアライメントを下げることはできるが、#[repr(packed)] でアライメントを上げることはできない。
まとめ
- 型のアライメントは値が始まらなければならない2の冪乗のアドレス境界であり、
std::mem::align_of::<T>()で返される。 - ミスアライメントされたアクセスはパフォーマンスを低下させ、アトミック性の保証を破り、一部のハードウェアではフォールトを起こす。Rustはアライメントを自動的に強制する。
- コンパイラはアライメント要件を満たすために構造体フィールド間と構造体末尾にパディングを挿入する。
- フィールドをアライメントの大きい順に並べ替えると通常パディングが最小化される。
#[repr(C)]はCインタロップのためにフィールド順とパディングを固定する。#[repr(packed)]はパディングを取り除くが、ミスアライメントされた参照を未定義動作にする。#[repr(align(N))]は型のアライメントを引き上げる。