La vitesse du CPU

Créé le 2010-11-24 par André Gillibert

L'argument le plus fréquemment utilisé par les programmeurs paresseux pour ne pas optimiser leur code est de dire que le CPU n'est pas le facteur limitant, invoquant la lenteur des entrées-sorties ou des back-end.

C'est le plus souvent faux. Même si le CPU peut ne pas être le facteur limitant, c'est loin d'être toujours le cas. Un CPU plus puissant sur la même carte mère, avec la même RAM et le même disque dur rend l'ordinateur visiblement plus puissant. Si on améliore à la fois le CPU, la RAM et la carte mère, les performances peuvent être décuplées, même si on conserve la même carte réseau et le même disque dur, et pourtant on s'est contenté d'améliorer la vitesse des opérations que l'on attribue classiquement au CPU.

De plus, quand bien même les entrées-sorties seraient le facteur limitant, ce n'est pas du tout un argument pour abandonner toute optimisation. En optimisant les entrées-sorties, on peut accélérer énormément le programme, jusqu'à ateindre un stade où les optimisations de vitesse de CPU peuvent être significatives.

Je voudrais détruire ces mythes ridicules et délétères.

Dans une première section, je traiterai des entrées-sorties et dans une seconde je parlerai des back-ends.

Les entrées-sorties

Réseau

D'abord, les entrées-sorties réseau sont souvent extrêmement rapides sur un réseau local. Ethernet à un gigabit est un des réseaux les plus répandus, mais le 10 gigabits est disponible dans de nombreuses entreprises. En fait, le 100 gigabits existe depuis Juin 2010. Excluons le 100 gigabits, trop récent et trop coûteux.

Sur du Ethernet 10 gigabits, dépenser 3 cycles de CPU par octet sur un CPU à 3 gigahertz peut ralentir par un facteur deux un programme. Croyez moi, 3 cycles de CPU, on y arrive vite si on ne fait pas attention.

Le temps de ping peut être plus significatif. Les algorithmes devraient être optimisés pour limiter le nombre d'allers et retours.

Disque dur

Les disques durs ont la réputation d'être très lents. Ce n'est vrai que pour le temps d'accès. La lecture ou l'écriture linéaire sont rapides, atteignant 110 ou 120 méga-octets par seconde sur un ordinateur dernier cri. Ça fait à peu près un gigabit, seulement 10 fois plus lent que les bons réseaux.

On peut dépenser une trentaine de cycles de CPU par octet au grand maximum, après, il faut savoir que si le programme est constamment un peu plus lent que le disque dur à lire ou à écrire, la rotation des plateaux va prendre un peu d'avance et le programme va devoir attendre un cycle de rotation complète de temps en temps et ce, d'autant plus souvent que le programme est lent. C'est pourquoi les vieux disques durs faisaient de l'interleaving. Heureusement, en compensant par des tampons largement dimensionnés, on peut s'en tirer. L'OS peut faciliter ça, par ses fonctions de read-ahead et d'accumulation des dirty buffers avant écriture. Au quel cas, on peut s'attendre à des performances très variables en fonction de l'OS et de la configuration matérielle.

Exemple: Le dos2unix de Benjamin Lin ne dépasse pas 20 méga-octets par seconde en copiant des données RAM vers RAM sur un Pentium 4 Northwood à 2.67Ghz. Déjà, c'est tellement lent que le temps de CPU est plus grand que le temps d'entrée d'un gros fichier. p.e. Pour un fichier de 8 méga-octets sur mon vieux DD 2.5" de 80 giga-octets, dos2unix après avoir vidé les caches (fichier lu depuis le disque) met 570 ms +/- 10 ms, puis 390 ms +/- 10 ms les fois suivantes, le fichier étant lu depuis la cache disque. C'est donc la charge de CPU le plus gros facteur. Ce programme effectue une des opérations les plus simples qui soit et on pourrait s'attendre à ce qu'il soit limité par la vitesse du disque.

Solide state drive

Débit linéaire collosal et arrivant ainsi à saturer le bus SATA 3 Gb/s soit 300 méga-octets par seconde. Le temps d'accès est beaucoup plus raisonnable, descendant en dessous de 100 micro-secondes.

