Una historia sobre V8, React y una caída en el rendimiento. Parte 1

El material, cuya primera parte de la traducción publicamos hoy, discutirá cómo el motor V8 JavaScript selecciona las mejores formas de representar varios valores JS en la memoria, y cómo esto afecta los mecanismos internos de V8 con respecto al trabajo con los llamados formularios. objetos (forma). Todo esto nos ayudará a resolver la esencia del reciente problema de rendimiento de React .



Tipos de datos de JavaScript


Cada valor de JavaScript puede tener solo uno de los ocho tipos de datos existentes: Number , String , Symbol , BigInt , Boolean , Undefined , Null y Object .


Tipos de datos de JavaScript

El tipo de valor se puede determinar utilizando el operador typeof , pero hay una excepción importante:

 typeof 42; // 'number' typeof 'foo'; // 'string' typeof Symbol('bar'); // 'symbol' typeof 42n; // 'bigint' typeof true; // 'boolean' typeof undefined; // 'undefined' typeof null; // 'object' -   ,     typeof { x: 42 }; // 'object' 

Como puede ver, el comando typeof null devuelve 'object' , no 'null' , a pesar de que null tiene su propio tipo: Null . Para comprender la razón de este tipo de comportamiento, tenemos en cuenta el hecho de que el conjunto de todos los tipos de JavaScript se puede dividir en dos grupos:

  • Objetos (es decir, tipo Object ).
  • Valores primitivos (es decir, cualquier valor no objetivo).

A la luz de este conocimiento, resulta que null significa "sin valor de objeto", mientras que undefined significa "sin valor".


Valores primitivos, objetos, nulos e indefinidos

Siguiendo estas reflexiones en el espíritu de Java, Brendan Eich diseñó JavaScript para que el operador typeof devolviera 'object' para los valores de esos tipos que se encuentran en la figura anterior a la derecha. Todos los valores de objeto y null llegan aquí. Es por eso que la expresión typeof null === 'object' es verdadera, aunque hay un tipo separado Null en la especificación del lenguaje.


La expresión typeof v === 'objeto' es verdadera

Representación de valores


Los motores de JavaScript deberían poder representar cualquier valor de JavaScript en la memoria. Sin embargo, es importante tener en cuenta que los tipos de valores en JavaScript están separados de cómo los representan los motores JS en la memoria.

Por ejemplo, un valor de 42 en JavaScript es de tipo number .

 typeof 42; // 'number' 

Hay varias formas de representar enteros como 42 en la memoria:
Sumisión
Pedacitos
8 bits, además de dos
0010 1010
32 bits, con adición de hasta dos
0000 0000 0000 0000 0000 0000 0010 1010
Paquete decimal codificado en binario (BCD)
0100 0010
32 bits, número de coma flotante IEEE-754
0100 0010 0010 1000 0000 0000 0000 0000
64 bits, número de coma flotante IEEE-754
0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

Según el estándar ECMAScript, los números son valores de coma flotante de 64 bits, conocidos como números de coma flotante de doble precisión (Float64). Sin embargo, esto no significa que los motores de JavaScript siempre almacenen números en una vista Float64. ¡Eso sería muy, muy ineficiente! Los motores pueden usar otras representaciones internas de números, siempre que el comportamiento de los valores coincida exactamente con el comportamiento de los números Float64.

La mayoría de los números en aplicaciones JS reales, como resultó, son índices de matriz ECMAScript válidos. Es decir, enteros en el rango de 0 a 2 32 -2.

 array[0]; //      . array[42]; array[2**32-2]; //      . 

Los motores de JavaScript pueden elegir el formato óptimo para representar dichos valores en la memoria. Esto se hace para optimizar el código que funciona con elementos de matriz mediante índices. Un procesador que realiza operaciones de acceso a memoria necesita que los índices de la matriz estén disponibles como números almacenados en una vista con una suma de dos . Si, en cambio, representamos los índices de las matrices en forma de valores Float64, esto significaría un desperdicio de recursos del sistema, ya que el motor necesitaría convertir los números Float64 a un formato con la suma de dos y viceversa cada vez que alguien acceda a un elemento de la matriz.

