我正在寻找一些用于签名饱和64位加法的C代码,它使用gcc优化器编译为高效的X86-64代码。便携式代码是理想的,尽管可以在必要时使用asm解决方案。
static const int64 kint64max = 0x7fffffffffffffffll;
static const int64 kint64min = 0x8000000000000000ll;
int64 signed_saturated_add(int64 x, int64 y) {
bool x_is_negative = (x & kint64min) != 0;
bool y_is_negative = (y & kint64min) != 0;
int64 sum = x+y;
bool sum_is_negative = (sum & kint64min) != 0;
if (x_is_negative != y_is_negative) return sum; // can't overflow
if (x_is_negative && !sum_is_negative) return kint64min;
if (!x_is_negative && sum_is_negative) return kint64max;
return sum;
}
写入的函数会生成一个包含多个分支的相当冗长的汇编输出。有关优化的提示吗?看起来应该可以通过一些带有一些CMOV指令的ADD来实现,但我对这些东西有点生疏。
答案 0 :(得分:10)
这可能会进一步优化,但这是一个便携式解决方案。它不会调用未定义的行为,它会在可能发生之前检查整数溢出。
#include <stdint.h>
int64_t sadd64(int64_t a, int64_t b)
{
if (a > 0) {
if (b > INT64_MAX - a) {
return INT64_MAX;
}
} else if (b < INT64_MIN - a) {
return INT64_MIN;
}
return a + b;
}
答案 1 :(得分:4)
这是一个在其中一条评论中一直延续的解决方案,并且也在ouah的解决方案中使用过。这里生成的代码应该没有条件跳转
int64_t signed_saturated_add(int64_t x, int64_t y) {
// determine the lower or upper bound of the result
int64_t ret = (x < 0) ? INT64_MIN : INT64_MAX;
// this is always well defined:
// if x < 0 this adds a positive value to INT64_MIN
// if x > 0 this subtracts a positive value from INT64_MAX
int64_t comp = ret - x;
// the condition is equivalent to
// ((x < 0) && (y > comp)) || ((x >=0) && (y <= comp))
if ((x < 0) == (y > comp)) ret = x + y;
return ret;
}
第一个看起来好像会有一个条件移动,但由于特殊值我的编译器得到一个加法:在2的补码INT64_MIN
是INT64_MAX+1
。
如果一切正常,那么只有一个条件移动来分配总和。
所有这些都没有UB,因为在抽象状态机中,只有在没有溢出时才会执行求和。
答案 2 :(得分:3)
相关:在纯ISO C中,unsigned
饱和度要容易得多并且有效地实现:How to do unsigned saturating addition in C?
编译器都非常糟糕。
他们看不到可以使用add
指令的带符号溢出标志结果来检测是否需要达到INT64_MIN / MAX饱和。 AFAIK没有编译器识别为读取add
的OF标志结果的纯C模式。
在这里,内联汇编不是一个坏主意,但是我们可以使用GCC的内建函数避免这种情况,该内建函数公开UB安全包装的签名加法并产生布尔溢出结果。 https://gcc.gnu.org/onlinedocs/gcc/Integer-Overflow-Builtins.html
((如果您要使用GNU C内联汇编,那将限制您这些GNU C内置插件的范围。这些内置插件不是特定于Arch的。它们确实需要gcc5或更高版本,但是gcc4.9和基本上已经过时了。 https://gcc.gnu.org/wiki/DontUseInlineAsm -它破坏了持续的传播并且难以维护。)
此版本使用INT64_MIN = INT64_MAX + 1ULL
(用于2的补码)基于b
的符号来选择INT64_MIN / MAX的事实。通过使用uint64_t
来避免有符号溢出的UB,GNU C定义了将无符号整数转换为不能代表其值的有符号类型的行为(使用不变的位模式)。当前的gcc / clang可以从此功能中受益,因为他们无法从(b<0) ? INT64_MIN : INT64_MAX
之类的三元模式中找出此技巧。 (有关使用该版本的替代版本,请参见下文)。我还没有检查过32位体系结构上的asm。
GCC only supports 2's complement integer types,因此使用__builtin_add_overflow
的函数不必关心可移植到使用1的补码(where the same identity holds)或符号/幅值(其中不需要)的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
)请参见Add saturate 32-bit signed ints intrinsics?和x86 SIMD内部函数(SSE2 / SSE4)
On Godbolt和gcc9和clang8 (以及其他答案中的其他实现):
# 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
,因为必须在之前准备好饱和常数。添加,保留两个操作数。
请注意,非溢出案例的关键路径仅包括add
和未采用的jo
。它们无法宏融合到单个uop even on Sandybridge-family中,但是jo
仅花费吞吐量而不是延迟,这要归功于分支预测+投机执行。 (内联时,mov
将消失。)
如果饱和度实际上并不罕见,并且分支预测是一个问题,则使用配置文件引导的优化进行编译,gcc希望进行if-conversion并使用cmovno
而不是{{1 }},就像c语一样。这会将MIN / MAX选择以及CMOV本身放在关键路径上。 MIN / MAX选择可以与jo
并行运行。
您可以改用add
。我使用a<0
是因为我认为大多数人会写b
而不是x = sadd(x, 123)
,并且具有编译时间常数可使x = sadd(123, x)
进行优化强>。为了获得最大的优化机会,您可以使用b<0
来询问编译器if (__builtin_constant_p(a))
是否是编译时常量。这适用于gcc,但是clang在内联之前评估const-ness的方式为时过早,因此,除了在具有clang的宏中,它无用。 (相关:ICC19不会通过a
进行常量传播:它会将两个输入都放入寄存器中,但仍会进行加法运算。GCC和Clang只是返回一个常量。)
此优化在悬挂MIN / MAX选择的循环内特别有价值,仅保留__builtin_saddll_overflow
+ add
。 (或将cmovo
+ add
更改为jo
。)
mov
是Broadwell之前针对Intel P6系列和SnB系列的2 uop指令,因为它具有3个输入。在其他x86 CPU(Broadwell / Skylake和AMD)上,这是单uup指令。在大多数此类CPU上,它具有1个周期的延迟。这是一个简单的ALU选择操作; only 并发症是3个输入(2个regs + FLAGS)。但是在KNL上,它仍然是2个周期的延迟。
不幸的是,AArch64的gcc无法使用cmov
来设置标志并检查V(溢出)标志的结果,因此它花费了一些指令来决定是否分支。
Clang做得很好,并且AArch64的立即编码可以将adds
表示为INT64_MAX
或eor
的操作数。
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
不能在大多数版本的gcc / clang上进行最佳编译。它们在寄存器和cmov中生成常量以供选择,或者在其他ISA上生成类似的东西。
我们可以假设2的补码是因为GCC only supports 2's complement integer types,并且因为存在ISO C可选return (b<0) ? INT64_MIN : INT64_MAX;
类型也可以保证是2的补码。 (int64_t
的签名溢出仍然是UB,这使它成为int64_t
或typedef
的简单long
)。
(在支持某种等效的long long
的符号/幅度C实现中,此功能的__builtin_add_overflow
或long long
版本不能使用SHR / ADD技巧。极端的可移植性,您可能只使用简单的三进制,或者对于符号/幅度,可以int
或将return (b&0x800...) | 0x7FFF...
的符号位或为最大幅度数。)
对于二进制补码,MIN和MAX的位模式为b
(仅设置高位)和0x8000...
(所有其他位设置)。它们具有几个有趣的属性: 0x7FFF...
(如果在位模式上用unsigned计算)和 MIN = MAX + 1
:它们的位模式是按位求逆,又名彼此的补码。
MIN = ~MAX
属性源自MIN = ~MAX
(标准~x = -x - 1
2's complement identity的重新安排)和-x = ~x + 1
的事实。 MIN = -MAX - 1
属性是不相关的,它遵循从最正值到最负值的简单过渡,并适用于one's complement encoding of signed integer as well。 (但不是符号/幅度;如果没有符号幅度,您会得到+1
。
上面的函数使用-0
技巧。 通过在算术右移(创建MIN = MAX + 1
或MIN = ~MAX
)的所有位置广播符号位,然后对其进行异或操作,0
技巧也可以使用。
GNU C保证有符号右移是算术运算(符号扩展),因此 0xFF...
等同于GNU C中的(b>>63) ^ INT64_MAX
。
ISO C保留了实现定义的带符号右移,但是我们可以使用(b<0) ? INT64_MIN : INT64_MAX
的三进制。编译器会将以下内容优化为b<0 ? ~0ULL : 0ULL
/ sar
或等效指令,但没有实现定义的行为。 AArch64可以为xor
使用移位的输入操作数,也可以为eor
使用移位的输入操作数。
add
有趣的事实:AArch64有一条 // 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;
指令:条件选择逆。而且,由于其对简单位模式的强大立即编码,它可以使用一条32位csinv
指令将INT64_MIN放入寄存器。 AArch64 GCC已将mov
技巧用于原始csinv
版本的MIN = ~MAX
。
在Godbolt上使用的clang 6.0和更早版本的普通return (b<0) ? INT64_MIN : INT64_MAX;
版本使用的是shr
/ add
。它看起来比clang7 / 8的效率更高,所以我认为这是一个回归/未优化的错误。 (这是本节的重点,也是我编写第二版的原因。)
我选择了(b<0) ? INT64_MIN : INT64_MAX;
版本,因为它可以更好地自动矢量化:x86具有64位SIMD逻辑右移,但是只有16位和32位SIMD算术右移until AVX512F。 当然,使用SIMD进行有符号溢出检测可能使它变得不值得,直到针对64位整数的AVX512。好吧,也许是AVX2。而且如果它是可以进行否则有效矢量化的较大计算的一部分,则可以解压缩为标量并反吸。
对于标量来说,这确实是洗钱;在Agner Fog测试过的所有CPU上,这两种方法的编译效果都不佳,MIN = MAX + 1
的性能相同,sar/shr
的性能也相同。 (https://agner.org/optimize/)。
但是add/xor
有时可以进行其他优化,因此您可以想象gcc将常量的后+
或+
折叠到溢出分支中。或者可以使用-
进行复制而不是使用LEA
进行复制和添加。较简单的用于XOR与ADD的ALU执行单元的能力差异将因执行无序执行所需的全部能力和其他工作的成本而消失。所有x86 CPU都具有单周期标量ADD和XOR,即使对于64位整数也是如此,甚至在具有奇异加法器的P4 Prescott / Nocona上也是如此。
@chqrlie还提出了一种紧凑的可读方法,可以在不使用UB的情况下用C编写它,它看起来比超级可移植的ADD
更好。
不依赖于MIN / MAX的任何特殊属性,因此对于在其他溢出检测条件下达到其他边界可能有用。或者,以防编译器在此版本中做得更好。
int mask = ternary
其编译如下
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;
}
这基本上是@drwowe的内联asm所做的,但是用# 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
替换了一个cmov。 (当然,cmov上的条件也不同。)
与shr / add的test
相比,此方法的另一个缺点是它需要2个常量。在一个循环中,这将占用额外的寄存器。 (同样,除非_v2
是编译时常量。)
clang使用b
而不是分支,并且确实发现了cmov
技巧,从而避免了第二个10字节的lea rax, [rcx + 1]
指令。 (或者clang6.0及更早版本使用mov r64, imm64
/ shr 63
技巧而不是该cmov。)
此答案的第一个版本将add
放在int64_t sat = (b<0) ? MIN : MAX
之外,但是gcc错过了将其移入分支的优化方案,因此在非溢出情况下根本无法运行。这比在关键路径上运行还要好。 (而且,编译器是否决定采用无分支方式也没关系)。
但是,当我将其放在if()
之外,然后将放在之后的if
时,gcc确实很笨,并将__builtin_saddll_overflow
结果保存为整数,然后执行了测试/移动,然后再次在bool
结果上使用test
将其放回FLAGS。重新排序源即可解决此问题。
答案 3 :(得分:1)
我仍然在寻找一个不错的便携式解决方案,但这和我到目前为止一样好:
改进建议?
int64 saturated_add(int64 x, int64 y) {
#if __GNUC__ && __X86_64__
asm("add %1, %0\n\t"
"jno 1f\n\t"
"cmovge %3, %0\n\t"
"cmovl %2, %0\n"
"1:" : "+r"(x) : "r"(y), "r"(kint64min), "r"(kint64max));
return x;
#else
return portable_saturated_add(x, y);
#endif
}