Análisis detallado de RenderingNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

Soy Ian Kilpatrick, líder de ingeniería del equipo de diseño de Blink, junto con Koji Ishii. Antes de trabajar en el equipo de Blink, era ingeniero de frontend (antes de que Google tuviera el puesto de "ingeniero de frontend") y compilaba funciones en Documentos de Google, Drive y Gmail. Después de alrededor de cinco años en ese puesto, aposté a cambiarme al equipo de Blink, aprender C++ de manera eficaz en el trabajo y tratar de adaptarme a la base de código de Blink, que es muy compleja. Incluso hoy, solo entiendo una parte relativamente pequeña. Gracias por tu tiempo durante este período. Me tranquilizó el hecho de que muchos “ingenieros de frontend en recuperación” hicieron la transición a “ingenieros de navegadores” antes que yo.

Mi experiencia previa me ha guiado personalmente mientras trabajaba en el equipo de Blink. Como ingeniero de frontend, me encontraba constantemente con inconsistencias en el navegador, problemas de rendimiento, errores de renderización y funciones faltantes. LayoutNG fue una oportunidad para ayudar a corregir de forma sistemática estos problemas dentro del sistema de diseño de Blink y representa la suma de los esfuerzos de muchos ingenieros a lo largo de los años.

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

Vista panorámica de 30,000 pies de las arquitecturas de motores de diseño

Anteriormente, el árbol de diseño de Blink era lo que llamaré un "á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 que impone un elemento superior, la posición de cualquier elemento flotante y la información de salida, por ejemplo, el ancho y la altura finales del objeto o su posición en X e Y.

Estos objetos se mantuvieron entre renderizaciones. Cuando se produjo un cambio de estilo, marcamos ese objeto como no sincronizado y, del mismo modo, todos sus elementos superiores en el árbol. Cuando se ejecutaba la fase de diseño de la canalización de renderización, limpiaba el árbol, recorría los objetos sucios y, luego, ejecutaba el diseño para que estuvieran en un estado limpio.

Descubrimos que esta arquitectura generaba muchos tipos de problemas, que describiremos a continuación. Pero primero, hagamos un alto y consideremos cuáles son las entradas y salidas del diseño.

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

El modelo conceptual que se describió anteriormente.

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 árbol de fragmentos.

El árbol de fragmentos.

Anteriormente, analicé el árbol de fragmentos inmutable y describí cómo está diseñado para volver a usar grandes porciones del árbol anterior para diseños incrementales.

Además, almacenamos el objeto de restricciones superior que generó ese fragmento. La usamos como clave de caché, que analizaremos más adelante.

El algoritmo de diseño intercalado (texto) también se volvió a escribir para que coincida con la nueva arquitectura inmutable. No solo produce la representación de lista plana inmutable para el diseño intercalado, sino que también cuenta con almacenamiento en caché a nivel del párrafo para un nuevo diseño más rápido, forma por párrafo para aplicar atributos de fuente en elementos y palabras, un nuevo algoritmo Unicode bidireccional 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 correcció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 dañados". Antes, esto era en lo que pasábamos mucho tiempo y, en el proceso, debíamos lidiar constantemente con el sistema. Un modo de fallo común era aplicar una solución muy específica para un error, pero descubrir semanas después que habíamos causado una regresión en otra parte (aparentemente no relacionada) del sistema.

Como se describe en publicaciones anteriores, esto es un signo de un sistema muy frágil. En el caso del diseño, no teníamos un contrato limpio entre ninguna clase, lo que hacía que los ingenieros de navegadores dependieran de un estado que no deberían o malinterpretaran algún valor de otra parte del sistema.

