Étiquette : Performance

  • Conception orientée données et caches du processeur

    Conception orientée données et caches du processeur

    La plus grande leçon de performance que j’aie apprise, c’est que les processeurs modernes ne sont pas lents à calculer, ils sont lents à attendre la mémoire. Un défaut de cache vers la mémoire principale peut coûter quelques centaines de cycles. Pendant ce temps, le processeur aurait pu faire des centaines d’additions. Une fois cet écart intériorisé, on commence à concevoir les programmes autour de la façon dont les données circulent, pas seulement des opérations qu’on exécute dessus. C’est le cœur de la conception orientée données.

    La hiérarchie mémoire est la vraie machine

    Entre le processeur et la mémoire principale s’intercalent plusieurs niveaux de cache : L1 est minuscule et presque aussi rapide que les registres, L2 est plus grand et plus lent, L3 plus grand et plus lent encore. Quand le processeur a besoin d’un octet, il n’en cherche pas un seul. Il récupère une ligne de cache entière, typiquement 64 octets, et la range en cache. Si votre accès suivant est dans cette ligne, il est quasi gratuit. S’il est ailleurs au loin, vous payez la pénalité complète du défaut.

    Cela veut dire que la disposition de vos données en mémoire contrôle directement votre performance. Deux programmes faisant l’arithmétique identique peuvent différer d’un ordre de grandeur uniquement à cause des schémas d’accès.

    Tableau de structures contre structure de tableaux

    L’exemple classique est la façon de stocker une collection d’enregistrements. La disposition intuitive, orientée objet, est un tableau de structures :

    struct Particle {
        float x, y, z;     // position
        float vx, vy, vz;  // vitesse
        float mass;
        char name[32];
    };
    struct Particle particles[100000];
    
    // mise à jour des positions
    for (int i = 0; i < 100000; i++) {
        particles[i].x += particles[i].vx;
    }

    Cette boucle ne touche que x et vx, mais chaque ligne de cache chargée est pleine de mass, name et des autres champs dont vous n'avez pas besoin. Vous traînez des données froides à travers le cache pour rien. La disposition orientée données scinde les champs en tableaux parallèles, une structure de tableaux :

    struct Particles {
        float x[100000], y[100000], z[100000];
        float vx[100000], vy[100000], vz[100000];
        float mass[100000];
    };
    
    for (int i = 0; i < 100000; i++) {
        p.x[i] += p.vx[i];
    }

    Maintenant le tableau x et le tableau vx sont chacun densément empaquetés. Chaque octet tiré en cache est un octet utilisé. Sur du vrai matériel, ce genre de changement donne couramment des accélérations de 3x à 10x sur les boucles chaudes, sans aucun changement d'algorithme.

    Pourquoi la poursuite de pointeurs fait mal

    Les listes chaînées et les arbres à nœuds sont l'opposé d'amicaux pour le cache. Chaque nœud est une allocation de tas séparée qui peut vivre n'importe où, donc parcourir la liste saute partout en mémoire et rate le cache à presque chaque étape. Si vous comprenez pile contre tas et comment les allocations de tas s'éparpillent, cela découle naturellement. Un tableau plat parcouru linéairement est nettement plus rapide qu'une liste chaînée du même nombre d'éléments, même si les deux sont en O(n), car le préchargeur matériel peut prédire et précharger un accès séquentiel.

    • Préférez les tableaux contigus aux structures à nœuds quand vous itérez souvent.
    • Regroupez les champs auxquels vous accédez ensemble, et séparez ceux que vous n'utilisez pas.
    • Traitez les données dans l'ordre où elles sont en mémoire dès que possible.
    • Gardez les données chaudes petites pour qu'il en tienne plus en cache à la fois.

    C'est la même idée qu'un bon allocateur

    Voilà pourquoi la stratégie d'allocation compte autant. Si vous allouez dix mille objets un par un, ils finissent éparpillés. Si vous les allouez en un seul bloc, ils sont voisins et itèrent vite. L'allocateur sur mesure de écrire un allocateur mémoire simple vous donne exactement ce contrôle : vous décidez la disposition au lieu de la laisser au hasard.

    Quand y recourir

    Je ne restructure pas tout autour du cache. Pour du code qui tourne une fois ou rarement, la clarté l'emporte. Mais pour les boucles chaudes, les noyaux internes qui tournent des millions de fois, la disposition des données est généralement la première chose que je règle et souvent le meilleur retour. Profilez d'abord, trouvez où sont les défauts de cache, puis disposez les données comme le matériel veut les lire. La machine est ravie d'être rapide si vous cessez de la faire attendre.

  • Comment optimiser les images pour le web

    Comment optimiser les images pour le web

    Si une page semble lente, les images sont la première chose que je vérifie, et c’est presque toujours le coupable. Le texte et le code sont minuscules. Une seule photo de bannière non compressée peut peser plus que tout votre bundle JavaScript. La bonne nouvelle, c’est que l’optimisation d’images est surtout mécanique, et vous pouvez tout automatiser.

    Choisissez le bon format

    Le choix du format est la décision la plus rentable que vous prenez. Le JPEG convient aux photographies mais il est vieux et peu efficace. Le PNG est pour les images qui ont besoin de transparence ou de bords nets, comme les logos et les captures d’écran, et il est catastrophique pour les photos. La réponse moderne pour presque tout est le WebP, qui donne des photos de qualité JPEG à une fraction de la taille, et l’AVIF quand vous voulez pousser la compression encore plus loin. Je sers de l’AVIF avec un repli WebP et un repli JPEG en dessous.

    • Photographies : AVIF ou WebP, jamais du JPEG brut si vous pouvez l’éviter.
    • Logos et icônes : SVG si possible, il s’agrandit à l’infini et ne pèse rien.
    • Captures avec du texte : PNG ou WebP sans perte pour que le texte reste net.

    Redimensionnez avant de compresser

    C’est l’erreur que je vois sans cesse. Les gens prennent une photo d’appareil de 4000 pixels de large, la compressent, et l’affichent dans une colonne de 600 pixels. Le navigateur télécharge tous ces pixels gaspillés et les jette. Redimensionnez d’abord l’image à la plus grande taille à laquelle elle sera réellement affichée, puis compressez. Le seul redimensionnement réduit souvent la taille du fichier de 80 pour cent avant même de toucher aux réglages de qualité.

    Automatisez avec sharp

    Je n’édite pas les images à la main dans une application. Je les passe par un script utilisant la bibliothèque sharp, qui est rapide et produit un excellent résultat. Un court pipeline redimensionne et convertit tout un dossier :

    const sharp = require('sharp');
    
    sharp('input.jpg')
      .resize({ width: 1200, withoutEnlargement: true })
      .webp({ quality: 75 })
      .toFile('output.webp')
      .then(() => console.log('done'));

    La qualité 75 en WebP est mon réglage par défaut. La différence entre 75 et 90 est invisible à l’écran mais double la taille du fichier. Glissez-le dans votre build et chaque image reçoit le même traitement automatiquement, ce qui s’intègre bien à un pipeline lancé via le CI/CD avec GitHub Actions.

    Servez des tailles adaptatives

    Un téléphone et un ordinateur de bureau ne devraient pas télécharger la même image. Générez quelques largeurs et laissez le navigateur choisir avec srcset. Le balisage indique au navigateur quelles tailles existent et la largeur d’affichage de l’image, et il prend la plus petite qui reste nette :

    <img
      src="photo-800.webp"
      srcset="photo-400.webp 400w, photo-800.webp 800w, photo-1200.webp 1200w"
      sizes="(max-width: 600px) 100vw, 600px"
      alt="Une légende descriptive"
    >

    Chargez en paresseux tout ce qui est sous la ligne de flottaison

    Ajoutez loading="lazy" aux images qui ne sont pas visibles au premier rendu de la page. Le navigateur diffère alors leur chargement jusqu’à ce que l’utilisateur s’en approche en faisant défiler. C’est un changement d’un seul attribut au gros retour, car il empêche les images hors écran de se disputer la bande passante avec le contenu que les gens voient vraiment. Laissez-le toutefois sur votre image de bannière, car vous voulez qu’elle se charge immédiatement.

    Définissez toujours largeur et hauteur

    Définissez des attributs de largeur et de hauteur explicites, ou un ratio d’aspect en CSS, sur chaque image. Sans eux, le navigateur ne sait pas combien d’espace réserver, donc la page sautille pendant le chargement des images. Ce saut est le décalage de mise en page, et c’est l’une des métriques sur lesquelles Google vous note. Cela donne aussi une impression de site cassé aux utilisateurs. Réserver l’espace ne coûte rien et corrige le problème complètement.

    Le gain

    Mettez tout cela ensemble et une page typique chargée d’images passe de plusieurs mégaoctets à quelques centaines de kilooctets sans perte de qualité visible. Cela se voit directement dans vos temps de chargement et vos Core Web Vitals. Des images plus légères donnent un site plus vif partout, y compris au moment où vous le publiez via Cloudflare Pages et que vos visiteurs le chargent depuis la périphérie. Optimisez une fois dans le build, et vous n’avez plus jamais à y penser image par image.

  • Indexation des bases de données et optimisation des requêtes, plongée en profondeur

    Indexation des bases de données et optimisation des requêtes, plongée en profondeur

    Si vous me donnez une requête lente et une heure, le correctif est un index plus souvent que toute autre chose. Les index sont l’outil de performance le plus puissant d’une base relationnelle, et c’est aussi là que je vois l’intuition la plus confiante et la plus fausse. Les gens ajoutent un index sur chaque colonne « au cas où », ou ils ajoutent un index multi-colonnes dans le mauvais ordre et se demandent pourquoi le planificateur l’ignore. Voici mon modèle mental de fonctionnement des index et de la manière dont je décide lesquels construire.

    Rien de tout cela ne compte si le schéma en dessous est un désastre, donc si ce n’est pas déjà fait, les fondations viennent d’une bonne conception et normalisation. Supposons ici que le modèle est sain et que nous le rendons rapide.

    Ce qu’est réellement un index

    Un index est une structure de données séparée et triée qui permet à la base de trouver des lignes sans parcourir toute la table. Le choix par défaut dans la plupart des bases relationnelles est un arbre B, qui maintient les clés en ordre trié et prend en charge les recherches d’égalité et les parcours de plage en temps logarithmique. Cet ordre trié est tout l’intérêt, et il explique presque tout ce qu’un index peut et ne peut pas faire.

    Parce que les clés sont triées, un arbre B est excellent pour trois choses. Trouver une valeur précise, trouver une plage de valeurs, et renvoyer des lignes déjà triées pour que la base puisse sauter une étape de tri séparée. Il est inutile pour l’inverse. Une requête qui demande tout sauf une valeur, ou qui enveloppe la colonne dans une fonction que l’index ne connaît pas, ne peut pas utiliser la structure triée et retombe sur un parcours complet.

    Le coût dont personne ne parle

    Chaque index que vous ajoutez rend les lectures plus rapides et les écritures plus lentes. Ce n’est pas un slogan, c’est mécanique. Quand vous insérez, mettez à jour ou supprimez une ligne, la base doit mettre à jour chaque index couvrant les colonnes touchées. Une table avec huit index paie huit petites opérations de maintenance à chaque écriture. Les index prennent aussi de l’espace disque et de la mémoire, et un index gonflé qui ne tient pas en cache cesse d’être l’accélération que vous vouliez.

    Je n’indexe donc pas par défense. J’indexe en réponse à des preuves. Le bon nombre d’index est le plus petit ensemble qui rend vos vraies requêtes rapides, et trouver cet ensemble passe par la lecture des plans de requête plutôt que par la devinette.

    Lire le plan de requête

    La compétence la plus utile en performance de base de données est la lecture de la sortie EXPLAIN. Elle vous dit ce que le planificateur a l’intention de faire, et EXPLAIN ANALYZE vous dit ce qui s’est réellement passé avec des temps réels. Je le lance en permanence.

    -- Voir le plan et les vrais chiffres d'exécution
    EXPLAIN (ANALYZE, BUFFERS)
    SELECT id, placed_at
    FROM orders
    WHERE customer_id = 42
      AND status = 'paid'
    ORDER BY placed_at DESC
    LIMIT 20;

    Ce que je regarde, dans l’ordre. Y a-t-il un parcours séquentiel sur une grande table où j’attendais un parcours d’index ? C’est l’alarme la plus bruyante. À quel point l’estimation de lignes du planificateur est-elle éloignée du nombre réel ? Un grand écart signifie des statistiques périmées et je lance ANALYZE sur la table. Y a-t-il un tri coûteux qu’un index pourrait satisfaire directement ? La même table est-elle parcourue plus d’une fois ?

    Index composites et ordre des colonnes

    Les index multi-colonnes sont là où le plus de points se gagnent et se perdent. La règle que j’ai mis trop de temps à intérioriser est que l’ordre des colonnes est primordial, et il découle de la structure triée. Un index sur (customer_id, status, placed_at) est trié d’abord par customer_id, puis par status, puis par placed_at. Cet ordre lui permet de servir une requête filtrant sur customer_id seul, ou customer_id et status, ou les trois. Il ne peut pas servir efficacement une requête qui filtre uniquement sur status, parce que status n’est pas la colonne de tête.

    La ligne directrice que j’utilise est les colonnes d’égalité d’abord, puis la colonne de plage ou de tri en dernier. Pour la requête ci-dessus, un index sur (customer_id, status, placed_at) est proche de l’idéal. Les deux prédicats d’égalité restreignent la recherche, et parce que placed_at est la dernière colonne et déjà triée, la base peut satisfaire le ORDER BY et le LIMIT sans tri séparé. Un index, pas d’étape de tri, vingt lignes.

    -- Colonnes d'égalité d'abord, puis la colonne de tri
    CREATE INDEX idx_orders_customer_status_time
        ON orders (customer_id, status, placed_at DESC);

    Index couvrants et parcours index-only

    Il y a une astuce supplémentaire. Si un index contient toutes les colonnes dont une requête a besoin, la base peut répondre à la requête depuis le seul index et ne jamais toucher la table. C’est un parcours index-only, et il peut être bien plus rapide parce qu’il évite les lectures aléatoires de retour vers le tas. J’y arrive en incluant les colonnes supplémentaires que la requête renvoie.

    -- INCLUDE ajoute des colonnes de charge utile sans changer la clé de tri
    CREATE INDEX idx_orders_cover
        ON orders (customer_id, status)
        INCLUDE (placed_at, id);

    Je ne fais pas cela partout, parce que des index plus larges coûtent plus cher à maintenir et à stocker. Mais pour une requête chaude et bien comprise qui tourne en permanence, la transformer en parcours index-only est l’un des meilleurs retours sur effort disponibles.

    Les index que le planificateur refusera discrètement

    Un nombre surprenant d’index restent inutilisés à cause de la façon dont la requête est écrite, pas de la façon dont l’index est construit. Les erreurs classiques que je cherche en premier.

    • Fonctions sur la colonne indexée. WHERE lower(email) = ‘x’ ne peut pas utiliser un index simple sur email. Stockez la valeur normalisée, utilisez un type insensible à la casse, ou construisez un index d’expression sur lower(email).
    • Jokers en tête. LIKE ‘foo%’ peut utiliser un arbre B, mais LIKE ‘%foo’ ne le peut pas, parce que l’ordre trié est inutile quand le début de la chaîne est inconnu.
    • Incompatibilités de type. Comparer une colonne texte à un nombre force une conversion qui peut désactiver l’index. Faites correspondre vos types.
    • Faible sélectivité. Un index sur une colonne à deux valeurs possibles aide rarement, parce que lire l’index plus le tas est souvent plus lent qu’un simple parcours. Le planificateur le sait et le saute, à juste titre.

    Index partiels pour données asymétriques

    L’un de mes outils préférés pour les charges réelles est l’index partiel, qui ne couvre que les lignes correspondant à une condition. Si 95 pour cent de vos commandes sont terminées et que vous interrogez presque toujours la petite tranche encore en attente, n’indexer que les lignes en attente vous donne un index minuscule et rapide qui reste chaud en mémoire.

    -- Indexer seulement les lignes que nous cherchons réellement
    CREATE INDEX idx_orders_pending
        ON orders (placed_at)
        WHERE status = 'pending';

    Cela garde l’index petit, ce qui le garde rapide et peu coûteux à maintenir. Pour les tables à forte asymétrie c’est souvent la différence entre un index qui tient en cache et un qui n’y tient pas.

    Comment je travaille réellement

    Ma boucle est ennuyeuse et elle marche. Trouver la requête lente à partir de vraies métriques, pas d’une intuition. Lancer EXPLAIN ANALYZE et le lire attentivement. Identifier si le problème est un index manquant, un mauvais ordre de colonnes, des statistiques périmées, ou une requête écrite d’une façon qui défait l’indexation. Faire un changement. Mesurer à nouveau. Répéter jusqu’à ce que le plan soit propre.

    Je résiste à l’envie d’ajouter cinq index d’un coup, parce qu’alors je ne peux pas dire lequel a aidé et j’ai souscrit à un surcoût d’écriture dont je n’ai peut-être pas besoin. Un changement, une mesure. Et je révise l’ensemble des index périodiquement, parce que les charges dérivent et l’index essentiel d’hier peut devenir le poids mort d’aujourd’hui qui ne fait que ralentir les écritures. Le même soin qui va dans le schéma et le modèle de données va dans le maintien d’index honnêtes. Mesurer, changer une chose, mesurer à nouveau. Cette discipline bat l’astuce à tous les coups.