Дійсно, так як C ++ 11, вартість копіюванняstd::vector
зникає в більшості випадків.
Однак слід пам’ятати, що вартість побудови нового вектора (потім його руйнування ) все ще існує, а використання вихідних параметрів замість повернення за значенням все-таки корисно, коли ви хочете повторно використовувати ємність вектора. Це задокументовано як виняток у F.20 Основних Правил C ++.
Порівняємо:
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) = 8GB), часи ідеально однакові. Дійсно, в обох випадках ми виділяємо лише один раз величезний вектор в 8 ГБ пам'яті. Це також доводить, що при використанні BuildLargeVector1 () жодної копії не сталося: мені не вистачило оперативної пам’яті, щоб зробити копію!
Коли numIter = 2
повторне використання ємності вектора замість повторного виділення другого вектора на 1,37x швидше.
Коли 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 (), які починають домінувати.
Зауважте, наскільки крива часу2 є більш плавною, ніж крива часу1: не тільки повторне використання ємності вектора, як правило, швидше, але, що може бути важливіше, воно більш передбачуване .
Також зауважте, що в солодкому місці нам вдалося здійснити 2 мільярди додавання 64-бітових цілих чисел за ~ 0,5 секунди, що цілком оптимально для 64-бітного процесора 4,2 ГГц. Ми могли б зробити краще, паралелізуючи обчислення, щоб використовувати всі 8 ядер (тест вище використовує лише одне ядро за один раз, що я перевірив, повторно запустивши тест під час моніторингу використання процесора). Найкраща продуктивність досягається при mem (v) = 16 кБ, що є порядком величини кешу L1 (кеш даних L1 для i7-7700K становить 4х32 кБ).
Звичайно, відмінності стають все менш актуальними, чим більше обчислень, які ви насправді повинні робити на даних. Нижче наведені результати , якщо замінити sum = std::accumulate(v.begin(), v.end(), sum);
на for (int k : v) sum += std::sqrt(2.0*k);
:
Висновки
- Використання вихідних параметрів замість повернення за значенням може забезпечити підвищення продуктивності за рахунок повторного використання потужності.
- На сучасному настільному комп’ютері це здається застосовно лише до великих векторів (> 16 МБ) та малих векторів (<1 КБ).
- Уникайте виділення мільйонів / мільярдів малих векторів (<1 КБ). Якщо можливо, повторно використовуйте ємність, а ще краще, розробіть архітектуру по-іншому.
Результати можуть відрізнятися на інших платформах. Як завжди, якщо продуктивність важлива, напишіть орієнтири для конкретного випадку використання.