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, asegurarte de 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 usando herramientas de generación de perfiles para ver qué ocurre a nivel interno mientras se ejecuta durante un período. El panel Performance de las Herramientas para desarrolladores es una excelente herramienta de generación de perfiles que sirve 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 app. 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.

Configuración y recreación de nuestra situación de generación de perfiles

Recientemente, establecimos el objetivo de aumentar el rendimiento del panel Rendimiento. En particular, queríamos que cargara grandes volúmenes de datos de rendimiento con mayor rapidez. Este es el caso, por ejemplo, cuando se crean perfiles de procesos complejos o de larga duración, o se capturan datos de alto nivel de detalle. Para lograrlo, primero se necesitaba comprender cómo era el rendimiento de la aplicación y por qué lo hacía de esa manera. Esto 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 perfilar con el panel Rendimiento. Para generar un perfil de este panel, puedes abrir Herramientas para desarrolladores y, luego, otra instancia de Herramientas para desarrolladores adjunta. En Google, esta configuración se conoce como DevTools-on-DevTools.

Con la configuración lista, se debe volver a crear y registrar la situación para la que se crearán perfiles. Para evitar confusiones, la ventana original de Herramientas para desarrolladores se denominará la “primera instancia de Herramientas para desarrolladores” y la ventana que inspecciona la primera instancia se denominará la “segunda instancia de Herramientas de desarrolladores”.

Captura de pantalla de una instancia de Herramientas para desarrolladores que inspecciona los elementos de Herramientas para desarrolladores.
Herramientas para desarrolladores-on-DevTools: Cómo inspeccionar Herramientas para desarrolladores con Herramientas para desarrolladores.

En la segunda instancia de Herramientas para desarrolladores, el panel Rendimiento, que de aquí en adelante se llamará panel de rendimiento, observa la primera instancia de Herramientas para desarrolladores que recrea la situación, que carga un perfil.

En la segunda instancia de Herramientas para desarrolladores, se inicia una grabación en vivo, mientras que en la primera instancia, se carga un perfil desde un archivo del 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 la generación de perfiles de rendimiento, que suelen denominarse trace, se ven en la segunda instancia de Herramientas para desarrolladores 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 de panel de rendimiento en la siguiente captura de pantalla. Enfócate en la actividad del subproceso principal, que está visible debajo del segmento con la etiqueta Main. Se puede ver que hay cinco grandes grupos de actividad en el gráfico tipo llama. Se trata de 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, el panel de rendimiento se usa para enfocarse en cada uno de estos grupos de actividades para ver qué se puede encontrar.

Captura de pantalla del panel de rendimiento de Herramientas para desarrolladores donde se inspecciona la carga de un registro de rendimiento en el panel de rendimiento de otra instancia de Herramientas para desarrolladores. El perfil tarda unos 10 segundos en cargarse. Este tiempo se divide principalmente en cinco grupos principales de actividad.

Primer grupo de actividades: trabajo innecesario

Se hizo evidente que el primer grupo de actividades era el código heredado que aún se ejecutaba, pero que en realidad no era necesario. Básicamente, todo lo que estaba debajo del bloque verde etiquetado processThreadEvents era un esfuerzo desperdiciado. Esa fue una victoria rápida. Quitar esa llamada a función le permitió ahorrar alrededor de 1.5 segundos de tiempo. Genial.

Segundo grupo de actividades

En el segundo grupo de actividades, la solución no fue tan simple como la del primero. El buildProfileCalls tardó alrededor de 0.5 segundos, y esa tarea no era algo que se pudiera evitar.

Captura de pantalla del panel de rendimiento en Herramientas para desarrolladores 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 a fondo y vimos que la actividad buildProfileCalls también usaba mucha memoria. Aquí puedes ver cómo el gráfico de la 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 Herramientas para desarrolladores 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 continuar con esta sospecha, usamos el panel Memory (otro panel en Herramientas para desarrolladores, diferente del panel lateral de memoria en el panel de rendimiento) para investigar. En el panel Memory, se seleccionó el tipo de perfil "Allocation sample" y se registraba la instantánea del montón para el panel de rendimiento que cargaba el perfil de CPU.

