将32位循环计数器替换为64位会在Intel CPU上使用_mm_popcnt_u64引起疯狂的性能偏差


1424

我一直在寻找处理popcount大量数据的最快方法。我遇到了一个非常奇怪的效果:将loop变量从更改为unsigneduint64_t使PC上的性能下降了50%。

基准测试

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

    using namespace std;
    if (argc != 2) {
       cerr << "usage: array_size in MB" << endl;
       return -1;
    }

    uint64_t size = atol(argv[1])<<20;
    uint64_t* buffer = new uint64_t[size/8];
    char* charbuffer = reinterpret_cast<char*>(buffer);
    for (unsigned i=0; i<size; ++i)
        charbuffer[i] = rand()%256;

    uint64_t count,duration;
    chrono::time_point<chrono::system_clock> startP,endP;
    {
        startP = chrono::system_clock::now();
        count = 0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with unsigned
            for (unsigned i=0; i<size/8; i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }
    {
        startP = chrono::system_clock::now();
        count=0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with uint64_t
            for (uint64_t i=0;i<size/8;i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }

    free(charbuffer);
}

如您所见,我们创建了一个随机数据缓冲区,大小是从命令行读取的x兆字节x。之后,我们遍历缓冲区并使用x86 popcount内在函数的展开版本来执行popcount。为了获得更精确的结果,我们将弹出次数进行了10,000次。我们计算弹出次数的时间。在大写的情况下,内部循环变量为unsigned,在小写的情况下,内部循环变量为uint64_t。我认为这应该没有什么区别,但情况恰恰相反。

(绝对疯狂)结果

我这样编译(g ++版本:Ubuntu 4.8.2-19ubuntu1):

g++ -O3 -march=native -std=c++11 test.cpp -o test

这是我的Haswell Core i7-4770K CPU @ 3.50 GHz(正在运行test 1(因此1 MB随机数据))上的结果:

  • 未签名41959360000 0.401554秒 26.113 GB / s
  • uint64_t 41959360000 0.759822秒 13.8003 GB / s

正如你看到的,的吞吐量uint64_t版本是只有一半的一个unsigned版本!问题似乎是生成了不同的程序集,但是为什么呢?首先,我想到了编译器错误,因此我尝试了一下clang++(Ubuntu Clang版本3.4-1ubuntu3):

clang++ -O3 -march=native -std=c++11 teest.cpp -o test

结果: test 1

  • 无符号41959360000 0.398293秒 26.3267 GB / s
  • uint64_t 41959360000 0.680954秒 15.3986 GB / s

因此,几乎是相同的结果,但仍然很奇怪。但是现在变得超级奇怪。我用常量替换从输入中读取的缓冲区大小1,因此我进行了更改:

uint64_t size = atol(argv[1]) << 20;

uint64_t size = 1 << 20;

因此,编译器现在在编译时就知道缓冲区的大小。也许它可以添加一些优化!以下是数字g++

  • 未签名41959360000 0.509156秒 20.5944 GB / s
  • uint64_t 41959360000 0.508673秒 20.6139 GB / s

现在,两个版本都同样快。但是,unsigned 速度变慢了!它从26降到20 GB/s,因此用常数替换非常数会导致优化不足。说真的,我不知道这是怎么回事!但是现在到clang++新版本:

  • 未签名41959360000 0.677009秒 15.4884 GB / s
  • uint64_t 41959360000 0.676909秒 15.4906 GB / s

等一下 现在,两个版本的速度都降低到了15 GB / s 的缓慢速度。因此,在两种情况下,用常数替换非常数都会导致Clang!代码缓慢。

我要求具有Ivy Bridge CPU 的同事来编译我的基准测试。他得到了类似的结果,因此似乎不是Haswell。因为两个编译器在这里产生奇怪的结果,所以它似乎也不是编译器错误。我们这里没有AMD CPU,因此只能在Intel上进行测试。

请更加疯狂!

以第一个示例(带有的示例atol(argv[1]))为例,static然后在变量前放置一个,即:

static uint64_t size=atol(argv[1])<<20;

这是我在g ++中的结果:

  • 无符号41959360000 0.396728秒 26.4306 GB / s
  • uint64_t 41959360000 0.509484秒 20.5811 GB / s

是的,另一种选择。仍然有26 GB / s的快速速度u32,但是我们设法u64至少从13 GB / s达到20 GB / s!在我同事的PC上,该u64版本变得比该u32版本更快,从而产生了最快的结果。可悲的是,这仅适用于g++clang++似乎并不在乎static

我的问题

您能解释这些结果吗?特别:

  • u32和之间怎么会有这种区别u64
  • 如何用恒定的缓冲区大小替换非常数会触发次优代码
  • 插入static关键字如何使u64循环更快?甚至比同事计算机上的原始代码还要快!

我知道优化是一个棘手的领域,但是,我从未想到过如此小的更改会导致执行时间差异100%,而诸如恒定缓冲区大小之类的小因素又会完全混和结果。当然,我一直想拥有能够以26 GB / s的速度计数的版本。我能想到的唯一可靠的方法是针对这种情况复制粘贴程序集并使用内联程序集。这是我摆脱似乎对微小更改感到恼火的编译器​​的唯一方法。你怎么看?还有另一种方法可以可靠地获得性能最高的代码吗?

拆卸

这是各种结果的反汇编:

来自g ++ / u32 / non-const bufsize的 26 GB / s版本:

0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8

来自g ++ / u64 / non-const bufsize的 13 GB / s版本:

0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00

来自clang ++ / u64 / non-const bufsize的 15 GB / s版本:

0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50

来自g ++ / u32&u64 / const bufsize的 20 GB / s版本:

0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68

来自clang ++ / u32&u64 / const bufsize的 15 GB / s版本:

0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0

有趣的是,最快的(26 GB / s)版本也是最长的!它似乎是唯一使用的解决方案lea。一些版本使用jb跳转,另一些版本使用jne。但除此之外,所有版本似乎都是可比的。我看不出100%的性能差距可能源于何处,但我不太擅长破译汇编。最慢的版本(13 GB / s)看起来非常短而且很好。谁能解释一下?

得到教训

不管这个问题的答案是什么;我了解到,在真正的热循环中,每个细节都可能很重要,即使那些似乎与该热代码没有任何关联的细节也是如此。我从未考虑过要为循环变量使用哪种类型,但是如您所见,如此小的更改可能会产生100%的差异!正如我们static在size变量前面插入关键字所看到的,甚至缓冲区的存储类型也可以产生巨大的变化!将来,在编写对系统性能至关重要的紧密而又热的循环时,我将始终在各种编译器上测试各种替代方案。

有趣的是,尽管我已经四次展开循环,但性能差异仍然很高。因此,即使展开,仍然会受到重大性能偏差的影响。很有趣。


8
这么多评论!您可以在聊天中查看它们,甚至可以在聊天室中留下自己的名字,但是请不要在此处添加更多内容!
Shog9 2013年

3
另请参阅GCC第62011版,popcnt指令中的False Data Dependency。有人提供了它,但是它似乎在清理过程中丢失了。
jww

我不知道,但这是带静电版本的反汇编之一吗?如果没有,您可以编辑并添加帖子吗?
凯利·法国

Answers:


1552

罪魁祸首:错误的数据依赖关系(并且编译器甚至没有意识到)

在Sandy / Ivy Bridge和Haswell处理器上,指令如下:

popcnt  src, dest

似乎对目标寄存器有错误的依赖性dest。即使指令只写指令,指令也要等到dest准备好后再执行。英特尔现已(现在)将这种错误依赖性记录为勘误表HSD146(Haswell)SKL029(Skylake)

Skylake将其固定为lzcnttzcnt
佳能湖(和冰湖)将其修复为popcnt
bsf/ bsr具有真正的输出依存关系:输入= 0时输出未修改。(但是没有办法利用内在函数来利用它 -只有AMD记录了它,编译器没有公开它。)

(是的,这些指令都在同一执行单元上运行)。


这种依赖关系不仅会阻止popcnt单个循环迭代中的4 s。它可以进行循环迭代,从而使处理器无法并行化不同的循环迭代。

unsigneduint64_t等的调整不会直接影响的问题。但是它们会影响寄存器分配器,后者将寄存器分配给变量。

在您的情况下,速度是卡在(假)依赖链上的直接结果,具体取决于寄存器分配器决定执行的操作。

  • 13 GB / s的具有链:popcnt- add- popcnt- popcnt→下一次迭代
  • 15 GB /秒有一个链条:popcnt- add- popcnt- add→下一个迭代
  • 20 GB / s具有一条链:popcnt- popcnt→下一次迭代
  • 26 GB / s有一条链:popcnt- popcnt→下一次迭代

20 GB / s和26 GB / s之间的差异似乎只是间接寻址的一个小问题。无论哪种方式,一旦达到此速度,处理器就会开始遇到其他瓶颈。


为了测试这一点,我使用了内联程序集来绕过编译器并获得我想要的程序集。我还拆分了count变量,以破坏可能与基准混淆的所有其他依赖项。

结果如下:

Sandy Bridge Xeon @ 3.5 GHz :(完整的测试代码可在底部找到)

  • GCC 4.6.3: g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • Ubuntu 12

不同的寄存器:18.6195 GB / s

.L4:
    movq    (%rbx,%rax,8), %r8
    movq    8(%rbx,%rax,8), %r9
    movq    16(%rbx,%rax,8), %r10
    movq    24(%rbx,%rax,8), %r11
    addq    $4, %rax

    popcnt %r8, %r8
    add    %r8, %rdx
    popcnt %r9, %r9
    add    %r9, %rcx
    popcnt %r10, %r10
    add    %r10, %rdi
    popcnt %r11, %r11
    add    %r11, %rsi

    cmpq    $131072, %rax
    jne .L4

相同寄存器:8.49272 GB / s

.L9:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # This time reuse "rax" for all the popcnts.
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L9

链断裂的同一个寄存器:17.8869 GB / s

.L14:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # Reuse "rax" for all the popcnts.
    xor    %rax, %rax    # Break the cross-iteration dependency by zeroing "rax".
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L14

那么编译器出了什么问题?

似乎GCC和Visual Studio都没有意识到popcnt这种错误的依赖关系。但是,这些错误的依赖关系并不少见。只是编译器是否意识到这一点。

popcnt并不是最常用的指令。因此,大型编译器可能会错过这样的内容,这并不令人感到意外。似乎也没有任何文档提到此问题。如果英特尔不公开,那么除非有人偶然碰到它,否则外界不会知道。

更新: 从4.9.2版开始,GCC意识到了这种虚假依赖关系,并在启用优化后生成了代码来对其进行补偿。其他厂商的主要编译器,包括Clang,MSVC甚至是英特尔自己的ICC都尚未意识到此微体系结构勘误表,并且不会发出对其进行补偿的代码。)

