Profiling et optimisation générale

Bonjour,

Je vais traiter du profiling de vos programmes, de l’analyse de la vitesse, de la loi de wirth: le logiciel ralentit plus vite que le matériel n’accélère. Mes propos sont trés généraux, les articles suivant parleront plus de micro-optimisation dans des cas précis.

Les ordinateurs peuvent traiter facilement des milliards d’instructions par secondes (177 millards pour un Intel Core i7 Extreme Edition 3960X).

Comment profiler?

A première vue cela semble facile: on lance le profiler comme valgrind sous linux, et boom, c’est fini. Même si c’est déjà un début que peu de développeurs font, si c’est mal fait cela ne sert à rien.

Il faut avant tout isoler les cas de test, c’est à dire les cas plus ou moins probable d’utilisation. Un cas souvent oublié: le démarrage. Et oui, tout le monde démarre le programme avant de l’utiliser, or peu de développeurs profilent cette partie où intervient souvent le disque dur et le FS (car pas mal de fichier sont chargé).

Sous ultracopier j’ai isolé ces cas de test:

  • Copie/déplacement de gros fichiers
  • Copie d’une liste raisonnable de petits fichiers (<10000 fichiers)
  • Cas que j’ai rajouté car Ultracopier est des fois utilisé comme ça, et que nos hdd devienne de plus en plus gros: 2 000 000 fichiers, tailles mixtes, destination/sources mixte
Ensuite je me suis aperçu durant l’analyse que l’obtention des modules via une bête liste chaînée prends pas mal de temps, je suis donc passé à une MultiHash.
Un dernier exemple : ouvrir plusieurs fichiers avec un éditeur texte est normal. Or, quand je tentait ça avec kwrite, il fallait 10 minutes à cause d’un problème d’initialisation (corrigé depuis).

Optimiser l’algorithmique

Vous devez évaluer le coût de chaque opération. Votre profiler peut vous y aider.

Essayer de mettre un minimum de chose dans vos boucles. Par exemple, pour 2 boucles imbriqués, mettez l’instanciation en dehors des 2 boucles, cela permet de ne pas désallouer et réallouer vos variables à l’infini.

Evitez de trop presser la mémoire: évitez les allocations inutiles, évitez d’utiliser trop de mémoire. Lorsque vous utilisez beaucoup de donnée, pensez à la cohérence des données dans le cache (accéder à une donnée située à l’adresse X, puis accéder à une donnée située à l’adresse X + 1024 provoque une erreur de cache (cache miss), forçant le cache à être rechargé).

Faites la différence entre le temps d’appel d’une fonction, et le temps total passé dans cette fonction (temps d’appel multiplié par le nombre d’appel).

Bien coder

Ne pas coder un crapware qui fait tout et n’importe quoi, fait partie de la conception générale. Si le logiciel est trop fourni d’options, l’utilisateur vas s’y perdre. Un mode détaillé peu aider.

Les fonctionalités peu utilisées doivent être mises à part (sous forme de patch ou de plugins).

Un code clair est très important.

Le fait de faire tout ça peut vous aider à vous protéger du software bloat, cause de ralentissement logiciel.

Les sections lentes

Soit une section est lente car elle est mal codé.

Évitez qu’une fonction fasse intervenir le hdd puis le cpu, puis de nouveau le hdd. Car à chaque fois l’un des éléments attends l’autre (valable pour d’autre éléments).

Quand ont recherche un éléments dans une longue liste, il faut souvent parcourir toutes la liste, cela prend du cpu et demande beaucoup d’accès à des zones mémoire fragmentées. Lorsque c’est possible, utilisé une table de hashage: celà vous évitera de nombreux parcours de liste.

Soit c’est du à sa nature et cela ne peuT être changé (une compression xz mettra toujours plus de temps qu’une compression gzip; parsING d’un fichier xml).

Dans ce cas essayer de la mettre dans un thread à coté, ou/et d’utilisé les events.

