web-dev-qa-db-fra.com

En C ++, est-ce toujours une mauvaise pratique de renvoyer un vecteur à partir d'une fonction?

Version courte: Il est courant de renvoyer des objets volumineux, tels que des vecteurs/tableaux, dans de nombreux langages de programmation. Ce style est-il maintenant acceptable en C++ 0x si la classe a un constructeur de déplacement, ou les programmeurs C++ le considèrent-il bizarre/laid/abomination?

Version longue: En C++ 0x est-ce toujours considéré comme une mauvaise forme?

std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();

La version traditionnelle ressemblerait à ceci:

void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);

Dans la version la plus récente, la valeur renvoyée par BuildLargeVector est une valeur r, donc v serait construit à l'aide du constructeur de déplacement de std::vector, en supposant que (N) RVO n'a pas lieu.

Même avant C++ 0x, la première forme était souvent "efficace" en raison de (N) RVO. Cependant, (N) RVO est à la discrétion du compilateur. Maintenant que nous avons des références rvalue, il est garanti qu'aucune copie complète n'aura lieu.

Edit: La question n'est vraiment pas sur l'optimisation. Les deux formes présentées ont des performances presque identiques dans les programmes du monde réel. Alors que, dans le passé, la première forme aurait pu avoir des performances moins bonnes d'un ordre de grandeur. En conséquence, le premier formulaire était une odeur de code majeure dans la programmation C++ depuis longtemps. Plus maintenant, j'espère?

102
Nate

Dave Abrahams a une analyse assez complète de la vitesse de passage/retour des valeurs .

Réponse courte, si vous devez renvoyer une valeur, renvoyez une valeur. N'utilisez pas de références de sortie car le compilateur le fait quand même. Bien sûr, il y a des mises en garde, vous devriez donc lire cet article.

73
Peter Alexander

Au moins l'OMI, c'est généralement une mauvaise idée, mais pas pour des raisons d'efficacité. C'est une mauvaise idée car la fonction en question doit généralement être écrite comme un algorithme générique qui produit sa sortie via un itérateur. Presque tout code qui accepte ou retourne un conteneur au lieu de fonctionner sur des itérateurs doit être considéré comme suspect.

Ne vous méprenez pas: il est parfois judicieux de contourner des objets de type collection (par exemple, des chaînes), mais pour l'exemple cité, je considérerais que passer ou renvoyer le vecteur est une mauvaise idée.

37
Jerry Coffin

The Gist, c'est:

Copy Elision and RVO can évitez les "copies effrayantes" (le compilateur n'est pas requis pour implémenter ces optimisations, et dans certaines situations, il ne peut pas être appliqué)

C++ 0x RValue fait référence à autoriser une implémentation chaîne/vecteur qui garantit cela.

Si vous pouvez abandonner les anciennes compilations/implémentations STL, renvoyez les vecteurs librement (et assurez-vous que vos propres objets le prennent également en charge). Si votre base de code doit prendre en charge des compilateurs "moindres", respectez l'ancien style.

Malheureusement, cela a une influence majeure sur vos interfaces. Si C++ 0x n'est pas une option et que vous avez besoin de garanties, vous pouvez utiliser à la place des objets comptés par référence ou copiés sur écriture dans certains scénarios. Ils ont cependant des inconvénients avec le multithreading.

(Je souhaite qu'une seule réponse en C++ soit simple et directe et sans conditions).

18
peterchen

En effet, depuis C++ 11, le coût de copie le std::vector A disparu dans la plupart des cas.

Cependant, il faut garder à l'esprit que le coût de construire le nouveau vecteur (alors détruire il) existe toujours et utiliser des paramètres de sortie au lieu de retourner par valeur est toujours utile lorsque vous souhaitez réutiliser la capacité du vecteur. Ceci est documenté comme une exception dans F.2 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);
}

Supposons maintenant que nous devions appeler ces méthodes numIter fois dans une boucle étroite 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, il existe de nombreuses allocations/désallocations dynamiques inutiles, 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 pertinence de cette optimisation dépend du coût relatif de l'allocation/désallocation par rapport au coût de calcul/mutation des valeurs.

Référence

Jouons avec les valeurs de vecSize et numIter. Nous garderons vecSize * numIter constant afin que "en théorie", cela prenne le même temps (= il y a le même nombre d'affectations et d'ajouts, avec les mêmes valeurs exactes), et la différence de temps 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 qu'aucun maximum de 8 Go n'est alloué (sizeof (int) = 4), m'assurer que je ne permute pas sur le disque (tous les autres programmes ont été 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

Benchmark results

(Intel i7-7700K @ 4,20 GHz; 16 Go 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'en aurais pas assez RAM pour faire la copie!

Lorsque numIter = 2, La réutilisation de la capacité vectorielle au lieu de réallouer un deuxième vecteur est 1,37x plus rapide.

Lorsque numIter = 256, La réutilisation de la capacité vectorielle (au lieu d'allouer/désallouer 256 fois de suite ...) est 2,45 fois plus rapide :)

Nous pouvons remarquer que time1 est à peu près constant de numIter = 1 À numIter = 256, Ce qui signifie que l'allocation d'un énorme vecteur de 8 Go est à peu près aussi coûteuse que l'allocation de 256 vecteurs de 32 Mo. Cependant, l'allocation d'un énorme vecteur de 8 Go est nettement plus coûteuse que l'allocation d'un vecteur de 32 Mo, donc la réutilisation de la capacité du vecteur permet des gains de performances.

De numIter = 512 (Mem (v) = 16 Mo) à numIter = 8M (Mem (v) = 1 Ko) 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) = 16MB, il semblerait plus logique de se produire juste après, quand mem (v) = 8MB. Notez que de manière surprenante, 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 numIter > 8M Les choses commencent à mal tourner. Les deux méthodes deviennent plus lentes, mais le retour du vecteur par valeur devient encore plus lent. Dans le pire des cas, avec un vecteur contenant un seul int, la réutilisation de la capacité au lieu de renvoyer par valeur est 3,3 fois plus rapide. Vraisemblablement, cela est dû aux coûts fixes de malloc () qui commencent à dominer.

Notez comment la courbe pour le temps2 est plus lisse 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 réexécutant 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 4x32kB).

Bien sûr, les différences deviennent de moins en moins pertinentes au fur et à mesure que vous devez réellement 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);:

