Usa la detección de intrusiones para resolver misterios de rendimiento de JavaScript

Introducción

En los últimos años, las aplicaciones web se aceleraron considerablemente. Muchas aplicaciones ahora se ejecutan con la rapidez suficiente como para que algunos desarrolladores se pregunten en voz alta "¿la Web es lo suficientemente rápida?". Para algunas aplicaciones, puede serlo, pero, para los desarrolladores que trabajan en aplicaciones de alto rendimiento, sabemos que no es lo suficientemente rápido. A pesar de los increíbles avances en la tecnología de máquinas virtuales de JavaScript, un estudio reciente demostró que las aplicaciones de Google pasan entre el 50% y el 70% de su tiempo dentro de V8. Tu aplicación tiene una cantidad de tiempo limitada, por lo que reducir los ciclos de un sistema significa que otro sistema puede hacer más. Recuerda que las aplicaciones que se ejecutan a 60 FPS solo tienen 16 ms por fotograma; de lo contrario, se produce jank. Sigue leyendo para aprender a optimizar JavaScript y generar perfiles de aplicaciones JavaScript, en una historia de los detectives de rendimiento del equipo de V8 que rastrean un problema de rendimiento poco claro en Find Your Way to Oz.

Sesión de Google I/O 2013

Presenté este material en Google I/O 2013. Mira el siguiente video:

¿Por qué es importante el rendimiento?

Los ciclos de CPU son un juego de suma cero. Si haces que una parte de tu sistema use menos, podrás usar más en otra o ejecutarlo de forma más fluida en general. Ejecutar más rápido y hacer más suelen ser objetivos que compiten entre sí. Los usuarios exigen funciones nuevas y, al mismo tiempo, esperan que tu aplicación se ejecute con mayor fluidez. Las máquinas virtuales de JavaScript son cada vez más rápidas, pero eso no es motivo para ignorar los problemas de rendimiento que puedes solucionar hoy, como ya saben los muchos desarrolladores que se enfrentan a problemas de rendimiento en sus aplicaciones web. En las aplicaciones en tiempo real con una alta velocidad de fotogramas, la presión para que no haya tirones es primordial. Insomniac Games produjo un estudio que demostró que una velocidad de fotogramas sólida y sostenida es importante para el éxito de un juego: "Una velocidad de fotogramas sólida sigue siendo un signo de un producto profesional y bien hecho". Los desarrolladores web deben tomar nota.

Soluciona problemas de rendimiento

Resolver un problema de rendimiento es como resolver un crimen. Debes examinar cuidadosamente la evidencia, verificar las causas sospechosas y experimentar con diferentes soluciones. Durante todo el proceso, debes documentar tus mediciones para asegurarte de que realmente solucionaste el problema. Hay muy poca diferencia entre este método y la forma en que los detectives criminales resuelven un caso. Los detectives examinan la evidencia, interrogan a los sospechosos y realizan experimentos con la esperanza de encontrar la prueba irrefutable.

V8 CSI: Oz

Los increíbles magos que crearon Find Your Way to Oz se acercaron al equipo de V8 con un problema de rendimiento que no podían resolver por su cuenta. En ocasiones, Oz se congelaba, lo que causaba bloqueos. Los desarrolladores de Oz realizaron una investigación inicial con el panel de cronograma de las Herramientas para desarrolladores de Chrome. Cuando analizaron el uso de memoria, se encontraron con el temido gráfico de diente de sierra. Una vez por segundo, el recolector de elementos no utilizados recopilaba 10 MB de basura, y las pausas de la recolección de elementos no utilizados correspondían a la latencia. Similar a la siguiente captura de pantalla de la línea de tiempo en las Herramientas para desarrolladores de Chrome:

Cronograma de Herramientas para desarrolladores

Los detectives de V8, Jakob y Yang, se hicieron cargo del caso. Lo que ocurrió fue un largo intercambio entre Jakob y Yang, del equipo de V8, y el equipo de Oz. Resumí esta conversación en los eventos importantes que ayudaron a detectar este problema.

Evidencia

El primer paso es recopilar y estudiar la evidencia inicial.

¿Qué tipo de aplicación estamos viendo?

