Quand le coût des appels de fonction compte-t-il encore dans les compilateurs modernes?


95

Je suis une personne religieuse et fais des efforts pour ne pas commettre de péchés. C'est pourquoi j'ai tendance à écrire de petites fonctions ( plus petites , pour reformuler Robert C. Martin) afin de se conformer aux différents commandements ordonnés par la bible de Clean Code . Mais en vérifiant certaines choses, j'ai atterri sur ce post , en dessous duquel j'ai lu ce commentaire:

N'oubliez pas que le coût d'un appel de méthode peut être important, en fonction de la langue. Il y a presque toujours un compromis entre écrire du code lisible et écrire du code performant.

Sous quelles conditions cette déclaration citée est-elle toujours valable de nos jours compte tenu de la riche industrie des compilateurs modernes performants?

C'est ma seule question. Et il ne s'agit pas de savoir si je devrais écrire de longues ou de petites fonctions. Je souligne simplement que vos réactions peuvent (ou non) contribuer à modifier mon attitude et me laisser incapable de résister à la tentation des blasphémateurs .


11
Écrire un code lisible et maintenable. Vous ne pouvez repenser votre approche que lorsque vous rencontrez un problème de débordement de pile
Fabio

33
Une réponse générale est impossible. Il y a trop de compilateurs différents, implémentant trop de spécifications de langage différentes. Et puis il y a les langages compilés par JIT, les langages interprétés dynamiquement, etc. Cela dit, si vous compilez du code C ou C ++ natif avec un compilateur moderne, vous n'avez pas à vous soucier des coûts d'un appel de fonction. L'optimiseur les intégrera chaque fois que cela sera approprié. En tant que passionné de micro-optimisation, je vois rarement des compilateurs prendre des décisions en ligne avec lesquelles moi ou mes benchmarks ne sommes pas d’accord.
Cody Gray

6
Par expérience personnelle, j’écris du code dans un langage propriétaire assez moderne en termes de capacités, mais les appels de fonctions sont ridiculement coûteux, au point où même les boucles typiques doivent être optimisées pour la vitesse: for(Integer index = 0, size = someList.size(); index < size; index++)au lieu de simplement for(Integer index = 0; index < someList.size(); index++). Le fait que votre compilateur ait été réalisé au cours des dernières années ne signifie pas nécessairement que vous pouvez renoncer au profilage.
phyrfox

5
@phyrfox, ça a du sens, obtenir la valeur de someList.size () en dehors de la boucle au lieu de l'appeler à chaque fois à travers la boucle. Cela est d'autant plus vrai s'il existe un risque de problème de synchronisation susceptible d'entraîner des conflits entre lecteurs et rédacteurs lors de l'itération. Dans ce cas, vous souhaitez également protéger la liste des modifications apportées lors de l'itération.
Craig

8
Méfiez-vous des petites fonctions trop loin, cela pourrait obscurcir le code aussi efficacement qu'une méga-fonction monolithique. Si vous ne me croyez pas, découvrez quelques-uns des gagnants de ioccc.org : certains codent tout en un seul main(), d'autres divisent le tout en une cinquantaine de fonctions minuscules et sont totalement illisibles. L'astuce consiste, comme toujours, à trouver un bon équilibre .
cmaster

Réponses:


148

Cela dépend de votre domaine.

Si vous écrivez du code pour un microcontrôleur à faible consommation, le coût d’appel de méthode peut être important. Mais si vous créez un site Web ou une application normale, le coût des appels de méthode sera négligeable par rapport au reste du code. Dans ce cas, il sera toujours plus intéressant de se concentrer sur les algorithmes et les structures de données appropriés plutôt que sur les micro-optimisations telles que les appels de méthodes.

Et il est également question de compiler en ligne les méthodes pour vous. La plupart des compilateurs sont suffisamment intelligents pour intégrer des fonctions dans la mesure du possible.

Et enfin, il y a la règle d'or de la performance: TOUJOURS PROFIL AVANT. N'écrivez pas de code "optimisé" basé sur des hypothèses. Si vous êtes inutilisé, écrivez les deux cas et voyez lequel est le meilleur.


13
Et par exemple, le compilateur HotSpot exécute une spéculation en ligne , qui est en quelque sorte en ligne même lorsque cela n’est pas possible.
Jörg W Mittag

49
En fait, dans une application Web, l' ensemble du code est probablement insignifiant par rapport à l'accès à la base de données et au trafic réseau ...
AnoE

72
En fait, je suis en train d’incorporer une puissance très faible avec un très vieux compilateur qui sait à peine ce que l’optimisation signifie, et croyez-moi, même si les appels de fonctions importent, ce n’est jamais le premier endroit où chercher de l’optimisation. Même dans ce domaine de niche, la qualité du code vient en premier dans ce cas.
Tim

2
@Mehrdad Même dans ce cas, je serais surpris qu'il n'y ait rien de plus pertinent à optimiser dans le code. Lors du profilage du code, je vois les choses beaucoup plus lourdes que les appels de fonctions, et c'est là qu'il convient de rechercher l'optimisation. Certains développeurs deviennent fous pour un ou deux LOC non optimisés, mais lorsque vous profilez le logiciel, vous vous rendez compte que la conception compte plus que cela, du moins pour la plus grande partie du code. Lorsque vous trouvez le goulot d'étranglement, vous pouvez essayer de l'optimiser, ce qui aura bien plus d'impact que l'optimisation arbitraire de bas niveau, telle que l'écriture de grandes fonctions pour éviter la surcharge des appels.
Tim le

