12

I'm looking for some C code for signed saturated 64-bit addition that compiles to efficient x86-64 code with the gcc optimizer. Portable code would be ideal, although an asm solution could be used if necessary.

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;
}

The function as written produces a fairly lengthy assembly output with several branches. Any tips on optimization? Seems like it ought to be be implementable with just an ADD with a few CMOV instructions but I'm a little bit rusty with this stuff.

4

4 回答 4

13

这可能会进一步优化,但这是一个便携式解决方案。它不会调用未定义的行为,它会在整数溢出发生之前检查它。

#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;
}
于 2013-07-10T22:57:37.610 回答
8

这是一个继续沿用其中一条评论中给出的思路的解决方案,并且也已在 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,因为在抽象状态机中,只有在没有溢出的情况下才会进行求和。

于 2013-07-11T07:13:34.703 回答
6

相关: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 实现的可移植性,即使你做了用于longint代替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_MAXeoror的操作数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

优化MINMAX选择

如上所述,return (b<0) ? INT64_MIN : INT64_MAX;大多数版本的 gcc/clang 不能以最佳方式编译;它们在寄存器和 cmov 中生成常量以供选择,或者在其他 ISA 上生成类似的东西。

我们可以假设 2 的补码,因为GCC 仅支持 2 的补码整数类型,并且因为 ISO C 可选int64_t类型保证是 2 的补码(如果存在)。(有符号溢出的int64_t仍然是 UB,这允许它是一个简单typedeflongor long long)。

(在支持某种等价物的符号/幅度 C 实现上__builtin_add_overflow,此函数的一个版本用于long longint无法使用 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技巧也可以通过使用算术右移(创建00xFF...)将符号位广播到所有位置来使用,并与之进行异或。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 = ~MAXreturn (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/xorhttps://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_overflowgcc 真的很愚蠢并将bool结果保存为一个整数,然后执行 test/cmov,然后再次用于test结果saddll_overflow将其放回 FLAGS 中。重新排序源解决了这个问题。

于 2019-06-10T17:54:03.130 回答
2

我仍在寻找一个体面的便携式解决方案,但这和我迄今为止想出的一样好:

改进建议?

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
}
于 2013-07-10T21:24:34.500 回答