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


2246

假设a1b1c1,并d1指向堆内存和我的数字代码具有下列核心循环。

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上编译,并在Intel Core 2 Duo(x64)上为32位启用了SSE2,第一个示例花费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;
    }
}

(显示不同值的FLOP / s n。)

在此处输入图片说明


4
可能是操作系统在您每次访问物理内存时都会在搜索物理内存时变慢,并且在二级访问同一内存块的情况下具有类似于缓存的内容。
AlexTheo 2011年

7
您是否正在进行优化?看起来很多O2的asm代码...
Luchian Grigore 2011年

1
一段时间前,我问过类似的问题。它或答案可能包含感兴趣的信息。
马克·威尔金斯

61
只是要挑剔,这两个代码段由于可能重叠的指针而并不等效。C99具有restrict此类情况的关键字。我不知道MSVC是否有类似的东西。当然,如果这是问题所在,那么SSE代码将不正确。
user510306 2011年

8
这可能与内存别名有关。通过一个循环,d1[j]可以使用别名a1[j],因此编译器可以退出进行某些内存优化的工作。如果您将写作分为两个循环,则不会发生这种情况。
rturrado

Answers:


1690

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

如果我猜对了如何分配数组,则它们很可能与page line对齐

这意味着您在每个循环中的所有访问都将使用相同的缓存方式。但是,一段时间以来,英特尔处理器已经具有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个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上搜索了一下,发现Intel实际上有一个硬件计数器,用于部分地址别名停顿:

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的,但该想法仍应适用于Core2。)

区域3:

此时,数据不再适合L1缓存。因此,性能受L1 <-> L2缓存带宽的限制。

区域4:

我们正在观察单循环中的性能下降。并且如前所述,这是由于对齐(最有可能)导致处理器加载/存储单元中的假混叠停顿。

但是,为了使假混叠发生,数据集之间必须有足够大的跨度。这就是为什么您在区域3中看不到它的原因。

区域5:

此时,缓存中没有任何内容。因此,您受内存带宽的束缚。


2个Intel X5482 Harpertown @ 3.2 GHz 英特尔酷睿i7 870 @ 2.8 GHz 英特尔酷睿i7 2600K @ 4.4 GHz


162
+1:我认为这就是答案。与所有其他答案相反,这不是单循环变体固有地具有更多的高速缓存未命中,而是关于导致高速缓存未命中的阵列的特定对齐方式。
奥利弗·查尔斯沃思

30
这个; 一个假的混叠失速是最可能的解释。
斯蒂芬·佳能

7
@VictorT。我使用了OP链接到的代码。它会生成一个.css文件,可以在Excel中打开该文件并从中制作一个图形。
Mysticial 2011年

5
@Nawaz页面通常为4KB。如果您查看我打印出的十六进制地址,则分别分配的测试都具有相同的4096模。(从4KB边界的开头算起32个字节)也许是GCC没有这种行为。这可以解释为什么您看不到差异。
Mysticial 2011年


224

好的,正确的答案肯定与CPU缓存有关。但是使用cache参数可能非常困难,尤其是在没有数据的情况下。

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

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

这就是为什么我将他的测试(使用连续分配与单独分配)和@James'Answer的建议结合在一起的原因。

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

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

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

  2. 这是唯一的一环(即具有连续分配)优于两环版本的地方。(这完全使Mysticial的答案成为可能。)

使用初始化数据的结果:

在此处输入图片说明

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

在此处输入图片说明

这是一个难以解释的数据:初始化数据,该数据只分配一次,并在以下每个具有不同向量大小的测试用例中重新使用:

在此处输入图片说明

提案

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


18
+1不错的分析。首先,我无意保留未初始化的数据。碰巧分配器将它们归零。因此,初始化数据很重要。我只是在实际的 Core 2架构机器上编辑了结果的答案,它们与您观察到的结果非常接近。另一件事是,我测试了一系列大小n,它显示出相同的性能差距n = 80000, n = 100000, n = 200000,等等...
Mysticial 2011年

