C++ C++;:将一个操作数保留在寄存器中会产生不可思议的巨大加速
我一直在尝试通过计时一个例程,使用下面的代码对数组的元素进行缩放和求和,来了解在一级缓存中使用数组相对于内存的影响(我知道我应该在最后将结果按“a”进行缩放;关键是在循环中进行乘法和加法运算-到目前为止,编译器还没有计算出“a”的系数): 这是完全未缓存的性能,尽管在循环迭代之间X的所有元素都应该被保存在缓存中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
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个周期)
加载存储预测失误: 现代处理器以你能想象的更多方式使用预测。其中最著名的可能是。不太为人所知的一种是负载预测 当处理器看到加载时,它将在所有挂起的写入完成之前立即加载。它将假定这些写入不会与加载的值冲突 如果先前的写入结果与加载冲突,则必须重新执行加载,并将计算回滚到加载点。(与分支预测失误回滚的方式大致相同) 它在这里的相关性: 不用说,现代处理器将能够同时执行此循环的多个迭代。因此,处理器将尝试在完成上一次迭代的存储(
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%
- 可能希望尽早批处理请求(通过展开)
我移动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%