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

假设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 VisualC++10.0上编译,完全优化,在英特尔酷睿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],xmm0movsd       xmm0,mmword ptr [esi+10h]addsd       xmm0,mmword ptr [eax+30h]movsd       mmword ptr [eax+30h],xmm0movsd       xmm0,mmword ptr [edx+20h]addsd       xmm0,mmword ptr [ecx+28h]movsd       mmword ptr [ecx+28h],xmm0movsd       xmm0,mmword ptr [esi+18h]addsd       xmm0,mmword ptr [eax+38h]

双循环示例的每个循环都会生成以下代码(以下块重复大约三次):

addsd       xmm0,mmword ptr [eax+28h]movsd       mmword ptr [eax+28h],xmm0movsd       xmm0,mmword ptr [ecx+20h]addsd       xmm0,mmword ptr [eax+30h]movsd       mmword ptr [eax+30h],xmm0movsd       xmm0,mmword ptr [ecx+28h]addsd       xmm0,mmword ptr [eax+38h]movsd       mmword ptr [eax+38h],xmm0movsd       xmm0,mmword ptr [ecx+30h]addsd       xmm0,mmword ptr [eax+40h]movsd       mmword ptr [eax+40h],xmm0

这个问题被证明是无关紧要的,因为行为严重依赖于数组(n)和CPU缓存的大小。因此,如果有进一步的兴趣,我重新表述这个问题:

  • 您能否提供一些可靠的见解,以了解导致不同缓存行为的细节,如下图所示的五个区域?

  • 通过为这些CPU提供类似的图表,指出CPU/缓存架构之间的差异可能也很有趣。

这是完整的代码。它使用TBBTick_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_memoryallo(cont,n);#endif
#ifdef TBB_TIMINGtick_count t0 = tick_count::now();#elseclock_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_TIMINGtick_count t1 = tick_count::now();ret = 2.0*double(n)*double(m)/(t1-t0).seconds();#elseclock_t end = clock();ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);#endif    
#ifndef preallocate_memoryff(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_memorycout << s << i << "_loops_" << na[preallocate_memory];#elsecout << s << i << "_loops_" << na[j];#endif            
cout << endl;
long long nmax = 1000000;
#ifdef preallocate_memoryallo(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。

性能图表

254089 次浏览

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

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

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

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

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

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

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

这意味着每个循环中的所有访问都将落在相同的缓存方式上。然而,英特尔处理器已经有一段时间了8路L1缓存关联性。但实际上,性能并不完全统一。访问4路仍然比访问2路慢。

编辑:实际上看起来确实像您正在单独分配所有数组。通常,当请求如此大的分配时,分配器将从操作系统请求新页面。因此,大分配很有可能出现在页面边界的同一偏移处。

下面是测试代码:

int main(){const int n = 100000;
#ifdef ALLOCATE_SEPERATEdouble *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));#elsedouble *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 addressescout << a1 << endl;cout << b1 << endl;cout << c1 << endl;cout << d1 << endl;
clock_t start = clock();
int c = 0;while (c++ < 10000){
#if ONE_LOOPfor(int j=0;j<n;j++){a1[j] += b1[j];c1[j] += d1[j];}#elsefor(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_LOOP00600020006D0020007A002000870020seconds = 6.206
#define ALLOCATE_SEPERATE//#define ONE_LOOP005E0020006B00200078002000850020seconds = 2.116
//#define ALLOCATE_SEPERATE#define ONE_LOOP0057002000633520006F6A20007B9F20seconds = 1.894
//#define ALLOCATE_SEPERATE//#define ONE_LOOP008C00200098352000A46A2000B09F20seconds = 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的,但这个想法应该仍然适用于Core 2。)

区域3:

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

区域4:

单回路的性能下降是我们所观察到的。如前所述,这是由于对齐(很可能)导致处理器加载/存储单元中的混淆现象停顿。

但是,为了发生错误混淆现象,数据集之间必须有足够大的步幅。这就是为什么在区域3中看不到这一点。

区域5:

此时,缓存中没有任何内容。所以您受内存带宽的约束。


2 x Intel X5482 Harpertown@3.2 GHzIntel Core i7 870 @ 2.8 GHz英特尔酷睿i72600K@4.4 GHz

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

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

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

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

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

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

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

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

使用初始化数据的结果:

在此处输入图像描述

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

在此处输入图像描述

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

在此处输入图像描述

提案

Stack Overflow上的每个低级性能相关问题都应该被要求提供整个缓存相关数据大小范围的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];}

