Je ressens souvent que le code javascript en général s’exécute beaucoup plus lentement qu’il ne le pourrait, simplement parce qu’il n’est pas optimisé correctement. Voici un résumé des techniques d’optimisation courantes que j’ai trouvées utiles. Notez que le compromis entre les performances et la lisibilité est souvent présent, donc la question de quand privilégier les performances par rapport à la lisibilité est laissée au lecteur. Je noterai également que parler d’optimisation nécessite nécessairement de parler de benchmarking. Optimiser une fonction pendant des heures pour la faire fonctionner 100 fois plus vite est inutile si la fonction ne représentait qu’une fraction du temps d’exécution global réel au départ. Si l’on optimise, la première et la plus importante étape est le benchmarking. Je couvrirai le sujet dans les points suivants. Soyez également conscient que les micro-benchmarks sont souvent défectueux, et cela peut inclure ceux présentés ici. J’ai fait de mon mieux pour éviter ces pièges, mais n’appliquez pas aveuglément les points présentés ici sans benchmarking.
J’ai inclus des exemples exécutables pour tous les cas où c’est possible. Ils montrent par défaut les résultats que j’ai obtenus sur ma machine (brave 122 sur archlinux) mais vous pouvez les exécuter vous-même. Autant je déteste le dire, Firefox a un peu pris du retard dans le jeu de l’optimisation, et représente une très petite fraction du trafic, donc je ne recommande pas d’utiliser les résultats que vous obtiendriez sur Firefox comme des indicateurs utiles.
Cela peut sembler évident, mais cela doit être mentionné car il ne peut y avoir une autre première étape vers l’optimisation : si vous essayez d’optimiser, vous devriez d’abord chercher à éviter le travail. Cela inclut des concepts comme la mémorisation, la paresse et le calcul incrémental. Cela serait appliqué différemment en fonction du contexte. Dans React, par exemple, cela signifierait d’appliquer, et d’autres primitives applicables.
Javascript rend facile de cacher le coût réel des comparaisons de chaînes. Si vous devez comparer des chaînes en C, vous utiliseriez la fonction . Javascript utilise à la place, donc vous ne voyez pas le . Mais il est là, et une comparaison de chaînes nécessitera généralement (mais pas toujours) de comparer chacun des caractères de la chaîne avec ceux de l’autre chaîne ; la comparaison de chaînes est . Un motif JavaScript courant à éviter est les chaînes en tant qu’énumérations. Mais avec l’avènement de TypeScript, cela devrait être facilement évitable, car les énumérations sont des entiers par défaut.
Les moteurs javascript essaient d’optimiser le code en supposant que les objets ont une forme spécifique, et que les fonctions recevront des objets de la même forme. Cela leur permet de stocker les clés de la forme une fois pour tous les objets de cette forme, et les valeurs dans un tableau plat séparé. Pour le représenter en javascript :
Par exemple, à l’exécution si la fonction suivante reçoit deux objets avec la forme , le moteur va spéculer que les futurs objets auront la même forme, et générer du code machine optimisé pour cette forme.
Si l’on passait plutôt un objet non avec la forme mais avec la forme , le moteur devrait annuler sa spéculation et la fonction deviendrait soudainement beaucoup plus lente. Je vais limiter mon explication ici car vous devriez lire le si vous voulez plus de détails, mais je vais souligner que V8 en particulier a 3 modes, pour les accès qui sont : monomorphes (1 forme), polymorphes (2-4 formes) et mégamorphes (5+ formes). Disons que vous voulez vraiment rester monomorphe, car le ralentissement est drastique :
J’aime autant la programmation fonctionnelle que n’importe qui d’autre, mais sauf si vous travaillez en Haskell/OCaml/Rust où le code fonctionnel est compilé en code machine efficace, le fonctionnel sera toujours plus lent que l’impératif.
Les problèmes avec ces méthodes sont que :
Un autre endroit où chercher des gains d’optimisation est toute source d’indirection, dont je peux voir 3 sources principales :
Le benchmark proxy est particulièrement brutal sur V8 pour le moment. La dernière fois que j’ai vérifié, les objets proxy retombaient toujours de la JIT à l’interprète, en voyant ces résultats, cela pourrait toujours être le cas.
Je voulais aussi montrer l’accès à un objet profondément imbriqué par rapport à un accès direct, mais les moteurs sont très bons pour quand il y a une boucle chaude et un objet constant. J’ai inséré un peu d’indirection pour l’empêcher.
Ce point nécessite un peu de connaissances de bas niveau, mais a des implications même en javascript, donc je vais expliquer. Du point de vue du CPU, récupérer de la mémoire de la RAM est lent. Pour accélérer les choses, il utilise principalement deux optimisations.
Comme expliqué dans la section 2, les moteurs utilisent des formes pour optimiser les objets. Cependant, lorsque la forme devient trop grande, le moteur n’a pas d’autre choix que d’utiliser une table de hachage régulière (comme un objet). Et comme nous l’avons vu dans la section 5, les erreurs de cache diminuent considérablement les performances. Les tables de hachage sont sujettes à cela car leurs données sont généralement réparties de manière aléatoire et uniforme sur la région mémoire qu’elles occupent. Voyons comment cela se comporte avec cette carte de certains utilisateurs indexés par leur ID.
Certains motifs javascript sont difficiles à optimiser pour les moteurs, et en utilisant ou ses dérivés, vous pouvez faire disparaître ces motifs. Dans cet exemple, nous pouvons observer comment l’utilisation de évite le coût de la création d’un objet avec une clé d’objet dynamique.
Nous avons déjà vu ci-dessus comment les chaînes sont plus coûteuses qu’elles n’en ont l’air. Eh bien, j’ai un peu une situation de bonnes nouvelles/mauvaises nouvelles ici, que j’annoncerai dans le seul ordre logique (mauvais d’abord, bon ensuite) : les chaînes sont plus complexes qu’elles n’en ont l’air, mais elles peuvent aussi être assez efficaces si elles sont bien utilisées.
Un concept important en matière d’optimisation des performances est la spécialisation : adapter votre logique pour s’adapter aux contraintes de votre cas d’utilisation particulier. Cela signifie généralement déterminer quelles conditions sont <em>probablement</em> vraies pour votre cas, et coder pour ces conditions.
Je ne rentrerai pas dans les détails sur les structures de données car elles nécessiteraient leur propre publication. Mais sachez que l’utilisation de structures de données incorrectes pour votre cas d’utilisation peut avoir un <strong>impact plus important que toutes les optimisations ci-dessus</strong>. Je vous suggère de vous familiariser avec les structures de données natives comme et , et d’apprendre les listes chaînées, les files prioritaires, les arbres (RB et B+) et les tries.
J’ai laissé cette section pour la fin pour une raison : j’avais besoin d’établir ma crédibilité avec les sections amusantes ci-dessus. Maintenant que je (espère) l’ai, laissez-moi vous dire que le benchmarking est la partie la plus importante de l’optimisation. Non seulement c’est la plus importante, mais c’est aussi <em>difficile</em>. Même après 20 ans d’expérience, je crée parfois des benchmarks défectueux, ou j’utilise incorrectement les outils de profilage. Donc quoi que vous fassiez, veuillez <strong>mettre le plus d’efforts dans le benchmarking correct</strong>.