Texte écrit dans le contexte d’une Question d’utilisateur sur Quora: « les fonctions sont couteuses à appeler, ne ferais-je pas mieux d’inline (copier/coller) au niveau de l’appelant le corps des fonction pour aller plus vite ? »
0. Règle zéro de l’optimisation : NE PAS OPTIMISER.
Avant toute modification de code, rappelle-toi toujours pourquoi tu le fais.
Les trois points qui suivent sont des variantes « de ne pas optimiser », donc toujours règle zéro.
00. Règle Zéro-Zéro : MESURER d’abord, N’OPTIMISER qu’ensuite
Si la réponse est « parce que c’est trop lent », un développeur expérimenté te demandera immédiatement : « As-tu mesuré ? » Sans mesure (même un simple temps d’exécution), comment sauras-tu si ton changement a amélioré ou dégradé les performances ?
Supposons que tu aies mesuré, et que le code soit effectivement trop lent. Même dans ce cas, il y a plusieurs étapes à franchir avant de s’attaquer aux micro optimisations genre inliner les appels de fonction.
000. Règle Triple Zéro : CLEAN CODE d’abord, N’OPTIMISER qu’ensuite
Dans la pratique, la réponse à « Pourquoi modifier ce code ? » est rarement liée à la performance. Dans 99% des cas, c’est pour :
- Ajouter une fonctionnalité (le code doit faire quelque chose de nouveau) ;
- Améliorer la maintenabilité (plus lisible, plus facile à modifier, à tester, à comprendre pour un autre développeur ou pour toi dans six mois) ;
- Le rendre plus générique (ce qui, à mon avis, est une source de complexité prématurée presque aussi fréquente et néfaste que l’optimisation prématurée… mais c’est un autre débat).
En tout état de cause, il n’est raisonnable d’essayer d’optimiser du code que lorsque celui-ci est maintenable. Certains parlent de « Clean Code ».
Ce qui signifie:
- Lisible : un autre programmeur doit pouvoir le comprendre et reprendre le code ;
- Modifiable : une modification en vue de l’ajout d’une fonctionnalité devrait autant que possible rester locale. Que ceux que le sujet intéresse se renseignent sur les Code Smells et le refactoring, par exemple en lisant le livre Tidy First de Kent Beck.
- Couvert par des tests : il est très facile de « casser » un programme en tentant de l’optimiser, il est donc important de commencer par travailler sur la couverture de tests ; bonus gratuit : lorsqu’un programme est correctement couvert par des tests on peut souvent utiliser ces tests pour mesurer les performances, du moins s’il s’agit de tests utilisant des jeux de données réalistes et suffisamment grand pour être représentatif. Dans l’idéal on aura les deux types de test: tests courts et rapides vérifiant la logique du code et tests scénarisés un peu plus volumineux pour mettre en évidence la validité de cas réels et tester les performances. ; second bonus gratuit : les tests, notamment les tests scénarisés peuvent aussi servir de points d’entrée pour expliquer l’utlisation à un autre développeur.
Il est très fréquent qu’une fois rendu simple et lisible le code devienne aussi plus performant et que le besoin d’optimisation disparaisse.
0000. Règle Quadruple Zéro : N’OPTIMISER QUE LA OÙ ÇA COMPTE
Il existe quantité d’outils de profiling (perf, VTune, valgrind, cProfile pour Python) pour identifier où le temps est vraiment perdu. Inutile d’optimiser des portions de code ou des fonctionnalités qui ne contribuent que marginalement à la perte de performances. Il sera toujours temps de le faire lorsque les portions critiques auront été optimisées.
1. D’abord la Complexité algorithmique
C’est le premier point à vérifier. Un algorithme mal choisi peut rendre ton code exponentiellement lent, surtout si la taille des données est importante. Pose-toi la question : « Mon algorithme est-il adapté à la taille du problème ? » Si non, change d’algorithme, parallélise si possible, etc.
Attention : la parallèlisation même lorsque c’est possible a un coût: coût de synchronisation, coût de partage des données. Cela signifie qu’une fois parallèlisé/threadé la consommation totale de CPU va normalement augmenter. Ce n’est pas forcément un problème, les CPU actuels étant pourvus de plusieurs cores souvent inoccupés, mais il faut en ê conscient.
Il faut aussi savoir ce qu’on cherche à optimiser, optimiser un traitement en boucle ou optimiser une latence (pour qu’un programme réponde le plus vite possible), ne se fait pas exactement de la même façon, même s’il existe une logique commune (identifier et réduire le chemin critique).
En ce qui concerne les données facilement parallèlisables (vectorielles) les compilateurs modernes savent assez bien le faire. Quid des languages de haut niveau comme Python ? Dans ce cas les bibliothèques appelées gèrent peut-être elles même le parallèlisme, utilisez ces fonctionnalités lorsque c’est possible.
2. Goulets d’étranglement externes (I/O)
Si ton programme passe son temps à attendre (disque, réseau, base de données, entrée utilisateur…), il est I/O bound. Dans ce cas, optimiser le code CPU est inutile : le processeur est déjà inactif. Passer de Python à C++ ne changera rien. C’est une situation très courante.
3. Localité des données
Si le processeur est bien utilisé mais que le code reste lent, regarde comment les données sont accédées. Copier des blocs mémoire, avoir une mauvaise localité des données (cache misses), etc., peut fortement dégrader les performances. L’idéal : travailler autant que possible dans les registres ou le cache.
L’équivalent lorsqu’on travaille avec une base de données peut consister à conserver les données utiles dans un redis, là aussi on aura des problématiques de localité des données.
4. Dépendances et parallélisation
Au niveau des instructions (en C/C++ compilé en assembleur), les dépendances read-after-write limitent le parallélisme possible sur les processeurs modernes. Évite de réutiliser des variables, simplifie les branches, etc.
En termes de bases de données, le problème équivalent se manifeste au niveau des écritures dans les tables qui verrouillent les accès aux lecteurs. Un read attends que le write soit terminé. Il est souvent pertinent de limiter les tables modifiables et de leur préférer celles où seuls les ajouts sont autorisés (plus des purges des données anciennes).
5. Bulles de pipelines
Les bulles de pipeline (stalls), c’est à dire les mises attentes du CPU pour obtenir de nouvelles instructions à exécuter pénalisent beaucoup les performances. Les processeurs modernes divisent en effets les instructions en dizaines de cycles processeurs. Lors d »un appel de fonction lorsque la destination ne peut pas être anticipée on tombe dans ce cas. Mais ils ne surviennent pas que lors des appels de fonction : toutes les branches (tests, boucles) en génèrent. Les prédictions de branche et les optimisations du compilateur (vectorisation, suppression de boucles) peuvent aider, mais c’est limité.
6. Appels de fonction
Les compilateurs modernes inlinent automatiquement les fonctions simples et sans branche. Supprimer des appels de fonction a donc peu de chances d’améliorer les performances, sauf dans des cas très spécifiques :
- Appels indirects (pointeurs de fonction, fonctions virtuelles en C++, API comme
qsort()) ; - Langages à coût d’appel élevé (Python, où la recherche et l’appel de fonction ont un surcoût important).
Même dans ces cas, il s’agit toujours de micro-optimisations.
Conclusion (et début d’une autre histoire) : Pour un code rapide, vise la simplicité et la clarté : des fonctions bien découpées, chacune faisant une seule chose. C’est souvent ce qui permet au compilateur au matériel et aux programmeurs de faire leur travail efficacement.