Conceptos básicos de JavaScript: por qué debería saber cómo funciona el motor

Este artículo también está disponible en español.

En este artículo, quiero explicar lo que un desarrollador de software, que usa JavaScript para escribir aplicaciones, debe saber acerca de los motores para que el código escrito se ejecute correctamente.

Verá a continuación una función de una sola línea que devuelve la propiedad lastName del argumento pasado. ¡Con solo agregar una sola propiedad a cada objeto, terminamos con una caída de rendimiento de más del 700%!

Como explicaré en detalle, la falta de tipos estáticos de JavaScript impulsa este comportamiento. Una vez visto como una ventaja sobre otros lenguajes como C # o Java, resulta ser más un "trato fáustico".

Frenado a máxima velocidad

Por lo general, no necesitamos conocer los aspectos internos de un motor que ejecuta nuestro código. Los proveedores de navegadores invierten mucho en hacer que los motores ejecuten código muy rápido.

¡Excelente!

Deje que los demás hagan el trabajo pesado. ¿Por qué preocuparse por cómo funcionan los motores?

En nuestro ejemplo de código a continuación, tenemos cinco objetos que almacenan el nombre y apellido de los personajes de Star Wars. La función getNamedevuelve el valor de lastname. Medimos el tiempo total que tarda esta función en ejecutarse mil millones de veces:

(() => { const han = {firstname: "Han", lastname: "Solo"}; const luke = {firstname: "Luke", lastname: "Skywalker"}; const leia = {firstname: "Leia", lastname: "Organa"}; const obi = {firstname: "Obi", lastname: "Wan"}; const yoda = {firstname: "", lastname: "Yoda"}; const people = [ han, luke, leia, obi, yoda, luke, leia, obi ]; const getName = (person) => person.lastname;
 console.time("engine"); for(var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); } console.timeEnd("engine"); })();

En un Intel i7 4510U, el tiempo de ejecución es de aproximadamente 1,2 segundos. Hasta aquí todo bien. Ahora agregamos otra propiedad a cada objeto y lo ejecutamos nuevamente.

(() => { const han = { firstname: "Han", lastname: "Solo", spacecraft: "Falcon"}; const luke = { firstname: "Luke", lastname: "Skywalker", job: "Jedi"}; const leia = { firstname: "Leia", lastname: "Organa", gender: "female"}; const obi = { firstname: "Obi", lastname: "Wan", retired: true}; const yoda = {lastname: "Yoda"};
 const people = [ han, luke, leia, obi, yoda, luke, leia, obi];
 const getName = (person) => person.lastname;
 console.time("engine"); for(var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); } console.timeEnd("engine");})();

Nuestro tiempo de ejecución ahora es de 8.5 segundos, que es aproximadamente un factor de 7 más lento que nuestra primera versión. Esto se siente como pisar los frenos a toda velocidad. ¿Cómo pudo pasar eso?

Es hora de echar un vistazo más de cerca al motor.

Fuerzas combinadas: intérprete y compilador

El motor es la parte que lee y ejecuta el código fuente. Cada uno de los principales proveedores de navegadores tiene su propio motor. Mozilla Firefox tiene Spidermonkey, Microsoft Edge tiene Chakra / ChakraCore y Apple Safari nombra su motor JavaScriptCore. Google Chrome usa V8, que también es el motor de Node.js.

El lanzamiento del V8 en 2008 marcó un momento crucial en la historia de los motores. V8 reemplazó la interpretación relativamente lenta del navegador de JavaScript.

La razón de esta enorme mejora radica principalmente en la combinación de intérprete y compilador. Hoy, los cuatro motores utilizan esta técnica.

El intérprete ejecuta el código fuente casi de inmediato. El compilador genera código de máquina que el sistema del usuario ejecuta directamente.

A medida que el compilador trabaja en la generación de código de máquina, aplica optimizaciones. Tanto la compilación como la optimización dan como resultado una ejecución de código más rápida a pesar del tiempo adicional necesario en la fase de compilación.

La idea principal detrás de los motores modernos es combinar lo mejor de ambos mundos:

  • Inicio rápido de la aplicación del intérprete.
  • Ejecución rápida del compilador.

La consecución de ambos objetivos empieza con el intérprete. En paralelo, el motor marca con frecuencia partes de código ejecutadas como una "ruta activa" y las pasa al compilador junto con la información contextual recopilada durante la ejecución. Este proceso permite al compilador adaptar y optimizar el código para el contexto actual.

Llamamos al comportamiento del compilador "Just in Time" o simplemente JIT.

Cuando el motor funciona bien, puede imaginar ciertos escenarios en los que JavaScript incluso supera a C ++. No es de extrañar que la mayor parte del trabajo del motor se dedique a esa "optimización contextual".

Tipos estáticos durante el tiempo de ejecución: almacenamiento en caché en línea

El almacenamiento en caché en línea, o IC, es una técnica de optimización importante dentro de los motores de JavaScript. El intérprete debe realizar una búsqueda antes de poder acceder a la propiedad de un objeto. Esa propiedad puede ser parte del prototipo de un objeto, tener un método getter o incluso ser accesible a través de un proxy. La búsqueda de la propiedad es bastante cara en términos de velocidad de ejecución.

El motor asigna cada objeto a un "tipo" que genera durante el tiempo de ejecución. V8 llama a estos "tipos", que no forman parte del estándar ECMAScript, clases ocultas o formas de objetos. Para que dos objetos compartan la misma forma de objeto, ambos deben tener exactamente las mismas propiedades en el mismo orden. Entonces, un objeto {firstname: "Han", lastname: "Solo"}se asignaría a una clase diferente a {lastname: "Solo", firstname: "Han"}.

