64位整数的有符号饱和加法?

12

我正在寻找一些C代码,用于带符号饱和的64位加法,该代码能够编译出高效的x86-64代码,并通过gcc优化器。最好是可移植的代码,但如果需要,也可以使用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,但我对这些东西有点生疏了。


2
你计算数值的符号的方式太复杂了,为什么不直接使用(x < 0)呢?为了可移植性,请使用[u]int64_t。这样你就可以免费使用INT64_MAXINT64_MIN,而不必使用自己的常量来实现。 - Jens Gustedt
2
此实现调用了未定义的行为。一个好的实现应该在发生溢出之前检查溢出。 - ouah
可能是Saturating Addition in C的重复问题。 - Carl Norum
3
请注意,即使计算总和会导致溢出,这也是未定义行为。编译器在进行优化时可以利用这一事实(例如,将表达式 x+1 > x 编译成常数 1)。仅因为这个原因,我认为这个问题不应该被关闭,因为所引用的答案都没有考虑到这一点...... - Nemo
显示剩余2条评论
4个回答

14

这里提供了一种可携带的解决方案,可能还可以进行进一步的优化。它不会调用未定义的行为,并且在整数溢出发生之前检查。

#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
同意这是可移植的、优雅的和100%正确的。一个潜在的优化:尝试使用b = INT64_MAX - a代替return INT64_MAX,并使用b = INT64_MIN - a代替return INT64_MIN。在我的编译器(GCC 4.7.3)上,这将生成稍微更紧凑的代码,用条件移动替换两个条件分支。(另一方面,它引入了更多的数据依赖性,所以可能会更慢...) - Nemo
我同意这是正确的,“直接”的解决方案。@Nemo,实际上有可能只需要一个条件移动就能得出结果,详见我的回答。哪种解决方案更有效只有基准测试才能显示。 - Jens Gustedt

9

相关: unsigned 饱和操作在纯 ISO C 中实现起来更加容易和高效: 如何在 C 中进行无符号饱和加法?


编译器对目前提出的所有纯C选项都不太擅长。它们没有意识到可以使用“add”指令的带符号溢出标志结果来检测饱和度是否达到INT64_MIN/MAX。据我所知,没有纯C模式被编译器识别为读取“add”的OF标志结果。在这里使用内联汇编并不是一个坏主意,但我们可以避免使用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的补码)这一事实,根据b的符号选择INT64_MIN / MAX。通过使用uint64_t进行加法运算来避免有符号整数溢出的未定义行为,并且GNU C定义了将无法表示其值的无符号整数转换为已知类型的行为(未更改使用的位模式)。当前gcc / clang受益于此指导,因为它们从三元运算符中不能理解这个技巧,例如(b<0) ? INT64_MIN : INT64_MAX。(请参见下面使用该方法的备用版本)。我还没有检查过32位架构上的asm。

GCC仅支持二进制补码整数类型,因此使用__builtin_add_overflow的函数不必考虑可移植性到使用一位补码(在那里相同的身份保持)或符号/幅度(在这种情况下不是)的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之前的英特尔上可能会有用。 (但是x86没有SIMD 64位算术右移,只有逻辑右移,因此这只对int32_t版本有帮助,并且您需要有效地检测第一次溢出。或者您可以在符号位上使用可变混合,例如blendvpd)。请参见Add saturate 32-bit signed ints intrinsics?与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,因为它必须在add之前准备饱和常量,保留两个操作数。
请注意,非溢出情况的关键路径仅包括一个add和一个未执行的jo。即使在Sandybridge系列上,它们也无法宏观融合成单个uop 甚至,但由于分支预测+推测执行,jo仅产生吞吐量而不是延迟。(在内联时,mov将消失。)
如果饱和度并不罕见且分支预测是一个问题,使用基于配置文件的优化编译,并且gcc会希望进行if转换并使用cmovno而不是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 太早,所以除了在宏中外无用。 (相关:ICC19 不通过 __builtin_saddll_overflow 进行常量传播:它将两个输入都放在寄存器中并仍然执行加法。GCC 和 Clang 只返回一个常量。)

在 hoisted MIN/MAX 选择的循环内部,此优化尤其有价值,只剩下 add + cmovo。(或者 add + jo 到一个 mov。)