为什么CPU具有如此错误的依赖性?

我们可以推测:它在与bsf/ 相同的执行单元上运行,bsr确实具有输出依赖性。(如何在硬件中实现POPCNT?)。对于这些指令,Intel将input = 0的整数结果记录为“未定义”(ZF = 1),但是Intel硬件实际上为避免破坏旧软件提供了更强的保证:输出未修改。AMD记录了这种行为。

可能不方便地为此执行单元的输出依赖输出,而另一些则不然。

AMD处理器似乎没有这种虚假依赖性。


完整的测试代码如下:

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

   using namespace std;
   uint64_t size=1<<20;

   uint64_t* buffer = new uint64_t[size/8];
   char* charbuffer=reinterpret_cast<char*>(buffer);
   for (unsigned i=0;i<size;++i) charbuffer[i]=rand()%256;

   uint64_t count,duration;
   chrono::time_point<chrono::system_clock> startP,endP;
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %4  \n\t"
                "add %4, %0     \n\t"
                "popcnt %5, %5  \n\t"
                "add %5, %1     \n\t"
                "popcnt %6, %6  \n\t"
                "add %6, %2     \n\t"
                "popcnt %7, %7  \n\t"
                "add %7, %3     \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "No Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Chain 4   \t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "xor %%rax, %%rax   \n\t"   // <--- Break the chain.
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Broken Chain\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }

   free(charbuffer);
}