2
@Mysticial我认为,每当给进程提供新页面时,操作系统都会实现页面清零,以避免可能的进程间监视。
v.oddou '17

1
@ v.oddou:行为也取决于操作系统;IIRC,Windows有一个线程将后台零释放页面进行后台处理,如果无法从已清零的页面VirtualAlloc中满足请求,则调用将阻塞,直到它可以清零到足以满足请求为止。相比之下,Linux只是根据需要将零页映射为写时复制,并且在写入时,它会在写入新数据之前将新的零复制到新页。无论哪种方式,从用户模式过程的角度来看,页面都是零的,但是在Linux上第一次使用未初始化的内存通常会比Windows昂贵。
ShadowRanger

81

第二个循环涉及较少的缓存活动,因此处理器可以更轻松地满足内存需求。


1
您是说第二个变体导致更少的缓存未命中吗?为什么?
奥利弗·查尔斯沃思

2
@Oli:在第一变型中,处理器需要访问四个存储线在一个时间a[i]b[i]c[i]d[i]在第二变型,它需要两个。这使得在添加时重新填充这些行更加可行。
小狗

4
但是,只要数组不会在高速缓存中发生冲突,每个变体都需要对主内存进行完全相同的读写次数。因此结论是(我认为)这两个阵列一直在发生碰撞。
奥利弗·查尔斯沃思

3
我不懂 每条指令(即的每个实例x += y)有两次读取和一次写入。这两种情况均适用。因此,缓存<-> CPU带宽要求是相同的。只要不存在冲突,则高速缓存< - > RAM带宽要求也相同..
奥利弗查尔斯沃思

2
stackoverflow.com/a/1742231/102916中所述,Pentium M的硬件预取可以跟踪12个不同的前向流(我希望以后的硬件至少具有同样的功能)。循环2仍仅读取四个流,因此完全在该限制之内。
Brooks Moses

50

想象一下,您在一台机器上工作,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倍)。

假设一个相当愚蠢的缓存策略是如果该字节不在缓存中,则将其放在那里并在我们处于缓存状态时也获取下一个字节,您将获得类似以下的情况:

  • 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]现在缓存中有四个字节,a[0], a[1]b[0], b[1]。费用= 100 + 100。

  • a[1] = a[1] + b[1]在缓存中设置。费用= 1 + 1。
  • 重复cd
  • 总费用= (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]a[0] = a[0] + b[0]现在缓存中有四个字节,a[0], a[1]b[0], b[1]。费用= 100 + 100。

  • 弹出a[0], a[1], b[0], b[1]从缓存和缓存c[0]c[1]随后d[0]d[1]和设置c[0] = c[0] + d[0]在缓存中。费用= 100 + 100。
  • 我怀疑您开始看到我要去的地方。
  • 总费用= (100 + 100 + 100 + 100) * 2 = 800

这是一个经典的缓存崩溃情况。


12
这是不正确的。引用数组的特定元素不会导致整个数组从磁盘(或非缓存的内存)被分页;只有相关页面或高速缓存行的分页。
布鲁克斯摩西

1
@Brooks Moses-如果您遍历整个数组,就像这里发生的那样,那么它将完成。
OldCurmudgeon 2011年

1
是的,但这是整个操作发生的情况,而不是每次循环都发生的情况。您声称第二种形式“将在循环中每次分页出两个数组,并在其他两个页面中分页”,这就是我反对的内容。无论整个数组的大小如何,在此循环的中间,您的RAM都将从四个阵列中的每一个中保存一个页面,直到循环完成后,任何内容都不会被分页。
Brooks Moses

在特殊情况下,n只是一个正确的值,它只能一次将两个数组保存在内存中,然后在一个循环中访问四个数组的所有元素肯定会导致崩溃。
OldCurmudgeon 2011年

1
您为什么要在整个循环a1以及b1第一份作业中都停留在该2页中,而不仅仅是每个页面的第一页中?(您是否假设5个字节的页面,所以一个页面仅占RAM的一半?这不只是扩展,还完全不同于真正的处理器。)
Brooks Moses

35

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

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

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


为什么这会导致缓存连续失效?
奥利弗·查尔斯沃思

