C++ C++;:将一个操作数保留在寄存器中会产生不可思议的巨大加速

C++ C++;:将一个操作数保留在寄存器中会产生不可思议的巨大加速,c++,c,performance,optimization,assembly,C++,C,Performance,Optimization,Assembly,我一直在尝试通过计时一个例程,使用下面的代码对数组的元素进行缩放和求和,来了解在一级缓存中使用数组相对于内存的影响(我知道我应该在最后将结果按“a”进行缩放;关键是在循环中进行乘法和加法运算-到目前为止,编译器还没有计算出“a”的系数): 这是完全未缓存的性能,尽管在循环迭代之间X的所有元素都应该被保存在缓存中 g++ -O3 -S -fno-asynchronous-unwind-tables register_opt_example.cpp 我注意到求和函数循环中有一个奇怪的地方: L55

我一直在尝试通过计时一个例程,使用下面的代码对数组的元素进行缩放和求和,来了解在一级缓存中使用数组相对于内存的影响(我知道我应该在最后将结果按“a”进行缩放;关键是在循环中进行乘法和加法运算-到目前为止,编译器还没有计算出“a”的系数):

这是完全未缓存的性能,尽管在循环迭代之间X的所有元素都应该被保存在缓存中

g++ -O3 -S -fno-asynchronous-unwind-tables register_opt_example.cpp
我注意到求和函数循环中有一个奇怪的地方:

L55:
    movsd   (%r12,%rax,8), %xmm0
    mulsd   %xmm1, %xmm0
    addsd   -72(%rbp), %xmm0
    movsd   %xmm0, -72(%rbp)
    incq    %rax
    cmpq    $2048, %rax
    jne L55
说明:

    addsd   -72(%rbp), %xmm0
    movsd   %xmm0, -72(%rbp)
指示它正在堆栈上存储sum()中的“total”值,并在每次循环迭代时读取和写入该值。我修改了程序集,以便将此操作数保留在寄存器中:

...
addsd   %xmm0, %xmm3
...
这一小小的变化带来了巨大的性能提升:

Operand size: 2048
Vector size 2048: mflops=1958.9, result=-67.8
tl;dr 我的问题是:考虑到单个内存位置应该存储在一级缓存中,为什么用寄存器替换单个内存位置访问会大大加快代码的速度?是什么体系结构因素使这成为可能?重复写入一个堆栈位置会完全破坏缓存的有效性,这似乎非常奇怪

附录

我的gcc版本是:

Target: i686-apple-darwin10
Configured with: /var/tmp/gcc/gcc-5646.1~2/src/configure --disable-checking --enable-werror --prefix=/usr --mandir=/share/man --enable-languages=c,objc,c++,obj-c++ --program-transform-name=/^[cg][^.-]*$/s/$/-4.2/ --with-slibdir=/usr/lib --build=i686-apple-darwin10 --with-gxx-include-dir=/include/c++/4.2.1 --program-prefix=i686-apple-darwin10- --host=x86_64-apple-darwin10 --target=i686-apple-darwin10
Thread model: posix
gcc version 4.2.1 (Apple Inc. build 5646) (dot 1)
我的CPU是:


英特尔至强X5650

我无法实际复制它,因为我的编译器(gcc 4.7.2)在寄存器中保留了
total

我怀疑速度慢的主要原因不一定与一级缓存有关,而是由于存储在内存中的数据之间的依赖关系

movsd   %xmm0, -72(%rbp)
以及后续迭代的负载:

addsd   -72(%rbp), %xmm0

这很可能是一个较长的依赖链和负载预测失误*的组合


更长的依赖链:

首先,我们确定关键的依赖路径。然后我们看一下:(第117页)提供的指令延迟

在未优化版本中,关键依赖路径为:

  • addsd-72(%rbp),%xmm0
  • movsd%xmm0,-72(%rbp)
在内部,它可能分为:

  • 负载(2个循环)
  • addsd(3个周期)
  • 存储(3个循环)
如果我们看一下优化版本,它只是:

  • addsd(3个周期)
所以你有8个周期和3个周期,几乎是3的一个因素

我不确定Nehalem处理器线对存储负载依赖性有多敏感,以及它的性能如何。但有理由相信它不是零