Captura de pantalla del estado inicial del Generador de perfiles de memoria. La opción “allocation sample” se destaca con un cuadro rojo, lo cual indica que es la mejor opción 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 Set que requiere mucha memoria seleccionada.

A partir de esta instantánea del montón, se observó que la clase Set consumía mucha memoria. Cuando se verificaron los puntos de llamada, se descubrió que estábamos asignando innecesariamente propiedades de tipo Set a objetos que se creaban en grandes volúmenes. Este costo se sumaba 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 singularidad de su contenido, como anular la duplicación de conjuntos de datos y proporcionar búsquedas más eficientes. Sin embargo, estas funciones no eran necesarias, ya que se garantizaba que los datos almacenados fueran únicos desde 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 asignación de memoria reducida. A pesar de no lograr mejoras de velocidad considerables con este cambio, el beneficio secundario fue que la aplicación fallaba con menos frecuencia.

Captura de pantalla del Generador de perfiles de memoria. Se cambió la operación basada en set que antes requería mucha memoria para usar un array sin formato, lo que redujo significativamente el costo de memoria.

Tercer grupo de actividades: sopesar 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 se observó la parte inferior de esta sección, era evidente que el ancho de estas columnas se determinaba por la duración de una función: appendEventAtLevel, que sugirió que podría ser un cuello de botella.

Dentro de la implementación de la función appendEventAtLevel, se destacó un aspecto. Por cada entrada de datos en la entrada (que en el código se conoce como “evento”), se agregaba un elemento a un mapa que rastreaba la posición vertical de las entradas del cronograma. Esto fue un problema porque 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 se agranda un mapa, agregar datos puede resultar costoso, por ejemplo, debido a la generación de un hash nuevo. Este costo se vuelve notorio cuando se agregan varios 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 que agreguemos un elemento en un mapa para cada entrada en el gráfico tipo llama. La mejora fue significativa, lo que confirmó que el cuello de botella estaba realmente relacionado con la sobrecarga incurrida al agregar todos los datos al mapa. El tiempo que tardó el grupo de actividades disminuyó de 1.4 segundos a 200 milisegundos aproximadamente.

Antes:

Captura de pantalla del panel de rendimiento antes de que se realicen optimizaciones en la función attachEventAtLevel. El tiempo total de ejecución de la función fue de 1,372.51 milisegundos.

Después:

Captura de pantalla del panel de rendimiento después de que se realizaron optimizaciones en la función attachEventAtLevel. El tiempo total de ejecución de la función fue de 207.2 milisegundos.

Cuarto grupo de actividades: Aplazar el trabajo no fundamental y almacenar en caché los datos para evitar trabajos duplicados

Si nos acercamos a esta ventana, podemos ver que hay dos bloques casi idénticos de llamadas a funciones. Si observas el nombre de las funciones llamadas, 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 lateral inferior del panel. Lo interesante es que estas vistas de árbol no se muestran inmediatamente después de la carga. En su lugar, el usuario debe seleccionar una vista de árbol (las pestañas "Bottom-up", "Call Tree" y "Event Log" en el panel lateral) para que se muestren los árboles. Además, como puedes ver en la captura de pantalla, el proceso de creación de árboles 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 de manera anticipada.

Hay dos problemas que identificamos con esta imagen:

  1. Una tarea no crítica obstaculizaba el rendimiento del tiempo de carga. Los usuarios no siempre necesitan los resultados. 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 cambian.

Comenzamos a aplazar el cálculo de árbol hasta el momento en que el usuario abría manualmente la vista de árbol. Solo entonces vale la pena pagar el precio de crear estos árboles. El tiempo total de ejecución de este comando dos veces fue de alrededor de 3.4 segundos, por lo que diferirlo marcó una diferencia significativa en el tiempo de carga. Todavía estamos buscando almacenar este tipo de tareas en caché.