1
@OliCharlesworth:将高速缓存视为连续范围的内存地址的硬拷贝。如果您假装访问不属于其中一部分的地址,则必须重新加载缓存。如果缓存中的某些内容已被修改,则必须将其写回RAM中,否则将会丢失。在示例代码中,四个100'000整数(400kBytes)的向量最有可能超过L1高速缓存(128或256K)的容量。
Emilio Garavaglia 2011年

5
高速缓存的大小在这种情况下没有影响。每个数组元素仅使用一次,此后是否收回都无关紧要。缓存大小仅在您具有时间局部性时才重要(例如,将来您将重复使用相同的元素)。
奥利弗·查尔斯沃思

2
@OliCharlesworth:如果我必须在缓存中加载一个新值,并且其中已经有一个值被修改,那么我必须首先将其写下来,这使我等待写入发生。
Emilio Garavaglia 2011年

2
但是在OP代码的两个变体中,每个值仅被修改一次。在每个变体中,这样做的次数是相同的。
奥利弗·查尔斯沃思

22

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

我不知道应该归咎于糟糕的基准测试代码还是什么,但是使用下面的代码,这两种方法在我的机器上的相差不到10%,并且一个循环通常只比两个循环快一点-如您所愿期望。

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

我打得四处各种方案,如把的分配b[j]d[j]InitToZero[j]环内,并且还用+= b[j] = 1+= d[j] = 1,和我有相当一致的效果。

如你所料,初始化bd使用循环内InitToZero[j]给组合法的优势,因为他们做背到后面的任务之前ac,但仍然在10%以内。去搞清楚。

硬件是Dell XPS 8500,具有第三代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秒。


1
您在此处提到的不对齐代价是当单个加载/存储未对齐(包括未对齐的SSE加载/存储)时。但是这里不是这种情况,因为性能对不同阵列的相对对齐很敏感。在指令级别没有错位。每个单个加载/存储都正确对齐。
Mysticial

18

这是因为CPU没有太多的高速缓存未命中(它必须等待阵列数据来自RAM芯片)。您将不断调整数组的大小,以使其超过CPU的1级缓存(L1)和2级缓存(L2)的大小,并绘制代码所花费的时间,这将很有趣。对数组的大小执行。该图不应是您期望的直线。


2
我不认为缓存大小和数组大小之间存在任何交互。每个数组元素仅使用一次,然后可以安全地逐出。但是,如果高速缓存大小与阵列大小之间可能存在相互作用,则这会导致四个阵列发生冲突。
奥利弗·查尔斯沃思

15

第一个循环交替写入每个变量。第二个和第三个只使元素大小发生很小的变化。

尝试用相隔20厘米的钢笔和纸书写两条20条交叉的平行线。尝试先完成一条,然后再完成另一条线,然后通过在每条线中交替书写一个十字来尝试另一次。


在考虑诸如CPU指令之类的事情时,与现实世界活动的类比充满了危险。您要说明的是有效的寻道时间,如果我们正在谈论读/写存储在旋转磁盘上的数据,则将适用该时间,但是CPU缓存(或RAM或SSD)中没有寻道时间。与相邻访问相比,对不相交的内存区域的访问不会产生任何代价。
FeRD

7

原始问题

为什么一个循环比两个循环要慢得多?


结论:

情况1是一个经典的插值问题,碰巧是一个效率低下的问题。我还认为,这是许多机器体系结构和开发人员最终构建和设计具有执行多线程应用程序以及并行编程能力的多核系统的主要原因之一。

从这种方法来看待它,而无需涉及硬件,操作系统和编译器如何一起工作以进行堆分配,这涉及使用RAM,缓存,页面文件等。这些算法基础上的数学方法向我们展示了这两种方法中哪种更好。

我们可以用比喻“存在” BossSummation表示For Loop必须在工A&之间移动的“存在” B

我们可以很容易地看到,情况2至少比情况1快一半,原因是行进所需的距离和工人之间的时间差。这个数学运算几乎与基准时间和汇编指令中的差异数量几乎完美地吻合。


