Optimisation des programmes

L’optimisation peut se produire à un certain nombre de niveaux. Généralement, les niveaux supérieurs ont un impact plus important et sont plus difficiles à modifier plus tard dans un projet, nécessitant des changements importants ou une réécriture complète s’ils doivent être modifiés. Ainsi, l’optimisation peut généralement se faire par raffinement du niveau supérieur au niveau inférieur, les gains initiaux étant plus importants et obtenus avec moins de travail, et les gains ultérieurs étant plus petits et nécessitant plus de travail. Cependant, dans certains cas, les performances globales dépendent des performances de parties de très bas niveau d’un programme, et de petits changements à un stade avancé ou une prise en compte précoce des détails de bas niveau peuvent avoir un impact considérable. En général, l’efficacité est prise en compte tout au long d’un projet – bien que cela varie considérablement – mais une optimisation majeure est souvent considérée comme un raffinement à faire tardivement, voire jamais. Sur les projets de plus longue durée, il y a généralement des cycles d’optimisation, où l’amélioration d’un domaine révèle des limitations dans un autre, et ceux-ci sont généralement réduits lorsque la performance est acceptable ou que les gains deviennent trop faibles ou coûteux.

Comme la performance fait partie de la spécification d’un programme – un programme qui est inutilement lent n’est pas adapté à l’usage : un jeu vidéo avec 60 Hz (images par seconde) est acceptable, mais 6 images par seconde sont inacceptablement hachées – la performance est une considération dès le début, pour s’assurer que le système est capable de fournir une performance suffisante, et les premiers prototypes doivent avoir une performance à peu près acceptable pour qu’il y ait une confiance dans le fait que le système final atteindra (avec une optimisation) une performance acceptable. On omet parfois de le faire, croyant que l’optimisation peut toujours se faire plus tard, ce qui donne lieu à des prototypes de systèmes beaucoup trop lents – souvent d’un ordre de grandeur ou plus – et à des systèmes qui sont finalement des échecs parce que leur architecture ne leur permet pas d’atteindre leurs objectifs de performance, comme l’Intel 432 (1981) ; ou à des systèmes qui nécessitent des années de travail pour atteindre une performance acceptable, comme Java (1995), qui n’a atteint une performance acceptable qu’avec HotSpot (1999). Le degré de changement de la performance entre le prototype et le système de production, et la façon dont elle se prête à l’optimisation, peut être une source importante d’incertitude et de risque.

Niveau de conceptionEdit

Au plus haut niveau, la conception peut être optimisée pour faire le meilleur usage des ressources disponibles, compte tenu des objectifs, des contraintes et de l’utilisation/charge attendue. La conception architecturale d’un système a une incidence considérable sur ses performances. Par exemple, un système qui est lié à la latence du réseau (où la latence du réseau est la principale contrainte sur les performances globales) sera optimisé pour minimiser les trajets sur le réseau, en faisant idéalement une seule demande (ou aucune demande, comme dans un protocole push) plutôt que de multiples allers-retours. Le choix de la conception dépend des objectifs : lors de la conception d’un compilateur, si la compilation rapide est la principale priorité, un compilateur à un passage est plus rapide qu’un compilateur à plusieurs passages (en supposant le même travail), mais si la vitesse du code de sortie est l’objectif, un compilateur à plusieurs passages plus lent remplit mieux cet objectif, même s’il prend lui-même plus de temps. Le choix de la plate-forme et du langage de programmation intervient à ce niveau, et leur changement nécessite fréquemment une réécriture complète, bien qu’un système modulaire puisse permettre la réécriture de seulement certains composants – par exemple, un programme Python peut réécrire des sections critiques en termes de performances en C. Dans un système distribué, le choix de l’architecture (client-serveur, pair-à-pair, etc.) intervient au niveau de la conception, et peut être difficile à modifier, en particulier si tous les composants ne peuvent pas être remplacés de manière synchronisée (ex, les anciens clients).

Algorithmes et structures de donnéesEdit

