Introducción
En los últimos años, las aplicaciones web se aceleraron considerablemente. Muchas aplicaciones ahora se ejecutan con la suficiente rapidez 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 mostró que las aplicaciones de Google pasan entre el 50% y el 70% de su tiempo dentro de V8. Tu aplicación tiene una cantidad finita de tiempo, por lo que quitar 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 lag. Sigue leyendo para obtener información sobre cómo optimizar JavaScript y generar perfiles de aplicaciones de JavaScript en una historia de primera línea 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. Hacer que una parte del sistema use menos te permite usar más en otra o ejecutarlo de forma más fluida en general. Ejecutar más rápido y hacer más a menudo son objetivos que suelen competir entre sí. Los usuarios demandan funciones nuevas y, al mismo tiempo, esperan que tu aplicación se ejecute de forma más fluida. Las máquinas virtuales de JavaScript siguen siendo más rápidas, pero eso no es una razón para ignorar los problemas de rendimiento que puedes solucionar hoy, como ya saben muchos desarrolladores que enfrentan problemas de rendimiento en sus aplicaciones web. En las aplicaciones de alta velocidad de fotogramas y en tiempo real, la presión para que no haya bloqueos es fundamental. Insomniac Games realizó un estudio en el que se 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". Desarrolladores web, tomen nota.
Cómo solucionar problemas de rendimiento
Resolver un problema de rendimiento es como resolver un crimen. Debes examinar cuidadosamente las pruebas, verificar las causas presuntas y experimentar con diferentes soluciones. Durante todo el proceso, debes documentar tus mediciones para asegurarte de haber solucionado el problema. Hay muy poca diferencia entre este método y la forma en que los detectives resuelven un caso. Los detectives examinan la evidencia, interrogan a los sospechosos y realizan experimentos con la esperanza de encontrar la prueba definitiva.
V8 CSI: Oz
Los increíbles magos que crean 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 bloqueaba, lo que causaba bloqueos. Los desarrolladores de Oz realizaron una investigación inicial con el panel Timeline en Herramientas para desarrolladores de Chrome. Cuando observaron el uso de la memoria, encontraron 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 coincidían con el bloqueo. Similar a la siguiente captura de pantalla de la función Timeline en Chrome DevTools:
Los detectives de V8, Jakob y Yang, se encargaron del caso. Hubo un largo intercambio entre Jakob y Yang del equipo de V8 y el equipo de Oz. Reduje esta conversación a los eventos importantes que ayudaron a rastrear 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 interactiva en 3D. Por este motivo, es muy sensible a las pausas causadas por las limpiezas de basura. 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 gráficas y dibuje la pantalla.
Oz realiza muchos cálculos aritméticos en valores dobles y realiza 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 el rendimiento de la VM de JavaScript y las técnicas de optimización. 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 complicada debido a la desconexión entre el código que escribían los desarrolladores de Oz y el 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 usados 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 antigua. Esto es así por diseño, ya que la recopilación de la generación joven es mucho más económica. A menudo, es 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 contiguos de memoria de igual tamaño. Solo uno de estos dos bloques de memoria está en uso en un momento determinado y se denomina espacio de destino. Mientras haya memoria restante 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 para el objeto to. En este punto, se detiene el programa y comienza la recopilación.
En este punto, se intercambian los espacios de origen y destino. Lo que era el espacio de destino y 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 promocionan al montón de generación anterior. Si quieres obtener más detalles, te recomiendo que leas sobre el algoritmo de Cheney.
De manera intuitiva, debes 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 usados y a la temida pausa de la aplicación.
¿Se espera que esta aplicación genere 10 MB/s de basura?
En resumen, no. El desarrollador no está haciendo nada para esperar 10 MB/s de basura.
Sospechosos
La siguiente fase de la investigación es determinar los posibles sospechosos y, luego, reducirlos.
Sospechoso n° 1
Llamada 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 a velocidades de fotogramas altas deben esforzarse por tener cero asignaciones por fotograma. Por lo general, esto requiere un sistema de reciclaje de objetos específico para la aplicación y bien pensado. Los detectives de V8 verificaron con el equipo de Oz y no estaban llamando a nuevos. De hecho, el equipo de Oz ya conocía este requisito y dijo: "Eso sería vergonzoso". Bórralo de la lista.
Sospechoso 2
Modificar la "forma" de un objeto fuera del constructor Esto ocurre 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 deopt y se ejecutará el código no optimizado hasta que se clasifique como en uso y se vuelva a optimizar. Este cambio de optimización a desoptimización y viceversa generará interrupciones,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ó la sospecha n° 2.
Sospechoso 3
Aritmética en código no optimizado En el código no optimizado, todos los resultados del procesamiento se asignan a objetos reales. Por ejemplo, este fragmento:
var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;
Esto genera la creación de 5 objetos HeapNumber. Los tres primeros son para las variables a, b y c. El cuarto es para el valor anónimo (a * b) y el quinto es de #4 * c. El quinto se asigna finalmente a point.x.
Oz realiza miles de estas operaciones por fotograma. Si alguno de estos cálculos ocurre en funciones que nunca se optimizan, podría ser la causa de los datos no utilizados. Porque los cálculos en no optimizados asignan memoria incluso para resultados temporales.
Sospechoso n° 4
Almacena un número de doble precisión en una propiedad. Se debe crear un objeto HeapNumber para almacenar el número y la propiedad alterada para que apunte a este objeto nuevo. Si modificas la propiedad para que apunte a HeapNumber, no se producirá basura. Sin embargo, es posible que haya muchos números de doble precisión almacenados como propiedades de objetos. El código está lleno de instrucciones 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 sentencia aparentemente inofensiva, 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 escrito (o un array normal que solo contiene números dobles), puedes evitar este problema específico por completo, ya que el almacenamiento para el número de precisión doble se asigna solo una vez y cambiar el valor de forma repetida no requiere que se asigne 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 quién era el 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 de órdenes de magnitud más pequeña que el código original, por lo que es más fácil de razonar.
Experimento n.° 1
Se verifica el 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 estadísticas valiosas sobre lo que sucede bajo la superficie.
Si Chrome no se inicia, inícialo con las siguientes marcas:
--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"
y, luego, salir por completo de Chrome generará 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 (consulta about:version) y compilarla.
Después de compilar correctamente v8, puedes procesar el registro con el procesador de ticks:
$ 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 de origen de nivel superior en la 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 está optimizada.
Otra herramienta importante en el kit 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 ejecutarse, se crea un archivo png llamado timer-events.png en el directorio actual. Cuando lo abras, deberías ver algo como lo siguiente:
Además del gráfico de la parte inferior, los datos se muestran en filas. El eje X es el tiempo (ms). El lado izquierdo incluye etiquetas para cada fila:
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 colección de nueva generación. De manera similar, para el resto de los estados de V8.
Una de las filas más importantes es "tipo de código que se ejecuta". Será verde cuando se ejecute el código optimizado y una combinación de rojo y azul cuando se ejecute el 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 código optimizado:
Idealmente, pero nunca de inmediato, esta línea aparecerá en 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 a este punto, vale la pena señalar que puedes trabajar mucho más rápido si refactorizas tu aplicación para que se ejecute en la shell de depuración v8: d8. El uso de d8 te brinda tiempos de iteración más rápidos con las herramientas de tick-processor y plot-timer-event. 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.
Si observas el gráfico de eventos del temporizador del código fuente de Oz, se muestra una transición del código optimizado al no optimizado y, mientras se ejecutaba el código no optimizado, se activaron muchas colecciones de nueva generación, similares a la siguiente captura de pantalla (ten en cuenta que se quitó el tiempo en el medio):
Si observas con atención, puedes ver que faltan las líneas negras que indican cuándo V8 ejecuta código JavaScript en los mismos tiempos de marca de perfil que las colecciones de nueva generación (líneas azules). Esto demuestra claramente que, mientras se recopilan los elementos sin usar, la secuencia de comandos se detiene.
Si observas el resultado del procesador de tics del código fuente de Oz, la función principal (updateSprites) no se optimizó. En otras palabras, la función en la que el programa pasó más tiempo tampoco se optimizó. Esto indica con mucha certeza que el sospechoso 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 conocían V8 tan bien como lo hacen, 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 esté optimizada. Actualmente, este es un caso especial y es probable que cambie en el futuro, es decir, es posible que V8 algún día optimice esta construcción de bucle. 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.° 2
Ejecutar Chrome con esta marca:
--js-flags="--trace-deopt --trace-opt-verbose"
Muestra un registro detallado de los datos de optimización y de optimización inversa. Si buscamos en los datos de updateSprites, encontramos lo siguiente:
[disabled optimization for updateSprites, reason: ForInStatement is not fast case]
Tal como los detectives plantearon, la construcción del bucle for-i-in fue el motivo.
Caso cerrado
Después de descubrir el motivo por el que updateSprites no se optimizó, la solución fue simple: simplemente, mueve el procesamiento 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, lo que provocará pausas de GC menos frecuentes. Debería ser fácil confirmar esto si realizas los mismos experimentos con código nuevo. El lector atento notará que los números dobles aún se almacenan como propiedades. Si el perfil indica que vale la pena, cambiar la posición a un array de números dobles o un array de datos tipados 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 otras funciones que estaban atascadas 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!