提问人:Johannes Gerer 提问时间:12/18/2011 最后编辑:InSyncJohannes Gerer 更新时间:11/20/2023 访问量:258109
为什么单独循环中的元素添加比组合循环中的元素添加要快得多?
Why are elementwise additions much faster in separate loops than in a combined loop?
问:
假设 、 、 和 指向堆内存,我的数字代码具有以下核心循环。a1
b1
c1
d1
const int n = 100000;
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
c1[j] += d1[j];
}
此循环通过另一个外部循环执行 10,000 次。为了加快速度,我将代码更改为:for
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 上编译,在 Intel Core 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],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
答:
第二个循环涉及的缓存活动要少得多,因此处理器更容易跟上内存需求。
这不是因为代码不同,而是因为缓存:RAM 比 CPU 寄存器慢,缓存内存位于 CPU 内部,以避免每次变量更改时写入 RAM。但是缓存并不像RAM那样大,因此,它只映射了其中的一小部分。
第一个代码修改远距离内存地址,在每个循环中交替使用它们,因此需要不断使缓存失效。
第二个代码不会交替:它只是在相邻地址上流动两次。这使得所有作业都在缓存中完成,仅在第二个循环开始后才使其失效。
这是因为 CPU 没有那么多的缓存未命中(它必须等待来自 RAM 芯片的数组数据)。您可以不断调整数组的大小,以便超过 CPU 的 1 级缓存 (L1) 和 2 级缓存 (L2) 的大小,并绘制代码执行与数组大小相符所需的时间。该图不应该像您预期的那样是一条直线。
经过进一步分析,我认为这(至少部分)是由四分球的数据对齐引起的。这将导致一定程度的缓存组/方式冲突。
如果我猜对了你如何分配数组,它们很可能与页面行对齐。
这意味着每个循环中的所有访问都将落在相同的缓存方式上。但是,英特尔处理器具有 8 路 L1 缓存关联性已经有一段时间了。但实际上,性能并不完全一致。访问 4 路仍然比 2 路慢。
编辑:实际上,看起来您正在单独分配所有数组。通常,当请求如此大的分配时,分配器会从操作系统请求新页面。因此,大型分配很有可能出现在页面边界的相同偏移量处。
测试代码如下:
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 在评论中指出的那样,这种对齐很可能会导致加载/存储单元或缓存中的错误混叠。我用谷歌搜索了一下,发现英特尔实际上有一个用于部分地址混叠停滞的硬件计数器:
5 地区 - 解释
区域 1:
这个很容易。数据集非常小,以至于性能主要由循环和分支等开销决定。
区域 2:
在这里,随着数据大小的增加,相对开销量会下降,性能会“饱和”。这里的两个循环速度较慢,因为它的循环和分支开销是其两倍。
我不确定这里到底发生了什么......当 Agner Fog 提到缓存库冲突时,对齐仍然可能发挥作用。(该链接是关于桑迪桥的,但这个想法应该仍然适用于核心 2。
区域 3:
此时,数据不再适合 L1 缓存。因此,性能受到 L1 <-> L2 缓存带宽的限制。
区域 4:
我们观察到的是单环路的性能下降。如前所述,这是由于对齐(最有可能)导致处理器负载/存储单元中的错误混叠停顿。
但是,为了发生错误锯齿,数据集之间必须有足够大的步幅。这就是为什么您在区域 3 中看不到它的原因。
区域 5:
此时,缓存中没有任何内容适合。因此,您受到内存带宽的限制。
评论
好的,正确的答案肯定与CPU缓存有关。但是使用 cache 参数可能非常困难,尤其是在没有数据的情况下。
有很多答案,引发了很多讨论,但让我们面对现实吧:缓存问题可能非常复杂,而且不是一维的。它们很大程度上取决于数据的大小,所以我的问题是不公平的:结果证明它位于缓存图中一个非常有趣的点。
@Mysticial的回答说服了很多人(包括我),可能是因为它是唯一一个似乎依赖于事实的答案,但这只是真相的一个“数据点”。
这就是为什么我将他的测试(使用连续分配与单独分配)和 @James' Answer 的建议结合起来。
下图显示,大多数答案,尤其是对问题和答案的大多数评论,可以被认为是完全错误或正确的,具体取决于所使用的确切场景和参数。
请注意,我最初的问题是在 n = 100.000。这一点(偶然)表现出特殊行为:
它拥有单循环和双循环版本之间的最大差异(几乎是三倍)
这是单循环(即连续分配)击败双循环版本的唯一点。(这使得Mysticial的答案成为可能。
使用初始化数据的结果:
使用未初始化数据的结果(这是 Mysticial 测试的):
这是一个很难解释的问题:初始化数据,分配一次,并重用于以下每个不同向量大小的测试用例:
建议
Stack Overflow 上每个与性能相关的低级问题都应该被要求提供整个缓存相关数据大小范围的 MFLOPS 信息!在没有这些信息的情况下思考答案,尤其是与他人讨论答案,都是浪费每个人的时间。
评论
n
n = 80000, n = 100000, n = 200000
想象一下,您正在一台机器上工作,其中的值恰到好处,只能同时在内存中保存两个阵列,但通过磁盘缓存可用的总内存仍然足以容纳所有四个阵列。n
假设采用简单的后进先出缓存策略,则以下代码:
for(int j=0;j<n;j++){
a[j] += b[j];
}
for(int j=0;j<n;j++){
c[j] += d[j];
}
将首先导致并加载到 RAM 中,然后完全在 RAM 中处理。当第二个循环开始时,然后将从磁盘加载到 RAM 中并对其进行操作。a
b
c
d
另一个循环
for(int j=0;j<n;j++){
a[j] += b[j];
c[j] += d[j];
}
每次循环时都会分页出两个数组并分页其他两个数组。这显然会慢得多。
您可能在测试中没有看到磁盘缓存,但可能会看到其他形式的缓存的副作用。
这里似乎有一点混淆/误解,所以我将尝试用一个例子来阐述一下。
说,我们正在处理字节。因此,在我的场景中,我们只有 4 个字节的 RAM,其余的内存速度要慢得多(比如说访问时间延长 100 倍)。n = 2
假设一个相当愚蠢的缓存策略,如果字节不在缓存中,把它放在那里并获取以下字节,当我们使用它时,你会得到这样的场景:
跟
for(int j=0;j<n;j++){ a[j] += b[j]; } for(int j=0;j<n;j++){ c[j] += d[j]; }
cache 和 then and 并在缓存中设置 - 缓存中现在有四个字节,并且 .成本 = 100 + 100。
a[0]
a[1]
b[0]
b[1]
a[0] = a[0] + b[0]
a[0], a[1]
b[0], b[1]
- 在缓存中设置。成本 = 1 + 1。
a[1] = a[1] + b[1]
- 重复 for 和 。
c
d
总成本 =
(100 + 100 + 1 + 1) * 2 = 404
跟
for(int j=0;j<n;j++){ a[j] += b[j]; c[j] += d[j]; }
cache 和 then and 并在缓存中设置 - 缓存中现在有四个字节,并且 .成本 = 100 + 100。
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) * 2 = 800
这是一个经典的缓存捶打方案。
评论
第一个循环交替写入每个变量。第二个和第三个只做元素大小的小跳跃。
尝试用笔和纸写两条平行线,每行 20 个十字架,相隔 20 厘米。尝试完成一行,然后完成另一行,然后通过在每行交替写一个叉来尝试另一次。
我无法复制这里讨论的结果。
我不知道是糟糕的基准代码造成的,还是什么,但是使用以下代码,这两种方法在我的机器上彼此相距不到 10%,并且一个循环通常比两个循环略快 - 正如您所期望的那样。
数组大小范围从 2^16 到 2^24,使用 8 个循环。我小心翼翼地初始化源数组,因此分配不会要求 FPU 添加解释为双精度的内存垃圾。+=
我尝试了各种方案,例如将 的赋值 放在循环中,以及使用 和 ,我得到了相当一致的结果。b[j]
d[j]
InitToZero[j]
+= b[j] = 1
+= d[j] = 1
正如您所料,初始化和在循环内使用使组合方法具有优势,因为它们是在赋值到 和 之前背靠背完成的,但仍在 10% 以内。去想想。b
d
InitToZero[j]
a
c
硬件是戴尔 XPS 8500,具有第 3 代酷睿 i7 @ 3.4 GHz 和 8 GB 内存。对于 2^16 到 2^24,使用 8 个循环,累积时间分别为 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 是一个经典的插值问题,恰好是一个低效的问题。我还认为,这是许多机器架构和开发人员最终构建和设计能够执行多线程应用程序和并行编程的多核系统的主要原因之一。
从这种方法来看,不涉及硬件、操作系统和编译器如何协同工作以执行涉及处理 RAM、缓存、页面文件等的堆分配;作为这些算法基础的数学向我们展示了这两者中哪一个是更好的解决方案。
我们可以用一个存在来类比,它代表一个必须在工人之间旅行的。Boss
Summation
For Loop
A
B
我们可以很容易地看到,由于旅行所需的距离和工人之间花费的时间不同,情况 2 的速度至少是情况 1 的一半,甚至比情况 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 次。我们也知道 , , & 是 64 位架构上的指针。在 32 位计算机上的 C++ 中,所有指针都是 4 个字节,而在 64 位计算机上,它们的大小为 8 个字节,因为指针的长度是固定的。a1
b1
c1
d1
我们知道,在这两种情况下,我们都有 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); }
如果您注意到仅存在于 from 中,则包含在 from 和 in 中和 from 中。稍后,当我们开始得出结论,即在第二种算法中正在发生优化时,这一点将很明显。F2()
Sum
Case1
F1()
Sum
Case1
Sum1
Sum2
Case2
通过第一个案例调用的迭代将增加其自身,然后它调用将执行相同的操作,但在每次迭代中增加自身。在第二种情况下,我们有 和 两者的行为相同,就好像它们是连续调用两次的相同函数一样。Sum
f(a)
f(b)
f(c)
f(d)
100000
Sum1
Sum2
在这种情况下,我们可以将 and 视为普通的旧函数,在这种情况下看起来像这样: 现在这看起来像是一个优化,我们可以将其视为相同的函数。Sum1
Sum2
Sum
Sum
Sum n=1 : [1,100000] { f(a) = f(a) + f(b); }
类比总结
根据我们在第二种情况下所看到的情况,它似乎似乎存在优化,因为两个 for 循环具有相同的完全相同的签名,但这不是真正的问题。问题不在于 、 、 和 正在完成的工作。在这两种情况下以及两者之间的比较中,在每种情况下求和必须行进的距离的差异会得出执行时间的差异。f(a)
f(b)
f(c)
f(d)
把for
循环看作是进行迭代的总和,就像向两个人下达命令一样,他们的工作是分别吃肉和从他们那里拿起一些包裹并归还。在此类比中,for 循环或求和迭代和条件检查本身实际上并不表示 .实际表示的不是直接来自实际的数学算法,而是来自例程或子例程、方法、函数、翻译单元等的实际概念和内部。第一种算法有一个作用域,而第二种算法有两个连续的作用域。Boss
A
B
C
D
Boss
Boss
Scope
Code Block
在每个呼叫单上的第一种情况下,去并给出命令并去获取包,然后去并给出命令以执行相同的操作并从每次迭代中接收包。Boss
A
A
B's
Boss
C
D
在第二种情况下,直接使用 to go 并获取包,直到收到所有包。然后,与获取所有包执行相同的操作。Boss
A
B's
Boss
C
D's
由于我们使用的是 8 字节指针并处理堆分配,因此让我们考虑以下问题。假设距离 100 英尺,距离 500 英尺。由于执行顺序的原因,我们不需要担心最初离多远。在这两种情况下,初始行进都是从 first 然后到 。这个类比并不是说这个距离是准确的;这只是一个有用的测试用例场景,用于展示算法的工作原理。Boss
A
A
C
Boss
C
Boss
A
B
在许多情况下,在执行堆分配以及使用缓存和页面文件时,地址位置之间的这些距离可能不会有太大变化,或者它们可能会因数据类型的性质和数组大小而有很大差异。
测试用例:
第一种情况:在第一次迭代中,他最初必须走 100 英尺才能给他订单单,然后离开并做他的事情,但随后必须走 500 英尺才能给他订单单。然后在下一次迭代和之后的每隔一次迭代中,必须在两者之间来回 500 英尺。Boss
A
A
Boss
C
Boss
第二种情况:在第一次迭代时必须行进 100 英尺,但在那之后,他已经在那里,只是等待回来,直到所有的纸条都被填满。然后,在第一次迭代中必须行进 500 英尺,因为距离 500 英尺。由于这是在工作后立即调用的,因此他只是像以前一样在那里等待,直到所有订单单都完成。Boss
A
A
Boss
C
C
A
Boss( Summation, For Loop )
A
A
C's
行进距离的差异
const n = 100000
distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500));
// Simplify
distTraveledOfFirst = 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地址或来自哪个缓存或页面文件之间的实际距离差异是由于许多其他看不见的变量造成的。这只是对需要注意的情况的评估,并从最坏的情况来看。
从这些数字来看,算法一似乎应该比算法二慢;然而,这只是算法的一部分或责任,它没有考虑到实际的工人、、、和,以及他们在循环的每一次迭代中必须做什么。因此,老板的工作只占总工作的15-40%左右。通过工人完成的大部分工作对将速度差异的比率保持在 50-70% 左右的影响略大99%
Boss's
A
B
C
D
观察: - 两种算法之间的差异
在这种情况下,它是正在完成的工作过程的结构。它表明,从具有相似函数声明和定义的部分优化来看,情况 2 更有效,其中只有变量的名称和行进距离不同。
我们还看到,在案例 1 中行进的总距离比在案例 2 中要远得多,我们可以将这个行进距离视为两种算法之间的时间因子。案例 1 比案例 2 要做更多的工作要做。
从两种情况下显示的组装说明的证据中可以看出这一点。除了已经说明的这些情况外,这并没有考虑到这样一个事实,即在案例 1 中,老板必须等待两者 & 回来,然后他才能再次返回每次迭代。它也没有考虑到这样一个事实,即如果或正在花费非常长的时间,那么该工作人员和其他工作人员都处于空闲状态,等待执行。A
C
A
A
B
Boss
在案例 2 中,唯一处于空闲状态的是 直到工作人员回来。因此,即使这样也会对算法产生影响。Boss
OP的修正问题
编辑:这个问题被证明是无关紧要的,因为行为严重取决于数组(n)和CPU缓存的大小。因此,如果有进一步的兴趣,我重新表述这个问题:
您能否提供一些关于导致不同缓存行为的细节的可靠见解,如下图中的五个区域所示?
通过为这些 CPU 提供类似的图表来指出 CPU/缓存架构之间的差异也可能很有趣。
关于这些问题
正如我毫无疑问地证明的那样,甚至在涉及硬件和软件之前就存在一个潜在的问题。
现在,关于内存和缓存以及页面文件等的管理,它们都在以下系统之间的一组集成系统中协同工作:
- 架构(硬件、固件、一些嵌入式驱动程序、内核和汇编指令集)。
- 操作系统(文件和内存管理系统、驱动程序和注册表)。
- 编译器(源代码的翻译单元和优化)。
- 甚至源代码本身及其独特的算法集。
我们已经可以看到,与第二种算法相比,在我们将其应用于任何具有任意架构、操作系统和可编程语言的任何机器之前,第一种算法中就已经出现了瓶颈。在涉及现代计算机的内在原理之前,已经存在一个问题。
最终结果
然而;这并不是说这些新问题不重要,因为它们本身很重要,而且它们毕竟确实发挥了作用。它们确实会影响程序和整体性能,这在许多给出答案和/或评论的人的各种图表和评估中很明显。
如果你注意 和 两个工人 & 的类比,他们必须分别去 & 中检索包,并考虑所讨论的两种算法的数学符号;可以看出,在没有计算机硬件和软件参与的情况下,大约比 快。Boss
A
B
C
D
Case 2
60%
Case 1
当您查看这些算法应用于某些源代码、编译、优化和通过操作系统执行以在给定硬件上执行其操作后的图形和图表时,您甚至可以看到这些算法之间的差异会有所下降。
如果集合相当小,乍一看似乎并没有那么糟糕的区别。但是,由于速度慢于我们根据时间执行的差异来查看此函数的增长速度: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 具有更多的提取次数,这在第一次迭代后必须来回移动 & 之间的最大距离时很明显,而算法 2 必须移动到一次,然后在完成后,他必须在从 to 时移动一次最大距离。Boss
A
C
Boss
A
A
A
C
试图专注于同时做两件相似的事情并来回处理它们,而不是专注于相似的连续任务,这会让他在一天结束时非常生气,因为他不得不出差和工作两倍。因此,不要让你的老板陷入插值瓶颈,因为老板的配偶和孩子不会欣赏它而失去情况的范围。Boss
修订:软件工程设计原则
-- 迭代 for 循环中本地 Stack 和堆分配计算之间的差异,以及它们的使用、效率和有效性之间的差异 --
我上面提出的数学算法主要适用于对堆上分配的数据执行操作的循环。
- 连续堆栈操作:
- 如果循环在堆栈帧内的单个代码块或作用域内本地对数据执行操作,它仍然适用,但内存位置更接近它们通常是连续的,并且行进距离或执行时间的差异几乎可以忽略不计。由于堆中没有进行分配,因此内存不会分散,并且内存不会通过 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 pseudo code
// to illustrate a point.
这就是我所指的堆栈变体与堆变体的单独实现。算法本身并不重要,重要的是您将在其中使用它们的循环结构。
评论
a[i+0..7]
int arr[10000]
a[i] += 1
a[i] += b[i]
它可能是旧的 C++ 和优化。在我的计算机上,我获得了几乎相同的速度:
一个环路:1.577 ms
两个环路:1.507 ms
我在具有 16 GB RAM 的 E5-1620 3.5 GHz 处理器上运行 Visual Studio 2015。
为了使此代码快速运行,CPU 需要执行缓存预取。基本上,CPU会知道您正在访问顺序数据,并在实际需要数据之前从RAM中读取数据。
双循环有两个输入流和两个输出流,因此需要四个单独的预取操作才能快速运行。第二个循环只需要两个单独的预取操作。如果在可以自动预取两行(而不是四行)缓存行的 CPU 上运行此代码,则第一个版本会更慢。
在改进的 CPU 上,问题将消失。在这种情况下,您可以更改代码以将三个数组添加到第四个数组中,并且可能更好的 CPU 可以预取 4 个流,但不能预取 8 个流,并且会显示完全相同的效果。
评论
restrict
d1[j]
a1[j]