长期以来,Rust 在 x86-32 和 x86-64 架构上,128 位整数的对齐方式与 C 存在不一致。这个问题最近已得到解决,但修复带来了一些值得注意的影响。
作为用户,你很可能无需担心这些变更,除非你在执行以下操作:
- 假设
i128
/u128
的对齐方式,而非使用align_of
- 忽略
improper_ctypes*
lint 警告并在 FFI 中使用这些类型
除了 x86-32 和 x86-64 之外的架构没有发生变化。如果你的代码大量使用 128 位整数,你可能会注意到运行时性能有所提升,但这可能伴随着额外的内存使用。
本文档描述了问题所在、为解决问题所做的更改以及这些更改的影响。如果你已经熟悉这个问题并且只想查看兼容性矩阵,请跳转到兼容性部分。
背景
数据类型有两个与其在内存中的排列方式相关的固有值:大小(size)和对齐(alignment)。类型的大小是它在内存中占用的空间量,而其对齐方式指定了它被允许放置在哪些地址。
基本类型等简单类型的大小通常是明确的,它们就是所代表数据的精确大小,不包含填充(未使用空间)。例如,一个 i64
的大小始终是 64 位或 8 字节。
然而,对齐方式可能有所不同。一个 8 字节的整数可以存储在任何内存地址(1 字节对齐),但大多数 64 位计算机如果在地址是 8 的倍数处存储(8 字节对齐),将获得最佳性能。因此,与其它语言一样,Rust 中的基本类型默认具有这种最有效的对齐方式。在创建复合类型时可以看到这种效果(playground 链接)
use ;
println!;
println!;
println!;
println!;
输出
Offset of b (u16) in Foo: 2
Alignment of Foo: 2
Offset of b (u64) in Bar: 8
Alignment of Bar: 8
我们看到,在结构体内部,一个类型总是会被放置,使其偏移量是其对齐方式的倍数——即使这意味着存在未使用空间(当不使用 repr(C)
时,Rust 默认会最小化这种情况)。
这些数字并非随意决定;应用程序二进制接口(ABI)规定了它们应该是什么。在适用于 System V (Unix & Linux) 的 x86-64 psABI(处理器专用 ABI)中,图 3.1:标量类型 精确地告诉了我们基本类型应该如何表示
C 类型 | Rust 对应类型 | 大小 (sizeof) | 对齐 (字节) |
---|---|---|---|
char | i8 | 1 | 1 |
unsigned char | u8 | 1 | 1 |
short | i16 | 2 | 2 |
unsigned short | u16 | 2 | 2 |
long | i64 | 8 | 8 |
unsigned long | u64 | 8 | 8 |
ABI 只规定了 C 类型,但 Rust 为了兼容性和性能优势,遵循了相同的定义。
对齐错误的问题
如果两个实现对于某个数据类型的对齐方式存在分歧,它们就无法可靠地共享包含该类型的数据。Rust 在 128 位类型的对齐上存在不一致
println!;
// rustc 1.76.0
alignment of i128: 8
;
// gcc 13.2
alignment of __int128: 16
// clang 17.0.1
alignment of __int128: 16
(Godbolt 链接)回看 psABI,我们可以看到 Rust 在这里有错误的对齐方式
C 类型 | Rust 对应类型 | 大小 (sizeof) | 对齐 (字节) |
---|---|---|---|
__int128 | i128 | 16 | 16 |
unsigned __int128 | u128 | 16 | 16 |
事实证明,这并非因为 Rust 在主动做错什么:基本类型的布局来自于 Rust 和 Clang 等语言使用的 LLVM 代码生成后端,并且它将 i128
的对齐方式硬编码为 8 字节。
Clang 之所以使用正确的对齐方式,仅仅是因为一个变通方法:在将类型交给 LLVM 之前,手动将对齐方式设置为 16 字节。这解决了布局问题,但也带来了一些其他小问题。12 Rust 没有进行这样的手动调整,因此出现了在 https://github.com/rust-lang/rust/issues/54341 报告的问题。
调用约定问题
还有一个额外的问题:LLVM 在将 128 位整数作为函数参数传递时,并不总是能正确处理。这是 LLVM 中的一个已知问题,在发现其与 Rust 的相关性之前就已经存在。
调用函数时,参数首先通过寄存器(CPU 内部的特殊存储位置)传递,直到没有更多空位,然后它们会被“溢出”(spill)到栈上(程序的内存)。ABI 也告诉我们在这种情况下的处理方式,具体在3.2.3 参数传递章节
__int128
类型的参数提供与 INTEGER 类型相同的操作,但它们无法放入一个通用寄存器,而是需要两个寄存器。出于分类目的,__int128
被视为如同实现了以下结构:typedef struct __int128;
例外情况是,存储在内存中的
__int128
类型参数必须按 16 字节边界对齐。
我们可以通过手动实现调用约定来验证这一点。在下面的 C 示例中,使用内联汇编调用 foo(0xaf, val, val, val)
,其中 val
为 0x11223344556677889900aabbccddeeff
。
x86-64 使用寄存器 rdi
, rsi
, rdx
, rcx
, r8
和 r9
来按顺序传递函数参数(你猜对了,这也记录在 ABI 中)。每个寄存器能容纳一个字(64 位),任何无法容纳的内容都会被 push
到栈上。
/* full example at <https://godbolt.org/z/5c8cb5cxs> */
/* to see the issue, we need a padding value to "mess up" argument alignment */
void
int
使用 GCC 运行上述代码会打印出以下预期输出
0xaf
0x11223344556677889900aabbccddeeff
0x11223344556677889900aabbccddeeff
0x11223344556677889900aabbccddeeff
但使用 Clang 17 运行会打印出
0xaf
0x11223344556677889900aabbccddeeff
0x11223344556677889900aabbccddeeff
0x9900aabbccddeeffdeadbeef4c0ffee0
//^^^^^^^^^^^^^^^^ this should be the lower half
// ^^^^^^^^^^^^^^^^ look familiar?
意外!
这说明了第二个问题:LLVM 在可能的情况下,期望将 i128
分一半通过寄存器传递,另一半通过栈传递,但这不符合 ABI 的规定。
由于这种行为源于 LLVM 且没有合理的变通方案,因此这在 Clang 和 Rust 中都是一个问题。
解决方案
解决这些问题是许多人长期努力的结果,始于编译器团队成员 Simonas Kazlauskas 在 2017 年提交的一个补丁:D28990。不幸的是,这个补丁后来被撤销了。LLVM 贡献者 Harald van Dijk 后来在 D86310 中再次尝试,该版本最终于 2023 年 10 月合入。
大约在同一时间,Nikita Popov 通过 D158169 修复了调用约定问题。这两个更改都已合入 LLVM 18,这意味着使用此版本的 Clang 和 Rust (Clang 18 以及使用捆绑 LLVM 的 Rust 1.78) 将解决所有相关的 ABI 问题。
然而,rustc
也可以使用系统上安装的 LLVM 版本,而不是捆绑版本,这可能导致版本较旧。为了减轻同一 rustc
版本因对齐方式不同而产生问题的几率,有人提出了一个建议,像 Clang 那样手动修正对齐方式。这由 Matthew Maurer 在#116672 中实现。
自这些更改以来,Rust 现在生成正确的对齐方式
println!;
// rustc 1.77.0
alignment of i128: 16
如上所述,ABI 规定数据类型对齐方式的部分原因在于它在该架构上更高效。我们实际上亲身验证了这一点:手动对齐更改的初步性能测试显示,编译器性能(其大量依赖 128 位整数处理整数字面量)有了显著提升。增加对齐方式的缺点是复合类型在内存中并非总是能很好地紧密排列,导致内存使用增加。不幸的是,这意味着需要牺牲一部分性能提升来避免增加内存占用。
兼容性
最重要的问题是这些修复如何改变了兼容性。简而言之,使用 LLVM 18(从 1.78 版本开始的默认版本)的 Rust 中的 i128
和 u128
将与任何版本的 GCC 以及 Clang 18 及更高版本(2024 年 3 月发布)完全兼容。所有其他组合都存在一些不兼容的情况,总结在下表中
编译器 1 | 编译器 2 | 状态 |
---|---|---|
Rust ≥ 1.78 使用捆绑 LLVM (18) | GCC (任意版本) | 完全兼容 |
Rust ≥ 1.78 使用捆绑 LLVM (18) | Clang ≥ 18 | 完全兼容 |
Rust ≥ 1.77 使用 LLVM ≥ 18 | GCC (任意版本) | 完全兼容 |
Rust ≥ 1.77 使用 LLVM ≥ 18 | Clang ≥ 18 | 完全兼容 |
Rust ≥ 1.77 使用 LLVM ≥ 18 | Clang < 18 | 存储兼容,存在调用 bug |
Rust ≥ 1.77 使用 LLVM < 18 | GCC (任意版本) | 存储兼容,存在调用 bug |
Rust ≥ 1.77 使用 LLVM < 18 | Clang (任意版本) | 存储兼容,存在调用 bug |
Rust < 1.773 | GCC (任意版本) | 不兼容 |
Rust < 1.773 | Clang (任意版本) | 不兼容 |
GCC (任意版本) | Clang ≥ 18 | 完全兼容 |
GCC (任意版本) | Clang < 18 | 存储兼容,存在调用 bug |
影响与未来步骤
正如引言中所述,大多数用户不会注意到此更改的影响,除非你已经在使用这些类型做一些有疑问的事情。
从 Rust 1.77 开始,在 FFI 中试验 128 位整数将是相对安全的,而在 1.78 版本的 LLVM 更新后,确定性会更高。关于在即将发布的版本中取消 lint 警告,目前正在进行讨论,但我们希望谨慎行事,避免给使用旧版 LLVM 构建的 Rust 编译器用户引入静默的破坏性变更。