Cache disque

Quand bien même les disques durs sont lents, ils ont une cache interne et le système d'exploitation y rajoute une cache en RAM. En conséquence, la majorité des données lues au démarrage d'un programme sont en cache lors du second lancement d'une application. En fait, amusez vous à désactiver la cache du disque et du système d'exploitation, et voyez comme les accès deviennent lent. C'est pour dire que les temps d'un appel à read(2) ou write(2) est à priori extrêmement court, à moins de grandes quantités d'accès nouveaux (p.e. copie de fichiers massifs).

Le long temps de démarrage JVM est soi-disant limité par la vitesse des entrées-sorties, mais ce n'est vrai que lors du premier lancement. Sur mon P4 2.67Ghz, au second lancement, un /bin/true écrit pour la J2SE 6 met 160 ms dont seulement 30 en temps "sys" passé aux recopies de caches et autres appels systèmes. Un "hello world!" SWING met 1000 millisecondes au second lancement, dont seulement 110 en appels systèmes. À côté de ça, bash à l'air rapide comme l'éclair.

Tuyaux et sockets UNIX

Un programme tels que dos2unix est conçu pour s'intégrer dans une longue pipeline. La communication par tuyau est extrêmement véloce et donc, il faudra multiplier le temps de calcul par le nombre de programmes invoqués.

Console

Les consoles sont habituellement extrêmement lentes, mais c'est uniquement dû au fait qu'elles n'ont pas du tout été optimisées et veulent afficher les données de manière synchrone. Après tout, l'écriture dans un terminal virtuel est essentiellement de la copie de RAM vers RAM. Une fois que toutes les données sont dans l'historique, leur format est fixe. Avec un projet expérimental, qui séparait les événements de dessin et de mise en mémoire j'avais obtenu une vitesse de centaines de méga-octets par seconde.

Un programme sortant des données sur une console, peut, la plupart du temps, partir du fait que la console est lente, même si ça peut devenir faux dans le futur. Paradoxalement, alors que les programmes sortent assez peu de données sur la console, il arrive que la console soit tellement lente qu'elle devienne le facteur limitant. J'ai déjà vu ça avec un terminal virtuel X11 qui n'avait pas l'accélération 2D, et donc, était très lent pour le scrolling, puisqu'il lisait chaque pixel dans la RAM graphique, opération très lente, puis le ré-écrivait, opération habituellement moins lente. Avec ça, on en arrive à quelques lignes par secondes de sortie, ce qui peut ralentir le travail de programmes comme mv ou cp en mode verbeux.

Optimiser les entrées-sorties

Quand bien même les entrées-sorties sont le facteur limitant d'un programme, cela ne signifie pas qu'on ne peut pas l'optimiser. En effet, si des accès linéaires sont faits sur de grandes quantités de données, il vaut mieux éviter de les stocker en XML expansé. La compression peut énormément aider, même s'il faut faire attention à ne pas pénaliser les vieilles machines (p.e. les bitmaps sont plus rapides à charger que les JPEG sur mon vieux K6-2 550 Mhz).

On peut aussi réduire le nombre de transferts parfois grandement. Par exemple, Yum ne cesse de télécharger des bases de données des dépôts dès qu'elle sont un peu modifiées, soit environ 10 méga-octets sur mon système CentOS, pour souvent n'installer qu'un programme de 200 kilo-octets. Si le système avait été mieux pensé, il ne téléchargerait que des diffs de base de données ce qui réduirait prèsque à néant les téléchargements. Un autre moyen serait de ne pas télécharger du tout la base de données mais plutôt de faire des requêtes à la base de données via le serveur du dépôt mais cela a ses propres inconvénients.

Sur réseau, on peut minimiser le nombre d'allers et retours en optimisant les protocoles. Le Keep-Alive de HTTP 1.1 en est un bon exemple.