A modo de ejemplo, en un momento dado, tuvimos una cadena de aproximadamente 10 errores durante más de un año, relacionados con el diseño flexible. Cada corrección causaba un problema de corrección o rendimiento en una parte del sistema, lo que generaba 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 del excelente proyecto Web Platform Tests (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 se debe a 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 de WPT, lo que ayuda a garantizar que ningún navegador vuelva a cometer el mismo error.

Invalidación insuficiente

Si alguna vez tuviste un error misterioso en el que cambiar el tamaño de la ventana del navegador o activar una propiedad CSS hacía que el error desapareciera mágicamente, te encontraste con un problema de invalidación insuficiente. Efectivamente, una parte del árbol mutable se consideró limpia, pero debido a algún cambio en las restricciones superiores, no representó el resultado correcto.

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

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

Una solución para este tipo de errores suele ser 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 delicada de corregir.

Hoy (como se describió anteriormente), tenemos un objeto de restricciones superiores inmutable que describe todas las entradas del diseño superior al secundario. Lo almacenamos con el fragmento inmutable resultante. Debido a esto, tenemos un lugar centralizado en el que diff estas dos entradas para determinar si el elemento secundario necesita que se realice otro pase de diseño. Esta lógica de diferencias es complicada, pero está bien contenida. La depuración de esta clase de problemas de invalidación insuficiente suele generar la inspección manual de las dos entradas y la decisión de qué cambió en la entrada para que se requiera otro pase de diseño.

Las correcciones de este código de diferencia suelen ser simples y fáciles de probar en unidades debido a la simplicidad de crear estos objetos independientes.

Comparación de una imagen de ancho fijo y una de ancho porcentual.
Un elemento con un valor de ancho o alto fijo no se ve afectado si aumenta el tamaño disponible que se le asigna, pero sí lo hace un ancho o alto basado en un porcentaje. El objeto available-size se representa en el objeto Parent Constraints y, como parte del algoritmo de comparación, realizará esta optimización.

El código de diferencia para el 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 esencia, en el sistema anterior, era muy difícil garantizar que el diseño fuera idempotente, es decir, que volver a ejecutar el diseño con las mismas entradas generara el mismo resultado.

En el siguiente ejemplo, simplemente cambiamos una propiedad CSS entre dos valores. Sin embargo, esto genera un rectángulo que “crece infinitamente”.

El video y la demostración muestran 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 introducir errores como este. Si el código cometió el error de leer el tamaño o la posición de un objeto en el momento o la etapa incorrectos (por ejemplo, porque no “limpiamos” el tamaño o la posición anteriores), agregaríamos inmediatamente un error sutil de histéresis. Por lo general, estos errores no aparecen en las pruebas, ya que la mayoría de las pruebas se enfocan en un solo diseño y renderización. Aún más preocupante, sabíamos que se necesitaba algo de esta histéresis para que algunos modos de diseño funcionaran correctamente. Teníamos errores en los que realizábamos una optimización para quitar un pase de diseño, pero introducíamos 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 del resultado del diseño anterior, genera 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, mitigamos ampliamente esta clase de errores del sistema de diseño.

Invalidación excesiva y rendimiento

Esto es lo opuesto a la clase de errores de invalidación insuficiente. A menudo, cuando corregimos un error de invalidación insuficiente, se generaba una disminución abrupta del rendimiento.

A menudo, tuvimos que tomar decisiones difíciles que priorizaban la corrección sobre el rendimiento. En la siguiente sección, analizaremos en más detalle cómo mitigamos este tipo de problemas de rendimiento.

Aumento de los diseños de dos pases y los descensos de rendimiento

El diseño flexible y de cuadrícula representó un cambio en la expresividad de los diseños en la Web. Sin embargo, estos algoritmos eran muy diferentes del algoritmo de diseño de bloques que los precedía.

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 los desarrolladores web desean.

Por ejemplo, a menudo, deseas que el tamaño de todos los elementos secundarios se expanda al tamaño del más grande. Para admitir esto, el diseño principal (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 a este tamaño. Este comportamiento es el predeterminado para el diseño flexible y de cuadrícula.

Dos conjuntos de cuadros: el primero muestra el tamaño intrínseco de los cuadros en el pase de medición y el segundo en el diseño con la misma altura.

En un principio, estos diseños de dos pases eran aceptables en términos de rendimiento, ya que las personas no solían anidarlos de forma profunda. Sin embargo, comenzamos a ver problemas de rendimiento significativos a medida que se generaba contenido más complejo. Si no almacenas en caché el resultado de la fase de medición, el árbol de diseño cambiará entre su estado de medida y su estado de diseño final.

Los diseños de un, dos y tres pases se explican en la leyenda.
En la imagen anterior, tenemos tres elementos <div>. Un diseño simple de un solo pase (como el diseño de bloques) visitará tres nodos de diseño (complejidad O(n)). Sin embargo, para un diseño de dos pases (como flex o cuadrícula), esto puede generar una complejidad de visitas de O(2n) para este ejemplo.
Gráfico que muestra el aumento exponencial del tiempo de diseño.
En esta imagen y demo, se muestra un diseño exponencial con el diseño de cuadrícula. Este error se corrigió en Chrome 93 como resultado de mover la cuadrícula a la nueva arquitectura.

Anteriormente, intentábamos agregar cachés muy específicas al diseño flexible y de cuadrícula para combatir este tipo de disminución repentina del rendimiento. Esto funcionó (y llegamos muy lejos con Flex), pero constantemente teníamos problemas con errores de invalidación insuficiente y excesiva.

LayoutNG nos permite crear estructuras de datos explícitas para la entrada y la salida del diseño, y, además, compilamos cachés de los pases de medición y diseño. Esto vuelve a llevar la complejidad a O(n), lo que genera un rendimiento lineal predecible para los desarrolladores web. Si alguna vez hay un caso en el que un diseño realiza un diseño de tres pases, simplemente almacenaremos en caché ese pase también. Esto puede abrir oportunidades para presentar de forma segura modos de diseño más avanzados en el futuro, un ejemplo de cómo RenderingNG desbloquea la extensibilidad en todos los aspectos. En algunos casos, el diseño de cuadrícula puede requerir diseños de tres pases, pero es muy raro en este momento.

Cuando los desarrolladores encuentran problemas de rendimiento específicamente con el diseño, suele deberse a un error exponencial del tiempo de diseño en lugar de 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) genera un diseño de 50 a 100 ms, es probable que se trate de un error de diseño exponencial.

En 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). Incluso los conceptos de los que hablamos aquí solo tocaron la superficie y pasaron por alto muchos detalles. Sin embargo, esperamos haber demostrado cómo mejorar de forma sistemática la arquitectura de un sistema puede generar ganancias extraordinarias a largo plazo.

Dicho esto, sabemos que aún tenemos mucho trabajo por delante. Estamos al tanto de las clases de problemas (tanto de rendimiento como de precisión) que estamos trabajando para resolver y nos entusiasman las nuevas funciones de diseño que llegarán a CSS. Creemos que la arquitectura de LayoutNG permite resolver estos problemas de forma segura y manejable.

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