Analyse approfondie de RenderingNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

Je m'appelle Ian Kilpatrick, je suis ingénieur dans l'équipe de mise en page Blink, avec Koji Ishii. Avant de rejoindre l'équipe Blink, j'étais ingénieur front-end (avant que Google n'ait créé le poste d'ingénieur front-end), et je développais des fonctionnalités dans Google Docs, Drive et Gmail. Après environ cinq ans dans ce rôle, j'ai pris un gros pari en rejoignant l'équipe Blink, en apprenant efficacement le C++ sur le tas et en essayant de me familiariser avec le codebase Blink extrêmement complexe. Encore aujourd'hui, je ne comprends qu'une partie relativement petite. Je vous remercie de m'avoir accordé du temps pendant cette période. J'ai été rassuré par le fait que de nombreux "ingénieurs front-end en convalescence" sont devenus "ingénieurs navigateurs" avant moi.

Mon expérience antérieure m'a guidé personnellement au sein de 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 d'aider à résoudre systématiquement ces problèmes dans le système de mise en page de Blink et représente la somme des efforts de nombreux ingénieurs au fil des ans.

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

Vue d'ensemble des architectures de moteur de mise en page

Auparavant, l'arborescence de mise en page de Blink était ce que j'appellerai un "arbre 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 de tout élément flottant et des informations d'sortie, par exemple la largeur et la hauteur finales de l'objet ou sa position x et y.

Ces objets étaient conservés entre les rendus. Lorsqu'un changement de style s'est produit, nous avons marqué cet objet comme sale, ainsi que tous ses parents dans l'arborescence. Lorsque la phase de mise en page du pipeline de rendu était exécutée, nous nettoyions l'arborescence, parcourions tous les objets sales, puis exécutions la mise en page pour les amener à un état propre.

Nous avons constaté que cette architecture entraînait de nombreuses classes de problèmes, que nous décrirons ci-dessous. Mais d'abord, prenons du recul et examinons les entrées et les sorties de la mise en page.

L'exécution de la mise en page sur un nœud de cet arbre prend conceptuellement le "Style plus DOM" et 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 produit un résultat.

Le modèle conceptuel décrit précédemment.

Notre nouvelle architecture formalise ce modèle conceptuel. Nous disposons toujours de 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 objet immuable entièrement nouveau appelé arbre de fragments.

Arborescence des fragments.

J'ai déjà abordé l'arbre de fragments immuable, en décrivant comment il est conçu pour réutiliser de grandes parties de l'arbre précédent pour les 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 plus loin dans cet article.

L'algorithme de mise en page en ligne (texte) a également été réécrit pour correspondre à la nouvelle architecture immuable. Il produit non seulement la représentation de liste plate immuable pour la mise en page en ligne, mais propose également un cache au niveau du paragraphe pour une redisposition plus rapide, une forme par paragraphe pour appliquer les fonctionnalités de police aux éléments et aux mots, un nouvel algorithme Unicode bidirectionnel utilisant ICU, de nombreuses corrections d'exactitude, et plus encore.

Types de bugs de mise en page

Les bugs de mise en page se répartissent en quatre catégories différentes, chacune ayant des causes différentes.

Exactitude

Lorsque nous parlons de bugs dans le système de rendu, nous pensons généralement à l'exactitude, par exemple : "Le navigateur A a le comportement X alors que le navigateur B a le comportement Y", ou "Les navigateurs A et B sont tous les deux endommagés". Auparavant, c'était ce à quoi nous consacrions une grande partie de notre temps, et nous nous battions constamment avec le système. Un mode d'échec courant consistait à appliquer un correctif très ciblé pour un bug, mais à découvrir des semaines plus tard que nous avions provoqué une régression dans une autre partie (apparemment sans rapport) du système.

Comme décrit dans des posts précédents, cela indique que le système est très fragile. Plus précisément, nous n'avions pas de contrat clair entre les classes, ce qui obligeait les ingénieurs du navigateur à dépendre d'un état qu'ils ne devraient pas, ou à mal interpréter une valeur d'une autre partie du système.