La demostración de Oz es una aplicación en 3D interactiva. Por este motivo, es muy sensible a las pausas que causan las recolecciones de elementos no utilizados. Recuerda que una aplicación interactiva que se ejecuta a 60 FPS tiene 16 ms para realizar todo el trabajo de JavaScript y debe dejar parte de ese tiempo para que Chrome procese las llamadas de gráficos y dibuje la pantalla.

Oz realiza muchos cálculos aritméticos con valores dobles y hace llamadas frecuentes a WebAudio y WebGL.

¿Qué tipo de problema de rendimiento estamos viendo?

Vemos pausas, también conocidas como pérdidas de fotogramas o bloqueos. Estas pausas se correlacionan con las ejecuciones de recolección de elementos no utilizados.

¿Los desarrolladores siguen las prácticas recomendadas?

Sí, los desarrolladores de Oz conocen bien las técnicas de optimización y rendimiento de la VM de JavaScript. Vale la pena señalar que los desarrolladores de Oz usaban CoffeeScript como lenguaje de origen y producían código JavaScript a través del compilador de CoffeeScript. Esto hizo que parte de la investigación fuera más difícil debido a la desconexión entre el código que escribían los desarrolladores de Oz y el código que consumía V8. Las Herramientas para desarrolladores de Chrome ahora admiten mapas de origen, lo que habría facilitado este proceso.

¿Por qué se ejecuta el recolector de elementos no utilizados?

La VM administra automáticamente la memoria en JavaScript para el desarrollador. V8 usa un sistema de recolección de elementos no utilizados común en el que la memoria se divide en dos (o más) generaciones. La generación joven contiene objetos que se asignaron recientemente. Si un objeto sobrevive el tiempo suficiente, se mueve a la generación anterior.

La generación joven se recopila con una frecuencia mucho mayor que la generación anterior. Esto es por diseño, ya que la recolección de la generación joven es mucho más económica. Suele ser seguro suponer que las pausas frecuentes de la GC se deben a la recolección de la generación joven.

En V8, el espacio de memoria joven se divide en dos bloques de memoria contiguos de igual tamaño. Solo uno de estos dos bloques de memoria está en uso en un momento dado y se denomina espacio de destino. Mientras quede memoria en el espacio de destino, asignar un objeto nuevo es económico. Un cursor en el espacio de destino se mueve hacia adelante la cantidad de bytes necesarios para el objeto nuevo. Esto continúa hasta que se agota el espacio de destino. En este punto, se detiene el programa y comienza la recopilación.

Memoria joven de V8

En este punto, se intercambian el espacio de origen y el de destino. El espacio de destino, que ahora es el espacio de origen, se analiza de principio a fin, y los objetos que aún están activos se copian en el espacio de destino o se promueven al montón de la generación anterior. Si quieres obtener más detalles, te sugerimos que leas sobre el algoritmo de Cheney.

De forma intuitiva, deberías comprender que cada vez que se asigna un objeto de forma implícita o explícita (a través de una llamada a new, [] o {}), tu aplicación se acerca cada vez más a una recolección de elementos no utilizados y a la temida pausa de la aplicación.

¿Se esperan 10 MB/s de basura para esta aplicación?

En resumen, no. El desarrollador no está haciendo nada para esperar 10 MB/s de basura.

Suspects

La siguiente fase de la investigación consiste en determinar los posibles sospechosos y, luego, reducir la lista.

Sospechoso 1

Llamar a new durante el fotograma Recuerda que cada objeto que se asigna te acerca cada vez más a una pausa de GC. En particular, las aplicaciones que se ejecutan con altas velocidades de fotogramas deben esforzarse por lograr cero asignaciones por fotograma. Por lo general, esto requiere un sistema de reciclaje de objetos específico de la aplicación y cuidadosamente pensado. Los detectives de V8 consultaron con el equipo de Oz y no estaban llamando a new. De hecho, el equipo de Oz ya estaba al tanto de este requisito y dijo: "Eso sería vergonzoso". Tacha esta opción de la lista.

Sospechoso 2

Modificar la "forma" de un objeto fuera del constructor Esto sucede cada vez que se agrega una propiedad nueva a un objeto fuera del constructor. Esto crea una nueva clase oculta para el objeto. Cuando el código optimizado vea esta nueva clase oculta, se activará una desoptimización y se ejecutará el código no optimizado hasta que se clasifique como de uso frecuente y se vuelva a optimizar. Esta desoptimización y reoptimización generará jank,pero no se correlaciona estrictamente con la creación excesiva de basura. Después de una auditoría cuidadosa del código, se confirmó que las formas de los objetos eran estáticas, por lo que se descartó al sospechoso n° 2.

