1.77 和 1.78 版本中 `u128`/`i128` 布局的更改

2024 年 3 月 30 日 · Trevor Gross 代表 Rust 语言团队

长期以来,Rust 在 x86-32 和 x86-64 架构上关于 128 位整数的对齐方式与 C 语言存在不一致。这个问题最近得到了解决,但这个修复带来了一些值得注意的影响。

作为用户,您很可能不需要担心这些更改,除非您正在

  1. 假设 `i128`/`u128` 的对齐方式,而不是使用 `align_of`
  2. 忽略 `improper_ctypes*` lint,并在 FFI 中使用这些类型

此外,除了 x86-32 和 x86-64 之外的架构没有任何变化。如果您的代码大量使用 128 位整数,您可能会注意到运行时性能有所提高,但可能会增加内存使用量。

这篇文章记录了问题所在,为了解决问题所做的更改,以及对这些更改的预期。如果您已经熟悉这个问题,只是在寻找兼容性矩阵,请跳转到兼容性部分。

背景

数据类型有两个与它们在内存中的排列方式相关的内在值:大小和对齐方式。类型的大小是它在内存中占用的空间量,而对齐方式指定了它允许放置的地址。

像基本类型这样的简单类型的大小通常是明确的,即它们表示的数据的确切大小,没有填充(未使用的空间)。例如,一个 `i64` 总是具有 64 位或 8 字节的大小。

