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


当前回答

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

其他回答

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

好的,正确的答案肯定是要对CPU缓存进行一些处理。但是使用缓存参数可能非常困难,尤其是在没有数据的情况下。

有很多答案,引发了很多讨论,但让我们面对现实:缓存问题可能非常复杂,而且不是一维的。它们在很大程度上取决于数据的大小,所以我的问题是不公平的:结果是在缓存图中的一个非常有趣的点。

@Mysticial的回答说服了很多人(包括我),可能是因为它是唯一一个似乎依赖事实的答案,但它只是真相的一个“数据点”。

这就是为什么我结合了他的测试(使用连续分配和单独分配)和@James’Answer的建议。

下面的图表显示,根据所使用的确切场景和参数,大多数答案,尤其是对问题和答案的大多数评论都可以被视为完全错误或正确。

注意,我最初的问题是n=100.000。这一点(偶然)表现出特殊的行为:

它在单圈和双圈版本之间具有最大的差异(几乎是三分之一)这是唯一一点,其中一个循环(即连续分配)胜过两个循环版本。(这使得Mysticial的答案成为可能。)

使用初始化数据的结果:

使用未初始化数据的结果(这是Mysticial测试的结果):

这是一个很难解释的问题:初始化的数据,分配一次,并重复用于以下不同向量大小的测试用例:

提议

堆栈溢出上的每一个低级性能相关问题都应该被要求为整个缓存相关数据大小范围提供MFLOPS信息!在没有这些信息的情况下,思考答案,特别是与其他人讨论答案,是浪费每个人的时间。

假设您正在一台机器上工作,其中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)的大小,并根据数组的大小绘制代码执行所需的时间。图表不应该像你期望的那样是一条直线。

进一步分析后,我认为这(至少部分)是由四个指针的数据对齐造成的。这将导致一定程度的缓存库/通道冲突。

如果我猜对了如何分配数组,那么它们很可能与页面行对齐。

这意味着每个循环中的所有访问都将落在相同的缓存路径上。然而,英特尔处理器已经有一段时间具有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:

此时,缓存中没有任何内容。所以你受到内存带宽的限制。