假设a1、b1、c1和d1指向堆内存,我的数字代码具有以下核心循环。
const int n = 100000;
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
c1[j] += d1[j];
}
该循环通过另一个外部for循环执行10000次。为了加快速度,我将代码更改为:
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
}
for (int j = 0; j < n; j++) {
c1[j] += d1[j];
}
在Microsoft Visual C++10.0上编译,经过完全优化,并在Intel Core 2 Duo(x64)上启用了32位SSE2,第一个示例耗时5.5秒,双循环示例仅需1.9秒。
第一个循环的反汇编基本上是这样的(在整个程序中,这个块重复了大约五次):
movsd xmm0,mmword ptr [edx+18h]
addsd xmm0,mmword ptr [ecx+20h]
movsd mmword ptr [ecx+20h],xmm0
movsd xmm0,mmword ptr [esi+10h]
addsd xmm0,mmword ptr [eax+30h]
movsd mmword ptr [eax+30h],xmm0
movsd xmm0,mmword ptr [edx+20h]
addsd xmm0,mmword ptr [ecx+28h]
movsd mmword ptr [ecx+28h],xmm0
movsd xmm0,mmword ptr [esi+18h]
addsd xmm0,mmword ptr [eax+38h]
双循环示例的每个循环都会生成此代码(以下块重复大约三次):
addsd xmm0,mmword ptr [eax+28h]
movsd mmword ptr [eax+28h],xmm0
movsd xmm0,mmword ptr [ecx+20h]
addsd xmm0,mmword ptr [eax+30h]
movsd mmword ptr [eax+30h],xmm0
movsd xmm0,mmword ptr [ecx+28h]
addsd xmm0,mmword ptr [eax+38h]
movsd mmword ptr [eax+38h],xmm0
movsd xmm0,mmword ptr [ecx+30h]
addsd xmm0,mmword ptr [eax+40h]
movsd mmword ptr [eax+40h],xmm0
事实证明,这个问题无关紧要,因为行为严重依赖于数组(n)和CPU缓存的大小。因此,如果有进一步的兴趣,我会重新表述这个问题:
您能否深入了解导致不同缓存行为的细节,如下图中的五个区域所示?通过为这些CPU提供类似的图表,指出CPU/缓存架构之间的差异可能也很有趣。
这是完整的代码。它使用TBB Tick_Count进行更高分辨率的计时,可以通过不定义TBB_timing宏来禁用:
#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>
//#define TBB_TIMING
#ifdef TBB_TIMING
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif
using namespace std;
//#define preallocate_memory new_cont
enum { new_cont, new_sep };
double *a1, *b1, *c1, *d1;
void allo(int cont, int n)
{
switch(cont) {
case new_cont:
a1 = new double[n*4];
b1 = a1 + n;
c1 = b1 + n;
d1 = c1 + n;
break;
case new_sep:
a1 = new double[n];
b1 = new double[n];
c1 = new double[n];
d1 = new double[n];
break;
}
for (int i = 0; i < n; i++) {
a1[i] = 1.0;
d1[i] = 1.0;
c1[i] = 1.0;
b1[i] = 1.0;
}
}
void ff(int cont)
{
switch(cont){
case new_sep:
delete[] b1;
delete[] c1;
delete[] d1;
case new_cont:
delete[] a1;
}
}
double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
allo(cont,n);
#endif
#ifdef TBB_TIMING
tick_count t0 = tick_count::now();
#else
clock_t start = clock();
#endif
if (loops == 1) {
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++){
a1[j] += b1[j];
c1[j] += d1[j];
}
}
} else {
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
}
for (int j = 0; j < n; j++) {
c1[j] += d1[j];
}
}
}
double ret;
#ifdef TBB_TIMING
tick_count t1 = tick_count::now();
ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
clock_t end = clock();
ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif
#ifndef preallocate_memory
ff(cont);
#endif
return ret;
}
void main()
{
freopen("C:\\test.csv", "w", stdout);
char *s = " ";
string na[2] ={"new_cont", "new_sep"};
cout << "n";
for (int j = 0; j < 2; j++)
for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
cout << s << i << "_loops_" << na[preallocate_memory];
#else
cout << s << i << "_loops_" << na[j];
#endif
cout << endl;
long long nmax = 1000000;
#ifdef preallocate_memory
allo(preallocate_memory, nmax);
#endif
for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
{
const long long m = 10000000/n;
cout << n;
for (int j = 0; j < 2; j++)
for (int i = 1; i <= 2; i++)
cout << s << plain(n, m, j, i);
cout << endl;
}
}
它显示了n的不同值的FLOP/s。
进一步分析后,我认为这(至少部分)是由四个指针的数据对齐造成的。这将导致一定程度的缓存库/通道冲突。
如果我猜对了如何分配数组,那么它们很可能与页面行对齐。
这意味着每个循环中的所有访问都将落在相同的缓存路径上。然而,英特尔处理器已经有一段时间具有8路L1缓存关联性。但事实上,表现并不完全一致。访问4路仍然比访问2路慢。
编辑:事实上,它看起来像是在单独分配所有数组。通常,当请求如此大的分配时,分配器将从OS请求新的页面。因此,大的分配很有可能出现在距页面边界相同的偏移处。
以下是测试代码:
int main(){
const int n = 100000;
#ifdef ALLOCATE_SEPERATE
double *a1 = (double*)malloc(n * sizeof(double));
double *b1 = (double*)malloc(n * sizeof(double));
double *c1 = (double*)malloc(n * sizeof(double));
double *d1 = (double*)malloc(n * sizeof(double));
#else
double *a1 = (double*)malloc(n * sizeof(double) * 4);
double *b1 = a1 + n;
double *c1 = b1 + n;
double *d1 = c1 + n;
#endif
// Zero the data to prevent any chance of denormals.
memset(a1,0,n * sizeof(double));
memset(b1,0,n * sizeof(double));
memset(c1,0,n * sizeof(double));
memset(d1,0,n * sizeof(double));
// Print the addresses
cout << a1 << endl;
cout << b1 << endl;
cout << c1 << endl;
cout << d1 << endl;
clock_t start = clock();
int c = 0;
while (c++ < 10000){
#if ONE_LOOP
for(int j=0;j<n;j++){
a1[j] += b1[j];
c1[j] += d1[j];
}
#else
for(int j=0;j<n;j++){
a1[j] += b1[j];
}
for(int j=0;j<n;j++){
c1[j] += d1[j];
}
#endif
}
clock_t end = clock();
cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;
system("pause");
return 0;
}
基准结果:
编辑:实际Core 2体系结构机器上的结果:
2 x Intel Xeon X5482 Harpertown@3.2 GHz:
#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206
#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116
//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894
//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993
观察:
一圈6.206秒,两圈2.116秒。这准确地再现了OP的结果。在前两个测试中,阵列是单独分配的。您将注意到它们都具有与页面相同的对齐方式。在第二个测试中,阵列被打包在一起以打破这种对齐。在这里,您会注意到两个循环都更快。此外,第二个(双)循环现在是您通常预期的速度较慢的循环。
正如@Stephen Cannon在评论中指出的那样,这种对齐很可能会导致加载/存储单元或缓存中出现假别名。我在谷歌上搜索了一下,发现英特尔实际上有一个用于部分地址混淆暂停的硬件计数器:
http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html
5个地区-解释
区域1:
这个很简单。数据集如此之小,以至于性能受到开销(如循环和分支)的控制。
区域2:
在这里,随着数据大小的增加,相对开销的数量下降,性能“饱和”。这里两个循环比较慢,因为它有两倍的循环和分支开销。
我不知道这里到底发生了什么。。。当Agner Fog提到缓存库冲突时,对齐仍可能发挥作用。(这个链接是关于Sandy Bridge的,但这个想法应该仍然适用于核心2。)
区域3:
此时,数据不再适合一级缓存。因此,性能受到L1<->L2缓存带宽的限制。
区域4:
我们观察到的是单循环中的性能下降。如上所述,这是由于对齐(最有可能)导致处理器加载/存储单元中的假混叠暂停。
然而,为了发生假混叠,数据集之间必须有足够大的步长。这就是为什么你在区域3中看不到这一点。
区域5:
此时,缓存中没有任何内容。所以你受到内存带宽的限制。
我无法复制这里讨论的结果。
我不知道是不是糟糕的基准测试代码造成了问题,或者是什么,但在我的机器上,使用以下代码,这两个方法的速度相差10%以内,而且一个循环通常比两个循环稍快-正如您所预期的那样。
数组大小从2^16到2^24不等,使用八个循环。我小心地初始化了源数组,因此+=赋值不会要求FPU添加解释为双精度的内存垃圾。
我尝试了各种方案,例如将b[j]、d[j]赋值给InitToZero[j]到循环中,以及使用+=b[j]=1和+=d[j]=1,我得到了相当一致的结果。
正如您可能预期的那样,使用InitToZero[j]在循环内初始化b和d使组合方法具有优势,因为它们在分配给a和c之前是背靠背完成的,但仍在10%以内。想想看吧
硬件为Dell XPS 8500,具有第3代Core i7@3.4 GHz和8 GB内存。对于2^16到2^24,使用八个循环,累积时间分别为44.987和40.965。Visual C++2010,完全优化。
PS:我将循环改为倒数为零,组合方法稍微快一些。挠我的头。注意新的数组大小和循环计数。
// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>
#define dbl double
#define MAX_ARRAY_SZ 262145 //16777216 // AKA (2^24)
#define STEP_SZ 1024 // 65536 // AKA (2^16)
int _tmain(int argc, _TCHAR* argv[]) {
long i, j, ArraySz = 0, LoopKnt = 1024;
time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;
a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
// Initialize array to 1.0 second.
for(j = 0; j< MAX_ARRAY_SZ; j++) {
InitToOnes[j] = 1.0;
}
// Increase size of arrays and time
for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
// Outside the timing loop, initialize
// b and d arrays to 1.0 sec for consistent += performance.
memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));
start = clock();
for(i = LoopKnt; i; i--) {
for(j = ArraySz; j; j--) {
a[j] += b[j];
c[j] += d[j];
}
}
Cumulative_Combined += (clock()-start);
printf("\n %6i miliseconds for combined array sizes %i and %i loops",
(int)(clock()-start), ArraySz, LoopKnt);
start = clock();
for(i = LoopKnt; i; i--) {
for(j = ArraySz; j; j--) {
a[j] += b[j];
}
for(j = ArraySz; j; j--) {
c[j] += d[j];
}
}
Cumulative_Separate += (clock()-start);
printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
(int)(clock()-start), ArraySz, LoopKnt);
}
printf("\n Cumulative combined array processing took %10.3f seconds",
(dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
printf("\n Cumulative seperate array processing took %10.3f seconds",
(dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
getchar();
free(a); free(b); free(c); free(d); free(InitToOnes);
return 0;
}
我不确定为什么决定MFLOPS是一个相关的指标。我认为我的想法是专注于内存访问,所以我尽量减少浮点计算时间。我离开了,但我不知道为什么。
没有计算的直接赋值将是对内存访问时间的更干净的测试,并将创建一个与循环计数无关的统一测试。也许我在谈话中漏掉了什么,但值得三思。如果赋值中省略了加号,则累积时间几乎相同,每个值为31秒。