关于C++:用64位替换32位循环计数器引入疯狂性能偏差

Replacing a 32-bit loop counter with 64-bit introduces crazy performance deviations

我一直在寻找最快的方法来访问popcount大型数据阵列。我遇到了一个非常奇怪的效果:将循环变量从unsigned更改为uint64_t,使我的PC性能下降了50%。

基准

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#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。为了得到更精确的结果,我们做了10000次PopCount。我们测量人口统计的时间。在上例中,内环变量为unsigned,在下例中,内环变量为uint64_t。我认为这应该没什么区别,但事实恰恰相反。

(绝对疯狂的)结果

我这样编译它(G++版本:Ubuntu 4.8.2-19Ubuntu1):

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

以下是我的haswell核心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版本的一半!问题似乎是生成了不同的程序集,但为什么呢?首先,我想到了一个编译器bug,所以我尝试了clang++(ubuntu clang版本3.4-1ubuntu 3):

1
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,因此我更改:

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

1
uint64_t size = 1 << 20;

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

  • 无符号41959360000 0.509156秒20.5944 GB/s
  • uint64_t 41959360000 0.508673秒20.6139 GB/s

现在,这两种版本的速度都一样快。然而,江户十一〔一〕的速度更慢了!它从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代码都很慢!

我让一位同事用常春藤桥CPU编译我的基准测试。他得到了相似的结果,所以似乎不是哈斯韦尔。因为两个编译器在这里产生奇怪的结果,所以它似乎也不是一个编译器bug。我们这里没有AMD的CPU,所以我们只能用Intel测试。

请再疯狂一点!

以第一个例子(带有atol(argv[1])的例子)为例,将static放在变量前面,即:

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

以下是我在g++中的结果:

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

是的,还有另一个选择。我们仍然有与u32一起使用的26 GB/s的快速版本,但我们设法至少从13 GB/s版本到20 GB/s版本获得了u64!在我的同事的电脑上,u64版本比u32版本更快,产生了最快的结果。可悲的是,这只适用于g++clang++似乎并不关心static

我的问题

你能解释一下这些结果吗?特别是:

  • u32u64之间有什么区别?
  • 如何将非常量替换为常量缓冲区大小,从而触发不太理想的代码?
  • 插入static关键字如何使u64循环更快?比我同事电脑上的原始代码还要快!

我知道优化是一个棘手的领域,但是,我从未想过如此小的更改会导致100%的执行时间差异,并且像恒定缓冲区大小这样的小因素会再次完全混合结果。当然,我总是希望有能够弹出26 GB/s的版本。我能想到的唯一可靠的方法是复制粘贴此情况下的程序集并使用内联程序集。这是我唯一能摆脱那些似乎对小改动很生气的编译器的方法。你怎么认为?是否有其他方法可以可靠地获取性能最高的代码?

拆卸

这是拆卸图


罪魁祸首:错误的数据依赖(编译器甚至不知道)

在Sandy/Ivy Bridge和Haswell处理器上,说明:

1
popcnt  src, dest

似乎对目标寄存器dest有错误依赖。即使该指令只写给它,该指令也会等到dest就绪后再执行。

这种依赖性不仅支持单循环迭代中的4popcnt。它可以进行循环迭代,使处理器不可能并行处理不同的循环迭代。

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

在您的例子中,速度是粘在(错误)依赖链上的东西的直接结果,这取决于寄存器分配器决定做什么。

  • 13 GB/s有一个链:popcnt-add-popcnt-popcnt&rarr;下一个迭代
  • 15GB/s有一个链:popcnt-add-popcnt-add&rarr;下一个迭代
  • 20 GB/s有一个链:popcnt-popcnt&rarr;下一个迭代
  • 26 GB/s有一个链:popcnt-popcnt&rarr;下一个迭代

20 GB/s和26 GB/s之间的差异似乎是间接寻址的一个小错误。不管怎样,一旦达到这个速度,处理器就会开始碰到其他瓶颈。

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

结果如下:

Sandy Bridge [email protected] GHz:(可在底部找到完整的测试代码)

  • 一般合同条款第4.6.3款:g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • Ubuntu 12

不同寄存器:18.6195 GB/s

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.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有如此错误的依赖性?

我们只能推测,但英特尔对许多双操作数指令的处理方式可能相同。像addsub这样的通用指令接受两个操作数,这两个操作数都是输入。因此,英特尔可能将popcnt推到同一类别,以保持处理器设计的简单性。

AMD处理器似乎没有这种错误的依赖性。

完整测试代码如下供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
#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  
\t"

               "add %4, %0    
\t"

               "popcnt %5, %5  
\t"

               "add %5, %1    
\t"

               "popcnt %6, %6  
\t"

               "add %6, %2    
\t"

               "popcnt %7, %7  
\t"

               "add %7, %3    
\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  
\t"

               "add %%rax, %0      
\t"

               "popcnt %5, %%rax  
\t"

               "add %%rax, %1      
\t"

               "popcnt %6, %%rax  
\t"

               "add %%rax, %2      
\t"

               "popcnt %7, %%rax  
