Un panel de rendimiento 400% más rápido mediante la percepción

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

Independientemente del tipo de aplicación que estés desarrollando, optimizar su rendimiento y garantizar que se cargue rápido y ofrezca interacciones fluidas es fundamental para la experiencia del usuario y el éxito de la aplicación. Una forma de hacerlo es inspeccionar la actividad de una aplicación con herramientas de generación de perfiles para ver qué sucede en segundo plano mientras se ejecuta durante un período determinado. El panel Rendimiento de DevTools es una excelente herramienta de generación de perfiles para analizar y optimizar el rendimiento de las aplicaciones web. Si tu app se ejecuta en Chrome, te brinda una descripción general visual detallada de lo que hace el navegador mientras se ejecuta la aplicación. Comprender esta actividad puede ayudarte a identificar patrones, cuellos de botella y hotspots de rendimiento sobre los que puedes tomar medidas para mejorar el rendimiento.

En el siguiente ejemplo, se explica cómo usar el panel Rendimiento.

Configuramos y recreamos nuestra situación de perfil

Recientemente, nos propusimos mejorar el rendimiento del panel Rendimiento. En particular, queríamos que cargara grandes volúmenes de datos de rendimiento más rápido. Este es el caso, por ejemplo, cuando se genera perfiles de procesos complejos o de larga duración, o cuando se capturan datos de alta granularidad. Para lograrlo, primero fue necesario comprender cómo se desempeñaba la aplicación y por qué lo hacía de esa manera, lo que se logró con una herramienta de generación de perfiles.

Como sabrás, Herramientas para desarrolladores es una aplicación web. Por lo tanto, se puede generar un perfil con el panel Rendimiento. Para generar perfiles de este panel, puedes abrir Herramientas para desarrolladores y, luego, abrir otra instancia de Herramientas para desarrolladores adjunta. En Google, esta configuración se conoce como DevTools en DevTools.

Con la configuración lista, se debe recrear y registrar la situación para la que se va a crear el perfil. Para evitar confusiones, la ventana original de DevTools se denominará "primera instancia de DevTools" y la ventana que inspecciona la primera instancia se denominará "segunda instancia de DevTools".

Captura de pantalla de una instancia de DevTools que inspecciona los elementos en DevTools.
Herramientas para desarrolladores en Herramientas para desarrolladores: Inspecciona las Herramientas para desarrolladores con las Herramientas para desarrolladores.

En la segunda instancia de DevTools, el panel Rendimiento (que se llamará panel de rendimiento de ahora en adelante) observa la primera instancia de DevTools para recrear la situación, que carga un perfil.

En la segunda instancia de DevTools, se inicia una grabación en vivo, mientras que en la primera, se carga un perfil desde un archivo en el disco. Se carga un archivo grande para perfilar con precisión el rendimiento del procesamiento de entradas grandes. Cuando ambas instancias terminan de cargarse, los datos de perfil de rendimiento, comúnmente llamados seguimiento, se ven en la segunda instancia de DevTools del panel de rendimiento que carga un perfil.

El estado inicial: identificar oportunidades de mejora

Una vez finalizada la carga, se observó lo siguiente en nuestra segunda instancia del panel de rendimiento en la siguiente captura de pantalla. Enfócate en la actividad del subproceso principal, que se puede ver debajo de la pista etiquetada como Main. Se puede ver que hay cinco grandes grupos de actividad en el gráfico de llama. Estas consisten en las tareas en las que la carga tarda más tiempo. El tiempo total de estas tareas fue de aproximadamente 10 segundos. En la siguiente captura de pantalla, se usa el panel de rendimiento para enfocarse en cada uno de estos grupos de actividades y ver qué se puede encontrar.

Captura de pantalla del panel de rendimiento en DevTools que inspecciona la carga de un seguimiento de rendimiento en el panel de rendimiento de otra instancia de DevTools. El perfil tarda unos 10 segundos en cargarse. Este tiempo se divide principalmente en cinco grupos principales de actividades.

Primer grupo de actividades: trabajo innecesario

Se hizo evidente que el primer grupo de actividades era código heredado que aún se ejecutaba, pero que no era realmente necesario. Básicamente, todo lo que está debajo del bloque verde etiquetado como processThreadEvents era una pérdida de esfuerzo. Eso fue un éxito rápido. Quitar esa llamada a función ahorró alrededor de 1.5 segundos. Genial.

Segundo grupo de actividades

En el segundo grupo de actividades, la solución no fue tan simple como en el primero. La buildProfileCalls tardó alrededor de 0.5 segundos, y esa tarea no se podía evitar.

