24

有一个现有的问题“3 个长整数的平均值”,它特别关注三个有符号整数的平均值的有效计算。

然而,无符号整数的使用允许额外的优化不适用于上一个问题所涵盖的场景。这个问题是关于三个无符号整数平均值的有效计算,其中平均值向零舍入,即在数学术语中我想计算 ⌊ (a + b + c) / 3 ⌋。

计算该平均值的一种直接方法是

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

首先,现代优化编译器会将除法转换为具有倒数加移位的乘法,并将模运算转换为反乘和减法,其中反乘可以使用许多架构上可用的scale_add习惯用法,例如leax86_64,addlsl #nARM 上,iscadd在 NVIDIA GPU 上。

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

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

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

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

最后,对于校正项中的除以 3,(a % 3 + b % 3 + c % 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 个周期),吞吐量变差了(从数据每 6.8 个周期一个结果到每 8.5 个周期一个结果uint64_t)。更仔细地查看汇编代码很明显为什么会这样:我基本上设法将代码从大致三向并行度降低到大致双向并行度。

是否有一种普遍适用的优化技术,对常见的处理器特别是所有类型的 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
4

7 回答 7

15

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

#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;
于 2020-10-31T01:04:16.973 回答
6

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

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溢出类型则返回*r0否则返回,因此计算的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;
}
于 2020-10-28T11:00:19.237 回答
5

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

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;
于 2020-10-31T14:14:40.567 回答
5

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

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

( a + b + c ) / 3

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

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

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

于 2020-10-31T21:14:59.613 回答
3

我怀疑 SIMPLE 正在通过 CSEing 和提升a/3+b/3a%3+b%3脱离循环来击败吞吐量基准,并为所有 16 个结果重用这些avg0..15结果。

(简单的版本比复杂的版本可以完成更多的工作;真的只是在那个版本中。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 / andnmulxRCX / and rcx, -2,它只需要一个符号扩展的立即数。


在不强制调用/调用开销的情况下执行此操作的另一种方法是内联汇编,例如在进行基准测试时防止编译器优化(Chandler Carruth 的 CppCon 演讲有一些他如何使用几个包装器的示例)或 Google Benchmark 的benchmark::DoNotOptimize.

具体来说,asm("" : "+r"(a), "+r"(b))每条avgX = average_of_3 (a, b, avgX);语句之间的 GNU C 将使编译器忘记它所知道的关于aand的值的所有信息b,同时将它们保存在寄存器中。

我对我不理解 DoNotOptimizeAway 的定义的回答更详细地介绍了使用只读"r"寄存器约束来强制编译器在寄存器中实现结果,而不是"+r"让它假设值已被修改。

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

于 2020-10-28T04:53:42.353 回答
3

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

在我的问题中,我找到了一种比 CSA 技术更好的方法,可以将除法和取模工作从三个操作数减少到两个操作数。首先,形成完整的双字和,然后分别对每一半应用除法和模3,最后组合结果。由于最重要的一半只能取值 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;
}
于 2020-10-29T10:27:32.553 回答
1

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-01-11T20:20:09.993 回答