Infatti, dal momento che C ++ 11, il costo di copiare l' std::vector
è andato nella maggior parte dei casi.
Tuttavia, si dovrebbe tenere presente che il costo della costruzione del nuovo vettore (quindi della sua distruzione ) esiste ancora e l'uso dei parametri di output invece di restituire per valore è ancora utile quando si desidera riutilizzare la capacità del vettore. Ciò è documentato come un'eccezione in F.20 delle linee guida di base C ++.
Confrontiamo:
std::vector<int> BuildLargeVector1(size_t vecSize) {
return std::vector<int>(vecSize, 1);
}
con:
void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
v.assign(vecSize, 1);
}
Supponiamo ora di dover chiamare questi metodi numIter
volte in un ciclo stretto ed eseguire alcune azioni. Ad esempio, calcoliamo la somma di tutti gli elementi.
Usando BuildLargeVector1
, faresti:
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);
}
Usando BuildLargeVector2
, faresti:
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);
}
Nel primo esempio, si verificano molte allocazioni / deallocazioni dinamiche non necessarie, che vengono evitate nel secondo esempio utilizzando un parametro di output alla vecchia maniera, riutilizzando la memoria già allocata. La validità o meno di questa ottimizzazione dipende dal costo relativo dell'allocazione / deallocazione rispetto al costo del calcolo / della modifica dei valori.
Prova delle prestazioni
Giochiamo con i valori di vecSize
e numIter
. Manterremo vecSize * numIter costante in modo che "in teoria", dovrebbe richiedere lo stesso tempo (= c'è lo stesso numero di assegnazioni e aggiunte, con gli stessi identici valori), e la differenza di tempo può venire solo dal costo di allocazioni, deallocazioni e migliore utilizzo della cache.
Più specificamente, usiamo vecSize * numIter = 2 ^ 31 = 2147483648, perché ho 16 GB di RAM e questo numero garantisce che non vengano allocati più di 8 GB (sizeof (int) = 4), assicurandomi che non sto scambiando su disco ( tutti gli altri programmi erano chiusi, avevo ~ 15 GB disponibili durante l'esecuzione del test).
Ecco il codice:
#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;
}
E questo è il risultato:
$ 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 GHz; 16 GB DDR4 2400 Mhz; Kubuntu 18.04)
Notazione: mem (v) = v.size () * sizeof (int) = v.size () * 4 sulla mia piattaforma.
Non sorprende che quando numIter = 1
(cioè, mem (v) = 8GB), i tempi siano perfettamente identici. Infatti, in entrambi i casi stiamo allocando solo una volta un enorme vettore di 8 GB in memoria. Ciò dimostra anche che non è avvenuta alcuna copia durante l'utilizzo di BuildLargeVector1 (): non avrei abbastanza RAM per fare la copia!
Quando numIter = 2
, riutilizzare la capacità del vettore invece di riallocare un secondo vettore è 1,37 volte più veloce.
Quando numIter = 256
, riutilizzare la capacità del vettore (invece di allocare / deallocare un vettore più e più volte 256 volte ...) è 2.45 volte più veloce :)
Possiamo notare che time1 è praticamente costante da numIter = 1
a numIter = 256
, il che significa che allocare un enorme vettore di 8 GB è più o meno costoso quanto allocare 256 vettori di 32 MB. Tuttavia, l'allocazione di un enorme vettore di 8 GB è decisamente più costoso rispetto all'allocazione di un vettore di 32 MB, quindi il riutilizzo della capacità del vettore fornisce miglioramenti delle prestazioni.
Da numIter = 512
(mem (v) = 16MB) a numIter = 8M
(mem (v) = 1kB) è il punto debole: entrambi i metodi sono esattamente altrettanto veloci e più veloci di tutte le altre combinazioni di numIter e vecSize. Ciò probabilmente ha a che fare con il fatto che la dimensione della cache L3 del mio processore è di 8 MB, quindi il vettore si adatta perfettamente alla cache. Non spiego davvero perché il salto improvviso di time1
è per mem (v) = 16 MB, sembrerebbe più logico accadere subito dopo, quando mem (v) = 8 MB. Notare che sorprendentemente, in questo punto debole, non riutilizzare la capacità è in effetti leggermente più veloce! Non lo spiego davvero.
Quando le numIter > 8M
cose iniziano a diventare brutte. Entrambi i metodi diventano più lenti, ma la restituzione del vettore per valore diventa ancora più lenta. Nel peggiore dei casi, con un vettore contenente un solo singolo int
, riutilizzare la capacità invece di restituire per valore è 3,3 volte più veloce. Presumibilmente, ciò è dovuto ai costi fissi di malloc () che iniziano a dominare.
Nota come la curva per il tempo2 è più liscia della curva per il tempo1: non solo il riutilizzo della capacità del vettore è generalmente più veloce, ma forse ancora più importante, è più prevedibile .
Si noti inoltre che nel punto debole, siamo stati in grado di eseguire 2 miliardi di aggiunte di interi a 64 bit in ~ 0,5 secondi, il che è abbastanza ottimale su un processore a 64 bit da 4,2 Ghz. Potremmo fare di meglio parallelizzando il calcolo per utilizzare tutti gli 8 core (il test sopra utilizza solo un core alla volta, cosa che ho verificato rieseguendo il test mentre monitorava l'utilizzo della CPU). Le migliori prestazioni si ottengono quando mem (v) = 16kB, che è l'ordine di grandezza della cache L1 (la cache dati L1 per l'i7-7700K è 4x32kB).
Ovviamente, le differenze diventano sempre meno rilevanti quanto più calcoli devi effettivamente fare sui dati. Di seguito sono riportati i risultati se sostituiamo sum = std::accumulate(v.begin(), v.end(), sum);
con for (int k : v) sum += std::sqrt(2.0*k);
:
conclusioni
- L'utilizzo dei parametri di output anziché la restituzione in base al valore può fornire miglioramenti delle prestazioni riutilizzando la capacità.
- Su un moderno computer desktop, questo sembra applicabile solo a vettori grandi (> 16 MB) e piccoli vettori (<1kB).
- Evita di allocare milioni / miliardi di piccoli vettori (<1kB). Se possibile, riutilizza la capacità o, meglio ancora, progetta la tua architettura in modo diverso.
I risultati possono differire su altre piattaforme. Come al solito, se le prestazioni sono importanti, scrivi benchmark per il tuo caso d'uso specifico.