为什么在单独的循环中元素添加比在组合循环中快得多?

时间:2011-12-17 20:40:52

标签: c++ c performance compiler-optimization vectorization

假设a1b1c1d1指向堆内存,我的数字代码具有以下核心循环。

const int n = 100000;

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
    c1[j] += d1[j];
}

此循环通过另一个外部for循环执行10,000次。为了加快速度,我将代码更改为:

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

for (int j = 0; j < n; j++) {
    c1[j] += d1[j];
}

在MS Visual C++ 10.0上进行了完整优化并在SSE2 Duo(x64)上为32位启用了Intel Core 2,第一个示例需要5.5秒,双循环示例需要只有1.9秒。我的问题是:(请参阅我在底部的改写问题)

PS:我不确定,如果这有帮助:

第一个循环的反汇编基本上看起来像这样(在整个程序中该块重复约五次):

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 /缓存架构之间的差异可能也很有趣。

PPS:这是完整的代码。它使用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。)

enter image description here

9 个答案:

答案 0 :(得分:1618)

在对此进行进一步分析后,我相信这是(至少部分地)由四个指针的数据对齐引起的。这将导致某种程度的缓存库/方式冲突。

如果我已正确猜测您如何分配数组,那么 可能会与页面行对齐

这意味着每个循环中的所有访问都将采用相同的缓存方式。但是,英特尔处理器暂时具有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在评论中指出的那样,这种对齐很可能会在加载/存储单元或缓存中导致 错误别名 。我用Google搜索了一下,发现英特尔实际上有一个用于 部分地址别名 停顿的硬件计数器:

http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html


5个地区 - 说明

区域1:

这个很容易。数据集非常小,性能主要由开销和分支等开销控制。

区域2:

这里,随着数据大小的增加,相对开销量下降,性能“饱和”。这里有两个循环较慢,因为它有两倍的循环和分支开销。

我不确定这里到底发生了什么......当Agner Fog提到cache bank conflicts时,对齐仍然可以发挥作用。 (这个链接是关于Sandy Bridge的,但这个想法仍应适用于Core 2。)

区域3:

此时,数据不再适合L1缓存。因此,性能受到L1&lt; - &gt;的限制。 L2缓存带宽。

区域4:

单循环中的性能下降是我们所观察到的。如上所述,这是由于对齐(很可能)导致处理器加载/存储单元中的 错误别名 停顿。

但是,为了发生错误的混叠,数据集之间必须有足够大的步幅。这就是为什么你在3区没有看到这个。

区域5:

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


2 x Intel X5482 Harpertown @ 3.2 GHz Intel Core i7 870 @ 2.8 GHz Intel Core i7 2600K @ 4.4 GHz

答案 1 :(得分:210)

好的,正确的答案肯定是要对CPU缓存做些什么。但是使用缓存参数可能非常困难,尤其是没有数据。

有很多答案,导致了很多讨论,但让我们面对现实:缓存问题可能非常复杂而且不是一维的。它们在很大程度上依赖于数据的大小,所以我的问题是不公平的:事实证明它在缓存图中非常有趣。

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

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

下图显示,根据所使用的确切方案和参数,大多数答案,特别是对问题和答案的大多数评论都可以被视为完全错误或真实。

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

  1. 它在一个和两个循环版本之间存在最大的差异(几乎是三分之一)

  2. 这是唯一的一点,其中一个循环(即连续分配)胜过双循环版本。 (这使得Mysticial的答案成为可能。)

  3. 使用初始化数据的结果:

    Enter image description here

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

    Enter image description here

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

    Enter image description here

    提案

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

答案 2 :(得分:73)

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

答案 3 :(得分:44)

想象一下,你正在使用n只是正确值的机器,只能在内存中同时保存两个阵列,但通过磁盘缓存可用的总内存是仍足以容纳所有四个人。

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

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

首先会将ab加载到RAM中,然后完全在RAM中处理。当第二个循环开始时,cd将从磁盘加载到RAM中并进行操作。

另一个循环

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

每次围绕循环,将在另外两个中分页两个数组和页面。这显然会慢。

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


