【问题标题】:Efficient computation of the average of three unsigned integers (without overflow)高效计算三个无符号整数的平均值(无溢出)
【发布时间】:2021-02-10 05:49:01
【问题描述】:

有一个existing question“3 个长整数的平均值”专门关注三个有符号整数的平均值的有效计算。

但是,使用无符号整数可以进行其他优化,但不适用于上一个问题中涉及的场景。这个问题是关于三个 unsigned 整数平均值的有效计算,其中平均值向零舍入,即在数学术语中我想计算 ⌊ (a + b + c) / 3 ⌋。

计算这个平均值的简单方法是

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

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

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

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

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

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

最后对于修正项(a % 3 + b % 3 + c % 3) / 3中的除以3,我们不需要通用除以3的代码。由于除数很小,在[0, 6]中,我们可以将x / 3简化为(3 * x) / 8 只需要一个 scale_add 加上一个 shift

下面的代码显示了我当前正在进行的工作。使用 Compiler Explorer 检查为各种平台生成的代码显示了我所期望的紧凑代码(使用 -O3 编译时)。

但是,在使用 Intel 13.x 编译器对我的 Ivy Bridge x86_64 机器上的代码进行计时时,一个缺陷变得明显:与简单的代码相比,我的代码提高了延迟(uint64_t 数据从 18 个周期到 15 个周期)版本,吞吐量恶化(uint64_t 数据从每 6.8 个周期一个结果到每 8.5 个周期一个结果)。更仔细地查看汇编代码,很明显为什么会这样:我基本上设法将代码从大致三向并行度降低到大致双向并行度。

是否有一种普遍适用的优化技术,对常见处理器特别是所有类型的 x86 和 ARM 以及 GPU 都有益,并且可以保留更多的并行性?或者,是否有一种优化技术可以进一步减少总体操作数以弥补并行度的降低?校正项的计算(下面代码中的tail)似乎是一个很好的目标。简化 (carry_mod_3 + sum_mod_3) / 2 看起来很诱人,但对于九种可能的组合之一给出的结果不正确。

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

#define BENCHMARK           (1)
#define SIMPLE_COMPUTATION  (0)

#if BENCHMARK
#define T uint64_t
#else // !BENCHMARK
#define T uint8_t
#endif // BENCHMARK

T average_of_3 (T a, T b, T c) 
{
    T avg;

#if SIMPLE_COMPUTATION
    avg = a / 3 + b / 3 + c / 3 + (a % 3 + b % 3 + c % 3) / 3;
#else // !SIMPLE_COMPUTATION
    /* carry save adder */
    T a_xor_b = a ^ b;
    T sum = a_xor_b ^ c;
    T carry = (a_xor_b & c) | (a & b);
    /* here 2 * carry + sum = a + b + c */
    T sum_div_3 = (sum / 3);                                   // {MUL|MULHI}, SHR
    T sum_mod_3 = (sum + sum_div_3) & 3;                       // ADD, AND

    if (sizeof (size_t) == sizeof (T)) { // "native precision" (well, not always)
        T two_carry_div_3 = (carry / 3) * 2;                   // MULHI, ANDN
        T two_carry_mod_3 = (2 * carry + two_carry_div_3) & 6; // SCALE_ADD, AND
        T head = two_carry_div_3 + sum_div_3;                  // ADD
        T tail = (3 * (two_carry_mod_3 + sum_mod_3)) / 8;      // ADD, SCALE_ADD, SHR
        avg = head + tail;                                     // ADD
    } else {
        T carry_div_3 = (carry / 3);                           // MUL, SHR
        T carry_mod_3 = (carry + carry_div_3) & 3;             // ADD, AND
        T head = (2 * carry_div_3 + sum_div_3);                // SCALE_ADD
        T tail = (3 * (2 * carry_mod_3 + sum_mod_3)) / 8;      // SCALE_ADD, SCALE_ADD, SHR
        avg = head + tail;                                     // ADD
    }
#endif // SIMPLE_COMPUTATION
    return avg;
}

#if !BENCHMARK
/* Test correctness on 8-bit data exhaustively. Should catch most errors */
int main (void)
{
    T a, b, c, res, ref;
    a = 0;
    do {
        b = 0;
        do {
            c = 0;
            do {
                res = average_of_3 (a, b, c);
                ref = ((uint64_t)a + (uint64_t)b + (uint64_t)c) / 3;
                if (res != ref) {
                    printf ("a=%08x  b=%08x  c=%08x  res=%08x  ref=%08x\n", 
                            a, b, c, res, ref);
                    return EXIT_FAILURE;
                }
                c++;
            } while (c);
            b++;
        } while (b);
        a++;
    } while (a);
    return EXIT_SUCCESS;
}

