确实,自C ++ 11起,大多数情况下复制的成本std::vector
就消失了。
但是,请记住,构造新向量(然后对其进行销毁)的成本仍然存在,并且当您希望重用向量的容量时,使用输出参数而不是按值返回仍然有用。C ++核心准则的F.20中将此记录为例外。
让我们比较一下:
std::vector<int> BuildLargeVector1(size_t vecSize) {
return std::vector<int>(vecSize, 1);
}
与:
void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
v.assign(vecSize, 1);
}
现在,假设我们需要numIter
在一个紧密的循环中多次调用这些方法,并执行一些操作。例如,让我们计算所有元素的总和。
使用BuildLargeVector1
,您将执行以下操作:
size_t sum1 = 0;
for (int i = 0; i < numIter; ++i) {
std::vector<int> v = BuildLargeVector1(vecSize);
sum1 = std::accumulate(v.begin(), v.end(), sum1);
}
使用BuildLargeVector2
,您将执行以下操作:
size_t sum2 = 0;
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
BuildLargeVector2(/*out*/ v, vecSize);
sum2 = std::accumulate(v.begin(), v.end(), sum2);
}
在第一个示例中,发生了许多不必要的动态分配/取消分配,在第二个示例中,通过按旧方式使用输出参数,重新使用已分配的内存,可以防止这种情况。该优化是否值得做取决于与计算/更改值的成本相比分配/取消分配的相对成本。
基准测试
让我们的价值观发挥vecSize
和numIter
。我们将保持vecSize * numIter不变,以便“理论上”应该花费相同的时间(=赋值和相加的次数相同,值也完全相同),并且时间差只能来自于分配,释放和更好地使用缓存。
更具体地说,让我们使用vecSize * numIter = 2 ^ 31 = 2147483648,因为我有16GB的RAM,并且此数字可确保分配的内存不超过8GB(sizeof(int)= 4),从而确保我不会交换到磁盘(所有其他程序均已关闭,运行测试时我有约15GB的可用空间)。
这是代码:
#include <chrono>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>
class Timer {
using clock = std::chrono::steady_clock;
using seconds = std::chrono::duration<double>;
clock::time_point t_;
public:
void tic() { t_ = clock::now(); }
double toc() const { return seconds(clock::now() - t_).count(); }
};
std::vector<int> BuildLargeVector1(size_t vecSize) {
return std::vector<int>(vecSize, 1);
}
void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
v.assign(vecSize, 1);
}
int main() {
Timer t;
size_t vecSize = size_t(1) << 31;
size_t numIter = 1;
std::cout << std::setw(10) << "vecSize" << ", "
<< std::setw(10) << "numIter" << ", "
<< std::setw(10) << "time1" << ", "
<< std::setw(10) << "time2" << ", "
<< std::setw(10) << "sum1" << ", "
<< std::setw(10) << "sum2" << "\n";
while (vecSize > 0) {
t.tic();
size_t sum1 = 0;
{
for (int i = 0; i < numIter; ++i) {
std::vector<int> v = BuildLargeVector1(vecSize);
sum1 = std::accumulate(v.begin(), v.end(), sum1);
}
}
double time1 = t.toc();
t.tic();
size_t sum2 = 0;
{
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
BuildLargeVector2(/*out*/ v, vecSize);
sum2 = std::accumulate(v.begin(), v.end(), sum2);
}
} // deallocate v
double time2 = t.toc();
std::cout << std::setw(10) << vecSize << ", "
<< std::setw(10) << numIter << ", "
<< std::setw(10) << std::fixed << time1 << ", "
<< std::setw(10) << std::fixed << time2 << ", "
<< std::setw(10) << sum1 << ", "
<< std::setw(10) << sum2 << "\n";
vecSize /= 2;
numIter *= 2;
}
return 0;
}
结果如下:
$ g++ -std=c++11 -O3 main.cpp && ./a.out
vecSize, numIter, time1, time2, sum1, sum2
2147483648, 1, 2.360384, 2.356355, 2147483648, 2147483648
1073741824, 2, 2.365807, 1.732609, 2147483648, 2147483648
536870912, 4, 2.373231, 1.420104, 2147483648, 2147483648
268435456, 8, 2.383480, 1.261789, 2147483648, 2147483648
134217728, 16, 2.395904, 1.179340, 2147483648, 2147483648
67108864, 32, 2.408513, 1.131662, 2147483648, 2147483648
33554432, 64, 2.416114, 1.097719, 2147483648, 2147483648
16777216, 128, 2.431061, 1.060238, 2147483648, 2147483648
8388608, 256, 2.448200, 0.998743, 2147483648, 2147483648
4194304, 512, 0.884540, 0.875196, 2147483648, 2147483648
2097152, 1024, 0.712911, 0.716124, 2147483648, 2147483648
1048576, 2048, 0.552157, 0.603028, 2147483648, 2147483648
524288, 4096, 0.549749, 0.602881, 2147483648, 2147483648
262144, 8192, 0.547767, 0.604248, 2147483648, 2147483648
131072, 16384, 0.537548, 0.603802, 2147483648, 2147483648
65536, 32768, 0.524037, 0.600768, 2147483648, 2147483648
32768, 65536, 0.526727, 0.598521, 2147483648, 2147483648
16384, 131072, 0.515227, 0.599254, 2147483648, 2147483648
8192, 262144, 0.540541, 0.600642, 2147483648, 2147483648
4096, 524288, 0.495638, 0.603396, 2147483648, 2147483648
2048, 1048576, 0.512905, 0.609594, 2147483648, 2147483648
1024, 2097152, 0.548257, 0.622393, 2147483648, 2147483648
512, 4194304, 0.616906, 0.647442, 2147483648, 2147483648
256, 8388608, 0.571628, 0.629563, 2147483648, 2147483648
128, 16777216, 0.846666, 0.657051, 2147483648, 2147483648
64, 33554432, 0.853286, 0.724897, 2147483648, 2147483648
32, 67108864, 1.232520, 0.851337, 2147483648, 2147483648
16, 134217728, 1.982755, 1.079628, 2147483648, 2147483648
8, 268435456, 3.483588, 1.673199, 2147483648, 2147483648
4, 536870912, 5.724022, 2.150334, 2147483648, 2147483648
2, 1073741824, 10.285453, 3.583777, 2147483648, 2147483648
1, 2147483648, 20.552860, 6.214054, 2147483648, 2147483648
(Intel i7-7700K @ 4.20GHz; 16GB DDR4 2400Mhz; Kubuntu 18.04)
表示法:在我的平台上,内存(v)= v.size()* sizeof(int)= v.size()* 4。
毫不奇怪,当numIter = 1
(即mem(v)= 8GB)时,时间完全相同。确实,在两种情况下,我们仅在内存中分配了一个巨大的8GB向量。这也证明使用BuildLargeVector1()时没有发生复制:我没有足够的RAM来进行复制!
numIter = 2
设为时,重用向量容量而不是重新分配第二个向量快1.37倍。
当时numIter = 256
,重用向量容量(而不是一遍又一遍地重复分配/取消向量256次...)快了2.45倍:)
我们可以注意到,time1从numIter = 1
到几乎是常数numIter = 256
,这意味着分配一个8GB的巨大向量与分配256个32MB的向量几乎一样昂贵。但是,分配一个8GB的巨大向量肯定比分配32MB的向量要昂贵得多,因此重用该向量的容量可提高性能。
从numIter = 512
(mem(v)= 16MB)到numIter = 8M
(mem(v)= 1kB)是最有效的方法:这两种方法的速度完全相同,并且比numIter和vecSize的所有其他组合更快。这可能与以下事实有关:我的处理器的L3高速缓存大小为8MB,因此矢量几乎完全适合高速缓存。我并没有真正解释为什么time1
mem(v)= 16MB 的突然跳跃,似乎在mem(v)= 8MB之后才发生,这更合乎逻辑。请注意,令人惊讶的是,在这个最佳位置,不重用容量实际上要快一些!我真的没有解释。
当numIter > 8M
事情开始变得丑陋。两种方法都会变慢,但按值返回向量会变得更慢。在最坏的情况下,如果向量仅包含一个int
,则重用容量(而不是按值返回)的速度要快3.3倍。据推测,这是由于malloc()的固定成本开始占主导地位。
请注意,时间2的曲线比时间1的曲线更平滑:不仅重用向量容量通常更快,而且更重要的是,它更可预测。
另请注意,在最佳位置,我们能够在约0.5秒内完成20亿个64位整数的加法运算,这在4.2Ghz 64位处理器上是非常理想的。通过并行化计算以使用所有8个内核,我们可以做得更好(上面的测试一次只使用一个内核,我已经通过在监视CPU使用率的同时重新运行测试来验证了这一点)。当mem(v)= 16kB时,可获得最佳性能,这是L1缓存的数量级(i7-7700K的L1数据缓存为4x32kB)。
当然,实际上您必须对数据进行更多的计算,所以差异变得越来越不重要。如果我们替换为,则结果sum = std::accumulate(v.begin(), v.end(), sum);
如下for (int k : v) sum += std::sqrt(2.0*k);
:
结论
- 使用输出参数而不是按值返回可以通过重用容量来提高性能。
- 在现代台式计算机上,这似乎仅适用于大型向量(> 16MB)和小型向量(<1kB)。
- 避免分配数百万/十亿的小向量(<1kB)。如果可能,请重用容量,或者更好的方法是,以其他方式设计您的体系结构。
在其他平台上,结果可能会有所不同。通常,如果性能很重要,请为您的特定用例编写基准。