Captura de pantalla del panel de rendimiento en DevTools que inspecciona otra instancia del panel de rendimiento. Una tarea asociada con la función buildProfileCalls tarda alrededor de 0.5 segundos.

Por curiosidad, habilitamos la opción Memoria en el panel de rendimiento para investigar más y vimos que la actividad buildProfileCalls también usaba mucha memoria. Aquí, puedes ver cómo el gráfico de línea azul salta repentinamente alrededor del momento en que se ejecuta buildProfileCalls, lo que sugiere una posible fuga de memoria.

Captura de pantalla del generador de perfiles de memoria en DevTools que evalúa el consumo de memoria del panel de rendimiento. El inspector sugiere que la función buildProfileCalls es responsable de una fuga de memoria.

Para investigar esta sospecha, usamos el panel Memory (otro panel de DevTools, diferente del panel Memory del panel de rendimiento) para investigar. En el panel Memoria, se seleccionó el tipo de generación de perfiles "Muestreo de asignación", que registró la instantánea del montón para que el panel de rendimiento cargue el perfil de CPU.

Una captura de pantalla del estado inicial del generador de perfiles de memoria. La opción "allocation sampling" se destaca con un cuadro rojo y se indica que esta opción es mejor para la generación de perfiles de memoria de JavaScript.

En la siguiente captura de pantalla, se muestra la instantánea del montón que se recopiló.

Captura de pantalla del generador de perfiles de memoria, con una operación basada en conjuntos que requiere mucha memoria seleccionada.

A partir de esta instantánea del montón, se observó que la clase Set consumía mucha memoria. Tras revisar los puntos de llamada, se descubrió que asignamos innecesariamente propiedades de tipo Set a objetos creados en grandes volúmenes. Este costo se acumulaba y se consumía mucha memoria, hasta el punto de que era común que la aplicación fallara en entradas grandes.

Los conjuntos son útiles para almacenar elementos únicos y proporcionar operaciones que usan la unicidad de su contenido, como anular la duplicación de conjuntos de datos y proporcionar búsquedas más eficientes. Sin embargo, esas funciones no eran necesarias, ya que se garantizaba que los datos almacenados fueran únicos de la fuente. Por lo tanto, los conjuntos no eran necesarios en primer lugar. Para mejorar la asignación de memoria, se cambió el tipo de propiedad de Set a un array simple. Después de aplicar este cambio, se tomó otra instantánea del montón y se observó una reducción en la asignación de memoria. A pesar de no lograr mejoras considerables en la velocidad con este cambio, el beneficio secundario fue que la aplicación fallaba con menos frecuencia.

Captura de pantalla del Generador de perfiles de memoria. La operación basada en conjuntos que antes era intensiva en memoria se cambió para usar un array simple, lo que redujo significativamente el costo de memoria.

Tercer grupo de actividades: ponderación de las compensaciones de la estructura de datos

La tercera sección es peculiar: puedes ver en el gráfico tipo llama que consta de columnas estrechas pero altas, que denotan llamadas a funciones profundas, y recursiones profundas en este caso. En total, esta sección duró alrededor de 1.4 segundos. Cuando observas la parte inferior de esta sección, resulta evidente que el ancho de estas columnas estaba determinado por la duración de una función: appendEventAtLevel, lo que sugirió que podría ser un cuello de botella.

Dentro de la implementación de la función appendEventAtLevel, se destacó una cosa. Para cada entrada de datos en la entrada (que se conoce en el código como "evento"), se agregó un elemento a un mapa que hacía un seguimiento de la posición vertical de las entradas de la línea de tiempo. Esto era un problema, ya que la cantidad de elementos almacenados era muy grande. Los mapas son rápidos para las búsquedas basadas en claves, pero esta ventaja no es gratuita. A medida que un mapa crece, agregarle datos puede, por ejemplo, volverse costoso debido al reescritura. Este costo se hace evidente cuando se agregan grandes cantidades de elementos al mapa de forma sucesiva.

/**
 * Adds an event to the flame chart data at a defined vertical level.
 */
function appendEventAtLevel (event, level) {
  // ...

  const index = data.length;
  data.push(event);
  this.indexForEventMap.set(event, index);

  // ...
}

Experimentamos con otro enfoque que no requería agregar un elemento en un mapa para cada entrada en el gráfico de llamas. La mejora fue significativa, lo que confirmó que el cuello de botella se relacionaba con la sobrecarga que se generaba cuando se agregaban todos los datos al mapa. El tiempo que tardó el grupo de actividades disminuyó de alrededor de 1.4 segundos a alrededor de 200 milisegundos.