Quinto grupo de actividades: Cuando sea posible, evita jerarquías de llamadas complejas

Si observamos este grupo con detenimiento, queda claro que una cadena de llamadas específica se invocaba repetidamente. El mismo patrón apareció 6 veces en diferentes lugares en el gráfico tipo 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 funciones separadas para generar el mismo minimapa de seguimiento, cada una de las 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 del cronograma en la parte superior del panel). No estaba claro por qué ocurría varias veces, pero sin duda no tenía que suceder 6 veces. De hecho, el resultado del código debe permanecer actualizado si no se carga ningún otro perfil. En teoría, el código solo debería ejecutarse una vez.

En una investigación, se descubrió que se llamaba al código relacionado como consecuencia de varias partes en la canalización de carga mediante llamadas directa o indirecta 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 permanecían sin cambios. Después de la implementación, obtuvimos este panorama del cronograma:

Captura de pantalla del panel de rendimiento que muestra las seis llamadas a funciones separadas para generar el mismo minimapa de seguimiento que se redujeron a solo dos veces.

Ten en cuenta que la ejecución de la renderización de minimapas 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 que selecciona). Sin embargo, estos dos tienen exactamente el mismo contenido, por lo que uno debería poder reutilizarse para el otro.

Como estos minimapas son imágenes dibujadas en un lienzo, era cuestión de usar la utilidad lienzo drawImage y, luego, ejecutar el código solo una vez para ahorrar tiempo adicional. 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 en esta y otra parte), el cambio en el cronograma de carga del perfil se ve de la siguiente manera:

Antes:

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

Después:

Captura de pantalla del panel de rendimiento que muestra la carga del seguimiento después de las optimizaciones. Ahora el proceso 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 adecuadamente qué hacer al principio era clave, y el panel de rendimiento era la herramienta adecuada para esto.

También es importante destacar que estos números son específicos de un perfil que se utiliza como tema de estudio. El perfil nos pareció interesante porque era particularmente grande. No obstante, dado que la canalización de procesamiento es la misma para todos los perfiles, la mejora significativa que se logra se aplica a todos los perfiles cargados en el panel de rendimiento.

Conclusiones

A continuación, se detallan algunas lecciones que puedes 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 tiempo de ejecución

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

Usa muestras que se puedan usar como cargas de trabajo representativas y observa lo que puedes encontrar.

2. Evita las 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 ingresar regresiones de rendimiento y difícil comprender por qué tu código se ejecuta como está, lo que dificulta obtener mejoras.

3. Identifica el trabajo innecesario

Es común que las bases de código antiguas contengan código que ya no se necesita. En nuestro caso, el código innecesario y heredado ocupaba una parte significativa del tiempo de carga total. Quitarla fue la fruta que menos se colocó.

4. Usar 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 brinda cada tipo de estructura de datos a la hora de decidir cuáles utilizar. Esto no es solo la complejidad del espacio de la estructura de datos en sí, sino también la complejidad temporal de las operaciones aplicables.

5. Almacena los resultados en caché para evitar trabajos duplicados en operaciones complejas o repetitivas

Si es costoso ejecutar la operación, tiene sentido almacenar los resultados para la próxima vez que sea necesario. También tiene sentido hacer esto si la operación se realiza muchas veces, incluso si cada vez individual no es particularmente costoso.

6. Cómo aplazar el trabajo no crítico

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

7. Usa algoritmos eficientes en entradas grandes

Para entradas grandes, los algoritmos de complejidad de tiempo óptima se vuelven fundamentales. No vimos esta categoría en este ejemplo, pero difícilmente se puede exagerar su importancia.

8. Contenido adicional: Compara tus canalizaciones

Para asegurarte de que tu código en evolución se mantenga rápido, te recomendamos supervisar el comportamiento y compararlo con los estándares. De esta manera, puedes identificar regresiones de forma proactiva y mejorar la confiabilidad general, lo que te prepara para el éxito a largo plazo.