Análisis detallado de RenderingNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji-Ishi
Koji Ishi

Soy Ian Kilpatrick, jefe de ingeniería del equipo de diseño de Blink, junto con Koji Ishii. Antes de trabajar en el equipo de Blink, fui ingeniero de frontend (antes de que Google tuviera el rol de "ingeniero de frontend"), desarrollando funciones en Documentos de Google, Drive y Gmail. Después de aproximadamente cinco años en ese puesto, me arriesgué a pasar al equipo de Blink, aprendiendo C++ de forma efectiva en el trabajo e intentando aumentar la complejidad de la base de código de Blink. Incluso hoy, solo entiendo una parte relativamente pequeña de esto. Agradezco por el tiempo que se me brindó durante este período. Me reconfortó el hecho de que muchos de los "ingenieros de recuperación de front-end" hicieron la transición a ser "ingenieros de navegadores" antes que yo.

Mi experiencia previa me guio personalmente mientras pertenecía al equipo de Blink. Como ingeniero front-end, siempre tenía problemas de rendimiento, inconsistencias en el navegador, errores de renderización y funciones faltantes. LayoutNG me brindó la oportunidad de ayudar a solucionar sistemáticamente estos problemas dentro del sistema de diseño de Blink y representa la suma del esfuerzo de muchos ingenieros a lo largo de los años.

En esta publicación, explicaremos cómo un gran cambio de arquitectura como este puede reducir y mitigar varios tipos de errores y problemas de rendimiento.

Vista de 30,000 pies de arquitecturas de motores de diseño

Anteriormente, el árbol de diseño de Blink es lo que llamaré "árbol mutable".

Muestra el árbol como se describe en el siguiente texto.

Cada objeto del árbol de diseño contenía información de entrada, como el tamaño disponible impuesto por un elemento superior, la posición de los números de punto flotante y la información de salida, por ejemplo, el ancho y la altura finales del objeto o su posición x e y.

Estos objetos se mantuvieron entre las renderizaciones. Cuando se producía un cambio de estilo, marcamos ese objeto como sucio y, asimismo, todos sus elementos superiores en el árbol. Cuando se ejecutaba la fase de diseño de la canalización de renderización, limpiábamos el árbol, recorrimos los objetos sucios y, luego, ejecutamos el diseño para llevarlos a un estado limpio.

Descubrimos que esta arquitectura generaba muchas clases de problemas, que describiremos a continuación. Pero, primero, pensemos en cuáles son las entradas y salidas del diseño.

Ejecutar el diseño en un nodo de este árbol toma conceptualmente "Estilo más DOM" y cualquier restricción superior del sistema de diseño superior (cuadrícula, bloque o flexible), ejecuta el algoritmo de restricciones de diseño y produce un resultado.

El modelo conceptual que se describió antes.

Nuestra nueva arquitectura formaliza este modelo conceptual. Aún tenemos el árbol de diseño, pero lo usamos principalmente para conservar las entradas y salidas del diseño. Para el resultado, generamos un objeto inmutable completamente nuevo llamado fragment tree.

El árbol de fragmentos

Anteriormente, abordamos el árbol de fragmentos inmutable y describí cómo está diseñado para reutilizar grandes partes del árbol anterior en diseños incrementales.

Además, almacenamos el objeto de restricciones superior que generó ese fragmento. Usaremos esto como una clave de caché, que analizaremos con más detalle a continuación.

El algoritmo de diseño intercalado (de texto) también se reescribe para que coincida con la nueva arquitectura inmutable. No solo produce la representación de lista plana inmutable para un diseño intercalado, sino que también incluye almacenamiento en caché a nivel de párrafo para un rediseño más rápido, forma por párrafo para aplicar atributos de fuente en elementos y palabras, un nuevo algoritmo bidireccional Unicode que usa ICU, muchas correcciones de precisión y mucho más.

Tipos de errores de diseño

En términos generales, los errores de diseño se dividen en cuatro categorías diferentes, cada una con diferentes causas raíz.

Precisión

Cuando pensamos en errores en el sistema de renderización, por lo general, pensamos en la precisión, por ejemplo: "El navegador A tiene el comportamiento X, mientras que el navegador B tiene el comportamiento Y", o "los navegadores A y B están rotos". Invertíamos mucho tiempo en esto y, en el proceso, luchamos constantemente contra el sistema. Un modo de falla común era aplicar una corrección muy específica para un error, pero algunas semanas más tarde se descubrió que habíamos causado una regresión en otra parte (aparentemente no relacionada) del sistema.

Como se describió en publicaciones anteriores, esto es una señal de que el sistema es muy frágil. En el caso del diseño específicamente, no teníamos un contrato claro entre ninguna clase, lo que provocaba que los ingenieros de navegadores dependieran de un estado que no debían o interpretar mal algún valor de otra parte del sistema.