现在,我将在下面开始解释所有这些工作方式。


评估问题

OP的代码:

const int n=100000;

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

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

考虑

考虑到OP最初关于for循环的2个变体的问题,以及他对缓存行为的修正问题,以及许多其他出色的答案和有用的注释;我想通过对这种情况和问题采取不同的方法来尝试做一些不同的事情。


该方法

考虑到这两个循环以及有关缓存和页面归档的所有讨论,我想采用另一种方法从不同的角度来看待这一点。一种不涉及缓存和页面文件,也不涉及执行分配内存的方法,实际上,这种方法甚至根本不涉及实际的硬件或软件。


观点

在看了一段时间的代码之后,很明显的是问题出在哪里,问题是由什么产生的。让我们将其分解为一个算法问题,并从使用数学符号的角度来看待它,然后将类推应用于数学问题以及算法。


我们所知道的

我们知道,此循环将运行100,000次。我们也知道a1b1c1d1在64位架构的指针。在32位计算机上的C ++中,所有指针均为4个字节,而在64位计算机上,它们的大小均为8个字节,因为指针的长度是固定的。

我们知道在两种情况下都需要分配32个字节。唯一的区别是我们在每次迭代中分配32个字节或2组2-8字节,其中第二种情况是,对于两个独立循环,我们为每次迭代分配16个字节。

两个循环的总分配仍然等于32个字节。现在,借助这些信息,我们继续展示这些概念的一般数学,算法和类比。

我们确实知道在两种情况下必须执行同一组或同一组操作的次数。我们确实知道在两种情况下都需要分配的内存量。我们可以估计,这两种情况之间分配的总工作量将大致相同。


我们不知道的

除非我们设置计数器并运行基准测试,否则我们不知道每种情况需要花费多长时间。但是,基准已经包含在原始问题以及一些答案和评论中;我们可以看到两者之间存在显着差异,这就是针对此问题的提案的全部理由。


让我们调查一下

很明显,许多人已经通过查看堆分配,基准测试,查看RAM,缓存和页面文件来做到这一点。还包括查看特定的数据点和特定的迭代索引,关于此特定问题的各种讨论使许多人开始质疑与此有关的其他问题。我们如何开始使用数学算法并对其进行类推来研究这个问题?我们先做几个断言!然后,我们从那里构建算法。


我们的断言:

  • 我们将循环及其迭代设为从1开始到100000结束的求和,而不是像在循环中那样从0开始,因为我们不必担心内存寻址的0索引方案,因为我们只关心算法本身。
  • 在这两种情况下,我们都有4个函数可以使用,有2个函数调用,每个函数调用都需要进行2个操作。我们将设置这些函数和函数调用,如下:F1()F2()f(a)f(b)f(c)f(d)

算法:

第一种情况: -只有一个求和,但有两个独立的函数调用。

Sum n=1 : [1,100000] = F1(), F2();
                       F1() = { f(a) = f(a) + f(b); }
                       F2() = { f(c) = f(c) + f(d); }

第二种情况: -两个求和,但每个求和都有自己的函数调用。

Sum1 n=1 : [1,100000] = F1();
                        F1() = { f(a) = f(a) + f(b); }

Sum2 n=1 : [1,100000] = F1();
                        F1() = { f(c) = f(c) + f(d); }

如果您发现F2()只存在于SumCase1那里F1()被包含在SumCase1和两个Sum1Sum2Case2。稍后我们开始得出结论,第二种算法中正在发生优化时,这一点将显而易见。

通过第一个case Sum调用进行的迭代f(a)将添加到自身中,f(b)然后通过调用f(c)进行相同的操作,但f(d)每次100000迭代都会添加到自身中。在第二种情况下,我们具有Sum1Sum2,它们的行为相同,就好像它们是同一函数连续被调用两次一样。

在这种情况下,我们可以将Sum1Sum2视为老式SumSum在这种情况下,它看起来像这样:Sum n=1 : [1,100000] { f(a) = f(a) + f(b); }现在看起来像一个优化,我们可以认为它是相同的函数。


类比总结

