Zheng Junyi
5/3/2025
翻译了一篇 James Fennell 写的博客。主要介绍了 Rust 编译器对于(嵌套的)枚举大小优化的新思路。
枚举(Enum)是Rust中最受欢迎的特性之一。枚举是一种类型,其值为一组预定义变体(variants)中的某一个。
类型为 Foo 的值要么是整数(例如变体 Foo::Int(3),其负载值为 3),要么是字符(例如变体 Foo::Char('A'),其负载值为 'A')。如果将结构体视为其字段的「与」组合,那么枚举就是其变体的「或」组合。
这篇文章讲述的是 Rust 编译器对枚举值内存表示进行的一项令人惊讶的优化,目的是减小它们的内存占用(剧透:这不是「空位优化」)。一般来说,保持值较小可以使程序运行更快,因为值会在 CPU 寄存器中传递,并且更多的值可以放入单个 CPU 缓存行中。
通常情况下,枚举的大小等于其最大负载成员的大小,再加上用于存储标识当前值属于哪个变体的标签所需的额外字节。以上述 Foo 类型为例,两个变体的负载各占 4 字节,同时至少需要额外 1 字节来存储标签。但由于一种称为「类型对齐」的要求(此处不展开说明),Rust 实际上会为每个标签分配 4 字节空间。因此该类型的总大小为 8 字节:
所有代码片段都可以在 Rust Playground 中直接运行。
在本文的后续部分,实际查看各种枚举值的内存表示形式将既实用又有趣。这里有一个函数,它能打印出任何Rust值的原始字节表示:
此函数改编自这篇 10 年前的 Reddit 帖子。
让我们为枚举 Foo 运行这个函数。
首先需要指出的是,这台计算机采用小端序存储方式,因此低位字节在前。以 32 位十六进制数 5 为例,其标准表示为 0x00000005,但小端序存储格式为 05 00 00 00。
基于此规则,我们可以看到前4字节是类型标记。整型变体被分配标记 0,字符型变体标记 1。随后的 4 字节则是常规的有效载荷数值。需注意大写字母 "A" 的ASCII码对应十六进制 41,因此其内存表示为 41 00 00 00。
除了通用的标签方案外,还有一种著名的枚举大小优化技术,称为「空位优化」。这种优化适用于仅有一个变体携带有效载荷的类型。内置的 Option 类型就是典型范例:
根据上一节对标签的分析,我们可能猜测枚举类型的大小会是 8 字节(最大有效载荷 4 字节加上标签占用的 4 字节)。但实际上,该类型的值总共仅占用 4 字节内存:
为什么会这样呢?Rust 编译器知道虽然 char 类型占用 4 字节内存空间,但并非这 4 字节的所有取值都是有效的 char 值。char 仅有约 个有效取值(每个 Unicode 码对应一个),而 4 字节理论上能表示 种不同值。编译器会选取其中一个无效位模式作为「空位(niche)」,这样就能无需标签直接表示该枚举类型 —— Some 变体与 char 的存储方式完全相同,而 None 变体则用这个预留的空位值表示。
一个有趣的问题是:Rust 具体使用了哪些内存空间?让我们打印内存表示来一探究竟:
如我们所见,'A' 与 Some('A') 的内存表示完全相同。Rust 使用 32 位数值 0x00110000 来表示 None。快速检索可知,该数值恰好比最大有效 Unicode 码大 1。
我原本的理解是 Rust 不会进行更多优化了,但最近发现它确实会优化时,我感到了惊喜。
具体场景涉及嵌套枚举。首先从一个内部枚举开始:
如果我们查看内存中的表示形式,会发现正如预期:8 个字节,其中前 4 个字节存储标签,后 4 个字节存储有效载荷。
现在添加另一个枚举,其中包含作为有效载荷的内部枚举:
我猜测这种类型的值大小会是 12 字节 —— 其中 8 字节用于最大的负载 Inner,再加上 4 字节用于标签。但实际上并非如此 —— 这些值仅占用了 8 字节!
为什么会这样呢?
首先,我们来看看类型 Outer::C 在内存中的值表现为何种形式:
我们已经看到一些奇怪的现象:Rust 选择为 Outer::C 使用标签号 2,而不是像对 Inner::A 那样从 0 开始。接着看 Outer::D 的情况:
值 Outer::D(inner) 的表示形式与 Inner 的表示形式完全相同!
我猜 Rust 编译器已将以下部分整合在一起:
Inner 的前 4 个字节构成一个标签,其值仅为 0 或 1。特别地,我们还可以在此存储更多数值,正如空位优化中所采用的方式。
对于 Outer 的其他所有变体,其有效载荷均不超过 Inner 任一有效载荷的大小。具体而言,若 Inner 值的格式为 <Inner 标签><Inner 有效载荷>,则 Outer 其他所有变体的有效载荷均可容纳于 <Inner有效载荷> 之内。
因此,我们可以将 Outer 的值表示为 <Outer 标签><Outer 其余部分> 的形式,其中:
若 <Outer 标签> 匹配任意 <Inner 标签>,则值为 Outer::D 且有效载荷为完整的位模式 <Outer 标签><Outer 其余部分>。
否则,值为另一变体且有效载荷位于 <Outer 其余部分> 中。
/// Foo is either a 32-bit integer or character.
enum Foo {
Int(u32),
Char(char),
}
assert_eq!(std::mem::size_of::<Foo>(), 8);
/// Print the memory representation of a value of a type T.
fn print_memory_representation<T: std::fmt::Debug>(t: T) {
print!("type={} value={t:?}: ", std::any::type_name::<T>());
let start = &t as *const _ as *const u8;
for i in 0..std::mem::size_of::<T>() {
print!("{:02x} ", unsafe {*start.offset(i as isize)});
}
println!();
}
print_memory_representation(Foo::Int(5));
// type=Foo value=Int(5): 00 00 00 00 05 00 00 00
// |-- tag --| |- value -|
print_memory_representation(Foo::Char('A'));
// type=Foo value=Char('A'): 01 00 00 00 41 00 00 00
// |-- tag --| |- value -|
enum Option<char> {
None,
Some(char),
}
assert_eq!(std::mem::size_of::<Option<char>>(), 4);
let a: char = 'A'
print_memory_representation(a);
// type=char value='A': 41 00 00 00
print_memory_representation(Some(a));
// type=Option<char> value=Some('A'): 41 00 00 00
let none: Option<char> = None;
print_memory_representation(none);
// type=Option<char> value=None: 00 00 11 00
enum Inner {
A(u32),
B(u32),
}
assert_eq!(std::mem::size_of::<Inner>(), 8);
print_memory_representation(Inner::A(2));
// type=Inner value=A(2): 00 00 00 00 02 00 00 00
// |-- tag --| |- value -|
print_memory_representation(Inner::B(3));
// type=Inner value=B(3): 01 00 00 00 03 00 00 00
// |-- tag --| |- value -|
enum Outer {
C(u32),
D(Inner),
}
assert_eq!(std::mem::size_of::<Outer>(), 8);
print_memory_representation(Outer::C(5));
// type=Outer value=C(5): 02 00 00 00 05 00 00 00
// |-- tag --| |- value -|
print_memory_representation(Outer::D(Inner::A(2)));
// type=Outer value=D(A(2)): 00 00 00 00 02 00 00 00
// |-- tag --| |- value -|
print_memory_representation(Outer::D(Inner::B(3)));
// type=Outer value=D(B(3)): 01 00 00 00 03 00 00 00
// |-- tag --| |- value -|
通过复用嵌套枚举的内部标签空间,将外层枚举的标签嵌入到内部枚举未使用的标签值中,从而无需额外存储外层标签,显著减少了枚举的内存占用;这种优化超越了传统的空位优化方法。