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 mediante el uso de herramientas de generación de perfiles para ver lo que sucede de forma interna mientras se ejecuta durante un período. El panel Performance de Herramientas para desarrolladores 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 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 Performance.

Configura y recrea nuestra situación de generación de perfiles

Recientemente, nos propusimos mejorar 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 cuando se capturan datos con un nivel de detalle alto. Para lograrlo, primero era necesario comprender el rendimiento de la aplicación y por qué funcionaba 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 pueden generar perfiles 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-on-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 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 para desarrolladores".

Captura de pantalla de una instancia de Herramientas para desarrolladores que inspecciona los elementos en sí.
Herramientas para desarrolladores: Inspeccionar Herramientas para desarrolladores con Herramientas para desarrolladores

En la segunda instancia de Herramientas para desarrolladores, el panel Rendimiento (que se llamará panel perf de aquí en adelante) 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 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 la generación de perfiles de rendimiento (por lo general, llamados seguimiento) se ven en la segunda instancia de Herramientas para desarrolladores del panel de rendimiento cuando se 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 llamas. Estas consisten en las tareas en las que la carga está tardando más tiempo. El tiempo total de estas tareas fue de aproximadamente 10 segundos. En la siguiente captura de pantalla, se usa el panel Performance para enfocarse en cada uno de estos grupos de actividades y ver qué información se puede encontrar.

Captura de pantalla del panel de rendimiento en 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 código heredado que se ejecutaba, pero que en realidad no era necesario. Básicamente, todo lo que está debajo del bloque verde etiquetado como processThreadEvents era una pérdida de esfuerzo. Esa fue una victoria rápida. Quitar esa llamada a función ahorró 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 en el primero. La buildProfileCalls tomó 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 en la que se 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 líneas azules 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 en la que se 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 hacer un seguimiento de esta sospecha, usamos el panel Memory (otro panel en Herramientas para desarrolladores, diferente del panel lateral Memory en el panel de rendimiento) para investigar. En el panel Memoria, la sección "Muestreo de asignación" Se seleccionó el tipo de perfil, que registró la instantánea del montón para el panel de rendimiento cargando el perfil de CPU.

Captura de pantalla del estado inicial del Generador de perfiles de memoria. El "muestreo de asignación" está destacada con un cuadro rojo e indica que esta opción es la mejor para la generación de perfiles de memoria en 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.

En 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 sumaba y se consumió 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 proporcionan 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, esos atributos no eran necesarios, ya que se garantizó 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 sin formato. Después de aplicar este cambio, se tomó otra instantánea de 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 era que la aplicación fallaba con menos frecuencia.

Captura de pantalla del Generador de perfiles de memoria. Se cambió la operación que antes tenía mucha memoria y se basaba en Set, de modo que 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. Al mirar la parte inferior de esta sección, resultó 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ó un elemento. Por cada entrada de datos en la entrada (lo que se conoce en el código como el “evento”), se agregaba un elemento a un mapa que realizaba un seguimiento de la posición vertical de las entradas del cronograma. Esto era 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 un mapa se expande, agregar datos puede, por ejemplo, volverse costoso debido a la nueva codificación hash. Este costo se nota cuando se agregan sucesivamente grandes cantidades de elementos al mapa.

/**
 * 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 y se confirmó que el cuello de botella estaba realmente relacionado con la sobrecarga que se generaba al agregar todos los datos al mapa. El tiempo que tardó el grupo de actividades se redujo de 1,4 segundos a 200 milisegundos aproximadamente.

Antes:

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

Después:

Captura de pantalla del panel de rendimiento después de que se realizaran optimizaciones en la función addEventAtLevel. El tiempo total para que se ejecute la función 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 miras el nombre de las funciones llamadas, puedes inferir que estos bloques consisten en código que construye á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" del panel lateral) para que se muestren los árboles. Además, como puedes ver en la captura de pantalla, el proceso de construcció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 con anticipación.

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 su resultado. Por lo tanto, la tarea no es crítica 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 por diferir el cálculo del árbol hasta el momento en 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. Todavía estamos analizando el almacenamiento en caché de este tipo de tareas también.

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

Si observamos detenidamente este grupo, queda claro que una cadena de llamadas en particular se invocaba repetidamente. El mismo patrón apareció 6 veces en diferentes lugares en el gráfico de llamas, y la duración total de esta ventana fue de alrededor de 2.4 segundos.

Captura de pantalla del panel de rendimiento que muestra seis llamadas a función 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 quedó claro por qué ocurrió varias veces, pero no fue necesario que sucediera 6 veces. De hecho, el resultado del código debe permanecer actualizado si no se carga otro perfil. En teoría, el código debería ejecutarse una sola vez.

Después de la investigación, se descubrió que se llamó al código relacionado como consecuencia de múltiples 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 esta visión 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 la 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 uno debería poder reutilizarse para el otro.

Como ambos 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 aquí y allá), el cambio en el cronograma de carga de perfiles se ve 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 aproximadamente el 80% con un esfuerzo relativamente bajo, ya que la mayor parte de lo que se hizo consistía en correcciones rápidas. Por supuesto, identificar correctamente 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 del 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 cada perfil, la mejora significativa que se logra se aplica a cada perfil cargado en el panel de rendimiento.

Conclusiones

Hay algunas lecciones que puedes aprender 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 creación de perfiles son increíblemente útiles para comprender lo que sucede en tu aplicación mientras se ejecuta, en especial con el fin de identificar oportunidades para mejorar el rendimiento. El panel Performance de las Herramientas para desarrolladores de Chrome es una excelente opción para las aplicaciones web, ya que es la herramienta nativa de generación de perfiles web del navegador, y se actualiza activamente 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

Siempre que 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 es difícil comprender por qué tu código se ejecuta como está, lo que dificulta conseguir 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 era la fruta que menos iba a perder.

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 genera cada tipo de estructura de datos a la hora de decidir cuáles usar. No se trata solo de la complejidad del espacio de la estructura de datos en sí, sino también de la complejidad temporal de las operaciones aplicables.

5. Almacena en caché los resultados para evitar la duplicación de trabajos para operaciones complejas o repetitivas.

Si la operación es costosa de ejecutar, tiene sentido almacenar sus resultados para la próxima vez que sea necesaria. 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 el resultado de una tarea no se necesita de inmediato y la ejecución de la tarea extiende la ruta crítica, considera aplazarla llamando 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 óptimo se vuelven fundamentales. No analizamos esta categoría en este ejemplo, pero es difícil sobrevalorar su importancia.

8. Contenido adicional: Compara tus canalizaciones

Para asegurarte de que la evolución de tu código se mantenga rápida, es aconsejable supervisar el comportamiento y compararlo con los estándares. De esta manera, identificas proactivamente las regresiones y mejoras la confiabilidad general, lo que te prepara para el éxito a largo plazo.