web-dev-qa-db-fra.com

Est-ce une mauvaise pratique d'écrire du code qui repose sur les optimisations du compilateur?

J'ai appris du C++ et je dois souvent renvoyer de gros objets à partir de fonctions créées dans la fonction. Je sais qu'il y a le passage par référence, retourne un pointeur et renvoie des solutions de type référence, mais j'ai également lu que les compilateurs C++ (et la norme C++) permettent l'optimisation de la valeur de retour, ce qui évite de copier ces gros objets via la mémoire, ce qui permet sauver le temps et la mémoire de tout cela.

Maintenant, je pense que la syntaxe est beaucoup plus claire lorsque l'objet est renvoyé explicitement par valeur, et le compilateur utilisera généralement le RVO et rendra le processus plus efficace. Est-ce une mauvaise pratique de s'appuyer sur cette optimisation? Cela rend le code plus clair et plus lisible pour l'utilisateur, ce qui est extrêmement important, mais dois-je me garder de supposer que le compilateur saisira l'opportunité RVO?

Est-ce une micro-optimisation ou quelque chose que je dois garder à l'esprit lors de la conception de mon code?

99
Matt

Employer le principe du moindre étonnement .

Est-ce vous et seulement vous qui utiliserez ce code, et êtes-vous sûr que le même que vous dans 3 ans ne sera pas surpris par ce que vous faites?

Alors vas-y.

Dans tous les autres cas, utilisez la méthode standard; sinon, vous et vos collègues allez avoir du mal à trouver des bogues.

Par exemple, mon collègue se plaignait que mon code provoquait des erreurs. Il s'est avéré qu'il avait désactivé l'évaluation booléenne de court-circuit dans ses paramètres de compilation. Je l'ai presque giflé.

129
Pieter B

Pour ce cas particulier, revenez simplement par valeur.

  • RVO et NRVO sont des optimisations bien connues et robustes qui devraient vraiment être faites par n'importe quel compilateur décent, même en mode C++ 03.

  • La sémantique de déplacement garantit que les objets sont déplacés hors des fonctions si (N) RVO n'a pas eu lieu. Cela n'est utile que si votre objet utilise des données dynamiques en interne (comme std::vector le fait), mais cela devrait vraiment être le cas si c'est que gros - le débordement de la pile est un risque avec les gros objets automatiques.

  • C++ 17 applique RVO. Alors ne vous inquiétez pas, il ne disparaîtra pas sur vous et ne finira de s'établir complètement qu'une fois les compilateurs à jour.

Et à la fin, forcer une allocation dynamique supplémentaire pour renvoyer un pointeur, ou forcer votre type de résultat à être constructible par défaut juste pour que vous puissiez le passer en tant que paramètre de sortie sont des solutions à la fois laides et non idiomatiques à un problème que vous n'aurez probablement jamais avoir.

Écrivez simplement le code qui a du sens et remerciez les rédacteurs du compilateur pour avoir correctement optimisé le code qui a du sens.

81
Quentin

Maintenant, je pense que la syntaxe est beaucoup plus claire lorsque l'objet est explicitement renvoyé par valeur, et le compilateur utilisera généralement le RVO et rendra le processus plus efficace. Est-ce une mauvaise pratique de s'appuyer sur cette optimisation? Cela rend le code plus clair et plus lisible pour l'utilisateur, ce qui est extrêmement important, mais devrais-je me garder de supposer que le compilateur saisira l'opportunité RVO?

Ce n'est pas une micro-optimisation peu connue et mignonne que vous lisez dans un petit blog peu trafiqué, puis vous vous sentez intelligent et supérieur à propos de l'utilisation.

Après C++ 11, RVO est la manière standard pour écrire ce code de code. Il est courant, attendu, enseigné, mentionné dans les discussions, mentionné dans les blogs, mentionné dans la norme, sera signalé comme un bug de compilation s'il n'est pas implémenté. En C++ 17, le langage va plus loin et impose la copie élision dans certains scénarios.

Vous devez absolument compter sur cette optimisation.

En plus de cela, le retour par valeur conduit simplement à un code beaucoup plus facile à lire et à gérer que le code renvoyé par référence. La sémantique de la valeur est une chose puissante, qui pourrait elle-même conduire à plus d'opportunités d'optimisation.

61
Barry

L'exactitude du code que vous écrivez ne devrait jamais dépendre d'une optimisation. Il doit afficher le résultat correct lorsqu'il est exécuté sur la "machine virtuelle" C++ qu'ils utilisent dans la spécification.

