我正在寻找最快的方法来弹出大量数据。我遇到了一个非常奇怪的效果:将循环变量从unsigned更改为uint64_t,使我的电脑性能下降了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。为了得到更精确的结果,我们做了10000次popcount。我们为教皇计时。在大写情况下,内部循环变量是无符号的,在小写情况下,内循环变量是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下运行测试1(因此1 MB随机数据)的结果:

无符号41959360000 0.401554秒26.113 GB/suint64_t 41959360000 0.759822秒13.8003 GB/s

如您所见,uint64_t版本的吞吐量仅为未签名版本的一半!问题似乎是生成了不同的程序集,但为什么?首先,我想到了一个编译器错误,所以我尝试了clang++(Ubuntu clang版本3.4-1ubuntu3):

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

结果:测试1

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

所以,这几乎是相同的结果,仍然很奇怪。但现在它变得超级奇怪。我将从输入读取的缓冲区大小替换为常量1,因此我更改:

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

to

uint64_t size = 1 << 20;

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

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

现在,两个版本的速度都一样快。然而,未签名的变得更慢了!它从26 GB/s下降到20 GB/s,因此用常量值替换非常量会导致非优化。说真的,我不知道这里发生了什么!但现在,要用新版本发出叮当声:

无符号41959360000 0.677009秒15.4884 GB/suint64_t 41959360000 0.676909秒15.4906 GB/s

等等,什么?现在,两个版本都降到了15GB/s的速度。因此,用常量值替换非常量甚至会导致Clang在这两种情况下的代码变慢!

我请一位拥有Ivy Bridge CPU的同事来编译我的基准测试。他得到了类似的结果,所以似乎不是哈斯韦尔。因为两个编译器在这里产生了奇怪的结果,所以它似乎也不是编译器错误。我们这里没有AMD CPU,所以我们只能使用Intel进行测试。

请再疯狂一点!

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

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

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

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

是的,又是另一种选择。我们仍然拥有u32的快速26GB/s,但我们设法获得了u64,至少从13GB/s到20GB/s版本!在我同事的电脑上,u64版本比u32版本更快,取得了最快的成绩。遗憾的是,这只适用于g++,clang++似乎不关心静态。

我的问题

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

u32和u64之间怎么会有这样的区别?用恒定缓冲区大小替换非常量如何触发更少的优化代码?静态关键字的插入如何使u64循环更快?甚至比我同事电脑上的原始代码还要快!

我知道优化是一个棘手的领域,然而,我从来没有想过这样的小变化会导致执行时间的100%差异,并且像恒定缓冲区大小这样的小因素会再次完全混合结果。当然,我总是希望有一个能够弹出26GB/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%的差异!即使缓冲区的存储类型也会产生巨大的差异,正如我们在大小变量前面插入静态关键字所看到的那样!将来,当编写对系统性能至关重要的非常紧凑和热循环时,我将始终在各种编译器上测试各种替代方案。

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


当前回答

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

因此,在峰值流水线和多重调度性能以及这些机制的故障之间,我们有六倍的性能。众所周知,x86指令集的复杂性使得很容易发生奇怪的破坏。上面的文档有一个很好的例子:

奔腾4的64位右移性能非常差。64位左移位以及所有32位移位具有可接受的性能。从ALU的高位32位到低位32位的数据路径似乎设计得不好。

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

这里我的猜测是整数单位的争用:popcnt、循环计数器和地址计算都只能用32位宽的计数器以全速运行,但64位计数器会导致争用和管道暂停。由于每个循环体执行总共只有大约12个循环,可能有4个循环具有多个分派,因此单个暂停可以合理地影响运行时间2倍。

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

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

其他回答

首先,尝试估计峰值性能-检查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、…count8),并在最后将它们相加。或者甚至创建一个计数数组[8]并累积到其元素中——也许,它甚至会被矢量化,您将获得更好的吞吐量。

P.S.,永远不要运行基准测试一秒钟,先预热核心,然后运行循环至少10秒或更好的100秒。否则,您将在硬件中测试电源管理固件和DVFS实现:)

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

还不相信?只需通过assp1r1n3使用高于C版本的基准测试(https://stackoverflow.com/a/37026212/9706746)在重试循环中尝试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/s

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

每分钟。最后,关于“接受的答案”和其他错误;-)

让我们用assp1r1n3的答案——他有2.5Ghz的核心。POPCNT有1个时钟输出,他的代码使用64位POPCNT。因此,对于他的设置,数学是2.5Ghz*1时钟*8字节=20GB/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-4870小时

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

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

P.P.P.P.S.仍然有人认为硬件错误是罪魁祸首,所以我遵循建议,创建了内联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;
}

缺点:虚假数据依赖性(编译器甚至不知道)

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

popcnt  src, dest

似乎对目标寄存器dest有错误的依赖性。即使该指令只向其写入数据,该指令仍将等待dest就绪后再执行。这种错误的依赖性(现在)被Intel记录为勘误表HSD146(Haswell)和SKL029(Skylake)

Skylake为lzcnt和tzcnt修复了此问题。Cannon Lake(和Ice Lake)为popcnt修复了这个问题。bsf/bsr具有真正的输出依赖性:对于输入=0,输出未修改。(但无法利用内部函数的优势——只有AMD记录了它,编译器不会公开它。)

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


这种依赖性不仅仅支持单个循环迭代中的4个popcnt。它可以跨循环迭代,使得处理器无法并行化不同的循环迭代。

无符号vs.uint64_t和其他调整不会直接影响问题。但它们会影响将寄存器分配给变量的寄存器分配器。

在您的案例中,速度是由于寄存器分配器决定执行的操作而导致的(错误)依赖链的直接结果。

13 GB/s有一个链:popcnt add popcnt popcnt→ 下一次迭代15 GB/s有一个链: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=本地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是如何在硬件中实现的?)。对于这些指令,英特尔将input=0的整数结果记录为“undefined”(ZF=1),但英特尔硬件实际上提供了更有力的保证,以避免损坏旧软件:未修改输出。AMD记录了这种行为。

大概是不方便让这个执行单元的一些uop依赖于输出,但其他的不依赖于输出。

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该基准会改变(false)依赖链中popcnt的数量。

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

TL;DR:改用__builtin intrinsic;他们可能会帮上忙。

通过使用__builtin_popcountll(使用相同的汇编指令),我能够使gcc 4.8.4(甚至gcc.godbolt.org上的4.7.3)为此生成最佳代码,但幸运的是,由于错误的依赖性错误,生成的代码没有意外的长循环携带依赖性。

我对我的基准测试代码没有100%的把握,但objdump输出似乎与我的观点相同。我使用了一些其他技巧(++I vs 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:

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

我用Mac Pro(Westmere 6核Xeon 3.33 GHz)得到了这些结果。

clang with uint64_t size=环礁(argv[1])<<20;

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

uint64_t size=1<<20时发出叮当声;

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位寄存器和寻址,而对于32位偏移量,可以使用立即数。这可能会使32位版本更快。指令仅在64位编译(即预取)时发出。这使64位速度更快。

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

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

为什么静态会改变性能?

问题所在的行:uint64_t大小=环礁(argv[1])<<20;

简短回答

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

长答案

由于变量只有一个副本,无论它是否声明为静态,大小也不会改变,所以我的理论是,不同之处在于用于支持变量的内存的位置,以及它在代码中的使用位置。

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

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