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

Introducción

En los últimos años, las aplicaciones web se han acelerado considerablemente. Muchas aplicaciones ahora se ejecutan lo suficientemente rápido como para contarles a algunos desarrolladores que se preguntan en voz alta si la Web es lo suficientemente rápida. En el caso de algunas aplicaciones, puede ser así, pero sabemos que no es lo suficientemente rápido para los desarrolladores que trabajan en aplicaciones de alto rendimiento. A pesar de los increíbles avances en la tecnología de máquinas virtuales de JavaScript, en un estudio reciente se demostró que las aplicaciones de Google pasan entre un 50% y un 70% de su tiempo en V8. Tu aplicación tiene una cantidad de tiempo limitada; los ciclos de afeitar de un sistema significan 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 trata de bloqueo. Sigue leyendo para obtener más información sobre la optimización de JavaScript y el perfil de las aplicaciones de JavaScript, en una historia de la historia de los detectives de rendimiento del equipo de V8 que rastrean un oscuro problema de rendimiento en Encuentra tu camino a 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. Hacer que una parte de tu sistema use menos te permite usar más en otra o ejecutar de manera más fluida en general. Ejecutar más rápido y realizar más acciones suelen ser objetivos que compiten. Los usuarios demandan nuevas funciones y esperan que tu aplicación se ejecute con mayor fluidez. Las máquinas virtuales de JavaScript son cada vez más rápidas, pero esa no es una razón para ignorar los problemas de rendimiento que puedes solucionar hoy en día, como ya conocen muchos desarrolladores que enfrentan problemas de rendimiento en sus aplicaciones web. En tiempo real y con una velocidad de fotogramas alta, la presión para que las aplicaciones no experimenten bloqueos es primordial. Insomniac Games realizó 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 que un producto está bien fabricado y profesional". Los desarrolladores web toman nota.

Resolución de problemas de rendimiento

Resolver un problema de rendimiento es como resolver un crimen. Debes examinar con cuidado la evidencia, verificar las causas sospechosas y experimentar con diferentes soluciones. Durante el proceso, debes documentar tus medidas para asegurarte de que realmente se solucionó el problema. Hay muy poca diferencia entre este método y la manera en que los detectives criminales resuelven un caso. Los detectives examinan la evidencia, interrogan a los sospechosos y realizan experimentos para encontrar la pistola humeante.

V8 CSI: Oz

Los increíbles magos que compilan Encuentra tu camino a Oz se acercaron al equipo de V8 con un problema de rendimiento que no pudieron resolver por sí mismos. En ocasiones, Oz se bloqueaba, lo que provocaba bloqueos. Los desarrolladores de Oz realizaron una investigación inicial con el panel Timeline en las herramientas para desarrolladores de Chrome. Al analizar el uso de memoria, se encontraron con el temido gráfico del diente de sierra. Una vez por segundo, el recolector de elementos no utilizados recolectaba 10 MB, y las pausas de recolección de elementos no utilizados se correspondían con el bloqueo. Es similar a la siguiente captura de pantalla de Rutas en Herramientas para desarrolladores de Chrome:

Cronograma de Herramientas para desarrolladores

Los detectives del V8, Jakob y Yang tomaron el caso. Lo que ocurrió fue mucho ida y vuelta entre Jakob y Yang, del equipo V8 y el equipo Oz. Resumimos esta conversación en los eventos importantes que nos ayudaron a encontrar este problema.

Evidencia

El primer paso es recopilar y estudiar la evidencia inicial.

¿Qué tipo de aplicación estamos mirando?

La demostración de Oz es una aplicación 3D interactiva. Debido a esto, es muy sensible a las pausas causadas por la recolección 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 algo de 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 realiza llamadas frecuentes a WebAudio y WebGL.

¿Qué tipo de problema de rendimiento observamos?

Observamos pausas, también conocidas como disminuciones 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 las VMs de JavaScript. Vale la pena señalar que los desarrolladores de Oz usaban CoffeeScript como lenguaje fuente y producían código JavaScript a través del compilador CoffeeScript. Esto hizo que parte de la investigación fuera más complicada debido a la desconexión entre el código que escriben los desarrolladores de Oz y el que consume V8. Las Herramientas para desarrolladores de Chrome ahora son compatibles con los mapas de fuentes, lo que habría facilitado esta tarea.

¿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 común de recolección de elementos no utilizados en el que la memoria se divide en dos (o más) generations. La generación joven conserva objetos que se asignaron recientemente. Si un objeto sobrevive el tiempo suficiente, se traslada a la generación anterior.