然而,对齐方式可能有所不同。一个 8 字节的整数 *可以* 存储在任何内存地址(1 字节对齐),但是大多数 64 位计算机如果将其存储在 8 的倍数上(8 字节对齐),将会获得最佳性能。因此,像其他语言一样,Rust 中的基本类型默认具有这种最有效的对齐方式。当创建复合类型时,可以看到这种影响(playground 链接

use core::mem::{align_of, offset_of};

#[repr(C)]
struct Foo {
    a: u8,  // 1-byte aligned
    b: u16, // 2-byte aligned
}

#[repr(C)]
struct Bar {
    a: u8,  // 1-byte aligned
    b: u64, // 8-byte aligned
}

println!("Offset of b (u16) in Foo: {}", offset_of!(Foo, b));
println!("Alignment of Foo: {}", align_of::<Foo>());
println!("Offset of b (u64) in Bar: {}", offset_of!(Bar, b));
println!("Alignment of Bar: {}", align_of::<Bar>());

输出

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!("alignment of i128: {}", align_of::<i128>());
// rustc 1.76.0
alignment of i128: 8
printf("alignment of __int128: %zu\n", _Alignof(__int128));
// 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 主动做错了什么:基本类型的布局来自 LLVM 代码生成后端,Rust 和 Clang 以及其他语言都使用这个后端,而它将 `i128` 的对齐方式硬编码为 8 字节。

Clang 使用正确的对齐方式仅仅是因为一个变通方法,即在将类型传递给 LLVM 之前,手动将对齐方式设置为 16 字节。这修复了布局问题,但一直是其他一些小问题的根源。12 Rust 没有进行这样的手动调整,因此出现了 https://github.com/rust-lang/rust/issues/54341 中报告的问题。

调用约定问题

还有一个问题:当将 128 位整数作为函数参数传递时,LLVM 并非总是做正确的事情。这是 LLVM 中的一个已知问题,在发现它与 Rust 相关之前。

在调用函数时,参数会传递到寄存器(CPU 内的特殊存储位置),直到没有更多空位,然后它们会被“溢出”到堆栈(程序的内存)。ABI 在 3.2.3 参数传递 部分也告诉我们该怎么做

类型为 `__int128` 的参数提供与 INTEGER 相同的操作,但它们不适合一个通用寄存器,而是需要两个寄存器。为了分类目的,`__int128` 被视为好像它被实现为

typedef struct {
    long low, high;
} __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 foo(char pad, __int128 a, __int128 b, __int128 c) {
    printf("%#x\n", pad & 0xff);
    print_i128(a);
    print_i128(b);
    print_i128(c);
}

int main() {
    asm(
        /* load arguments that fit in registers */
        "movl    $0xaf, %edi \n\t"                /* 1st slot (edi): padding char (`edi` is the
                                                   * same as `rdi`, just a smaller access size) */
        "movq    $0x9900aabbccddeeff, %rsi \n\t"  /* 2nd slot (rsi): lower half of `a` */
        "movq    $0x1122334455667788, %rdx \n\t"  /* 3rd slot (rdx): upper half of `a` */
        "movq    $0x9900aabbccddeeff, %rcx \n\t"  /* 4th slot (rcx): lower half of `b` */
        "movq    $0x1122334455667788, %r8  \n\t"  /* 5th slot (r8):  upper half of `b` */
        "movq    $0xdeadbeef4c0ffee0, %r9  \n\t"  /* 6th slot (r9):  should be unused, but
                                                   * let's trick clang! */

        /* reuse our stored registers to load the stack */
        "pushq   %rdx \n\t"                       /* upper half of `c` gets passed on the stack */
        "pushq   %rsi \n\t"                       /* lower half of `c` gets passed on the stack */

        "call    foo \n\t"                        /* call the function */
        "addq    $16, %rsp \n\t"                  /* reset the stack */
    );
}

使用 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,这意味着所有相关的 ABI 问题将在使用此版本的 Clang 和 Rust 中得到解决(Clang 18 和 Rust 1.78 在使用捆绑的 LLVM 时)。

但是,`rustc` 也可以使用系统上安装的 LLVM 版本,而不是捆绑的版本,这可能会比较旧。为了减少由于相同 `rustc` 版本中对齐方式不同而产生的问题,一个提案被引入,以手动更正对齐方式,就像 Clang 一直在做的那样。这是由 Matthew Maurer 在 #116672 中实现的。

自从这些更改之后,Rust 现在会生成正确的对齐方式

println!("alignment of i128: {}", align_of::<i128>());
// rustc 1.77.0
alignment of i128: 16

如上所述,ABI 指定数据类型对齐方式的部分原因是,它在该架构上更有效率。我们实际上亲身看到了这一点:手动更改对齐方式的初始性能运行显示,编译器性能得到了显着提高(它在处理整数文字时严重依赖 128 位整数)。增加对齐方式的缺点是,复合类型在内存中并不总是能很好地组合在一起,导致使用量增加。不幸的是,这意味着需要牺牲一些性能提升,以避免增加内存占用。

兼容性

最重要的问题是,这些修复导致兼容性发生了哪些变化。简而言之,使用 LLVM 18 的 Rust(从 1.78 版本开始的默认版本)中的 `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 存储兼容,有调用错误
Rust ≥ 1.77 与 LLVM < 18 GCC(任何版本) 存储兼容,有调用错误
Rust ≥ 1.77 与 LLVM < 18 Clang(任何版本) 存储兼容,有调用错误
Rust < 1.773 GCC(任何版本) 不兼容
Rust < 1.773 Clang(任何版本) 不兼容
GCC(任何版本) Clang ≥ 18 完全兼容
GCC(任何版本) Clang < 18 存储兼容,有调用错误

影响 & 未来步骤

正如简介中所述,大多数用户不会注意到此更改的任何影响,除非您已经对这些类型做了某些有疑问的事情。

从 Rust 1.77 开始,在 FFI 中开始尝试使用 128 位整数将是相当安全的,在 1.78 版本中更新 LLVM 后会更加确定。关于在即将发布的版本中取消 lint,正在进行讨论,但我们希望谨慎,避免为那些使用较旧 LLVM 构建的 Rust 编译器的用户引入静默中断。

  1. https://bugs.llvm.org/show_bug.cgi?id=50198

  2. https://github.com/llvm/llvm-project/issues/20283

  3. Rust < 1.77 与 LLVM 18 具有一定程度的兼容性,这只是一个不常见的组合。 2