可以在这里找到一个同样有趣的基准:http : //pastebin.com/kbzgL8si
该基准会改变popcnt(假)依赖关系链中s 的数量。

False Chain 0:  41959360000 0.57748 sec     18.1578 GB/s
False Chain 1:  41959360000 0.585398 sec    17.9122 GB/s
False Chain 2:  41959360000 0.645483 sec    16.2448 GB/s
False Chain 3:  41959360000 0.929718 sec    11.2784 GB/s
False Chain 4:  41959360000 1.23572 sec     8.48557 GB/s

3
嗨伙计!这里有很多过去的评论;在离开新的档案之前,请查看档案
Shog9 2013年

1
@ JustinL.it看起来像这个特殊的问题是在锵为7.0
丹M.

@PeterCordes我认为它不是调度程序,而是执行单元。调度程序跟踪依赖关系。为此,将指令分组为多个“指令类”,调度程序对每个指令类进行相同的处理。因此,出于指令调度的目的,所有3个周期的“ slow-int”指令都被扔到同一“类”中。
Mysticial

@Mysticial:你现在还是这么想吗?这是合理的,但 imul dst, src, imm没有输出依赖性,而且slow-也没有lea。都没有pdep,但这是VEX编码的2个输入操作数。同意不是由执行单元本身引起错误的dep;而是由执行单元本身引起。这取决于RAT和issue / rename阶段,因为它将架构寄存器操作数重命名为物理寄存器。大概它需要一个uop-code表->依赖模式和端口选择,并且将同一执行单元的所有uup分组在一起可以简化该表。这就是我更详细的意思。
彼得·科德斯

让我知道是要让您将其编辑为答案,还是想让它回到您最初对调度程序所说的内容。SKL放弃了lzcnt / tzcnt而不是popcnt的错误提示这一事实应该告诉我们一些信息,但IDK告诉我们什么。与重命名/ RAT相关的另一个可能迹象是,SKL取消了分层编址寻址模式作为lzcnt / tzcnt而不是popcnt的存储源。显然,重命名单元必须创建后端可以表示的微指令。
彼得·科德斯

50

我编写了一个等效的C程序进行实验,我可以证实这种奇怪的行为。而且,gcc相信64位整数(size_t无论如何应该还是...)会更好,因为使用uint_fast32_t会使gcc使用64位uint。

我对程序集做了一些修改:
只需采用32位版本,在程序内部popcount循环中将所有32位指令/寄存器替换为64位版本。观察:代码与32位版本一样快!