#else // BENCHMARK

#include <math.h>

// A routine to give access to a high precision timer on most systems.
#if defined(_WIN32)
#if !defined(WIN32_LEAN_AND_MEAN)
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
double second (void)
{
    LARGE_INTEGER t;
    static double oofreq;
    static int checkedForHighResTimer;
    static BOOL hasHighResTimer;

    if (!checkedForHighResTimer) {
        hasHighResTimer = QueryPerformanceFrequency (&t);
        oofreq = 1.0 / (double)t.QuadPart;
        checkedForHighResTimer = 1;
    }
    if (hasHighResTimer) {
        QueryPerformanceCounter (&t);
        return (double)t.QuadPart * oofreq;
    } else {
        return (double)GetTickCount() * 1.0e-3;
    }
}
#elif defined(__linux__) || defined(__APPLE__)
#include <stddef.h>
#include <sys/time.h>
double second (void)
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return (double)tv.tv_sec + (double)tv.tv_usec * 1.0e-6;
}
#else
#error unsupported platform
#endif

#define N  (3000000)
int main (void)
{
    double start, stop, elapsed = INFINITY;
    int i, k;
    T a, b;
    T avg0  = 0xffffffff,  avg1 = 0xfffffffe;
    T avg2  = 0xfffffffd,  avg3 = 0xfffffffc;
    T avg4  = 0xfffffffb,  avg5 = 0xfffffffa;
    T avg6  = 0xfffffff9,  avg7 = 0xfffffff8;
    T avg8  = 0xfffffff7,  avg9 = 0xfffffff6;
    T avg10 = 0xfffffff5, avg11 = 0xfffffff4;
    T avg12 = 0xfffffff2, avg13 = 0xfffffff2;
    T avg14 = 0xfffffff1, avg15 = 0xfffffff0;

    a = 0x31415926;
    b = 0x27182818;
    avg0 = average_of_3 (a, b, avg0);
    for (k = 0; k < 5; k++) {
        start = second();
        for (i = 0; i < N; i++) {
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            avg0 = average_of_3 (a, b, avg0);
            b = (b + avg0) ^ a;
            a = (a ^ b) + avg0;
        }
        stop = second();
        elapsed = fmin (stop - start, elapsed);
    }
    printf ("a=%016llx b=%016llx avg=%016llx", 
            (uint64_t)a, (uint64_t)b, (uint64_t)avg0);
    printf ("\rlatency:    each average_of_3() took  %.6e seconds\n", 
            elapsed / 16 / N);


    a = 0x31415926;
    b = 0x27182818;
    avg0 = average_of_3 (a, b, avg0);
    for (k = 0; k < 5; k++) {
        start = second();
        for (i = 0; i < N; i++) {
            avg0  = average_of_3 (a, b, avg0);
            avg1  = average_of_3 (a, b, avg1);
            avg2  = average_of_3 (a, b, avg2);
            avg3  = average_of_3 (a, b, avg3);
            avg4  = average_of_3 (a, b, avg4);
            avg5  = average_of_3 (a, b, avg5);
            avg6  = average_of_3 (a, b, avg6);
            avg7  = average_of_3 (a, b, avg7);
            avg8  = average_of_3 (a, b, avg8);
            avg9  = average_of_3 (a, b, avg9);
            avg10 = average_of_3 (a, b, avg10);
            avg11 = average_of_3 (a, b, avg11);
            avg12 = average_of_3 (a, b, avg12);
            avg13 = average_of_3 (a, b, avg13);
            avg14 = average_of_3 (a, b, avg14);
            avg15 = average_of_3 (a, b, avg15);
            b = (b + avg0) ^ a;
            a = (a ^ b) + avg0;
        }
        stop = second();
        elapsed = fmin (stop - start, elapsed);
    }
    printf ("a=%016llx b=%016llx avg=%016llx", (uint64_t)a, (uint64_t)b, 
            (uint64_t)(avg0 + avg1 + avg2 + avg3 + avg4 + avg5 + avg6 + avg7 + 
                       avg8 + avg9 +avg10 +avg11 +avg12 +avg13 +avg14 +avg15));
    printf ("\rthroughput: each average_of_3() took  %.6e seconds\n", 
            elapsed / 16 / N);

    return EXIT_SUCCESS;
}

#endif // BENCHMARK

