【问题标题】:Efficient overflow-immune arithmetic mean in C/C++C/C++ 中的高效溢出免疫算术平均值
【发布时间】:2022-03-10 22:02:15
【问题描述】:

两个无符号整数的算术平均值定义为:

mean = (a+b)/2

在 C/C++ 中直接实现它可能会溢出并产生错误的结果。正确的实现可以避免这种情况。一种编码方式可能是:

mean = a/2 + b/2 + (a%2 + b%2)/2

但这会产生相当多的典型编译器代码。在汇编程序中,这通常可以更有效地完成。例如,x86可以通过以下方式做到这一点(汇编伪代码,希望你明白):

ADD a,b   ; addition, leaving the overflow condition in the carry bit
RCR a,1   ; rotate right through carry, effectively a division by 2

在这两条指令之后,结果在a,除法的余数在进位位。如果需要正确舍入,第三条ADC 指令必须将进位添加到结果中。

请注意,使用了 RCR 指令,该指令通过进位循环寄存器。在我们的例子中,它是一个位置的循环,因此前一个进位成为寄存器中的最高有效位,新的进位保存寄存器中的前一个 LSB。似乎 MSVC 甚至没有为此指令提供内在函数。

是否有一种已知的 C/C++ 模式可以被优化编译器识别,从而生成如此高效的代码?或者,更一般地说,是否有一种合理的方式如何在 C/C++ 源代码级别进行编程,以便编译器使用进位位来优化生成的代码?

编辑:

关于std::midpoint:https://www.youtube.com/watch?v=sBtAGxBh-XI的1小时讲座

哇!

EDIT2:Great discussion on Microsoft blog

【问题讨论】:

  • 考虑((wider_type)a+b)/2
  • 您应该澄清您正在寻找无符号算术平均值。您的“add + rcr”为有符号整数给出了错误的答案。许多编译器都有一个内在的“添加和报告执行”,您可以使用它。然后除以 2 并根据进位设置最高位,或使用旋转内在函数。
  • @sh- 是的,你提到“如果需要正确的舍入,第三条 ADC 指令必须将进位添加到结果中。” +1 的额外加法仅对有符号负数的舍入方向有意义。但是在有符号数字的情况下,进位没有设置,而是溢出。因此我很困惑。
  • @chux-ReinstateMonica:对于小于 reg 宽度的类型,加宽编译非常有效。令人惊讶的是,uint64_t 使用 unsigned __int128 作为更广泛的类型也相当不错:编译器实现高半 0/1,然后将 shrd 它放入。godbolt.org/z/sz53eEYh9 显示答案和 cmets 中提出的其他公式。 在 ARM64 上,总共只需要 3 条指令,adds/adcs/extr。因此,如果 ARM64 有 RCR,它只比你在 asm 中做的差 1。

标签: c++ c optimization compiler-optimization intrinsics


【解决方案1】:

以下方法可以避免溢出,并且应该可以在不依赖非标准功能的情况下实现相当高效的组装 (example):

    mean = (a&b) + (a^b)/2;

【讨论】:

【解决方案2】:

有三种典型的方法来计算平均值而不溢出,其中一种仅限于 uint32_t(在 64 位架构上)。

// average "SWAR" / Montgomery
uint32_t avg(uint32_t a, uint32_t b) {
   return (a & b) + ((a ^ b) >> 1);
}

// in case the relative magnitudes are known
uint32_t avg2(uint32_t min, uint32_t max) {
  return min + (max - min) / 2;
}
// in case the relative magnitudes are not known
uint32_t avg2_constrained(uint32_t a, uint32_t b) {
  return a + (int32_t)(b - a) / 2;
}

// average increase width (not applicable to uint64_t)
uint32_t avg3(uint32_t a, uint32_t b) {
   return ((uint64_t)a + b) >> 1;
}

clang 在两种架构中对应的汇编序列是

avg(unsigned int, unsigned int)
    mov     eax, esi
    and     eax, edi
    xor     esi, edi
    shr     esi
    add     eax, esi

avg2(unsigned int, unsigned int)
    sub     esi, edi
    shr     esi
    lea     eax, [rsi + rdi]

avg3(unsigned int, unsigned int)
    mov     ecx, edi
    mov     eax, esi
    add     rax, rcx
    shr     rax

对比

avg(unsigned int, unsigned int)         
    and     w8, w1, w0
    eor     w9, w1, w0
    add     w0, w8, w9, lsr #1
    ret
avg2(unsigned int, unsigned int)
    sub     w8, w1, w0
    add     w0, w0, w8, lsr #1
    ret
avg3(unsigned int, unsigned int):                                       
    mov     w8, w1
    add     x8, x8, w0, uxtw
    lsr     x0, x8, #1
    ret

在这三个版本中,avg2 也可以在 ARM64 中执行,作为使用进位标志的最佳序列——而且avg3 也很可能也可以执行,注意到 mov w8,w1 用于清除前 32 位,这可能是不必要的,因为编译器知道它们已被用于生成值的任何先前指令清除。

英特尔版本的avg3 也可以做出类似的声明,在最佳情况下,它会编译为仅两条有意义的指令:

add     rax, rcx
shr     rax

在线比较见https://godbolt.org/z/5TMd3zr81

“SWAR”/Montgomery 版本通常仅在尝试计算打包为单个(大)整数的多个平均值时才合理,在这种情况下,完整的公式包含最高位的位位置的掩码:return (a & b) + (((a ^ b) >> 1) & ~kH;

【讨论】:

  • godbolt.org/z/sz53eEYh9 显示了 x86-64 和 ARM64 的 GCC 和 clang 的扩展和各种其他公式。 (包括 uint64_t 扩大到 unsigned __int128,它们实现高半 0 或 1 并将其双移/提取回来,模拟 RCR。)
  • a > b 时 avg2 是否正确?
  • @sh-:不,不是。尝试在 Godbolt 上不断输入,例如只需将a=10, b=5; 添加到该函数,然后优化为mov eax, -2147483641。我想知道这是否好得令人难以置信;事实证明,除非你知道哪个输入总是更大。
  • @Peter Cordes:这有更好的机会,但我仍然不能完全确定没有无效的极端情况:a + int(b - a) / 2(当然,必须使用正确的大小 int)。
  • @AkiSuihkonen:所以avg2_constrained 的约束是b-a 中没有带符号溢出和/或无符号-> 带符号转换没有溢出?对于a=0; b=0xFFFFFFFFu,它产生 0 而不是 2147483647,因此它不适用于所有无符号输入。 godbolt.org/z/WrfG9fjTd
猜你喜欢
  • 1970-01-01
  • 2015-06-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-02-10
  • 2021-09-04
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多