\t"

               "add %%rax, %3      
\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  
\t"
  // <--- Break the chain.
               "popcnt %4, %%rax  
\t"

               "add %%rax, %0      
\t"

               "popcnt %5, %%rax  
\t"

               "add %%rax, %1      
\t"

               "popcnt %6, %%rax  
\t"

               "add %%rax, %2      
\t"

               "popcnt %7, %%rax  
\t"

               "add %%rax, %3      
\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的数量。

1
2
3
4
5
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


我编写了一个等效的C程序来进行实验,我可以证实这种奇怪的行为。另外,gcc认为64位整数(可能是size_t)更好,因为使用uint_fast32_t会导致gcc使用64位uint。我在大会上做了些零碎的工作:只需使用32位版本,在程序的内部popcount循环中将所有32位指令/寄存器替换为64位版本。观察:代码和32位版本一样快!这显然是一个黑客,因为变量的大小不是真正的64位,因为程序的其他部分仍然使用32位版本,但是只要内部popcount循环控制性能,这是一个好的开始。然后,我从32位版本的程序中复制了内部循环代码,将其修改为64位,修改寄存器,以替换64位版本的内部循环。此代码的运行速度与32位版本相同。我的结论是,这是编译器错误的指令调度,而不是32位指令的实际速度/延迟优势。(警告:我拼凑了大会,可能在不注意的情况下弄坏了什么。我不这么认为。)


这不是一个答案,但如果我把结果放在评论中,就很难阅读了。

我使用Mac Pro(Westmire 6核Xeon 3.33 GHz)获得这些结果。我用clang -O3 -msse4 -lstdc++ a.cpp -o a编译它(-o2得到相同的结果)。

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

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

uint64_t size=1<<20;碰撞

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

