微信公众号搜"智元新知"关注
微信扫一扫可直接关注哦!

有效计算三个无符号整数的平均值无溢出

如何解决有效计算三个无符号整数的平均值无溢出

一个existing question“ 3个长整数的平均值”,特别涉及对三个 signed 整数的平均值的有效计算。

但是,使用无符号整数可以实现不适用于上一个问题所涉及的方案的其他优化。这个问题是关于有效计算三个 unsigned 整数的平均值的方法,其中平均值取整为零,即,在数学上,我想计算⌊(a + b + c)/ 3⌋。

一种计算平均值的简单方法

 avg = a / 3 + b / 3 + c / 3 + (a % 3 + b % 3 + c % 3) / 3;

首先,现代优化编译器会将除法运算转换为带有倒数加移位的乘法运算,并将模运算转换为反乘和减法,其中反乘可能使用 scale_add 习惯用法可用于许多体系结构,例如在x86_64上为lea,在ARM上为addlsl #n在NVIDIA GPU上为iscadd

在尝试以适用于许多常见平台的通用方式优化上述内容时,我观察到整数运算的开销通常与 logical ≤( add | sub )≤ shift scale_add mul 。这里的成本是指所有延迟,吞吐量限制和功耗。当整数类型的处理宽度大于本地寄存器的宽度时,任何此类差异都会变得更加明显。在32位处理器上处理uint64_t数据时。

因此,我的优化策略是最大程度地减少指令数量,并在可能的情况下用“便宜”的操作代替“昂贵”的操作,同时又不增加寄存器压力,并为广泛乱序的处理器保留可利用的并行性。

一个观察结果是,我们可以通过首先应用产生和值和进位值的CSA(进位保存加法器)将三个操作数之和减少为两个操作数之和,其中进位值的权重是两倍总和的在大多数处理器上,基于软件的CSA的成本为逻辑五。某些处理器(例如NVIDIA GPU)具有LOP3指令,可以一口气地计算三个操作数的任意逻辑表达式,在这种情况下,CSA压缩为两个LOP3(注:我尚未说服CUDA编译器发出这两个LOP3;它当前产生四个LOP3!)。

