问题
很长一段时间我的印象是使用嵌套的std::vector<std::vector...>
来模拟N维数组通常很糟糕,因为内存不保证是连续的,并且可能有缓存未命中。我认为使用平面矢量并将多维度的地图映射到1D更好,反之亦然。所以,我决定测试它(最后列出的代码)。这非常简单,我定时读取/写入嵌套的3D矢量与我自己的一维矢量3D包装器。我使用g++
和clang++
编译了代码,并启用了-O3
优化。对于每次运行,我都改变了尺寸,因此我可以很好地了解这种行为。令我惊讶的是,这些是我在我的机器MacBook Pro(Retina,13英寸,2012年末),2.5GHz i5,8GB RAM,OS X 10.10.5上获得的结果:
g ++ 5.2
dimensions nested flat
X Y Z (ms) (ms)
100 100 100 -> 16 24
150 150 150 -> 58 98
200 200 200 -> 136 308
250 250 250 -> 264 746
300 300 300 -> 440 1537
clang ++(LLVM 7.0.0)
dimensions nested flat
X Y Z (ms) (ms)
100 100 100 -> 16 18
150 150 150 -> 53 61
200 200 200 -> 135 137
250 250 250 -> 255 271
300 300 300 -> 423 477
正如您所看到的,&#34;展平&#34;包装器永远不会破坏嵌套版本。此外,与libc ++实现相比,g ++的libstdc ++实现执行得非常糟糕,例如对于300 x 300 x 300
,flatten版本几乎比嵌套版本慢4倍。 libc ++似乎具有相同的性能。
我的问题:
我使用的代码:
#include <chrono>
#include <cstddef>
#include <iostream>
#include <memory>
#include <random>
#include <vector>
// Thin wrapper around flatten vector
template<typename T>
class Array3D
{
std::size_t _X, _Y, _Z;
std::vector<T> _vec;
public:
Array3D(std::size_t X, std::size_t Y, std::size_t Z):
_X(X), _Y(Y), _Z(Z), _vec(_X * _Y * _Z) {}
T& operator()(std::size_t x, std::size_t y, std::size_t z)
{
return _vec[z * (_X * _Y) + y * _X + x];
}
const T& operator()(std::size_t x, std::size_t y, std::size_t z) const
{
return _vec[z * (_X * _Y) + y * _X + x];
}
};
int main(int argc, char** argv)
{
std::random_device rd{};
std::mt19937 rng{rd()};
std::uniform_real_distribution<double> urd(-1, 1);
const std::size_t X = std::stol(argv[1]);
const std::size_t Y = std::stol(argv[2]);
const std::size_t Z = std::stol(argv[3]);
// Standard library nested vector
std::vector<std::vector<std::vector<double>>>
vec3D(X, std::vector<std::vector<double>>(Y, std::vector<double>(Z)));
// 3D wrapper around a 1D flat vector
Array3D<double> vec1D(X, Y, Z);
// TIMING nested vectors
std::cout << "Timing nested vectors...\n";
auto start = std::chrono::steady_clock::now();
volatile double tmp1 = 0;
for (std::size_t x = 0 ; x < X; ++x)
{
for (std::size_t y = 0 ; y < Y; ++y)
{
for (std::size_t z = 0 ; z < Z; ++z)
{
vec3D[x][y][z] = urd(rng);
tmp1 += vec3D[x][y][z];
}
}
}
std::cout << "\tSum: " << tmp1 << std::endl; // we make sure the loops are not optimized out
auto end = std::chrono::steady_clock::now();
std::cout << "Took: ";
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << ms << " milliseconds\n";
// TIMING flatten vector
std::cout << "Timing flatten vector...\n";
start = std::chrono::steady_clock::now();
volatile double tmp2 = 0;
for (std::size_t x = 0 ; x < X; ++x)
{
for (std::size_t y = 0 ; y < Y; ++y)
{
for (std::size_t z = 0 ; z < Z; ++z)
{
vec1D(x, y, z) = urd(rng);
tmp2 += vec1D(x, y, z);
}
}
}
std::cout << "\tSum: " << tmp2 << std::endl; // we make sure the loops are not optimized out
end = std::chrono::steady_clock::now();
std::cout << "Took: ";
ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << ms << " milliseconds\n";
}
修改
将Array3D<T>::operator()
返回更改为
return _vec[(x * _Y + y) * _Z + z];
根据@1201ProgramAlarm's suggestion确实摆脱了'#34;怪异的&#34; g ++的行为,从平面和嵌套版本现在大致相同的时间。然而,它仍然有趣。我认为由于缓存问题,嵌套的会更糟糕。 我能幸运并且连续分配所有记忆吗?
答案 0 :(得分:15)
为什么在修复索引顺序后,嵌套向量与微基准测试中的平面速度大致相同:您希望平面阵列更快(请参阅Tobias's answer about potential locality problems ,和my other answer为什么嵌套向量一般很糟糕,但顺序访问并不太糟糕。但是你的具体测试是做了很多事情,让乱序执行隐藏了使用嵌套向量的开销,和/或只是减慢了速度以至于额外的开销在测量噪声中丢失了。
我将内部循环的性能错误修复源代码up on Godbolt so we can look at the asm放在g ++ 5.2编译的-O3
中。 (Apple的铿锵声可能类似于clang3.7,但我只看一下gcc版本。)C ++函数中有很多代码,但是你可以右键单击一个源代码行,用于将asm窗口滚动到该行的代码。另外,将鼠标悬停在源行以粗体化实现该行的asm,反之亦然。
gcc的嵌套版本的内部两个循环如下(手动添加一些注释):
## outer-most loop not shown
.L213: ## middle loop (over `y`)
test rbp, rbp # Z
je .L127 # inner loop runs zero times if Z==0
mov rax, QWORD PTR [rsp+80] # MEM[(struct vector * *)&vec3D], MEM[(struct vector * *)&vec3D]
xor r15d, r15d # z = 0
mov rax, QWORD PTR [rax+r12] # MEM[(struct vector * *)_195], MEM[(struct vector * *)_195]
mov rdx, QWORD PTR [rax+rbx] # D.103857, MEM[(double * *)_38]
## Top of inner-most loop.
.L128:
lea rdi, [rsp+5328] # tmp511, ## function arg: pointer to the RNG object, which is a local on the stack.
lea r14, [rdx+r15*8] # D.103851, ## r14 = &(vec3D[x][y][z])
call double std::generate_canonical<double, 53ul, std::mersenne_twister_engine<unsigned long, 32ul, 624ul, 397ul, 31ul, 2567483615ul, 11ul, 4294967295ul, 7ul, 2636928640ul, 15ul, 4022730752ul, 18ul, 1812433253ul> >(std::mersenne_twister_engine<unsigned long, 32ul, 624ul, 397ul, 31ul, 2567483615ul, 11ul, 4294967295ul, 7ul, 2636928640ul, 15ul, 4022730752ul, 18ul, 1812433253ul>&) #
addsd xmm0, xmm0 # D.103853, D.103853 ## return val *= 2.0: [0.0, 2.0]
mov rdx, QWORD PTR [rsp+80] # MEM[(struct vector * *)&vec3D], MEM[(struct vector * *)&vec3D] ## redo the pointer-chasing from vec3D.data()
mov rdx, QWORD PTR [rdx+r12] # MEM[(struct vector * *)_150], MEM[(struct vector * *)_150]
subsd xmm0, QWORD PTR .LC6[rip] # D.103859, ## and subtract 1.0: [-1.0, 1.0]
mov rdx, QWORD PTR [rdx+rbx] # D.103857, MEM[(double * *)_27]
movsd QWORD PTR [r14], xmm0 # *_155, D.103859 # store into vec3D[x][y][z]
movsd xmm0, QWORD PTR [rsp+64] # D.103853, tmp1 # reload volatile tmp1
addsd xmm0, QWORD PTR [rdx+r15*8] # D.103853, *_62 # add the value just stored into the array (r14 = rdx+r15*8 because nothing else modifies the pointers in the outer vectors)
add r15, 1 # z,
cmp rbp, r15 # Z, z
movsd QWORD PTR [rsp+64], xmm0 # tmp1, D.103853 # spill tmp1
jne .L128 #,
#End of inner-most loop
.L127: ## middle-loop
add r13, 1 # y,
add rbx, 24 # sizeof(std::vector<> == 24) == the size of 3 pointers.
cmp QWORD PTR [rsp+8], r13 # %sfp, y
jne .L213 #,
## outer loop not shown.
对于扁平循环:
## outer not shown.
.L214:
test rbp, rbp # Z
je .L135 #,
mov rax, QWORD PTR [rsp+280] # D.103849, vec1D._Y
mov rdi, QWORD PTR [rsp+288] # D.103849, vec1D._Z
xor r15d, r15d # z
mov rsi, QWORD PTR [rsp+296] # D.103857, MEM[(double * *)&vec1D + 24B]
.L136: ## inner-most loop
imul rax, r12 # D.103849, x
lea rax, [rax+rbx] # D.103849,
imul rax, rdi # D.103849, D.103849
lea rdi, [rsp+5328] # tmp520,
add rax, r15 # D.103849, z
lea r14, [rsi+rax*8] # D.103851, # &vec1D(x,y,z)
call double std::generate_canonical<double, 53ul, std::mersenne_twister_engine<unsigned long, 32ul, 624ul, 397ul, 31ul, 2567483615ul, 11ul, 4294967295ul, 7ul, 2636928640ul, 15ul, 4022730752ul, 18ul, 1812433253ul> >(std::mersenne_twister_engine<unsigned long, 32ul, 624ul, 397ul, 31ul, 2567483615ul, 11ul, 4294967295ul, 7ul, 2636928640ul, 15ul, 4022730752ul, 18ul, 1812433253ul>&) #
mov rax, QWORD PTR [rsp+280] # D.103849, vec1D._Y
addsd xmm0, xmm0 # D.103853, D.103853
mov rdi, QWORD PTR [rsp+288] # D.103849, vec1D._Z
mov rsi, QWORD PTR [rsp+296] # D.103857, MEM[(double * *)&vec1D + 24B]
mov rdx, rax # D.103849, D.103849
imul rdx, r12 # D.103849, x # redo address calculation a 2nd time per iteration
subsd xmm0, QWORD PTR .LC6[rip] # D.103859,
add rdx, rbx # D.103849, y
imul rdx, rdi # D.103849, D.103849
movsd QWORD PTR [r14], xmm0 # MEM[(double &)_181], D.103859 # store into the address calculated earlier
movsd xmm0, QWORD PTR [rsp+72] # D.103853, tmp2
add rdx, r15 # tmp374, z
add r15, 1 # z,
addsd xmm0, QWORD PTR [rsi+rdx*8] # D.103853, MEM[(double &)_170] # tmp2 += vec1D(x,y,z). rsi+rdx*8 == r14, so this is a reload of the store this iteration.
cmp rbp, r15 # Z, z
movsd QWORD PTR [rsp+72], xmm0 # tmp2, D.103853
jne .L136 #,
.L135: ## middle loop: increment y
add rbx, 1 # y,
cmp r13, rbx # Y, y
jne .L214 #,
## outer loop not shown.
您的MacBook Pro(2012年末)有一个Intel IvyBridge CPU,所以我使用来自Agner Fog's instruction tables and microarch guide的微架构的数字。其他Intel / AMD CPU上的情况大致相同。
唯一的2.5GHz移动IvB i5是i5-3210M,因此您的CPU具有3MiB的L3缓存。这意味着即使您最小的测试用例(每double
〜= 7.63MiB为100 ^ 3 * 8B)也比最后一级高速缓存大,因此您的测试用例根本不适合高速缓存。这可能是一件好事,因为在测试其中任何一个之前,你都会分配并默认初始化嵌套和平面。但是,您按照分配的顺序进行测试,因此如果嵌套数组在将平面数组归零后仍然是高速缓存,则在嵌套数组上的定时循环之后,平面数组在L3高速缓存中可能仍然很热。
如果您使用重复循环多次循环同一个数组,则可能有足够大的时间来测量较小的数组大小。
你在这里做了几件非常奇怪的事情并且使得这么慢,以至于无序执行可以隐藏更改y
的额外延迟,即使你的内部z
}向量不是完全连续的。
您在定时循环内运行缓慢的PRNG。 std::uniform_real_distribution<double> urd(-1, 1);
是std::mt19937 rng{rd()};
之上的额外开销,与FP-add延迟相比已经很慢(3个周期),或与每个周期2个L1D缓存负载吞吐量相比较。运行PRNG所有这些额外的时间使得无序执行有机会运行数组索引指令,因此在数据发布时最终地址就绪。 除非你有很多的缓存未命中,否则你主要只是测量PRNG速度,因为它产生的结果比每个时钟周期的速度慢得多。
g ++ 5.2并不完全内联urd(rng)
代码,x86-64 System V调用约定没有调用保留的XMM寄存器。因此,tmp1
/ tmp2
必须为每个元素进行溢出/重新加载,即使它们不是volatile
。
它也失去了在Z向量中的位置,并且在访问下一个z
元素之前必须重做外部2个间接层。这是因为它不知道它的调用函数的内部结构,并假设它可能有一个指向外部vector<>
内存的指针。 (平面版本在内部循环中进行两次索引,而不是简单的指针添加。)
clang(使用libc ++)完全内联PRNG,因此移动到下一个z
只需要add reg, 8
来增加平面和嵌套版本中的指针。您可以通过在内部循环外部获取迭代器或获取对内部向量的引用来获取gcc中的相同行为,而不是重做operator[]
并希望编译器将为您提升它。
英特尔/ AMD FP添加/子/ mul吞吐量/延迟与数据无关,除了非正规。 (x87 also slows down for NaN and maybe infinity,但SSE没有.64位代码甚至对标量float
/ double
使用SSE。)所以你可以用零初始化你的数组,或者用PRNG超越了时序循环。 (或者将它们归零,因为vector<double>
构造函数会为您执行此操作,并且实际上需要额外的代码才能将而不是添加到您将要编写其他内容的情况下。)分区和sqrt性能依赖于某些CPU,并且比add / sub / mul慢得多。
在中读取它之前,在内循环内编写每个元素。在源代码中,这看起来像存储/重新加载。不幸的是,gcc实际上做了什么,但是用libc ++(内联PRNG)来改变循环体:
// original
vec3D[x][y][z] = urd(rng);
tmp1 += vec3D[x][y][z];
// what clang's asm really does
double xmm7 = urd(rng);
vec3D[x][y][z] = xmm7;
tmp1 += xmm7;
在clang的asm:
# do { ...
addsd xmm7, xmm4 # last instruction of the PRNG
movsd qword ptr [r8], xmm7 # store it into the Z vector
addsd xmm7, qword ptr [rsp + 88]
add r8, 8 # pointer-increment to walk along the Z vector
dec r13 # i--
movsd qword ptr [rsp + 88], xmm7
jne .LBB0_74 # }while(i != 0);
允许这样做是因为vec3D
不是volatile
或atomic<>
,所以对于任何其他线程来说,写入此内存将是未定义的行为同时。这意味着它可以优化存储/重新加载到内存中的对象到一个商店(并简单地使用它存储的值,而无需重新加载)。或者完全优化商店,如果它可以证明它是一个死的商店(没有任何东西可以阅读的商店,例如未使用的static
变量)。
在gcc的版本中,它会在PRNG调用之前为商店建立索引,并在之后重新加载索引。所以我认为gcc不确定函数调用是否修改指针,因为指向外部向量的指针已经转义了函数。 (并且PRNG没有内联)。
然而,即使是asm中的实际存储/重新加载,对缓存未命中的敏感度仍然低于简单加载!
即使商店在缓存中未命中,Store-&gt;加载转发仍然有效。因此,Z向量中的高速缓存未命中并不直接延迟关键路径。如果无序执行无法隐藏缓存未命中的延迟,它只会减慢速度。 (一旦数据被写入存储缓冲区,商店就会退出(之前的所有指令都已退役)。我不确定加载是否可以在缓存行进入L1D之前退出,如果它可以通过存储转发获得数据。因为x86允许StoreLoad重新排序(允许存储在加载后变为全局可见)。在这种情况下,存储/重新加载只会为PRNG增加6个周期的延迟结果(关闭从一个PRNG状态到下一个PRNG状态的关键路径)。如果缓存丢失太多以至于存储缓冲区填满并阻止新的存储uop执行,那么它只是吞吐量瓶颈。当预订站或ROB填满未执行或未退役(分别)uops时,最终会阻止新的uops发出无序核心。
使用反向索引(平面代码的原始版本),可能主要的瓶颈是分散的商店。 IDK为什么clang比那里的gcc好得多。也许clang设法反转一个循环,然后按顺序遍历内存。 (因为它完全内联了PRNG,所以没有函数调用需要内存状态来匹配程序顺序。)
按顺序遍历每个Z向量意味着高速缓存未命中相对较远(即使每个Z向量与前一个不相邻),也为存储执行提供了大量时间。或者即使存储转发的负载实际上不能实际退出,直到L1D缓存实际拥有缓存行(处于MESI协议的修改状态),推测执行具有正确的数据并且不必等待缓存未命中的延迟。无序指令窗口可能大到足以使关键路径可能在负载退出之前停止。 (缓存未命中负载通常非常糟糕,因为相关指令无法在没有数据的情况下执行。因此它们更容易在管道中创建气泡。由于DRAM的完全缓存缺失具有延迟超过300个周期,并且IvB上的无序窗口为168微秒,它无法隐藏每个时钟甚至1个uop(大约1个指令)执行的代码的所有延迟。)对于纯存储,无序窗口超出了ROB大小,因为他们不需要承诺L1D退休。事实上,他们无法提交,直到他们退休之后,因为他们知道他们是非投机性的。 (因此,尽早使它们在全球范围内可见,可以防止在检测到异常或错误推测时回滚。)
我的桌面上没有安装libc++
,因此我无法针对g ++对该版本进行基准测试。使用g ++ 5.4,我发现嵌套:225毫秒和平坦:239毫秒。我怀疑额外的数组索引乘法是一个问题,并与PRNG使用的ALU指令竞争。相比之下,重写在L1D缓存中的一堆指针追逐的嵌套版本可以并行发生。我的桌面是4.4GHz的Skylake i7-6700k。 SKL的ROB(ReOrder缓冲区)大小为224 uops,RS为97 uops,so the out-of-order window is very large。它还具有4个周期的FP添加延迟(与以前的搜索不同,它是3个)。
volatile double tmp1 = 0;
您的累加器为volatile
,这会强制编译器在内循环的每次迭代中存储/重新加载它。循环的总延迟 - 内循环中携带的依赖链是9个循环:addsd
为3,商店从movsd
到movsd
重新加载的存储转发为6。 (clang将重新加载到addsd xmm7, qword ptr [rsp + 88]
的内存操作数中,但差别相同。([rsp+88]
在堆栈中,如果需要从寄存器溢出,则存储带有自动存储的变量。)< / p>
如上所述,gcc的非内联函数调用也会强制x86-64 System V调用约定中的溢出/重新加载(除了Windows之外的所有内容都使用)。但是,智能编译器可以完成4个PRNG调用,然后是4个数组存储。 (如果你使用迭代器来确保gcc知道持有其他向量的向量不会改变。)
使用-ffast-math
会让编译器自动矢量化(如果不是PRNG和volatile
)。这样可以让你快速地遍历数组,使得不同Z向量之间缺少局部性可能是一个真正的问题。它还可以让编译器展开多个累加器,以隐藏FP添加延迟。例如他们可以(和clang会)使asm相当于:
float t0=0, t1=0, t2=0, t3=0;
for () {
t0 += a[i + 0];
t1 += a[i + 1];
t2 += a[i + 2];
t3 += a[i + 3];
}
t0 = (t0 + t1) + (t2 + t3);
它有4个独立的依赖链,因此可以保留4个FP添加。由于IvB有3个周期延迟,addsd
每个时钟吞吐量一个,我们只需要保持4个飞行时间来满足其吞吐量。 (Skylake有4c延迟,每时钟吞吐量2,与mul或FMA相同,所以你需要8个累加器以避免延迟瓶颈。实际上,even more is better。问题的提问者测试显示,Haswell甚至做得更好当接近最大化负载吞吐量时,会有更多的累加器。)
这样的事情会更好地测试循环Array3D的效率。 如果您希望完全停止循环优化,只需使用结果。测试您的微基准测试,以确保增加问题大小缩短时间;如果没有那么一些东西得到优化,或者你没有测试你认为你正在测试的东西。不要使内循环临时volatile
!!
编写微基准测试并不容易。你必须足够理解写一个测试你认为你正在测试的东西。 :P这是一个很容易出错的好例子。
我可以幸运并且连续分配所有内存吗?
是的,这可能发生在按顺序完成的许多小额分配中,当您在执行此操作之前尚未分配和释放任何内容时。如果它们足够大(通常是一个4kiB页面或更大),glibc malloc
将切换到使用mmap(MAP_ANONYMOUS)
,然后内核将选择随机化的虚拟地址(ASLR)。因此,对于较大的Z,您可能会期望局部性变得更糟。但另一方面,较大的Z向量意味着您花费更多的时间在一个连续的向量上循环,因此在更改y
(和x
)时缓存未命中变得相对不那么重要。
依次对你的数据进行循环,显然没有公开这个,因为额外的指针访问在缓存中命中,因此指针追逐具有足够低的延迟,以便OOO执行以用慢速循环隐藏它。
Prefetch非常容易保持在这里。
不同的编译器/库可以对这个奇怪的测试产生很大的影响。在我的系统(Arch Linux,i7-6700k Skylake,4.4GHz max turbo)上,对于g ++ 5.4 -O3,300 300 300
的4次运行中最好的是:
Timing nested vectors...
Sum: 579.78
Took: 225 milliseconds
Timing flatten vector...
Sum: 579.78
Took: 239 milliseconds
Performance counter stats for './array3D-gcc54 300 300 300':
532.066374 task-clock (msec) # 1.000 CPUs utilized
2 context-switches # 0.004 K/sec
0 cpu-migrations # 0.000 K/sec
54,523 page-faults # 0.102 M/sec
2,330,334,633 cycles # 4.380 GHz
7,162,855,480 instructions # 3.07 insn per cycle
632,509,527 branches # 1188.779 M/sec
756,486 branch-misses # 0.12% of all branches
0.532233632 seconds time elapsed
VS。 g ++ 7.1 -O3(显然决定分支g ++ 5.4没有的东西)
Timing nested vectors...
Sum: 932.159
Took: 363 milliseconds
Timing flatten vector...
Sum: 932.159
Took: 378 milliseconds
Performance counter stats for './array3D-gcc71 300 300 300':
810.911200 task-clock (msec) # 1.000 CPUs utilized
0 context-switches # 0.000 K/sec
0 cpu-migrations # 0.000 K/sec
54,523 page-faults # 0.067 M/sec
3,546,467,563 cycles # 4.373 GHz
7,107,511,057 instructions # 2.00 insn per cycle
794,124,850 branches # 979.299 M/sec
55,074,134 branch-misses # 6.94% of all branches
0.811067686 seconds time elapsed
VS。 clang4.0 -O3(使用gcc&lt; libstdc ++,而不是libc ++)
perf stat ./array3D-clang40-libstdc++ 300 300 300
Timing nested vectors...
Sum: -349.786
Took: 1657 milliseconds
Timing flatten vector...
Sum: -349.786
Took: 1631 milliseconds
Performance counter stats for './array3D-clang40-libstdc++ 300 300 300':
3358.297093 task-clock (msec) # 1.000 CPUs utilized
9 context-switches # 0.003 K/sec
0 cpu-migrations # 0.000 K/sec
54,521 page-faults # 0.016 M/sec
14,679,919,916 cycles # 4.371 GHz
12,917,363,173 instructions # 0.88 insn per cycle
1,658,618,144 branches # 493.887 M/sec
916,195 branch-misses # 0.06% of all branches
3.358518335 seconds time elapsed
我没有深入了解clang做错了什么,或尝试使用-ffast-math
和/或-march=native
。 (除非你删除volatile
,否则那些不会做太多的事情。)
perf stat -d
并没有为gng显示更多缓存未命中(L1或最后一级)。但它确实表明clang的负载量是L1D的两倍多。
我尝试使用非方阵。几乎完全相同的时间保持总元素数相同,但将最终尺寸更改为5或6。
即使是对C的微小改动也会有所帮助,并且会使&#34;变得扁平化。比使用gcc嵌套更快(从240ms到300ms用于300 ^ 3,但对于嵌套几乎没有任何区别。):
// vec1D(x, y, z) = urd(rng);
double res = urd(rng);
vec1D(x, y, z) = res; // indexing calculation only done once, after the function call
tmp2 += vec1D(x, y, z);
// using iterators would still avoid redoing it at all.
答案 1 :(得分:9)
这是因为您在3D类中订购索引的方式。由于您的最内层循环正在改变z,这是索引的最大部分,因此您会获得大量缓存未命中。将索引重新排列为
_vec[(x * _Y + y) * _Z + z]
你应该看到更好的表现。
答案 2 :(得分:4)
阅读其他答案我对答案的准确性和详细程度并不满意,所以我会自己尝试解释:
这里的男人问题是不是间接,而是空间位置的问题:
基本上有两件事使缓存特别有效:
时间位置,这意味着最近访问过的内存字很可能会在不久的将来再次被访问。例如,这可能发生在频繁访问的二叉搜索树的根目录附近的节点上。
空间位置,这意味着如果访问了一个记忆词,很可能很快就会访问该词之前或之后的记忆词。对于嵌套的和展平数组,在我们的例子中会发生这种情况。
要评估间接和缓存效果可能对此问题产生的影响,我们假设我们有X = Y = Z = 1024
从this question判断,单个缓存行(L1,L2或L3)长度为64个字节,这意味着8个双精度值。我们假设L1缓存有32 kB(4096双),L2缓存有256 kB(32k双精度),L3缓存有8 MB(1M双精度)。
这意味着 - 假设缓存中没有其他数据(这是一个大胆的猜测,我知道) - 在扁平的情况下,只有y
的每4个值导致L1缓存未命中(L2缓存延迟可能大约为10-20个周期),只有y
的每32个值导致L2缓存未命中(L3缓存延迟低于100个周期)并且仅在L3缓存未命中的情况下实际上必须访问主内存。我不想在这里打开整个计算,因为考虑整个缓存层次结构会让它变得更加困难,但是我们只是说几乎所有对内存的访问都可以缓存在扁平化的情况下。
在这个问题的原始表述中,扁平索引的计算方式不同(z * (_X * _Y) + y * _X + x
),最内层循环(z)中变化的值的增加总是意味着_X * _Y * 64 bit
的跳跃,从而导致更多的非本地内存布局,这大大增加了缓存故障。
在嵌套的情况下,答案很大程度上取决于Z的值:
vector<vector<vector>>>
,连续布局。只有当y或x值增加时,我们才需要实际使用间接来检索下一个最内层向量的开始指针。由于有关于汇编输出的问题,请允许我简要介绍一下:
如果你比较嵌套和扁平数组的assembly output,你会发现很多相似之处:有三个等效的嵌套循环,计数变量x,y和z存储在寄存器中。唯一真正的区别 - 除了嵌套版本为每个外部索引使用两个计数器以避免在每个地址计算中乘以24这一事实,并且扁平版本对最内层循环执行相同操作并乘以8 - 可以在 y 循环中找到,而不是仅仅递增y并计算展平索引,我们需要做三个相互依赖的内存加载来确定内循环的基指针:
mov rax, QWORD PTR [rdi]
mov rax, QWORD PTR [rax+r11]
mov rax, QWORD PTR [rax+r10]
但是因为这些只发生在每个 Zth 时间,并且“中间矢量”的指针很可能被缓存,所以时间差可以忽略不计。
答案 3 :(得分:2)
(这并没有真正回答这个问题。我认为我最初是向后看的,假设OP刚刚发现了我所期待的,嵌套向量比平缓慢。)
除了顺序访问之外,您应该期望嵌套矢量版本的速度更慢。在修复了平面版本的行/列主索引顺序之后,对于许多用途来说它应该更快,特别是因为编译器使用大型平面阵列上的SIMD自动向量化比使用多个短版本{{{ 1}}。
缓存行仅为64B。那是8 std::vector<>
。由于TLB条目有限,页面级别的位置很重要,并且预取需要顺序访问,但是无论如何(使用大多数malloc实现一次性分配的嵌套向量,您将获得足够接近)。 (这是一个微不足道的微基准测试,在分配它的double
之前没有做任何事情。在一个真正的程序中,在进行大量的小分配之前分配和释放一些内存,其中一些可能分散在更多。)
除了地方性,额外的间接水平可能存在问题。
指向std :: vector的引用/指针只指向包含当前大小,已分配空间和指向缓冲区的指针的固定大小的块。 IDK,如果任何实现将缓冲区放在控制数据之后,作为同一malloced块的一部分,但可能这是不可能的,因为vector
必须是常量,所以你可以有一个向量向量。 Check out the asm on godbolt:只返回sizeof(std::vector<int>)
的函数需要一个带有数组arg的加载,但是带有std :: vector arg的两个加载。
在嵌套向量实现中,加载v[10]
需要4个步骤(假设指针或对v[x][y][z]
的引用已经在寄存器中)。
v
或v.buffer_pointer
或实现所称的任何内容。 (指向v.bp
)数组的指针std::vector<std::vector<double>>
(指向v.bp[x].bp
数组的指针)std::vector<double>
(指向v.bp[x].bp[y].bp
数组的指针)double
(我们想要的v.bp[x].bp[y].bp[z]
)使用单个double
模拟的正确3D阵列就是:
std::vector
(指向v.bp
数组的指针)double
(我们想要的v.bp[(x*w + y)*h + z]
)对具有不同x和y的相同模拟3D阵列的多次访问需要计算新索引,但double
将保留在寄存器中。 因此,我们只获得一个。
遍历3D数组以隐藏嵌套向量实现的代价,因为在最内层向量中的所有值上的循环隐藏了更改x和y的开销。外部向量中的连续指针的预取在这里有所帮助,并且v.bp
在您的测试中足够小,循环在一个最内部的向量上,不会驱逐下一个Z
值的指针。
What Every Programmer Should Know About Memory有点过时,但它涵盖了缓存和地点的细节。软件预取并不像P4那样重要,所以不要过多关注指南的这一部分。
答案 4 :(得分:2)
May I just be lucky and have all the memory contiguously allocated?
Probably yes. I've modified your sample a little bit, so we have a benchmark which concentrates more on the differences between the two approaches:
tmp1
was printed instead of tmp2
)#if 1
part, which can be used to randomize vec3D
placement in memory. As it turned out, it has a huge difference on my machine.Without randomization (I've used parameters: 300 300 300):
Timing nested vectors...
Sum: -131835
Took: 2122 milliseconds
Timing flatten vector...
Sum: -131835
Took: 2085 milliseconds
So there is a little difference, flatten version is a little bit faster. (I've run several tests, and put the minimal time here).
With randomization:
Timing nested vectors...
Sum: -117685
Took: 3014 milliseconds
Timing flatten vector...
Sum: -117685
Took: 2085 milliseconds
So the cache effect can be seen here: nested version is ~50% slower. This is because CPU cannot predict which memory area will be used, so its cache prefetcher is not efficient.
Here's the modified code:
#include <chrono>
#include <cstddef>
#include <iostream>
#include <memory>
#include <random>
#include <vector>
template<typename T>
class Array3D
{
std::size_t _X, _Y, _Z;
std::vector<T> _vec;
public:
Array3D(std::size_t X, std::size_t Y, std::size_t Z):
_X(X), _Y(Y), _Z(Z), _vec(_X * _Y * _Z) {}
T& operator()(std::size_t x, std::size_t y, std::size_t z)
{
return _vec[(x * _Y + y) * _Z + z];
}
const T& operator()(std::size_t x, std::size_t y, std::size_t z) const
{
return _vec[(x * _Y + y) * _Z + z];
}
};
double nested(std::vector<std::vector<std::vector<double>>> &vec3D, std::size_t X, std::size_t Y, std::size_t Z) {
double tmp1 = 0;
for (int iter=0; iter<100; iter++)
for (std::size_t x = 0 ; x < X; ++x)
{
for (std::size_t y = 0 ; y < Y; ++y)
{
for (std::size_t z = 0 ; z < Z; ++z)
{
tmp1 += vec3D[x][y][z];
}
}
}
return tmp1;
}
double flatten(Array3D<double> &vec1D, std::size_t X, std::size_t Y, std::size_t Z) {
double tmp2 = 0;
for (int iter=0; iter<100; iter++)
for (std::size_t x = 0 ; x < X; ++x)
{
for (std::size_t y = 0 ; y < Y; ++y)
{
for (std::size_t z = 0 ; z < Z; ++z)
{
tmp2 += vec1D(x, y, z);
}
}
}
return tmp2;
}
int main(int argc, char** argv)
{
std::random_device rd{};
std::mt19937 rng{rd()};
std::uniform_real_distribution<double> urd(-1, 1);
const std::size_t X = std::stol(argv[1]);
const std::size_t Y = std::stol(argv[2]);
const std::size_t Z = std::stol(argv[3]);
std::vector<std::vector<std::vector<double>>>
vec3D(X, std::vector<std::vector<double>>(Y, std::vector<double>(Z)));
#if 1
for (std::size_t i = 0 ; i < X*Y; i++)
{
std::size_t xa = rand()%X;
std::size_t ya = rand()%Y;
std::size_t xb = rand()%X;
std::size_t yb = rand()%Y;
std::swap(vec3D[xa][ya], vec3D[xb][yb]);
}
#endif
// 3D wrapper around a 1D flat vector
Array3D<double> vec1D(X, Y, Z);
for (std::size_t x = 0 ; x < X; ++x)
{
for (std::size_t y = 0 ; y < Y; ++y)
{
for (std::size_t z = 0 ; z < Z; ++z)
{
vec3D[x][y][z] = vec1D(x, y, z) = urd(rng);
}
}
}
std::cout << "Timing nested vectors...\n";
auto start = std::chrono::steady_clock::now();
double tmp1 = nested(vec3D, X, Y, Z);
auto end = std::chrono::steady_clock::now();
std::cout << "\tSum: " << tmp1 << std::endl; // we make sure the loops are not optimized out
std::cout << "Took: ";
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << ms << " milliseconds\n";
std::cout << "Timing flatten vector...\n";
start = std::chrono::steady_clock::now();
double tmp2 = flatten(vec1D, X, Y, Z);
end = std::chrono::steady_clock::now();
std::cout << "\tSum: " << tmp2 << std::endl; // we make sure the loops are not optimized out
std::cout << "Took: ";
ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << ms << " milliseconds\n";
}