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

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

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

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

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

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

这篇文章记录了问题是什么,为了解决它做了哪些更改,以及这些更改带来的预期结果。如果您已经熟悉这个问题,并且只想知道兼容性矩阵,请跳到 兼容性 部分。

背景

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

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

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

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) 规定了它们应该是什么。在 x86-64 psABI(针对 System V(Unix & Linux)的处理器特定 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 中报告了这个问题。

调用约定问题

还有一个问题:LLVM 在将 128 位整数作为函数参数传递时并不总是做正确的事情。这是一个 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"  /* 2rd slot (rsi): lower half of `a` */
        "movq    $0x1122334455667788, %rdx \n\t"  /* 3nd 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 中得到解决(使用捆绑的 LLVM 的 Clang 18 和 Rust 1.78)。

但是,`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(从 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 存储兼容,存在调用错误
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 后,您可以更加确定。关于在即将发布的版本中解除警告的 正在进行的讨论,但我们希望谨慎行事,避免为那些 Rust 编译器可能使用旧版 LLVM 构建的用户引入静默错误。

  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