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


128

Rust具有128位整数,这些整数用数据类型表示i128u128对于无符号整数):

let a: i128 = 170141183460469231731687303715884105727;

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

据我所知,既然该值不能容纳在x86-64 CPU的一个寄存器中,那么编译器是否会以某种方式使用2个寄存器i128?还是他们改用某种大整数结构来表示它们?



54
当您只有10个手指时,两位整数如何工作?
约尔格W¯¯米塔格

27
@JorgWMittag:啊-旧的“只有十个手指的两位数字”策略。嘿嘿 还以为你可以骗我那个旧的,是吗?好吧,我的朋友,任何二年级学生都会告诉你-这就是脚趾头的用途!(向彼得·塞勒斯...和利顿夫人道歉:-)
鲍勃·贾维斯-恢复莫妮卡

1
FWIW大多数x86机器都有一些特殊的128位或更大的SIMD操作寄存器。参见en.wikipedia.org/wiki/Streaming_SIMD_Extensions编辑:我莫名其妙地错过了@eckes的评论
Ryan1729 '19

4
@JörgWMittagNah,计算机科学家通过降低或延伸单个手指来计算二进制数。现在132个,我要回家了
;-D

Answers:


141

所有Rust的整数类型都被编译为LLVM integers。LLVM抽象机允许从1到2 ^ 23-1的任何位宽的整数。* LLVM 指令通常在任何大小的整数上工作。

显然,那里并没有太多的8388607位架构,因此,在将代码编译为本机代码时,LLVM必须决定如何实现它。类似的抽象指令的语义add由LLVM本身定义。通常,在本机代码中具有单指令等效项的抽象指令将被编译为该本机指令,而在没有本机代码的情况下,将被模拟,可能使用多个本机指令。mcarton的答案演示了LLVM如何编译本机指令和仿真指令。

(这不仅适用于大于本机机器可以支持的整数,而且还适用于较小的整数。例如,现代体系结构可能不支持本机8位算术,因此可以模拟add两个i8s 上的指令使用更宽的指令,多余的位将被丢弃。)

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

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


*但是,并非所有的LLVM后端都是相同的。这个答案与x86-64有关。我了解后端对大于128的大小和非2的幂的支持是参差不齐的(这可能部分解释了Rust为何只公开8位,16位,32位,64位和128位整数)。根据Reddit上的est31,rustc定位到本机不支持它们的后端时,会在软件中实现128位整数。


1
h,我想知道为什么是2 ^ 23而不是更典型的2 ^ 32(嗯,广义上讲这些数字出现的频率,而不是编译器后端支持的整数的最大位宽...)
基金莫妮卡的诉讼

26
@NicHartley某些LLVM的基类都有一个字段,子类可以在其中存储数据。对于Type类,这意味着有8位用于存储它是哪种类型(函数,块,整数等),而24位用于子类数据。然后,IntegerType该类使用这24位存储大小,从而使实例完全适合32位!
托德·塞威尔

56

编译器会将它们存储在多个寄存器中,并在需要时使用多个指令对这些值进行算术运算。大多数ISA都具有x86这样的adc带进位指令,这使得执行扩展精度的整数加/减相当有效。

例如,给定

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存储在rax和中rcx