首先将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倍的访问时间)。

假设一个相当愚蠢的缓存策略为如果字节不在缓存中,把它放在那里,当我们在它的时候也得到下面的字节,你会得到一个类似这样的场景:

  • for(int j=0;j<n;j++){a[j] += b[j];}for(int j=0;j<n;j++){c[j] += d[j];}
  • cache a[0] and a[1] then b[0] and b[1] and set a[0] = a[0] + b[0] in cache - there are now four bytes in cache, a[0], a[1] and b[0], b[1]. Cost = 100 + 100.

  • set a[1] = a[1] + b[1] in cache. Cost = 1 + 1.
  • Repeat for c and d.
  • Total cost = (100 + 100 + 1 + 1) * 2 = 404

  • With

    for(int j=0;j<n;j++){a[j] += b[j];c[j] += d[j];}
  • cache a[0] and a[1] then b[0] and b[1] and set a[0] = a[0] + b[0] in cache - there are now four bytes in cache, a[0], a[1] and b[0], b[1]. Cost = 100 + 100.

  • eject a[0], a[1], b[0], b[1] from cache and cache c[0] and c[1] then d[0] and d[1] and set c[0] = c[0] + d[0] in cache. Cost = 100 + 100.
  • I suspect you are beginning to see where I am going.
  • Total cost = (100 + 100 + 100 + 100) * 2 = 800

This is a classic cache thrash scenario.

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

尝试用笔和纸隔开20厘米写两条20个十字的平行线。尝试完成一行,然后完成另一行,再尝试在每一行交替写一个十字。

我无法复制这里讨论的结果。

我不知道糟糕的基准代码是不是罪魁祸首,或者是什么,但是这两种方法在我的机器上使用以下代码时彼此相差不到10%,并且一个循环通常比两个循环稍微快——正如你所期望的那样。

数组大小从2^16到2^24,使用8个循环。我小心地初始化源数组,因此+=赋值不会要求FPU添加解释为双精度的内存垃圾。

我尝试了各种方案,例如将b[j]d[j]InitToZero[j]的赋值放在循环中,以及使用+= b[j] = 1+= d[j] = 1,我得到了相当一致的结果。

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

硬件是戴尔XPS 8500,第3酷睿i7代@3.4 GHz和8 GB内存。对于2^16到2^24,使用八个循环,累积时间分别为44.987和40.965。视觉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 timefor(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是一个经典的插值问题,碰巧是一个低效的问题。我也认为这是许多机器架构和开发人员最终构建和设计能够执行多线程应用程序以及并行编程的多核系统的主要原因之一。

从这种方法来看,不涉及硬件、操作系统和编译器如何协同工作来进行涉及RAM、缓存、页面文件等的堆分配;这些算法基础的数学告诉我们这两个中哪一个是更好的解决方案。

我们可以使用Boss作为Summation的类比,它将代表必须在工人AB之间旅行的For Loop

我们可以很容易地看到案例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循环的两个变体的原始问题以及他对缓存行为的修正问题以及许多其他优秀的答案和有用的评论;我想尝试通过对这种情况和问题采取不同的方法来做一些不同的事情。


的方法

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


的视角

在看了一段时间的代码后,很明显问题是什么以及是什么产生了它。让我们把它分解成一个算法问题,从使用数学符号的角度来看待它,然后对数学问题和算法进行类比。


我们所知道的

我们知道这个循环将运行100,000次。我们还知道a1b1c1d1是64位架构上的指针。在32位机器上的C++内,所有指针都是4字节,在64位机器上,它们的大小是8字节,因为指针的长度是固定的。

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

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

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


我们不知道的

我们不知道每一宗个案需时多久,除非我们设一个计数器,进行基准测试,但原来的问题,以及一些答复和评论,已经包括了基准,我们可以看到两者之间有很大的分别,这是我们提出这个问题的全部理由。


我们来调查一下

很明显,许多人已经通过查看堆分配、基准测试、查看RAM、缓存和页面文件来做到这一点。查看特定的数据点和特定的迭代索引也包括在内,关于这个特定问题的各种对话让许多人开始质疑它的其他相关事物。我们如何通过使用数学算法并将其类比来看待这个问题?我们首先做出几个断言!然后我们从那里构建我们的算法。


我们的断言:

  • 我们将让我们的循环及其迭代是一个从1开始到100000结束的求和,而不是像循环中那样从0开始,因为我们不需要担心内存寻址的0索引方案,因为我们只对算法本身感兴趣。
  • 在这两种情况下,我们都有四个函数和两个函数调用,每个函数调用都要执行两个操作。我们将这些设置为函数,并将对函数的调用设置为以下内容: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()只存在于Case1中的Sum中,其中F1()包含在Case1中的Sum中,以及Case2中的Sum1Sum2中。当我们开始得出结论,第二个算法中正在发生优化时,这将是显而易见的。

通过第一种情况Sum的迭代调用f(a),它将添加到它的selff(b),然后它调用f(c),它将执行相同的操作,但为每个100000迭代添加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)正在做的工作。在这两种情况下以及两者之间的比较中,正是求和在每种情况下必须行进的距离的差异导致了执行时间的差异。