这里似乎有点混乱/误解,所以我将尝试用一个例子来详细说明。

n = 2,我们正在使用字节。因此,在我的场景中,我们只有<4个字节的RAM ,其余的内存显着变慢(比如说访问时间长100倍)。

假设的缓存策略相当愚蠢,如果该字节不在缓存中,那么把它放在那里并在我们处理它时得到以下字节你会得到这样的场景:< / p>

  • 使用

    for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
    
  • 缓存a[0]a[1]然后b[0]b[1]并在缓存中设置a[0] = a[0] + b[0] - 缓存中现在有四个字节,{{ 1}}和a[0], a[1]。费用= 100 + 100。

  • 在缓存中设置b[0], b[1]。费用= 1 + 1。
  • 重复a[1] = a[1] + b[1]c
  • 总费用= d

  • 使用

    (100 + 100 + 1 + 1) * 2 = 404
  • 缓存for(int j=0;j<n;j++){ a[j] += b[j]; c[j] += d[j]; } a[0]然后a[1]b[0]并在缓存中设置b[1] - 缓存中现在有四个字节,{{ 1}}和a[0] = a[0] + b[0]。费用= 100 + 100。

  • 从缓存中弹出a[0], a[1]并缓存b[0], b[1]a[0], a[1], b[0], b[1],然后c[0]c[1]并在缓存中设置d[0]。费用= 100 + 100。
  • 我怀疑你开始明白我要去哪里了。
  • 总费用= d[1]

这是一个经典的缓存捶打场景。

答案 4 :(得分:30)

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

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

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

答案 5 :(得分:19)

我无法复制此处讨论的结果。

我不知道糟糕的基准代码是否应该归咎于什么,但是这两种方法在我的机器上使用以下代码在彼此的10%之内,并且一个循环通常只比两个稍快 - 如你期待的。

使用八个循环,数组大小范围为2 ^ 16到2 ^ 24。我小心地初始化了源数组,因此+=赋值并没有要求FPU添加被解释为double的内存垃圾。

我使用了各种方案,例如将b[j]d[j]分配给InitToZero[j]到循环内,还使用+= b[j] = 1和{{1我得到了相当一致的结果。

正如您所料,使用+= d[j] = 1在循环内初始化bd使得组合方法具有优势,因为它们在分配给{之前背靠背地完成{1}}和InitToZero[j],但仍在10%之内。去图。

硬件为Dell XPS 8500,第3代Core i7 @ 3.4 GHz,内存为8 GB。对于2 ^ 16到2 ^ 24,使用八个循环,累积时间分别为44.987和40.965。 Visual C ++ 2010,完全优化。

PS:我改变了循环以倒数到零,并且组合方法稍微快一些。抓我的头。请注意新的数组大小和循环计数。

a

我不确定为什么MFLOPS是一个相关指标。我的想法是专注于内存访问,所以我试图最小化浮点计算时间。我离开了c,但我不确定原因。

没有计算的直接赋值将是对内存访问时间的更清晰的测试,并且无论循环计数如何都会创建一个统一的测试。也许我在谈话中遗漏了一些东西,但值得三思。如果加号不在作业中,则累计时间几乎相同,每次31秒。

答案 6 :(得分:16)

这是因为CPU没有那么多缓存未命中(必须等待阵列数据来自RAM芯片)。您可以持续调整数组的大小,以便超过CPU的level 1 cache(L1)和level 2 cache(L2)的大小,并绘制时间使您的代码针对数组的大小执行。图表不应该像您期望的那样是直线。

答案 7 :(得分:13)

第一个循环交替写入每个变量。第二个和第三个只是元素大小的小跳跃。

尝试用笔和纸隔开20厘米,用20个十字架写两条平行线。尝试完成一个然后另一个行,然后通过交替地在每一行中写一个十字来尝试另一次。

答案 8 :(得分:1)

可能是旧的C ++和优化。在我的计算机上,我获得了几乎相同的速度:

一个循环:1.577毫秒

两个循环:1.507毫秒

我在具有16 GB RAM的E5-1620 3.5 GHz处理器上运行Visual Studio 2015。