第二个观察结果是,因为我们正在计算除以3的模,所以不需要反乘来计算它。相反,我们可以使用dividend % 3 = ((dividend / 3) + dividend) & 3,因为我们已经有了除法结果,所以将模取为 add 逻辑。这是通用算法的一个实例:股息%(2 n -1)=((股息/(2 n -1)+股息)&(2 n -1)。

最后,对于校正项(a % 3 + b % 3 + c % 3) / 3中的三分频,我们不需要进行三分法的代码。由于除数很小,因此在[0,6]中,我们可以简化{{ 1}}到x / 3中,只需要 scale_add shift

下面的代码显示了我当前的工作进度。使用Compiler Explorer检查为各种平台生成代码显示了我期望的紧密代码(使用(3 * x) / 8进行编译时)。

但是,在使用Intel 13.x编译器在Ivy Bridge x86_64机器上计时代码时,一个缺陷变得显而易见:与之相比,我的代码将延迟(-O3数据从18个周期提高到15个周期)对于简单的版本,吞吐量会恶化(uint64_t数据从每6.8个周期的一个结果到每8.5个周期的一个结果)。更仔细地看一下汇编代码,原因很明显:我基本上设法将代码从大约三路并行性降低到大约两路并行性。

是否存在一种通用的优化技术,它对通用处理器(尤其是x86和ARM的所有版本以及GPU)有益,并且保留了更多的并行性?或者,是否有一种优化技术可以进一步减少总体操作次数以弥补并行性的降低?校正项(以下代码中的uint64_t)的计算似乎是一个不错的目标。简化tail看上去很诱人,但对于9种可能的组合之一却给出了错误的结果。

(carry_mod_3 + sum_mod_3) / 2

解决方法

让我戴上帽子。我在这里没有做任何棘手的事情 想。

#include <stdint.h>

uint64_t average_of_three(uint64_t a,uint64_t b,uint64_t c) {
  uint64_t hi = (a >> 32) + (b >> 32) + (c >> 32);
  uint64_t lo = hi + (a & 0xffffffff) + (b & 0xffffffff) + (c & 0xffffffff);
  return 0x55555555 * hi + lo / 3;
}

下面是有关不同拆分的讨论,下面是一个版本,它以三个按位与为代价来保存乘法:

T hi = (a >> 2) + (b >> 2) + (c >> 2);
T lo = (a & 3) + (b & 3) + (c & 3);
avg = hi + (hi + lo) / 3;
,

我不确定它是否符合您的要求,但也许可以用来计算结果,然后修复溢出中的错误:

T average_of_3 (T a,T b,T c)
{
    T r = ((T) (a + b + c)) / 3;
    T o = (a > (T) ~b) + ((T) (a + b) > (T) (~c));
    if (o) r += ((T) 0x5555555555555555) << (o - 1);
    T rem = ((T) (a + b + c)) % 3;
    if (rem >= (3 - o)) ++r;
    return r;
}

[编辑]这是我能想到的最好的无分支和无比较版本。在我的计算机上,此版本实际上比njuffa的代码具有更高的吞吐量。 __builtin_add_overflow(x,y,r)受gcc和clang支持,如果总和1溢出了x + y的类型,则返回*r,否则返回0,因此{{1 }}与第一个版本中的可移植代码等效,但是至少gcc内置了更好的代码。

o
,

新答案,新思路。这是基于数学身份的

floor((a+b+c)/3) = floor(x + (a+b+c - 3x)/3)

什么时候可以使用机器整数和无符号除法?
如果差异不明显,即0 ≤ a+b+c - 3x ≤ T_MAX

x的定义很快并且可以完成工作。

T avg3(T a,T c) {
  T x = (a >> 2) + (b >> 2) + (c >> 2);
  return x + (a + b + c - 3 * x) / 3;
}

奇怪的是,除非我这样做,否则ICC会插入一个额外的负数:

T avg3(T a,T c) {
  T x = (a >> 2) + (b >> 2) + (c >> 2);
  return x + (a + b + c - (x + x * 2)) / 3;
}

请注意,T的宽度必须至少为五位。

如果T长两个平台字,则可以通过省略x的低字来节省一些双字操作。

具有较差的延迟但吞吐量可能稍高的替代版本?

T lo = a + b;
T hi = lo < b;
lo += c;
hi += lo < c;
T x = (hi << (sizeof(T) * CHAR_BIT - 2)) + (lo >> 2);
avg = x + (T)(lo - 3 * x) / 3;
,

我已经回答了您链接到的问题,所以我只回答与这一部分不同的部分:性能。

如果您真的很关心性能,那么答案是:

( a + b + c ) / 3

由于您关心性能,因此您应该对正在使用的数据大小有一个直觉。您不必担心仅3个值的加法溢出(乘法是另一回事),因为如果您的数据已经足够大到可以使用所选数据类型的高位,则无论如何您都有溢出的危险,应该使用较大的整数类型。如果您在uint64_t上溢出,那么您应该真正地问自己:为什么精确地计数直到18十亿位数仍然是正确的,并且也许考虑使用float或double。

现在,说完这些之后,我会给您我的实际答复:没关系。这个问题在现实生活中不会出现,当它出现时,性能不会出现问题。

如果您在SIMD中进行一百万次操作,这可能是一个真正的性能问题,因为在那里,您真的被激励使用较小宽度的整数,并且可能需要最后一点余量,但这不是您的目标问题。

,

我怀疑SIMPLE正在通过CSEing和提升a/3+b/3a%3+b%3并将其结果重新用于全部16个avg0..15结果中来击败吞吐量基准。 >

(SIMPLE版本比棘手版本可完成更多工作;实际上,该版本仅a ^ ba & b。)

强制该函数不内联会带来更多的前端开销,但确实会使您的版本获胜,正如我们期望的那样,它应该在具有大量乱序执行缓冲区的CPU上重叠独立的工作。对于吞吐量基准,有很多ILP可以在各个迭代中找到。 (对于非内联版本,我没有仔细查看asm。)

https://godbolt.org/z/j95qn3(在Godbolt的SKX CPU上将__attribute__((noinline))clang -O3 -march=skylake一起使用)以简单的方式显示2.58纳秒的吞吐量,以您的方式显示2.48纳秒的吞吐量。与简单版本的内联处理相比,吞吐量为1.17纳秒。

-march=skylake允许mulx进行更灵活的全数乘法,但否则无法从BMI2中受益。 andn未使用;您用mulhi / andn注释的行是mulx到RCX / and rcx,-2中,它只需要符号扩展立即数即可。


另一种不造成通话/重载开销的方法是像Preventing compiler optimizations while benchmarking中那样的内联汇编程序(钱德勒·卡鲁斯(Chandler Carruth)的CppCon演讲中有一些他如何使用包装纸的示例),或者是Google Benchmark的benchmark::DoNotOptimize

具体来说,每条asm("" : "+r"(a),"+r"(b))语句之间的GNU C avgX = average_of_3 (a,b,avgX); 会使编译器忘记关于a和{{ 1}},同时将它们保存在寄存器中。

我对I don't understand the definition of DoNotOptimizeAway的回答更详细地介绍了如何使用只读b寄存器约束来强制编译器将结果具体化到寄存器中,而不是"r"假设该值已被修改。

如果您对GNU C内联控件了解得很好,则可以通过自己确切地了解GNU C的功能来进行滚动。

,

[FalkHüffner在评论中指出,此答案与his answer 类似。迟来看看他的代码,我确实发现了一些相似之处。但是,我在这里发布的内容是独立思考过程的产物,是我最初的想法的延续:“在div-mod之前将三项减少为两项”。我理解赫夫纳的方法是不同的:“天真计算,然后进行校正”。]

在我的问题中,我找到了一种比CSA技术更好的方法,可以将除法和模运算从三个操作数减少为两个操作数。首先,形成完整的双字和,然后分别对每个半部分应用除法和除以3的模,最后合并结果。由于最高有效的一半只能取值0、1或2,因此计算商和除以3的余数是微不足道的。而且,合并到最终结果中变得更加简单。

与非简单代码变体相比,该问题在我检查的所有平台上均实现了加速。编译器为模拟双字加法生成的代码质量各不相同,但总体上令人满意。尽管如此,以非便携式的方式编码此部分可能是值得的,例如内联汇编。

T average_of_3_hilo (T a,T c) 
{
    const T fives = (((T)(~(T)0)) / 3); // 0x5555...
    T avg,hi,lo,lo_div_3,lo_mod_3,hi_div_3,hi_mod_3; 
    /* compute the full sum a + b + c into the operand pair hi:lo */
    lo = a + b;
    hi = lo < a;
    lo = c + lo;
    hi = hi + (lo < c);
    /* determine quotient and remainder of each half separately */
    lo_div_3 = lo / 3;
    lo_mod_3 = (lo + lo_div_3) & 3;
    hi_div_3 = hi * fives;
    hi_mod_3 = hi;
    /* combine partial results into the division result for the full sum */
    avg = lo_div_3 + hi_div_3 + ((lo_mod_3 + hi_mod_3 + 1) / 4);
    return avg;
}
,

一个 GCC-11 的实验版本将明显的天真的函数编译成类似这样的东西:

uint32_t avg3t (uint32_t a,uint32_t b,uint32_t c) {
    a += b;
    b = a < b;
    a += c;
    b += a < c;

    b = b + a;
    b += b < a;
    return (a - (b % 3)) * 0xaaaaaaab;
}

这与此处发布的其他一些答案相似。 欢迎对这些解决方案的工作原理进行任何解释 (不确定这里的网络礼节)。

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。