Boss0想象成Boss1,它进行迭代,就像Boss向两个人AB发出命令,他们的工作是分别处理CD,并从他们那里获取一些包并返回。在这个类比中,Boss2循环或求和迭代和条件检查本身实际上并不代表Boss。实际上代表Boss的不是直接来自实际的数学算法,而是来自例程或子例程、方法、函数、翻译单元等中ScopeCode Block的实际概念。第一个算法有一个范围,第二个算法有两个连续的范围。

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

在第二种情况下,Boss直接与A一起获取B's包,直到收到所有包。然后BossC一起获取所有D's包。

由于我们使用的是8字节指针并处理堆分配,让我们考虑以下问题。假设Boss距离A 100英尺,A距离C 500英尺。由于执行顺序的原因,我们不需要担心Boss最初距离C有多远。在这两种情况下,Boss最初都从A先到B。这个类比并不是说这个距离是精确的;这只是一个有用的测试用例场景来展示算法的工作原理。

在许多情况下,在进行堆分配以及处理缓存和页面文件时,地址位置之间的这些距离可能不会有太大变化,或者它们可能会根据数据类型和数组大小的性质而显着变化。


测试用例:

第一例:在第一次迭代中,Boss最初必须走100英尺才能给A订单,A开始做他的事情,但随后Boss必须走500英尺到C给他订单。然后在下一次迭代和Boss之后的每一次迭代中,两者之间必须来回500英尺。

A1Boss在第一次迭代中必须移动100英尺到达A,但在那之后,他已经在那里了,只是等待A回来,直到所有的工单都填满。然后Boss必须在第一次迭代中移动500英尺到达C,因为C距离A有500英尺。由于这个Boss( Summation, For Loop )是在与A一起工作后立即被调用的,他就像与A一起工作一样在那里等待,直到所有的A0订单都完成。


旅行距离的差异

const n = 100000distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500));// SimplifydistTraveledOfFirst = 600 + (99999*1000);distTraveledOfFirst = 600 + 99999000;distTraveledOfFirst = 99999600// Distance Traveled On First Algorithm = 99,999,600ft
distTraveledOfSecond = 100 + 500 = 600;// Distance Traveled On Second Algorithm = 600ft;

任意值的比较

我们可以很容易地看到600远远小于大约1亿。现在,这不是精确的,因为我们不知道每次迭代中每个调用的RAM地址、缓存或页面文件之间的实际距离差异是由于许多其他看不见的变量造成的。这只是对需要注意的情况的评估,并从最坏的情况来看。

从这些数字来看,似乎算法一应该比算法二99%;然而,这只是算法的Boss's部分或责任,它没有考虑到实际的工人ABCD以及他们在Loop的每次迭代中必须做的事情。因此,老板的工作只占总工作量的15-40%左右。通过工人完成的大部分工作对将速度差异率保持在50-70%左右的影响略大


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