Il ce peu que ce soit la lib que vous utilisez qui ralentisse votre programme. Hormis signaler les ralentissements excessifs au mainteneur ou aider à l’optimisation de la lib, ont ne peut pas y faire grands chose.

Une partie des compilateurs supporte la compilation de fonction optimisé pour un certain cpu, cela peu être utile pour utiliser toutes les instructions du votre cpu (et gagner en performance). Dans certain cas, compilé votre programme pour votre cpu peu être intéressant (ce qui souvent ne le rends plus distribuable à d’autre). Un serveur applicatif qui est sur un serveur dédié spécifique (et qui ne vas pas en bouger), dans ce cas, le compiler sur le serveur même pour le cpu précis du serveur dédié peu être avantageux.

Les caches

On ne parle pas ici du cache processeur, mais des caches en général.

Les caches ne sont pas toujours une bonne idée. Je m’explique : si pour faire un calcul il me faut 10ms et que l’accès au cache demande 15ms (swap?), alors l’utilisation du cache ralenti les performances.

Comment déterminer si un cache est utile? Il faut pour cela mesure le temps d’accès au cache dans un scénario d’utilisation courant. Un cache utilisé souvent et qui est de petite taille a de bonne chance d’être préchargé par le processeur, ce qui va rendre son accès quasiment gratuit.

Attention : un cache de plusieurs centaines de Mo prendra plusieurs centaines de millisecondes pour être parcouru. L’algorithme qui fait le lien entre les données d’entrée et la position dans le cache doit être bien pensé et bien codé. Cela veux dire: ne pas parcourir chaque entrée du cache si le cache contiens des millions d’entrée ; utiliser plusieurs niveaux d’indirection (des sous dossier) au lieu de tout mettre dans une liste (dans un même dossier ; dans le cas réel de dossiers sur un FS, les performances sont fortement amoindries pour les dossiers de plus de 10000 entrée – surtout si les noms sont des hashs).

Il faut analyser le temps de traitement des données : plus il sera grands face au temps d’accès du cache, mieux cela sera. Quand le cache ne fait gagner que 10% alors passer votre chemin, ça fait plus de code, plus difficile à lire.

Régénération du cache, c’est quoi? Le cache peu/doit être mit à jour. Par exemple à chaque écriture de la données entrantes, la donnée sortant doit changer. Si cette donnée est aléatoire et change souvent, un cache risque de coûter plus cher qu’il ne rapporte. Le ratio mise à jour du cache/temps d’accès du cache/temps de génération de la donnée est important.

Voila quel exemple pour mieux comprendre:

  • Si mon image est une image de caméra, elle change par définition à chaque frame. Mettre en cache une frame avec l’effet « noir et blanc » ne servira à rien, vu que l’image ne se répétera pas – et le cache ne sera jamais utilisé en lecture.
  • Si le traitement de mon image se fait en 100ms, que je fait 2 lectures du résultat final avec l’effet « noir et blanc », et que le cache met 50ms, cela veux dire: 1er accéss 100ms, 2eme accéss: 50ms. Le gain apporté par le cache est trop faible.
  • Si maintenant, j’upload mon image, et que je veux l’afficher des milliers de fois sur mon site web avec l’effet « noir et blanc », et que le cache met 5ms, cela veux dire: 1er accès 100ms chaque 1000 lecture, autre accéss: 5ms pour les autres accès. Le cache nous est très utile dans ce cas.
Il faut bien sur voir les problématiques de cohérence de cache.

Les threads

Nous nous dirigeons vers des machines massivement multi-processeur (cpu, gpu…).