在第二种情况下,由于两个for循环具有相同的确切签名,因此几乎看起来好像是在进行优化,但这不是真正的问题。这个问题是不是正在做的工作f(a)f(b)f(c),和f(d)。在这两种情况下以及两者之间的比较中,求和必须行进的距离的不同使执行时间有所不同。

的认为For Loops作为是Summations,做迭代作为一个Boss被发号施令两个人AB与他们的工作是肉CD分别拿起从他们一些包并返回它。以此类推,for循环或求和迭代以及条件检查本身实际上并不表示Boss。真正代表的Boss不是直接来自实际的数学算法,而是来自例程或子例程,方法,函数,翻译单元等的实际概念,ScopeCode Block在例程或子例程,方法,函数,转换单元等内部。在第一种算法中,有一个作用域,在第二种算法中有两个连续的作用域。

在每个调用清单的第一种情况下,Boss转到A并给出订单,然后A去取B's包,然后Boss转到C并给出订单以执行相同的操作并从D每次迭代中接收包。

在第二种情况下,Boss直接使用Ago来获取B's软件包,直到收到所有软件包为止。然后,Boss与之C进行相同的工作以获取所有D's软件包。

由于我们正在使用8字节指针并处理堆分配,因此我们考虑以下问题。假设距离Boss为100英尺,A而距离A则为500英尺C。由于执行的顺序,我们不必担心Boss最初的距离C。在这两种情况下,Boss最初的旅行都是从最初到A然后B。这个比喻并不是说这个距离是准确的。这只是一个有用的测试用例场景,它展示了算法的工作原理。

在许多情况下,当进行堆分配以及使用缓存和页面文件时,地址位置之间的距离可能相差不大,或者取决于数据类型和数组大小的性质而有很大不同。


测试案例:

第一种情况:在第一次迭代Boss中,首先必须走100英尺才能给订单滑行,A然后A去做他的事情,但是随后Boss必须走500英尺C才能给他滑行。然后在下一次迭代和之后的其他每一次迭代Boss之间必须在两者之间来回500英尺。

第二种情况:Boss有行驶100英尺的第一次迭代A,但在那之后,他已经在那里,只是等待A取回,直到所有的票据被填满。然后,Boss必须在第一次迭代中移动500英尺,C因为C距500英尺A。由于这Boss( Summation, For Loop )是在与A他一起工作之后立即被调用的,所以他就象在那里一样在那儿等待,A直到所有的C's订单都完成了。


行驶距离的差异

const n = 100000
distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500); 
// Simplify
distTraveledOfFirst = 600 + (99999*100);
distTraveledOfFirst = 600 + 9999900;
distTraveledOfFirst =  10000500;
// Distance Traveled On First Algorithm = 10,000,500ft

distTraveledOfSecond = 100 + 500 = 600;
// Distance Traveled On Second Algorithm = 600ft;    

任意值的比较

我们可以很容易地看到600远远少于1000万。现在,这是不准确的,因为我们不知道每次迭代中每次调用的哪个RAM地址或哪个Cache或Page File距离之间的实际距离差异将归因于许多其他看不见的变量。这只是对要了解的情况的评估,并从最坏的情况来看。

从这些数字看来,似乎算法一应该99%慢于算法二;然而,这只是Boss's部分或算法的责任,它不占实际工作者ABC,和D他们必须在每回路的每个迭代做什么。因此,老板的工作仅占完成工作总数的15-40%。通过工人完成的大部分工作对于将速度差异率保持在大约50-70%的影响要稍大一些


观察结果: - 两种算法之间的差异

在这种情况下,它就是工作流程的结构。结果表明,案例2从具有类似函数声明和定义的部分优化中更有效,其中仅变量因名称和行进距离而异。

我们还看到,案例1中行进的总距离比案例2中行进的远得多,我们可以考虑此行进距离是两种算法之间的时间因子案例1案例2有更多的工作要做。

从这ASM两种情况下显示的说明的证据可以观察到这一点。除了已经说明过的这些情况之外,这还不能说明在情况1中老板必须等待两者AC回来,然后才能A为每次迭代再次返回。它还没有考虑以下事实:如果AB花费的时间很长,则Boss工人和其他工人都闲着等待执行。