Donné une conception globale, un bon choix d’algorithmes et de structures de données efficaces, et une mise en œuvre efficace de ces algorithmes et structures de données vient ensuite. Après la conception, le choix des algorithmes et des structures de données affecte l’efficacité plus que tout autre aspect du programme. Généralement, les structures de données sont plus difficiles à modifier que les algorithmes, car une hypothèse de structure de données et ses hypothèses de performance sont utilisées dans tout le programme, bien que cela puisse être minimisé par l’utilisation de types de données abstraits dans les définitions de fonctions, et en gardant les définitions de structures de données concrètes limitées à quelques endroits.

Pour les algorithmes, cela consiste principalement à s’assurer que les algorithmes sont constants O(1), logarithmiques O(log n), linéaires O(n), ou dans certains cas log-linéaires O(n log n) dans l’entrée (à la fois en espace et en temps). Les algorithmes de complexité quadratique O(n2) ne parviennent pas à passer à l’échelle, et même les algorithmes linéaires posent des problèmes s’ils sont appelés de manière répétée, et sont généralement remplacés par des constantes ou des logarithmes si possible.

Au delà de l’ordre asymptotique de croissance, les facteurs constants ont de l’importance : un algorithme asymptotiquement plus lent peut être plus rapide ou plus petit (car plus simple) qu’un algorithme asymptotiquement plus rapide lorsqu’ils sont tous deux confrontés à une petite entrée, ce qui peut être le cas qui se produit dans la réalité. Souvent, un algorithme hybride fournira les meilleures performances, en raison de ce compromis qui change avec la taille.

Une technique générale pour améliorer les performances est d’éviter le travail. Un bon exemple est l’utilisation d’un chemin rapide pour les cas courants, améliorant les performances en évitant le travail inutile. Par exemple, l’utilisation d’un algorithme de mise en page simple pour le texte latin, ne passant à un algorithme de mise en page complexe que pour les écritures complexes, comme le Devanagari. Une autre technique importante est la mise en cache, en particulier la mémorisation, qui évite les calculs redondants. En raison de l’importance de la mise en cache, il y a souvent de nombreux niveaux de mise en cache dans un système, ce qui peut causer des problèmes d’utilisation de la mémoire, et des problèmes de correction dus à des caches périmés.

Niveau du code sourceEdit

Au delà des algorithmes généraux et de leur implémentation sur une machine abstraite, les choix concrets au niveau du code source peuvent faire une différence significative. Par exemple, sur les premiers compilateurs C, while(1) était plus lent que for(;;) pour une boucle inconditionnelle, parce que while(1) évaluait 1 et avait ensuite un saut conditionnel qui testait si c’était vrai, alors que for (;;) avait un saut inconditionnel . Certaines optimisations (comme celle-ci) peuvent aujourd’hui être réalisées par des compilateurs optimisateurs. Cela dépend du langage source, du langage machine cible et du compilateur, et peut être à la fois difficile à comprendre ou à prévoir, et change au fil du temps ; c’est un endroit clé où la compréhension des compilateurs et du code machine peut améliorer les performances. Le mouvement de code invariant en boucle et l’optimisation des valeurs de retour sont des exemples d’optimisations qui réduisent le besoin de variables auxiliaires et peuvent même entraîner des performances plus rapides en évitant les optimisations détournées.

Niveau de constructionEdit

Entre le niveau source et le niveau de compilation, des directives et des drapeaux de construction peuvent être utilisés pour régler les options de performance dans le code source et le compilateur respectivement, comme l’utilisation de définitions de préprocesseur pour désactiver les fonctionnalités logicielles inutiles, l’optimisation pour des modèles de processeurs ou des capacités matérielles spécifiques, ou la prédiction des branchements, par exemple. Les systèmes de distribution de logiciels basés sur les sources, tels que Ports de BSD et Portage de Gentoo, peuvent tirer parti de cette forme d’optimisation.

Niveau de compilationEdit

L’utilisation d’un compilateur optimisateur tend à garantir que le programme exécutable est optimisé au moins autant que le compilateur peut le prévoir.

Niveau d’assemblageEdit

Au niveau le plus bas, l’écriture de code en utilisant un langage d’assemblage, conçu pour une plate-forme matérielle particulière peut produire le code le plus efficace et le plus compact si le programmeur tire parti du répertoire complet des instructions de la machine. De nombreux systèmes d’exploitation utilisés sur les systèmes embarqués ont été traditionnellement écrits en code assembleur pour cette raison. Les programmes (autres que les très petits programmes) sont rarement écrits du début à la fin en assembleur en raison du temps et du coût que cela implique. La plupart sont compilés à partir d’un langage de haut niveau en assembleur et optimisés manuellement à partir de là. Lorsque l’efficacité et la taille sont moins importantes, de grandes parties peuvent être écrites dans un langage de haut niveau.