Antes:

Captura de pantalla del panel de rendimiento antes de que se realizaran optimizaciones en la función addEventAtLevel. El tiempo total que tardó en ejecutarse la función fue de 1,372.51 milisegundos.

Después:

Captura de pantalla del panel de rendimiento después de realizar optimizaciones en la función appendEventAtLevel. El tiempo total que tardó la función en ejecutarse fue de 207.2 milisegundos.

Cuarto grupo de actividades: Aplazamiento de los trabajos no críticos y almacenamiento en caché de datos para evitar trabajos duplicados

Cuando acercas esta ventana, se puede ver que hay dos bloques casi idénticos de llamadas a funciones. Si observas el nombre de las funciones a las que se llama, puedes inferir que estos bloques consisten en código que crea árboles (por ejemplo, con nombres como refreshTree o buildChildren). De hecho, el código relacionado es el que crea las vistas de árbol en el panel inferior del panel. Lo interesante es que estas vistas de árbol no se muestran justo después de la carga. En su lugar, el usuario debe seleccionar una vista de árbol (las pestañas "De abajo hacia arriba", "Árbol de llamadas" y "Registro de eventos" en el panel lateral) para que se muestren los árboles. Además, como puedes ver en la captura de pantalla, el proceso de compilación del árbol se ejecutó dos veces.

Captura de pantalla del panel de rendimiento que muestra varias tareas repetitivas que se ejecutan incluso si no son necesarias. Estas tareas podrían aplazarse para ejecutarse a pedido, en lugar de hacerlo con anticipación.

Identificamos dos problemas con esta foto:

  1. Una tarea no crítica obstaculizaba el rendimiento del tiempo de carga. Los usuarios no siempre necesitan su resultado. Por lo tanto, la tarea no es fundamental para la carga del perfil.
  2. El resultado de estas tareas no se almacenó en caché. Es por eso que los árboles se calcularon dos veces, a pesar de que los datos no cambiaron.

Comenzamos con aplazar el cálculo del árbol hasta que el usuario abrió manualmente la vista de árbol. Solo entonces vale la pena pagar el precio de crear estos árboles. El tiempo total de ejecución de esta dos veces fue de aproximadamente 3.4 segundos, por lo que aplazarlo tuvo una diferencia significativa en el tiempo de carga. También estamos analizando el almacenamiento en caché de estos tipos de tareas.

Quinto grupo de actividades: Evita las jerarquías de llamadas complejas siempre que sea posible

Al observar este grupo con detenimiento, quedó claro que se invocaba una cadena de llamadas en particular de forma reiterada. El mismo patrón apareció 6 veces en diferentes lugares del gráfico de llama, y la duración total de esta ventana fue de aproximadamente 2.4 segundos.

Captura de pantalla del panel de rendimiento que muestra seis llamadas a función independientes para generar el mismo minimapa de seguimiento, cada uno de los cuales tiene pilas de llamadas profundas.

El código relacionado al que se llama varias veces es la parte que procesa los datos que se renderizarán en el "minimapa" (la descripción general de la actividad de la ruta en la parte superior del panel). No estaba claro por qué sucedía varias veces, pero no tenía que suceder 6 veces. De hecho, el resultado del código debe permanecer actualizado si no se carga otro perfil. En teoría, el código solo debería ejecutarse una vez.

Después de la investigación, se descubrió que se llamó al código relacionado como consecuencia de varias partes en la canalización de carga, llamando directa o indirectamente a la función que calcula el minimapa. Esto se debe a que la complejidad del gráfico de llamadas del programa evolucionó con el tiempo y se agregaron más dependencias a este código sin saberlo. No hay una solución rápida para este problema. La forma de resolverlo depende de la arquitectura de la base de código en cuestión. En nuestro caso, tuvimos que reducir un poco la complejidad de la jerarquía de llamadas y agregar una verificación para evitar la ejecución del código si los datos de entrada no se modificaron. Después de implementar esto, obtuvimos este panorama del cronograma:

Captura de pantalla del panel de rendimiento que muestra seis llamadas a función separadas para generar el mismo minimapa de seguimiento reducido a solo dos veces.

Ten en cuenta que la ejecución de renderización del minimapa se produce dos veces, no una. Esto se debe a que se dibujan dos minimapas para cada perfil: uno para la descripción general en la parte superior del panel y otro para el menú desplegable que selecciona el perfil actualmente visible del historial (cada elemento de este menú contiene una descripción general del perfil seleccionado). Sin embargo, ambos tienen exactamente el mismo contenido, por lo que se debería poder reutilizar uno para el otro.