Par exemple, à un moment donné, nous avons eu une chaîne d'environ 10 bugs sur une période de plus d'un an, liés à la mise en page Flex. Chaque correction a entraîné un problème d'exactitude ou de performances dans une partie du système, ce qui a entraîné 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 pouvions appliquer des modifications avec beaucoup plus de confiance. Nous profitons également grandement de l'excellent projet Web Platform Tests (WPT), qui permet à plusieurs parties de contribuer à une suite de tests Web commune.

Aujourd'hui, nous constatons que si nous publions une véritable régression sur notre canal stable, elle n'est généralement associée à aucun test dans le dépôt WPT et ne résulte pas d'un malentendu sur les contrats de composants. De plus, conformément à notre règlement sur la correction des bugs, nous ajoutons toujours un nouveau test WPT pour nous assurer qu'aucun navigateur ne commet à nouveau la même erreur.

Invalidation insuffisante

Si vous avez déjà rencontré un bug mystérieux qui disparaît en modifiant la taille de la fenêtre du navigateur ou en activant/désactivant une propriété CSS, vous avez rencontré un problème d'invalidation insuffisante. Une partie de l'arbre modifiable a été considérée comme propre, mais en raison d'un changement dans les contraintes des parents, elle ne représentait pas la sortie correcte.

Cela est très courant avec les modes de mise en page en deux passes (parcours de l'arborescence de mise en page deux fois pour déterminer l'état de mise en page final) décrits ci-dessous. Auparavant, notre code se présentait comme suit:

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 correction de ce type de problème entraînait généralement une régression importante des performances (voir la survalidation ci-dessous) et était très délicate à effectuer.

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 parente vers l'enfant. Nous le stockons avec le fragment immuable obtenu. Par conséquent, nous disposons d'un emplacement centralisé où nous différencions ces deux entrées pour déterminer si un autre passage de mise en page doit être effectué pour l'enfant. Cette logique de comparaison est complexe, mais bien contenue. Le débogage de cette classe de problèmes d'invalidation insuffisante consiste généralement à inspecter manuellement les deux entrées et à déterminer ce qui a changé dans l'entrée pour qu'une autre étape de mise en page soit requise.

Les corrections apportées à ce code de comparaison sont généralement simples et facilement testables unitairement, car la création de ces objets indépendants est simple.

Comparaison d'une image à largeur fixe et d'une image à largeur en pourcentage.
Un élément de largeur/hauteur fixe ne se soucie pas si la taille disponible qui lui est attribuée augmente, mais une largeur/hauteur basée sur un pourcentage le fait. La valeur available-size est représentée dans l'objet Parent Constraints (Contraintes parent) et sera optimisée dans le cadre de l'algorithme de comparaison.

Le code de comparaison de 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ésis

Cette classe de bugs est semblable à l'invalidation insuffisante. Essentiellement, dans le système précédent, il était extrêmement difficile de s'assurer que la mise en page était idempotente, c'est-à-dire que la réexécution de la mise en page avec les mêmes entrées produisait le même résultat.

Dans l'exemple ci-dessous, nous passons simplement d'une valeur à une autre pour une propriété CSS. Cependant, cela entraîne un rectangle "à croissance infinie".

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

Avec notre précédent arbre modifiable, il était extrêmement facile d'introduire des bugs comme celui-ci. Si le code a fait l'erreur de lire la taille ou la position d'un objet au mauvais moment ou à la mauvaise étape (par exemple, parce que nous n'avons pas "effacé" la taille ou la position précédente), nous ajouterions immédiatement un bug d'hystérésis subtil. Ces bugs n'apparaissent généralement pas lors des tests, car la majorité des tests portent sur une seule mise en page et un seul rendu. Plus inquiétant encore, nous savions qu'une partie de cette hystérésis était nécessaire pour que certains modes de mise en page fonctionnent correctement. Nous avions des bugs : nous effectuions une optimisation pour supprimer une passe de mise en page, mais introduisions un "bug", car le mode de mise en page nécessitait deux passes pour obtenir le résultat correct.