Cependant, ce dont vous parlez est plutôt une question d'efficacité. Votre code fonctionne mieux s'il est optimisé avec un compilateur d'optimisation RVO. C'est très bien, pour toutes les raisons mentionnées dans les autres réponses.

Cependant, si vous avez besoin de cette optimisation (comme si le constructeur de copie entraînerait l'échec de votre code), vous êtes maintenant dans les caprices de la compilateur.

Je pense que le meilleur exemple de cela dans ma propre pratique est l'optimisation des appels de queue:

   int sillyAdd(int a, int b)
   {
      if (b == 0)
          return a;
      return sillyAdd(a + 1, b - 1);
   }

C'est un exemple stupide, mais il montre un appel de queue, où une fonction est appelée récursivement juste à la fin d'une fonction. La machine virtuelle C++ montrera que ce code fonctionne correctement, bien que je puisse causer un peu de confusion quant à pourquoi j'ai pris la peine d'écrire une telle routine d'ajout dans le premier endroit. Cependant, dans les implémentations pratiques de C++, nous avons une pile et elle a un espace limité. Si elle est effectuée de manière pédante, cette fonction devrait pousser au moins b + 1 Des cadres de pile sur la pile pendant qu'elle ajoute. Si je veux calculer sillyAdd(5, 7), ce n'est pas grave. Si je veux calculer sillyAdd(0, 1000000000), je pourrais avoir de gros problèmes pour provoquer un StackOverflow (et non pas bon type ).

Cependant, nous pouvons voir qu'une fois que nous avons atteint cette dernière ligne de retour, nous avons vraiment fini avec tout dans le cadre de pile actuel. Nous n'avons pas vraiment besoin de le garder. L'optimisation des appels de queue vous permet de "réutiliser" le cadre de pile existant pour la fonction suivante. De cette façon, nous n'avons besoin que d'un cadre de pile, au lieu de b+1. (Nous devons encore faire toutes ces additions et soustractions stupides, mais elles ne prennent pas plus de place.) En effet, l'optimisation transforme le code en:

   int sillyAdd(int a, int b)
   {
      begin:
      if (b == 0)
          return a;
      // return sillyAdd(a + 1, b - 1);
      a = a + 1;
      b = b - 1;
      goto begin;  
   }

Dans certaines langues, l'optimisation des appels de queue est explicitement requise par la spécification. C++ n'est pas l'un d'entre eux. Je ne peux pas compter sur les compilateurs C++ pour reconnaître cette opportunité d'optimisation des appels de queue, à moins que j'aille au cas par cas. Avec ma version de Visual Studio, la version finale fait l'optimisation des appels de queue, mais pas la version de débogage (par conception).

Il serait donc mauvais pour moi de dépendre de la capacité de calculer sillyAdd(0, 1000000000).

16
Cort Ammon

En pratique Les programmes C++ attendent des optimisations du compilateur.

Examinez notamment les en-têtes standard de vos implémentations standard containers . Avec GCC , vous pouvez demander le formulaire prétraité (g++ -C -E) et la représentation interne de GIMPLE (g++ -fdump-tree-gimple ou Gimple SSA avec -fdump-tree-ssa) de la plupart des fichiers source (unités de traduction technique) utilisant des conteneurs. Vous serez surpris par la quantité d'optimisation qui est effectuée (avec g++ -O2). Ainsi, les implémenteurs de conteneurs s'appuient sur les optimisations (et la plupart du temps, l'implémenteur d'une bibliothèque standard C++ sait quelle optimisation se produira et écrit l'implémentation du conteneur en gardant cela à l'esprit; parfois, il écrit également la passe d'optimisation dans le compilateur pour traiter les fonctionnalités requises par la bibliothèque C++ standard).

Dans la pratique, ce sont les optimisations du compilateur qui rendent C++ et ses conteneurs standard suffisamment efficaces. Vous pouvez donc compter sur eux.

Et de même pour le cas du RVO mentionné dans votre question.

Le standard C++ a été co-conçu (notamment en expérimentant des optimisations suffisamment bonnes tout en proposant de nouvelles fonctionnalités) pour bien fonctionner avec les optimisations possibles.

Par exemple, considérez le programme ci-dessous:

#include <algorithm>
#include <vector>

extern "C" bool all_positive(const std::vector<int>& v) {
  return std::all_of(v.begin(), v.end(), [](int x){return x >0;});
}

compilez-le avec g++ -O3 -fverbose-asm -S. Vous découvrirez que la fonction générée n'exécute aucune instruction machine CALL. Ainsi, la plupart des étapes C++ (construction d'une fermeture lambda, son application répétée, obtention des itérateurs begin et end, etc ...) ont été optimisées. Le code machine ne contient qu'une boucle (qui n'apparaît pas explicitement dans le code source). Sans de telles optimisations, C++ 11 ne réussira pas.