显然,这是一个hack,因为变量的大小并不是真正的64位,因为程序的其他部分仍使用32位版本,但是只要内部popcount循环主导性能,这就是一个好的开始。

然后,我从程序的32位版本中复制了内部循环代码,将其修改为64位,然后将寄存器弄乱了以使其替代64位版本的内部循环。此代码也可以与32位版本一样快地运行。

我的结论是,这是编译器不好的指令调度,而不是32位指令的实际速度/延迟优势。

(注意:我搞砸了程序集,可能在不注意的情况下破坏了某些东西。我不这么认为。)


1
“此外,gcc相信64位整数[…]会更好,因为使用uint_fast32_t会导致gcc使用64位uint。” 不幸的是,令我遗憾的是,这些类型背后没有魔术,也没有深入的代码自省。我还没有看到它们为整个平台上的每个可能位置和每个程序提供了除单一typedef之外的任何其他方式。对于类型的确切选择,人们可能已经深思熟虑了,但是对于每种类型的一种定义可能无法适应将来的每种应用。进一步阅读:stackoverflow.com/q/4116297
基诺

2
@Keno那是因为sizeof(uint_fast32_t)必须定义。如果您不允许这样做,则可以这样做,但只能通过编译器扩展来完成。
wizzwizz4

25

这不是答案,但是如果我将结果放入评论中则很难阅读。

我通过Mac ProWestmere 6核Xeon 3.33 GHz)获得了这些结果。我用clang -O3 -msse4 -lstdc++ a.cpp -o a(-O2得到相同的结果)编译了它。

与lang uint64_t size=atol(argv[1])<<20;

unsigned    41950110000 0.811198 sec    12.9263 GB/s
uint64_t    41950110000 0.622884 sec    16.8342 GB/s

与lang uint64_t size=1<<20;

unsigned    41950110000 0.623406 sec    16.8201 GB/s
uint64_t    41950110000 0.623685 sec    16.8126 GB/s

我还试图:

  1. 颠倒测试顺序,结果相同,因此排除了缓存因素。
  2. for相反的声明:for (uint64_t i=size/8;i>0;i-=4)。这给出了相同的结果,并证明了编译足够聪明,不会在每次迭代中都将大小除以8(如预期的那样)。

这是我的疯狂猜测:

速度因数分为三个部分:

  • 代码缓存:uint64_t版本具有更大的代码大小,但这对我的Xeon CPU没有影响。这会使64位版本变慢。

  • 使用说明。请注意,不仅循环计数,而且在两个版本上还使用32位和64位索引访问缓冲区。访问具有64位偏移量的指针需要专用的64位寄存器和地址,而您可以将Instant用于32位偏移量。这可能会使32位版本更快。

  • 指令仅在64位编译器上发出(即,预取)。这使64位速度更快。

这三个因素与观察到的看似矛盾的结果相吻合。


4
有趣的是,您可以添加编译器版本和编译器标志吗?最好的是,在您的计算机上,结果得到了解决,即使用u64更快。到目前为止,我还没有考虑过我的循环变量具有哪种类型,但是似乎下次我必须再三考虑:)。
gexicide,2014年

2
@gexicide:我不会说从16.8201跳到16.8126使其“更快”。
user541686

2
@Mehrdad:我说的是跳之间的一个12.916.8,因此unsigned速度更快这里。在我的基准测试中,情况恰恰相反,例如,unsigned针对uint64_t
gexicide的使用率是

@gexicide您是否注意到寻址缓冲区[i]的不同?
不可屏蔽的中断

@Calvin:不,你是什么意思?
gexicide

10

我无法给出权威性的答案,但会概述可能的原因。该参考资料清楚地表明,对于循环主体中的指令,延迟和吞吐量之间的比例为3:1。它还显示了多个调度的效果。由于现代x86处理器中有3个整数单元,因此通常每个周期可以分配3条指令。

因此,在高峰管道和多个调度性能以及这些机制的失败之间,我们的性能有六分之一。众所周知,x86指令集的复杂性使得它很容易发生古怪的破坏。上面的文档有一个很好的例子:

奔腾4的64位右移性能确实很差。64位左移以及所有32位移都具有可接受的性能。看来从ALU的高32位到低32位的数据路径设计得不好。

我个人遇到了一个奇怪的情况,即热循环在四核芯片(如果我还记得的话,AMD)的特定核心上运行得相当慢。通过关闭该核心,我们实际上在映射减少计算上获得了更好的性能。

在这里,我的猜测是争用整数单位:popcnt使用32位宽的计数器,循环计数器和地址计算都几乎无法全速运行,但是64位计数器会导致争用和流水线停顿。由于每个循环体执行总共只有大约12个周期,可能有4个周期有多个调度,因此单个停顿可以合理地将运行时间影响2倍。

我猜想,使用静态变量引起的更改只是对指令进行了少量重新排序,这是32位代码处于竞争临界点的另一个线索。

我知道这不是严格的分析,但这一个合理的解释。