【问题讨论】:

  • 我不认为你的carry = (a_xor_b &amp; c) | (a &amp; b) 赶上a&amp;b&amp;c。为什么 (4/3 sum)%4 等于 sum%3?
  • 最好将这些测试程序编辑到问题中;它们足够长,以至于在 SO cmets 中变得一团糟。也许链接一个简短的版本来测试每个输入的低 3 位及其 MSB 的所有组合,可能在 tio.rungodbolt.org 上,因为现在 Godbolt 有一个编译和运行选项。
  • 输入的分布是什么?
  • @Sopel:无法对​​此做出任何假设,因此假设均匀随机。因此非常需要无分支解决方案。
  • @njuffa IMO 值得针对特定架构和数据大小对其进行优化。如果我们考虑 64 位系统和 32 位数据(在大多数 64 位系统上整数是 32 位),“天真”方法是最有效的。 godbolt.org/z/dh1jrPIMO 如果我们正在考虑微优化和 uOptimizations,它不能从硬件和实现中抽象出来。

标签: c algorithm bit-manipulation micro-optimization extended-precision


【解决方案1】:

让我把帽子扔进擂台。这里没有做任何太棘手的事情,我 想想。

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

【讨论】:

  • Clang 在这方面比 GCC for x86-64 做得更好。 gcc 花费更多指令,并使用mov same,same 击败mov-elimination 将其截断为32 位。 godbolt.org/z/ejYxvT(例如,mov esi,esi 将 ESI 零扩展到 RSI 仍然需要后端 uop,而 mov r8d, esi 不需要,这对于 OP 的 Ivy Bridge CPU(前端比后端宽)可能更重要用于 Haswell 及更高版本。)如果处于关键路径上,也可以用于任何地方的延迟。
  • 我喜欢这种简单;似乎我的大脑陷入了太多的结。由于我 (= OP) 使用的是英特尔编译器,因此这个变体在 Ivy Bridge 上对我来说实际上比迄今为止尝试的任何其他变体在延迟和吞吐量方面都更好。除了x86_64 上的gcc 带有奇怪的截断习语之外,还为我在godbolt 上检查的所有编译器提供了更严格的代码。因为我需要 uint32_tuint64_t 的版本,所以我这样概括:const int shift = ((sizeof(T) * CHAR_BIT) / 2); const T mask = (((T)1) &lt;&lt; shift) - ((T)1); const T multiplier = (((T)(~(T)0)) / 3) &gt;&gt; shift;
  • @njuffa:对于 64 位机器上的 uint32_t 版本,您可能只想使用 (a + (uint64_t)b + c) / 3 将所有 3 个输入零扩展为 64 位。寄存器中的值通常已经知道是零扩展的,所以如果你有一个快速的 64 位乘法,这几乎是免费的。但是,是的,这对于已经是全寄存器宽度的输入来说非常优雅。
  • @njuffa:对于 IvyBridge 上的 32 位 x86 及更高版本,lo = 8,hi = 24 位的拆分让我们可以使用movzx dword, byte,它允许对字节源 movzx 进行 mov-elimination。 (在 Intel 而不是 AMD。仅适用于字节源,而不是字源。Same link as my first comment 了解详细信息。)如果数学仍然有效,那将节省后端 uops 并可能减少关键路径延迟。
  • @njuffa 你也可以定义multiplier = mask / 3
【解决方案2】:

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

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支持,如果和x + y溢出*r0的类型,则返回1,否则o的计算相当于第一版中的可移植代码,但至少 gcc 使用内置生成更好的代码。

T average_of_3 (T a, T b, T c)
{
    T r = ((T) (a + b + c)) / 3;
    T rem = ((T) (a + b + c)) % 3;
    T dummy;
    T o = __builtin_add_overflow(a, b, &dummy) + __builtin_add_overflow((T) (a + b), c, &dummy);
    r += -((o - 1) & 0xaaaaaaaaaaaaaaab) ^ 0x5555555555555555;
    r += (rem + o + 1) >> 2;
    return r;
}

【讨论】:

  • 有趣的想法!据我现在所知,这在功能上是正确的并且非常有效。在 Ivy Bridge 系统上,在吞吐量(现在我已经修复了我的测试框架)方面,性能比我的解决方案落后了大约 15%:每 3.3ns 一个结果与每 2.84ns 一个结果。此外,在 Compiler Explorer 中为 ARM 编译时,我看到的指令比我的解决方案中的指令多。如果你能设法修剪另外两三个指令,我们可能会有一个普遍的赢家。我还没有看 GPU 代码(现在是凌晨 4 点,该睡觉了 :-)
  • 可能编译器无论如何都会这样做,但是呢:return r + (rem &gt;= (3 - o));
  • 其实不太正确,我们需要以o != 0为条件做第一个+=。我会考虑如何更简洁地表达这一点......
  • 好的,修复并添加了无分支版本。
  • @FalkHüffner 恐怕我不知道__builtin_add_overflow_p() 做了什么,所以我可以尝试在这里复制它。这编译成什么?这可以用便携的方式表达吗?一些解释性说明会有所帮助。