Sospechoso 3

Aritmética en código no optimizado. En el código no optimizado, todos los cálculos dan como resultado la asignación de objetos reales. Por ejemplo, este fragmento:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

Se crean 5 objetos HeapNumber. Los primeros tres son para las variables a, b y c. La 4ª es para el valor anónimo (a * b) y la 5ª es de #4 * c; la 5ª se asigna en última instancia a point.x.

Oz realiza miles de estas operaciones por fotograma. Si alguno de estos cálculos se produce en funciones que nunca se optimizan, podrían ser la causa de la basura. Porque los cálculos en unoptimized asignan memoria incluso para resultados temporales.

Sospechoso 4

Almacenar un número de precisión doble en una propiedad Se debe crear un objeto HeapNumber para almacenar el número y la propiedad modificada para que apunte a este objeto nuevo. Modificar la propiedad para que apunte al HeapNumber no producirá basura. Sin embargo, es posible que se almacenen muchos números de doble precisión como propiedades de objetos. El código está lleno de declaraciones como la siguiente:

sprite.position.x += 0.5 * (dt);

En el código optimizado, cada vez que se asigna a x un valor recién calculado, una sentencia aparentemente inocua, se asigna implícitamente un nuevo objeto HeapNumber, lo que nos acerca a una pausa de recolección de basura.

Ten en cuenta que, si usas un array con tipo (o un array normal que solo contenga números de punto flotante de doble precisión), puedes evitar este problema específico por completo, ya que el almacenamiento para el número de punto flotante de doble precisión se asigna solo una vez y cambiar el valor de forma repetida no requiere que se asigne un almacenamiento nuevo.

El sospechoso n° 4 es una posibilidad.

Detección de intrusiones

En este punto, los detectives tienen dos posibles sospechosos: el almacenamiento de números de montón como propiedades de objetos y el cálculo aritmético que se produce dentro de funciones no optimizadas. Era hora de ir al laboratorio y determinar de forma definitiva qué sospechoso era culpable. NOTA: En esta sección, usaré una reproducción del problema que se encuentra en el código fuente real de Oz. Esta reproducción es mucho más pequeña que el código original, por lo que es más fácil de entender.

Experimento 1

Verifica si hay un sospechoso n° 3 (cálculo aritmético dentro de funciones no optimizadas). El motor de JavaScript V8 tiene un sistema de registro integrado que puede proporcionar información valiosa sobre lo que sucede en segundo plano.

Si Chrome no se está ejecutando, inicia Chrome con los siguientes parámetros:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

y, luego, salir por completo de Chrome generará un archivo de registro v8.log en el directorio actual.

Para interpretar el contenido de v8.log, debes descargar la misma versión de v8 que usa tu Chrome (consulta about:version) y compilarla.

Después de compilar v8 correctamente, puedes procesar el registro con el procesador de marcas:

$ tools/linux-tick-processor /path/to/v8.log

(Sustituye mac o windows por linux según tu plataforma). (Esta herramienta se debe ejecutar desde el directorio del código fuente de nivel superior en v8).

El procesador de marcas muestra una tabla basada en texto de las funciones de JavaScript que tuvieron la mayor cantidad de marcas:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

Puedes ver que demo.js tenía tres funciones: opt, unopt y main. Las funciones optimizadas tienen un asterisco (*) junto a sus nombres. Observa que la función opt está optimizada y unopt no lo está.

Otra herramienta importante en la bolsa de herramientas del detective de V8 es plot-timer-event. Se puede ejecutar de la siguiente manera:

$ tools/plot-timer-event /path/to/v8.log

Después de ejecutarlo, se creará un archivo PNG llamado timer-events.png en el directorio actual. Cuando lo abras, deberías ver algo como esto:

Eventos de temporizador

Además del gráfico que se encuentra en la parte inferior, los datos se muestran en filas. El eje X es el tiempo (ms). En el lado izquierdo, se incluyen etiquetas para cada fila:

Eje Y de los eventos de temporizador