addenda

(ajouté le 31 décembrest 2017)

Voir CppCon 2017: Matt Godbolt "Qu'est-ce que mon compilateur a fait pour moi récemment? Déboulonner le couvercle du compilateur" parler.

8

Chaque fois que vous utilisez un compilateur, il est entendu qu'il produira pour vous du code machine ou octet. Il ne garantit rien à quoi ressemble le code généré, sauf qu'il implémentera le code source selon les spécifications du langage. Notez que cette garantie est la même quel que soit le niveau d'optimisation utilisé, et donc, en général, il n'y a aucune raison de considérer une sortie comme plus "correcte" que l'autre.

De plus, dans ces cas, comme RVO, où il est spécifié dans le langage, il semblerait inutile de faire tout son possible pour éviter de l'utiliser, surtout si cela rend le code source plus simple.

Beaucoup d'efforts sont déployés pour que les compilateurs produisent une sortie efficace, et il est clair que l'intention est d'utiliser ces capacités.

Il peut y avoir des raisons d'utiliser du code non optimisé (pour le débogage, par exemple), mais le cas mentionné dans cette question ne semble pas en être un (et si votre code échoue uniquement lorsqu'il est optimisé, et ce n'est pas la conséquence d'une particularité de la sur lequel vous l'exécutez, il y a un bogue quelque part et il est peu probable qu'il se trouve dans le compilateur.)

4
sdenham

Je pense que d'autres ont bien couvert l'angle spécifique de C++ et de RVO. Voici une réponse plus générale:

En ce qui concerne l'exactitude, vous ne devez pas vous fier aux optimisations du compilateur ou au comportement spécifique au compilateur en général. Heureusement, vous ne semblez pas faire cela.

En ce qui concerne les performances, vous devez vous fier au comportement spécifique au compilateur en général et aux optimisations du compilateur en particulier. Un compilateur conforme aux normes est libre de compiler votre code comme il le souhaite, tant que le code compilé se comporte conformément aux spécifications du langage. Et je ne connais aucune spécification pour un langage traditionnel qui spécifie la vitesse à laquelle chaque opération doit être.

3
svick

Les optimisations du compilateur ne doivent affecter que les performances, pas les résultats. S'appuyer sur les optimisations du compilateur pour répondre aux exigences non fonctionnelles n'est pas seulement raisonnable, c'est souvent la raison pour laquelle un compilateur est choisi par dessus un autre.