Sur un système de fichiers, on peut réduire le nombre d'accès aléatoires qui ralentissent le démarrage des programmes, en réduisant la fragmentation des accès. Plutôt que de stocker tout dizaines de fichiers et librairies dynamiques éparses, on peut utiliser un exécutable statiquement lié dont les dépendances ont été minimisées qui accède à un ou deux gros fichiers de données dont le contenu a été soigneusement ordonné pour que son accès soit linéaire.

Les dépendances de librairies dynamiques sont, sous GNU/Linux, parfois beaucoup trop nombreuses. Gcalctool dépend d'une soixantaine de librairies dynamiques, et consomme environ 13 méga-octets de mémoire non partageable. Les librairies dynamiques sont une grande source d'accès disque aléatoires lors du démarrage. On peut souvent se passer de ces librairies, shunter le système. Au quel cas, on y gagne en espace disque, en RAM utilisée et en CPU consommé lors du démarrage. J'ai déjà réussi à obtenir des exécutables statiquement liés à la dietlibc plus petit que lorsqu'ils étaient dynamiquement liés à la GLIBC, grâce à la suppression des sections d'importation des symboles.

Pour aller plus vite il suffit de faire moins de choses. Après tout, un /bin/true écrit en C/dietlibc démarre en 30 micro-secondes sur mon Pentium 4 à 2.67Ghz, il n'y a pas de raison qu'écrit en Java il mette 160 millisecondes, soit 5000 fois plus. Que la JVM consomme du disque ou du CPU, dans les 2 cas, c'est injustifié. Bon, d'accord, c'est en partie justifié par l'architecture de la machine virtuelle Java. Au quel cas, cette architecture est mauvaise. Le démarrage d'un programme n'est limité par rien. Aucun facteur physique ne justifie un temps de démarrage long. Le fautif est forcément le programme ou le système d'exploitation. En général, c'est le programme.

Entrées-sorties: Conclusion

Les entrées-sorties sont loin d'être toujours le facteur limitant et, lorsqu'elles le sont, c'est à ce moment que l'on doit le mieux optimiser le programme, par des algorithmes intelligents, réduisant le nombre d'entrées-sorties ou le nombre d'opérations lentes comme les pings et les seeks.

Les back-ends

Interfaces graphiques

Les applications ayant une interface graphique ont la tradition d'être d'une lenteur atroce sous Linux. Sous Windows on commence aussi à voire poindre des applications Qt aréactives et buggées. La faute en revient à une pile profonde d'API telles que GTK+2/GDK/XLib/X.org/DRI. Il semblerait que les API les plus superficielles soient responsables de prèsque toute la lenteur du système parce qu'elles semblent partir du principe que l'API du dessous est très lente et donc que la médiocrité de leur code est un facteur négligeable.

Certains vont jusqu'à vouloir la suppression des plus basses couches ! D'où l'absence d'X11 d'Android, alors qu'il est possible d'écrire un serveur X11 léger (X.org n'est pas léger mais reste performant) comme KDrive. Par ailleurs Android rame sur des ARM à 600 ou 1000 megahertz. X11 n'était peut-être pas au top sur 80386, mais ça tournait quand même. Pire, X.org serait à long terme supprimé d'Ubuntu et de Fedora, remplacés par des couches plus directes, sans transparence réseau, et basées sur OpenGL (le truc qui ne marche chez personne). Pourquoi OpenGL ? Parce qu'il y a encore des imbéciles qui croient que dessiner un rectangle avec les pilotes accélérés 2D est la cause de la lenteur de KDE 4 alors que c'était instantané sur un Pentium 90Mhz avec une Mach 64 au bon vieux temps de Windows 95.

Si on regarde une application lamentable telle que Nautilus, on s'aperçoit que les couches de haut niveau font toute la lenteur. Ainsi, l'application elle-même est mal optimisée puisque l'outil équivalent de XFCE4 (thunar), utilise aussi GTK+2 et est pourtant trois fois plus réactive. Les applications GTK+2 les plus simples sont pourtant déjà lentes à démarrer et aréactives comparées aux applications FLTK, ce qui prouve que la lenteur de GTK+2 n'est nullement liée à X.org. On peut obtenir des applications vraiment rapides si on utilise directement XLib. X.org a beau être un gros projet et plutôt lent de démarrage, ses performances sont tout à fait correctes sur un PII 233, et la transparence réseau rend même possible le transfert de vidéos sur un réseau 100 mégabits même si ce n'est pas parfaitement fluide. Quant au temps de dessin proprement dit, les cartes vidéo peuvent dessiner des millions de rectangles par seconde, donc, tout passer en OpenGL ne va pas accélérer ça.