Il faut voir l’aspect synchronisation entre les threads. En effet, si vous avez 2000 thread qui se synchronisent (ça peu être juste pour attendre le résultat d’un calcul), et que chaque thread fait le traitement en 1s, cela serait dramatique de mettre aussi 1s par thread pour se synchroniser. Car 2000 thread en fonction de la taille des données transmise, du bus, … ça peu rapidement faire un goulot d’étranglement. Une structure de thread en arbre (1 maître pour 50 esclave) peu aider. La complexité peu être facilement quarré (temps de synchronisation d’un éléments = nombre de threads car il faut parcourir la liste des thread * nombre d’éléments qui doivent le faire = nombre de threads)

Ensuite il y a la localité des données. Si le thread à ses info pour travailler dans le cache de son processeur, il vas travailler sans attendre d’avoir la donnée. Mais si vous faite une grosse zone avec un mutex/vérou dessus, et que tout les threads y accèdent alors il vont plus attendre leur tour que travailler (idem pour la concurrence des autres ressources comme le hdd).

Il faut éviter de partager les données entre plusieurs threads. Un thread qui écrit une donnée accédé en lecture par un autre thread (ça peut être un mutex aussi : à la base, un mutex c’est une donnée à laquelle le processeur accède de manière atomique), alors le coeur gérant le thread en lecture va devoir remettre à jour son cache de données. Si les interactions de ce type sont trop fréquente, les performances vont s’en ressentir.

Si un thread accède à une variable protégé par un mutex, et que le thread qui à besoin de puissance accède à cette même variable via ce même mutex, mais 1000x plus. Alors c’est qu’il y as un problème  car le mutex ralenti fortement le thread qui à besoin de puissance, juste pour que l’autre thread y accède 1/1000.

Mais bien optimiser son programme est plus important que d’utiliser des threads ou les possibilités du matériel. Un programme mal conçu sera lent avec ou sans multi-thread, et ce, quel que soit le CPU.

Les approximations et erreurs

A certains endroits, ont peut tolérer des erreurs, des incertitudes. On peut dire: c’est suffisamment correct.

Il faut utiliser cette logique quand c’est possible. Par exemple, le moteur de copie sur ultracopier remonte la vitesse de lecture. Cette vitesse peut être approximative. De plus, je lit depuis un autre thread la variable sans mutex, ça veux dire que ce que je lit peu être faux une fois sur 1 millions. Pas grave, ça ne sert qu’à l’affichage, et le fait de ne pas poser de mutex me permet de gagner en performance.

L’OS vous fournit des timers précis, ou un peu moins précis. Même chose pour les nombres aléatoires. Utilisez les correctement pour ne pas ralentir votre programme.

Ne pas dire non

J’ai rapporter des problèmes de performance à des développeurs (openTTD). Il m’ont dit: Nous ne voulons pas bosser sur ton problème spécifique qui demande 1 heures, car la gestions d’un autre problèmes (gestion des trains) est un vrai goulot d’étranglement depuis plusieurs années. Voila un bon contre exemple: il vaut mieux corriger un problème qui n’affecte qu’une partie des utilisateurs en 1h plutôt que se limiter à travailler sur un problème qui affecte tous les utilisateurs, mais qui est présent depuis plusieurs années et qui nécessitera encore de long moins de développement.

D’autre diront: non, un éditeur texte n’as pas besoin de performance. Alors qu’il pourrai gagner facilement en performance, consommer moins de cpu (et économisé de la batterie). Cela permet aussi de passer sur des ordinations un peu vieux. Pourquoi faire un programme lent, quand on peu le faire rapide?

Chaque optimisation peut faire que le cpu diminue ses cache miss, que tel ou tel cache de données est mieux exploité, que l’OS swap moins. Il ne faut pas le faire à l’extrême, ni contre le bon sens, mais faire toutes les optimisations facile peu aider un programme à être plus efficace. Cela permet aussi d’apprendre les bons réflexes pour directement bien coder la prochaine fois.

Ne pas considérer les optimisations comme une tache annexe. Cela rends l’optimisation difficile quand elle n’est pas fait en amont (comme la sécurité).