我还试图:

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

    速度系数分为三部分:

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

    • 使用说明。注意,不仅循环计数,而且在两个版本中,缓冲区是通过32位和64位索引访问的。使用64位偏移量访问指针请求专用的64位寄存器和寻址,而您可以使用immediate进行32位偏移量。这可能会使32位版本更快。

    • 指令仅在64位编译时发出(即预取)。这使得64位更快。

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


    我在Visual Studio 2013 Express中尝试了这个方法,使用指针而不是索引,这会加快进程。我怀疑这是因为地址是偏移+寄存器,而不是偏移+寄存器+(寄存器<<3)。C++代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
       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=COUNT,RDI=BUFFER,R13=K:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    $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

    我不能给出权威性的答案,但提供可能原因的概述。这个参考非常清楚地表明,对于循环体中的指令,延迟和吞吐量之间的比率为3:1。它还显示了多次调度的效果。由于在现代x86处理器中有三个整数单元,因此通常可以每个周期发送三个指令。

    因此,在峰值管道和多个调度性能以及这些机制的故障之间,我们有一个性能系数为6。众所周知,x86指令集的复杂性使得它很容易发生奇怪的中断。上面的文档有一个很好的例子:

    The Pentium 4 performance for 64-bit right shifts is really poor. 64-bit left shift as well as all 32-bit shifts have acceptable performance. It appears that the data path from the upper 32 bits to the lower 32 bit of the ALU is not well designed.

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

    这里我的猜测是对整数单元的争用:popcnt、循环计数器和地址计算在32位宽的计数器下几乎不能全速运行,但是64位计数器会导致争用和管道中断。由于总共只有大约12个周期,可能有4个周期具有多个调度,每个循环体执行,单个暂停可以合理地影响运行时间2倍。

    使用静态变量引起的变化,我猜这只会导致指令的轻微重新排序,这也是32位代码处于争用临界点的另一个线索。

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


    你试过把-funroll-loops -fprefetch-loop-arrays交给海湾合作委员会吗?

    通过这些额外的优化,我得到了以下结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    [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


    你试过把减速步移出回路吗?现在您有一个真正不需要的数据依赖项。

    尝试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
      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];

    你还有一些奇怪的混叠,我不确定是否符合严格的混叠规则。


    tl;dr:使用__builtinintrinsics代替。

    我能够让gcc4.8.4(甚至gcc.godbolt.org上的4.7.3)通过使用__builtin_popcountll生成最佳代码,该代码使用相同的汇编指令,但没有错误的依赖性错误。

    我对我的基准代码没有100%的把握,但objdump的输出似乎与我的观点相同。我使用其他一些技巧(++ii++使编译器在没有任何movl指令的情况下为我展开循环(我必须说是奇怪的行为)。

    结果:

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

    基准代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    #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>
    "
    , 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
    "
    , count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
      return 0;
    }

    编译选项:

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

    GCC版本:

    1
    gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

    Linux内核版本:

    1
    3.19.0-58-generic

    CPU信息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    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:


    好的,我想对OP提出的一个子问题提供一个小的答案,这个问题在现有问题中似乎没有解决。注意,我没有做过任何测试、代码生成或反汇编,只是想和大家分享一个想法,以便大家可以加以阐述。

    为什么static会改变性能?

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

    简短回答

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

    长回答

    由于变量只有一个副本,不管它是否声明为static,并且大小不变,因此我推测差异在于用于备份变量的内存位置以及变量在代码中的使用位置。

    好的,从显而易见的开始,记住函数的所有局部变量(连同参数)都在堆栈上提供了空间,用作存储。现在,显然,main()的堆栈帧永远不会清除,只生成一次。好吧,那我们把它变成1号[0号]怎么样?在这种情况下,编译器知道在进程的全局数据空间中保留空间,这样就不能通过删除堆栈帧来清除位置。但是,我们只有一个地点,那有什么区别呢?我怀疑这与堆栈上内存位置的引用方式有关。

    当编译器生成符号表时,它只为标签和相关属性(如大小等)创建一个条目。它知道必须在内存中保留适当的空间,但在执行活动性分析和可能的寄存器分配之后,在进程中稍晚些时候才实际选择该位置。那么,链接器如何知道为最终程序集代码向机器代码提供什么地址?它要么知道最终位置,要么知道如何到达该位置。对于堆栈,很容易引用基于位置的一个两个元素,即指向stack frame的指针,然后指向该帧的偏移量。这基本上是因为链接器在运行时之前不知道stackframe的位置。


    首先,尝试评估峰值性能-查看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个时钟。吞吐量以时钟为单位显示您的最大速率(如果使用popcnt64,则乘以核心频率和8字节,以获得最佳带宽数)。好的。

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

    最后,查看循环中指令之间的数据依赖性,因为它们会强制延迟大而不是吞吐量大——因此将单次迭代的指令拆分到数据流链上,并计算它们之间的延迟,然后天真地从中获取最大值。它将给出考虑数据流依赖性的粗略估计。好的。

    但是,在您的情况下,只要以正确的方式编写代码,就可以消除所有这些复杂性。不要累积到同一个计数变量,只需累积到不同的变量(如count0、count1,…)。倒计时8)并在最后进行总结。或者甚至创建一个计数数组[8]并累积到它的元素中——也许,它甚至会被矢量化,您将获得更好的吞吐量。好的。

    另一方面,不要让基准跑一秒钟,先热身核心,然后循环跑至少10秒或100秒以上。否则,您将在硬件中测试电源管理固件和DVFS实现:)好的。

    P.P.S.我听到了关于基准测试到底要运行多长时间的无休止的争论。最聪明的人甚至会问为什么10秒不是11秒或12秒。我应该承认这在理论上很有趣。在实践中,您只需连续运行基准100次并记录偏差。真有趣。大多数人都会更改源代码,然后运行工作台一次,以获取新的性能记录。做正确的事。好的。

    还是不相信?只需使用上面的c-version of benchmark by assp1r1n3(https://stackoverflow.com/a/37026212/9706746)并在retry循环中尝试100而不是10000。好的。

    我的7960x显示,重试=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/s好的。

    重试=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好的。

    P.P.P.S.最后,关于"接受的回答"和其他迷雾;—)好的。

    让我们用assp1r1n3的答案-他有2.5GHz的内核。popcnt有1个时钟throughput,他的代码使用64位popcnt。因此,对于他的设置,数学是2.5GHz*1时钟*8字节=20 GB/s。他看到25GB/s,可能是由于涡轮增压到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—48 70HQ好的。

    该内核最高可运行3.7GHz,其硬件的实际最大速率为29.6GB/s。那么,另一个4Gb/s在哪里呢?也许,它花费在循环逻辑和每个迭代中的其他周围代码上。好的。

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

    P.P.P.P.P.S.仍然有人认为HW勘误表是罪魁祸首,所以我遵循建议并创建了内联ASM示例,请参见下面的内容。好的。

    在我的7960x上,第一个版本(对cnt0的单输出)以11MB/s的速度运行,第二个版本(输出到cnt0、cnt1、cnt2和cnt3)以33MB/s的速度运行。有人会说-哇!这是输出依赖关系。好的。

    好吧,也许,我的观点是这样写代码没有意义,这不是输出依赖性问题,而是愚蠢的代码生成。我们不是在测试硬件,而是在编写代码来释放最大的性能。你可以期待hw ooo应该重命名并隐藏那些"输出依赖项",但是,gash,只要做正确的事情,你就永远不会面临任何神秘。好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    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:
    \t"

               "popcnt %2, %1
    \t"

               "popcnt %2, %1
    \t"

               "popcnt %2, %1
    \t"

               "popcnt %2, %1
    \t"

               "subq $4, %0
    \t"

               "jnz 1b
    \t"

            :"+q" (len),"=q" (cnt0)
            :"q" (val)
            :
            );
        #else
            __asm__ __volatile__ (
               "1:
    \t"

               "popcnt %5, %1
    \t"

               "popcnt %5, %2
    \t"

               "popcnt %5, %3
    \t"

               "popcnt %5, %4
    \t"

               "subq $4, %0
    \t"

               "jnz 1b
    \t"

            :"+q" (len),"=q" (cnt0),"=q" (cnt1),"=q" (cnt2),"=q" (cnt3)
            :"q" (val)
            :
            );
        #endif
        return cnt0;
    }

    好啊。