Avec les compilateurs optimiseurs plus modernes et la plus grande complexité des CPU récents, il est plus difficile d’écrire un code plus efficace que ce que le compilateur génère, et peu de projets ont besoin de cette étape d’optimisation « ultime ».

Une grande partie du code écrit aujourd’hui est destinée à fonctionner sur autant de machines que possible. Par conséquent, les programmeurs et les compilateurs ne tirent pas toujours parti des instructions plus efficaces fournies par les nouveaux processeurs ou des bizarreries des anciens modèles. De plus, un code assembleur réglé pour un processeur particulier sans utiliser de telles instructions pourrait encore être sous-optimal sur un processeur différent, attendant un réglage différent du code.

Typiquement aujourd’hui, plutôt que d’écrire en langage d’assemblage, les programmeurs utiliseront un désassembleur pour analyser la sortie d’un compilateur et modifier le code source de haut niveau afin qu’il puisse être compilé plus efficacement, ou comprendre pourquoi il est inefficace.

Run timeEdit

Les compilateurs just-in-time peuvent produire un code machine personnalisé basé sur des données d’exécution, au prix d’une surcharge de compilation. Cette technique remonte aux premiers moteurs d’expression régulière, et s’est généralisée avec Java HotSpot et V8 pour JavaScript. Dans certains cas, l’optimisation adaptative peut être capable d’effectuer une optimisation d’exécution dépassant la capacité des compilateurs statiques en ajustant dynamiquement les paramètres en fonction de l’entrée réelle ou d’autres facteurs.

L’optimisation guidée par profil est une technique d’optimisation de compilation en avance sur le temps (AOT) basée sur des profils d’exécution, et est similaire à un analogue statique de « cas moyen » de la technique dynamique de l’optimisation adaptative.

Le code auto-modifiant peut se modifier en réponse aux conditions d’exécution afin d’optimiser le code ; ceci était plus courant dans les programmes en langage d’assemblage.

Certaines conceptions de CPU peuvent effectuer certaines optimisations au moment de l’exécution. Certains exemples incluent l’exécution hors ordre, l’exécution spéculative, les pipelines d’instruction et les prédicteurs de branchement. Les compilateurs peuvent aider le programme à tirer parti de ces caractéristiques du processeur, par exemple par l’ordonnancement des instructions.

Optimisations dépendantes et indépendantes de la plate-formeEdit

L’optimisation du code peut également être largement classée en techniques dépendantes et indépendantes de la plate-forme. Alors que ces dernières sont efficaces sur la plupart ou toutes les plates-formes, les techniques dépendantes de la plate-forme utilisent des propriétés spécifiques d’une plate-forme, ou s’appuient sur des paramètres dépendant de la seule plate-forme ou même du seul processeur. Il peut donc être nécessaire d’écrire ou de produire différentes versions du même code pour différents processeurs. Par exemple, dans le cas de l’optimisation au niveau de la compilation, les techniques indépendantes de la plate-forme sont des techniques génériques (telles que le déroulement des boucles, la réduction des appels de fonction, les routines efficaces en termes de mémoire, la réduction des conditions, etc. ), qui ont un impact similaire sur la plupart des architectures de CPU. Un excellent exemple d’optimisation indépendante de la plate-forme a été montré avec la boucle for interne, où il a été observé qu’une boucle avec une boucle for interne effectue plus de calculs par unité de temps qu’une boucle sans boucle ou une boucle avec une boucle while interne. En général, ces boucles servent à réduire la longueur totale du chemin d’instruction nécessaire pour terminer le programme et/ou à réduire l’utilisation totale de la mémoire pendant le processus. D’autre part, les techniques dépendantes de la plate-forme impliquent l’ordonnancement des instructions, le parallélisme au niveau des instructions, le parallélisme au niveau des données, les techniques d’optimisation du cache (c’est-à-dire des paramètres qui diffèrent entre diverses plates-formes) et l’ordonnancement optimal des instructions pourrait être différent même sur différents processeurs de la même architecture.