Como ejemplo, en un momento tuvimos una cadena de aproximadamente 10 errores en el transcurso de más de un año, relacionados con el diseño flexible. Cada corrección causaba un problema de corrección o de rendimiento en parte del sistema, lo que generó otro error.

Ahora que LayoutNG define claramente el contrato entre todos los componentes del sistema de diseño, descubrimos que podemos aplicar cambios con mucha más confianza. También nos beneficiamos mucho con el excelente proyecto de pruebas de plataforma web (WPT), que permite que varias partes contribuyan a un conjunto de pruebas web común.

Hoy descubrimos que si lanzamos una regresión real en nuestro canal estable, por lo general, no tiene pruebas asociadas en el repositorio de WPT, y no es el resultado de un malentendido de los contratos de componentes. Además, como parte de nuestra política de corrección de errores, siempre agregamos una nueva prueba WPT, que ayuda a garantizar que ningún navegador vuelva a cometer el mismo error.

Anulación insuficiente

Si alguna vez tuviste un error misterioso en el que cambiar el tamaño de la ventana del navegador o activar o desactivar una propiedad de CSS de forma mágica hace que el error desaparezca, entonces tienes un problema de invalidación insuficiente. Efectivamente, una parte del árbol mutable se consideró limpia, pero debido a algunos cambios en las restricciones superiores, no representó el resultado correcto.

Esto es muy común con los modos de diseño de dos pases (caminar por el árbol de diseño dos veces para determinar el estado de diseño final) que se describen a continuación. Antes, nuestro código se veía de la siguiente manera:

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

Por lo general, la solución para este tipo de error sería la siguiente:

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

Por lo general, una solución para este tipo de problema causaba una regresión de rendimiento grave (consulta la invalidación excesiva a continuación) y era muy difícil corregirla.

Hoy (como se describió anteriormente) tenemos un objeto de restricciones superior inmutable que describe todas las entradas desde el diseño de nivel superior hasta el elemento secundario. Almacenamos esto con el fragmento inmutable resultante. Por lo tanto, tenemos un lugar centralizado donde diferenciamos estas dos entradas para determinar si el elemento secundario debe tener otro pase de diseño. Esta lógica de diferenciación es complicada, pero está bien contenida. La depuración de esta clase de problemas de invalidación insuficiente suele dar como resultado una inspección manual de las dos entradas y una decisión sobre qué cambió en la entrada para que se requiera otro pase de diseño.

Las correcciones a este código de diffing suelen ser simples y pueden probarse por unidad debido a la simplicidad de la creación de estos objetos independientes.

Comparación de una imagen de ancho fijo y porcentaje de ancho.
A un elemento de ancho o alto fijo no le importa si aumenta el tamaño disponible que tiene asignado. Sin embargo, sí lo hace un elemento de ancho o alto basado en porcentajes. El available-size se representa en el objeto Parent Constraints y como parte del algoritmo de diffing realizará esta optimización.

El código de diffing del ejemplo anterior es el siguiente:

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

Histéresis

Esta clase de errores es similar a la invalidación insuficiente. En términos sencillos, en el sistema anterior era increíblemente difícil garantizar que el diseño fuera idempotente, es decir, volver a ejecutarlo con las mismas entradas o generar el mismo resultado.

En el siguiente ejemplo, simplemente alternamos una propiedad de CSS entre dos valores. Sin embargo, esto da como resultado un rectángulo que “crece infinitamente”.

En el video y la demostración, se muestra un error de histéresis en Chrome 92 y versiones anteriores. Se corrigió en Chrome 93.

Con nuestro árbol mutable anterior, fue increíblemente fácil ingresar errores como este. Si el código cometió el error de leer el tamaño o la posición de un objeto en un momento o etapa incorrectos (ya que no "borramos" el tamaño o la posición anteriores, por ejemplo), agregaríamos de inmediato un error de histéresis sutil. Por lo general, estos errores no aparecen en las pruebas, ya que la mayoría de ellas se enfocan en un solo diseño y renderización. Y lo que es aún más preocupante, sabíamos que parte de esta histéresis era necesaria para que algunos modos de diseño funcionaran correctamente. Se produjeron errores en los que se realizaba una optimización para quitar un pase de diseño, pero se introdujo un "error", ya que el modo de diseño requería dos pases para obtener el resultado correcto.

Un árbol que muestra los problemas descritos en el texto anterior.
Según la información anterior sobre el resultado del diseño, se generan diseños no idempotentes.

Con LayoutNG, como tenemos estructuras de datos de entrada y salida explícitas, y no se permite el acceso al estado anterior, hemos mitigado ampliamente esta clase de error del sistema de diseño.

Sobre invalidación y rendimiento

