Rust 编译器中一个令人惊讶的 enum 大小优化
jpfennell.com > posts > 2025-04-07
Rust 编译器中一个令人惊讶的 enum 大小优化
2025 年 4 月 7 日
Enum 是 Rust 中最受欢迎的特性之一。Enum 是一种类型,其值是指定的一组变体之一。
/// Foo 要么是一个 32 位整数,要么是一个字符。
enum Foo {
Int(u32),
Char(char),
}
Foo
类型的值可以是整数(例如,带有有效载荷 3
的变体 Foo::Int(3)
),也可以是字符(例如,带有有效载荷 'A'
的变体 Foo::Char('A')
)。如果你认为 struct 是其字段的 and 组合,那么 enum 就是其变体的 or 组合。
这篇文章是关于 Rust 编译器对 enum 值的内存表示执行的一个令人惊讶的优化,目的是使其内存使用量更小(剧透:它不是 niche 优化)。一般来说,保持值较小可以提高程序的运行速度,因为值可以在 CPU 寄存器中传递,并且更多的值可以放入单个 CPU 缓存行中。
通常,enum 的大小是最大有效载荷的大小,再加上一些额外的字节,用于存储指定值的变体的 tag。对于上面的 Foo
类型,两个变体的有效载荷都占用 4 个字节,并且我们至少需要一个额外的字节作为 tag。一个名为“类型对齐”(我不会深入讨论)的要求要求 Rust 实际上使用 4 个字节作为 tag。因此,该类型的总大小为 8 个字节:
assert_eq!(std::mem::size_of::<Foo>(), 8);
(所有代码片段都可以在 Rust Playground 中运行。)
在本文的其余部分,实际查看各种 enum 值的内存表示将非常有用且有趣。这是一个打印任何 Rust 值的原始字节表示的函数:
/// 打印类型 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!();
}
(此函数改编自这篇 10 年前的 Reddit 帖子。)
让我们为我们的 enum Foo
运行它。
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 -|
首先要指出的是,这台计算机的内存是 little endian,因此最低有效字节排在最前面。在 32 位十六进制中,数字 5 是 0x00000005
,但其 little endian 表示形式是 05 00 00 00
。
考虑到这一点,我们看到前 4 个字节是 tag。整数变体已分配 tag 0,字符变体已分配 tag 1。后 4 个字节只是有效载荷的通常值。请注意,小写字母“a”在 ASCII 中是 41,这就是其内存表示形式为 41 00 00 00
的原因。
niche 优化
除了通用的 tags 方案外,还有一种众所周知的 enum 大小优化,称为 niche 优化。这种优化适用于只有其中一个变体具有有效载荷的类型。一个很好的例子是内置的 option 类型:
enum Option<char> {
None,
Some(char),
}
根据上一节中的 tags 分析,我们可能会猜测 enum 大小将为 8 个字节(最大的 4 字节有效载荷加上 4 个字节的 tag)。但实际上,这种类型的值总共只使用 4 个字节的内存:
assert_eq!(std::mem::size_of::<Option<char>>(), 4);
发生了什么?Rust 编译器知道,虽然 char
占用 4 个字节的内存,但并非这 4 个字节的每个值都是 char
的有效值。Char 只有大约 2^21
个有效值(每个 Unicode 代码点一个),而 4 个字节支持 2^32
个不同的值。编译器选择其中一种无效的位模式作为 niche。然后,它表示 enum 值而不使用 tags。它以与 char 相同的方式表示 Some
变体。它使用 niche 表示 None
变体。
一个有趣的问题是:Rust 到底使用什么 niche?让我们打印内存表示形式来看看:
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
正如我们所看到的,'A'
和 Some('A')
的内存表示形式是相同的。Rust 使用 32 位数字 0x00110000
表示 None
。快速搜索显示,这个数字正好比最大的有效 Unicode 代码点大 1。
超越 niche 优化?
我之前的理解是 Rust 不会执行任何更多的优化,所以最近当我发现一个优化时,我感到非常惊喜。
上下文是嵌套的 enum。从一个内部 enum 开始
enum Inner {
A(u32),
B(u32),
}
如果我们查看内存中的表示形式,它正如我们所预期的那样:8 个字节,其中前 4 个字节存储 tag,后 4 个字节存储有效载荷。
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 -|
现在添加另一个包含 Inner
enum 作为有效载荷的 enum:
enum Outer {
C(u32),
D(Inner),
}
我的猜测是这种类型的值的大小为 12 个字节 - Inner
的 8 个字节(最大有效载荷),加上 tag 的 4 个字节。但事实并非如此 - 值仅占用 8 个字节!
assert_eq!(std::mem::size_of::<Outer>(), 8);
这里发生了什么?
首先,让我们检查一下 Outer::C
类型的值在内存中的样子:
print_memory_representation(Outer::C(5));
// type=Outer value=C(5): 02 00 00 00 05 00 00 00
// |-- tag --| |- value -|
我们已经看到了一些奇怪的事情:Rust 选择使用 tag 数字 2 作为 Outer::C
,而不是像对 Inner::A
那样从 0 开始。接下来看看 Outer::D
:
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 -|
Outer::D(inner)
值的表示形式与 inner
的表示形式相同!
我猜 Rust 编译器已经将以下部分放在一起:
Inner
的前 4 个字节构成一个 tag,其值仅为 0 或 1。特别是,我们可以在这里存储更多的值,就像在 niche 优化中一样。Outer
的每个其他变体的有效载荷都不大于Inner
的任何有效载荷。特别是,如果Inner
值的形式为<Inner tag><Inner payload>
,则Outer
的每个其他变体的有效载荷都适合<Inner payload>
。
因此,我们可以将 Outer
的值表示为 <Outer tag><Outer remainder>
的形式,其中
- 如果
<Outer tag>
匹配任何<Inner tag>
,则该值为Outer::D
,有效载荷为整个位模式<Outer tag><Outer remainder>
。 - 否则,该值是另一个变体,有效载荷位于
<Outer remainder>
中。