8
Bonne réponse! Votre dernier point devrait être le premier: faites toujours le profil avant d’optimiser .
CJ Dennis

56

La surcharge des appels de fonction dépend entièrement de la langue et du niveau que vous optimisez.

À un niveau très bas, les appels de fonction et encore plus les appels de méthodes virtuelles peuvent être coûteux s'ils conduisent à des erreurs de prédiction de branche ou à des erreurs de cache du processeur. Si vous avez écrit assembler , vous saurez également que vous avez besoin de quelques instructions supplémentaires pour enregistrer et restaurer les registres autour d'un appel. Il n’est pas vrai qu’un compilateur «suffisamment intelligent» serait capable d’aligner les fonctions correctes pour éviter cette surcharge, car les compilateurs sont limités par la sémantique du langage (en particulier autour de fonctionnalités telles que la distribution de méthode d’interface ou les bibliothèques chargées dynamiquement).

À un niveau élevé, des langages comme Perl, Python, Ruby font beaucoup de comptabilité par appel de fonction, ce qui rend ceux-ci relativement coûteux. Ceci est aggravé par la méta-programmation. Une fois, j’ai accéléré un logiciel 3x Python simplement en soulevant des appels de fonction d’une boucle très chaude. Dans les codes critiques en termes de performances, les fonctions d'assistance en ligne peuvent avoir un effet notable.

Mais la grande majorité des logiciels n’est pas aussi critique en termes de performances que vous seriez en mesure de remarquer le temps système d’appel de fonction. Dans tous les cas, écrire du code propre et simple rapporte:

  • Si votre code n'est pas critique en termes de performances, cela facilite la maintenance. Même dans les logiciels critiques en termes de performances, la majorité du code ne sera pas un «point chaud».

  • Si votre code est critique en termes de performances, un code simple facilite la compréhension du code et la détection des opportunités d'optimisation. Les plus gros gains ne proviennent généralement pas de micro-optimisations telles que des fonctions en ligne, mais d'améliorations algorithmiques. Ou exprimé différemment: ne faites pas la même chose plus rapidement. Trouvez un moyen de faire moins.

Notez que «code simple» ne signifie pas «intégré dans mille fonctions minuscules». Chaque fonction introduit également un peu de surcharge cognitive - il est plus difficile de raisonner sur un code plus abstrait. À un moment donné, ces minuscules fonctions pourraient faire si peu que ne pas les utiliser simplifierait votre code.


16
Un administrateur de base de données très intelligent m'a dit une fois: "Normalisez jusqu'à ce que vous ayez mal, puis dénormalisez jusqu'à ce que ce ne soit pas le cas". Il me semble que cela pourrait être reformulé comme suit: "Extraire les méthodes jusqu'à ce que ça fasse mal, puis en ligne jusqu'à ce que ce ne soit pas le cas".
RubberDuck

1
En plus de la surcharge cognitive, il existe une surcharge symbolique dans les informations du débogueur, et généralement une surcharge dans les fichiers binaires finaux est inévitable.
Frank Hileman

En ce qui concerne les compilateurs intelligents, ils peuvent le faire, mais pas toujours. Par exemple, jvm peut intégrer des éléments basés sur un profil d’exécution avec un piège très bon marché / gratuit pour un chemin inhabituel ou une fonction polymorphe en ligne pour laquelle il n’existe qu’une implémentation de méthode / interface donnée, puis désoptimise cet appel à une fonction correctement polymorphe lorsque la nouvelle sous-classe est chargée de manière dynamique. runtime. Mais oui, il y a beaucoup de langues où de telles choses ne sont pas possibles et beaucoup de cas, même en format jvm, quand ce n'est pas rentable ou possible dans le cas général.
Commentaires

19

Presque tous les adages sur le code de réglage pour l'exécution sont des cas particuliers de la loi d' Amdahl . La déclaration courte et humoristique de la loi d'Amdahl est

Si une partie de votre programme prend 5% du temps d’exécution et que vous optimisez cette partie de sorte qu’elle ne prenne plus que 0 % du temps d’exécution, le programme dans son ensemble ne sera que 5% plus rapide.

(Il est tout à fait possible d’optimiser jusqu’à zéro pour cent du temps d’exécution: lorsque vous optimisez un programme volumineux et compliqué, vous avez toutes les chances de penser qu’il passe au moins une partie de son exécution à des tâches qu’il n’a pas du tout besoin de faire. .)

C'est pourquoi les gens disent normalement qu'ils ne s'inquiètent pas du coût des appels de fonction: peu importe leur coût, normalement, le programme dans son ensemble ne dépense qu'une infime fraction de son temps d'exécution en temps système, de sorte que son accélération n'aide pas beaucoup. .

Mais si vous pouvez tirer un truc qui accélère tous les appels de fonction, ce truc en vaut probablement la peine. Les développeurs de compilateurs passent beaucoup de temps à optimiser les fonctions "prologues" et "épilogues", car cela profite à tous les programmes compilés avec ce compilateur, même si ce n'est qu'un tout petit peu pour chacun.