2
不幸的是,自从(Core 2?)以来,32位和64位整数运算之间几乎没有性能差异,除了乘法/除法-该代码中没有。
Mysticial

@Gene:请注意,所有版本都将大小存储在寄存器中,并且永远不要在循环中从堆栈中读取它。因此,地址计算不能混在一起,至少不在循环内部。
杀人罪

@Gene:确实有趣的解释!但这并不能解释WTF的主要要点:由于管道停顿,64位比32位慢是一回事。但是,如果是这样的话,应该没有64位版本是可靠比32位的速度慢?相反,当使用编译时常数缓冲区大小时,即使对于32位版本,三个不同的编译器也会发出缓慢的代码。再次将缓冲区大小更改为静态会完全改变事情。在我的同事的机器上(甚至在卡尔文的回答中)甚至有一个案例,其中64位版本的速度要快得多!这似乎是绝对不可预测的
。–杀人事件

@Mysticial这就是我的观点。当IU,总线时间等争用为零时,没有峰值性能差异。参考文献清楚地表明了这一点。竞争使一切变得不同。这是英特尔核心文献的一个示例:“设计中包含的一项新技术是Macro-Ops Fusion,它将两个x86指令组合到一个微操作中。例如,比较之类的通用代码序列,然后是条件跳转。将变成一个微型操作。遗憾的是,该技术无法在64位模式下工作。” 因此我们的执行速度为2:1。
基因

@gexicide我明白您在说什么,但您的推断比我想的要多。我说的是运行速度最快的代码是保持管道和调度队列已满。这种情况很脆弱。微小的变化(例如在总数据流中增加32位和指令重新排序)足以破坏它。简而言之,OP断言摆弄和测试是唯一的前进方法是正确的。
基因

10

我使用Visual Studio 2013 Express尝试了此操作,使用了指针而不是索引,这加快了整个过程。我怀疑这是因为寻址是偏移量+寄存器,而不是偏移量+寄存器+(寄存器<< 3)。C ++代码。

   uint64_t* bfrend = buffer+(size/8);
   uint64_t* bfrptr;

// ...

   {
      startP = chrono::system_clock::now();
      count = 0;
      for (unsigned k = 0; k < 10000; k++){
         // Tight unrolled loop with uint64_t
         for (bfrptr = buffer; bfrptr < bfrend;){
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
         }
      }
      endP = chrono::system_clock::now();
      duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
           << (10000.0*size)/(duration) << " GB/s" << endl;
   }

汇编代码:r10 = bfrptr,r15 = bfrend,rsi =计数,rdi =缓冲区,r13 = k:

$LL5@main:
        mov     r10, rdi
        cmp     rdi, r15
        jae     SHORT $LN4@main
        npad    4
$LL2@main:
        mov     rax, QWORD PTR [r10+24]
        mov     rcx, QWORD PTR [r10+16]
        mov     r8, QWORD PTR [r10+8]
        mov     r9, QWORD PTR [r10]
        popcnt  rdx, rax
        popcnt  rax, rcx
        add     rdx, rax
        popcnt  rax, r8
        add     r10, 32
        add     rdx, rax
        popcnt  rax, r9
        add     rsi, rax
        add     rsi, rdx
        cmp     r10, r15
        jb      SHORT $LL2@main
$LN4@main:
        dec     r13
        jne     SHORT $LL5@main

9

您是否尝试过传递-funroll-loops -fprefetch-loop-arrays给GCC?

通过这些其他优化,我得到以下结果:

