我想用英特尔处理器实现以下操作的最大带宽 .
for(int i=0; i<n; i++) z[i] = x[i] + y[i]; //n=2048
其中x,y和z是浮点数组 . 我在Haswell,Ivy Bridge和Westmere系统上这样做 .
我最初分配了这样的内存
char *a = (char*)_mm_malloc(sizeof(float)*n, 64);
char *b = (char*)_mm_malloc(sizeof(float)*n, 64);
char *c = (char*)_mm_malloc(sizeof(float)*n, 64);
float *x = (float*)a; float *y = (float*)b; float *z = (float*)c;
当我这样做时,我获得了每个系统预期的峰值带宽的大约50% .
峰值计算为 frequency * average bytes/clock_cycle
. 每个系统的平均字节/时钟周期为:
Core2: two 16 byte reads one 16 byte write per 2 clock cycles -> 24 bytes/clock cycle
SB/IB: two 32 byte reads and one 32 byte write per 2 clock cycles -> 48 bytes/clock cycle
Haswell: two 32 byte reads and one 32 byte write per clock cycle -> 96 bytes/clock cycle
这意味着,例如在Haswell I上我只观察到48字节/时钟周期(可能是一个时钟周期内的两次读取,另一次写入下一个时钟周期) .
我打印出 b-a
和 c-b
地址的差异,每个都是8256字节 . 值8256是8192 64.因此它们每个都比一个高速缓存行大一些数组大小(8192字节) .
一时兴起,我尝试像这样分配内存 .
const int k = 0;
char *mem = (char*)_mm_malloc(1<<18,4096);
char *a = mem;
char *b = a+n*sizeof(float)+k*64;
char *c = b+n*sizeof(float)+k*64;
float *x = (float*)a; float *y = (float*)b; float *z = (float*)c;
This nearly doubled my peak bandwidth so that I now get around 90% of the peak bandwidth. 然而,当我尝试 k=1
时,它回落到50% . 我已经尝试了 k
的其他值,并发现例如 k=2
, k=33
, k=65
仅获得峰值的50%,例如 k=10
, k=32
, k=63
全速前进 . I don't understand this.
在Agner Fog的micrarchitecture手册中,他说存在与存储器地址的错误依赖关系,具有相同的设置和偏移
不能同时从间隔4 KB的地址读取和写入 .
但这正是我看到最大利益的地方!当 k=0
时,内存地址恰好相差 2*4096
个字节 . Agner还谈到了Cache bank冲突 . 但Haswell和Westmere并不认为存在这些银行冲突,所以不应该解释我所观察到的 . What's going on!?
我知道OoO执行决定了哪个地址可以读写,所以即使数组的存储器地址恰好相差4096字节,也不一定意味着处理器读取例如 &x[0]
并同时写入 &z[0]
但是为什么单个缓存行会导致它被阻塞?
编辑:根据Evgeny Kluev的回答,我现在相信这就是Agner Fog所说的“虚假商店转发摊位” . 在Pentium Pro,II和II的手册中,他写道:
有趣的是,如果在不同的缓存库中碰巧具有相同的设置值,那么在编写和读取完全不同的地址时,您可以获得一个伪造商店转发停顿:
; Example 5.28. Bogus store-to-load forwarding stall
mov byte ptr [esi], al
mov ebx, dword ptr [esi+4092]
; No stall
mov ecx, dword ptr [esi+4096]
; Bogus stall
编辑:以下是 k=0
和 k=1
的每个系统的效率表 .
k=0 k=1
Westmere: 99% 66%
Ivy Bridge: 98% 44%
Haswell: 90% 49%
我想我可以解释这些数字,如果我假设 k=1
写入和读取不能在同一个时钟周期发生 .
cycle Westmere Ivy Bridge Haswell
1 read 16 read 16 read 16 read 32 read 32
2 write 16 read 16 read 16 write 32
3 write 16
4 write 16
k=1/k=0 peak 16/24=66% 24/48=50% 48/96=50%
这个理论非常有效 . 常 Spring 藤桥比我预期的要低一些,但Ivy Bridge遭遇银行缓存冲突,其他人不这样做,这可能是另一个需要考虑的效果 .
下面是自己测试的工作代码 . 在没有AVX的系统上使用_2904100编译,否则使用 g++ -O3 -mavx sum.cpp
进行编译 . 尝试改变值 k
.
//sum.cpp
#include <x86intrin.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#define TIMER_TYPE CLOCK_REALTIME
double time_diff(timespec start, timespec end)
{
timespec temp;
if ((end.tv_nsec-start.tv_nsec)<0) {
temp.tv_sec = end.tv_sec-start.tv_sec-1;
temp.tv_nsec = 1000000000+end.tv_nsec-start.tv_nsec;
} else {
temp.tv_sec = end.tv_sec-start.tv_sec;
temp.tv_nsec = end.tv_nsec-start.tv_nsec;
}
return (double)temp.tv_sec + (double)temp.tv_nsec*1E-9;
}
void sum(float * __restrict x, float * __restrict y, float * __restrict z, const int n) {
#if defined(__GNUC__)
x = (float*)__builtin_assume_aligned (x, 64);
y = (float*)__builtin_assume_aligned (y, 64);
z = (float*)__builtin_assume_aligned (z, 64);
#endif
for(int i=0; i<n; i++) {
z[i] = x[i] + y[i];
}
}
#if (defined(__AVX__))
void sum_avx(float *x, float *y, float *z, const int n) {
float *x1 = x;
float *y1 = y;
float *z1 = z;
for(int i=0; i<n/64; i++) { //unroll eight times
_mm256_store_ps(z1+64*i+ 0,_mm256_add_ps(_mm256_load_ps(x1+64*i+ 0), _mm256_load_ps(y1+64*i+ 0)));
_mm256_store_ps(z1+64*i+ 8,_mm256_add_ps(_mm256_load_ps(x1+64*i+ 8), _mm256_load_ps(y1+64*i+ 8)));
_mm256_store_ps(z1+64*i+ 16,_mm256_add_ps(_mm256_load_ps(x1+64*i+16), _mm256_load_ps(y1+64*i+ 16)));
_mm256_store_ps(z1+64*i+ 24,_mm256_add_ps(_mm256_load_ps(x1+64*i+24), _mm256_load_ps(y1+64*i+ 24)));
_mm256_store_ps(z1+64*i+ 32,_mm256_add_ps(_mm256_load_ps(x1+64*i+32), _mm256_load_ps(y1+64*i+ 32)));
_mm256_store_ps(z1+64*i+ 40,_mm256_add_ps(_mm256_load_ps(x1+64*i+40), _mm256_load_ps(y1+64*i+ 40)));
_mm256_store_ps(z1+64*i+ 48,_mm256_add_ps(_mm256_load_ps(x1+64*i+48), _mm256_load_ps(y1+64*i+ 48)));
_mm256_store_ps(z1+64*i+ 56,_mm256_add_ps(_mm256_load_ps(x1+64*i+56), _mm256_load_ps(y1+64*i+ 56)));
}
}
#else
void sum_sse(float *x, float *y, float *z, const int n) {
float *x1 = x;
float *y1 = y;
float *z1 = z;
for(int i=0; i<n/32; i++) { //unroll eight times
_mm_store_ps(z1+32*i+ 0,_mm_add_ps(_mm_load_ps(x1+32*i+ 0), _mm_load_ps(y1+32*i+ 0)));
_mm_store_ps(z1+32*i+ 4,_mm_add_ps(_mm_load_ps(x1+32*i+ 4), _mm_load_ps(y1+32*i+ 4)));
_mm_store_ps(z1+32*i+ 8,_mm_add_ps(_mm_load_ps(x1+32*i+ 8), _mm_load_ps(y1+32*i+ 8)));
_mm_store_ps(z1+32*i+ 12,_mm_add_ps(_mm_load_ps(x1+32*i+12), _mm_load_ps(y1+32*i+ 12)));
_mm_store_ps(z1+32*i+ 16,_mm_add_ps(_mm_load_ps(x1+32*i+16), _mm_load_ps(y1+32*i+ 16)));
_mm_store_ps(z1+32*i+ 20,_mm_add_ps(_mm_load_ps(x1+32*i+20), _mm_load_ps(y1+32*i+ 20)));
_mm_store_ps(z1+32*i+ 24,_mm_add_ps(_mm_load_ps(x1+32*i+24), _mm_load_ps(y1+32*i+ 24)));
_mm_store_ps(z1+32*i+ 28,_mm_add_ps(_mm_load_ps(x1+32*i+28), _mm_load_ps(y1+32*i+ 28)));
}
}
#endif
int main () {
const int n = 2048;
const int k = 0;
float *z2 = (float*)_mm_malloc(sizeof(float)*n, 64);
char *mem = (char*)_mm_malloc(1<<18,4096);
char *a = mem;
char *b = a+n*sizeof(float)+k*64;
char *c = b+n*sizeof(float)+k*64;
float *x = (float*)a;
float *y = (float*)b;
float *z = (float*)c;
printf("x %p, y %p, z %p, y-x %d, z-y %d\n", a, b, c, b-a, c-b);
for(int i=0; i<n; i++) {
x[i] = (1.0f*i+1.0f);
y[i] = (1.0f*i+1.0f);
z[i] = 0;
}
int repeat = 1000000;
timespec time1, time2;
sum(x,y,z,n);
#if (defined(__AVX__))
sum_avx(x,y,z2,n);
#else
sum_sse(x,y,z2,n);
#endif
printf("error: %d\n", memcmp(z,z2,sizeof(float)*n));
while(1) {
clock_gettime(TIMER_TYPE, &time1);
#if (defined(__AVX__))
for(int r=0; r<repeat; r++) sum_avx(x,y,z,n);
#else
for(int r=0; r<repeat; r++) sum_sse(x,y,z,n);
#endif
clock_gettime(TIMER_TYPE, &time2);
double dtime = time_diff(time1,time2);
double peak = 1.3*96; //haswell @1.3GHz
//double peak = 3.6*48; //Ivy Bridge @ 3.6Ghz
//double peak = 2.4*24; // Westmere @ 2.4GHz
double rate = 3.0*1E-9*sizeof(float)*n*repeat/dtime;
printf("dtime %f, %f GB/s, peak, %f, efficiency %f%%\n", dtime, rate, peak, 100*rate/peak);
}
}
2 回答
我认为
a
和b
之间的差距并不重要 . 在b
和c
之间只留下一个空隙后,我在Haswell上得到了以下结果:由于Haswell被认为没有银行冲突,唯一剩下的解释是内存地址之间的错误依赖(你已经在Agner Fog的微架构手册中找到了解释这个问题的适当位置) . 银行冲突和虚假共享之间的区别在于,银行冲突阻止在同一时钟周期内访问同一银行两次,而虚假共享阻止在您写入相同的偏移量之后读取4K内存中的某些偏移量(并且不仅仅是在相同的时钟周期内,也可以在写入后的几个时钟周期内) .
由于您的代码(对于
k=0
)写入任何偏移量只是 after 从相同的偏移量执行两次读取并且在很长时间内不会从中读取,因此这种情况应该被视为"best",所以我将k=0
放在表的末尾 . 对于k=1
,您总是从最近被覆盖的偏移读取,这意味着错误共享,从而降低性能 . 写入和读取之间的时间间隔越长,CPU内核就有更多机会将写入的数据传递到所有内存层次结构(这意味着读取和写入的两个地址转换)写入,更新缓存数据和标签,从缓存中获取数据,核心之间的数据同步,以及可能还有更多东西) .k=12
或24个时钟(在我的CPU上)足以让每个写入的数据准备好进行后续读取操作,因此从这个值开始,性能将恢复正常 . 看起来与AMD的20个时钟没有太大区别(正如@Mysticial所说) .TL;DR :对于
k
的某些值,会出现太多4K混叠条件,这是带宽降级的主要原因 . 在4K混叠中,负载不必要地停止,从而增加了有效负载延迟并且停止所有后来的相关指令 . 这反过来导致L1带宽利用率降低 . 对于k
的这些值,可以通过按如下方式拆分循环来消除大多数4K混叠条件:当
k
是奇数正整数(例如1)时,此分割消除了大多数4K混叠 . Haswell实现的L1带宽提高了约50% . 例如,通过展开循环并找出不使用索引寻址模式进行加载和存储的方法,仍有改进的余地 .但是,对于
k
的偶数值,此拆分不会消除4K混叠 . 因此,需要对k
的偶数值使用不同的拆分 . 但是,当k
为0时,可以在不分割循环的情况下实现最佳性能 . 在这种情况下,性能同时在端口1,2,3,4和7上进行后端绑定 .在某些情况下,在同时执行加载和存储时可能会有几个周期的惩罚,但在这种特殊情况下,这种惩罚基本上不存在,因为基本上没有这样的冲突(即并发加载的地址)和商店相距甚远) . 此外,总工作集大小适合L1,因此在第一次执行循环之后没有L1-L2流量 .
本答复的其余部分包括对本摘要的详细解释 .
首先,观察三个阵列的总大小为24KB . 此外,由于您不得不担心未命中或硬件预取 . 在这种情况下,最重要的性能事件是
LD_BLOCKS_PARTIAL.ADDRESS_ALIAS
,当涉及稍后加载的部分地址比较导致与早期存储匹配并且满足所有商店转发条件但目标位置实际上不同时,会发生这种情况 . 英特尔将此情况称为4K别名或虚假存储转发 . 4K混叠的可观察性能损失取决于周围的代码 .通过测量
cycles
,LD_BLOCKS_PARTIAL.ADDRESS_ALIAS
和MEM_UOPS_RETIRED.ALL_LOADS
,我们可以看到,对于k
的所有值,其中实现的带宽远小于峰值带宽,LD_BLOCKS_PARTIAL.ADDRESS_ALIAS
和MEM_UOPS_RETIRED.ALL_LOADS
几乎相等 . 对于k
的所有值,其中实现的带宽接近峰值带宽,LD_BLOCKS_PARTIAL.ADDRESS_ALIAS
与MEM_UOPS_RETIRED.ALL_LOADS
相比非常小 . 这证实了由于大多数负载遭受4K混叠而发生带宽降级 .英特尔优化手册第12.8节说明如下:
当代码存储到一个内存位置时,会发生> 4 KB内存别名,之后不久,它会从不同的内存位置加载,它们之间的偏移量为4 KB . 例如,线性地址0x400020的加载跟随存储到线性地址0x401020 . 加载和存储对于其地址的位5-11具有相同的值,并且所访问的字节偏移应该具有部分或完全重叠 .
也就是说,稍后加载与早期商店的别名有两个必要条件:
两个线性地址的位5-11必须相等 .
访问的位置必须重叠(以便可以转发一些数据) .
在支持AVX-512的处理器上,在我看来,单个加载uop最多可以加载64个字节 . 所以我认为第一个条件的范围应该是6-11而不是5-11 .
下面的清单显示了基于AVX(32字节)的存储器访问序列,以及它们的两个不同值
k
的最低有效12位 .注意,当k = 0时,没有负载似乎满足4K混叠的两个条件 . 另一方面,当k = 1时,所有负载似乎都满足条件 . 但是,对于所有迭代和
k
的所有值,手动执行此操作非常繁琐 . 所以我编写了一个基本上生成内存地址的程序访问并计算针对k
的不同值遭受4K别名的负载总数 . 我遇到的一个问题是我们没有设计模拟器,因此它可以针对k
的不同值使用不同的存储吞吐量,这似乎更好地反映了真实处理器上实际发生的情况 . 代码可以找到here .下图显示了模拟器生成的4K混叠情况数与使用Haswell上的
LD_BLOCKS_PARTIAL.ADDRESS_ALIAS
测量的数字相比较 . 我已经为每个k
值调整了模拟器中使用的存储吞吐量,以使两条曲线尽可能相似 . 第二个图显示了在模拟器中使用并在Haswell上测量的逆存储吞吐量(总周期除以存储总数) . 请注意,k = 0时的存储吞吐量无关紧要,因为无论如何都没有4K混叠 . 由于每个存储有两个负载,因此反向负载吞吐量是反向存储吞吐量的一半 .显然,每个商店在商店缓冲区中保留的时间量与Haswell和模拟器不同,因此我需要使用不同的吞吐量来使两条曲线相似 . 模拟器可用于显示商店吞吐量如何影响4K别名的数量 . 如果商店吞吐量非常接近1c / store,则4K混叠情况的数量会小得多 . 4K混叠条件不会导致管道刷新,但它们可能导致来自RS的uop重放 . 在这种特殊情况下,我没有观察到任何重播 .
在同时执行加载和存储时实际上会有几个周期的惩罚,但它们只能在加载和存储的地址在Haswell上的64字节(但不相等)或Ivy Bridge上的32字节之间发生和桑迪桥 . Weird performance effects from nearby dependent stores in a pointer-chasing loop on IvyBridge. Adding an extra load speeds it up? . 在这种情况下,所有访问的地址都是32字节对齐的,但是在IvB上,L1端口的大小都是16字节,因此可能会对Haswell和IvB造成损失 . 实际上,由于加载和存储可能需要更多时间才能退出,并且由于存储缓冲区的负载缓冲区数量较多,因此后续加载将更有可能对早期存储区域进行伪造 . 然而,这提出了一个问题,即4K别名惩罚和L1访问惩罚如何相互作用并有助于整体性能 . 使用
CYCLE_ACTIVITY.STALLS_LDM_PENDING
事件和负载延迟性能监视工具MEM_TRANS_RETIRED.LOAD_LATENCY_GT_*
,在我看来,没有可观察到的L1访问惩罚 . 这意味着大多数情况下并发加载和存储的地址不会导致惩罚 . 因此,4K混叠损失是带宽降级的主要原因 .我使用以下代码对Haswell进行测量 . 这基本上与
g++ -O3 -mavx
发出的代码相同 .