Bibliothèque · Résumé et avis

Thinking Low-Level, Writing High-Level

Write Great Code, vol. 2, de Randall Hyde. Ce que votre code élégant coûte une fois que le compilateur en a fini avec lui.

FR EN
Couverture de Write Great Code volume 2

Write Great Code, vol. 2

Write Great Code, Volume 2: Thinking Low-Level, Writing High-Level (2nd Edition)

6.6 /10

« Le tarif machine de chaque ligne de code : daté dans ses outils, irremplaçable dans ses verdicts. »

  • AuteurRandall Hyde · auteur de The Art of Assembly Language
  • VONo Starch Press, 2020 · 656 pages
  • ÉditionFiche fondée sur la 1ʳᵉ éd. (2006) + apports vérifiés de la 2ᵉ
  • Fiche~10 min de lecture
Notation du livre sur 5 dimensionsIdées8/10Applicable6/10Lisibilité6/10Actualité6/10Exemples7/10

Ce que chaque construction de votre langage coûte une fois compilée : switch, boucles, objets, chaînes.

Pourquoi ce livre

Le volume 1 enseignait ce que la machine fait de vos données. Celui-ci répond à la question d'après : que fait le compilateur de vos instructions ? Deux bouts de code peuvent sembler équivalents en PHP, C ou JavaScript et compiler vers du code machine qui diffère d'un ordre de grandeur. Le pari de Hyde : pas besoin d'écrire de l'assembleur (le langage à une instruction machine par ligne) pour savoir lequel est lequel. « Pensez en assembleur ; écrivez en langage de haut niveau » (p. 5).

Et il s'attaque à la citation détournée qui nous protège de la question. « L'optimisation prématurée est la racine de tous les maux » ? Hoare parlait du comptage de cycles en assembleur, pas d'un architecte qui choisit ses structures en connaissance de cause au moment de la conception. Repousser toute décision de coût à une hypothétique « phase d'optimisation », c'est garantir qu'elle n'arrivera jamais.

Les idées qui restent

1Penser en assembleur, écrire en haut niveau#

La première génération de programmeurs haut niveau venait de l'assembleur : elle choisissait naturellement les constructions qui compilaient bien, parce qu'elle voyait le code machine derrière l'instruction. La génération suivante a hérité des langages sans l'œil, et, comme l'écrit Hyde, « la plupart des ingénieurs logiciels n'ont aucune idée des coûts d'exécution des instructions haut niveau » (p. xviii). Son remède n'est pas de revenir à l'assembleur : c'est de restaurer l'œil.

À l'objection « le compilateur optimise mieux que moi », il répond deux fois :

  • « un des secrets les mieux gardés du monde des compilateurs : la plupart des benchmarks de compilateurs sont truqués » (p. 4) : les vendeurs écrivent eux-mêmes les programmes de référence, en sachant exactement ce que leur compilateur récompense ;
  • plus fondamental : aucun optimiseur ne remplacera jamais votre recherche linéaire par une recherche binaire. Les algorithmes et les structures de données restent votre travail.

L'œil bas niveau, c'est repérer le coût caché derrière une ligne anodine :

// ✗ strlen() RECOMPTE la chaîne entière à chaque tour → coût caché énorme
for (int i = 0; i < strlen(s); i++) { ... }

// ✓ on mesure UNE fois : le même résultat, sans le piège
int n = strlen(s);
for (int i = 0; i < n; i++) { ... }
Un développeur tape une calligraphie élégante et fluide à l'écran pendant que sa bulle de pensée contient de petits engrenages précis et des points façon binaire rangés en motifs mécaniques
Écrivez dans votre langue. Pensez dans la sienne.

2L'optimiseur est borné : aidez-le#

Pourquoi le compilateur ne répare-t-il pas tout ? Parce que l'optimisation parfaite est hors de portée : « une optimisation complète et garantie d'une application moderne pourrait prendre plus longtemps que la durée de vie connue de l'univers » (p. 71). Les compilateurs réels font tourner des heuristiques bornées dans le temps, sur de petites fenêtres de code. Du code brouillon, des conditions très imbriquées, des sauts qui emmêlent le flux d'exécution : tout cela épuise le budget de l'heuristique avant qu'elle ait fait quoi que ce soit d'utile pour vous.