Cas particulier: les cms subissant des attaques DDOS vont résister bien plus facilement si il sont optimisés, car il faudra un botnet et une bande passante plus importante pour saturer le serveur. Alors que s’il ne faut que quelques requêtes par secondes pour saturer le serveur, faut juste 1000 bot chargeant des pages sur le site à quelque page toutes les 10s (navigation normale), et le serveur est saturé. Et vu que les vrai visiteurs naviguent à cette vitesse, comme voulez vous faire la différence? (aucune protection ne marchera). Sans parler du fait que le confort de navigation est meilleur sur un site rapide.

Ne pas répéter comme un perroquet : « Google/Microsoft/Apple/Linux/Untel à dit… » Il peuvent se tromper, vous pouvez avoir mal compris leurs arguments, ou leur cas d’utilisation peut ne pas s’appliquer à la personne que vous conseillez. Ne répéter que si vous comprenez bien les implications de ce que vous dites.

Les erreurs à éviter

La perte de performance est inévitable dans l’absolu (même si vous codez en assembleur : vous voudrez alors écrire du code un peu lisible, or un code lisible n’est pas la meilleure manière d’écrire du code assembleur). Par contre faire un CMS en PHP avec framework qui est 1000x plus lent que du PHP pur, cela n’est pas acceptable.

Une astuces pour les performances peut être vrai en générale, mais pas forcément dans votre cas. Et elle peut tout simplement être mal implémentée. Il vaut mieux ne pas utiliser les variables globales, ni les goto. Mais si une série de goto permet de faire en 100 lignes ce qu’un code sans fait en 5000 lignes, alors foncez! C’est l’un des cas particulier où son usage est conseillé. C’est surement que l’algorithme veux ça (ou la prise en main utilisateurs, …).

Tout à un coût. Analyser bien le coût de ce qu’on vous dit avant de vous lancer. Par exemple grouper les CSS pour avoir moins de taille au final et moins de requête HTTP semble avantageux, mais ce n’est pas toujours vrai: car si 1 CSS sur 10 change, vous re-téléchargez tout les CSS de la page. Alors qu’avec des CSS séparé, juste celui qui n’est pas en cache est téléchargé. Sur un groupement, il faut donc recalculer le groupement des CSS et leur compression à chaque fois en PHP + framework (qui est donc super long).

La compression avec upx peut vous faire gagner un peu de temps dans certains cas sur le chargement du programme en mémoire. Ce ne sera probablement pas le cas pour une petite application (<100Ko) car l’overhead associé au temps de décompression est alors plus fort (le point important ici est le ration temps de lecture sur disque / temps de décompression ; au delà d’une certaine taille, il est toujours plus lent de lire sur disque, mais la différence peut être négligeable voire s’inverser si on traite un petit fichier).

Ne dites pas: les utilisateurs veulent voir les temps rééls et veulent qu’on exploite tous les CPU. Par exemple une fois que vous avez écrit un gros fichier et avant le flush() et close(), masquer la fenêtre, ça évitera de rester sur 100% de progression pendant plusieurs secondes. Vos utilisateurs vont surement vous dire: utilisez tout les cores de nos CPU ! Mais vont râler si votre application est plus lente que celle du voisin (juste car l’utilisation du multi coeur ralenti l’application).

 Statique vs dynamique

Utilisé une bibliothèque en statique permet de minimiser les dépendances. Cela permet donc d’avoir moins de symboles à résoudre lors du chargement de la dll/so/… (qui rajoutent des symboles particuliers utilisés par le linker dynamique), ce qui permet d’avoir un meilleur temps de démarrage.

Cela permet aussi au compilateur de faire des optimisation inter-procédurale, particulièrement quand les optimisation sont fait au link (LTO).

En échange, cela ce traduit par moins de souplesse (pour mettre à jour la lib ont doit recompiler l’application). Cela fait une application plus grosses (mais plus facilement compressible avec upx).