Dado que estos minimapas son imágenes dibujadas en un lienzo, solo fue cuestión de usar la utilidad de lienzo drawImage y, luego, ejecutar el código solo una vez para ahorrar tiempo. Como resultado de este esfuerzo, la duración del grupo se redujo de 2.4 segundos a 140 milisegundos.

Conclusión

Después de aplicar todas estas correcciones (y algunas otras más pequeñas aquí y allá), el cambio del cronograma de carga del perfil se veía de la siguiente manera:

Antes:

Captura de pantalla del panel de rendimiento que muestra la carga de seguimiento antes de las optimizaciones. El proceso tardó aproximadamente diez segundos.

Después:

Captura de pantalla del panel de rendimiento que muestra la carga de seguimiento después de las optimizaciones. El proceso ahora tarda aproximadamente dos segundos.

El tiempo de carga después de las mejoras fue de 2 segundos, lo que significa que se logró una mejora de alrededor del 80% con un esfuerzo relativamente bajo, ya que la mayor parte de lo que se hizo consistió en correcciones rápidas. Por supuesto, identificar correctamente qué hacer al principio fue clave, y el panel de rendimiento fue la herramienta adecuada para esto.

También es importante destacar que estas cifras son particulares de un perfil que se usa como sujeto de estudio. El perfil nos interesó porque era muy grande. Sin embargo, como la canalización de procesamiento es la misma para cada perfil, la mejora significativa que se logra se aplica a cada perfil cargado en el panel de rendimiento.

Conclusiones

Existen algunas lecciones que se pueden extraer de estos resultados en términos de optimización del rendimiento de tu aplicación:

1. Usar herramientas de generación de perfiles para identificar patrones de rendimiento del entorno de ejecución

Las herramientas de generación de perfiles son muy útiles para comprender lo que sucede en tu aplicación mientras se ejecuta, en especial para identificar oportunidades de mejora del rendimiento. El panel Rendimiento en Chrome DevTools es una excelente opción para las aplicaciones web, ya que es la herramienta de generación de perfiles web nativa del navegador y se mantiene de forma activa para estar al día con las funciones más recientes de la plataforma web. Además, ahora es mucho más rápido. 😉

Usa muestras que se puedan utilizar como cargas de trabajo representativas y descubre qué puedes encontrar.

2. Evita jerarquías de llamadas complejas

Cuando sea posible, evita que el gráfico de llamadas sea demasiado complicado. Con jerarquías de llamadas complejas, es fácil introducir regresiones de rendimiento y difícil comprender por qué tu código se ejecuta de la forma en que lo hace, lo que dificulta la implementación de mejoras.

3. Identifica el trabajo innecesario

Es común que las bases de código antiguas contengan código que ya no es necesario. En nuestro caso, el código heredado y no necesario ocupaba una parte significativa del tiempo de carga total. Quitarlo fue la opción más fácil.

4. Usa las estructuras de datos de forma adecuada

Usa estructuras de datos para optimizar el rendimiento, pero también comprende los costos y las compensaciones que cada tipo de estructura de datos conlleva cuando decidas cuáles usar. Esto no solo incluye la complejidad espacial de la estructura de datos en sí, sino también la complejidad temporal de las operaciones aplicables.

5. Almacena en caché los resultados para evitar el trabajo duplicado en operaciones complejas o repetitivas

Si la operación es costosa de ejecutar, tiene sentido almacenar sus resultados para la próxima vez que se necesite. También tiene sentido hacer esto si la operación se realiza muchas veces, incluso si cada una de las veces no es particularmente costosa.

6. Aplaza el trabajo no crítico

Si no se necesita de inmediato el resultado de una tarea y la ejecución de la tarea extiende la ruta crítica, considera aplazarla llamándola de forma diferida cuando realmente se necesite su resultado.

7. Usa algoritmos eficientes en entradas grandes

En el caso de las entradas grandes, los algoritmos de complejidad temporal óptimos se vuelven fundamentales. No analizamos esta categoría en este ejemplo, pero su importancia es innegable.

8. Contenido adicional: Compara tus canalizaciones

Para asegurarte de que tu código en evolución siga siendo rápido, es conveniente supervisar el comportamiento y compararlo con los estándares. De esta manera, identificas de forma proactiva las regresiones y mejoras la confiabilidad general, lo que te permite alcanzar el éxito a largo plazo.