Esto es lo opuesto directo a la clase de errores que no cuentan con invalidación suficiente. A menudo, cuando se corrige un error de invalidación deficiente, se activa un umbral de rendimiento.

A menudo teníamos que tomar decisiones difíciles para favorecer la corrección por sobre el rendimiento. En la siguiente sección, analizaremos en detalle cómo mitigamos este tipo de problemas de rendimiento.

Incremento de los diseños de dos pases y los acantilados de rendimiento

El diseño flexible y de cuadrícula representaba un cambio en la expresividad de los diseños en la Web. Sin embargo, estos algoritmos eran fundamentalmente diferentes del algoritmo de diseño de bloques que venía antes.

El diseño de bloques (en casi todos los casos) solo requiere que el motor realice el diseño en todos sus elementos secundarios exactamente una vez. Esto es excelente para el rendimiento, pero no es tan expresivo como quieren los desarrolladores web.

Por ejemplo, a menudo deseas que el tamaño de todos los elementos secundarios se expanda al tamaño del más grande. Para ello, el diseño superior (flex o cuadrícula) realizará un pase de medición para determinar el tamaño de cada uno de los elementos secundarios y, luego, un pase de diseño para estirar todos los elementos secundarios hasta este tamaño. Este comportamiento es el predeterminado tanto para el diseño Flex como para el de cuadrícula.

Dos conjuntos de cuadros, el primero muestra el tamaño intrínseco de los cuadros en el pase de medición; el segundo, a la misma altura.

Al principio, estos diseños de dos pases eran aceptables en lo que respecta al rendimiento, ya que las personas no suelen anidarlos profundamente. Sin embargo, comenzamos a ver problemas de rendimiento significativos a medida que surgía contenido más complejo. Si no almacenas en caché el resultado de la fase de medición, se realizará una hiperpaginación del árbol de diseño entre su estado de medida y su estado de diseño final.

Los diseños de uno, dos y tres pases se explican en la leyenda.
En la imagen anterior, tenemos tres elementos <div>. Un diseño simple de un pase (como el diseño de bloques) visitará tres nodos de diseño (complejidad O(n). Sin embargo, en el caso de un diseño de dos pases (como Flex o Cuadrícula), esto puede dar lugar a la complejidad de las visitas O(2n) en este ejemplo.
Gráfico que muestra el aumento exponencial en el tiempo de diseño.
En esta imagen y en la demostración, se muestra un diseño exponencial con el diseño de cuadrícula. Este problema se solucionó en Chrome 93 debido al traslado de Grid a la nueva arquitectura.

Anteriormente, intentábamos agregar cachés muy específicos al diseño de Flex y Cuadrícula para combatir este tipo de acantilados de rendimiento. Esto funcionó (y llegamos muy lejos con Flex), pero luchamos constantemente con errores de invalidación excesiva y excesiva.

LayoutNG nos permite crear estructuras de datos explícitas para la entrada y la salida del diseño. Además, tenemos cachés de los pases de medición y diseño. Esto devuelve la complejidad a O(n), lo que da como resultado un rendimiento lineal predecible para los desarrolladores web. Si alguna vez hay un caso en el que un diseño realice un diseño de tres pases, simplemente almacenaremos en caché ese pase también. Esto podría abrirte la posibilidad de agregar de manera segura modos de diseño más avanzados en el futuro, un ejemplo de cómo Renderizarte principalmente desbloquea la extensibilidad de forma general. En algunos casos, el diseño de cuadrícula puede requerir diseños de tres pases, pero es muy raro en este momento.

Descubrimos que, cuando los desarrolladores tienen problemas de rendimiento específicos con el diseño, por lo general, se debe a un error de tiempo de diseño exponencial en lugar de a la capacidad de procesamiento sin procesar de la etapa de diseño de la canalización. Si un pequeño cambio incremental (un elemento que cambia una sola propiedad CSS) da como resultado un diseño de 50 a 100 ms, es probable que se trate de un error de diseño exponencial.

Resumen

El diseño es un área muy compleja, y no cubrimos todo tipo de detalles interesantes, como las optimizaciones de diseño intercalado (en realidad, cómo funciona todo el subsistema intercalado y de texto), e incluso los conceptos sobre los que hablamos aquí solo recorrieron la superficie y abordaron muchos detalles. Sin embargo, esperamos que hayamos demostrado cómo mejorar sistemáticamente la arquitectura de un sistema puede generar grandes ganancias a largo plazo.

Dicho esto, sabemos que todavía tenemos mucho trabajo por delante. Conocemos las clases de problemas (tanto de rendimiento como de corrección) en las que trabajamos para resolverlas, y nos entusiasman las nuevas funciones de diseño que se implementarán en CSS. Creemos que la arquitectura de LayoutNG hace que la resolución de estos problemas sea segura y fácil.

Una imagen (¡ya sabes cuál!) de Una Kravets.