假设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。


当前回答

第一个循环交替写入每个变量。第二个和第三个仅对元素大小进行小的跳跃。

试着用20厘米的纸和笔写两条平行的20个十字线。试着先完成一行,然后再完成另一行,试着在每一行中交替写一个十字。

其他回答

第二个循环涉及的缓存活动更少,因此处理器更容易跟上内存需求。

我无法复制这里讨论的结果。

我不知道是不是糟糕的基准测试代码造成了问题,或者是什么,但在我的机器上,使用以下代码,这两个方法的速度相差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秒。

这不是因为不同的代码,而是因为缓存:RAM比CPU寄存器慢,并且CPU内部有一个缓存,以避免每次变量发生变化时都写入RAM。但是缓存并不像RAM那么大,因此它只映射了其中的一小部分。

第一个代码在每个循环中交替修改远程内存地址,因此需要不断地使缓存无效。

第二个代码不交替:它只在相邻地址上流动两次。这使得所有作业都在缓存中完成,只有在第二个循环开始后才使其无效。

假设您正在一台机器上工作,其中n正好是一个正确的值,它只可能同时在内存中存储两个阵列,但通过磁盘缓存,可用的总内存仍然足以存储所有四个阵列。

假设一个简单的LIFO缓存策略,下面的代码:

for(int j=0;j<n;j++){
    a[j] += b[j];
}
for(int j=0;j<n;j++){
    c[j] += d[j];
}

首先将a和b加载到RAM中,然后完全在RAM中工作。当第二个循环开始时,c和d将从磁盘加载到RAM并在其上运行。

另一个回路

for(int j=0;j<n;j++){
    a[j] += b[j];
    c[j] += d[j];
}

每次循环时,都会调出两个数组并调入另两个数组。这显然要慢得多。

您可能在测试中没有看到磁盘缓存,但可能看到了其他形式缓存的副作用。


这里似乎有一点困惑/误解,所以我将尝试用一个例子来阐述一点。

假设n=2,我们正在处理字节。在我的场景中,我们只有4个字节的RAM,而我们的内存的其余部分则要慢得多(比如100倍的访问时间)。

假设一个相当愚蠢的缓存策略,即如果字节不在缓存中,那么将其放在那里,并在我们进行时获得以下字节,您将得到类似这样的场景:

具有对于(int j=0;j<n;j++){a[j]+=b[j];}对于(int j=0;j<n;j++){c[j]+=d[j];}缓存a[0]和a[1],然后是b[0]和b[1],并在缓存中设置a[0]=a[0]+b[0]-缓存中现在有四个字节,a[0]、a[1]和b[0]、b[1]。成本=100+100。在缓存中设置a[1]=a[1]+b[1]。成本=1+1。对c和d重复上述步骤。总成本=(100+100+1+1)*2=404具有对于(int j=0;j<n;j++){a[j]+=b[j];c[j]+=d[j];}缓存a[0]和a[1],然后是b[0]和b[1],并在缓存中设置a[0]=a[0]+b[0]-缓存中现在有四个字节,a[0]、a[1]和b[0]、b[1]。成本=100+100。从缓存和缓存c[0]和c[1]中弹出a[0]、a[1]、b[0]和b[1],然后是d[0]和d[1],并在缓存中设置c[0]=c[0]+d[0]。成本=100+100。我怀疑你开始明白我要去哪里了。总成本=(100+100+100+100)*2=800

这是一个经典的缓存抖动场景。

这是因为CPU没有太多缓存未命中(它必须等待来自RAM芯片的阵列数据)。您可以不断调整数组的大小,使其超过CPU的一级缓存(L1)和二级缓存(L2)的大小,并根据数组的大小绘制代码执行所需的时间。图表不应该像你期望的那样是一条直线。