El material, cuya traducción publicamos hoy, fue preparado por Matthias Binens y Benedict Meirer. Están trabajando en el motor
V8 JS en Google. Este artículo está dedicado a algunos mecanismos básicos que son característicos no solo para V8, sino también para otros motores. La familiaridad con la estructura interna de dichos mecanismos permite a los involucrados en el desarrollo de JavaScript navegar mejor por los problemas de rendimiento del código. En particular, aquí hablaremos sobre las características de las tuberías de optimización del motor y cómo acelerar el acceso a las propiedades de los prototipos de objetos.

Niveles de optimización de código y compensaciones
El proceso de convertir los textos de programas escritos en JavaScript en código adecuado para la ejecución se ve aproximadamente igual en diferentes motores.
El proceso de convertir el código fuente JS en código ejecutableLos detalles se pueden encontrar
aquí . Además, debe tenerse en cuenta que aunque, en un nivel alto, las canalizaciones para convertir el código fuente en ejecutable son muy similares para diferentes motores, sus sistemas de optimización de código a menudo difieren. ¿Por qué es esto así? ¿Por qué algunos motores tienen más niveles de optimización que otros? Resulta que los motores tienen que comprometerse de una forma u otra, lo que consiste en el hecho de que pueden generar rápidamente código que no es el más eficiente pero adecuado para la ejecución, o pasar más tiempo creando dicho código, pero debido a esto, lograr un rendimiento óptimo
Preparación rápida de código para ejecución y código optimizado que lleva más tiempo pero se ejecuta más rápidoEl intérprete puede generar rápidamente un código de bytes, pero dicho código generalmente no es muy eficiente. El compilador de optimización, por otro lado, necesita más tiempo para generar el código, pero al final se optimiza, el código de máquina más rápido.
Es este modelo de preparación de código para ejecución que se utiliza en V8. El intérprete V8 se llama Ignition, es el más rápido de los intérpretes existentes (en términos de ejecución del código de bytes de origen). El compilador de optimización V8 se llama TurboFan, que es responsable de crear un código de máquina altamente optimizado.
Intérprete de encendido y compilador de optimización TurboFanLa compensación entre el retraso en el inicio del programa y la velocidad de ejecución es la razón por la que algunos motores JS tienen niveles adicionales de optimización. Por ejemplo, en SpiderMonkey, entre el intérprete y el compilador de optimización IonMonkey, hay un nivel intermedio representado por el compilador básico (se llama "The Baseline Compiler" en la
documentación de Mozilla, pero "baseline" no es un nombre propio).
Niveles de optimización del código SpiderMonkeyEl intérprete genera rápidamente el código de bytes, pero dicho código se ejecuta con relativa lentitud. El compilador base tarda más en generar el código, pero este código ya es más rápido. Finalmente, el compilador de optimización de IonMonkey toma más tiempo para generar código de máquina, pero este código puede ejecutarse de manera muy eficiente.
Echemos un vistazo a un ejemplo específico y veamos cómo las tuberías de varios motores manejan el código. En el ejemplo presentado aquí, hay un bucle "activo" que contiene código que se repite tantas veces.
let result = 0; for (let i = 0; i < 4242424242; ++i) { result += i; } console.log(result);
V8 comienza a ejecutar bytecode en el intérprete de encendido. En algún momento, el motor descubre que el código está "activo" y lanza la interfaz de TurboFan, que es parte de TurboFan que trabaja con datos de creación de perfiles y crea una representación de máquina básica del código. Luego, los datos se pasan al optimizador TurboFan, que opera en una secuencia separada, para mejoras adicionales.
Optimización de código activo en V8Durante la optimización, V8 continúa ejecutando bytecode en Ignition. Cuando se completa el optimizador, tenemos un código de máquina ejecutable que puede usarse en el futuro.
El motor SpiderMonkey también comienza a ejecutar bytecode en el intérprete. Pero tiene un nivel adicional representado por el compilador básico, lo que lleva al hecho de que el código "activo" primero llega a este compilador. Genera el código base en el hilo principal, la transición a la ejecución de este código se realiza cuando está listo.
Optimización de código activo en SpiderMonkeySi el código base se ejecuta el tiempo suficiente, SpiderMonkey finalmente lanza la interfaz y el optimizador IonMonkey, que es muy similar a lo que sucede en V8. El código base continúa ejecutándose como parte del proceso de optimización de código realizado por IonMonkey. Como resultado, cuando se completa la optimización, se ejecuta el código optimizado en lugar del código base.
La arquitectura del motor Chakra es muy similar a la arquitectura de SpiderMonkey, pero Chakra se esfuerza por lograr un mayor nivel de concurrencia para evitar bloquear el hilo principal. En lugar de resolver cualquier tarea de compilación en el hilo principal, Chakra copia y envía el bytecode y los datos de perfil que el compilador probablemente necesitará en un proceso de compilación separado.
Optimización de código activo en ChakraCuando el código generado preparado por SimpleJIT está listo, el motor lo ejecutará en lugar de bytecode. Este proceso se repite para continuar con la ejecución del código preparado por FullJIT. La ventaja de este enfoque es que las pausas asociadas con la copia de datos suelen ser mucho más cortas que las causadas por la operación de un compilador completo (front-end). Sin embargo, la desventaja de este enfoque es el hecho de que los algoritmos de copia heurística pueden perder información que puede ser útil para algún tipo de optimización. Aquí vemos un ejemplo de compromiso entre la calidad del código recibido y los retrasos.
En JavaScriptCore, todas las tareas de compilación de optimización se realizan en paralelo con el hilo principal responsable de ejecutar el código JavaScript. Sin embargo, no hay etapa de copia. En cambio, el hilo principal simplemente invoca tareas de compilación en otro hilo. El compilador luego usa un complejo esquema de bloqueo para acceder a los datos de creación de perfiles desde el hilo principal.
Optimización del código "hot" en JavaScriptCoreLa ventaja de este enfoque es que reduce el bloqueo forzado del hilo principal causado por el hecho de que realiza tareas de optimización de código. Las desventajas de esta arquitectura son que su implementación requiere la solución de tareas complejas de procesamiento de datos multiproceso, y que en el curso del trabajo, para realizar varias operaciones, uno tiene que recurrir a las cerraduras.
Acabamos de discutir las compensaciones que los motores se ven obligados a hacer, eligiendo entre la generación rápida de códigos con intérpretes y la creación de códigos rápidos con compiladores optimizadores. Sin embargo, estos están lejos de todos los problemas que enfrentan los motores. La memoria es otro recurso del sistema cuando se utiliza y debe recurrir a soluciones de compromiso. Para demostrar esto, considere un programa JS simple que agregue números.
function add(x, y) { return x + y; } add(1, 2);
Aquí está el código de bytes de la función de
add
generada por el intérprete de encendido en V8:
StackCheck Ldar a1 Add a0, [0] Return
No puede entrar en el significado de este código de bytes, de hecho, su contenido no es de particular interés para nosotros. Lo principal aquí es que solo tiene cuatro instrucciones.
Cuando un código de este tipo está "activo", se utiliza TurboFan, que genera el siguiente código de máquina altamente optimizado:
leaq rcx,[rip+0x0] movq rcx,[rcx-0x37] testb [rcx+0xf],0x1 jnz CompileLazyDeoptimizedCode push rbp movq rbp,rsp push rsi push rdi cmpq rsp,[r13+0xe88] jna StackOverflow movq rax,[rbp+0x18] test al,0x1 jnz Deoptimize movq rbx,[rbp+0x10] testb rbx,0x1 jnz Deoptimize movq rdx,rbx shrq rdx, 32 movq rcx,rax shrq rcx, 32 addl rdx,rcx jo Deoptimize shlq rdx, 32 movq rax,rdx movq rsp,rbp pop rbp ret 0x18
Como puede ver, el volumen de código, en comparación con el ejemplo anterior de cuatro instrucciones, es muy grande. Normalmente, el código de bytes es mucho más compacto que el código de máquina, y en particular el código de máquina optimizado. Por otro lado, se necesita un intérprete para ejecutar bytecode, y el código optimizado se puede ejecutar directamente en el procesador.
Esta es una de las razones principales por las que los motores de JavaScript no optimizan absolutamente todo el código. Como vimos anteriormente, la creación de código de máquina optimizado lleva mucho tiempo y, además, como acabamos de descubrir, se necesita más memoria para almacenar el código de máquina optimizado.
Uso de memoria y nivel de optimizaciónComo resultado, podemos decir que la razón por la cual los motores JS tienen diferentes niveles de optimización es el problema fundamental de elegir entre la generación rápida de código, por ejemplo, usando un intérprete, y la generación rápida de código, que se ejecuta mediante el compilador de optimización. Si hablamos de los niveles de optimización de código utilizados en los motores, cuantos más haya, más optimizaciones sutiles puede sufrir el código, pero esto se logra debido a la complejidad de los motores y a la carga adicional en el sistema. Además, aquí no debemos olvidar que el nivel de optimización del código afecta la cantidad de memoria que ocupa este código. Es por eso que los motores JS intentan optimizar solo las funciones "activas".
Optimización del acceso a las propiedades del prototipo de objeto.
Los motores de JavaScript optimizan el acceso a las propiedades de los objetos mediante el uso de los llamados formularios de objetos (Shape) y cachés en línea (Caché en línea, IC). Los detalles sobre esto se pueden leer en
este material, pero para resumirlo, podemos decir que el motor almacena la forma del objeto por separado de los valores del objeto.
Objetos que tienen la misma forma.El uso de formas de objetos permite realizar una optimización llamada almacenamiento en caché en línea. El uso conjunto de formularios de objetos y cachés en línea le permite acelerar las operaciones repetidas de acceso a las propiedades de los objetos, realizadas desde el mismo lugar en el código.
Acelerar el acceso a una propiedad de objetoClases y Prototipos
Ahora que sabemos cómo acelerar el acceso a las propiedades de los objetos en JavaScript, eche un vistazo a una de las innovaciones recientes de JavaScript: las clases. Así es como se ve la declaración de clase:
class Bar { constructor(x) { this.x = x; } getX() { return this.x; } }
Aunque puede parecer la aparición en JS de un concepto completamente nuevo, las clases son en realidad solo azúcar sintáctica para el sistema prototipo para construir objetos, que siempre ha estado presente en JavaScript:
function Bar(x) { this.x = x; } Bar.prototype.getX = function getX() { return this.x; };
Aquí escribimos la función en la propiedad
getX
objeto
getX
. Esta operación funciona exactamente de la misma manera que cuando se crean las propiedades de cualquier otro objeto, ya que los prototipos en JavaScript son objetos. En los lenguajes basados en el uso de prototipos, como JavaScript, los métodos que pueden compartir todos los objetos de cierto tipo se almacenan en prototipos, y los campos de los objetos individuales se almacenan en sus instancias.
Veamos qué sucede, por así decirlo, detrás de escena cuando creamos una nueva instancia del objeto
Bar
, asignándola al
foo
constante.
const foo = new Bar(true);
Después de ejecutar dicho código, la instancia del objeto creado aquí tendrá un formulario que contiene una sola propiedad
x
. El prototipo del objeto
foo
es
Bar.prototype
, que pertenece a la clase
Bar
.
Objeto y su prototipoBar.prototype
tiene su propia forma que contiene una sola propiedad
getX
cuyo valor es una función que, cuando se llama, devuelve el valor de
this.x
El prototipo prototipo
Bar.prototype
es
Object.prototype
, que forma parte del lenguaje.
Object.prototype
es el elemento raíz del árbol prototipo, por lo que su prototipo es
null
.
Ahora veamos qué sucede si crea otro objeto de tipo
Bar
.
Varios objetos del mismo tipo.Como puede ver, tanto el objeto
foo
como el objeto
qux
, que son instancias de la clase
Bar
, como ya hemos dicho, usan la misma forma del objeto. Ambos usan el mismo prototipo: el objeto
Bar.prototype
.
Acceder a las propiedades del prototipo
Entonces, ahora sabemos lo que sucede cuando declaramos una nueva clase y la instanciamos. ¿Y qué hay de la llamada al método del objeto? Considere el siguiente fragmento de código:
class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX();
Una llamada al método puede entenderse como una operación que consta de dos pasos:
const x = foo.getX(); // : const $getX = foo.getX; const x = $getX.call(foo);
En el primer paso, se carga el método, que es solo una propiedad del prototipo (cuyo valor es la función). En el segundo paso, se llama a una función con
this
conjunto. Considere el primer paso para cargar el método
getX
desde el objeto
foo
:
Cargando el método getX desde el objeto fooEl motor analiza el objeto
foo
y descubre que no hay propiedad
getX
en la forma del objeto
foo
. Esto significa que el motor necesita mirar la cadena de prototipos del objeto para encontrar este método. El motor accede al prototipo
Bar.prototype
y observa la forma del objeto de este prototipo. Allí, encuentra la propiedad deseada en el desplazamiento 0. A continuación,
Bar.prototype
al valor almacenado en este desplazamiento en
Bar.prototype
, se detecta
JSFunction
getX
allí, y esto es exactamente lo que estamos buscando. Esto completa la búsqueda del método.
La flexibilidad de JavaScript hace posible cambiar las cadenas de prototipos. Por ejemplo, así:
const foo = new Bar(true); foo.getX(); // true Object.setPrototypeOf(foo, null); foo.getX(); // Uncaught TypeError: foo.getX is not a function
En este ejemplo, llamamos al método
foo.getX()
dos veces, pero cada una de estas llamadas tiene un significado y un resultado completamente diferentes. Es por eso que, aunque los prototipos de JavaScript son solo objetos, acelerar el acceso a las propiedades de los prototipos es aún más difícil para los motores JS que acelerar el acceso a sus propias propiedades de los objetos ordinarios.
Si observamos los programas de la vida real, resulta que cargar las propiedades del prototipo es una operación muy común. Se ejecuta cada vez que se llama a un método.
class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX();
Anteriormente, hablamos sobre cómo los motores optimizan la carga de propiedades regulares y personalizadas de objetos mediante el uso de formularios de objetos y cachés en línea. ¿Cómo optimizar la carga de propiedades de prototipo repetidas para objetos con la misma forma? Arriba, vimos cómo se cargan las propiedades.
Cargando el método getX desde el objeto fooPara acelerar el acceso al método con llamadas repetidas, en nuestro caso, necesita saber lo siguiente:
- La forma del objeto
foo
no contiene el método getX
y no cambia. Esto significa que el objeto foo
no se modifica al agregarle propiedades o eliminarlo o cambiar los atributos de las propiedades. - El prototipo
foo
sigue siendo el prototipo original de Bar.prototype
. Esto significa que el prototipo foo
no cambia utilizando el método Object.setPrototypeOf()
o asignando un nuevo prototipo a la propiedad especial _proto_
. - El formulario
Bar.prototype
contiene getX
y no cambia. Es decir, Bar.prototype
no cambia al eliminar propiedades, agregarlas o cambiar sus atributos.
En el caso general, esto significa que necesitamos hacer 1 comprobación del objeto en sí y 2 comprobaciones para cada prototipo hasta el prototipo que almacena la propiedad que estamos buscando. Es decir, debe realizar verificaciones 1 + 2N (donde N es el número de prototipos probados), que en este caso no se ve tan mal, ya que la cadena de prototipos es bastante corta. Sin embargo, los motores a menudo tienen que trabajar con cadenas de prototipos mucho más largas. Esto, por ejemplo, es típico de los elementos DOM ordinarios. Aquí hay un ejemplo:
const anchor = document.createElement('a');
Aquí tenemos
HTMLAnchorElement
y llamamos a su método
getAttribute()
. ¡La cadena de prototipos de este elemento simple que representa un enlace HTML incluye 6 prototipos! Los métodos DOM más interesantes no están en su propio prototipo
HTMLAnchorElement
. Están en prototipos ubicados más abajo en la cadena.
Cadena prototipoEl método
getAttribute()
se puede encontrar en
Element.prototype
. Esto significa que cada vez que se
anchor.getAttribute()
método
anchor.getAttribute()
, el motor se ve obligado a realizar las siguientes acciones:
- Comprueba el objeto de
anchor
sí para getAttribute
. - Verificar que el prototipo directo del objeto es
HTMLAnchorElement.prototype
. - Descubrir que
HTMLAnchorElement.prototype
no tiene un método getAttribute
. - Verificar que el próximo prototipo sea
HTMLElement.prototype
. - Descubrir que no hay un método necesario aquí.
- Finalmente, descubriendo que el próximo prototipo es
Element.prototype
. - Descubrir que hay un método
getAttribute
.
Como puede ver, aquí se realizan 7 comprobaciones. Dado que dicho código es muy común en la programación web, los motores utilizan optimizaciones para reducir la cantidad de comprobaciones necesarias para cargar las propiedades del prototipo.
Si volvemos a uno de los ejemplos anteriores, podemos recordar que cuando llamamos al método
getX
del objeto
getX
, realizamos 3 comprobaciones:
class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const $getX = foo.getX;
Para cada objeto que está en la cadena del prototipo, hasta el que contiene la propiedad deseada, necesitamos verificar la forma del objeto solo para descubrir la ausencia de lo que estamos buscando. Sería bueno si pudiéramos reducir el número de controles reduciendo el control del prototipo a verificar la presencia o ausencia de lo que estamos buscando. Esto es lo que hace el motor con un simple movimiento: en lugar de almacenar el enlace prototipo en la propia instancia, el motor lo almacena en forma de un objeto.
Prototipo de almacenamiento de referenciaCada formulario tiene un enlace a un prototipo. Esto también significa que cada vez que el prototipo cambia, el motor se mueve a la nueva forma del objeto. Ahora solo tenemos que verificar la forma del objeto para detectar la presencia de una propiedad en él y cuidar de proteger el enlace prototipo.
Gracias a este enfoque, podemos reducir el número de comprobaciones de 1 + 2N a 1 + N, lo que acelerará el acceso a las propiedades de los prototipos. Sin embargo, tales operaciones aún requieren muchos recursos, ya que existe una relación lineal entre su número y la longitud de la cadena del prototipo. Los motores han implementado varios mecanismos destinados a garantizar que el número de comprobaciones no dependa de la longitud de la cadena del prototipo, expresado como una constante. Esto es especialmente cierto en situaciones donde la carga de la misma propiedad se realiza varias veces.
Propiedad ValidityCell
V8 se refiere a las formas de prototipos específicamente para el propósito anterior. Cada prototipo tiene una forma única que no se comparte con otros objetos (en particular, con otros prototipos), y cada uno de los formularios de objetos prototipo tiene una propiedad
ValidityCell
asociada a ellos.
Propiedad ValidityCellEsta propiedad se declara inválida cuando se cambia el prototipo asociado con el formulario o cualquier prototipo superpuesto. Considere este mecanismo con más detalle.
Para acelerar las operaciones secuenciales de las propiedades de carga de los prototipos, V8 utiliza un caché en línea que contiene cuatro campos:
ValidityCell
,
Prototype
,
Shape
,
Offset
.
Campos de caché en líneaDurante el "calentamiento" del caché en línea la primera vez que se ejecuta el código, V8 recuerda el desplazamiento en el que se encontró la propiedad en el prototipo, el prototipo en el que se encontró la propiedad (en este ejemplo,
Bar.prototype
), la forma del objeto (
foo
en este caso) y, además, un enlace al parámetro
ValidityCell
actual del prototipo inmediato, un enlace que tiene la forma de un objeto (en este caso, también es
Bar.prototype
).
La próxima vez que acceda al caché en línea, el motor deberá verificar la forma del objeto y
ValidityCell
. Si
ValidityCell
sigue siendo válido, el motor puede aprovechar directamente el desplazamiento previamente guardado en el prototipo sin realizar operaciones de búsqueda adicionales.
Cuando el prototipo cambia, se crea un nuevo formulario y la propiedad
ValidityCell
anterior se declara inválida. Como resultado, la próxima vez que intente acceder al caché en línea, no ofrece ningún beneficio, lo que conduce a un bajo rendimiento.
Las consecuencias de cambiar el prototipoSi volvemos al ejemplo con el elemento DOM, esto significa que cualquier cambio, por ejemplo, en el prototipo de
Object.prototype
, conducirá no solo a invalidar el caché en línea para
Object.prototype
sí, sino también para cualquier prototipo ubicado debajo de él en la cadena de prototipos. incluidos
EventTarget.prototype
,
Node.prototype
,
Element.prototype
, etc., hasta
HTMLAnchorElement.prototype
.
Implicaciones de cambiar Object.prototypeDe hecho, modificar
Object.prototype
durante la ejecución del código significa hacer un daño grave al rendimiento. No hagas esto.
Estudiamos lo anterior con un ejemplo. Supongamos que tenemos la clase
Bar
y la función
loadX
, que llama al método de los objetos creados a partir de la clase
Bar
. Llamamos a la función
loadX
varias veces, pasándole instancias de la misma clase.
function loadX(bar) { return bar.getX(); // IC 'getX' `Bar`. } loadX(new Bar(true)); loadX(new Bar(false)); // IC `loadX` `ValidityCell` // `Bar.prototype`. Object.prototype.newMethod = y => y; // `ValidityCell` IC `loadX` // `Object.prototype` .
El caché en
loadX
en
loadX
ahora apunta a
Bar.prototype
para
Bar.prototype
. , ,
Object.prototype
— JavaScript,
ValidityCell
, - , .
Object.prototype
— , - , . , :
Object.prototype.foo = function() { };
Object.prototype
, - , . , . - , . , « », , .
, , . .
Object.prototype
, , - .
, — , JS- - , . . , , . , , , .
Resumen
, JS- , , , -,
ValidityCell
, . JavaScript, , ( , , , ).
Estimados lectores! , - , JS, ?