cmov是英特尔P6家族和SnB家族在Broadwell之前的一条2微操作指令,因为它有3个输入。在其他x86 CPU(Broadwell / Skylake 和 AMD)上,它是一个单微操作指令。在大多数这样的CPU上,它具有1个周期的延迟。它是一个简单的ALU选择操作;唯一的复杂性是3个输入(2个寄存器+FLAGS)。但在KNL上,它仍然是2周期延迟。


很遗憾,AArch64的gcc未能使用adds来设置标志并检查V(溢出)标志结果,因此它需要花费多个指令来决定是否分支。
Clang做得非常好,AArch64的立即编码可以将INT64_MAX表示为eoradd的操作数。
// 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 或其他类似指令进行选择。

我们可以假设使用的是二进制补码,因为 gcc 仅支持二进制补码整数类型,而 ISO C 可选的 int64_t 类型如果存在,则保证为二进制补码。(int64_t 的有符号溢出仍然是未定义行为,这允许它成为 longlong long 的简单 typedef。)

在支持某种等效于__builtin_add_overflow的符号/幅值C实现中,一个针对long longint的函数版本不能使用SHR / ADD技巧。为了极大的可移植性,你可能只需使用简单的三元运算符,或者特别针对符号/幅值,你可以return (b&0x800...) | 0x7FFF...b的符号位与最大幅值数进行按位或操作。

对于二进制补码,MIN和MAX的位模式分别为0x8000...(只有高位设置)和0x7FFF...(所有其他位设置)。它们具有一些有趣的属性:MIN = MAX + 1(如果使用无符号计算),MIN = ~MAX:它们的位模式是按位反转,也就是互为一的补码。