Et, si vous avez des raisons de croire qu'un programme est passé beaucoup de son exécution juste faire des appels de fonction, alors vous devriez commencer à penser à savoir si certains de ces appels de fonction ne sont pas nécessaires. Voici quelques règles de base pour savoir quand vous devez le faire:

  • Si l'exécution d'une fonction par invocation est inférieure à une milliseconde, mais que cette fonction est appelée des centaines de milliers de fois, elle devrait probablement être en ligne.

  • Si un profil du programme indique des milliers de fonctions et qu'aucune d'elles ne nécessite plus de 0,1% environ de son exécution, le temps système de traitement des appels de fonction est probablement important.

  • Si vous avez un " code de lasagne " dans lequel il existe de nombreuses couches d'abstraction qui ne fonctionnent pratiquement pas au-delà de l'envoi à la couche suivante, et que toutes ces couches sont implémentées avec des appels de méthodes virtuelles, il y a de fortes chances que le processeur gaspille beaucoup de temps sur les stands de pipeline indirects. Malheureusement, le seul remède à cela est de se débarrasser de certaines couches, ce qui est souvent très difficile.


7
Méfiez-vous simplement des trucs coûteux réalisés en profondeur dans des boucles imbriquées. J'ai optimisé une fonction et obtenu un code fonctionnant 10 fois plus vite. C'était après que le profileur ait désigné le coupable. (Il a été appelé à plusieurs reprises, en boucles de O (n ^ 3) à un petit n O (n ^ 6).)
Loren Pechtel

"Malheureusement, le seul remède à cela est de se débarrasser de certaines couches, ce qui est souvent très difficile." - Cela dépend beaucoup de votre compilateur de langage et / ou de la technologie de la machine virtuelle. Si vous pouvez modifier le code pour permettre au compilateur de s’inscrire plus facilement en ligne (par exemple, en utilisant des finalclasses et des méthodes, le cas échéant, en Java, ou des virtualméthodes autres que les méthodes en C # ou C ++), l’indirection peut être éliminée par le compilateur / runtime et vous ' Vous verrez un gain sans restructuration massive. Comme @JorgWMittag le souligne ci-dessus, la JVM peut même s'aligner dans les cas où il n'est pas prouvable que l'optimisation soit ...
Jules

... valide, il se peut donc que cela se fasse dans votre code malgré la superposition de toute façon.
Jules

@Jules S'il est vrai que les compilateurs JIT peuvent effectuer une optimisation spéculative, cela ne signifie pas pour autant que ces optimisations sont appliquées de manière uniforme. En ce qui concerne plus particulièrement Java, mon expérience est que la culture de développement favorise les couches superposées menant à des piles d’appel extrêmement profondes. De manière anecdotique, cela contribue à la sensation de lourdeur et de gonflement de nombreuses applications Java. Cette architecture hautement stratifiée fonctionne par rapport à l'environnement d'exécution JIT, que les couches soient techniquement ou non en ligne. JIT n'est pas une solution miracle capable de résoudre automatiquement les problèmes structurels.
amon

@amon Mon expérience avec le "code de lasagne" provient de très grandes applications C ++ avec beaucoup de code datant des années 1990, lorsque la hiérarchie des objets profondément imbriquée et COM étaient à la mode. Les compilateurs C ++ déploient des efforts héroïques pour éliminer les pénalités d'abstraction imposées par des programmes comme celui-ci, mais vous pouvez toujours les voir dépenser une fraction importante de la durée d'exécution sur des stalles de pipeline à branchement indirect (et une autre partie importante des échecs d'I-cache). .
dimanche

17

Je vais contester cette citation:

Il y a presque toujours un compromis entre écrire du code lisible et écrire du code performant.

C'est une déclaration vraiment trompeuse et une attitude potentiellement dangereuse. Il existe des cas spécifiques où vous devez faire un compromis, mais en général, les deux facteurs sont indépendants.

Un exemple de compromis nécessaire est lorsque vous avez un algorithme simple par opposition à un algorithme plus complexe mais plus performant. Une implémentation de table de hachage est clairement plus complexe qu'une implémentation de liste chaînée, mais la recherche sera plus lente. Vous devrez donc peut-être échanger de la simplicité (ce qui est un facteur de lisibilité) en termes de performances.

En ce qui concerne le temps système d’appel de fonction, transformer un algorithme récursif en itératif peut présenter un avantage significatif en fonction de l’algorithme et du langage. Mais il s’agit là encore d’un scénario très spécifique et, en général, la surcharge des appels de fonction sera négligeable ou optimisée.

(Certains langages dynamiques comme Python entraînent une surcharge d’appel de méthode. Toutefois, si les performances deviennent un problème, vous ne devriez probablement pas utiliser Python en premier lieu.)

La plupart des principes de code lisible - mise en forme cohérente, noms d'identifiant significatifs, commentaires appropriés et utiles, etc., n'ont aucun effet sur les performances. Et certains, comme l'utilisation d'énums plutôt que de chaînes, présentent également des avantages en termes de performances.


5

La surcharge de l'appel de fonction est sans importance dans la plupart des cas.

Cependant, le plus gros gain de code en ligne est l' optimisation du nouveau code après l'inline .

Par exemple, si vous appelez une fonction avec un argument constant, l'optimiseur peut maintenant plier cet argument de manière constante, comme auparavant. Si l'argument est un pointeur de fonction (ou lambda), l'optimiseur peut désormais intégrer également les appels à cette lambda.

C'est l'une des principales raisons pour lesquelles les fonctions virtuelles et les pointeurs de fonction ne sont pas attrayants, car vous ne pouvez les aligner du tout que si le pointeur de fonction réel a été constamment plié jusqu'au site d'appel.


5

En supposant que les performances importent pour votre programme et qu’il comporte effectivement de très nombreux appels, le coût peut ou non être important, en fonction du type d’appel.

Si la fonction appelée est petite et que le compilateur est en mesure de la mettre en ligne, le coût sera essentiellement nul. Les compilateurs modernes et les implémentations de langage ont JIT, des optimisations de temps de liaison et / ou des systèmes de modules conçus pour maximiser la capacité à intégrer des fonctions lorsque cela est bénéfique.

OTOH, il y a un coût non évident pour les appels de fonction: leur simple existence peut empêcher les optimisations du compilateur avant et après l'appel.

Si le compilateur ne peut pas raisonner sur le rôle de la fonction appelée (par exemple, son envoi virtuel / dynamique ou une fonction dans une bibliothèque dynamique), il peut être amené à assumer avec pessimisme que la fonction peut avoir un effet secondaire quelconque: lever une exception, modifier état global, ou changer toute mémoire vue par des pointeurs. Le compilateur devra peut-être sauvegarder des valeurs temporaires dans la mémoire vive et les relire après l'appel. Il ne sera pas en mesure de réordonner les instructions autour de l'appel. Il risque donc d'être incapable de vectoriser des boucles ou de lever des calculs redondants en boucle.

Par exemple, si vous appelez inutilement une fonction à chaque itération de boucle:

for(int i=0; i < /* gasp! */ strlen(s); i++) x ^= s[i];

Le compilateur peut savoir que c'est une fonction pure et le déplacer hors de la boucle (dans un cas aussi terrible que cet exemple, même, l'algorithme accidentel O (n ^ 2) est défini sur O (n)):

for(int i=0, end=strlen(s); i < end; i++) x ^= s[i];

Et puis peut-être même réécrivez la boucle pour traiter 4/8/16 éléments à la fois en utilisant des instructions larges / SIMD.

Mais si vous ajoutez un appel à un code opaque dans la boucle, même si l'appel ne fait rien et qu'il est très bon marché lui-même, le compilateur doit assumer le pire: l'appel appellera une variable globale pointant vers la même mémoire que le schangement. son contenu (même s'il fait partie de constvotre fonction, il peut ne pas être constailleurs), rendant l'optimisation impossible:

