Analyse approfondie de RenderingNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji hishi
Koji Ishi

Je suis Ian Kilpatrick, ingénieur en chef dans l'équipe de mise en page Blink, avec Koji Ishii. Avant de travailler dans l'équipe Blink, j'étais ingénieur front-end (avant que Google n'ait le rôle d'"ingénieur front-end"), développant des fonctionnalités dans Google Docs, Drive et Gmail. Après environ cinq ans à ce poste, j'ai pris le pari de rejoindre l'équipe Blink, d'apprendre C++ sur le terrain et d'essayer de monter en puissance sur le codebase Blink extrêmement complexe. Encore aujourd'hui, je n'en comprends qu'une petite partie. Je vous remercie d'avoir accordé un peu de temps pendant cette période. J'ai été rassuré par le fait que de nombreux "ingénieurs front-end en phase de récupération" aient fait la transition vers le poste d'"ingénieur navigateur" avant moi.

Mon expérience antérieure m'a guidé personnellement dans l'équipe Blink. En tant qu'ingénieur front-end, je rencontrais constamment des incohérences dans les navigateurs, des problèmes de performances, des bugs de rendu et des fonctionnalités manquantes. LayoutNG m'a permis de résoudre systématiquement ces problèmes dans le système de mise en page de Blink. Il représente la somme des efforts de nombreux ingénieurs au fil des ans.

Dans cet article, je vais vous expliquer comment un tel changement d'architecture peut réduire et atténuer divers types de bugs et de problèmes de performances.

Une vue de 30 000 pieds d'architectures de moteurs de mise en page

Auparavant, l'arborescence de mise en page de Blink était ce que j'appellerai une "arborescence modifiable".

Affiche l'arborescence, comme décrit dans le texte suivant.

Chaque objet de l'arborescence de mise en page contenait des informations d'entrée, telles que la taille disponible imposée par un parent, la position des flottants et des informations de sortie, telles que la largeur et la hauteur finales de l'objet, ou sa position X et Y.

Ces objets ont été conservés entre les rendus. En cas de changement de style, nous avons marqué cet objet comme sale et tous ses parents dans l'arborescence. Lors de l'exécution de la phase de mise en page du pipeline de rendu, nous avons nettoyé l'arborescence, parcouru les objets sales, puis exécuté la mise en page pour qu'ils retrouvent un état propre.

Nous avons constaté que cette architecture générait de nombreuses classes de problèmes, que nous allons décrire ci-dessous. Mais d'abord, prenons du recul et voyons quelles sont les entrées et les sorties de la mise en page.

L'exécution de la mise en page sur un nœud dans cette arborescence prend conceptuellement le "Style plus DOM". Toutes les contraintes parentes du système de mise en page parent (grille, bloc ou Flex) exécute l'algorithme de contrainte de mise en page et génère un résultat.

Modèle conceptuel décrit précédemment.

Notre nouvelle architecture formalise ce modèle conceptuel. Nous avons toujours l'arborescence de mise en page, mais nous l'utilisons principalement pour conserver les entrées et les sorties de la mise en page. Pour la sortie, nous générons un tout nouvel objet immuable appelé arborescence de fragments.

Arborescence des fragments.

J'ai déjà abordé l'arborescence des fragments immuables, décrivant comment elle est conçue pour réutiliser de grandes parties de l'arborescence précédente pour des mises en page incrémentielles.

De plus, nous stockons l'objet de contraintes parent qui a généré ce fragment. Nous l'utilisons comme clé de cache. Nous en reparlerons ci-dessous.

L'algorithme de mise en page intégré (texte) est également réécrit pour correspondre à la nouvelle architecture immuable. Elle génère non seulement une représentation de liste plate immuable pour la mise en page intégrée, mais elle inclut également une mise en cache au niveau du paragraphe pour une remise en page plus rapide, une fonctionnalité de forme par paragraphe pour appliquer des fonctionnalités de police aux éléments et des mots, un nouvel algorithme bidirectionnel Unicode utilisant la bibliothèque ICU, de nombreuses corrections d'exactitude, et plus encore.