加载存储预测失误:

现代处理器以你能想象的更多方式使用预测。其中最著名的可能是。不太为人所知的一种是负载预测

当处理器看到加载时,它将在所有挂起的写入完成之前立即加载。它将假定这些写入不会与加载的值冲突

如果先前的写入结果与加载冲突,则必须重新执行加载,并将计算回滚到加载点。(与分支预测失误回滚的方式大致相同)

它在这里的相关性:

不用说,现代处理器将能够同时执行此循环的多个迭代。因此,处理器将尝试在完成上一次迭代的存储(
movsd%xmm0,-72(%rbp)
)之前执行加载(
addsd-72(%rbp),%xmm0)

结果?前一个存储与加载冲突-因此预测失误和回滚


*请注意,我不确定“负载预测”的名称。我只在英特尔文档中读到过,他们似乎没有给出它的名称。

我猜想问题不在于缓存/内存访问,而在于处理器(代码的执行)。这里有几个明显的瓶颈

这里的性能数字基于我使用的盒子(sandybridge或Westmile)

标量数学的峰值性能为2.7Ghz x2触发器/时钟 x2,因为处理器可以同时进行加法和乘法运算。 代码的理论效率为0.6/(2.7*2)=11%

所需带宽: 每(+)和(x)->4字节/触发器两倍 4字节*5.4G浮点=21.6GB/s

如果您知道它是最近读取的,它可能在L1(89GB/s)、L2(42GB/s)或L3(24GB/s)中,因此我们可以排除缓存B/W

内存支持系统为18.9 GB/s,因此即使在主内存中,峰值性能也应接近18.9/21.6GB/s=87.5%

  • 可能希望尽早批处理请求(通过展开)
即使使用推测执行,tot+=a*X[i]加法也将被序列化,因为在启动tot(n+1)之前,需要对tot(n)进行求值

第一次展开循环
我移动8,然后做

{//your func
    for( int i = 0; i < size; i += 8 ){
        tot += a * X[i];
        tot += a * X[i+1];
        ...
        tot += a * X[i+7];
    }
    return tot
}
改进代码:

Operand size: 2048  
Vector size 2048: mflops=5313.7, result=61.8  
5.3137 / 5.4 = 98.4%  

编译器笨到连寄存器都没有保存?我很惊讶…@Mysticial:公平地说,gcc的人,
gcc 4.7.2
确实将它保存在寄存器中。@NPE啊,好吧。这有助于我对编译器保持一定的信心…为什么要使用这样一个旧的编译器?即使对于gcc 4.2这样一个旧的编译器,这也出人意料地糟糕。我认为有人更喜欢c现在Mac上的ompiler是铿锵的,这可能是最新的。此外,为了始终获得最佳优化,如果您的gcc版本已经支持,请使用
-O3-march=native
。啊,那么您的意思是它必须等待存储完成后才能开始加载?我猜如果缓存是通过写入的,那么将非常昂贵。This是gcc 4.2和更高版本之间的一个差异;gcc 4.4以后的版本优化了临时堆栈的使用。有一些有用的链接可以让我得出相同的结论。但是也有循环,因此大约3的因子仅用于添加到结果变量。如果你也计算跳跃和循环条件,则因子大约为2
{//your func
    for( int i = 0; i < size; i += 8 ){
        tot += a * X[i];
        tot += a * X[i+1];
        ...
        tot += a * X[i+7];
    }
    return tot
}
{//your func//
    int tot,tot2,tot3,tot4;
    tot = tot2 = tot3 = tot4 = 0
    for( int i = 0; i < size; i += 8 ) 
        tot  += a * X[i];
        tot2 += a * X[i+1];
        tot3 += a * X[i+2];
        tot4 += a * X[i+3];
        tot  += a * X[i+4];
        tot2 += a * X[i+5];
        tot3 += a * X[i+6];
        tot4 += a * X[i+7];
    }
    return tot + tot2 + tot3 + tot4;
}
Operand size: 2048  
Vector size 2048: mflops=2206.2, result=61.8  
2.206 / 5.4 = 40.8%
Operand size: 2048  
Vector size 2048: mflops=5313.7, result=61.8  
5.3137 / 5.4 = 98.4%