for(int i=0; i < strlen(s); i++) {
    x ^= s[i];
    do_nothing();
}

3

Ce vieil article pourrait répondre à votre question:

Guy Lewis Steele, Jr .. "Débarrasser le mythe" Appel de procédure coûteuse ", ou, Implémentations de procédure appelées considérées néfastes, ou Lambda: The Ultimate GOTO". MIT AI Lab. Mémo AI Lab AIM-443. Octobre 1977.

Abstrait:

Le folklore indique que les déclarations GOTO sont "peu coûteuses", alors que les procédures d'appel sont "coûteuses". Ce mythe est en grande partie dû à des implémentations linguistiques mal conçues. La croissance historique de ce mythe est considérée. On discute à la fois des idées théoriques et d’une mise en œuvre existante qui réfutent ce mythe. Il est démontré que l'utilisation sans restriction d'appels de procédure permet une grande liberté stylée. En particulier, tout diagramme peut être écrit sous forme de programme "structuré" sans introduire de variables supplémentaires. La difficulté liée à l'instruction GOTO et à l'appel de procédure est caractérisée par un conflit entre des concepts de programmation abstraits et des constructions de langage concrètes.


12
Je doute fortement qu'un article ancien réponde à la question de savoir si "les coûts des appels de fonctions comptent encore dans les compilateurs modernes ".
Cody Gray

6
@CodyGray Je pense que la technologie du compilateur aurait dû évoluer depuis 1977. Donc, si les appels de fonction peuvent être rendus économiques en 1977, nous devrions pouvoir le faire maintenant. Donc la réponse est non. Bien entendu, cela suppose que vous utilisiez une implémentation linguistique décente pouvant effectuer des tâches telles que l’intégration de fonctions.
Alex Vong

4
@AlexVong S'appuyer sur les optimisations du compilateur de 1977 revient à se fier aux tendances des prix des produits de base à l'âge de pierre. Tout a trop changé. Par exemple, la multiplication était remplacée par l’accès à la mémoire, ce qui représentait une opération moins coûteuse. Actuellement, c'est un facteur extrêmement coûteux. Les appels de méthodes virtuelles sont relativement beaucoup plus coûteux qu’avant (accès à la mémoire et prédictions erronées de branches), mais ils peuvent souvent être optimisés et l’appel de méthodes virtuelles peut même être intégré (Java le fait tout le temps). exactement zéro. Il n'y avait rien de tel en 1977.
vendredi

3
Comme d'autres l'ont fait remarquer, ce ne sont pas uniquement des modifications de la technologie du compilateur qui ont invalidé des recherches anciennes. Si les compilateurs avaient continué à s'améliorer alors que les microarchitectures étaient restées pratiquement inchangées, les conclusions du document resteraient valables. Mais ce n'est pas arrivé. Les microarchitectures ont davantage changé que les compilateurs. Les choses qui étaient rapides sont maintenant relativement lentes.
Cody Gray