[1829] /tmp/so_25078285 $ cat /proc/cpuinfo |grep CPU|head -n1
model name      : Intel(R) Core(TM) i3-3225 CPU @ 3.30GHz
[1829] /tmp/so_25078285 $ g++ --version|head -n1
g++ (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3

[1829] /tmp/so_25078285 $ g++ -O3 -march=native -std=c++11 test.cpp -o test_o3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -funroll-loops -fprefetch-loop-arrays -std=c++11     test.cpp -o test_o3_unroll_loops__and__prefetch_loop_arrays

[1829] /tmp/so_25078285 $ ./test_o3 1
unsigned        41959360000     0.595 sec       17.6231 GB/s
uint64_t        41959360000     0.898626 sec    11.6687 GB/s

[1829] /tmp/so_25078285 $ ./test_o3_unroll_loops__and__prefetch_loop_arrays 1
unsigned        41959360000     0.618222 sec    16.9612 GB/s
uint64_t        41959360000     0.407304 sec    25.7443 GB/s

3
但是,您的结果还是很奇怪的(首先更快地进行无符号签名,然后更快地进行uint64_t签名),因为展开无法解决错误依赖项的主要问题。
gexicide

7

您是否尝试过将还原步骤移出循环?现在,您确实不需要数据依赖。

尝试:

  uint64_t subset_counts[4] = {};
  for( unsigned k = 0; k < 10000; k++){
     // Tight unrolled loop with unsigned
     unsigned i=0;
     while (i < size/8) {
        subset_counts[0] += _mm_popcnt_u64(buffer[i]);
        subset_counts[1] += _mm_popcnt_u64(buffer[i+1]);
        subset_counts[2] += _mm_popcnt_u64(buffer[i+2]);
        subset_counts[3] += _mm_popcnt_u64(buffer[i+3]);
        i += 4;
     }
  }
  count = subset_counts[0] + subset_counts[1] + subset_counts[2] + subset_counts[3];

您还会遇到一些奇怪的别名,但我不确定这是否符合严格的别名规则。


2
这是我阅读问题后要做的第一件事。打破依赖关系链。事实证明,性能差异没有改变(至少在我的计算机上-带有GCC 4.7.3的Intel Haswell)。
Nils Pipenbrinck,2014年

1
@BenVoigt:它符合严格的别名。void*char*可能是别名的两种类型,因为它们本质上被视为“指向某个内存块的指针”!您关于删除数据依赖项的想法很适合优化,但是并不能回答问题。而且,正如@NilsPipenbrinck所说,它似乎并没有改变任何东西。
死刑

@gexicide:严格的别名规则不是对称的。您可以使用char*访问T[]。您不能安全地使用T*来访问char[],并且您的代码似乎可以执行。
Ben Voigt 2014年

@BenVoigt:然后,您将永远无法保存malloc任何数组,因为malloc返回void*并且您将其解释为T[]。而且我非常确定,void*并且char*在严格的别名方面具有相同的语义。但是,我想这在这里很
不合理

1
我个人认为正确的方法是uint64_t* buffer = new uint64_t[size/8]; /* type is clearly uint64_t[] */ char* charbuffer=reinterpret_cast<char*>(buffer); /* aliasing a uint64_t[] with char* is safe */
Ben Voigt 2014年

6

TL; DR:改用__builtin内在函数;他们可能碰巧会提供帮助。

gcc通过使用__builtin_popcountll使用相同的汇编指令,我能够使4.8.4(甚至在gcc.godbolt.org上为4.7.3)生成最佳代码,但很幸运,并且碰巧制作了没有意外的代码由于存在错误的依赖项错误,因此会导致长时间的循环依赖项。

我不确定我的基准测试代码100%可靠,但是objdump输出似乎可以分享我的观点。我使用其他技巧(++ivs i++)使编译器在没有任何movl指令的情况下为我展开循环(奇怪的行为,我必须说)。

结果:

Count: 20318230000  Elapsed: 0.411156 seconds   Speed: 25.503118 GB/s

基准测试代码:

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

uint64_t builtin_popcnt(const uint64_t* buf, size_t len){
  uint64_t cnt = 0;
  for(size_t i = 0; i < len; ++i){
    cnt += __builtin_popcountll(buf[i]);
  }
  return cnt;
}

int main(int argc, char** argv){
  if(argc != 2){
    printf("Usage: %s <buffer size in MB>\n", argv[0]);
    return -1;
  }
  uint64_t size = atol(argv[1]) << 20;
  uint64_t* buffer = (uint64_t*)malloc((size/8)*sizeof(*buffer));

  // Spoil copy-on-write memory allocation on *nix
  for (size_t i = 0; i < (size / 8); i++) {
    buffer[i] = random();
  }
  uint64_t count = 0;
  clock_t tic = clock();
  for(size_t i = 0; i < 10000; ++i){
    count += builtin_popcnt(buffer, size/8);
  }
  clock_t toc = clock();
  printf("Count: %lu\tElapsed: %f seconds\tSpeed: %f GB/s\n", count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
  return 0;
}

编译选项:

gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench

GCC版本:

gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

Linux内核版本:

3.19.0-58-generic

CPU信息:

processor   : 0
vendor_id   : GenuineIntel
cpu family  : 6
model       : 70
model name  : Intel(R) Core(TM) i7-4870HQ CPU @ 2.50 GHz
stepping    : 1
microcode   : 0xf
cpu MHz     : 2494.226
cache size  : 6144 KB
physical id : 0
siblings    : 1
core id     : 0
cpu cores   : 1
apicid      : 0
initial apicid  : 0
fpu     : yes
fpu_exception   : yes
cpuid level : 13
wp      : yes
flags       : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm constant_tsc nopl xtopology nonstop_tsc eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm arat pln pts dtherm fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 invpcid xsaveopt
bugs        :
bogomips    : 4988.45
clflush size    : 64
cache_alignment : 64
address sizes   : 36 bits physical, 48 bits virtual
power management:

3
这只是运气好这-funroll-loops恰好使代码不通过创建一个循环搬运依存链瓶颈popcnt的假DEP。使用不了解虚假依赖关系的旧编译器版本存在风险。没有它-funroll-loops,gcc 4.8.5的循环将成为popcnt延迟而不是吞吐量的瓶颈,因为它计入rdx由gcc 4.9.3编译的同一代码添加了一个,xor edx,edx以打破依赖关系链。
彼得·科德斯

3
使用旧的编译器,您的代码仍然容易受到OP所经历的性能完全相同的影响:看似微不足道的更改可能会使gcc变慢,因为它不知道会引起问题。 在旧的编译器上找到在某种情况下可以正常工作的东西不是问题。
彼得·科德斯

2
出于记录,GCC x86intrin.h_mm_popcnt_*功能__builtin_popcount*内联的内联包装器;内联应该使一个完全等同于另一个。我非常怀疑您会发现在它们之间进行切换可能会导致任何差异。
ShadowRanger

-2

首先,尝试评估最高性能-检查https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf,尤其是附录C。

在您的情况下,表C-10显示POPCNT指令的延迟= 3个时钟,吞吐量= 1个时钟。吞吐量以时钟为单位显示最大速率(乘以核心频率和8字节(如果使用popcnt64,则为可能的最大带宽))。

现在检查编译器做了什么,并总结了循环中所有其他指令的吞吐量。这将为生成的代码提供最佳的估计。

最后,请看一下循环中指令之间的数据依赖性,因为它们将迫使等待时间变成较大的延迟而不是吞吐量—因此,拆分数据流链上单次迭代的指令并计算它们之间的等待时间,然后天真地从它们中获取最大的延迟。考虑到数据流的依赖性,它将给出粗略的估计。

但是,对于您而言,仅以正确的方式编写代码将消除所有这些复杂性。与其累加到相同的计数变量,不如累加到不同的变量(如count0,count1,... count8),然后将它们累加起来。甚至创建一个counts [8]数组并将其累加到其元素-甚至将其矢量化,您将获得更好的吞吐量。

PS,并且永远不会运行基准测试,首先要预热内核,然后再运行循环至少10秒钟,或者至少100秒钟。否则,您将在硬件中测试电源管理固件和DVFS实现:)

PPS我听到了关于基准测试应该运行多少时间的无休止的辩论。最聪明的人甚至都问为什么10秒不是11或12。我应该承认这在理论上很有趣。实际上,您只需连续运行基准测试一百次并记录偏差。这IS滑稽。多数人确实会改变源头,并在一次ONCE之后跑台以获取新的性能记录。做正确的事。

还是不服气?只需使用assp1r1n3(https://stackoverflow.com/a/37026212/9706746)的上述基准C版本,然后在重试循环中尝试使用100而不是10000。

我的7960X显示为RETRY = 100:

计数:203182300经过:0.008385秒速度:12.505379 GB / s

计数:203182300经过:0.011063秒速度:9.478225 GB / s

计数:203182300已用:0.011188秒速度:9.372327 GB / s

计数:203182300已用:0.010393秒速度:10.089252 GB / s

计数:203182300已用:0.009076秒速度:11.553283 GB /秒

RETRY = 10000时:

计数:20318230000经过:0.661791秒速度:15.844519 GB / s

计数:20318230000经过:0.665422秒速度:15.758060 GB / s

计数:20318230000经过:0.660983秒速度:15.863888 GB / s

计数:20318230000经过:0.665337秒速度:15.760073 GB / s

计数:20318230000经过:0.662138秒速度:15.836215 GB / s

PPPS最后,关于“可接受的答案”和其他含糊的说法;-)

让我们用assp1r1n3的答案-他有2.5Ghz的内核。POPCNT有1个时钟吞吐量,他的代码使用64位popcnt。因此,对于他的设置,数学运算为2.5Ghz * 1时钟* 8字节= 20 GB / s。他看到的速度为25Gb / s,这可能是由于Turbo提升至3Ghz左右。

因此,请访问ark.intel.com并查找i7-4870HQ:https ://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-?q = i7-4870HQ

该内核的硬件运行速度可高达3.7Ghz,实际最大速率为29.6 GB / s。那么另外还有4GB / s?也许,它花费在每次迭代中的循环逻辑和其他周围的代码上。

现在这种虚假依赖在哪里?硬件几乎以峰值速率运行。也许我的数学不好,有时会发生:)