La generación joven se recolecta con una frecuencia mucho mayor que la anterior. Se diseñó de este modo, ya que la colección de la generación joven es mucho más barata. A menudo, es seguro suponer que las pausas frecuentes de la recolección de elementos no utilizados son ocasionadas por la recolección de la generación joven.

En V8, el espacio de memoria joven se divide en dos bloques contiguos de memoria de igual tamaño. Solo uno de estos dos bloques de memoria está en uso en un momento dado, y se lo denomina espacio "to". Mientras haya memoria restante en el espacio, asignar un nuevo objeto es económico. Un cursor en el espacio To se mueve hacia delante la cantidad de bytes necesarios para el nuevo objeto. Esto continuará hasta que se agote el espacio de acceso. En este punto, el programa se detiene y comienza la recopilación.

V8 Young Memory

En este punto, se intercambian los objetos desde el espacio y hacia el espacio. Lo que era el al espacio, y ahora el del espacio, se escanea de principio a fin, y los objetos que aún están vivos se copian al espacio o se trasladan al montón de la generación anterior. Si quieres obtener más información, te recomendamos que leas sobre el algoritmo de Cheney.

De manera intuitiva, debes comprender que cada vez que se asigna un objeto de manera 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 la aterradora aplicación se detiene.

¿Se esperan 10 MB/s de elementos no utilizados para esta aplicación?

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

Sospechosos

La siguiente fase de la investigación es determinar posibles sospechosos y luego reducir su tamaño.

Sospechoso núm. 1

Se llamará a new durante el fotograma. Recuerda que cada objeto asignado te acerca cada vez más a una pausa de recolección de elementos no utilizados. En particular, las aplicaciones que se ejecutan a velocidades de fotogramas altas deben esforzarse por que no se realicen asignaciones por cada fotograma. Por lo general, esto requiere un sistema de reciclaje de objetos cuidadosamente pensado y específico para la aplicación. Los detectives V8 consultaron con el equipo de Oz y no estaban llamando a nuevos. De hecho, el equipo de Oz ya estaba bien al tanto de este requisito y dijo "Sería vergonzoso". Raspa esto de la lista.

Sospechoso núm. 2

Modificar la "forma" de un objeto fuera del constructor Esto sucede cada vez que se agrega una nueva propiedad a un objeto fuera del constructor. De esta manera, se creará una clase oculta nueva para el objeto. Cuando el código optimizado vea esta nueva clase oculta, se activará una anulación. Se ejecutará el código no optimizado hasta que se clasifique como activo y se vuelva a optimizar. Esta deserción de de optimización y reoptimización dará lugar a bloqueos,pero no se correlaciona estrictamente con la creación excesiva de elementos no utilizados. Después de una cuidadosa auditoría del código, se confirmó que las formas de los objetos eran estáticas, por lo que se descartó la sospecha n.o 2.

Sospechoso núm. 3

Aritmética en código no optimizado En el código no optimizado, todo el procesamiento da como resultado la asignación de objetos reales. Por ejemplo, este fragmento hace lo siguiente:

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

Se generan 5 objetos HeapNumber. Las tres primeras son para las variables a, b y c. El cuarto es para el valor anónimo (a * b) y el 5 es de #4 * c; el quinto es, en última instancia, asignado a punto.x.

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

Sospechoso núm. 4

Almacenar un número de doble precisión en una propiedad Se debe crear un objeto HeapNumber para almacenar el número y la propiedad se debe modificar para que apunte a este objeto nuevo. Si modificas la propiedad para que apunte a HeapNumber, no se producirán elementos no utilizados. Sin embargo, es posible que se almacenen muchos números de precisión doble como propiedades de objetos. El código está lleno de sentencias como las siguientes:

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

En el código optimizado, cada vez que se asigna un valor recién calculado a x, una instrucción aparentemente inocua, se asigna de manera implícita un nuevo objeto HeapNumber, lo que nos acerca a una pausa en la recolección de elementos no utilizados.

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

El sospechoso n.o 4 es una posibilidad.

Ciencia forense

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

Experimento 1

Verificación del sospechoso n.o 3 (computación aritmética dentro de funciones no optimizadas) El motor V8 de JavaScript tiene un sistema de registro integrado que puede brindar información detallada sobre lo que ocurre en niveles más profundos.

Comenzando con Chrome que no se ejecuta, inicio del navegador con las siguientes funciones:

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