2
@AlexVong Pour être plus précis sur les modifications de la CPU qui rendent ce papier obsolète: en 1977, un accès à la mémoire principale était un cycle unique de la CPU. Aujourd'hui, même un simple accès au cache L1 (!) A une latence de 3 à 4 cycles. Maintenant, les appels de fonction sont assez lourds en accès mémoire (création de trame de pile, sauvegarde de l'adresse de retour, sauvegarde des registres pour les variables locales), ce qui permet de réduire facilement les coûts d'un appel de fonction à 20 cycles ou plus. Si votre fonction ne fait que réorganiser ses arguments, et peut-être ajoute un autre argument constant à passer à un appel interjeté, la surcharge est presque de 100%.
cmaster

3
  • Dans C ++, méfiez-vous de la conception d'appels de fonction qui copient les arguments, la valeur par défaut est "passer par la valeur". La surcharge de l'appel de fonction due aux registres de sauvegarde et à d'autres éléments liés à la pile peut être submergée par une copie non intentionnelle (et potentiellement très coûteuse) d'un objet.

  • Il existe des optimisations liées aux images de pile que vous devez étudier avant d'abandonner le code hautement factorisé.

  • La plupart du temps, lorsque j'ai dû faire face à un programme lent, j'ai constaté que les modifications algorithmiques produisaient des accélérations bien plus rapides que les appels de fonction en ligne. Par exemple: un autre ingénieur a refait un analyseur syntaxique qui a rempli une structure de carte de cartes. Dans ce cadre, il a supprimé un index mis en cache d'une carte à un autre associé de manière logique. C’était un bon choix pour la robustesse du code, mais il a rendu le programme inutilisable en raison d’un facteur de ralentissement de 100 dû à l’exécution d’une recherche de hachage pour tous les accès futurs par rapport à l’utilisation de l’index stocké. Le profilage a montré que la majeure partie du temps était consacrée à la fonction de hachage.


4
Le premier conseil est un peu vieux. Depuis C ++ 11, le déplacement est possible. En particulier, pour les fonctions qui doivent modifier leurs arguments en interne, prendre un argument par valeur et le modifier sur place peut être le choix le plus efficace.
MSalters

@MSalters: Je pense que vous avez confondu "en particulier" avec "en outre" ou quelque chose. La décision de transmettre des copies ou des références existait avant C ++ 11 (je sais que vous le connaissez).
phresnel

@phresnel: Je pense avoir bien compris. Le cas particulier auquel je fais référence est le cas où vous créez un temporaire dans l'appelant, le déplacez vers un argument, puis le modifiez dans l'appelé. Ce n'était pas possible avant C ++ 11, car C ++ 03 ne peut pas / ne va pas lier une référence non const à un temporaire.
MSalters

@MSalters: Ensuite, j'ai mal compris votre commentaire lors de la première lecture. Il me semblait que vous laissiez entendre qu'avant C ++ 11, passer par valeur n'était pas une chose à faire si l'on voulait modifier la valeur transmise.
phresnel

L'avènement du 'déplacement' aide le plus significativement au retour d'objets qui sont construits plus commodément dans la fonction qu'à l'extérieur et qui sont transférés par référence. Avant cela, le retour d'un objet à partir d'une fonction invoquait une copie, ce qui représentait souvent un déplacement coûteux. Cela ne traite pas des arguments de fonction. Je mets soigneusement le mot "conception" dans le commentaire car il faut explicitement donner au compilateur le droit de "se déplacer" dans les arguments de la fonction (syntaxe &&). J'ai pris l'habitude de "supprimer" les constructeurs de copies pour identifier les endroits où cela est utile.
user2543191

2

Oui, une prévision de branche manquée est plus coûteuse sur le matériel moderne qu'il ne l'était il y a plusieurs décennies, mais les compilateurs sont devenus beaucoup plus intelligents pour l'optimiser.

Par exemple, considérons Java. A première vue, le préfixe d'appel de fonction devrait être particulièrement dominant dans cette langue:

  • fonctions minuscules sont répandues en raison de la convention JavaBean
  • Les fonctions par défaut sont virtuelles et sont généralement
  • l'unité de compilation est la classe; le moteur d'exécution prend en charge le chargement de nouvelles classes à tout moment, y compris les sous-classes qui remplacent les méthodes précédemment monomorphes

Horrifié par ces pratiques, le programmeur C moyen prédirait que Java doit être au moins un ordre de grandeur plus lent que le C. Il y a 20 ans, il aurait eu raison. Les benchmarks modernes placent toutefois du code Java idiomatique à quelques pour cent du code C équivalent. Comment est-ce possible?

Une des raisons est que les appels de fonctions en ligne des machines virtuelles modernes (JVM) modernes sont bien sûr de mise. Il le fait en utilisant l'inline spéculative:

  1. Le code fraîchement chargé s'exécute sans optimisation. Au cours de cette étape, pour chaque site d’appel, la machine virtuelle Java garde une trace des méthodes qui ont été réellement appelées.
  2. Une fois que le code a été identifié comme étant un hotspot de performance, le moteur d’exécution utilise ces statistiques pour identifier le chemin d’exécution le plus probable.