Arbre illustrant les problèmes décrits dans le texte précédent.
Selon les informations sur les résultats de mise en page précédents, génère des mises en page non idempotentes

Avec LayoutNG, comme 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 bugs du système de mise en page.

Invalidation excessive et performances

C'est le contraire de la classe de sous-invalidation des bugs. Lorsque nous corrigions un bug d'invalidation insuffisante, nous provoquons souvent une chute des performances.

Nous avons souvent dû faire des choix difficiles en privilégiant la justesse aux performances. Dans la section suivante, nous allons examiner plus en détail comment nous avons atténué ces types de problèmes de performances.

L'essor des mises en page en deux passes et les chutes de performances

La mise en page Flex et la mise en page en grille ont marqué 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 par blocs qui les a précédés.

Dans presque tous les cas, la mise en page en bloc ne nécessite que le moteur effectue la mise en page sur tous ses enfants une seule fois. Cela permet d'améliorer les performances, mais finit par ne pas être aussi expressif que le souhaitent les développeurs Web.

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

Deux ensembles de cases : le premier montre la taille intrinsèque des cases dans l'étape de mesure, le second affiche une mise en page de toutes les cases de 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 profondément. Cependant, nous avons commencé à constater des problèmes de performances importants à mesure que des contenus plus complexes ont vu le jour. Si vous ne mettez pas en cache le résultat de la phase de mesure, l'arborescence de mise en page se thrash entre son état de mesure et son état final de mise en page.

Les mises en page en une, deux et trois passes sont expliquées dans la légende.
Dans l'image ci-dessus, nous avons trois éléments <div>. Une mise en page simple en une seule étape (comme la mise en page par bloc) visite trois nœuds de mise en page (complexité O(n)). Toutefois, pour une mise en page en deux passes (comme flex ou grille), cela peut entraîner une complexité de O(2n) visites pour cet exemple.
Graphique montrant l&#39;augmentation exponentielle du temps de mise en page.
Cette image et cette démonstration montrent une mise en page exponentielle en mode Grille. Ce problème est résolu dans Chrome 93, car Grid a été déplacé vers la nouvelle architecture.

Auparavant, nous avions essayé d'ajouter des caches très spécifiques aux mises en page flexible et en grille afin de lutter contre ce type de baisse des performances. Cela a fonctionné (et nous avons beaucoup avancé avec Flex), mais nous avons constamment été confrontés à des bugs d'invalidation sous- et sur-évaluée.

LayoutNG nous permet de créer des structures de données explicites pour l'entrée et la sortie de la mise en page. Nous avons également créé des caches pour les passes de mesure et de mise en page. La complexité revient alors à O(n), ce qui se traduit par des performances linéaires prévisibles pour les développeurs Web. Si une mise en page effectue une mise en page en trois passes, nous mettons simplement cette passe en cache également. Cela peut vous permettre d'introduire des modes de mise en page plus avancés en toute sécurité à l'avenir. C'est un exemple de la façon dont RenderingNG permet fondamentalement de développer 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 c'est extrêmement rare pour le moment.

Nous constatons que lorsque les développeurs rencontrent des problèmes de performances liés spécifiquement à la mise en page, cela est généralement dû à un bug de mise en page exponentielle 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 modifiant une seule propriété CSS) entraîne 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, et nous n'avons pas abordé toutes sortes de détails intéressants, comme les optimisations de mise en page intégrée (en fait, le fonctionnement de l'ensemble du sous-système de texte intégré et de texte) et même les concepts abordés ici n'ont fait qu'effleurer le sujet et n'ont pas dissimulé de nombreux détails. Nous espérons toutefois avoir montré comment l'amélioration systématique de l'architecture d'un système peut entraîner des gains importants à long terme.

Cela dit, nous savons que nous avons encore beaucoup de travail devant nous. Nous sommes conscients de certaines classes de problèmes (à la fois de performances et de validité) que nous essayons de résoudre, et nous sommes ravis des nouvelles fonctionnalités de mise en page à venir dans CSS. Nous pensons que l'architecture de LayoutNG permet de résoudre ces problèmes de manière sûre et simple.

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