问题
长期以来,我的印象是,使用嵌套的std::vector<std::vector...>
模拟N维数组通常是不好的,因为不能保证内存是连续的,并且可能会出现高速缓存未命中的情况。我认为最好使用平面 vector 并从多个维度映射到1D,反之亦然。因此,我决定对其进行测试(代码在末尾列出)。这非常简单,我定时对嵌套3D vector 与我自己的1D vector 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
如您所见,“扁平化”包装器从未击败嵌套版本。而且,与libc++实现相比,g++的libstdc++实现的性能非常差,例如,对于
300 x 300 x 300
,扁平版本的速度比嵌套版本慢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而言,确实确实摆脱了g++的“怪异”行为,从某种意义上来说,平面版本和嵌套版本现在大致需要相同的时间。但是,它仍然很有趣。我认为嵌套缓存会由于缓存问题而变得更糟。 我可以幸运地连续分配所有的内存吗?
最佳答案
为什么在固定索引顺序之后,嵌套 vector 在微基准测试中的速度与平移速度大致相同:您希望平面数组更快(请参见Tobias's answer about potential locality problems和my other answer,以了解为什么嵌套 vector 通常很烂,但并非如此)严重影响顺序访问)。但是您的特定测试正在做很多事情,以至于无序执行隐藏了使用嵌套 vector 的开销,并且/或者放慢了速度,以至于额外的开销损失了测量噪声。
我将由g++ 5.2编译的内部循环的性能修正的源代码up on Godbolt so we can look at the asm放入-O3
。 (Apple的clang分支可能类似于clang3.7,但我只看一下gcc版本。)C++函数有很多代码,但是您可以右键单击源代码行以将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
vector 不是很完美地连续也是如此。std::uniform_real_distribution<double> urd(-1, 1);
是std::mt19937 rng{rd()};
之上的额外开销,与FP-add延迟(3个周期)相比,或与每个周期2个的L1D缓存负载吞吐量相比,它已经很慢。运行PRNG所花费的所有这些额外时间使乱序执行有机会运行数组索引指令,因此在数据准备就绪时最终地址就准备好了。 除非您有很多高速缓存未命中,否则通常只测量PRNG速度,因为它产生的结果每个时钟周期慢于1。g++ 5.2没有完全内联
urd(rng)
代码,并且x86-64 System V调用约定没有保留 call 的XMM寄存器。因此,即使不是tmp1
,每个元素也必须溢出/重新加载tmp2
/volatile
。它还会丢失其在Z vector 中的位置,并且必须重做外部2级间接访问,然后才能访问下一个
z
元素。这是因为它不知道其调用函数的内部,并且假定它可能具有指向外部vector<>
内存的指针。 (平面版本对内部循环中的索引进行两次乘法运算,而不是简单的指针加法。)clang(使用libc++)确实完全内联了PRNG,因此移至下一个
z
只是add reg, 8
以增加平面版本和嵌套版本中的指针。通过在内部循环之外获取迭代器或获取对内部 vector 的引用,您可以从gcc获得相同的行为,而不是重做operator[]
并希望编译器为您提升它。除非异常,否则Intel/AMD FP的add/sub/mul吞吐量/延迟与数据无关。 (x87 also slows down for NaN and maybe infinity,但SSE不会。即使是标量
float
/double
,64位代码也会使用SSE。)因此,您可以将数组初始化为零,或者使用PRNG初始化定时循环。 (或将它们置零,因为vector<double>
构造函数为您完成了此操作,实际上它需要额外的代码才能在您要编写其他内容的情况下将其删除。)除法和sqrt性能取决于某些CPU的数据。 ,并且比add/sub/mul慢得多。 // 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不能确定函数调用不会修改指针,因为指向外部 vector 的指针已逸出该函数。 (并且PRNG不内联)。
但是,甚至是asm中的实际存储/重新加载对缓存丢失的敏感度仍然不如简单加载!
即使存储在高速缓存中丢失,存储->负载转发仍然有效。因此,Z vector 中的缓存未命中不会直接延迟关键路径。如果乱序执行无法掩盖缓存未命中的延迟,则只会减慢您的速度。 (将数据写入存储缓冲区后,存储区便可以退出(并且所有先前的指令都已退出)。我不确定加载是否可以在高速缓存行甚至到达L1D之前就退出)它的数据来自存储转发。由于x86确实允许StoreLoad重新排序(允许存储在加载后变为全局可见),因此可能能够这样做。在这种情况下,存储/重新加载仅会为PRNG结果增加6个周期的延迟(偏离从一个PRNG状态到下一个PRNG状态的关键路径),并且如果它缓存不足以至于存储缓冲区填满并阻止执行新的存储操作,这仅仅是吞吐量的瓶颈,从而最终阻止了新的操作当预留站或ROB填满未执行或未退回(分别)的指令时,它会发出乱序的内核。
使用反向索引(统一代码的原始版本),可能的主要瓶颈是分散的存储。 IDK为什么clang比gcc在那里做得好得多。也许clang毕竟设法使循环反转并按顺序遍历内存。 (由于它完全内联了PRNG,因此没有需要内存状态与程序顺序匹配的函数调用。)
按顺序遍历每个Z vector 意味着高速缓存未命中之间的距离相对较远(即使每个Z vector 与前一个 vector 都不连续),也为存储执行提供了很多时间。或者,即使在L1D高速缓存实际上拥有高速缓存行之前(在MESI协议(protocol)的“修改”状态下),实际上不能撤消存储转发的负载,推测执行也具有正确的数据,而不必等待延迟的延迟。缓存未命中。乱序指令窗口可能足够大,可以防止关键路径在负载退出之前停滞。 (高速缓存未命中负载通常确实很糟糕,因为依赖指令无法在没有数据的情况下执行,因此它们无法运行。因此,它们更容易在管道中产生气泡。DRAM的完全高速缓存未命中的延迟超过300周期,并且IvB上的无序窗口为168 oups,它无法隐藏每个时钟以1 uop(约1条指令)执行代码的所有延迟。)对于纯存储,乱码订单窗口超出了ROB的大小,因为他们不需要提交给L1D即可退休。实际上,他们只有在退休后才能作出 promise ,因为这就是他们被认为是非投机性的观点。 (因此,使它们在全局范围内可视化早于此将防止在检测到异常或错误推测时发生回滚。)
我的桌面上没有安装
libc++
,因此无法针对g++对该版本进行基准测试。使用g++ 5.4,我发现Nested:225毫秒和Flat:239毫秒。我怀疑额外的数组索引乘法是一个问题,并与PRNG使用的ALU指令竞争。相反,嵌套版本重做L1D缓存中命中的一堆指针追逐可以并行发生。我的台式机是4.4 GHz的Skylake i7-6700k。 SKL的ROB(重排序缓冲区)大小为224 uop,RS的大小为97 uop,即so the out-of-order window is very large。它还具有4个周期的FP添加延迟(与之前的3个uarch不同)。 volatile double tmp1 = 0;
您的累加器是volatile
,它强制编译器在每次内部循环迭代时存储/重新加载它。 内循环中循环承载的依赖链的总延迟为9个周期:3个用于addsd
,6个用于从movsd
存储到movsd
重载的存储转发。 (clang用addsd xmm7, qword ptr [rsp + 88]
将重载折叠到内存操作数中,但有相同的区别。([rsp+88]
在堆栈中,如果需要从寄存器中溢出变量,则会存储具有自动存储的变量。)如上所述,对gcc的非内联函数调用还将在x86-64 System V调用约定(Windows以外的所有程序都使用)中强制溢出/重新加载。但是,例如,一个智能编译器可以完成4个PRNG调用,然后进行4个数组存储。 (如果您使用了迭代器来确保gcc知道包含其他 vector 的 vector 没有变化。)
使用
-ffast-math
将使编译器自动矢量化(如果不是PRNG和volatile
)。这将使您足够快地遍历数组,以至于不同Z vector 之间缺乏局部性可能是一个真正的问题。它还会让编译器使用多个累加器展开,以隐藏FP添加延迟。例如他们可以(和铛会)使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这是一个容易出错的很好的例子。
May I just be lucky and have all the memory contiguously allocated?
是的,当您在执行之前没有分配和释放任何东西时,许多按顺序完成的小分配可能会发生这种情况。如果它们足够大(通常是一个4kiB页或更大),则glibc
malloc
将切换为使用mmap(MAP_ANONYMOUS)
,然后内核将选择随机虚拟地址(ASLR)。因此,对于较大的Z,您可能会期望局部性变差。但是另一方面,较大的Z vector 意味着您要花费更多的时间遍历一个连续 vector ,因此更改y
(和x
)时发生缓存丢失的重要性变得相对较小。显然,用您的数据按顺序循环显然不会暴露这一点,因为额外的指针访问会在高速缓存中命中,因此指针追逐具有足够低的延迟,OOO执行可以用您的慢速循环将其隐藏。
预取在这里非常容易。
不同的编译器/库可能会对这个奇怪的测试产生很大的影响。在我的系统(Arch Linux,具有4.4 GHz最大Turbo的i7-6700k Skylake)上,对于g++ 5.4 -O3,最好的4种运行在
300 300 300
上: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的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
没有显示出比gcc更多的clang缓存未命中(L1或最后一级)。但是它确实表明clang所做的工作量是L1D负载的两倍以上。我确实尝试过非正方形数组。几乎完全相同,同时总元素数保持不变,但最终尺寸更改为5或6。
即使对C进行很小的更改,也比使用gcc嵌套要快“变平”(300 ^ 3从240ms降低到220ms,但嵌套几乎没有任何区别。):
// 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.
关于c++ - 使用嵌套 vector 与扁平 vector 包装器时,行为异常,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/33093860/