Types de bugs de mise en page

D'une manière générale, les bugs de mise en page appartiennent à quatre catégories, chacune ayant des causes différentes.

Exactitude

Lorsqu'on évoque les bugs dans le système de rendu, on pense généralement à l'exactitude. Par exemple: "Le navigateur A a un comportement X, tandis que le navigateur B a un comportement Y", ou "Les navigateurs A et B ne fonctionnent pas". Auparavant, c'était ce sur quoi nous passions le plus de temps, et au cours de ce processus, nous nous battons constamment avec le système. Un mode d'échec courant consistait à appliquer une correction très ciblée pour un bug, mais rechercher des semaines plus tard où nous avions provoqué une régression dans une autre partie du système (qui n'apparaissait pas en rapport).

Comme décrit dans les articles précédents, cela est le signe d'un système très fragile. Pour la mise en page en particulier, nous n'avions pas de contrat propre entre les classes, ce qui permettait aux ingénieurs de navigateur de dépendre d'un état, ce qu'ils ne devaient pas interpréter, ou d'interpréter mal une valeur d'une autre partie du système.

Par exemple, à un moment donné, nous avons eu une chaîne d'environ 10 bugs en plus d'un an en lien avec la mise en page Flex. Chaque correction causait un problème d'exactitude ou de performances dans une partie du système, entraînant l'apparition d'un autre bug.

Maintenant que LayoutNG définit clairement le contrat entre tous les composants du système de mise en page, nous avons constaté que nous pouvons appliquer les modifications avec beaucoup plus de confiance. Nous profitons également énormément de l'excellent projet Web Platform Tests (WPT), qui permet à plusieurs parties de contribuer à une suite commune de tests Web.

Nous constatons aujourd'hui que si nous publions une véritable régression sur notre canal stable, celle-ci n'est généralement associée à aucun test dans le dépôt WPT et ne résulte pas d'une mauvaise compréhension des contrats de composants. De plus, dans le cadre de notre règlement de correction des bugs, nous ajoutons toujours un nouveau test WPT pour éviter qu'aucun navigateur ne fasse à nouveau la même erreur.

Sous-invalidation

Si vous avez déjà rencontré un bug mystérieux entraînant la disparition du bug en redimensionnant la fenêtre du navigateur ou en activant/désactivant une propriété CSS, cela signifie que vous rencontrez un problème de sous-invalidation. En réalité, une partie de l'arborescence modifiable a été considérée comme propre, mais en raison d'une modification des contraintes parentes, elle ne représentait pas la sortie correcte.

Ce cas de figure est très courant avec les modes de mise en page en deux passes (parcourir deux fois l'arborescence de mise en page pour déterminer l'état de mise en page final) décrits ci-dessous. Auparavant, notre code ressemblait à ceci:

if (/* some very complicated statement */) {
  child->ForceLayout();
}

Pour résoudre ce type de bug, procédez comme suit:

if (/* some very complicated statement */ ||
    /* another very complicated statement */) {
  child->ForceLayout();
}

La résolution de ce type de problème provoquait généralement une grave baisse des performances (voir l'invalidation excessive ci-dessous) et était très difficile à corriger.

Aujourd'hui (comme décrit ci-dessus), nous disposons d'un objet de contraintes parent immuable qui décrit toutes les entrées, de la mise en page parent à l'enfant. Nous stockons cela avec le fragment immuable qui en résulte. C'est pourquoi nous disposons d'un emplacement centralisé où nous différencions ces deux entrées pour déterminer si l'enfant doit effectuer une autre passe de mise en page. Cette logique de vérification différentielle est complexe, mais elle est bien comprise. Le débogage de cette classe de problèmes de sous-invalidation consiste généralement à inspecter manuellement les deux entrées et à déterminer ce qui a changé dans l'entrée afin qu'une autre passe de mise en page soit requise.

Les corrections de ce code de vérification différentielle sont généralement simples et faciles à tester unitaire, en raison de la simplicité de création de ces objets indépendants.

Comparer une image à largeur fixe et une image en pourcentage de largeur.
Un élément de largeur/hauteur fixe ne se soucie pas si la taille disponible qui lui est attribuée augmente, contrairement à une largeur/hauteur basée sur un pourcentage. La valeur available-size est représentée dans l'objet Parent Constraints (Contrainte parent), et l'algorithme de vérification différentielle effectue cette optimisation.

Le code différentiel pour l'exemple ci-dessus est le suivant:

if (width.IsPercent()) {
  if (old_constraints.WidthPercentageSize() 
    != new_constraints.WidthPercentageSize())
   return kNeedsLayout;
}
if (height.IsPercent()) {
  if (old_constraints.HeightPercentageSize() 
    != new_constraints.HeightPercentageSize())
   return kNeedsLayout;
}

Hystérèse

Cette classe de bugs est similaire à la sous-invalidation. En fait, dans le système précédent, il était incroyablement difficile de s'assurer que la mise en page était idempotente, c'est-à-dire que la réexécution d'une mise en page avec les mêmes entrées produisait la même sortie.

Dans l'exemple ci-dessous, nous faisons simplement passer une propriété CSS d'une valeur à l'autre. Toutefois, cela se traduit par un rectangle qui croît à l'infini.

La vidéo et la démonstration montrent un bug d'hystérèse dans Chrome 92 et versions antérieures. Ce problème est résolu dans Chrome 93.

Avec l'arborescence modifiable précédente, il était très facile d'introduire des bugs comme celui-ci. Si le code se trompait lors de la lecture de la taille ou de la position d'un objet au mauvais moment ou au mauvais moment (car nous n'avons pas "effacé" la taille ou la position précédente, par exemple), nous ajoutons immédiatement un bug d'hystérèse subtile. Ces bugs n'apparaissent généralement pas lors des tests, car la majorité des tests se concentrent sur une seule mise en page et un seul rendu. Plus préoccupant encore, nous savions qu'une partie de cette hystérèse était nécessaire pour que certains modes de mise en page fonctionnent correctement. Nous avions des bugs où nous avions effectué une optimisation pour supprimer une passe de mise en page, mais nous avons introduit un "bug", car le mode de mise en page nécessitait deux passes pour obtenir la sortie correcte.

Arborescence illustrant les problèmes décrits dans le texte précédent.
En fonction des informations de résultat de mise en page précédentes, les mises en page ne sont pas idempotentes

Avec LayoutNG, étant donné que nous disposons de structures de données d'entrée et de sortie explicites, et que l'accès à l'état précédent n'est pas autorisé, nous avons largement atténué cette classe de bug du système de mise en page.

Invalidation excessive et performances

C'est le contraire de la classe de sous-invalidation des bugs. Souvent, la correction d'un bug de sous-invalidation entraînait une baisse des performances.

Nous avons souvent dû faire des choix difficiles, en favorisant l'exactitude plutôt que les performances. Dans la section suivante, nous verrons plus en détail comment nous avons atténué ces types de problèmes de performances.

Élévation des configurations en deux passes et des performances inégalées

La mise en page flexible et en grille représentait un changement dans l'expressivité des mises en page sur le Web. Cependant, ces algorithmes étaient fondamentalement différents de l'algorithme de mise en page en blocs qui les précédait.

Dans la plupart des cas, la mise en page par blocs ne nécessite qu'une seule mise en page pour tous ses enfants. Cette méthode offre de très bonnes performances, mais elle finit par ne pas être aussi expressive que les développeurs Web le souhaitent.

Par exemple, vous souhaitez souvent que la taille de tous les éléments enfants s'étende à celle de la plus grande taille. Pour ce faire, la mise en page parent (flex ou grille) effectue une étape de mesure pour déterminer la taille de chacun des enfants, puis une passe de mise en page pour étirer tous les enfants jusqu'à cette taille. Ce comportement est le comportement par défaut pour les mises en page modulables et en grille.

Deux jeux de boîtes, le premier indique la taille intrinsèque des cases de la mesure, le second présente la même hauteur.

Ces mises en page en deux passes étaient initialement acceptables en termes de performances, car les utilisateurs ne les imbriquaient généralement pas en profondeur. Cependant, nous avons commencé à constater d'importants problèmes de performances à mesure que des contenus plus complexes étaient apparus. Si vous ne mettez pas en cache le résultat de la phase de mesure, l'arborescence de mise en page basculera entre son état measure (mesure) et son état de mise en page final.

Les mises en page en une, deux et trois étapes expliquées dans la légende.
Dans l'image ci-dessus, nous avons trois éléments <div>. Une mise en page simple en un passage (comme la mise en page en blocs) visitera trois nœuds de mise en page (complexity O(n)). Toutefois, pour une mise en page en deux passes (par exemple, Flex ou grille), cela peut potentiellement compliquer les visites O(2n) pour cet exemple.
Graphique illustrant l&#39;augmentation exponentielle du temps de mise en page
Cette image et cette démonstration présentent une mise en page exponentielle avec une mise en page sous forme de grille. Ce problème est résolu dans Chrome 93 suite au déplacement de Grid vers la nouvelle architecture.

Auparavant, nous avions essayé d'ajouter des caches très spécifiques aux mises en page modulables et en grille afin de lutter contre ce type de perte de performances. Cela a fonctionné (et nous sommes allés très loin avec Flex), mais nous luttons constamment avec des bugs d'invalidation sous-et-nombreux.

LayoutNG nous permet de créer des structures de données explicites pour l'entrée et la sortie de la mise en page. De plus, nous avons créé des caches des passes de mesure et de mise en page. Cela redevient complexe à O(n), ce qui permet aux développeurs Web d'obtenir des performances prévisibles et linéaires. S'il arrive qu'une mise en page effectue une mise en page en trois passes, nous mettrons simplement en cache cette même carte. Cela peut ouvrir des opportunités d'introduire des modes de mise en page plus avancés en toute sécurité à l'avenir, par exemple de la façon dont RenderingNG libère fondamentalement l'extensibilité à tous les niveaux. Dans certains cas, la mise en page en grille peut nécessiter des mises en page en trois passages, mais elle est extrêmement rare pour le moment.

Nous constatons que lorsque les développeurs rencontrent des problèmes de performances en particulier avec la mise en page, cela est généralement dû à un bug de temps de mise en page exponentiel plutôt qu'au débit brut de l'étape de mise en page du pipeline. Si une petite modification incrémentielle (un élément change une seule propriété CSS) génère une mise en page de 50 à 100 ms, il s'agit probablement d'un bug de mise en page exponentiel.

En résumé

La mise en page est un domaine extrêmement complexe. Nous n'avons pas abordé toutes sortes de détails intéressants, tels que les optimisations de mise en page intégrées (en réalité, le fonctionnement de l'ensemble du sous-système intégré et texte), et même les concepts abordés ici n'ont fait qu'effleurer la surface et négligé de nombreux détails. Toutefois, nous espérons avoir montré à quel point l'amélioration systématique de l'architecture d'un système peut entraîner des gains considérables à long terme.

Cela dit, nous savons que nous avons encore beaucoup de travail devant nous. Nous sommes conscients des problèmes de performances et d'exactitude que nous nous efforçons de résoudre, et nous sommes impatients de vous présenter de nouvelles fonctionnalités de mise en page dans CSS. Nous pensons que l'architecture de LayoutNG permet de résoudre ces problèmes de manière sécurisée et facile.

Une image (vous savez laquelle !) d'Una Kravets