MIN = ~MAX属性是从~x = -x - 1(标准的-x = ~x + 12's complement identity的重新排列)和MIN = -MAX - 1这个事实得出的。而+1属性与此无关,它只是从最正到最负的简单翻转中产生,并适用于有符号整数的补码编码。(但不适用于符号/大小;在无符号大小中会得到-0)。

上述函数使用了“MIN = MAX + 1”技巧。通过算术右移(创建“0”或“0xFF…”)将符号位广播到所有位置,并与之异或,也可以使用“MIN = ~MAX”技巧。GNU C保证有符号右移是算术(符号扩展),因此在GNU C中,“(b>>63) ^ INT64_MAX”等同于“(b<0) ? INT64_MIN : INT64_MAX”。
ISO C使有符号右移的实现是定义的,但我们可以使用“b<0 ? ~0ULL : 0ULL”的三元运算符。编译器将优化以下内容以进行sar / xor或等效指令,但它没有实现定义的行为。AArch64可以像使用add一样使用移位输入操作数来使用eor。
        // 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”指令:条件选择反转。由于其强大的立即编码,可以使用单个32位“mov”指令将INT64_MIN放入寄存器,以获得简单的位模式。 AArch64 GCC已经使用了“csinv”进行原始“return(b <0)?INT64_MIN:INT64_MAX;”版本的“MIN =〜MAX”技巧。

clang 6.0及更早版本在Godbolt上使用shr / add进行普通的“(b <0)?INT64_MIN:INT64_MAX;”版本。它看起来比clang7/8更有效,因此我认为这是一种回归/错过优化错误。 (这就是本节的全部内容以及我写第二版的原因。)
我选择了 MIN = MAX + 1 版本,因为它有可能更好地自动矢量化:x86 拥有 64 位 SIMD 逻辑右移,但只有 16 位和 32 位 SIMD 算术右移直到 AVX512F。当然,对于 64 位整数,使用 SIMD 进行有符号溢出检测可能不值得,直到 AVX512。好吧,也许是 AVX2。如果它是一些可以以其他方式高效矢量化的更大计算的一部分,那么解包到标量再返回就很糟糕。

对于标量来说,这真的没有区别;无论哪种方式编译效果都相同,sar/shr 的表现也相同,在 Agner Fog 测试过的所有 CPU 上都是如此https://agner.org/optimize/

但是,+ 有时候会被优化成其他东西,所以你可以想象gcc将常量后面的+-折叠到溢出分支中。或者可能使用LEA来复制并添加而不是ADD。从一个更简单的ALU执行单元执行XOR与ADD之间的功率差异将在超出顺序执行和其他操作所需的所有功率的噪声中丢失;即使对于64位整数,即使在P4 Prescott / Nocona上,所有x86 CPU都具有单周期标量ADD和XOR,其具有异乎寻常的加法器。

此外,@chqrlie建议以紧凑易读的方式编写它,而不需要UB,看起来比超级可移植的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的内联汇编所做的事情,但用一个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结果保存在一个整数中,然后执行测试/cmov,然后再次对saddll_overflow结果使用test将其放回FLAGS中。重新排列源代码解决了这个问题。

小改进:return b | INT64_MAX; - chqrlie
@chqrlie:我一直在尝试想出类似的东西,但这是其中一个不起作用的事情。两个常量分别为0x7FFF...0x8000...,而不是0xFFFF...。所需的设置位是不相交的,因此无法使用单个AND或OR生成它们。可以使用的是((uint64_t)b>>63) + (uint64_t)INT64_MAX。可以安全地假设为2的补码,因为int64_t是2的补码类型(它是可选的,因此非2的补码系统通常不会有它)。但是,通过将类型更改为long long,我的代码实际上是可移植的1的补码或符号/大小写。 - Peter Cordes
@chqrlie: INT64_MIN = ~INT64_MAX,因此我们可以通过广播位并使用算术右移进行条件位翻转,并将其用于XOR。b >> 63在普通编译器中是算术的,但ISO C不要求它。您可以使用b<0 ? 0ULL : ~0ULL的三元运算符,编译器将对其进行优化以进行sar。所以无论如何,(b>>63) ^ INT64_MAX都可以工作。 - Peter Cordes
@chqrlie:我更新了我的答案,使用了那个技巧,使其更加优化。 - Peter Cordes
1
抱歉让你感到困惑,提示是错误的,但结果很有趣。这里有一个稍微简单一些的解决方案,依赖于无符号算术运算:return ((uint64_t)b >> 63) + INT_MAX; - chqrlie
显示剩余2条评论

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

第一个看起来好像有一个条件移动需要执行,但由于我的编译器获得的特殊值可以通过加法完成:在二进制补码中,INT64_MININT64_MAX+1。 因此,在分配总和时只有一个条件移动,以防万一。 所有这些都没有UB,因为在抽象状态机中,只有在没有溢出的情况下才会执行总和。

1
漂亮的 (+1)。可以加上一些评论 :-) - Nemo
1
@Nemo,是的,有点简洁了,昨晚太晚了。我现在加了一些解释性注释。 - Jens Gustedt
cmov并不比其他指令更昂贵。执行所有额外的其他指令是便宜的。MSVC和ICC发现了在x86-64汇编中使用INT64_MAX + 1的技巧,例如(x>>63) + INT64_MAX。(但gcc没有;clang使用+1和cmov,针对cmov与任何其他单uop指令(如add)一样便宜的CPU进行调整,例如AMD和Broadwell/Skylake)。尽管如此,使用所有编译器实现(x < 0) == (y > comp)的其他工作仍然很重要。https://godbolt.org/z/gu5y81。让编译器发出`cmovo`会*好得多*。 - Peter Cordes

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
}

2
查看我的答案,它只生成一个条件移动的解决方案。这是否更好,您需要进行基准测试来确定。 - Jens Gustedt
我想知道你是否能够像这样做:asm("add %[y], %[x]\n\t" "jno 1f\n\t" "xor %%rax, %%rax\n\t" "mov %[MAX], %[x]\n\t" "setge %%al\n\t" "add %%rax, %[x]\n\t" "1:" : [x] "+r"(x) : [y] "r"(y), [MAX] "i"(INT64_MAX) : "eax", "cc");。乍一看,这可能比你的代码更长,但请记住,即使它不会使用它们,你的代码在调用汇编之前需要将值加载到%2和%3中。我的代码仅在溢出时执行加载(可能是较少见的情况)。注意:现在已经很晚了,我还没有运行过这个代码。正如@JensGustedt所说,请进行基准测试。 - David Wohlferd
假设饱和很少发生,您希望无溢出情况成为默认情况。如果您要使用2x CMOV,则可以使其无分支;例如,根据x的符号(对于正x,饱和到INT64_MIN是不可能的)选择INT64_MAX与MIN,然后进行addcmovo。但这会将两个CMOV都放在关键路径上。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接