我正在调查矢量化对程序性能的影响。在这方面,我写了以下代码:
#include <stdio.h>
#include <sys/time.h>
#include <stdlib.h>
#define LEN 10000000
int main(){
struct timeval stTime, endTime;
double* a = (double*)malloc(LEN*sizeof(*a));
double* b = (double*)malloc(LEN*sizeof(*b));
double* c = (double*)malloc(LEN*sizeof(*c));
int k;
for(k = 0; k < LEN; k++){
a[k] = rand();
b[k] = rand();
}
gettimeofday(&stTime, NULL);
for(k = 0; k < LEN; k++)
c[k] = a[k] * b[k];
gettimeofday(&endTime, NULL);
FILE* fh = fopen("dump", "w");
for(k = 0; k < LEN; k++)
fprintf(fh, "c[%d] = %f\t", k, c[k]);
fclose(fh);
double timeE = (double)(endTime.tv_usec + endTime.tv_sec*1000000 - stTime.tv_usec - stTime.tv_sec*1000000);
printf("Time elapsed: %f\n", timeE);
return 0;
}
在这段代码中,我只是简单地初始化和乘以两个向量。结果保存在向量c
中。我最感兴趣的是矢量化跟随循环的效果:
for(k = 0; k < LEN; k++)
c[k] = a[k] * b[k];
我使用以下两个命令编译代码:
1) icc -O2 TestSMID.c -o TestSMID -no-vec -no-simd
2) icc -O2 TestSMID.c -o TestSMID -vec-report2
我希望看到性能提升,因为第二个命令成功地对循环进行了矢量化。但是,我的研究表明,当循环被矢量化时,性能没有提高。
我可能在这里遗漏了一些东西,因为我对这个话题并不是很熟悉。所以,如果我的代码有问题,请告诉我。
提前感谢您的帮助。
PS:我使用的是Mac OSX,因此不需要对齐数据,因为所有分配的内存都是16字节对齐的。
编辑:
我想首先感谢大家的意见和答案。
我想到了@Mysticial提出的答案,这里还有一些要点。
首先,正如@Vinska所提到的,c[k]=a[k]*b[k]
不只需要一个循环。除了循环索引增量以及为确保k
小于LEN
而进行的比较之外,还有其他事情要执行操作。看一下编译器生成的汇编代码,可以看出简单的乘法需要多于一个循环。矢量化版本看起来像:
L_B1.9: # Preds L_B1.8
movq %r13, %rax #25.5
andq $15, %rax #25.5
testl %eax, %eax #25.5
je L_B1.12 # Prob 50% #25.5
# LOE rbx r12 r13 r14 r15 eax
L_B1.10: # Preds L_B1.9
testb $7, %al #25.5
jne L_B1.32 # Prob 10% #25.5
# LOE rbx r12 r13 r14 r15
L_B1.11: # Preds L_B1.10
movsd (%r14), %xmm0 #26.16
movl $1, %eax #25.5
mulsd (%r15), %xmm0 #26.23
movsd %xmm0, (%r13) #26.9
# LOE rbx r12 r13 r14 r15 eax
L_B1.12: # Preds L_B1.11 L_B1.9
movl %eax, %edx #25.5
movl %eax, %eax #26.23
negl %edx #25.5
andl $1, %edx #25.5
negl %edx #25.5
addl $10000000, %edx #25.5
lea (%r15,%rax,8), %rcx #26.23
testq $15, %rcx #25.5
je L_B1.16 # Prob 60% #25.5
# LOE rdx rbx r12 r13 r14 r15 eax
L_B1.13: # Preds L_B1.12
movl %eax, %eax #25.5
# LOE rax rdx rbx r12 r13 r14 r15
L_B1.14: # Preds L_B1.14 L_B1.13
movups (%r15,%rax,8), %xmm0 #26.23
movsd (%r14,%rax,8), %xmm1 #26.16
movhpd 8(%r14,%rax,8), %xmm1 #26.16
mulpd %xmm0, %xmm1 #26.23
movntpd %xmm1, (%r13,%rax,8) #26.9
addq $2, %rax #25.5
cmpq %rdx, %rax #25.5
jb L_B1.14 # Prob 99% #25.5
jmp L_B1.20 # Prob 100% #25.5
# LOE rax rdx rbx r12 r13 r14 r15
L_B1.16: # Preds L_B1.12
movl %eax, %eax #25.5
# LOE rax rdx rbx r12 r13 r14 r15
L_B1.17: # Preds L_B1.17 L_B1.16
movsd (%r14,%rax,8), %xmm0 #26.16
movhpd 8(%r14,%rax,8), %xmm0 #26.16
mulpd (%r15,%rax,8), %xmm0 #26.23
movntpd %xmm0, (%r13,%rax,8) #26.9
addq $2, %rax #25.5
cmpq %rdx, %rax #25.5
jb L_B1.17 # Prob 99% #25.5
# LOE rax rdx rbx r12 r13 r14 r15
L_B1.18: # Preds L_B1.17
mfence #25.5
# LOE rdx rbx r12 r13 r14 r15
L_B1.19: # Preds L_B1.18
mfence #25.5
# LOE rdx rbx r12 r13 r14 r15
L_B1.20: # Preds L_B1.14 L_B1.19 L_B1.32
cmpq $10000000, %rdx #25.5
jae L_B1.24 # Prob 0% #25.5
# LOE rdx rbx r12 r13 r14 r15
L_B1.22: # Preds L_B1.20 L_B1.22
movsd (%r14,%rdx,8), %xmm0 #26.16
mulsd (%r15,%rdx,8), %xmm0 #26.23
movsd %xmm0, (%r13,%rdx,8) #26.9
incq %rdx #25.5
cmpq $10000000, %rdx #25.5
jb L_B1.22 # Prob 99% #25.5
# LOE rdx rbx r12 r13 r14 r15
L_B1.24: # Preds L_B1.22 L_B1.20
非vectoized版本是:
L_B1.9: # Preds L_B1.8
xorl %eax, %eax #25.5
# LOE rbx r12 r13 r14 r15 eax
L_B1.10: # Preds L_B1.10 L_B1.9
lea (%rax,%rax), %edx #26.9
incl %eax #25.5
cmpl $5000000, %eax #25.5
movsd (%r15,%rdx,8), %xmm0 #26.16
movsd 8(%r15,%rdx,8), %xmm1 #26.16
mulsd (%r13,%rdx,8), %xmm0 #26.23
mulsd 8(%r13,%rdx,8), %xmm1 #26.23
movsd %xmm0, (%rbx,%rdx,8) #26.9
movsd %xmm1, 8(%rbx,%rdx,8) #26.9
jb L_B1.10 # Prob 99% #25.5
# LOE rbx r12 r13 r14 r15 eax
除此之外,处理器不会仅加载24个字节。在每次访问内存时,都会加载一个完整的行(64字节)。更重要的是,由于a
,b
和c
所需的内存是连续的,因此prefetcher肯定会提供很多帮助并提前加载下一个块。
话虽如此,我认为@Mysticial计算的内存带宽过于悲观。
此外,Intel Vectorization Guide中提到了使用SIMD来提高程序的性能,以便进行非常简单的添加。因此,似乎我们应该能够在这个非常简单的循环中获得一些性能提升。
EDIT2:
再次感谢您的评论。另外,感谢@Mysticial示例代码,我终于看到了SIMD对性能改进的影响。正如Mysticial所提到的,问题在于内存带宽。通过选择适合L1缓存的a
,b
和c
的小尺寸,可以看出SIMD可以显着提高性能。以下是我得到的结果:
icc -O2 -o TestSMIDNoVec -no-vec TestSMID2.c: 17.34 sec
icc -O2 -o TestSMIDVecNoUnroll -vec-report2 TestSMID2.c: 9.33 sec
展开循环可以进一步提高性能:
icc -O2 -o TestSMIDVecUnroll -vec-report2 TestSMID2.c -unroll=8: 8.6sec
另外,我应该提一下,当使用-O2
进行编译时,我的处理器只需一个周期即可完成迭代。
PS:我的电脑是Macbook Pro核心i5 @ 2.5GHz(双核)
答案 0 :(得分:67)
这个原始答案在2013年有效。截至2017年的硬件,事情已经发生了很大变化,问题和答案都已过时。
请参阅本答复的结尾2017年更新。
原始答案(2013):
因为你已经被内存带宽瓶颈了。
虽然矢量化和其他微优化可以提高计算速度,但它们不能提高记忆速度。
在你的例子中:
for(k = 0; k < LEN; k++)
c[k] = a[k] * b[k];
你只需要对所有内存进行一次通过即可。这最大化了你的内存带宽。
所以无论它如何优化,(矢量化,展开等等),它都不会更快。
2013年的典型台式机大约有 10 GB / s 的内存带宽*。
您的循环接触 24字节/迭代。< / p>
如果没有矢量化,现代x64处理器可能会在一个周期内完成大约1次迭代*。
假设您以4 GHz运行:
(4 * 10^9) * 24 bytes/iteration = 96 GB/s
这几乎是你内存带宽的10倍 - 没有矢量化。
*毫不奇怪,有些人怀疑我上面给出的数字,因为我没有引用。那些从经验中脱离了我的头脑。所以这里有一些基准来证明它。
循环迭代可以快速运行1个周期/迭代:
如果我们减少LEN
以便它适合缓存,我们就可以消除内存瓶颈
(我在C ++中对此进行了测试,因为它更容易。但它没有任何区别。)
#include <iostream>
#include <time.h>
using std::cout;
using std::endl;
int main(){
const int LEN = 256;
double *a = (double*)malloc(LEN*sizeof(*a));
double *b = (double*)malloc(LEN*sizeof(*a));
double *c = (double*)malloc(LEN*sizeof(*a));
int k;
for(k = 0; k < LEN; k++){
a[k] = rand();
b[k] = rand();
}
clock_t time0 = clock();
for (int i = 0; i < 100000000; i++){
for(k = 0; k < LEN; k++)
c[k] = a[k] * b[k];
}
clock_t time1 = clock();
cout << (double)(time1 - time0) / CLOCKS_PER_SEC << endl;
}
在此测试中,我仅在 6.55 秒内运行了25,600,000,000次迭代。
6.55 * 4.2 GHz
= 27,510,000,000次循环 27,510,000,000 / 25,600,000,000
= 1.074周期/迭代 现在,如果您想知道如何做到这一点:
一个循环......
这是因为现代处理器和编译器非常棒。
虽然这些操作中的每一个都具有延迟(尤其是乘法),但处理器能够同时执行多次迭代。我的测试机器是Sandy Bridge处理器,每个周期能够承受2x128b负载,1x128b存储和1x256b矢量FP。如果加载是微融合微指令的内存源操作数,则可能还有另外一个或两个向量或整数运算。 (2只在使用256b AVX加载/存储时加载+ 1个存储吞吐量,否则每个循环只有两个总存储操作数(最多一个存储区))。
查看程序集(为简洁起见,我省略了),似乎编译器展开了循环,从而减少了循环开销。但它并没有设法对其进行矢量化。
内存带宽大约为10 GB / s:
最简单的方法是通过memset()
:
#include <iostream>
#include <time.h>
using std::cout;
using std::endl;
int main(){
const int LEN = 1 << 30; // 1GB
char *a = (char*)calloc(LEN,1);
clock_t time0 = clock();
for (int i = 0; i < 100; i++){
memset(a,0xff,LEN);
}
clock_t time1 = clock();
cout << (double)(time1 - time0) / CLOCKS_PER_SEC << endl;
}
所以我的机器 5.811 秒需要写入100 GB的内存。这是关于 17.2 GB / s 。
我的处理器处于更高端。 Nehalem和Core 2代处理器的内存带宽较少。
2017年3月更新:
截至2017年,情况变得更加复杂。
由于DDR4和四通道内存,单个线程不再可能使内存带宽饱和。但带宽问题并不一定会消失。尽管带宽已经增加,但处理器内核也有所改进 - 而且其中有更多内容。
用数学方式说明:
X
。Y
。X > Y
。X < Y
。但是X * (# of cores) > Y
。早在2013年: Sandy Bridge @ 4 GHz +双通道DDR3 @ 1333 MHz
X = 32 GB/s
和Y = ~17 GB/s
X = 64 GB/s
和Y = ~17 GB/s
现在2017年: Haswell-E @ 4 GHz +四通道DDR4 @ 2400 MHz
X = 32 GB/s
和Y = ~70 GB/s
X = 64 GB/s
和Y = ~70 GB/s
(对于Sandy Bridge和Haswell,无论SIMD宽度如何,缓存中的架构限制都会将带宽限制为大约16个字节/周期。)
所以现在,单个线程并不总是能够使内存带宽饱和。您需要进行矢量化以达到X
的限制。但是,您仍将使用2个或更多线程达到Y
的主内存带宽限制。
但是有一件事情没有改变,可能很长时间都没有改变: 你将无法在所有内核上运行带宽占用环路而不会使总数饱和内存带宽。
答案 1 :(得分:2)
编辑:修改了答案很多。另外,请忽略我之前写的关于神秘答案不完全正确的大部分内容。 虽然,我仍然不同意它被内存瓶颈,因为尽管进行了各种各样的测试,但我看不出任何原始代码被内存速度限制的迹象。与此同时,它仍然显示出受CPU限制的明显迹象。
可能有很多原因。由于[s]的原因可能与硬件有关,因此我决定不应根据猜测进行推测。 我将概述在后期测试中遇到的这些事情,在那里我使用了更加准确可靠的CPU时间测量方法并循环1000次循环。我相信这些信息可能有所帮助。但是请把它拿出来,因为它依赖于硬件。
WRT Mystical每个时钟运行近1次迭代的例子 - 我没想到CPU调度器会那么高效,并假设每1.5-2个时钟周期进行1次迭代。但令我惊讶的是,事实并非如此;我确定错了,对不起。我自己的CPU更有效地运行它 - 1.048周期/迭代。所以我可以证明Mystical的这部分答案绝对正确。
答案 2 :(得分:2)
正如Mysticial已经描述的那样,主内存带宽限制是这里大缓冲区的瓶颈。解决这个问题的方法是重新设计您的处理以适应缓存中的块。 (而不是乘以整个200MiB的双精度数,只乘以128kiB,然后再做一些。所以使用乘法输出的代码会发现它仍然在L2缓存中.L2通常是256kiB,并且对每个CPU核心都是私有的,最近的英特尔设计。)
此技术称为cache blocking或loop tiling。对某些算法来说可能很棘手,但收益是L2缓存带宽与主内存带宽之间的差异
如果这样做,请确保编译器仍未生成流式存储(movnt...
)。这些写操作绕过缓存以避免使用不适合的数据来污染它。下一次读取该数据将需要触摸主存储器。
答案 3 :(得分:0)
以防万一[] b []和c []争夺L2缓存::
#include <string.h> /* for memcpy */
...
gettimeofday(&stTime, NULL);
for(k = 0; k < LEN; k += 4) {
double a4[4], b4[4], c4[4];
memcpy(a4,a+k, sizeof a4);
memcpy(b4,b+k, sizeof b4);
c4[0] = a4[0] * b4[0];
c4[1] = a4[1] * b4[1];
c4[2] = a4[2] * b4[2];
c4[3] = a4[3] * b4[3];
memcpy(c+k,c4, sizeof c4);
}
gettimeofday(&endTime, NULL);
将运行时间从98429.000000减少到67213.000000; 展开循环8倍将其减少到57157.000000。