D'où la règle discrète du livre : le bon code « travaille en synergie avec le compilateur, pas contre lui » (p. 72). Écrivez du code simple et bien structuré, et l'optimiseur peut dérouler toute sa boîte à outils :

int x = 3 * 4;     // PLIAGE DE CONSTANTES → le binaire contient juste "12"
if (false) foo(); // ÉLIMINATION DE CODE MORT → foo() disparaît du binaire
y = i * 8;        // RÉDUCTION DE FORCE → devient  y = i << 3  (décalage, moins cher)

Trois outils que l'optimiseur déroule tout seul, à condition que votre code soit assez simple pour qu'il les repère :

  • le pliage de constantes : calculer 3 * 4 une fois pour toutes, à la compilation ;
  • l'élimination de code mort : supprimer ce qui ne peut jamais s'exécuter ;
  • la réduction de force : remplacer une opération chère par une moins chère, comme une multiplication par un décalage de bits.

Et gardez en tête les trois choses qu'il ne fera jamais : choisir votre algorithme, choisir votre structure de données, restructurer votre architecture.

3Faire confiance au compilateur, puis vérifier son travail#

La méthode de travail du livre est une boucle : écrire la version haut niveau, regarder le code généré, comparer. Sur un cas réel, c'est éclairant :

// votre source                  // ce que Compiler Explorer montre (x86, -O2)
int doubler(int x) {            doubler(int):
  return x * 2;                   lea eax, [rdi + rdi]  ; pas de "mul" : une addition !
}                                ret

Le compilateur a remplacé la multiplication par une addition d'adresse, plus rapide : c'est exactement le genre de chose qu'on ne voit qu'en regardant. En 2006, cela voulait dire gcc -S et des désassembleurs ; aujourd'hui, la même boucle tient dans un copier-coller sur Compiler Explorer.

Le livre travaille en C/C++ (avec un peu de Pascal), mais la boucle vaut pour tout langage compilé en natif : en Go, go build -gcflags=-S affiche l'assembleur, et Compiler Explorer accepte Go et Rust aussi. Pour Java ou C#, il y a un étage de plus : le bytecode (javap -c) n'est pas le code final, c'est le JIT qui produit le vrai code machine à chaud.

Deux règles survivent intactes :

  • vérifier au même niveau d'optimisation que la production : « ne réglez jamais votre code haut niveau pour produire un meilleur assembleur à un niveau d'optimisation, pour ensuite changer de niveau en production » (ch. 6) ;
  • si deux versions compilent vers le même code machine, « utilisez la version la plus lisible et la plus maintenable » (ch. 6).

L'exercice sert la lisibilité autant que la vitesse : les micro-réécritures qui ne changent rien sont rejetées avec preuve.

4Chaque variable a un coût d'adresse#