情况2中,唯一的一个闲置的人是Boss直到工人回来为止。因此,即使这也会对算法产生影响。



OP修订的问题

编辑:问题被证明是无关紧要的,因为行为严重取决于数组(n)的大小和CPU缓存。因此,如果有进一步的兴趣,我重新提出一个问题:

您能否对导致不同缓存行为的细节提供深入的了解,如下图的五个区域所示?

通过为这些CPU提供类似的图形来指出CPU /缓存体系结构之间的差异也可能很有趣。


关于这些问题

正如我毫无疑问地证明的那样,甚至在涉及硬件和软件之前就存在一个潜在的问题。

现在,关于内存和缓存以及页面文件等的管理,它们在以下之间的一组集成系统中一起工作:

  • The Architecture {硬件,固件,某些嵌入式驱动程序,内核和ASM指令集}。
  • The OS{文件和内存管理系统,驱动程序和注册表}。
  • The Compiler {翻译单元和源代码的优化}。
  • 甚至Source Code本身也具有独特的算法集。

我们已经可以看到有是第一种算法中发生的事情之前,我们甚至可以与任意适用于任何机器的瓶颈ArchitectureOS以及Programmable Language相较于第二算法。在涉及现代计算机的本质之前,已经存在一个问题。


最终结果

然而; 并不是说这些新问题并不重要,因为它们本身是重要的,而且它们毕竟会发挥作用。它们确实会影响程序和整体性能,这在许多给出答案和/或评论的图表和评估中很明显。

如果您关注的类比Boss和两名工人AB谁过的去,并从中检索包CD分别与考虑问题的两种算法的数学符号; 您可以看到,在不涉及计算机硬件和软件的Case 2情况下,60%速度大约比Case 1

在将这些算法应用于某些源代码,通过OS进行编译,优化和执行以在给定硬件上执行其操作之后,查看图形和图表时,您甚至会发现差异之间的差异会有所减少在这些算法中。

如果Data集合很小,乍一看似乎并没有那么糟糕。然而,由于Case 1大约60 - 70%慢于Case 2我们可以看一下这个功能的的时间执行的不同方面的增长:

DeltaTimeDifference approximately = Loop1(time) - Loop2(time)
//where 
Loop1(time) = Loop2(time) + (Loop2(time)*[0.6,0.7]) // approximately
// So when we substitute this back into the difference equation we end up with 
DeltaTimeDifference approximately = (Loop2(time) + (Loop2(time)*[0.6,0.7])) - Loop2(time)
// And finally we can simplify this to
DeltaTimeDifference approximately = [0.6,0.7]*Loop2(time)

这个近似值是算法和机器操作(包括软件优化和机器指令)在这两个循环之间的平均差。

当数据集线性增长时,两者之间的时间差也会增加。算法1的获取次数比算法2的获取次数更多,这在以下情况下很明显:算法Boss必须在第一次迭代之后往返于AC每次迭代之间的最大距离,而算法2则Boss必须运行A一次,然后在完成之后必须进行一次A迁移从A到最大距离只能一次C

试图Boss一次集中精力做两个相似的事情并来回摆弄而不是专注于连续的相似任务,到一天结束时会让他非常生气,因为他不得不旅行和工作两次。因此,不要因为老板的配偶和子女不喜欢老板而让老板陷入内插的瓶颈,而不会失去局面。



修正案:软件工程设计原则

- 迭代for循环内和之间的差异Local Stack以及Heap Allocated计算的用法,效率和有效性之间的差异-

