它可以像任何其他 BigInt 库一样工作,只是(很多)速度更快且级别更低:处理器从缓存/RAM 中获取一个数字,添加它,然后再次写回结果。
几乎所有的 CPU都内置了这个。您必须围绕相关指令使用软件循环,但如果循环有效,则不会使其变慢。(由于部分标志停顿,这在 x86 上很重要,见下文)
例如,如果 x86 提供rep adc
src += dst,以 2 个指针和一个长度作为输入(就像rep movsd
memcpy 一样),它仍然会在微码中实现为循环。
32 位 x86 CPU 有可能在内部实现rep adc
使用 64 位加法器,因为 32 位 CPU 可能仍然有一个 64 位加法器。但是,64 位 CPU 可能没有单周期延迟 128b 加法器。所以我不希望为此有一个特殊的指令会加速你可以用软件做的事情,至少在 64 位 CPU 上。
也许特殊的宽加法指令在低功耗低时钟速度 CPU 上会很有用,在这种 CPU 中,具有单周期延迟的真正宽加法器是可能的。
您正在寻找的 x86 指令是:
当然,adc
适用于二进制整数,而不是单个十进制数字。x86 可以adc
包含 8、16、32 或 64 位块,这与 RISC CPU 不同,RISC CPU 通常只在全寄存器宽度下进行 adc。(GMP 将每个块称为“肢体”)。(x86 有一些使用 BCD 或 ASCII 的指令,但这些指令在 x86-64 中被删除了。)
imul
/idiv
是签名的等价物。Add 对有符号 2 的补码的工作方式与无符号的相同,因此没有单独的指令;只需查看相关标志即可检测有符号与无符号溢出。但是对于adc
,请记住只有最重要的块才有符号位;其余的都是必不可少的未签名。
ADX 和 BMI/BMI2 添加了一些指令,例如mulx
:全乘而不接触标志,因此它可以与adc
链交错以创建更多指令级并行性,以供超标量 CPU 使用。
在 x86 中,adc
甚至可以使用内存目标,因此它的执行方式与您描述的完全一样:一条指令触发 BigInteger 块的整个读取/修改/写入。请参见下面的示例。
大多数高级语言(包括 C/C++)不公开“进位”标志
通常在 C 中没有直接添加与携带的内在函数。BigInteger 库通常必须用 asm 编写才能获得良好的性能。
但是,英特尔实际上已经为(和/ )定义了内在函数。adc
adcx
adox
unsigned char _addcarry_u64 (unsigned char c_in, unsigned __int64 a, \
unsigned __int64 b, unsigned __int64 * out);
因此,进位结果unsigned char
在 C 中作为 an 处理。对于_addcarryx_u64
内在函数,由编译器来分析依赖链并决定哪些添加与adcx
以及adox
如何将它们串在一起以实现 C 源代码。
IDK当存在可以利用它的并行 dep 链时,_addcarryx
内在函数的意义是什么,而不是仅仅让编译器将adcx
/adox
用于现有的内在函数。_addcarry_u64
也许有些编译器不够聪明。
下面是一个 BigInteger 添加函数的示例,采用 NASM 语法:
;;;;;;;;;;;; UNTESTED ;;;;;;;;;;;;
; C prototype:
; void bigint_add(uint64_t *dst, uint64_t *src, size_t len);
; len is an element-count, not byte-count
global bigint_add
bigint_add: ; AMD64 SysV ABI: dst=rdi, src=rsi, len=rdx
; set up for using dst as an index for src
sub rsi, rdi ; rsi -= dst. So orig_src = rsi + rdi
clc ; CF=0 to set up for the first adc
; alternative: peel the first iteration and use add instead of adc
.loop:
mov rax, [rsi + rdi] ; load from src
adc rax, [rdi] ; <================= ADC with dst
mov [rdi], rax ; store back into dst. This appears to be cheaper than adc [rdi], rax since we're using a non-indexed addressing mode that can micro-fuse
lea rdi, [rdi + 8] ; pointer-increment without clobbering CF
dec rdx ; preserves CF
jnz .loop ; loop while(--len)
ret
在较旧的 CPU 上,尤其是 Sandybridge 之前的 CPU,在写入其他标志 adc
后读取 CF 时会导致部分标志停止。使用不同的指令循环将有助于旧 CPU 在合并部分标志写入时停止,但在 SnB-family 上不值得。dec
循环展开对于adc
循环也非常重要。 adc
在英特尔上解码为多个微指令,因此循环开销是一个问题,尤其是如果您有额外的循环开销来避免部分标志停顿。如果len
是一个小的已知常数,则完全展开的循环通常是好的。(例如编译器只使用add
/在 x86-64adc
上做 auint128_t
。)
adc
使用内存目标似乎不是最有效的方法,因为指针差异技巧让我们对 dst 使用单寄存器寻址模式。(如果没有这个技巧,内存操作数就不会微熔)。
根据Agner Fog 的Haswell 和 Skylake 指令表,adc r,m
是 2 微指令(融合域),每 1 个时钟吞吐量一个,而adc m, r/i
4 微指令(融合域),每 2 个时钟吞吐量一个。显然,Broadwell/Skylakeadc r,r/i
作为单 uop 指令运行并没有帮助(利用具有 3 个输入依赖项的 uop 的能力,与 Haswell 一起为 FMA 引入)。我也不能 100% 确定 Agner 的结果是否正确,因为他没有意识到 SnB 系列 CPU 仅在解码器/uop-cache 中微熔丝索引寻址模式,而不是在乱序核心中。
无论如何,这个简单的未展开循环是 6 微指令,并且应该在 Intel SnB 系列 CPU 上以每 2 个周期运行一次迭代。即使部分标志合并需要额外的微指令,这仍然容易少于可以在 2 个周期内发出的 8 个融合域微指令。
一些小的展开可以使每个周期接近 1adc
个,因为该部分只有 4 个微指令。但是,每个周期 2 次加载和 1 次存储不太可持续。
扩展精度乘法和除法也是可能的,利用扩大/缩小乘法和除法指令。当然,由于乘法的性质,它要复杂得多。
将 SSE 用于 add-with carry或 AFAIK 任何其他 BigInteger 操作并没有真正的帮助。
如果您正在设计一个新的指令集,如果您有正确的指令来有效地生成和传播进位,您可以在向量寄存器中添加 BigInteger。该线程对在硬件中支持进位标志的成本和收益进行了一些来回讨论,与让软件像 MIPS 那样生成进位:比较以检测无符号环绕,将结果放入另一个整数寄存器。