Les indicateurs qui déterminent la façon dont certaines opérations sont effectuées (conditions d'indexation ou de débordement par exemple), sont souvent regroupés avec les optimisations du compilateur, mais ne devraient pas l'être. Ils affectent explicitement les résultats des calculs.

Si une optimisation du compilateur entraîne des résultats différents, c'est un bogue - un bogue dans le compilateur. S'appuyer sur un bogue du compilateur est à long terme une erreur - que se passe-t-il lorsqu'il est corrigé?

L'utilisation d'indicateurs de compilation qui modifient le fonctionnement des calculs doit être bien documentée, mais utilisée selon les besoins.

1
jmoreno

Non.

C'est ce que je fais tout le temps. Si j'ai besoin d'accéder à un bloc arbitraire de 16 bits en mémoire, je le fais

void *ptr = get_pointer();
uint16_t u16;
memcpy(&u16, ptr, sizeof(u16)); // ntohs omitted for simplicity

... et comptez sur le compilateur pour faire tout ce qu'il peut pour optimiser ce morceau de code. Le code fonctionne sur ARM, i386, AMD64 et pratiquement sur toutes les architectures. En théorie, un compilateur non optimisant pourrait en fait appeler memcpy, ce qui entraînerait des performances totalement mauvaises, mais cela ne me pose aucun problème, car j'utilise des optimisations de compilateur.

Considérez l'alternative:

void *ptr = get_pointer();
uint16_t *u16ptr = ptr;
uint16_t u16;
u16 = *u16ptr;  // ntohs omitted for simplicity

Ce code alternatif ne fonctionne pas sur les machines qui nécessitent un alignement correct, si get_pointer() renvoie un pointeur non aligné. En outre, il peut y avoir des problèmes d'alias dans l'alternative.

La différence entre -O2 et -O0 lors de l'utilisation de l'astuce memcpy est grande: 3,2 Gbps de performances de somme de contrôle IP contre 67 Gbps de performances de somme de contrôle IP. Plus d'un ordre de grandeur de différence!

Parfois, vous devrez peut-être aider le compilateur. Ainsi, par exemple, au lieu de compter sur le compilateur pour dérouler les boucles, vous pouvez le faire vous-même. Soit en implémentant le fameux appareil de Duff , soit d'une manière plus propre.

L'inconvénient de s'appuyer sur les optimisations du compilateur est que si vous exécutez gdb pour déboguer votre code, vous pouvez découvrir que beaucoup a été optimisé. Ainsi, vous devrez peut-être recompiler avec -O0, ce qui signifie que les performances seront totalement nulles lors du débogage. Je pense que c'est un inconvénient à prendre en compte, compte tenu des avantages de l'optimisation des compilateurs.

Quoi que vous fassiez, veuillez vous assurer que votre chemin n'est pas un comportement indéfini. L'accès à un bloc de mémoire aléatoire sous forme d'entier 16 bits est certainement un comportement indéfini en raison de problèmes d'alias et d'alignement.

1
juhist

Toutes les tentatives de code efficace écrites en tout sauf Assembly reposent très, très fortement sur les optimisations du compilateur, en commençant par l'allocation de registre efficace comme la plus élémentaire pour éviter les déversements de pile superflus partout et au moins raisonnablement bonne, sinon excellente, la sélection des instructions. Sinon, nous serions de retour dans les années 80 où nous devions mettre des indices register partout et utiliser le nombre minimum de variables dans une fonction pour aider les compilateurs C archaïques ou même plus tôt lorsque goto était une optimisation de branchement utile.

Si nous ne pensions pas pouvoir compter sur la capacité de notre optimiseur à optimiser notre code, nous serions tous encore en train de coder des chemins d'exécution critiques pour les performances dans Assembly.

C'est vraiment une question de fiabilité de l'optimisation, qui est mieux triée en profilant et en examinant les capacités des compilateurs que vous avez et peut-être même en démontant s'il y a un hotspot que vous ne pouvez pas comprendre où le compilateur semble n'ont pas réussi à faire une optimisation évidente.

Le RVO est quelque chose qui existe depuis des lustres, et, au moins à l'exclusion des cas très complexes, les compilateurs l'appliquent bien de manière fiable depuis des lustres. Ce n'est certainement pas la peine de contourner un problème qui n'existe pas.

Err sur le côté de compter sur l'optimiseur, ne pas le craindre

Au contraire, je dirais qu'il faut trop compter sur les optimisations du compilateur que trop peu, et cette suggestion vient d'un gars qui travaille dans des domaines très critiques pour les performances où l'efficacité, la maintenabilité et la qualité perçue parmi les clients sont tout un flou géant. Je préférerais que vous vous appuyiez trop en confiance sur votre optimiseur et que vous trouviez des cas Edge obscurs où vous vous reposiez trop que de trop compter et de simplement coder par peurs superstitieuses tout le temps pour le reste de votre vie. Cela vous permettra au moins de rechercher un profileur et d'enquêter correctement si les choses ne s'exécutent pas aussi rapidement qu'elles le devraient et d'acquérir de précieuses connaissances, pas des superstitions, en cours de route.

Vous faites bien de vous appuyer sur l'optimiseur. Continuez. Ne devenez pas comme ce type qui commence à demander explicitement à incorporer chaque fonction appelée dans une boucle avant même de profiler par peur erronée des lacunes de l'optimiseur.

Profilage

Le profilage est vraiment le rond-point mais la réponse ultime à votre question. Le problème que les débutants désireux d'écrire du code efficace ont souvent du mal à résoudre n'est pas ce qu'il faut optimiser, c'est ce pas pour optimiser car ils développent toutes sortes de intuitions erronées sur les inefficacités qui, bien qu'humainement intuitifs, sont mal calculés. Développer une expérience avec un profileur commencera vraiment à vous donner une bonne appréciation non seulement des capacités d'optimisation de vos compilateurs sur lesquelles vous pouvez vous appuyer en toute confiance, mais également des capacités (ainsi que des limitations) de votre matériel. Le profilage a sans doute encore plus de valeur à apprendre ce qui ne valait pas la peine d'être optimisé que d'apprendre ce qui l'était.

0
user204677