(编者注: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 / test仍然是完全多余的:(jc如果CF = 1则跳转)可以正常工作。

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


4
@Anush不,rax / rsp / ...是64位寄存器。每个128位数字存储在两个寄存器/内存位置,这导致两个64位相加。
ManfP

5
@Anush:不,它只是使用了很多指令,因为它是在禁用优化的情况下编译的。如果您编译了一个带有两个args并返回一个值的函数(例如此godbolt.org/z/6JBza0),而不是禁用优化以阻止编译器执行操作,则您会看到简单的代码(如add / adc)关于编译时常数args的常数传播。u128
彼得·科德斯

3
@ CAD97释放模式使用自动换行算法,但不像调试模式那样检查溢出和紧急情况。此行为由RFC 560定义。不是UB。
trentcl

3
@PeterCordes:具体地说,Rust语言指定了未指定溢出,而rustc(唯一的编译器)指定了两种行为可供选择:Panic或Wrap。理想情况下,默认情况下将使用Panic。实际上,由于次优的代码生成,在Release模式下,默认值为Wrap,长期目标是在(如果有的话)代码生成“足够好”以供主流使用时转向Panic。另外,所有Rust整数类型都支持命名操作来选择行为:选中,包装,饱和...,因此您可以基于每个操作覆盖所选行为。
Matthieu M.19年

1
@MatthieuM .:是的,我喜欢在原始类型上使用包装,检查,饱和add / sub / shift /任何方法。UB签名比C的包装未签名的包装好得多,它迫使您根据此选择。无论如何,某些ISA可以为Panic提供有效的支持,例如,您可以在整个操作序列后检查一个粘性标志。(与x86的OF或CF不同,后者被0或1覆盖),例如,Agner Fog提出的ForwardCom ISA(agner.org/optimize/blog/read.php?i=421#478)但这仍然限制了优化,使其从不进行任何计算Rust来源没有。:/
Peter Cordes

30

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


26

为了提供一个更清晰的示例,在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)= 212²ac+2⁶⁴(ad + bc)+ bd,其中每个乘法都在一个寄存器中返回乘积的上半部,而在一个寄存器中返回乘积的下半部。另一个。其中一些术语将被丢弃,因为第128位以上的位不适合u128并被丢弃。即使这样,这仍然需要许多机器指令。除法也采取了几个步骤。对于带符号的值,乘法和除法还需要转换操作数的符号和结果。这些操作根本不是很有效。

在其他体系结构上,它变得更容易或更难。RISC-V定义了一种128位指令集扩展,尽管据我所知没有人在硅片上实现它。如果没有此扩展,则RISC-V体系结构手册建议使用条件分支:addi t0, t1, +imm; blt t0, t1, overflow

SPARC具有诸如x86的控制标志之类的控制代码,但是您必须使用特殊的指令add,cc来进行设置。另一方面,MIPS 要求您检查两个无符号整数的和是否严格小于一个操作数。 如果是这样,则添加内容溢出。至少您可以在没有条件分支的情况下将另一个寄存器设置为进位位的值。


1
最后一段:要通过查看结果的高位来检测两个无符号数中的哪个更大sub,您需要位输入的n+1位子结果n。也就是说,您需要查看进位,而不是相同宽度结果的符号位。这就是为什么x86无符号分支条件基于CF(完整逻辑结果的64位或32位)而不是SF(63位或31位)的原因。
彼得·科德斯

1
关于:divmod:AArch64的方法是提供除法和一条指令,该指令可以执行整数x - (a*b)运算,并根据被除数,商和除数来计算余数。(这对于使用除法部分的乘法逆的常数除数也很有用)。我没有读过有关将div + mod指令融合到单个divmod操作中的ISA的信息;那很整齐。
彼得·科德斯

1
re:标志:是的,标志输出是OoO exec +寄存器重命名必须以某种方式处理的第二个输出。x86 CPU通过保留一些额外的位以及FLAGS值所基于的整数结果来处理它,因此可能在需要时即时生成ZF,SF和PF。我认为这有一项英特尔专利。这样就减少了必须分别追溯到1的输出数量。(在Intel CPU中,任何uop都不能写入超过1个整数寄存器;例如,mul r64为2 uops,而第二个则将RDX高半写入)。
彼得·科德斯

1
但是对于有效的扩展精度,标志非常好。主要问题是没有为超标量有序执行重命名寄存器。标志是WAW危险(写后写)。当然,随身携带指令是3输入的,这也是一个要跟踪的重大问题。英特尔在Broadwell之前解码了adcsbb并且cmov每个解码为2 ups。(Haswell为FMA引入了3输入uops,Broadwell将其扩展为整数。)
Peter Cordes,

1
带标志的RISC ISA通常使标志设置是可选的,由额外的位控制。例如ARM和SPARC就是这样。PowerPC照常使一切变得更加复杂:它具有8个条件代码寄存器(打包到一个32位寄存器中以进行保存/恢复),因此您可以将其与c​​c0或cc7进行比较。然后将AND或OR条件代码组合在一起!Branch和cmov指令可以选择要读取的CR寄存器。因此,这使您能够同时运行多个标志dep链,例如x86 ADCX / ADOX。 alanclements.org/power%20pc.html
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.