En effet, depuis C ++ 11, le coût de la copie du std::vector
disparaît dans la plupart des cas.
Cependant, il faut garder à l'esprit que le coût de construction du nouveau vecteur (puis de sa destruction ) existe toujours, et l'utilisation de paramètres de sortie au lieu de renvoyer par valeur est toujours utile lorsque vous souhaitez réutiliser la capacité du vecteur. Ceci est documenté comme une exception dans F.20 des directives de base C ++.
Comparons:
std::vector<int> BuildLargeVector1(size_t vecSize) {
return std::vector<int>(vecSize, 1);
}
avec:
void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
v.assign(vecSize, 1);
}
Maintenant, supposons que nous devions appeler ces méthodes numIter
fois dans une boucle serrée et effectuer une action. Par exemple, calculons la somme de tous les éléments.
En utilisant BuildLargeVector1
, vous feriez:
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);
}
En utilisant BuildLargeVector2
, vous feriez:
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);
}
Dans le premier exemple, de nombreuses allocations / désallocations dynamiques inutiles se produisent, qui sont évitées dans le deuxième exemple en utilisant un paramètre de sortie à l'ancienne, en réutilisant la mémoire déjà allouée. La valeur de cette optimisation dépend du coût relatif de l'allocation / désallocation par rapport au coût du calcul / de la mutation des valeurs.
Référence
Jouons avec les valeurs de vecSize
et numIter
. Nous garderons vecSize * numIter constant pour que "en théorie", cela prenne le même temps (= il y a le même nombre d'affectations et d'ajouts, avec exactement les mêmes valeurs), et le décalage horaire ne peut provenir que du coût de allocations, désallocations et meilleure utilisation du cache.
Plus précisément, utilisons vecSize * numIter = 2 ^ 31 = 2147483648, car j'ai 16 Go de RAM et ce nombre garantit que pas plus de 8 Go sont alloués (sizeof (int) = 4), en veillant à ne pas échanger sur le disque ( tous les autres programmes étaient fermés, j'avais ~ 15 Go disponibles lors de l'exécution du test).
Voici le code:
#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;
}
Et voici le résultat:
$ 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 Go de DDR4 à 2400 MHz; Kubuntu 18.04)
Notation: mem (v) = v.size () * sizeof (int) = v.size () * 4 sur ma plateforme.
Sans surprise, lorsque numIter = 1
(c'est-à-dire, mem (v) = 8 Go), les heures sont parfaitement identiques. En effet, dans les deux cas, nous n'allouons qu'une seule fois un énorme vecteur de 8 Go en mémoire. Cela prouve également qu'aucune copie ne s'est produite lors de l'utilisation de BuildLargeVector1 (): je n'aurais pas assez de RAM pour faire la copie!
Lorsque numIter = 2
, réutiliser la capacité vectorielle au lieu de réallouer un deuxième vecteur est 1,37x plus rapide.
Quand numIter = 256
, réutiliser la capacité vectorielle (au lieu d'allouer / désallouer un vecteur encore et encore 256 fois ...) est 2,45x plus rapide :)
Nous pouvons remarquer que time1 est à peu près constant de numIter = 1
à numIter = 256
, ce qui signifie qu'allouer un énorme vecteur de 8 Go est à peu près aussi coûteux que d'allouer 256 vecteurs de 32 Mo. Cependant, allouer un vecteur énorme de 8 Go est certainement plus coûteux que d'allouer un vecteur de 32 Mo, donc la réutilisation de la capacité du vecteur offre des gains de performances.
De numIter = 512
(mem (v) = 16MB) à numIter = 8M
(mem (v) = 1kB) est le point idéal: les deux méthodes sont exactement aussi rapides et plus rapides que toutes les autres combinaisons de numIter et vecSize. Cela a probablement à voir avec le fait que la taille du cache L3 de mon processeur est de 8 Mo, de sorte que le vecteur tient à peu près complètement dans le cache. Je n'explique pas vraiment pourquoi le saut soudain de time1
est pour mem (v) = 16 Mo, il semblerait plus logique de se produire juste après, quand mem (v) = 8 Mo. Notez que étonnamment, dans ce sweet spot, ne pas réutiliser la capacité est en fait légèrement plus rapide! Je n'explique pas vraiment cela.
Quand les numIter > 8M
choses commencent à devenir moche. Les deux méthodes sont plus lentes, mais le retour du vecteur par valeur est encore plus lent. Dans le pire des cas, avec un vecteur ne contenant qu'une seule int
capacité, la réutilisation de la capacité au lieu de renvoyer par valeur est 3,3 fois plus rapide. Cela est probablement dû aux coûts fixes de malloc () qui commencent à dominer.
Notez que la courbe pour le temps2 est plus douce que la courbe pour le temps1: non seulement la réutilisation de la capacité vectorielle est généralement plus rapide, mais peut-être plus important encore, elle est plus prévisible .
Notez également que dans le sweet spot, nous avons pu effectuer 2 milliards d'ajouts d'entiers 64 bits en ~ 0,5 s, ce qui est tout à fait optimal sur un processeur 4,2 GHz 64 bits. Nous pourrions faire mieux en parallélisant le calcul afin d'utiliser les 8 cœurs (le test ci-dessus n'utilise qu'un seul cœur à la fois, ce que j'ai vérifié en relançant le test tout en surveillant l'utilisation du processeur). Les meilleures performances sont obtenues lorsque mem (v) = 16 ko, qui est l'ordre de grandeur du cache L1 (le cache de données L1 pour le i7-7700K est 4x32 ko).
Bien sûr, les différences deviennent de moins en moins pertinentes au fur et à mesure que vous devez effectuer des calculs sur les données. Voici les résultats si nous remplaçons sum = std::accumulate(v.begin(), v.end(), sum);
par for (int k : v) sum += std::sqrt(2.0*k);
:
Conclusions
- L'utilisation de paramètres de sortie au lieu de renvoyer par valeur peut fournir des gains de performances en réutilisant la capacité.
- Sur un ordinateur de bureau moderne, cela ne semble applicable qu'aux grands vecteurs (> 16 Mo) et aux petits vecteurs (<1 Ko).
- Évitez d'allouer des millions / milliards de petits vecteurs (<1 Ko). Si possible, réutilisez la capacité ou, mieux encore, concevez votre architecture différemment.
Les résultats peuvent différer sur d'autres plates-formes. Comme d'habitude, si les performances comptent, écrivez des benchmarks pour votre cas d'utilisation spécifique.