关于C++:为什么在独立循环中元素的添加比组合循环快得多?

Why are elementwise additions much faster in separate loops than in a combined loop?

我们a1b1c1,和d1点到广东的记忆和我的数值代码有下面的核环。 </P >

1
2
3
4
5
6
const int n = 100000;

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

这个环是executed 10000时报通过另一个for外环。它的速度上,改变了我的代码: </P >

1
2
3
4
5
6
7
for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

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

编译的多发性硬化的Visual C + +和10.0全优化和SSE2 ''enabled for是一个32位英特尔酷睿2核心(64),第一个实例把5.5和nbsp;秒和双回路以把只读1.9和nbsp;秒。我的问题是:(请参阅我的提问的问题的? </P >

PS:我是不担心,如果这helps: </P >

disassembly的第一环,基本上就像这样(这是五块重复约时报》在全程序): </P >

1
2
3
4
5
6
7
8
9
10
11
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]

每个环的双回路以产生的这段代码(下面的是重复上面的三块:《纽约时报》) </P >

1
2
3
4
5
6
7
8
9
10
11
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

该问题被淘汰是不relevance,为行为severely depends的大小之翼(N)和CPU的缓存。所以,如果有进一步的兴趣,我rephrase的问题: </P >

你可以提供一些洞察入固的细节,那铅对不同的高速缓存的行为作为插图由五个地区是下面的图吗? </P >

它也可能是有趣的点出的差异之间的CPU /缓存architectures,用类似的方法提供一个图,这些CPU。 </P >

PPS:这里是全码。它使用的Tick_CountTBB高分辨率为正时,这可以通过定义的残疾人不TBB_TIMING宏观调控: </P >

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
#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;
    }
}

(它的交往中触发器/ s的方法的不同的值n。) </P >

enter image description here </P >


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

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

这意味着每个循环中的所有访问都将以相同的缓存方式进行。然而,英特尔处理器有一段时间具有8路L1缓存关联性。但实际上,演出并不完全一致。访问4路仍然比访问2路慢。

编辑:事实上,它看起来像是单独分配所有数组。通常,当请求如此大的分配时,分配程序将从操作系统请求新的页面。因此,大量分配很可能出现在与页面边界相同的偏移量处。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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;
}

基准结果:

编辑:实际核心2体系结构计算机上的结果:

2 x Intel Xeon X5482 [email protected] GHz:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#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:

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

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


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

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

@神秘主义的答案使很多人(包括我)信服,可能是因为它是唯一一个似乎依赖事实的答案,但它只是真理的一个"数据点"。

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

