【问题标题】:How does Rust's 128-bit integer `i128` work on a 64-bit system?Rust 的 128 位整数“i128”如何在 64 位系统上工作?
【发布时间】:2019-12-11 21:32:59
【问题描述】:

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

let a: i128 = 170141183460469231731687303715884105727;

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

据我所知,由于 x86-64 CPU 的一个寄存器无法容纳该值,因此编译器是否以某种方式使用两个寄存器来存储一个 i128 值?或者他们是否使用某种大整数结构来表示它们?

【问题讨论】:

标签: rust x86-64 bigint int128 llvm-codegen


【解决方案1】:

所有 Rust 的整数类型都编译为 LLVM integers。 LLVM 抽象机允许从 1 到 2^23 - 1 的任何位宽的整数。* LLVM instructions 通常适用于任何大小的整数。

显然,8388607 位的架构并不多,因此当代码编译为本机机器码时,LLVM 必须决定如何实现它。像add 这样的抽象指令的语义是由 LLVM 本身定义的。通常,在本机代码中具有单指令等效的抽象指令将被编译为该本机指令,而那些没有的将被模拟,可能具有多个本机指令。 mcarton's answer 演示 LLVM 如何编译本机和模拟指令。

(这不仅适用于大于本机机器支持的整数,也适用于那些更小的整数。例如,现代架构可能不支持本机 8 位算术,因此 add 指令在两个i8s 上可以用更宽的指令来模拟,多余的位会被丢弃。)

编译器是否以某种方式将 2 个寄存器用于一个 i128 值?还是他们使用某种大整数结构来表示它们?

在 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 位整数。

【讨论】:

  • 呵呵,我想知道为什么它是 2^23 而不是更典型的 2^32编译器后端...)
  • @NicHartley LLVM 的一些基类有一个字段,子类可以在其中存储数据。对于Type 类,这意味着有 8 位来存储它的类型(函数、块、整数等),而 24 位用于存储子类数据。然后IntegerType 类使用这 24 位来存储大小,允许实例整齐地适合 32 位!
【解决方案2】:

编译器会将这些值存储在多个寄存器中,并在需要时使用多条指令对这些值进行算术运算。大多数 ISA 都有一个 add-with-carry 指令,例如 x86's adc,这使得执行扩展精度整数加/减相当有效。

例如,给定

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

编译器在为 x86-64 编译而不进行优化时会生成以下内容:
(@PeterCordes 添加的 cmets)

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 调用约定在 RDX:RAX 中返回 128 位整数。但是这个 main 根本不返回值。所有冗余复制纯粹来自禁用优化,而 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 / 测试仍然是完全多余的:jc(如果 CF=1 则跳转)可以正常工作。

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

【讨论】:

  • @Anush 不,rax/rsp/... 是 64 位寄存器。每个 128 位数字存储在两个寄存器/内存位置,这导致两个 64 位相加。
  • @Anush:不,它只是使用了这么多指令,因为它是在禁用优化的情况下编译的。如果您编译了一个接受两个 u128 参数并返回一个值的函数(例如 godbolt.org/z/6JBza0),而不是禁用优化,您会看到 很多 更简单的代码(例如 add/adc)阻止编译器对 compile-time-constant args 进行常量传播。
  • @CAD97 发布模式使用包装算法,但不像调试模式那样检查溢出和恐慌。此行为由RFC 560 定义。这不是 UB。
  • @PeterCordes:具体来说,Rust 语言指定溢出是未指定的,而 rustc(唯一的编译器)指定了两种可供选择的行为:Panic 或 Wrap。理想情况下,默认情况下会使用 Panic。在实践中,由于次优代码生成,在 Release 模式下,默认值为 Wrap,长期目标是在(如果有的话)代码生成“足够好”以供主流使用时转移到 Panic。此外,所有 Rust 整数类型都支持命名操作来选择一种行为:检查、包装、饱和等,因此您可以在每个操作的基础上覆盖选定的行为。
  • @MatthieuM.:是的,我喜欢原始类型的包装、检查和饱和添加/子/移位/任何方法。比 C 的未签名包装要好得多,UB 签名迫使您基于此进行选择。无论如何,一些 ISA 可以为 Panic 提供有效的支持,例如一个粘性标志,您可以在整个操作序列后检查。 (与 x86 的 OF 或 CF 不同,它们被 0 或 1 覆盖。)例如Agner Fog 提出的 ForwardCom ISA (agner.org/optimize/blog/read.php?i=421#478) 但这仍然限制优化永远不要进行 Rust 源没有做的任何计算。 ://
【解决方案3】:

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

【讨论】:

    【解决方案4】:

    为了提供一个更清晰的示例,在 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
    

    (我原来的帖子有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,其中每个乘法都在一个寄存器中返回乘积的上半部分,在一个寄存器中返回乘积的下半部分产品在另一个。其中一些术语将被删除,因为第 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. 如果是这样,则加法溢出。至少您可以在没有条件分支的情况下将另一个寄存器设置为进位位的值。

    【讨论】:

    • 最后一段:要通过查看 sub 结果的高位来检测两个 unsigned 数字中的哪一个更大,您需要一个 n+1 位子结果n 位输入。即您需要查看进位,而不是相同宽度结果的符号位。这就是为什么 x86 无符号分支条件基于 CF(完整逻辑结果的第 64 位或第 32 位),而不是 SF(第 63 位或第 31 位)。
    • re: divmod: AArch64 的方法是提供除法和一个执行整数x - (a*b) 的指令,计算除数、商和除数的余数。 (即使对于除法部分使用乘法逆的常数除数也是有用的)。我还没有阅读过将 div+mod 指令融合到单个 divmod 操作中的 ISA。这很整洁。
    • re: flags: 是的,标志输出是 OoO exec + register-renameing 必须以某种方式处理的第二个输出。 x86 CPU 通过在 FLAGS 值所基于的整数结果中保留一些额外的位来处理它,因此可能会在需要时动态生成 ZF、SF 和 PF。我认为有一项关于此的英特尔专利。这样可以将必须​​单独跟踪的输出数量减少到 1。(在 Intel CPU 中,没有 uop 可以写入超过 1 个整数寄存器;例如,mul r64 是 2 uop,第二个写入 RDX 高半部分)。
    • 但是对于有效的扩展精度,标志非常好。主要问题是没有为超标量顺序执行寄存器重命名。标志是 WAW 危险(写后写)。当然,add-with-carry 指令是 3 输入的,这也是一个需要跟踪的重大问题。 Broadwell 之前的英特尔将adcsbbcmov 分别解码为 2 微秒。 (Haswell 为 FMA 引入了 3 输入微指令,Broadwell 将其扩展到整数。)
    • 带有标志的 RISC ISA 通常使标志设置是可选的,由一个额外的位控制。例如ARM和SPARC就是这样的。 PowerPC 像往常一样使一切变得更加复杂:它有 8 个条件代码寄存器(打包到一个 32 位寄存器中用于保存/恢复),因此您可以比较 cc0 或 cc7 或其他任何内容。然后将 AND 或 OR 条件代码放在一起!分支和 cmov 指令可以选择读取哪个 CR 寄存器。因此,这使您能够同时运行多个标志 dep 链,例如 x86 ADCX / ADOX。 alanclements.org/power%20pc.html
    猜你喜欢
    • 2017-12-13
    • 1970-01-01
    • 2023-04-01
    • 1970-01-01
    • 1970-01-01
    • 2011-12-18
    • 2010-12-14
    • 2011-07-07
    • 2014-10-03
    相关资源
    最近更新 更多