【解决方案3】:

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

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

( a + b + c ) / 3

由于您关心性能,因此您应该对正在处理的数据大小有一个直觉。你不应该担心只有 3 个值的加法(乘法是另一回事)溢出,因为如果你的数据已经足够大,可以使用你选择的数据类型的高位,那么无论如何你都有溢出的危险,应该使用更大的整数类型。如果你在 uint64_t 上溢出,那么你真的应该问问自己,为什么你需要精确地计数到 18 quintillion,或许可以考虑使用 float 或 double。

现在,说了这么多,我将给你我的实际答复:没关系。这个问题在现实生活中不会出现,当它出现时,perf 不会问题。

如果您在 SIMD 中执行一百万次,这可能是一个真正的性能问题,因为在那里,您确实被激励使用较小宽度的整数,您可能需要最后一点空间,但这不是您的问题。

【讨论】:

  • 如果你偏执,你可以用 x86 add rdi, rsi / jc safe_version / add rdi, rdx / jc safe_version / ... 来检测环绕。在现代英特尔 CPU 上,这些 add/jcc 指令将宏融合到添加和分支微指令中。仍然比只添加而不检查效率低,但如果您希望永远不需要或很少需要安全版本,那么它是一个很好的乐观快速路径。但是,是的,大多数时候这是正确的答案,而且问题是 XY 问题。如果您还没有 uint64_t,如有必要,请将您的输入零扩展为 64 位。
【解决方案4】:

新的答案,新的想法。这是基于数学恒等式

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 b, T c) {
  T x = (a >> 2) + (b >> 2) + (c >> 2);
  return x + (a + b + c - 3 * x) / 3;
}

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

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

请注意,T 必须至少为 5 位宽。

如果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;