PPPPPS仍然有人认为HW勘误是罪魁祸首,因此我遵循了建议并创建了内联asm示例,请参见下文。

在我的7960X上,第一个版本(单输出到cnt0)的运行速度为11MB / s,第二个版本(输出到cnt0,cnt1,cnt2和cnt3的输出)的运行速度为33MB / s。有人会说-瞧!它是输出依赖性。

好的,也许,我的意思是写这样的代码没有意义,这不是输出依赖性问题,而是愚蠢的代码生成。我们不是在测试硬件,而是在编写代码以释放最大性能。您可能希望HW OOO应该重命名并隐藏那些“输出相关性”,但是,大胆地做对了正确的事情,您将永远不会面临任何谜团。

uint64_t builtin_popcnt1a(const uint64_t* buf, size_t len) 
{
    uint64_t cnt0, cnt1, cnt2, cnt3;
    cnt0 = cnt1 = cnt2 = cnt3 = 0;
    uint64_t val = buf[0];
    #if 0
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0)
        : "q" (val)
        :
        );
    #else
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %5, %1\n\t"
            "popcnt %5, %2\n\t"
            "popcnt %5, %3\n\t"
            "popcnt %5, %4\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0), "=q" (cnt1), "=q" (cnt2), "=q" (cnt3)
        : "q" (val)
        :
        );
    #endif
    return cnt0;
}

