Rust 的 128 位整数 `i128` 如何在 64 位系统上工作?

How does Rust's 128-bit integer `i128` work on a 64-bit system?

Rust 有 128 位整数,这些整数用 i128 数据类型表示(无符号整数为 u128):

let a: i128 = 170141183460469231731687303715884105727;

Rust 如何使这些 i128 值在 64 位系统上工作;例如它如何对这些进行运算?

因为据我所知,x86-64 CPU 的一个寄存器中不能容纳该值,所以编译器是否以某种方式将两个寄存器用于一个 i128 值?还是他们使用某种大整数结构来表示它们?

编译器会将这些存储在多个寄存器中,并在需要时使用多条指令对这些值进行算术运算。大多数 ISA 都有像 x86's adc 这样的加进位指令,这使得扩展精度整数 add/sub.

相当有效

例如给定

fn main() {
    let a = 42u128;
    let b = a + 1337;
}

编译器在没有优化的情况下为 x86-64 编译时生成以下内容:
(@PeterCordes 添加的评论)

playground::main:
    sub rsp, 56
    mov qword ptr [rsp + 32], 0
    mov qword ptr [rsp + 24], 42         # store 128-bit 0:42 on the stack
                                         # little-endian = low half at lower address

    mov rax, qword ptr [rsp + 24]
    mov rcx, qword ptr [rsp + 32]        # reload it to registers

    add rax, 1337                        # add 1337 to the low half
    adc rcx, 0                           # propagate carry to the high half. 1337u128 >> 64 = 0

    setb    dl                           # save carry-out (setb is an alias for setc)
    mov rsi, rax
    test    dl, 1                        # check carry-out (to detect overflow)
    mov qword ptr [rsp + 16], rax        # store the low half result
    mov qword ptr [rsp + 8], rsi         # store another copy of the low half
    mov qword ptr [rsp], rcx             # store the high half
                             # These are temporary copies of the halves; probably the high half at lower address isn't intentional
    jne .LBB8_2                       # jump if 128-bit add overflowed (to another not-shown block of code after the ret, I think)

    mov rax, qword ptr [rsp + 16]
    mov qword ptr [rsp + 40], rax     # copy low half to RSP+40
    mov rcx, qword ptr [rsp]
    mov qword ptr [rsp + 48], rcx     # copy high half to RSP+48
                  # This is the actual b, in normal little-endian order, forming a u128 at RSP+40
    add rsp, 56
    ret                               # with retval in EAX/RAX = low half result

您可以看到值 42 存储在 raxrcx 中。

(编者注:x86-64 C 调用约定 return 128 位整数 RDX:RAX。但是这个 main 根本不是 return 值.所有冗余复制纯粹来自禁用优化,Rust 实际上在调试模式下检查溢出。)

为了比较,这里是 x86-64 上 Rust 64 位整数的 asm,其中不需要进位加法,每个值只需要一个寄存器或栈槽。

playground::main:
    sub rsp, 24
    mov qword ptr [rsp + 8], 42           # store
    mov rax, qword ptr [rsp + 8]          # reload
    add rax, 1337                         # add
    setb    cl
    test    cl, 1                         # check for carry-out (overflow)
    mov qword ptr [rsp], rax              # store the result
    jne .LBB8_2                           # branch on non-zero carry-out

    mov rax, qword ptr [rsp]              # reload the result
    mov qword ptr [rsp + 16], rax         # and copy it (to b)
    add rsp, 24
    ret

.LBB8_2:
    call panic function because of integer overflow

setb / test 仍然是完全多余的:jc(如果 CF=1 则跳转)就可以了。

启用优化后,Rust 编译器不会检查溢出,因此 + 的工作方式类似于 .wrapping_add()

所有 Rust 的整数类型都被编译为 LLVM integers. The LLVM abstract machine allows integers of any bit width from 1 to 2^23 - 1.* LLVM instructions 通常适用于任何大小的整数。

显然,8388607 位架构并不多,因此当代码被编译为本机代码时,LLVM 必须决定如何实现它。 add are defined by LLVM itself. Typically, abstract instructions that have a single-instruction equivalent in native code will be compiled to that native instruction, while those that don't will be emulated, possibly with multiple native instructions. 等抽象指令的语义演示了 LLVM 如何编译本机指令和模拟指令。