【讨论】:

  • 在 x86_64 上,我测量的吞吐量与您之前的解决方案相同,但延迟更差(代码中的长依赖链很明显)。尽管如此,尝试过的任何变体的延迟都是第二好的。甚至可能在乘法器较慢的平台上赢得延迟,因为只需要一个乘法器。很好的解决方案,我很高兴我提供了赏金。
  • @njuffa 在我的最新型号 MacBook(Intel(R) Core(TM) i7-1060NG7 CPU @ 1.20GHz)上使用 Clang,吞吐量对我来说要好一些。
  • 我不认为你的意思是/3.double 常数作为除数)。您似乎只在实际实现中使用整数除法。 (我想知道 Ice Lake 改进的除法器是否会有所作为,但这仅适用于整数 div / idiv,除了 GCC 调用 __udivdi3 时 32 位模式下的天真 uint64_t 除法。即使你使用了divsd,它也会有与 Skylake 相同的延迟和吞吐量,并且类似于 Ice Lake。uops.info)
  • @PeterCordes 相等的本意是数学(注意缺少的 *)。我添加了缺少的楼层操作。
  • (那个替代版本已经足够接近我从臀部拍摄的照片了。也接近Falk Hüffner's answer。)
【解决方案5】:

我怀疑 SIMPLE 通过 CSE 和提升 a/3+b/3a%3+b%3 超出了吞吐量基准,将这些结果重用于所有 16 个 avg0..15 结果。

(简单的版本比复杂的版本可以完成更多的工作;实际上只有a ^ ba &amp; 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,它只需要一个符号扩展的立即数。


在不强制调用/调用开销的情况下执行此操作的另一种方法是内联 asm,例如 Preventing compiler optimizations while benchmarking(Chandler Carruth 的 CppCon 演讲中有一些他如何使用几个包装器的示例)或 Google Benchmark 的 benchmark::DoNotOptimize

具体来说,每个avgX = average_of_3 (a, b, avgX); 语句之间的GNU C asm("" : "+r"(a), "+r"(b)) 将使编译器忘记它所知道的关于ab 值的所有内容,同时将它们保留在注册。

我对@9​​87654323@ 的回答更详细地介绍了使用只读"r" 寄存器约束来强制编译器在寄存器中实现结果,而不是"+r" 使其假定值已被修改.

如果您对 GNU C 内联汇编有很好的了解,那么您可能会更容易地以您确切知道它们的作用的方式推出自己的汇编。

【讨论】:

  • 关于吞吐量基准提升的好点,我一定要检查一下。谢谢。如果发生,这两种代码变体都应该发生,但程度不同?让我考虑重新制作基准测试支架的吞吐量部分。我是一个多架构的人,并使用“概念”通用指令名称进行注释。在我注释 MULHI, ANDN 的地方,ARM 编译器使用 BIC,它只是 ANDN 的另一个名称。由于 x86 没有 ANDN,因此编译器会反转立即数(如您所述),从而达到相同的效果。
  • @njuffa:是的,在我看来,简单的工作比复杂的工作要多得多。 x86 BMI1 确实andn,但不需要立即。
  • 制作 'a' 和 'b' volatile 似乎可以通过仔细查看生成的 asm 代码来完成这项工作。我看到的唯一提升的是加载除以 3 替换序列的“魔术乘数”。我现在觉得有点愚蠢,因为通常我知道基准循环中的提升问题。我想我今天太累了,没有注意到,谢谢你让我回到正确的道路上!我会等几天看看是否有任何其他答案进来(保存更多指令不会有坏处:-)
  • 哦,是的,volatile vars 可以在每次引用它们时强制从内存中重新加载。另一种方法是在每个 avg4 = average_of_3 (a, b, avg4); 之间使用 GNU C asm("" : "+r"(a)),以便 a 可以保留在寄存器中,但编译器会忘记它所知道的关于其值的所有内容。该技巧的各种版本存在,包括 Google Benchmark 的 Benchmark::DoNotOptimize 和 Chandler Carruth 的 escapeclobber 包装器,在 Preventing compiler optimizations while benchmarking 中提到
  • @njuffa:如果您希望您的输入通常很小,那么简单地累积到unsigned __int128 可能会很好,add/setc + add/adc reg,0。我在godbolt.org/z/j95qn3 中玩过这个,注释掉了。您可以在上半部分分支为零。但如果它不为零,u128 / 3 编译为对使用普通div r64 的辅助函数的调用,这在英特尔上非常慢。 x/3 + (1 or 2&lt;&lt;64)/3 可能会使用正确的舍入进行某种诡计,如果我们可以有效地进行 66 位除法,则可能不值得分支。
【解决方案6】:

[Falk Hüffner 在 cmets 中指出,这个答案与 his answer 有相似之处。稍后再仔细查看他的代码,我确实发现了一些相似之处。然而我在这里发布的是一个独立思考过程的产物,是我最初想法“在 div-mod 之前将三个项目减少到两个”的延续。我理解 Hüffner 的方法是不同的:“天真计算后修正”。]

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

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

T average_of_3_hilo (T a, T b, 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;
}

【讨论】:

  • 这似乎与我发布的版本几乎相同,唯一的区别是我让编译器巧妙地计算lo_mod_3,并且我在计算hi_div_3时避免了乘法.
  • @FalkHüffner 我习惯于仔细记下对我发布到 Stackoverflow 的答案(以及我的一般代码)的任何已知影响。我在答案顶部添加了一条注释,以保留您的评论内容以确认相似之处。你满意吗?
  • 当然,没问题!我没有很好地描述我的代码,所以我可以看到这是怎么发生的。
  • hi 可以是uint32_t 或更窄,即使是T = uint64_t。如果编译器还没有发现它可以避免更新始终为零的高半部分的所有情况,那么任何 32 位系统都会受益。看起来hi_div_3 仍然需要为T hi_div_3,但其他可以像uint8_t 一样窄,包括在添加到lo_mod_3 之前的hi_mod_3 + 1。编译器并不总是擅长将变量保持在狭窄范围内,因此这可能值得手动注意。不过,我不希望 IvyBridge 上的 64 位构建有实际的不同。只是那里的代码大小。
  • godbolt.org/z/5oKerG 显示了与 gcc -m32 -O3 -march=ivybridge 的区别。 clang 使用-m32 为您缩小了hi,与asm 相同,但GCC 没有。当然,call __udivdi3 是性能灾难,因此编译器输出对于 32 位模式不是很有用。可能扩展精度乘法仍然是除以常数的胜利,但是要内联的代码很多,因此编译器不会为您这样做。
【解决方案7】:

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

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

【讨论】:

    猜你喜欢
    • 2021-09-04
    • 2012-02-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-04-10
    • 1970-01-01
    • 2017-03-03
    相关资源
    最近更新 更多