Petit décor si vous n'avez jamais touché au bas niveau. Le CPU ne calcule que dans ses registres, une poignée de cases à l'intérieur même de la puce. Tout le reste vit en RAM, donc chaque calcul commence par aller chercher la donnée. Et cette RAM est découpée en zones : la pile, où chaque appel de fonction range automatiquement ses variables locales (empilées à l'appel, jetées au return) ; les globales, posées à une adresse fixe pour toute la vie du programme ; et le tas, le libre-service où vont les objets et tout ce qu'on alloue à la demande. Particularité du tas : on n'y accède jamais directement, mais via un pointeur, c'est-à-dire une variable qui contient l'adresse de la donnée. Lire le pointeur d'abord, la donnée ensuite : deux trajets au lieu d'un.

L'endroit où vit une variable décide donc du prix de chaque accès. L'échelle, du gratuit au coûteux :

  • le registre : « les registres machine sont toujours l'endroit le plus efficace pour garder variables et paramètres » (p. 228) ; le compilateur les attribue, le plus souvent mieux que vous ;
  • la locale sur la pile : une instruction courte, tant qu'elle tient dans les 127 premiers octets du cadre d'appel, la zone de pile propre à la fonction (au-delà, l'instruction doit embarquer une adresse plus longue) ;
  • la globale : une adresse 32 bits complète embarquée dans chaque instruction qui la touche, et un poison pour l'optimiseur, qui peut rarement prouver qui d'autre la modifie ;
  • le tas : charger le pointeur d'abord, accéder ensuite, plus les frais de gestion de l'allocateur autour.

En PHP ou en JavaScript, vous ne choisissez rien de tout ça : le moteur décide pour vous, et il met vos objets sur le tas. C'est une des raisons pour lesquelles un objet coûte structurellement plus cher qu'un scalaire, quel que soit le langage.

Deux corollaires pratiques du livre :

  • déclarez d'abord les scalaires souvent utilisés (nombres, booléens) et les grands tableaux en dernier, pour que les variables chaudes restent dans la zone bon marché du cadre ;
  • rangez les champs d'une structure du plus grand au plus petit pour éviter le rembourrage invisible : un char (1 octet) suivi d'un int (4 octets) occupe 8 octets, pas 5, parce que l'int doit commencer sur un multiple de 4.

5Un accès tableau est un calcul d'adresse#

a[i] a l'air gratuit ; c'est en réalité un calcul d'adresse : adresse de départ + index × taille d'un élément. Concrètement : un tableau d'entiers de 4 octets qui commence à l'adresse 1000, son élément 3 est à 1000 + 3 × 4 = 1012. La machine fait ce calcul à chaque accès. Si la taille de l'élément est une puissance de deux, la multiplication devient un simple décalage de bits, quasi gratuit ; si elle fait 9 octets, le compilateur émet des instructions en plus à chaque accès. Chaque dimension supplémentaire (a[i][j]) ajoute une multiplication.

Et le sens de parcours d'un tableau 2D décide du comportement du cache. La même logique, deux ordres de boucles :

// ✗ colonne par colonne : chaque accès saute toute une ligne → cache raté
for (j...) for (i...) a[i][j] = 0;

// ✓ ligne par ligne : accès contigus, la ligne de cache resert → jusqu'à 10× plus vite
for (i...) for (j...) a[i][j] = 0;

À logique strictement identique, le second peut tourner dix fois plus vite. Écho direct de la leçon des lignes de cache du volume 1.

La transposition honnête pour les devs web : un tableau PHP ou un tableau JavaScript creux est une table de hachage, pas un tableau. Tous les avertissements du livre sur les « tableaux purement dynamiques » (de la comptabilité à chaque accès) s'appliquent avec encore plus de force ; les seuls vrais tableaux de JS sont les TypedArrays.

6Chaînes : la copie est l'ennemie#

Cadre concret : vous générez une page HTML, un JSON, un CSV, n'importe quoi qui se construit morceau par morceau. En mémoire, une chaîne est une rangée d'octets collés les uns aux autres. Or on ne peut pas « ajouter à la fin » : la place d'à côté est déjà occupée par autre chose. Donc à chaque out += morceau, le moteur fait en réalité trois choses : réserver une zone plus grande, recopier tout ce qui a déjà été accumulé, puis recopier le nouveau morceau.

Sur une chaîne courte, invisible. Dans une boucle, ça devient des copies de copies : au millième tour, vous recopiez les 999 morceaux déjà copiés. Pour produire une page d'un mégaoctet, vous en aurez déplacé des centaines au total. C'est ce que le livre mesure : « copier des données de chaîne d'un endroit à un autre de la mémoire est un des coûts les plus élevés [...] » (p. 300). Le remède tient en une ligne : accumuler les morceaux dans un tableau, et ne coller qu'une seule fois, à la fin.

// chaque += recopie TOUTE la chaîne accumulée
let out = "";
for (const ligne of lignes) out += rendre(ligne);

// construire les morceaux, copier une fois
const out = lignes.map(rendre).join("");

Même famille de gaspillage : en C, la longueur d'une chaîne n'est stockée nulle part, strlen la recompte octet par octet jusqu'au zéro final. L'appeler dans la condition d'une boucle, c'est relire toute la chaîne à chaque tour. Le réflexe transposé chez nous : ne recalculez pas dans une boucle ce qui ne change pas (un count(), une requête, une regex).

À savoir quand même : les moteurs modernes amortissent une partie de ce coût (V8 diffère la concaténation, PHP sur-réserve de la place). Mais le modèle mental du livre reste juste, et c'est sa règle de fond : chaque manipulation de chaîne peut cacher une copie intégrale. Faites circuler des références, et ne copiez que quand quelqu'un a vraiment besoin de sa propre copie.

7Le vrai prix du dynamisme#

Le chapitre le plus précieux pour un dev web est celui des types variants, l'ancêtre des valeurs PHP/JS/Python. Additionner deux entiers typés statiquement coûte 2 ou 3 instructions. Additionner deux variants, c'est inspecter le type de chaque opérande, convertir au besoin, puis aiguiller : « il n'est pas du tout déraisonnable de s'attendre à ce qu'une addition de variants demande des dizaines, voire des centaines, d'instructions machine » (ch. 12). Cette seule phrase explique pourquoi V8 et le JIT de PHP 8 gagnent autant en spécialisant les types à l'exécution, et contre quoi ils se battent.

Deux cousins de la même famille :

  • quand vous appelez $objet->methode(), la machine ne sait pas d'avance quel code exécuter : ça dépend de la classe de l'objet, qui peut redéfinir la méthode. Elle doit donc d'abord chercher la bonne version (lire la fiche de la classe, puis y trouver l'adresse de la méthode) avant de l'exécuter. Deux détours avant le travail. Supportable (« environ 10 % de la performance totale de votre application », ch. 12), jusqu'à ce que les hiérarchies profondes et les getters/setters appelés partout le multiplient ;
  • appeler une fonction a un prix fixe, quel que soit son contenu : sauvegarder où on en était, passer les arguments, sauter, revenir. Le livre compte ~9 instructions de cette machinerie pour emballer 3 instructions de travail réel : comme envoyer un livreur faire dix kilomètres pour remettre une enveloppe. Donnez du vrai travail à vos fonctions, ou laissez l'inlining (le compilateur recopie le corps de la fonction à la place de l'appel) supprimer le trajet.