我上面提出的数学算法主要适用于对堆上分配的数据执行操作的循环。

  • 连续堆栈操作:
    • 如果循环在堆栈框架内的单个代码块或范围内对本地数据执行操作,则仍会套用,但是内存位置更接近,它们通常是顺序的,并且行进距离或执行时间有所不同几乎可以忽略不计。由于堆中没有完成分配,因此不会分散内存,也不会通过ram来获取内存。存储器通常是顺序的,并且相对于堆栈帧和堆栈指针。
    • 当在堆栈上执行连续操作时,现代处理器将缓存重复的值和地址,并将这些值保存在本地缓存寄存器中。这里的操作或指令时间约为纳秒级。
  • 连续堆分配操作:
    • 当您开始应用堆分配并且处理器必须在连续的调用中获取内存地址时,具体取决于CPU,总线控制器和Ram模块的体系结构,操作或执行时间可能在微指令到微指令之间。毫秒。与缓存堆栈操作相比,这些操作速度很慢。
    • CPU必须从Ram中获取内存地址,并且与CPU内部的内部数据路径或数据总线相比,整个系统总线上的任何东西通常都比较慢。

因此,当您处理需要存储在堆中的数据并在循环中遍历它们时,将每个数据集及其对应的算法保留在自己的单个循环中会更有效。与通过将堆上不同数据集的多个操作放入单个循环中来尝试排除连续循环相比,您将获得更好的优化。

可以使用堆栈中的数据进行此操作,因为它们经常被高速缓存,但对于必须在每次迭代中查询其内存地址的数据则不需要这样做。

这就是软件工程和软件体系结构设计发挥作用的地方。它具有知道如何组织数据,何时缓存数据,何时在堆上分配数据,知道如何设计和实现算法以及知道何时何地调用它们的能力。

您可能具有与同一数据集有关的相同算法,但是您可能想要一个实现设计用于其堆栈变体,而另一个实现设计用于其堆分配变体,仅仅是因为从O(n)工作时算法的复杂性可以看出上述问题与堆。

从我多年来注意到的情况来看,许多人没有考虑到这一事实。他们将倾向于设计一种适用于特定数据集的算法,并且无论数据集在本地缓存在堆栈上还是在堆上分配,它们都将使用该算法。

如果您想要真正的优化,是的,看起来好像是代码重复,但是概括起来,拥有同一算法的两个变体会更有效。一个用于堆栈操作,另一个用于在迭代循环中执行的堆操作!

这是一个伪示例:两个简单的结构,一个算法。

struct A {
    int data;
    A() : data{0}{}
    A(int a) : data{a}{} 
};
struct B {
    int data;
    B() : data{0}{}
    A(int b) : data{b}{}
}                

template<typename T>
void Foo( T& t ) {
    // do something with t
}

// some looping operation: first stack then heap.

// stack data:
A dataSetA[10] = {};
B dataSetB[10] = {};

// For stack operations this is okay and efficient
for (int i = 0; i < 10; i++ ) {
   Foo(dataSetA[i]);
   Foo(dataSetB[i]);
}

// If the above two were on the heap then performing
// the same algorithm to both within the same loop
// will create that bottleneck
A* dataSetA = new [] A();
B* dataSetB = new [] B();
for ( int i = 0; i < 10; i++ ) {
    Foo(dataSetA[i]); // dataSetA is on the heap here
    Foo(dataSetB[i]); // dataSetB is on the heap here
} // this will be inefficient.

// To improve the efficiency above, put them into separate loops... 

for (int i = 0; i < 10; i++ ) {
    Foo(dataSetA[i]);
}
for (int i = 0; i < 10; i++ ) {
    Foo(dataSetB[i]);
}
// This will be much more efficient than above.
// The code isn't perfect syntax, it's only psuedo code
// to illustrate a point.

这就是我通过对堆栈变体和堆变体使用单独的实现所指的。算法本身并不重要,这是您将在其中使用的循环结构。


自发布此答案已有一段时间以来,但我还想添加一条简短的评论,这也可能有助于理解这一点:与Boss的for循环或循环的求和或迭代类似,我们也可以可以将此老板视为管理范围和堆栈变量以及for循环的内存寻址的堆栈框架和堆栈指针之间的组合。
弗朗西斯·库格勒

@PeterMortensen我已通过稍微修改我的原始答案来考虑您的建议。我相信这就是您的建议。
弗朗西斯·库格勒18'Nov

2

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

一圈:1.577毫秒

两个循环:1.507毫秒

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.