C'est le code:

int x = point.getX();

est réécrit pour

if (point.class != Point) GOTO interpreter;
x = point.x;

Et bien sûr, le moteur d’exécution est suffisamment intelligent pour passer à la vérification du type tant que le point n’est pas attribué, ou le supprimer si le type est connu du code appelant.

En résumé, si même Java gère l'inlignage automatique de méthodes, il n'y a aucune raison inhérente pour laquelle un compilateur ne peut pas prendre en charge l'inlining automatique, et toutes les raisons de le faire, car l'inlining est très bénéfique pour les processeurs modernes. Je peux donc difficilement imaginer un compilateur grand public moderne ignorant ces stratégies d'optimisation les plus élémentaires, et présumerais qu'un compilateur en est capable, sauf preuve du contraire.


4
"Il n'y a aucune raison inhérente pour laquelle un compilateur ne pourrait pas prendre en charge l'inline automatique" - il y en a une. Vous avez parlé de la compilation JIT, qui équivaut à une modification automatique du code (qu'un système d'exploitation peut empêcher pour des raisons de sécurité) et à la possibilité d'optimiser automatiquement le programme complet en fonction du profil. Un compilateur AOT pour un langage qui permet la liaison dynamique n’en sait pas assez pour pouvoir dé-virtualiser et intégrer tout appel. OTOH: un compilateur AOT a le temps d'optimiser tout ce qu'il peut, un compilateur JIT n'a que le temps de se concentrer sur des optimisations peu coûteuses dans les points chauds. Dans la plupart des cas, JIT est légèrement désavantagé.
amon

2
Dites-moi un système d'exploitation qui empêche l'exécution de Google Chrome "à cause de la sécurité" (V8 compile JavaScript en code natif au moment de l'exécution). De plus, vouloir inline AOT est pas tout à fait une raison inhérente (il n'est pas déterminée par la langue, mais l'architecture que vous choisissez pour votre compilateur), et en liaison dynamique fait inhiber inline AOT dans toutes les unités de compilation, il n'inhibe pas inline dans les compilation unités, où la plupart des appels ont lieu. En fait, une mise en ligne utile est sans doute plus facile dans un langage qui utilise moins les liaisons dynamiques que Java.
Meriton - en grève le

4
Notamment, iOS empêche JIT pour les applications non privilégiées. Chrome ou Firefox doivent utiliser la vue Web fournie par Apple au lieu de leurs propres moteurs. Le bon point cependant que AOT contre JIT est un niveau de mise en œuvre, pas un choix de niveau de langue.
amon

@meriton Windows 10 S et les systèmes d'exploitation des consoles de jeux vidéo ont également tendance à bloquer les moteurs JIT tiers.
Damian Yerrick

2

Comme d’autres le disent, vous devriez d’abord mesurer la performance de votre programme et vous ne constaterez probablement aucune différence dans la pratique.

Néanmoins, d'un point de vue conceptuel, je pensais éclaircir quelques points qui sont confondus dans votre question. Tout d'abord, vous demandez:

Les coûts des appels de fonctions comptent-ils encore dans les compilateurs modernes?

Remarquez les mots clés "fonction" et "compilateurs". Votre citation est subtile différente:

N'oubliez pas que le coût d'un appel de méthode peut être important, en fonction de la langue.

Il s’agit de méthodes , au sens orienté objet.

Bien que "fonction" et "méthode" soient souvent utilisés de manière interchangeable, il existe des différences quant au coût (dont vous parlez) et à la compilation (qui est le contexte que vous avez donné).

Nous devons en particulier connaître la répartition statique par rapport à la répartition dynamique . Je vais ignorer les optimisations pour le moment.

Dans un langage comme C, nous appelons généralement des fonctions à dispatch statique . Par exemple:

int foo(int x) {
  return x + 1;
}

int bar(int y) {
  return foo(y);
}

int main() {
  return bar(42);
}

Lorsque le compilateur voit l'appel foo(y), il sait à quelle fonction son foonom fait référence, ainsi le programme de sortie peut passer directement à la foofonction, ce qui est relativement peu coûteux. C'est ce que l'envoi statique signifie.

L'alternative est la répartition dynamique , où le compilateur ne sait pas quelle fonction est appelée. Voici un exemple de code Haskell (car l’équivalent C serait désordonné!):

foo x = x + 1

bar f x = f x

main = print (bar foo 42)

Ici, la barfonction appelle son argument f, ce qui pourrait être n'importe quoi. Par conséquent, le compilateur ne peut pas simplement compiler barune instruction de saut rapide, car il ne sait pas où aller. Au lieu de cela, le code que nous générons pour bardéréférencera fpour rechercher la fonction à laquelle il pointe, puis y accéder. C'est ce que l'envoi dynamique signifie.

Ces deux exemples concernent des fonctions . Vous avez mentionné les méthodes , qui peuvent être considérées comme un style particulier de fonction à distribution dynamique. Par exemple, voici quelques exemples de Python:

class A:
  def __init__(self, x):
    self.x = x

  def foo(self):
    return self.x + 1

def bar(y):
  return y.foo()

z = A(42)
bar(z)

L' y.foo()appel utilise la répartition dynamique, puisqu'il recherche la valeur de la foopropriété dans l' yobjet et appelle tout ce qu'il trouve. il ne sait pas qu'il yaura une classe A, ou que la Aclasse contient une foométhode, nous ne pouvons donc pas y aller directement.

OK, c'est l'idée de base. Notez que l' envoi statique est plus rapide que l' envoi dynamique indépendamment du fait que nous compilons ou interprétons; tout le reste étant égal. Le déréférencement entraîne un coût supplémentaire dans les deux sens.

Alors, comment cela affecte-t-il les compilateurs modernes et optimisants?

La première chose à noter est que la répartition statique peut être optimisée plus lourdement: lorsque nous savons à quelle fonction nous accédons, nous pouvons faire des choses comme l’intégration en ligne. Avec la répartition dynamique, nous ne savons pas que nous sautons jusqu'au moment de l'exécution, de sorte que nous ne pouvons pas optimiser beaucoup.

Deuxièmement, dans certaines langues, il est possible de déduire où certaines dépêches dynamiques finiront par sauter, et donc de les optimiser en répartition statique. Cela nous permet d’effectuer d’autres optimisations comme l’alignement, etc.

Dans l'exemple Python ci-dessus, une telle inférence est plutôt sans espoir, car Python permet à un autre code de remplacer les classes et les propriétés. Il est donc difficile d'en déduire ce qui va se passer dans tous les cas.

Si notre langage nous permet d'imposer davantage de restrictions, par exemple en limitant la yclasse à l' Aaide d'une annotation, nous pourrions utiliser cette information pour déduire la fonction cible. Dans les langues avec sous-classes (c'est-à-dire presque toutes les langues avec classes!), Cela ne suffit pas, car il ypeut en fait avoir une (sous) classe différente. Nous aurions donc besoin d'informations supplémentaires comme les finalannotations de Java pour savoir exactement quelle fonction sera appelée.