Con la ayuda de las formas de los objetos, el motor conoce la ubicación de memoria de cada propiedad. El motor codifica esas ubicaciones en la función que accede a la propiedad.

Lo que hace el almacenamiento en caché en línea es eliminar las operaciones de búsqueda. No es de extrañar que esto produzca una gran mejora en el rendimiento.

Volviendo a nuestro ejemplo anterior: todos los objetos de la primera ejecución solo tenían dos propiedades firstnamey lastname, en el mismo orden. Digamos que el nombre interno de esta forma de objeto es p1. Cuando el compilador aplica IC, supone que la función solo obtiene la forma del objeto p1y devuelve el valor de lastnameinmediatamente.

En la segunda ejecución, sin embargo, tratamos con 5 formas de objetos diferentes. Cada objeto tenía una propiedad adicional y yodafaltaba por firstnamecompleto. ¿Qué sucede una vez que estamos tratando con múltiples formas de objetos?

Patos intervinientes o tipos múltiples

La programación funcional tiene el conocido concepto de "tipeo de pato", donde la buena calidad del código requiere funciones que pueden manejar múltiples tipos. En nuestro caso, siempre que el objeto pasado tenga un apellido de propiedad, todo está bien.

El almacenamiento en caché en línea elimina la costosa búsqueda de la ubicación de memoria de una propiedad. Funciona mejor cuando, en cada acceso a la propiedad, el objeto tiene la misma forma. Esto se llama IC monomórfico.

Si tenemos hasta cuatro formas de objetos diferentes, estamos en un estado IC polimórfico. Como en monomórfico, el código de máquina optimizado "conoce" ya las cuatro ubicaciones. Pero tiene que comprobar a cuál de las cuatro formas de objeto posibles pertenece el argumento pasado. Esto da como resultado una disminución del rendimiento.

Una vez que superamos el umbral de cuatro, empeora dramáticamente. Ahora estamos en un CI megamórfico. En este estado, ya no hay almacenamiento en caché local de las ubicaciones de memoria. En cambio, debe buscarse desde una caché global. Esto da como resultado la caída extrema del rendimiento que hemos visto anteriormente.

Polimórficos y megamórficos en acción

A continuación, vemos una caché en línea polimórfica con 2 formas de objetos diferentes.

Y el IC megamórfico de nuestro ejemplo de código con 5 formas de objetos diferentes:

Clase JavaScript al rescate

Bien, teníamos 5 formas de objetos y nos encontramos con un CI megamórfico. como podemos arreglar esto?

Tenemos que asegurarnos de que el motor marque todos nuestros 5 objetos con la misma forma de objeto. Eso significa que los objetos que creamos deben contener todas las propiedades posibles. Podríamos usar objetos literales, pero creo que las clases de JavaScript son la mejor solución.

For properties that are not defined, we simply pass null or leave it out. The constructor makes sure that these fields are initialised with a value:

(() => { class Person { constructor({ firstname = '', lastname = '', spaceship = '', job = '', gender = '', retired = false } = {}) { Object.assign(this, { firstname, lastname, spaceship, job, gender, retired }); } }
 const han = new Person({ firstname: 'Han', lastname: 'Solo', spaceship: 'Falcon' }); const luke = new Person({ firstname: 'Luke', lastname: 'Skywalker', job: 'Jedi' }); const leia = new Person({ firstname: 'Leia', lastname: 'Organa', gender: 'female' }); const obi = new Person({ firstname: 'Obi', lastname: 'Wan', retired: true }); const yoda = new Person({ lastname: 'Yoda' }); const people = [ han, luke, leia, obi, yoda, luke, leia, obi ]; const getName = person => person.lastname; console.time('engine'); for (var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); } console.timeEnd('engine');})();

When we execute this function again, we see that our execution time returns to 1.2 seconds. Job done!

Summary

Modern JavaScript engines combine the benefits of interpreter and compiler: Fast application startup and fast code execution.

Inline Caching is a powerful optimisation technique. It works best when only a single object shape passes to the optimised function.

My drastic example showed the effects of Inline Caching’s different types and the performance penalties of megamorphic caches.

Using JavaScript classes is good practice. Static typed transpilers, like TypeScript, make monomorphic IC’s more likely.

Further Reading

  • David Mark Clements: Performance Killers for TurboShift and Ignition: //github.com/davidmarkclements/v8-perf
  • Victor Felder: JavaScript Engines Hidden Classes

    //draft.li/blog/2016/12/22/javascript-engines-hidden-classes

  • Jörg W. Mittag: Overview of JIT Compiler and Interpreter

    //softwareengineering.stackexchange.com/questions/246094/understanding-the-differences-traditional-interpreter-jit-compiler-jit-interp/269878#269878

  • Vyacheslav Egorov: What’s up with Monomorphism

    //mrale.ph/blog/2015/01/11/whats-up-with-monomorphism.html

  • WebComic explaining Google Chrome

    //www.google.com/googlebooks/chrome/big_00.html

  • Huiren Woo: Differences between V8 and ChakraCore

    //developers.redhat.com/blog/2016/05/31/javascript-engine-performance-comparison-v8-charkra-chakra-core-2/

  • Seth Thompson: V8, Advanced JavaScript, & the Next Performance Frontier

    //www.youtube.com/watch?v=EdFDJANJJLs

  • Franziska Hinkelmann — Performance Profiling for V8

    //www.youtube.com/watch?v=j6LfSlg8Fig

  • Benedikt Meurer: An Introduction to Speculative Optimization in V8

    //ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8

  • Mathias Bynens: JavaScript engine fundamentals: Shapes and Inline Caches

    //mathiasbynens.be/notes/shapes-ics