相关:unsigned
在纯 ISO C 中饱和更容易,更有效:如何在 C 中进行无符号饱和加法?
到目前为止,编译器对所有提出的纯 C 选项都很糟糕。
他们没有看到他们可以使用add
指令中的有符号溢出标志结果来检测是否需要饱和到 INT64_MIN/MAX。AFAIK 没有纯 C 模式被编译器识别为读取add
.
内联汇编在这里不是一个坏主意,但我们可以使用 GCC 的内置函数来避免这种情况,这些内置函数公开了带有布尔溢出结果的 UB 安全包装签名加法。 https://gcc.gnu.org/onlinedocs/gcc/Integer-Overflow-Builtins.html
(如果您要使用 GNU C 内联汇编,那将与这些 GNU C 内置程序一样限制您。而且这些内置程序不是特定于架构的。它们确实需要 gcc5 或更高版本,但 gcc4.9 和更早版本基本上是已过时 。https ://gcc.gnu.org/wiki/DontUseInlineAsm - 它破坏了持续传播并且难以维护。)
此版本使用INT64_MIN = INT64_MAX + 1ULL
(对于 2 的补码)根据 的符号选择 INT64_MIN/MAX 的事实b
。使用该添加避免了有符号溢出 UB uint64_t
,并且 GNU C 定义了将无符号整数转换为不能表示其值的有符号类型的行为(位模式未更改)。当前的 gcc/clang 受益于这种手持方式,因为他们没有从像(b<0) ? INT64_MIN : INT64_MAX
. (有关使用它的替代版本,请参见下文)。我还没有检查 32 位架构上的 asm。
GCC 仅支持 2 的补码整数类型,因此 using 的函数__builtin_add_overflow
不必关心使用 1 的补码(相同的恒等式)或符号/幅度(不存在)的 C 实现的可移植性,即使你做了用于long
或int
代替int64_t
.
#include <stdint.h>
#ifndef __cplusplus
#include <stdbool.h>
#endif
// static inline
int64_t signed_sat_add64_gnuc_v2(int64_t a, int64_t b) {
long long res;
bool overflow = __builtin_saddll_overflow(a, b, &res);
if (overflow) {
// overflow is only possible in one direction depending on the sign bit
return ((uint64_t)b >> 63) + INT64_MAX;
// INT64_MIN = INT64_MAX + 1 wraparound, done with unsigned
}
return res;
}
(b>>63) ^ INT64_MAX
如果手动矢量化 SIMD XOR 可以在比 SIMD ADD 更多的端口上运行的位置,例如在 Skylake 之前的 Intel 上,另一个选项可能会很有用。(但是 x86 没有 SIMD 64 位算术右移,只有逻辑,所以这只对一个int32_t
版本有帮助,你首先需要有效地检测溢出。或者你可以在符号位,如blendvpd
) 请参阅添加饱和 32 位有符号整数内在函数?使用 x86 SIMD 内在函数 (SSE2/SSE4)
在带有 gcc9 和 clang8的 Godbolt 上(以及来自其他答案的其他实现):
# gcc9.1 -O3 (clang chooses branchless with cmov)
signed_sat_add64_gnuc_v2:
add rdi, rsi # the actual add
jo .L3 # jump on signed overflow
mov rax, rdi # retval = the non-overflowing add result
ret
.L3:
movabs rax, 9223372036854775807 # INT64_MAX
shr rsi, 63 # b is still available after the ADD
add rax, rsi
ret
当内联成循环时,mov imm64
可以提升。如果b
之后需要,那么我们可能需要一个额外的mov
,否则shr
/add
可以破坏b
,使INT64_MAX
寄存器中的常量完好无损。或者如果编译器想要使用cmov
(就像 clang 一样),它必须mov
/因为它必须在添加之前shr
准备好饱和常数,保留两个操作数。
请注意,非溢出情况的关键路径仅包括 anadd
和 a not-takenjo
。即使在 Sandybridge-family 上,这些也不能宏融合到单个uop 中,但是jo
由于分支预测 + 推测执行,唯一的成本是吞吐量而不是延迟。(内联时,mov
将消失。)
如果饱和实际上并不罕见并且分支预测是一个问题,则使用配置文件引导优化进行编译,并且 gcc 将有望进行 if-conversion 并使用 acmovno
而不是jo
,就像 clang 一样。这将 MIN/MAX 选择置于关键路径上,以及 CMOV 本身。MIN/MAX 选择可以与add
.
你可以a<0
改用。我之所以使用b
,是因为我认为大多数人会写x = sadd(x, 123)
而不是x = sadd(123, x)
,并且拥有编译时常量可以b<0
优化. 为了获得最大的优化机会,您可以使用if (__builtin_constant_p(a))
询问编译器是否a
是编译时常量。这适用于 gcc,但 clang 在内联之前过早地评估了 const-ness,所以除了在带有 clang 的宏中之外它是无用的。(相关:ICC19 不会通过 进行常量传播__builtin_saddll_overflow
:它将两个输入都放在寄存器中并且仍然进行添加。GCC 和 Clang 只返回一个常量。)
这种优化在提升了 MIN/MAX 选择的循环内特别有价值,只留下add
+ cmovo
。(或add
+jo
到 a mov
。)
cmov
是在 Broadwell 之前的 Intel P6 系列和 SnB 系列上的 2 uop 指令,因为它有 3 个输入。在其他 x86 CPU(Broadwell / Skylake 和 AMD)上,它是单指令。在大多数此类 CPU 上,它具有 1 个周期延迟。这是一个简单的 ALU 选择操作;唯一的复杂之处是 3 个输入(2 个 regs + FLAGS)。但在 KNL 上,它仍然是 2 周期延迟。
不幸的是,AArch64 的 gcc 无法用于adds
设置标志和检查 V(溢出)标志结果,因此它花费了几条指令来决定是否分支。
Clang 做得很好,AArch64 的直接编码可以表示INT64_MAX
为eor
or的操作数add
。
// clang8.0 -O3 -target aarch64
signed_sat_add64_gnuc:
orr x9, xzr, #0x7fffffffffffffff // mov constant = OR with zero reg
adds x8, x0, x1 // add and set flags
add x9, x9, x1, lsr #63 // sat = (b shr 63) + MAX
csel x0, x9, x8, vs // conditional-select, condition = VS = oVerflow flag Set
ret
优化MIN
与MAX
选择
如上所述,return (b<0) ? INT64_MIN : INT64_MAX;
大多数版本的 gcc/clang 不能以最佳方式编译;它们在寄存器和 cmov 中生成常量以供选择,或者在其他 ISA 上生成类似的东西。
我们可以假设 2 的补码,因为GCC 仅支持 2 的补码整数类型,并且因为 ISO C 可选int64_t
类型保证是 2 的补码(如果存在)。(有符号溢出的int64_t
仍然是 UB,这允许它是一个简单typedef
的long
or long long
)。
(在支持某种等价物的符号/幅度 C 实现上__builtin_add_overflow
,此函数的一个版本用于long long
或int
无法使用 SHR / ADD 技巧。为了极端的可移植性,您可能只使用简单的三元,或者专门用于符号/幅度您可以return (b&0x800...) | 0x7FFF...
将 的符号位或符号位b
转换为最大幅度数。)
对于二进制补码,MIN 和 MAX 的位模式是0x8000...
(仅设置高位)和0x7FFF...
(设置所有其他位)。它们有几个有趣的属性:(MIN = MAX + 1
如果在位模式上使用无符号计算),并且MIN = ~MAX
:它们的位模式是按位反转的,也就是彼此的补码。
该MIN = ~MAX
属性来自(标准2 的补码恒等式~x = -x - 1
的重新排列)和. 该属性是不相关的,并且遵循从最正到最负的简单翻转,并且也适用于有符号整数的反码编码。(但不是符号/幅度;你会得到无符号幅度的位置)。-x = ~x + 1
MIN = -MAX - 1
+1
-0
上面的函数使用了这个MIN = MAX + 1
技巧。 这个MIN = ~MAX
技巧也可以通过使用算术右移(创建0
或0xFF...
)将符号位广播到所有位置来使用,并与之进行异或。GNU C 保证有符号的右移是算术的(符号扩展),所以在 GNU C 中(b>>63) ^ INT64_MAX
是等价的。(b<0) ? INT64_MIN : INT64_MAX
ISO C 将有符号右移实现定义,但我们可以使用b<0 ? ~0ULL : 0ULL
. 编译器将优化以下到sar
/xor
或等效指令,但它没有实现定义的行为。AArch64 可以使用移位的输入操作数,eor
就像它可以用于add
.
// an earlier version of this answer used this
int64_t mask = (b<0) ? ~0ULL : 0; // compiles to sar with good compilers, but is not implementation-defined.
return mask ^ INT64_MAX;
有趣的事实:AArch64 有一条csinv
指令:条件选择逆。并且它可以将 INT64_MIN 放入带有单个 32 位mov
指令的寄存器中,这要归功于其针对简单位模式的强大立即编码。AArch64 GCC 已经用于原始版本csinv
的技巧。MIN = ~MAX
return (b<0) ? INT64_MIN : INT64_MAX;
Godbolt 上的 clang 6.0 及更早版本使用shr
/add
作为普通(b<0) ? INT64_MIN : INT64_MAX;
版本。它看起来比 clang7/8 更有效,所以我认为这是一个回归/错过优化的错误。(这就是本节的重点,也是我写第二版的原因。)
我选择了这个MIN = MAX + 1
版本,因为它可以更好地自动矢量化:x86 具有 64 位 SIMD 逻辑右移,但只有 16 位和 32 位 SIMD 算术右移,直到 AVX512F。 当然,使用 SIMD 进行有符号溢出检测可能不值得,直到 AVX512 用于 64 位整数。好吧,也许是 AVX2。如果它是一些更大的计算的一部分,否则可以有效地矢量化,那么解包到标量和返回很糟糕。
对于标量,它确实是一种洗涤;在 Agner Fog 测试过的所有 CPU 上,这两种方法都不能更好地编译,并且sar/shr
性能相同,而且也是如此。add/xor
(https://agner.org/optimize/)。
但是+
有时可以优化到其他东西,所以你可以想象 gcc 将一个稍后+
或-
一个常量折叠到溢出分支中。或者可能使用LEA
该添加而不是ADD
复制和添加。XOR 与 ADD 的更简单 ALU 执行单元的功率差异将消失在无序执行和其他事情所需的所有功率成本的噪音中;所有 x86 CPU 都具有单周期标量 ADD 和 XOR,即使对于 64 位整数,甚至在具有奇异加法器的 P4 Prescott/Nocona 上也是如此。
@chqrlie 还建议了一种紧凑的可读方式,可以在没有 UB 的情况下用 C 语言编写它,这看起来比超级便携int mask = ternary
的东西更好。
此函数的早期“更简单”版本
不依赖于 MIN/MAX 的任何特殊属性,因此对于其他溢出检测条件饱和到其他边界可能有用。或者,如果编译器在这个版本上做得更好。
int64_t signed_sat_add64_gnuc(int64_t a, int64_t b) {
long long res;
bool overflow = __builtin_saddll_overflow(a, b, &res);
if (overflow) {
// overflow is only possible in one direction for a given `b`
return (b<0) ? INT64_MIN : INT64_MAX;
}
return res;
}
编译如下
# gcc9.1 -O3 (clang chooses branchless)
signed_sat_add64_gnuc:
add rdi, rsi # the actual add
jo .L3 # jump on signed overflow
mov rax, rdi # retval = the non-overflowing add result
ret
.L3:
movabs rdx, 9223372036854775807
test rsi, rsi # one of the addends is still available after
movabs rax, -9223372036854775808 # missed optimization: lea rdx, [rax+1]
cmovns rax, rdx # branchless selection of which saturation limit
ret
这基本上是 @drwowe 的内联 asm 所做的,但test
替换了一个 cmov。(当然还有 cmov 的不同条件。)
这与 shr/add 相比的另一个缺点_v2
是这需要 2 个常量。在一个循环中,这会占用一个额外的寄存器。(同样,除非b
是编译时常量。)
clang 使用cmov
而不是分支,并且确实发现了lea rax, [rcx + 1]
避免第二个 10 字节mov r64, imm64
指令的技巧。(或者 clang6.0 和更早的版本使用shr 63
/add
技巧而不是那个 cmov。)
这个答案的第一个版本放在int64_t sat = (b<0) ? MIN : MAX
之外if()
,但是 gcc 错过了在分支内移动它的优化,因此对于非溢出情况它根本不会运行。这比在关键路径上运行它还要好。(如果编译器决定无分支也没关系)。
但是当我把它放在 之外if
和之后,__builtin_saddll_overflow
gcc 真的很愚蠢并将bool
结果保存为一个整数,然后执行 test/cmov,然后再次用于test
结果saddll_overflow
将其放回 FLAGS 中。重新排序源解决了这个问题。