Rust 采用 小众值优化(也称为小众填充)来消除枚举的标识符存储,当一个变体包含一个无效位模式的类型时。对于 Option<&T>,None 变体由空指针值表示——对于 &T,因为引用必须始终是非空的,这是一种无效的位模式。这允许编译器在指针本身内隐式存储标识符,从而确保 Option<&T> 正好占用一个机器字。这种优化适用于具有 小众 值的任何类型——无效的位模式,例如 0 对于 NonZeroU32,对于 bool 的值在 0 或 1 之外,或者在 #[repr(C)] 结构中的特定哨兵值。
在开发一个用于处理数百万节点的编译器的高性能抽象语法树 (AST) 时,我们面临来自父子指针的严重内存压力。每个节点都需要对其父节点、左子节点和右子节点的可选引用,最初实现为 Option<Box<Node>>。
使用 Option<Box<Node>> 在 64 位系统上每个指针需消耗 16 字节——8 字节用于 Box 指针,8 字节用于标识符和填充。对于一个包含 1000 万个节点的树,这仅指链接指针就达到了 480 兆字节,超过了我们的内存预算。
我们考虑了三种方法。首先,用原始指针 (*mut Node) 替换 Option<Box<Node>>,使用空指针表示 None。这消除了开销,但要求整个代码库中使用 unsafe 块,风险在于悬挂指针和违反 Rust 的安全保证。其次,使用带有索引的区域分配器 (usize) 代替指针。虽然具有缓存友好性,但 Option<usize> 仍需 16 字节,因为 usize 中没有小众值,索引运算也使 API 复杂化。
我们选择了第三种方法:用安全的 ParentPtr 抽象封装的 Option<NonNull<Node>>。NonNull<T> 在地址 0 处带有小众,使 Option<NonNull<Node>> 保持为 8 字节。我们在包装方法中封装了 unsafe 解引用,保持内存安全,同时实现零成本抽象。这将 AST 的内存占用减少了 50%,符合我们的 256MB 限制而不牺牲安全。
为什么 Option<Option<bool>> 仍然是一个字节,而 Option<Option<usize>> 扩展到 16 字节?
bool 具有 254 个小众值,因为只有位模式 0 和 1 是有效的。第一个 Option 层消耗一个小众(例如 2)来表示 None,其余 253 个小众可用。第二个 Option 层消耗另一个小众(例如 3)作为其 None 变体。因此,Option<Option<bool>> 仍然适合一个字节。相比之下,usize 没有无效位模式——所有 2^64 值都是有效的内存地址或数据。没有小众,Option<usize> 必须附加一个标识字节,导致 16 字节(8 字节用于数据,8 字节用于对齐)。嵌套的 Option 层在没有可用小众的情况下无法进一步优化,因此 Option<Option<usize>> 仍然是 16 字节,内部存在标识逻辑。
为什么编译器对标记为 #[repr(C)] 的枚举拒绝小众优化,尽管有效载荷类型包含小众?
#[repr(C)] 属性保证了与 C 兼容的内存布局,具有稳定的字段顺序和位于可预测偏移量的显式标识符存储。C 语言标准不支持与有效载荷数据重叠的标识值——标识符必须位于专用内存位置,以确保 FFI 兼容性。虽然像 NonNull<T> 的结构包含小众(空指针),但 #[repr(C)] 的枚举不能利用这些小众与标识符重叠,因为外部 C 代码期望在固定偏移量处读取一个不同的标识符值。这项限制在牺牲内存效率的情况下保持了互操作性,确保 sizeof(Option<&T>) 等于 sizeof(&T) + sizeof(discriminant) 在 #[repr(C)] 下,通常是 16 字节,而不是 8。
对于像 Option<&T> 这样的类型,std::mem::discriminant 函数如何工作,因为它在内存中缺少显式的标识符存储?
std::mem::discriminant 返回一个不透明的 Discriminant<T> 值,独特地识别枚举变体,无论其底层内存表示如何。对于 Option<&T>,编译器生成代码通过检查指针值来推导标识符——如果指针非空,则返回表示 Some 的常量,如果指针为空,则返回表示 None 的常量。尽管没有单独的内存位置存储标识符标记,但 Discriminant 类型抽象了这一计算,允许通过 == 进行变体比较,而不暴露小众编码细节。这表明 discriminant 作用于语义变体身份而非物理内存布局,使优化和非优化的枚举表示之间能够保持一致的行为。