Rust は niche value optimization(ニッチ値最適化)を使用して、型が無効なビットパターンを持つ場合に列挙型の識別子ストレージを排除します。Option<&T> の None バリアントは、常に非ヌルでなければならない &T の無効なビットパターンであるヌルポインタ値で表されます。これにより、コンパイラはポインタ自体内に識別子を暗黙的に保存でき、Option<&T> が正確に1つのマシンワードを占めます。この最適化は、無効なビットパターン(たとえば、NonZeroU32 の 0 や bool の 0 または 1 の外の値、または #[repr(C)] 構造体の特定のセントリ値)を持つ任意の型に適用されます。
数百万のノードを処理するコンパイラのための高性能抽象構文木(AST)の開発中、親子ポインタからの重いメモリプレッシャーに直面しました。各ノードは親、左の子、右の子へのオプションの参照を必要とし、最初は Option<Box<Node>> として実装されていました。
Option<Box<Node>> の使用は、64ビットシステムでポインタごとに16バイトを消費しました — Box ポインタに8バイト、識別子およびパディングに8バイトです。1000万ノードのツリーでは、これだけで480メガバイトになり、私たちのメモリ予算を超えてしまいました。
私たちは三つのアプローチを検討しました。最初は、ヌルを None とする生ポインタ(*mut Node)に Option<Box<Node>> を置き換えることでした。これはオーバーヘッドを排除しましたが、コード全体で unsafe ブロックを必要とし、ダングリングポインタのリスクや Rust の安全性の保証に違反する可能性がありました。第二に、ポインタの代わりにインデックス(usize)を持つアリーナアロケータを使用しました。これはキャッシュフレンドリーでしたが、Option<usize> では、usize にニッチがないために16バイトを消費し、インデックス算術がAPIを複雑にしました。
私たちは三番目のアプローチを選びました: Option<NonNull<Node> を安全な ParentPtr 抽象にラップしました。NonNull<T> はアドレス 0 にニッチを持ち、Option<NonNull<Node>> が8バイトのままであることを可能にします。ラッパーメソッド内で unsafe のデリファレンスをカプセル化し、安全性を確保しつつゼロコストの抽象化を実現しました。これにより、ASTのメモリフットプリントが50%削減され、256MBの制約内に収まりつつ安全性を損なうことはありませんでした。
なぜ Option<Option<bool>> は1バイトのままで、Option<Option<usize>> が16バイトに拡張されるのですか?
bool は有効なビットパターンが 0 と 1 のみであるため、254のニッチ値を持っています。最初の Option レイヤーは None を表すために1つのニッチを消費する(例えば 2)、残りのニッチは253です。二番目の Option レイヤーは、その None バリアントのためにもう1つのニッチを消費します(例えば 3)。その結果、Option<Option<bool>> は依然として1バイト内に収まります。対照的に、usize には無効なビットパターンがないため - すべての 2^64 値は有効なメモリアドレスまたはデータです。ニッチがないため、Option<usize> は識別子バイトを追加する必要があり、結果として16バイト(データ用8バイト、アライメント用8バイト)になります。ネストされた Option レイヤーは利用可能なニッチがない限り、さらに最適化できないため、Option<Option<usize>> は内部の識別子ロジックにより16バイトのままとなります。
なぜコンパイラは、ペイロード型がニッチを含んでいる場合でも、#[repr(C)] でマークされた列挙型に対するニッチ最適化を拒否するのですか?
#[repr(C)] 属性は、安定したフィールド順序と予測可能なオフセットで明示的な識別子ストレージを持つC互換のメモリレイアウトを保証します。C言語の標準では、ペイロードデータとの識別子値のオーバーラップをサポートしておらず、外部Cコードが固定オフセットで明確な識別子値を読み取ることを期待するため、識別子は専用のメモリ位置に存在しなければなりません。NonNull<T> のような構造体はニッチ(ヌルポインタ)を含んでいますが、#[repr(C)] 列挙型はこれらを識別子とオーバーラップさせてはならないのは、外部Cコードが固定オフセットでの特定の識別子値を読み取ることを期待しているためです。この制限は、メモリ効率に対して相互運用性を保持し、#[repr(C)] の下で sizeof(Option<&T>) が sizeof(&T) + sizeof(識別子) に等しいことを保証します。一般に16バイトではなく8バイトです。
std::mem::discriminant は、明示的な識別子ストレージをメモリに持たないような Option<&T> のような型に対してどのように機能しますか?
std::mem::discriminant は、基礎となるメモリ表現に関係なく列挙型のバリアントを一意に識別する不透明な Discriminant<T> 値を返します。Option<&T> の場合、コンパイラはポインタ値を検査することで識別子を導き出すコードを生成します - ポインタがヌルでなければ Some を表す定数を返し、ヌルであれば None を表す定数を返します。識別子タグを格納するための別のメモリ位置は存在しませんが、Discriminant 型はこの計算を抽象化し、ニッチエンコーディングの詳細を公開せずに == を介してバリアントの比較を可能にします。これは、識別子が物理メモリレイアウトではなく意味論的なバリアントの同一性に基づいて機能することを示し、最適化された列挙型と非最適化された列挙型の表現全体で一貫した動作を可能にします。