La representación de números de 32 bits con la adición de hasta dos es útil no solo para optimizar el trabajo con matrices. En general, se puede observar que el procesador realiza operaciones enteras mucho más rápido que las operaciones que utilizan valores de punto flotante. Es por eso que en el siguiente ejemplo, el primer ciclo sin problemas es el doble de rápido en comparación con el segundo ciclo.

 for (let i = 0; i < 1000; ++i) {  //  } for (let i = 0.1; i < 1000.1; ++i) {  //  } 

Lo mismo se aplica a los cálculos que utilizan operadores matemáticos.

Por ejemplo, el desempeño del operador de tomar el resto de la división del siguiente fragmento de código depende de qué números estén involucrados en los cálculos.

 const remainder = value % divisor; //  -  `value`  `divisor`   , //    . 

Si ambos operandos están representados por enteros, entonces el procesador puede calcular el resultado de manera muy eficiente. Hay una optimización adicional en V8 para los casos en que el operando divisor está representado por un número que es una potencia de dos. Para los valores representados como números de coma flotante, los cálculos son mucho más complicados y toman mucho más tiempo.

Dado que las operaciones de enteros generalmente se realizan mucho más rápido que las operaciones en valores de punto flotante, podría parecer que los motores simplemente pueden almacenar todos los enteros y todos los resultados de las operaciones de enteros en un formato con una suma de dos. Desafortunadamente, este enfoque violaría la especificación ECMAScript. Como ya se mencionó, el estándar proporciona la representación de números en el formato Float64, y algunas operaciones con números enteros pueden dar lugar a la aparición de resultados en forma de números de punto flotante. Es importante que en tales situaciones, los motores JS produzcan resultados correctos.

 //  Float64   53-  . //         . 2**53 === 2**53+1; // true // Float64   ,   -1 * 0   -0,  //           . -1*0 === -0; // true // Float64   Infinity,   , //     . 1/0 === Infinity; // true -1/0 === -Infinity; // true // Float64    NaN. 0/0 === NaN; 

Aunque en el ejemplo anterior todos los números en el lado izquierdo de las expresiones son enteros, todos los números en el lado derecho de las expresiones son valores de coma flotante. Es por eso que ninguna de las operaciones anteriores se puede realizar correctamente utilizando un formato de 32 bits con una adición de hasta dos. Los motores de JavaScript deben prestar especial atención para garantizar que, al realizar operaciones con enteros, obtenga los resultados de Float64 correctos (aunque capaces de parecer inusuales, como en el ejemplo anterior).

En el caso de los enteros pequeños que caen dentro del rango de la representación de 31 bits de los enteros con signo, V8 usa una representación especial llamada Smi . Todo lo que no sea un valor Smi se representa como un valor HeapObject , que es la dirección de alguna entidad en la memoria. Para los números que no entran en el rango Smi , tenemos un tipo especial de HeapObject : el llamado HeapNumber .

 -Infinity // HeapNumber -(2**30)-1 // HeapNumber  -(2**30) // Smi       -42 // Smi        -0 // HeapNumber         0 // Smi       4.2 // HeapNumber        42 // Smi   2**30-1 // Smi     2**30 // HeapNumber  Infinity // HeapNumber       NaN // HeapNumber 

Como puede ver en el ejemplo anterior, algunos números JS se representan como Smi y otros como HeapNumber . El motor V8 está optimizado en términos de procesamiento de números Smi . El hecho es que los enteros pequeños son muy comunes en los programas JS reales. Cuando se trabaja con valores Smi , no es necesario asignar memoria para entidades individuales. Su uso, además, le permite realizar operaciones rápidas con enteros.

Comparación de Smi, HeapNumber y MutableHeapNumber


Hablemos de cómo se ve la estructura interna de estos mecanismos. Supongamos que tenemos el siguiente objeto:

 const o = {  x: 42, // Smi  y: 4.2, // HeapNumber }; 

El valor 42 de la propiedad del objeto x está codificado como Smi . Esto significa que se puede almacenar dentro del propio objeto. Para almacenar el valor 4.2, por otro lado, necesitará crear una entidad separada. En el objeto, habrá un enlace a esta entidad.


Almacenamiento de varios valores.

Supongamos que estamos ejecutando el siguiente código JavaScript:

 ox += 10; // ox   52 oy += 1; // oy   5.2 

En este caso, el valor de la propiedad x se puede actualizar en su ubicación de almacenamiento. El hecho es que el nuevo valor de x es 52, y este número se encuentra dentro del rango de Smi .


El nuevo valor de la propiedad x se almacena donde se almacenó el valor anterior.

Sin embargo, el nuevo valor de y , 5.2, no encaja en el rango de Smi y, además, difiere del valor anterior de y - 4.2. Como resultado, V8 tiene que asignar memoria para la nueva entidad HeapNumber y referenciarla desde el objeto ya.


Nueva entidad HeapNumber para almacenar el nuevo valor y

HeapNumber entidades HeapNumber son inmutables. Esto le permite implementar algunas optimizaciones. Supongamos que queremos establecer la propiedad del objeto x valor de la propiedad y :

 ox = oy; // ox   5.2 

Al realizar esta operación, podemos referirnos a la misma entidad HeapNumber y no asignar memoria adicional para almacenar el mismo valor.

Una de las desventajas de la inmunidad de las entidades HeapNuber es que la actualización frecuente de campos con valores fuera del rango Smi es lenta. Esto se demuestra en el siguiente ejemplo:

 //   `HeapNumber`. const o = { x: 0.1 }; for (let i = 0; i < 5; ++i) {  //    `HeapNumber`.  ox += 1; } 

Al procesar la primera línea, se crea una instancia de HeapNumber , cuyo valor inicial es 0.1. En el cuerpo del ciclo, este valor cambia a 1.1, 2.1, 3.1, 4.1 y finalmente a 5.1. Como resultado, en el proceso de ejecución de este código, HeapNumber 6 instancias de HeapNumber , cinco de las cuales estarán sujetas a operaciones de recolección de basura después de completar el ciclo.


HeapNumber Entities

Para evitar este problema, V8 tiene optimización, que es un mecanismo para actualizar campos numéricos cuyos valores no se ajustan al rango Smi en los mismos lugares donde ya están almacenados. Si un campo numérico almacena valores para los cuales la entidad Smi no Smi adecuada para el almacenamiento, entonces V8, en forma de objeto, marca este campo como Double y asigna memoria para la entidad MutableHeapNumber , que almacena el valor real representado en el formato Float64.


Uso de entidades MutableHeapNumber

Como resultado, después de que el valor del campo cambia, V8 ya no necesita asignar memoria para la nueva entidad HeapNumber . En cambio, simplemente escriba el nuevo valor en una entidad MutableHeapNumber existente.


Escribir un nuevo valor en MutableHeapNumber

Sin embargo, este enfoque tiene sus inconvenientes. Es decir, dado que los valores de MutableHeapNumber pueden cambiar, es importante asegurarse de que el sistema funcione de tal manera que estos valores se comporten según lo dispuesto en la especificación del lenguaje.


Desventajas de MutableHeapNumber

Por ejemplo, si asigna el valor de ox alguna otra variable y , entonces es necesario que el valor de y no cambie con un cambio posterior en ox . ¡Eso sería una violación de la especificación de JavaScript! Como resultado, al acceder a ox , el número debe volverse a empaquetar con el valor habitual de HeapNumber antes de que se le asigne y .

En el caso de los números de coma flotante, V8 realiza las operaciones de empaquetado anteriores utilizando sus mecanismos internos. Pero en el caso de los enteros pequeños, usar MutableHeapNumber sería una pérdida de tiempo, porque Smi es una forma más eficiente de representar tales números.

 const object = { x: 1 }; // ""  `x`    object.x += 1; //   `x`   

Para evitar el uso ineficiente de los recursos del sistema, todo lo que necesitamos hacer para trabajar con enteros pequeños es marcar los campos correspondientes en las formas de los objetos como Smi . Como resultado, los valores de estos campos, siempre que correspondan al rango Smi , se pueden actualizar directamente dentro de los objetos.


Trabaja con enteros cuyos valores caen dentro del rango de Smi

Continuará ...

Estimados lectores! ¿Ha encontrado problemas de rendimiento de JavaScript causados ​​por las características del motor JS?

Source: https://habr.com/ru/post/467247/


All Articles