La fila V8.Execute tiene una línea vertical negra dibujada en cada marca de perfil en la que V8 ejecutaba código JavaScript. V8.GCScavenger tiene una línea vertical azul dibujada en cada marca de perfil en la que V8 realizaba una recolección de nueva generación. Lo mismo ocurre con el resto de los estados de V8.

Una de las filas más importantes es "code kind being executed". Será verde cuando se ejecute código optimizado y una mezcla de rojo y azul cuando se ejecute código no optimizado. En la siguiente captura de pantalla, se muestra la transición de código optimizado a no optimizado y, luego, de vuelta a optimizado:

Tipo de código que se está ejecutando

Lo ideal, aunque nunca de inmediato, es que esta línea sea de color verde continuo. Esto significa que tu programa pasó a un estado estable optimizado. El código no optimizado siempre se ejecutará más lento que el código optimizado.

Si llegaste hasta aquí, vale la pena señalar que puedes trabajar mucho más rápido si refactorizas tu aplicación para que se pueda ejecutar en la shell de depuración de v8: d8. El uso de d8 te brinda tiempos de iteración más rápidos con las herramientas tick-processor y plot-timer-event. Otro efecto secundario de usar d8 es que se vuelve más fácil aislar el problema real, lo que reduce la cantidad de ruido presente en los datos.

Si observamos el trazado de eventos del temporizador del código fuente de Oz, se muestra una transición de código optimizado a no optimizado y, mientras se ejecuta el código no optimizado, se activaron muchas recopilaciones de nueva generación, de forma similar a la siguiente captura de pantalla (ten en cuenta que se quitó el tiempo en el medio):

Gráfico de eventos de temporizador

Si observas con atención, puedes ver que las líneas negras que indican cuándo V8 ejecuta código de JavaScript faltan exactamente en los mismos momentos de marca de perfil que las recolecciones de nueva generación (líneas azules). Esto demuestra claramente que, mientras se recolecta la basura, la secuencia de comandos se pausa.

Si observas el resultado del procesador de marcas del código fuente de Oz, verás que la función principal (updateSprites) no se optimizó. En otras palabras, la función en la que el programa pasó más tiempo tampoco estaba optimizada. Esto indica con firmeza que el sospechoso n° 3 es el culpable. La fuente de updateSprites contenía bucles que se veían de la siguiente manera:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

Como conocen V8 tan bien, reconocieron de inmediato que, a veces, V8 no optimiza la construcción del bucle for-i-in. En otras palabras, si una función contiene una construcción de bucle for-i-in, es posible que no se optimice. Este es un caso especial hoy en día y es probable que cambie en el futuro, es decir, es posible que V8 optimice esta construcción de bucle algún día. Como no somos detectives de V8 y no conocemos V8 como la palma de nuestra mano, ¿cómo podemos determinar por qué no se optimizó updateSprites?

Experimento núm. 2

Ejecuta Chrome con esta marca:

--js-flags="--trace-deopt --trace-opt-verbose"

muestra un registro detallado de los datos de optimización y desoptimización. Si buscamos en los datos de updateSprites, encontramos lo siguiente:

[disabled optimization for updateSprites, reason: ForInStatement is not fast case]

Tal como plantearon los detectives, el motivo fue la construcción del bucle for-i-in.

Caso cerrado

Después de descubrir el motivo por el que no se optimizó updateSprites, la solución fue sencilla: simplemente mover el cálculo a su propia función, es decir:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite se optimizará, lo que generará muchos menos objetos HeapNumber y, por lo tanto, pausas de GC menos frecuentes. Debería ser fácil para ti confirmar esto realizando los mismos experimentos con código nuevo. El lector atento notará que los números dobles se siguen almacenando como propiedades. Si la generación de perfiles indica que vale la pena, cambiar la posición para que sea un array de dobles o un array de datos con tipo reduciría aún más la cantidad de objetos que se crean.

Epílogo

Los desarrolladores de Oz no se detuvieron allí. Con las herramientas y técnicas que les compartieron los detectives de V8, pudieron encontrar algunas otras funciones que estaban atrapadas en el infierno de la desoptimización y factorizaron el código de procesamiento en funciones de hoja que se optimizaron, lo que generó un rendimiento aún mejor.

¡Sal y comienza a resolver algunos delitos de rendimiento!