8Branches : le switch n'est pas ce que vous croyez#

Que fait le compilateur d'un switch ? Ça dépend des valeurs de vos cas, et c'est là que tout le monde se trompe.

  • Trois ou quatre cas : il génère la même chose qu'une série de if/else, la valeur est comparée cas par cas.
  • Beaucoup de cas dont les valeurs se suivent (0, 1, 2, 3…) : il construit une table de sauts. Imaginez un tableau d'adresses où la case n contient « où se trouve le code du cas n » : le CPU lit la case, saute, c'est fini. Un seul saut, que le switch ait 4 cas ou 400. C'est ça, un switch « rapide ».

Le piège : la table doit avoir une case pour chaque valeur entre le plus petit et le plus grand cas, y compris celles que vous n'utilisez pas. L'exemple du livre : des cas de 0 à 15, plus un cas isolé à 10 000. Garder la table demanderait 10 001 cases (40 004 octets) dont 9 985 vides. Aucun compilateur n'accepte ça : il retombe sur la série de comparaisons, et votre switch « rapide » redevient une chaîne de if déguisée, sans que rien ne vous prévienne.

Deux habitudes de plus tirées de ces chapitres :

  • dans une chaîne de conditions, mettez en premier celle qui tranche le plus souvent. Avec &&, dès qu'un test est faux, la suite n'est même pas évaluée : placez donc le test le plus souvent faux en tête. Exemple : if (user.isAdmin && auditCouteux(user)), presque personne n'est admin, donc l'audit coûteux ne tourne presque jamais. Avec ||, c'est l'inverse : le test le plus souvent vrai d'abord ;
  • dans f(x) + g(x), qui s'exécute en premier, f ou g ? JavaScript le garantit (gauche à droite), C et C++ ne garantissent rien, PHP ne le promet pas partout. Si f et g modifient quelque chose au passage (un compteur, un panier, un fichier), le résultat peut dépendre du compilateur. Le réflexe sûr : appelez-les sur deux lignes séparées, puis combinez les résultats.