在这种情况下,它是所做工作过程的结构。它表明案例2从具有类似函数声明和定义的部分优化中更有效,其中只有名称和行进距离不同的变量。

我们还看到,在案例1中行进的总距离比在案例2中行进的距离要远得多,我们可以考虑在两个算法之间行进的时间因素距离。

这可以从两种情况下显示的汇编指令的证据中观察到。除了已经关于这些情况的说明之外,这并没有解释在案例1中,老板必须等待AC回来,然后才能在每次迭代中再次返回A。它也没有解释这样一个事实,即如果AB花费了非常长的时间,那么Boss和其他工作人员都空闲等待被执行。

案例2中,只有Boss是空闲的,直到工作进程返回。所以即使这样也会对算法产生影响。



修改后的问题(S)

编辑:这个问题被证明是无关紧要的,因为行为严重依赖于数组(n)和CPU缓存的大小。所以如果有进一步的兴趣,我重新表达这个问题:

您能否对导致不同缓存行为的细节提供一些可靠的见解,如下图所示的五个区域?

通过为这些CPU提供类似的图表,指出CPU/缓存架构之间的差异可能也很有趣。


关于这些问题

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

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

  • 的架构(硬件、固件、一些嵌入式驱动程序、内核和汇编指令集)。
  • 的操作系统(文件和内存管理系统、驱动程序和注册表)。
  • 该编译器(源代码的翻译单元和优化)。
  • 甚至源代码本身及其一组独特的算法。

与第二种算法相比,我们已经可以看到第一种算法中存在瓶颈,甚至在我们将其应用于任何具有任意架构os可编程语言的机器之前就已经存在问题。


最终结果

然而,这并不是说这些新问题不重要,因为它们本身是重要的,它们毕竟发挥了作用。它们确实影响了程序和整体表现,这从许多给出答案和/或评论的人的各种图表和评估中可以看出。

如果你注意到Boss和两个工人AB的类比,他们必须分别从CD中检索包,并考虑这两种算法的数学符号;你可以看到,没有计算机硬件和软件的参与,Case 2Case 1快了大约60%

当您查看这些算法应用于某些源代码,通过操作系统编译,优化和执行以在给定硬件上执行操作后的图表时,您甚至可以看到这些算法之间的差异有更多的退化。

如果Data集合相当小,一开始可能看起来并没有那么糟糕。然而,由于Case 1Case 2慢了大约60 - 70%,我们可以根据时间执行的差异来查看这个函数的增长:

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

这个近似是这两个循环之间的平均差异,无论是算法上的还是涉及软件优化和机器指令的机器操作。

当数据集线性增长时,两者之间的时间差异也是如此。算法1比算法2有更多的提取次数,这在Boss必须在第一次迭代后的每次迭代中来回旅行AC之间的最大距离时很明显,而算法2Boss必须旅行一次到A,然后在完成A后,他必须从AC只旅行一次最大距离。

试图让Boss专注于同时做两件类似的事情,并在它们之间来回杂耍,而不是专注于类似的连续任务,这会让他在一天结束时非常生气,因为他不得不出差和工作两倍。因此,不要让你的老板陷入插值瓶颈,因为老板的配偶和孩子不会欣赏它,从而失去情况的范围。



修改:软件工程设计原理

--本地堆栈之间的区别堆分配在迭代循环中的计算以及它们的用法、效率和有效性之间的差异--

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

  • 连续堆栈操作:
    • 如果循环在堆栈帧内的单个代码块或作用域内本地对数据执行操作,它仍然会应用,但内存位置更接近,它们通常是顺序的,移动距离或执行时间的差异几乎可以忽略不计。由于堆内没有分配,内存不会分散,也不会通过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 efficientfor (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 bottleneckA* dataSetA = new [] A();B* dataSetB = new [] B();for ( int i = 0; i < 10; i++ ) {Foo(dataSetA[i]); // dataSetA is on the heap hereFoo(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 pseudo code// to illustrate a point.

这就是我所指的为堆栈变体与堆变体提供单独的实现。算法本身并不重要,您将在其中使用它们的循环结构。

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

一个循环:1.577 ms

两个循环:1.507 ms

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