Haskell n'est pas un langage OO, mais nous pouvons déduire la valeur de finlining bar(qui est statiquement expédié) dans main, en remplaçant foopar y. Puisque la cible de fooin mainest connue de manière statique, l'appel est envoyé de manière statique et sera probablement totalement aligné et optimisé (ces fonctions étant petites, le compilateur est plus susceptible de les aligner; nous ne pouvons cependant pas compter sur cela en général. ).

Le coût revient donc à:

  • La langue envoie-t-elle votre appel de manière statique ou dynamique?
  • Si c'est le dernier cas, le langage permet-il à l'implémentation d'inférer la cible en utilisant d'autres informations (par exemple, types, classes, annotations, inline, etc.)?
  • Dans quelle mesure la répartition statique (déduite ou non) peut-elle être optimisée?

Si vous utilisez un langage "très dynamique", avec une bonne répartition dynamique et peu de garanties pour le compilateur, chaque appel engendrera un coût. Si vous utilisez un langage "très statique", un compilateur mature produira un code très rapide. Si vous êtes entre les deux, cela peut dépendre de votre style de codage et du degré d'implémentation de votre mise en œuvre.


1
Je ne suis pas d'accord sur le fait qu'appeler une fermeture (ou un pointeur de fonction ) - comme votre exemple Haskell - est une répartition dynamique. La répartition dynamique implique un certain calcul (par exemple, l'utilisation de vtable ) pour obtenir cette fermeture; elle est donc plus coûteuse que les appels indirects. Sinon, bonne réponse.
Basile Starynkevitch

2

N'oubliez pas que le coût d'un appel de méthode peut être important, en fonction de la langue. Il y a presque toujours un compromis entre écrire du code lisible et écrire du code performant.

Malheureusement, cela dépend fortement de:

  • la chaîne d'outils du compilateur, y compris le JIT, le cas échéant,
  • le domaine.

Tout d’abord, la première loi d’optimisation des performances est le profil d’abord . Il existe de nombreux domaines dans lesquels les performances de la partie logicielle sont sans rapport avec celles de l’ensemble de la pile: appels de base de données, opérations réseau, opérations OS, ...

Cela signifie que les performances du logiciel sont complètement hors de propos, même si cela n'améliore pas la latence, l'optimisation du logiciel peut entraîner des économies d'énergie et des économies matérielles (ou des économies de batterie pour les applications mobiles), ce qui peut avoir de l'importance.

Cependant, ceux-ci ne peuvent généralement PAS être remarqués, et souvent les améliorations algorithmiques l'emportent largement sur les micro-optimisations.

Avant d’optimiser, vous devez donc comprendre pourquoi vous optimisez ... et si cela en vaut la peine.


Maintenant, en ce qui concerne les performances logicielles pures, elles varient énormément d'une chaîne à une autre.

Un appel de fonction entraîne deux coûts:

  • le coût du temps d'exécution,
  • le temps de compilation a coûté.

Le coût d'exécution est plutôt évident. pour effectuer un appel de fonction, une certaine quantité de travail est nécessaire. En utilisant C sur x86 par exemple, un appel de fonction nécessitera (1) de renverser des registres dans la pile, (2) de pousser des arguments dans les registres, d'effectuer l'appel et, par la suite (3) de restaurer les registres à partir de la pile. Voir ce résumé des conventions d’appel pour voir le travail impliqué .

Ce registre déversé / restauré prend un nombre de fois non négligeable (des dizaines de cycles de processeur).

On s’attend généralement à ce que ce coût soit trivial par rapport au coût réel d’exécution de la fonction, mais certains modèles sont contre-productifs ici: accesseurs, fonctions gardées par une condition simple, etc.

Outre les interprètes , un programmeur espère donc que son compilateur ou JIT optimisera les appels de fonctions inutiles; bien que cet espoir puisse parfois ne pas porter ses fruits. Parce que les optimiseurs ne sont pas magiques.

Un optimiseur peut détecter qu'un appel de fonction est trivial et en ligne : il s'agit essentiellement de copier / coller le corps de la fonction sur le site de l'appel. Ce n’est pas toujours une bonne optimisation (peut induire un gonflement), mais en général en vaut la peine, car l’inline expose le contexte et le contexte permet davantage d’optimisations.

Un exemple typique est:

void func(condition: boolean) {
    if (condition) {
        doLotsOfWork();
    }
}

void call() { func(false); }

Si funcest inline, l'optimiseur réalisera que la branche est jamais prise, et d' optimiser callà void call() {}.

En ce sens, les appels de fonction, en masquant les informations de l'optimiseur (s'ils ne sont pas encore en ligne), peuvent inhiber certaines optimisations. Les appels de fonctions virtuelles en sont particulièrement responsables, car la dévirtualisation (prouver quelle fonction est appelée en dernier lieu au moment de l'exécution) n'est pas toujours facile.


En conclusion, mon conseil est d'écrire d' abord clairement , en évitant une pessimisation algorithmique prématurée (complexité cubique ou pire morsures rapidement), puis d'optimiser uniquement ce qui doit être optimisé.


1

"N'oubliez pas que le coût d'un appel de méthode peut être important, en fonction de la langue. Il y a presque toujours un compromis entre écrire du code lisible et écrire du code performant."

Sous quelles conditions cette déclaration citée est-elle toujours valable de nos jours compte tenu de la riche industrie des compilateurs modernes performants?

Je vais juste dire carrément jamais. Je crois que la citation est imprudente à jeter simplement là-bas.

Bien sûr, je ne dis pas la vérité complète, mais je ne me soucie pas d’être aussi véridique. C’est comme dans Matrix, j’avais oublié si c’était 1, 2 ou 3 - je pense que c’est celui avec la sexy actrice italienne avec les gros melons (je n’ai vraiment aimé que le premier), quand Dame d'oracle a dit à Keanu Reeves: "Je viens de vous dire ce que vous aviez besoin d'entendre", ou quelque chose du genre, c'est ce que je veux faire maintenant.

Les programmeurs n'ont pas besoin d'entendre ça. S'ils connaissent les profileurs à la main et que la citation est un peu applicable à leurs compilateurs, ils le sauront déjà et l'apprendront de la bonne manière à condition de bien comprendre la sortie de leur profilage et pourquoi certains appels de feuille sont des points chauds, grâce à la mesure. S'ils ne sont pas expérimentés et qu'ils n'ont jamais défini leur code, c'est la dernière chose dont ils ont besoin d'entendre, à savoir qu'ils devraient commencer à compromettre superstitieusement la manière dont ils écrivent le code au point de tout aligner avant même d'identifier les points chauds dans l'espoir que cela va se produire. devenir plus performant.

Quoi qu'il en soit, pour une réponse plus précise, cela dépend. Certaines des conditions remplies par bateau figurent déjà parmi les bonnes réponses. Les conditions possibles qui consistent à choisir un seul langage sont déjà énormes, comme le C ++, qui devrait être intégré de manière dynamique dans les appels virtuels et quand il peut être optimisé et sous quels compilateurs et même éditeurs de liens, et qui justifie déjà une réponse détaillée, encore moins d'essayer s'attaquer aux conditions dans tous les langages et compilateurs possibles. Mais je vais ajouter en haut, "qui s'en soucie?" Même si je travaille dans des domaines tels que le lancer de rayons dans des domaines critiques en termes de performances, la dernière chose que je commence à faire dès le départ est la mise en place manuelle de méthodes en ligne avant de prendre des mesures.

Je crois que certaines personnes deviennent trop zélées pour suggérer que vous ne devriez jamais faire de micro-optimisation avant de mesurer. Si l'optimisation pour la localité de référence compte comme une micro-optimisation, alors je commence souvent à appliquer ces optimisations dès le début avec un état d'esprit de conception orienté données dans des domaines dont je sais que certains seront essentiels à la performance (code de traçage, par exemple), sinon, je sais que je vais devoir réécrire de grandes sections peu de temps après avoir travaillé dans ces domaines pendant des années. L'optimisation de la représentation des données pour les accès en cache peut souvent apporter le même type d'amélioration des performances que celle des améliorations algorithmiques, à moins que nous ne parlions de temps quadratique à linéaire.

Mais je ne vois jamais une bonne raison de commencer à aligner avant les mesures, d’autant plus que les profileurs sont bien placés pour révéler ce qui pourrait bénéficier de l’inline, mais pas pour savoir ce qui pourrait bénéficier de ne pas être en ligne (et ne pas en ligne peut effectivement rendre le code plus rapidement L'appel de fonction non doublé est un cas rare, améliorant la localité de référence pour le icache pour le code dynamique et permettant même parfois aux optimiseurs de faire un meilleur travail pour le chemin d'exécution de la casse courante).

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.