(这不仅适用于大于本机支持的整数,也适用于那些更小的整数。例如,现代体系结构可能不支持本机 8 位算术,因此 [=两个 i8 上的 10=] 指令可以用更宽的指令来模拟,多余的位被丢弃。)

Does the compiler somehow use 2 registers for one i128 value? Or are they using some kind of big integer struct to represent them?

在 LLVM IR 级别,答案都不是:i128 适合单个寄存器,就像所有其他 single-valued type 一样。另一方面,一旦翻译成机器代码,两者之间并没有真正的区别,因为结构可以像整数一样分解为寄存器。但是,在进行算术运算时,可以肯定的是 LLVM 会将整个内容加载到两个寄存器中。


* 然而,并非所有的 LLVM 后端都是一样的。这个答案与 x86-64 有关。我知道后端对大于 128 的大小和非 2 的幂的支持参差不齐(这可能部分解释了为什么 Rust 只公开 8、16、32、64 和 128 位整数)。 According to est31 on Reddit,当针对本机不支持它们的后端时,rustc 在软件中实现 128 位整数。

是的,就像在 32 位机器上处理 64 位整数,或在 16 位机器上处理 32 位整数,甚至在 8 位机器上处理 16 位和 32 位整数一样(仍然适用于微控制器!)。是的,您将数字存储在两个寄存器或内存位置或其他任何地方(这并不重要)。加法和减法很简单,需要两条指令并使用进位标志。乘法需要三个乘法和一些加法(64 位芯片通常已经具有输出到两个寄存器的 64x64->128 乘法运算)。除法...需要一个子程序并且速度很慢(除了在某些情况下除以常数可以转换为移位或乘法),但它仍然有效。按位 and/or/xor 只需分别在上半部分和下半部分完成。可以通过旋转和掩蔽来完成移位。这几乎涵盖了所有内容。

为了提供一个更清晰的示例,在 x86_64 上,使用 -O 标志编译,函数

pub fn leet(a : i128) -> i128 {
    a + 1337
}

编译为

example::leet:
  mov rdx, rsi
  mov rax, rdi
  add rax, 1337
  adc rdx, 0
  ret

(我原来的 post 有 u128 而不是你问的 i128 。该函数以任何一种方式编译相同的代码,一个很好的证明有符号和无符号加法是在现代 CPU 上也是如此。)

另一个清单产生了未优化的代码。在调试器中单步执行是安全的,因为它确保您可以在任何地方放置断点并在程序的任何行检查任何变量的状态。它更慢,更难阅读。优化后的版本更接近实际 运行 生产中的代码。

该函数的参数a传入一对64位寄存器,rsi:rdi。结果在另一对寄存器 rdx:rax 中返回。前两行代码将总和初始化为 a.

第三行将输入的低位字加1337。如果溢出,它会在 CPU 的进位标志中携带 1。第四行将零添加到输入的高位字 - 如果它被进位则加上 1。

您可以将此视为一位数与两位数的简单相加

  a  b
+ 0  7
______
 

但基数为 18,446,744,073,709,551,616。您仍然首先添加最低的“数字”,可能将 1 带到下一列,然后添加下一个数字加上进位。减法非常相似。

乘法必须使用恒等式 (2⁶⁴a + b)(2⁶⁴c + d) = 2¹²⁸ac + 2⁶⁴(ad+bc) + bd,其中每个乘法 returns 乘积的上半部分合二为一寄存器和产品的下半部分在另一个。其中一些术语将被删除,因为第 128 位以上的位不适合 u128 并被丢弃。即便如此,这仍需要大量机器指令。除法也有几个步骤。对于带符号的值,乘法和除法还需要转换操作数和结果的符号。这些操作根本不是很有效。

在其他架构上,它变得更容易或更难。 RISC-V 定义了一个 128 位指令集扩展,尽管据我所知还没有人在硅片中实现它。如果没有此扩展,the RISC-V architecture manual recommends 条件分支:addi t0, t1, +imm; blt t0, t1, overflow

SPARC 有类似 x86 的控制标志的控制代码,但你必须使用特殊指令 add,cc 来设置它们。另一方面,MIPS requires you to check whether the sum of two unsigned integers is strictly less than one of the operands. 如果是这样,则加法溢出。至少你可以在没有条件分支的情况下将另一个寄存器设置为进位位的值。