Kernel switches

Il y a aussi le coup du coût d'un kernel switch. Soit utilisé pour justifier une front-end lente, soit au contraire pour justifier la création d'un OS entièrement écrit en JavaScript permettant d'économiser les kernel switches parce que la protection mémoire peut être entièrement intégrée dans l'interpréteur du langage. De toute façon avec un interpréteur adéquat, les scripts bourne shell, le JavaScript ou les fichiers batches c'est 10 fois plus rapide que l'assembleur optimisé.

Le temps d'un appel système avec un numéro de fonction système inexistant, créant ainsi un kernel switch minimal, sur Athlon 64 3000+ à 1800 Mhz avec Linux 2.6.16 à 2.6.36 compilé x86_64, en utilisant la fonction SYSENTER en assembleur, se résume à environ 160 cycles de CPU dont quelques uns passés dans arch/x86/kernel/entry_64.S/system_call. Un programme écrivant octets par octets dans /dev/null, a besoin de 360 cycles de CPU par écriture sur le noyau 2.6.36 x86_64. Le temps d'un kernel switch est donc très significatif mais pas non plus collosal. Le temps de distribution de l'appel est responsable de la moitié du temps de l'appel. C'est cette partie qu'il est facile d'optimiser et qui a déjà été super optimisée par Linux. Sous la 2.6.16, il fallait toujours 160 cycles pour un syscall inexistant mais il fallait 490 cycles pour un write(2) d'un octet dans /dev/null. Bien entendu il vaut mieux bufferizer sur quelques kilo-octets les données avant de les sortir, mais ça resterait vrai si un kernel switch était instantané. Par contre si on considère un système comme MS-DOS ou Windows 9x ou NT/2K/XP, les appels systèmes sont nettement plus lents.

MINIXv3 fait fort avec environ 9700 cycles pour écrire un octet dans /dev/null et 3300 cycles pour un appel système inexistant, toujours sur l'Athlon 64 3000+. Ça prouve qu'il vaut mieux optimiser le noyau plutôt que d'essayer à tout prix de supprimer les kernel switches.

Conclusion

L'optimisation est encore d'actualité. Le temps de CPU est important, mais aussi les entrées-sorties dans les applications intensives. Il ne faut pas céder à la tentation de rejeter les problèmes de lenteur sur des éléments échappant au contrôle du programmeur, que ce soient les librairies employées, le langage de programmation, les entrées-sorties, les appels système.

Il existe un ultime argument: Mais, les PC sont super rapides de nos jours, pourquoi n'utiliserions nous pas les programmes des années 80 mais en plus lent et buggé sur les ordinateurs derniers cris ? Ça ressemble à un complot pour nous faire acheter des nouveaux ordinateurs tous les 2 ans. Alors qu'un lot de nouvelles possibilités s'offre, grâce aux performances du matériel, on voudrait le transformer en vieilleries par la puissance du logiciel !

C'est à cause de cette mentalité qu'on arrive même pas à imposer la même plateforme sur les UMPC et les PC traditionnels. En effet en 2008, moults UMPC basés sur Windows XP existaient. Ils ne sont plus disponibles parce que Windows XP n'est plus en vente et Windows 7 trop gourmand et même pas portable à l'architecture ARM. Les UMPC se sont fait remplacer par des tablettes Android qui ont pourtant largement la puissance de faire tourner Windows XP ou une vraie distribution Linux avec XFCE. L'incompétence de Microsoft et de Google détruisit le rêve d'une plateforme unique alors que les UMPC ont enfin des processeurs surpuissant. De plus, Microsoft abandonne Windows Mobile qui était pourtant basée sur l'API Win32 au profit d'une horreur dénommée Windows Phone 7. Bon, heureusement on peut améliorer Android en lui rajoutant un serveur X11.