Действительно, так как C ++ 11, стоимость копированияstd::vector
исчезает в большинстве случаев.
Однако следует иметь в виду, что затраты на создание нового вектора (а затем его разрушение ) все еще существуют, и использование выходных параметров вместо возврата по значению по-прежнему полезно, когда вы хотите повторно использовать емкость вектора. Это задокументировано как исключение в F.20 Руководящих принципов C ++ Core.
Сравним:
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, потому что у меня 16 ГБ ОЗУ, и это число гарантирует, что выделено не более 8 ГБ (sizeof (int) = 4), гарантируя, что я не переключаюсь на диск ( все остальные программы были закрыты, у меня при запуске теста было доступно ~ 15Гб).
Вот код:
#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,20 ГГц; 16 ГБ DDR4 2400 МГц; Kubuntu 18.04)
Обозначение: mem (v) = v.size () * sizeof (int) = v.size () * 4 на моей платформе.
Неудивительно, что когда numIter = 1
(например, mem (v) = 8 ГБ), времена абсолютно идентичны. Действительно, в обоих случаях мы выделяем в памяти только один раз огромный вектор размером 8 ГБ. Это также доказывает, что при использовании BuildLargeVector1 () не было копирования: у меня не хватило бы ОЗУ для копирования!
Когда numIter = 2
повторное использование емкости вектора вместо перераспределения второго вектора происходит в 1,37 раза быстрее.
Когда numIter = 256
повторное использование емкости вектора (вместо выделения / освобождения вектора снова и снова 256 раз ...) происходит в 2,45 раза быстрее :)
Мы можем заметить, что time1 в значительной степени постоянен от numIter = 1
до numIter = 256
, что означает, что выделение одного огромного вектора размером 8 ГБ примерно так же дорого, как выделение 256 векторов размером 32 МБ. Однако выделение одного огромного вектора размером 8 ГБ определенно дороже, чем выделение одного вектора размером 32 МБ, поэтому повторное использование емкости вектора обеспечивает повышение производительности.
От numIter = 512
(mem (v) = 16MB) до numIter = 8M
(mem (v) = 1kB) - это золотая середина: оба метода работают так же быстро и быстрее, чем все другие комбинации numIter и vecSize. Вероятно, это связано с тем, что размер кэша L3 моего процессора составляет 8 МБ, так что вектор в значительной степени полностью помещается в кеш. На самом деле я не объясняю, почему внезапный скачок time1
для mem (v) = 16MB, более логично было бы произойти сразу после того, как mem (v) = 8MB. Обратите внимание, что, как ни странно, в этой золотой зоне отказ от повторного использования емкости на самом деле немного быстрее! Я действительно не объясняю этого.
Когда numIter > 8M
все становится некрасиво. Оба метода работают медленнее, но возврат вектора по значению становится еще медленнее. В худшем случае, когда вектор содержит только один единственный int
, повторное использование емкости вместо возврата по значению происходит в 3,3 раза быстрее. Предположительно, это связано с фиксированными затратами на malloc (), которые начинают преобладать.
Обратите внимание на то, что кривая для time2 более гладкая, чем кривая для time1: не только повторное использование векторной емкости, как правило, быстрее, но, что, возможно, более важно, оно более предсказуемо .
Также обратите внимание, что в лучшем случае мы смогли выполнить 2 миллиарда сложений 64-битных целых чисел за ~ 0,5 с, что вполне оптимально для 64-битного процессора с тактовой частотой 4,2 ГГц. Мы могли бы добиться большего, распараллелив вычисления, чтобы использовать все 8 ядер (в приведенном выше тесте одновременно используется только одно ядро, что я проверил, повторно запустив тест при мониторинге использования ЦП). Наилучшая производительность достигается при mem (v) = 16 КБ, что соответствует порядку величины кеша L1 (кэш данных L1 для i7-7700K составляет 4x32 КБ).
Конечно, различия становятся все менее и менее значимыми, чем больше вычислений вам действительно нужно выполнить с данными. Ниже приведены результаты, если мы заменим его sum = std::accumulate(v.begin(), v.end(), sum);
на for (int k : v) sum += std::sqrt(2.0*k);
:
Выводы
- Использование выходных параметров вместо возврата по значению может обеспечить повышение производительности за счет повторного использования емкости.
- На современном настольном компьютере это применимо только к большим векторам (> 16 МБ) и маленьким векторам (<1 КБ).
- Избегайте выделения миллионов / миллиардов небольших векторов (<1 КБ). Если возможно, повторно используйте емкость или, еще лучше, спроектируйте свою архитектуру по-другому.
Результаты могут отличаться на других платформах. Как обычно, если производительность имеет значение, напишите тесты для вашего конкретного варианта использования.