为什么逐元素相加在分开的循环中比在一个合并的循环中快得多?

2434
假设 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];
}

使用 Microsoft Visual C++ 10.0 进行完全优化和启用SSE2的32位编译,在 Intel Core 2 Duo (x64) 上,第一个示例需要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;
    }
}

它显示了不同值的FLOPS,其中包括n

Performace chart


4
可能是操作系统在每次访问时搜索物理内存,而且有类似缓存的东西以备再次访问同一内存块时减慢了速度。 - AlexTheo
9
你是否正在使用优化编译?这段代码在 O2 优化下看起来有很多汇编代码... - Luchian Grigore
74
仅仅挑剔一下,这两段代码片段由于指针可能重叠而不等同。 C99对于这种情况有“restrict”关键字。我不知道MSVC是否有类似的东西。当然,如果这是问题,那么SSE代码将不正确。 - user510306
9
这可能与内存别名有关。如果使用一个循环,d1[j] 可能与 a1[j] 产生别名,因此编译器可能会放弃执行某些内存优化。然而,如果将写入内存分为两个循环,就不会发生这种情况。 - rturrado
1
我敢打赌,L1DL1D_CACHE_STL2_RQSTSL2_DATA_RQSTS性能计数器会很有启示性。请参见Intel Core i7(Nehalem)事件 - jww
显示剩余11条评论
11个回答

0
为了使这段代码运行快速,CPU需要进行缓存预取。基本上,CPU会学习到你正在访问连续的数据,并在实际需要之前从RAM中读取数据。
双重循环有两个输入和两个输出流,因此它需要四个单独的预取操作才能快速运行。第二个循环只需要两个单独的预取操作。如果你在一个CPU上运行这段代码,该CPU可以自动预取两个但不能预取四个缓存行,那么第一个版本将会更慢。
在改进的CPU上,这个问题将会消失。在这种情况下,你可以将代码更改为将三个数组添加到第四个数组中,可能更好的CPU可以预取4个但不能预取8个流,并显示完全相同的效果。

正如现有的答案所显示,当时的CPU已经能够处理4个流。问题在于4k虚假别名;即在不同页面中具有相同的偏移量,使得CPU认为加载将重新加载最近的存储,但在检查上位地址位后,它发现并没有发生这种情况。这在现代英特尔CPU中仍然是一个问题,例如具有12个LFB(如Skylake)或后续CPU中可能更多。英特尔的L2流预取器可以追踪每个4k页面的一个向前和一个向后的流;至少在几年前是这样的,我还没有看到是否有所改变。 - undefined
我的意思是,是的,如果你在一台只能跟踪2个预取流的CPU上运行这段代码,那也会是一个问题,但我认为这在2023年并不是一个非常相关的问题,也不是当时的问题。在Skylake CPU上,具有6或7个流的循环通常是可以的,我认为;根据英特尔的优化手册,最后一次检查时,他们建议将4作为循环融合/分裂的启发式目标,这是非常保守的。 - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接