如果您以核心时钟周期(而不是秒)为单位计时,那么1秒的时间对于一个微小的CPU绑定循环来说是足够的时间。即使是100ms,也可以发现主要差异或检查性能计数器的uop计数。尤其是在Skylake上,通过硬件P状态管理,它可以在负载开始后以微秒的速度提升到最大时钟速度。
彼得·科德斯

clang可以__builtin_popcountl使用AVX2 自动矢量化vpshufb,并且在C源代码中不需要多个累加器。我不确定_mm_popcnt_u64; 只能使用AVX512-VPOPCNT自动矢量化。(请参阅使用AVX-512或AVX-2对大数据计数1位(填充计数) /)
Peter Cordes

但是无论如何,看一下英特尔的优化手册都无济于事:正如公认的答案所示,问题是出乎意料的输出依赖性popcnt。这在英特尔的勘误表中已针对他们最近的一些微体系结构进行了记录,但我认为当时还没有。如果存在意外的虚假依赖关系,则您的深度链分析将失败,因此,此答案是很好的通用建议,但不适用于此处。
彼得·科德斯

1
你在跟我开玩笑吗?我不必“相信”我可以在手写的asm循环中使用性能计数器进行实验测量的事情。它们只是事实。我已经测试过,Skylake修复了lzcnt/ 的错误依赖关系tzcnt,但没有解决popcnt。请参阅intel.com/content/dam/www/public/us/en/documents/…中的英特尔勘误表SKL029 。此外,gcc.gnu.org/bugzilla/show_bug.cgi?id=62011是“已解决固定”,而不是“无效”。您没有理由在硬件中没有输出依赖性。
彼得·科德斯

1
如果您制作一个像popcnt eax, edx/ 的简单循环dec ecx / jnz,您希望它以每个时钟1个的速度运行,这会成为popcnt吞吐量和分支分支吞吐量的瓶颈。但是popcnt,即使您希望它是只写的,它实际上也只能在延迟达瓶颈的每3个时钟中运行1次,以反复覆盖EAX。您拥有Skylake,因此您可以自己尝试。
彼得·科德斯

-3

好的,我想为OP提出的子问题之一提供一个小答案,而这些子问题似乎在现有问题中未得到解决。请注意,我没有做任何测试或代码生成,也没有反汇编,只是想与他人分享一个想法。

为什么要static改变性能?

有问题的行: uint64_t size = atol(argv[1])<<20;

简短答案

我将查看为访问size而生成的程序集,并查看非静态版本是否涉及指针间接访问的额外步骤。

长答案

由于变量只有一个副本(无论是否已声明)static,并且大小不变,因此我认为区别在于用于支持变量的内存位置以及在代码中进一步使用的位置下。

好的,首先,请记住,函数的所有局部变量(以及参数)都在堆栈上提供了用作存储的空间。现在,很明显,main()的堆栈框架永远不会清理,只会生成一次。好的,如何制作呢static?好吧,在那种情况下,编译器知道在进程的全局数据空间中保留空间,因此无法通过除去堆栈帧来清除该位置。但是,我们只有一个位置,所以有什么区别?我怀疑这与如何引用堆栈上的内存位置有关。

当编译器生成符号表时,它只是为标签以及相关属性(例如大小等)创建一个标签条目。它知道它必须在内存中保留适当的空间,但实际上直到稍后才选择该位置。进行活动性分析并可能进行寄存器分配后的处理。然后,链接器如何知道要为最终汇编代码提供给机器代码的地址?它要么知道最终位置,要么知道如何到达该位置。对于堆栈,引用基于两个元素(一个指向堆栈框架的指针,然后指向该框架的偏移量)的位置非常简单。这基本上是因为链接程序在运行时之前无法知道堆栈帧的位置。


2
在我看来,更可能以static某种方式改变函数的寄存器分配,从而影响popcntOP测试的Intel CPU对虚假输出的依赖,而编译器并不知道如何避免使用它们。(因为尚未发现Intel CPU的这种性能隐患。)编译器可以将static局部变量保留在寄存器中,就像自动存储变量一样,但是如果它们没有优化(假设main只运行一次),那么它将影响代码生成(因为该值仅由首次调用设置。)
Peter Cordes,

1
无论如何,在大多数情况下[RIP + rel32][rsp + 42]寻址模式和寻址模式之间的性能差异可以忽略不计。 cmp dword [RIP+rel32], immediate不能微融合到单个load + cmp uop中,但是我认为这不是一个因素。就像我说的那样,内部循环无论如何可能都保留在寄存器中,但是调整C ++可能意味着不同的编译器选择。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.