【问题标题】:Does the CRC calculation trick using subtraction to avoid conditional execution make it faster?CRC计算技巧是否使用减法来避免条件执行使其更快?
【发布时间】:2020-11-22 09:26:58
【问题描述】:

按位 CRC 计算的 C 或 C++ 实现中典型的最内层语句如下所示:

crc = crc & 1 ? (crc >> 1) ^ 0xa001 : crc >> 1;

? 表明实现将有条件地执行指令。该条件大约一半时间为真,大约一半时间为假,因此无法预测。普遍的看法是,为了优化代码,应该避免不可预测的条件分支。

因此可以在没有条件的情况下进行相同的计算:

crc = (crc >> 1) ^ ((0 - (crc & 1)) & 0xa001);

在这种情况下,中间计算都可以在 CPU 的算术逻辑单元中可预测地完成。所以理论上这应该更快。

是吗?

【问题讨论】:

  • 至少在我的情况下,减法“技巧”是一个遗留问题(旧的编译器,旧的处理器,486、68000,...)。这是用于左移 CRC 的另一种变体,以 32 位 CRC 为例,而不是 (0 - (CRC>>31)), (((int)CRC)>>31) (有符号右移生成全 0或全 1 位。现代编译器会做这样的“技巧”来避免条件。
  • 只是一个小问题,你是不是非常缺乏ROM或其他东西?为什么不使用更快的 LUT 方法?
  • @BorisLipschitz - 虽然问题是 CRC 的一个例子,但我认为更一般的情况是 condition ? value : 0; 。如果速度是目标,并且缓冲区足够大(256 字节左右),对于 X86,使用 pclmulqdq 和 xmm 寄存器的速度是 crc32fa.asm 中所示的 7 倍以上。 (我的系统没有AVX512,zmm寄存器应该更快)。
  • @BorisLipschitz 确实存在代码内存非常有限的情况,例如在嵌入式处理器中。然而,这里的重点不是应用程序,而是优化的有趣结果。我非常了解由我的crcany 代码生成的逐字节和逐字表实现。

标签: c++ c optimization crc


【解决方案1】:

很好的问号!

没有。它并不快。

至少对于我在 Intel 处理器上使用 clang 和在 ARM64 处理器上使用 gcc 进行的基准测试来说不是这样。

确实,? 会导致条件分支或条件指令。尽管如此,? 方法的编译代码最终比使用 -& 的代码快三分之一,从而避免了条件执行。

请参阅下面用于测试的代码。还包括该最内层语句的编译指令。我还测试了您列出的 Mark 之外的另一种替代方案:

crc = (crc >> 1) ^ (crc & 1 ? 0xa001 : 0);

那个仍然使用条件,但将其隔离为与移位的 CRC 异或的内容。有趣的是,这在 Intel 上编译为与减和方法完全相同的代码,没有条件执行。在 ARM 上,它编译为不同的第三组指令。对于 Intel 和 ARM,它仍然比第一种方法慢。

最重要的是,常见的条件执行方法比试图避免条件执行的尝试快 30% 到 35%。

为什么,你可能会问?据我所知,对于 ARM,您平均而言使用条件语句执行的指令更少。条件方法可以利用一条指令平均只执行一半时间的事实。这显然超过了不可预测的分支机构的成本。现代处理器似乎在处理不可预测的分支方面做得很好。对于英特尔来说,无论哪种方式都是 6 条指令,而且在一半的时间里似乎没有任何好处。

这仅适用于两种架构、技术和相关的编译器。您的里程可能会有所不同。

// Test the speed of alternative bit-wise CRC implementations. To do this, we
// simply propagate a CRC assuming zeros for the data with an initial value of
// all ones. A down-shifting (reflected) 16-bit CRC is used. All times are for
// one billion iterations, with each iteration cycling eight zero bits.

#include <stddef.h>

// Implementation using a conditional expression. This compiles to use a
// conditional move instruction on Intel or a conditional branch on ARM, and an
// unrolled inner loop on both. There are six instructions per iteration on
// Intel, 2.5 on average on ARM. This compiles to the fastest code on both
// architectures.
//
// Intel i7 (clang 11.0.3 -O3): 6.6 seconds
//      movl    %eax, %ecx
//      shrl    %ecx
//      movl    %ecx, %edx
//      xorl    $40961, %edx            ## imm = 0xA001
//      testb   $1, %al
//      cmovel  %ecx, %edx
//
// ARM64 (gcc 6.3.0 -O3): 12.0 seconds
//      tbnz    x0, 0, .L4
//      lsr     w0, w0, 1
//  .L5:
//---------
//  .L4:
//      eor    w0, w2, w0, lsr 1        ## w2 = 0xa001
//      b     .L5
unsigned crc16_cond(unsigned crc, size_t len) {
    while (len--)
        for (unsigned k = 0; k < 8; k++)
            crc = crc & 1 ? (crc >> 1) ^ 0xa001 : crc >> 1;
    return crc;
}

// Different implementation using a conditional expression as before, but here
// just to select zero or the polynomial. This compiles to use no conditionals
// and an unrolled inner loop. There are six instructions per iteration on
// Intel, three on ARM. This ends up compiling to the exact same instructions
// as the minus implementation on Intel.
//
// Intel i7 (clang 11.0.3 -O3): 8.9 seconds
//      movl    %eax, %ecx
//      shrl    %ecx
//      andl    $1, %eax
//      negl    %eax
//      andl    $40961, %eax            ## imm = 0xA001
//      xorl    %ecx, %eax
//
// ARM64 (gcc 6.3.0 -O3): 15.5 seconds
//      tst     x0, 1
//      csel    w3, w1, wzr, ne         ## w1 = 0xa001
//      eor     w0, w3, w0, lsr 1
unsigned crc16_cond2(unsigned crc, size_t len) {
    while (len--)
        for (unsigned k = 0; k < 8; k++)
            crc = (crc >> 1) ^ (crc & 1 ? 0xa001 : 0);
    return crc;
}

// Implementation using a minus and an and to select zero or the polynomial.
// This compiles as written in an unrolled inner loop. There are six
// instructions per iteration on Intel and three on ARM.
//
// Intel i7 (clang 11.0.3 -O3): 8.9 seconds
//      movl    %eax, %ecx
//      shrl    %ecx
//      andl    $1, %eax
//      negl    %eax
//      andl    $40961, %eax            ## imm = 0xA001
//      xorl    %ecx, %eax
//
// ARM64 (gcc 6.3.0 -O3): 16.0 seconds
//      sbfx    x2, x0, 0, 1
//      and     w2, w2, w1              ## w1 = 0xa001
//      eor     w0, w2, w0, lsr 1
unsigned crc16_minus(unsigned crc, size_t len) {
    while (len--)
        for (unsigned k = 0; k < 8; k++)
            crc = (crc >> 1) ^ ((0 - (crc & 1)) & 0xa001);
    return crc;
}

#include <stdio.h>

int main(void) {
    unsigned crc = crc16_cond2(0xffff, 1000000000);
    printf("%04x\n", crc);
    return 0;
}

【讨论】:

  • Also modern processors seem to be doing a very good job handling unpredictable branches. 不,编译器在不使用分支指令的情况下做得很好。
  • @P__J__ 我猜你错过了编译器确实使用分支指令的地方(无论如何对于ARM - 它使用了条件移动英特尔,处理器仍然需要跟踪两条路径)。
  • 如果你有最高效的asm,为什么不用它呢?你不能保证编译器会继续生成上述指令,对吗?
  • > 处理器仍需要跟踪两条路径 不,没有两条路径。请参阅 Peter Cordes 的答案:Is CMOVcc considered a branching instruction?
  • 我的系统速度较慢(Visual Studio / Intel 3770K(第 3 代 i7))。我要换? :下次我复制/粘贴旧代码时(其中一些可以追溯到 68000 / Atari ST - 1980 年代)。
【解决方案2】:

在我的个人机器(Intel Core i5 4300U)上,我通过 Google 基准测试得到了以下结果:

-----------------------------------------------------
Benchmark           Time             CPU   Iterations
-----------------------------------------------------
NoBranch   11333534841 ns   11320263595 ns            1
Branch     11195210177 ns   11182300909 ns            1

此外,这可以在 Quickbench 上使用 Clang (10.0) 进行验证。

对于 GCC (10.1),性能相同。 Quickbench

最快的情况(使用 Clang)似乎是:

    crc = crc & 1 ? (crc >> 1) ^ 0xa001 : crc >> 1;

Clang 10.0 程序集:

        mov     ecx, edi
        shr     ecx
        mov     eax, ecx
        xor     eax, 40961
        test    dil, 1
        cmove   eax, ecx

使用 GCC 10.1 x86-64 组装:

        mov     eax, edi
        shr     eax
        mov     edx, eax
        xor     edx, 40961
        and     edi, 1
        cmovne  eax, edx

# Total uops = 7

您的回答中的以下推理不正确:

条件方法可以利用一个或两条指令平均只执行一半时间的事实。这显然超过了不可预测的分支机构的成本。此外,现代处理器似乎在处理不可预测的分支方面做得非常好。

这里没有进行分支预测。然而,有“谓词”或条件寄存器来注册 mov,这很可能是这种加速的原因。

更新

我删除了基准测试的内部 while 循环,以便我们只测量我们想要的内容,而忽略对谷歌基准测试的循环。现在的函数如下所示:

unsigned crc16_br(unsigned crc) {
        for (unsigned k = 0; k < 8; k++)
            crc = crc & 1 ? (crc >> 1) ^ 0xa001 : crc >> 1;
    return crc;
}

三种变体的性能(使用 GCC 和 Clang (x86-64))相同,差别很小。 Link to benchmarkGodbolt GCC 和 Clang 的 asm 输出。

【讨论】:

  • 未预测的分支和没有分支预测对我来说听起来是一回事。
  • 我想在这种情况下我可能误解了你。但是,这里没有 no 分支。这就是使用条件指令的全部意义所在。而cmov 永远不会免费。
  • ARM案例中肯定有分支。
  • 另外,ARM 处理器的流水线较短,导致错误预测的损失相对较小。
猜你喜欢
  • 2011-02-17
  • 1970-01-01
  • 2018-01-30
  • 2021-06-30
  • 1970-01-01
  • 1970-01-01
  • 2020-09-03
  • 2018-12-29
  • 1970-01-01
相关资源
最近更新 更多