y, luego, salir de Chrome por completo dará como resultado un archivo v8.log en el directorio actual.

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

Después de compilar la versión 8 con éxito, 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 la versión 8).

El procesador de marcas muestra una tabla basada en texto de funciones de JavaScript que tuvo 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 habilitación de la función está optimizada y que la opción no está optimizada.

Otra herramienta importante de la bolsa de herramientas del detective V8 es el evento del temporizador de trazado. Se puede ejecutar de la siguiente manera:

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

Después de la ejecución, hay un archivo PNG llamado timeout-events.png en el directorio actual. Cuando lo abras, deberías ver algo similar a esto:

Eventos de temporizador

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

Eje Y de eventos de temporizador

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

Una de las filas más importantes es el “tipo de código que se ejecuta”. Aparecerá de color verde cuando se ejecute código optimizado y una combinación de rojo y azul cuando se ejecute código no optimizado. En la siguiente captura de pantalla, se muestra la transición de optimizado a no optimizado y, luego, nuevamente al código optimizado:

Tipo de código que se ejecuta

Idealmente, pero nunca de inmediato, esta línea será 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 optimizado.

Si llegaste a este punto, vale la pena señalar que puedes trabajar mucho más rápido si refactorizas tu aplicación para que pueda ejecutarse en la shell de depuración de la versión 8: d8. El uso de d8 te brinda tiempos de iteración más rápidos con las herramientas del procesador de marcas y el temporizador de trazado. Otro efecto secundario del uso de d8 es que se vuelve más fácil aislar el problema real, lo que reduce la cantidad de ruido presente en los datos.

Observando el trazado de eventos del temporizador desde el código fuente de Oz, se muestra una transición de código optimizado a no optimizado y, mientras se ejecutaba código no optimizado, se activaron muchas colecciones de nuevas generaciones, similar a la siguiente captura de pantalla (se quitó el tiempo de la nota en el medio):

Diagrama de eventos de temporizador

Si te fijas bien, podrás ver que las líneas negras que indican cuándo V8 ejecuta código JavaScript faltan exactamente en las mismas marcas de perfil que las colecciones de la nueva generación (líneas azules). Esto demuestra claramente que mientras se recolectan elementos no utilizados, la secuencia de comandos se pausa.

Cuando observamos el resultado del procesador de marcas del código fuente de Oz, no se optimizó la función superior (updateSprites). En otras palabras, tampoco se optimizó la función en la que el programa pasó más tiempo. Esto indica claramente que la sospecha n.o 3 es el culpable. La fuente de updateSprites contenía bucles como los siguientes:

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

Conociendo V8 tan bien como ellos, inmediatamente reconocieron que la construcción del bucle for-i-in a veces no estaba optimizada por V8. En otras palabras, si una función contiene una construcción de bucle for-i-in, es posible que no esté optimizada. Este es un caso especial en la actualidad y probablemente cambiará en el futuro, es decir, algún día V8 podría optimizar esta construcción del bucle. Ya que no somos detectives de V8 y no conocemos V8 como lo sabemos, ¿cómo podemos determinar por qué no se optimizó updateSprites?

Experimento 2

Ejecutas Chrome con esta marca:

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

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

[Optimización inhabilitada para updateSprites, motivo: ForInStatement no es un caso rápido]

Tal como los detectives plantearon la hipótesis, la razón fue la construcción del bucle for-i-in.

Caso cerrado

Después de descubrir la razón por la que updateSprites no estaba optimizado, la corrección fue sencilla. Simplemente mueve 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);
    }
}

Se optimizará updateSprite, lo que generará muchos menos objetos HeapNumber y, por lo tanto, se produzcan pausas de GC menos frecuentes. Debería poder confirmar esto fácilmente realizando los mismos experimentos con un código nuevo. El lector cuidadoso notará que los números dobles aún se almacenan como propiedades. Si la creación de perfiles indica que vale la pena, cambiar la posición para que sea un array de dobles o un array de datos escrito reduciría aún más la cantidad de objetos que se crean.

Epílogo

Los desarrolladores de Oz no se detuvieron ahí. Equipados con las herramientas y técnicas que los detectives de V8 compartieron con ellos, pudieron encontrar algunas otras funciones que quedaron atrapadas en el infierno de la desoptimización y consideraron el código de cálculo en funciones de hoja que se optimizaron, lo que dio como resultado un rendimiento aún mejor.

¡Sal y comienza a resolver algunos delitos de rendimiento!