Benchmark 2

Conclusions

  1. L'utilisation de paramètres de sortie au lieu de renvoyer par valeur mai offre des gains de performances en réutilisant la capacité.
  2. Sur un ordinateur de bureau moderne, cela ne semble applicable qu'aux gros vecteurs (> 16 Mo) et aux petits vecteurs (<1 Ko).
  3. É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 plateformes. Comme d'habitude, si les performances sont importantes, écrivez des références pour votre cas d'utilisation spécifique.

8
Boris Dalstein

Je pense toujours que c'est une mauvaise pratique, mais il convient de noter que mon équipe utilise MSVC 2008 et GCC 4.1, donc nous n'utilisons pas les derniers compilateurs.

Auparavant, un grand nombre des hotspots affichés dans vtune avec MSVC 2008 se résumaient à la copie de chaînes. Nous avions un code comme celui-ci:

String Something::id() const
{
    return valid() ? m_id: "";
}

... notez que nous avons utilisé notre propre type String (cela était nécessaire car nous fournissons un kit de développement logiciel où les rédacteurs de plugins pourraient utiliser différents compilateurs et donc des implémentations différentes et incompatibles de std :: string/std :: wstring).

J'ai fait un simple changement en réponse à la session de profilage d'échantillonnage de graphe d'appel montrant que String :: String (const String &) prenait beaucoup de temps. Les méthodes comme dans l'exemple ci-dessus étaient les plus grands contributeurs (en fait, la session de profilage a montré que l'allocation de mémoire et la désallocation étaient l'un des plus grands hotspots, le constructeur de copie de chaîne étant le principal contributeur pour les allocations).

Le changement que j'ai fait était simple:

static String null_string;
const String& Something::id() const
{
    return valid() ? m_id: null_string;
}

Pourtant, cela a fait toute une différence! Le hotspot a disparu lors des sessions de profileur suivantes, et en plus de cela, nous effectuons de nombreux tests unitaires approfondis pour suivre les performances de nos applications. Toutes sortes de temps de test de performances ont chuté de manière significative après ces simples modifications.

Conclusion: nous n'utilisons pas les derniers compilateurs absolus, mais nous ne pouvons toujours pas dépendre du compilateur optimisant la copie pour un retour fiable en valeur (du moins pas dans tous les cas). Cela peut ne pas être le cas pour ceux qui utilisent des compilateurs plus récents comme MSVC 2010. J'ai hâte de savoir quand nous pourrons utiliser C++ 0x et simplement utiliser des références rvalue et ne jamais avoir à nous soucier de pessimiser notre code en renvoyant des complexes classes par valeur.

[Modifier] Comme l'a souligné Nate, RVO s'applique au retour des temporaires créés à l'intérieur d'une fonction. Dans mon cas, il n'y avait pas de tels temporaires (sauf pour la branche invalide où nous construisons une chaîne vide) et donc RVO n'aurait pas été applicable.

5
stinky472

Juste pour taper un peu: il n'est pas courant dans de nombreux langages de programmation de renvoyer des tableaux à partir de fonctions. Dans la plupart d'entre eux, une référence au tableau est renvoyée. En C++, l'analogie la plus proche serait de renvoyer boost::shared_array

3
Nemanja Trifunovic

Si les performances sont un vrai problème, vous devez vous rendre compte que la sémantique de déplacement n'est pas toujours plus rapide que la copie. Par exemple, si vous avez une chaîne qui utilise l'optimisation de petite chaîne , pour les petites chaînes, un constructeur de déplacement doit faire exactement la même quantité de travail qu'une copie normale constructeur.

2
Motti