下面的图表显示,根据所使用的具体场景和参数,大多数答案,尤其是对问题和答案的大多数评论可能被认为是完全错误或真实的。

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

  • 它在单环版本和双环版本之间具有最大的差异(几乎是三倍)

  • 这是唯一的一点,一个循环(即连续分配)胜过两个循环版本。(这使得神秘主义的答案成为可能。)

  • 使用初始化数据的结果:

    Enter image description here

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

    Enter image description here

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

    Enter image description here

    提议

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


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


    假设您正在一台机器上工作,其中n只是正确的值,因为它只可能一次在内存中保存两个阵列,但通过磁盘缓存,可用的总内存仍然足以容纳全部四个阵列。

    假设有一个简单的后进先出缓存策略,此代码:

    1
    2
    3
    4
    5
    6
    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中并在上面操作。

    另一个循环

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

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

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

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

    比如说n = 2,我们使用的是字节。在我的场景中,我们只有4个字节的RAM,其余的内存速度明显较慢(比如说访问时间延长了100倍)。

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

    • 1
      2
      3
      4
      5
      6
      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

    • 1
      2
      3
      4
      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]设置到缓存中。成本=100+100。
    • 我怀疑你开始明白我要去哪里了。
    • 总成本=(100 + 100 + 100 + 100) * 2 = 800

    这是一个经典的高速缓存重击场景。


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

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

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


    我不能复制这里讨论的结果。

    我不知道是否应该怪糟糕的基准代码,或者是什么,但是在我的机器上,这两种方法使用以下代码的比例都在10%以内,而且一个循环通常比两个稍微快一点——正如您所期望的那样。

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

    我玩了各种各样的方案,比如把b[j]d[j]分配给循环中的InitToZero[j],也玩了+= b[j] = 1+= d[j] = 1的分配,结果相当一致。

    如您所料,使用InitToZero[j]在循环内部初始化bd给了组合方法一个优势,因为它们是在分配给ac之前背靠背完成的,但仍在10%以内。算了吧。

    硬件是Dell XPS 8500,具有第3代核心[email protected] GHz和8 GB内存。对于2^16到2^24,使用八个循环,累计时间分别为44.987和40.965。Visual C++ 2010,完全优化。

    PS:我把循环数改为倒数为零,合并后的方法稍微快一点。抓我的头。注意新的数组大小和循环计数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    // 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("
     %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("
     %6i miliseconds for separate array sizes %i and %i loops
    "
    ,
                    (int)(clock()-start), ArraySz, LoopKnt);
        }
        printf("
     Cumulative combined array processing took %10.3f seconds"
    ,
                (dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
        printf("
     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秒时几乎相同。


    这是因为CPU没有那么多的缓存未命中(它必须等待来自RAM芯片的阵列数据)。对您来说,连续地调整数组的大小是很有趣的,这样您就可以超过CPU的1级缓存(l1)和2级缓存(l2)的大小,并绘制代码根据数组大小执行所需的时间。图不应该像你想象的那样是直线。


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

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


    最初的问题好的。

    Why is one loop so much slower than two loops?

    Ok.

    结论:好的。

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

    从这种方法看它,而不涉及硬件、操作系统和编译器如何一起工作堆堆分配,包括处理RAM、缓存、页文件等等;在这些算法的基础上的数学告诉我们这两者中哪一个是更好的解决方案。我们可以使用一个类比,其中一个BossSummation,它将表示一个For Loop,它必须在工人之间移动A&B,我们可以很容易地看到,情况2至少是1/2,如果不是比情况1快一点,因为需要移动的距离和所用的时间不同工人之间。这一数学几乎与基准时间以及装配说明中的差异量完全一致。好的。

    下面我将开始解释所有这些是如何工作的。好的。

    评估问题好的。

    OP的代码:好的。

    1
    2
    3
    4
    5
    6
    const int n=100000;

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

    和好的。

    1
    2
    3
    4
    5
    6
    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个变体的原始问题,以及他对缓存行为的修正问题,以及许多其他优秀的答案和有用的评论;我想尝试通过对这种情况和问题采取不同的方法来做一些不同的事情。好的。

    途径好的。

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

    透视好的。

    在看了一会儿代码之后,问题是什么以及产生问题的原因变得非常明显。让我们把它分解成一个算法问题,从使用数学符号的角度来看,然后对数学问题和算法进行类比。好的。

    我们所知道的好的。

    我们知道他的循环将运行100000次。我们还知道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)

    算法:好的。

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

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

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

    1
    2
    3
    4
    5
    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()只存在于Sum中,其中Sum1Sum2都只包含F1()。当我们开始得出第二种算法正在进行某种优化的结论时,这一点在以后也会很明显。好的。

    通过第一种情况的迭代,Sum调用f(a),它将添加到自己的f(b),然后调用f(c),它将做同样的事情,但为每个100000 iterations添加f(d)。在第二种情况下,我们有Sum1Sum2两个函数的作用相同,就像它们是连续两次被调用的同一个函数一样。在这种情况下,我们可以将Sum1Sum2视为普通的老Sum,在这种情况下,Sum看起来是这样的: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直接与A合作,去取B's包,直到收到所有包为止。然后,BossC合作,以获得所有D's包。好的。

    既然我们正在处理一个8字节指针和堆分配,那么让我们在这里考虑这个问题。假设BossA100英尺,AC500英尺。我们不需要担心由于执行命令,Boss最初与C的距离有多远。在这两种情况下,Boss最初从A开始,然后到B。这个类比并不是说这个距离是精确的;它只是一个使用测试用例场景来显示算法的工作情况。在许多情况下,当执行堆分配和处理缓存和页面文件时,地址位置之间的距离可能不会有太大的差异,或者根据数据类型的性质和数组大小,它们可能会非常明显。好的。

    测试用例:好的。

    第一种情况:在第一次迭代中,Boss必须先走100英尺,才能给A下订单,A走了,做了他的事情,但是Boss必须走500英尺到C给他下订单。然后,在下一次迭代和在Boss之后的每一次迭代中,必须在两次迭代之间来回移动500英尺。好的。

    第二种情况:The Boss必须在第一次迭代到A时移动100英尺,但之后他已经在那里,等待A返回,直到所有的滑动都被填满。然后,由于C距离A500英尺,因此Boss在第一次迭代时必须移动500英尺,因为C在与A一起工作后立即调用该Boss( Summation, For Loop ),然后像对待A一样等待,直到完成所有C's订单滑动。好的。

    行驶距离的差异好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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地址,哪个缓存或页面文件之间的实际距离差异,每个调用都是由许多其他未看到的变量造成的,但这只是对需要注意的情况的一个评估,并试图从最坏的情况来看。好的。

    因此,从这些数字来看,算法1的速度应该比算法2慢99%;然而,这只是算法的The Boss's部分或职责,它不能解释实际工作人员ABC、和D,以及他们在每次迭代中必须做什么。循环的所以老板的工作只占总工作的15-40%。因此,通过工人完成的大部分工作对将速度差比率保持在50-70%左右有着更大的影响。好的。

    观察:两种算法的区别好的。

    在这种情况下,它是正在进行的工作的过程的结构,并且它确实表明,情况2比具有类似函数声明和定义的部分优化更有效,因为只有名称不同的变量。我们还可以看到,案例1中的总行驶距离远大于案例2中的总行驶距离,我们可以考虑这两种算法之间的时间系数。案例1比案例2有更多的工作要做。这一点也在两起案件之间显示的ASM的证据中得到了证实。即使已经对这些案例进行了说明,也不能说明在案例1中,老板必须等待AC返回,然后才能在下一次迭代中再次返回A,也不能说明如果AB正在进行一次外部测试。很长一段时间之后,Boss和其他工人也在等待空闲。在情况2中,唯一空闲的是Boss,直到工人回来。所以即使这样也会对算法产生影响。好的。

    行动组修改了问题好的。

    EDIT: The question turned out to be of no relevance, as the behavior severely depends on the sizes of the arrays (n) and the CPU cache. So if there is further interest, I rephrase the question:

    Ok.

    好的。

    Could you provide some solid insight into the details that lead to the different cache behaviors as illustrated by the five regions on the following graph?

    Ok.

    好的。

    It might also be interesting to point out the differences between CPU/cache architectures, by providing a similar graph for these CPUs.

    Ok.

    关于这些问题好的。

    正如我毫无疑问地证明的那样,即使在涉及到硬件和软件之前,也存在一个潜在的问题。现在关于内存和缓存以及页面文件等的管理,它们都是在一组集成的系统中工作的:The Architecture硬件、固件、一些嵌入式驱动程序、内核和asm指令集、The OS文件和内存管理系统、驱动程序和注册表、The Compiler翻译单元以及源代码的优化,甚至是Source Code本身及其一组独特的算法;我们已经看到,在将第一个算法应用于任何具有任意ArchitectureOSProgrammable Language的机器之前,第一个算法中发生了瓶颈。因此,在涉及到现代计算机的内部原理之前,已经存在一个问题。好的。

    最后的结果好的。

    然而,并不是说这些新问题并不重要,因为它们本身就是,而且它们毕竟扮演了一个角色。它们确实会影响程序和整体性能,这一点从许多给出答案和/或评论的人的各种图表和评估中可以明显看出。如果你注意到Boss和两个工人AB的类比,他们必须分别从CD取包,并且考虑到所讨论的两个算法的数学符号,你可以看到,即使没有计算机The OS的参与,你也可以看到这一点。12]比Case 1快大约60%,当您在将这些算法应用于源代码、通过操作系统编译和优化并执行以在给定硬件上执行操作后查看图表时,您甚至会发现这些算法之间的差异有点退化。好的。

    现在,如果"数据"集相当小,那么一开始看起来差异并不是那么糟糕,但是由于Case 1大约比Case 2慢,我们可以将此函数的增长视为时间执行的差异:好的。

    1
    2
    3
    4
    5
    6
    7
    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之间的最大距离时,算法1的取数就比算法2多了一次,然后在完成A之后,Boss必须来回移动A。从AC时,只需行驶一次最大距离。好的。

    因此,让Boss集中精力同时做两件相似的事情,来回地摆弄它们,而不是集中于相似的连续任务,这会使他在一天结束时非常生气,因为他必须旅行和工作两倍。因此,不要因为老板的配偶和孩子不喜欢这样做而让你的老板陷入一个被插入的瓶颈,从而失去这种情况的范围。好的。好啊。


    它可以是旧C++和优化。在我的电脑上,我获得了几乎相同的速度:

    单回路:1.577 ms

    双回路:1.507 ms

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