我一直在寻找处理popcount
大量数据的最快方法。我遇到了一个非常奇怪的效果:将loop变量从更改为unsigned
,uint64_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变量前面插入关键字所看到的,甚至缓冲区的存储类型也可以产生巨大的变化!将来,在编写对系统性能至关重要的紧密而又热的循环时,我将始终在各种编译器上测试各种替代方案。
有趣的是,尽管我已经四次展开循环,但性能差异仍然很高。因此,即使展开,仍然会受到重大性能偏差的影响。很有趣。