Trois choses que je ne savais pas

Mon avis, honnêtement

Je ne vais pas jouer l'expert : je suis dev web, je ne lis pas l'assembleur, et la moitié de ce livre est au-dessus de mon quotidien. Mais c'est justement pour ça qu'il m'a marqué. Il répond à une question que je me pose vraiment en codant : de ces deux façons d'écrire la même chose, laquelle coûte plus cher ? Et il y répond avec des preuves, pas des slogans.

Ce que j'en retire, vu de mon poste : le chapitre sur les variants m'a enfin fait comprendre pourquoi PHP et JavaScript paient une taxe sur chaque opération, et ce que V8 ou le JIT de PHP 8 se battent pour regagner. Le reste m'a donné des ordres de grandeur : ce qui est gratuit, ce qui coûte, ce qui cache une copie. Je ne regarderai jamais l'assembleur de mon contrôleur un mardi matin. Mais coller deux versions d'une fonction dans Compiler Explorer pour trancher un débat de micro-optimisation, ça, c'est à ma portée, et ça clôt la discussion en trente secondes.

Les limites, honnêtement : la moitié des pages sont des listings assembleur (x86 et PowerPC) que j'ai survolés, et j'ai lu la 1ʳᵉ édition (2006), dont l'outillage a vieilli. La 2ᵉ édition (No Starch, août 2020, 656 p.) est celle à acheter : selon l'éditeur, elle couvre les CPU 64 bits, ARM, la JVM et le CLR de .NET, avec des exemples en Swift et Java.

Bilan pour un dev web : on n'en sort pas optimiseur, on en sort moins crédule. On arrête de répéter les conseils de performance entendus ailleurs, parce qu'on a vu le mécanisme dessous. C'est déjà beaucoup.

Odilon

Toujours valable en 2026 ?

La méthode plus que jamais, les détails moins. Les JIT (V8, PHP 8) et les optimiseurs modernes ont absorbé plusieurs des micro-verdicts, et c'est précisément pour ça que le chapitre sur ce que les optimiseurs ne savent pas faire (vos algorithmes, vos structures, votre flux de contrôle emmêlé) est la partie la plus durable. Compiler Explorer a transformé le laborieux protocole du livre en habitude de trente secondes. Et à l'ère de l'IA, la leçon compte double : le code généré est exactement ce code plausible-mais-pas-examiné que ce livre apprend à chiffrer. À sauter dans la 1ʳᵉ édition : le chapitre PowerPC et l'outillage de 2006.

Pour qui ?

Lisez-le si

  • Vous avez aimé le volume 1 et voulez la suite qui chiffre vos instructions, pas vos données
  • Vous codez dans un langage dynamique et voulez savoir contre quoi le moteur se bat pour vous
  • Vous répétez des conseils de perf (« le switch est plus rapide ») sans connaître le mécanisme dessous
  • Vous relisez du code généré par IA et voulez un modèle de coût pour le juger

Passez votre chemin si

  • Les pages de listings assembleur vous rebutent : la moitié du livre démontre à travers eux
  • Vous n'avez pas lu le volume 1 : les notions de mémoire et de cache sont supposées acquises
  • Vous ne trouvez que la 1ʳᵉ édition : PowerPC et l'outillage 2006 ont mal vieilli

Pour aller plus loin

Commencez par le volume 1, qui pose les fondations mémoire et CPU sur lesquelles celui-ci s'appuie. Côté bibliothèque, Effective TypeScript montre le pendant côté système de types de la taxe des variants, et Fluent Python explore ce qu'un langage dynamique fait sous son propre capot. Mes cours gratuits appliquent la même règle partout : expliquer le mécanisme réel, pas l'incantation.

Commentaires (0)

Voir toute la bibliothèque

D'autres fiches arrivent : un livre à la fois, la substantifique moelle seulement.