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

Hoy publicamos la segunda parte de la traducción del material sobre los mecanismos internos de V8 y la investigación del problema de rendimiento de React.



La primera parte

Obsolescencia y migración de formas de objetos.


¿Qué pasa si el campo inicialmente contenía un Smi , y luego la situación cambió y necesitó almacenar un valor para el cual la representación Smi no Smi adecuada? Por ejemplo, como en el siguiente ejemplo, cuando dos objetos se representan usando la misma forma del objeto en el que x almacena inicialmente como Smi :

 const a = { x: 1 }; const b = { x: 2 }; //  `x`       `Smi` bx = 0.2; //  `bx`     `Double` y = ax; 

Al comienzo del ejemplo, tenemos dos objetos, para cuya representación usamos la misma forma del objeto en el que se Smi formato Smi para almacenar x .


La misma forma se utiliza para representar objetos.

Cuando la propiedad bx cambia y debe usar el formato Double para representarla, V8 asigna espacio de memoria para la nueva forma del objeto, en el que x se le asigna la representación Double , y que indica una forma vacía. V8 también crea una entidad MutableHeapNumber , que se usa para almacenar el valor 0.2 de la propiedad x . Luego actualizamos el objeto b para que se refiera a esta nueva forma y cambiemos la ranura en el objeto para que se MutableHeapNumber a la entidad MutableHeapNumber creada MutableHeapNumber en el desplazamiento 0. Finalmente, marcamos la forma antigua del objeto como obsoleta y la desconectamos del árbol transiciones Esto se hace creando una nueva transición para 'x' del formulario vacío al que acabamos de crear.


Consecuencias de asignar un nuevo valor a una propiedad de objeto

En este momento, no podemos eliminar completamente el formulario anterior, ya que todavía lo usa el objeto a . Además, será muy costoso omitir toda la memoria en la búsqueda de todos los objetos que se refieren al formulario anterior e inmediatamente actualizar el estado de estos objetos. En cambio, el V8 utiliza un enfoque "perezoso" aquí. Es decir, todas las operaciones de lectura o escritura de las propiedades del objeto a transfieren primero al uso de un nuevo formulario. La idea detrás de esta acción es hacer que la forma obsoleta del objeto sea inalcanzable. Esto hará que el recolector de basura se encargue de ello.


La memoria fuera de forma libera al recolector de basura

Las cosas son más complicadas en situaciones en las que el campo que cambia la vista no es el último en la cadena:

 const o = {  x: 1,  y: 2,  z: 3, }; oy = 0.1; 

En este caso, el V8 necesita encontrar la llamada forma dividida. Esta es la última forma de la cadena, ubicada antes de la forma en que aparece la propiedad correspondiente. Aquí cambiamos y , es decir, necesitamos encontrar la última forma en la que no hubo y . En nuestro ejemplo, esta es la forma en que aparece x .


Busque la última forma en la que no hubo cambio de valor

Aquí, comenzando con este formulario, creamos una nueva cadena de transición para y que reproduce todas las transiciones anteriores. Solo que ahora la propiedad 'y' se representará como Double . Ahora usamos esta nueva cadena de transición para y , marcándola como un viejo subárbol obsoleto. En el último paso, migramos la instancia del objeto o a un nuevo formulario, ahora usando la entidad MutableHeapNumber para almacenar el MutableHeapNumber y . Con este enfoque, el nuevo objeto no usará fragmentos del viejo árbol de transición y, después de que todas las referencias a la forma anterior hayan desaparecido, la parte obsoleta del árbol también desaparecerá.

Extensibilidad e integridad de transición


El Object.preventExtensions() permite evitar por completo la adición de nuevas propiedades a un objeto. Si procesa el objeto con este comando e intenta agregarle una nueva propiedad, se generará una excepción. (Es cierto que si el código no se ejecuta en modo estricto, no se generará una excepción, sin embargo, un intento de agregar una propiedad simplemente no tendrá consecuencias). Aquí hay un ejemplo:

 const object = { x: 1 }; Object.preventExtensions(object); object.y = 2; // TypeError: Cannot add property y; //      object is not extensible 

El método Object.seal() actúa sobre los objetos de la misma manera que Object.preventExtensions() , pero también marca todas las propiedades como no configurables. Esto significa que no se pueden eliminar, ni se pueden cambiar sus propiedades con respecto a las posibilidades de enumerarlas, configurarlas o reescribirlas.

 const object = { x: 1 }; Object.seal(object); object.y = 2; // TypeError: Cannot add property y; //      object is not extensible delete object.x; // TypeError: Cannot delete property x 

El método Object.freeze() realiza las mismas acciones que Object.seal() , pero su uso, además, lleva al hecho de que los valores de las propiedades existentes no se pueden cambiar. Se marcan como propiedades en las que no se pueden escribir nuevos valores.

 const object = { x: 1 }; Object.freeze(object); object.y = 2; // TypeError: Cannot add property y; //      object is not extensible delete object.x; // TypeError: Cannot delete property x object.x = 3; // TypeError: Cannot assign to read-only property x 

Considere un ejemplo específico. Tenemos dos objetos, cada uno de los cuales tiene un valor único x . Luego prohibimos la extensión del segundo objeto:

 const a = { x: 1 }; const b = { x: 2 }; Object.preventExtensions(b); 

El procesamiento de este código comienza con acciones que ya conocemos. Es decir, se realiza una transición desde la forma vacía del objeto a la nueva forma, que contiene la propiedad 'x' (representada como una entidad Smi ). Cuando prohibimos la expansión del objeto b , esto lleva a una transición especial a una nueva forma, que se marca como no expandible. Esta transición especial no conduce a la aparición de alguna nueva propiedad. Esto es, de hecho, solo un marcador.


El resultado de procesar un objeto utilizando el método Object.preventExtensions ()

Tenga en cuenta que no podemos simplemente cambiar la forma existente con el valor x , ya que lo necesita otro objeto, a saber, el objeto a , que todavía es expandible.

Reaccionar problema de rendimiento


Ahora recopilemos todo lo que hablamos y usemos el conocimiento que hemos adquirido para comprender la esencia del reciente problema de rendimiento de React. Cuando el equipo de React hizo un perfil de aplicaciones reales, notaron una extraña degradación en el rendimiento de V8 que actuó en el núcleo de React. Aquí hay una reproducción simplificada de la parte problemática del código:

 const o = { x: 1, y: 2 }; Object.preventExtensions(o); oy = 0.2; 

Tenemos un objeto con dos campos representados como entidades Smi . Prevenimos una mayor expansión del objeto y luego realizamos una acción que lleva al hecho de que el segundo campo debe estar representado en formato Double .

Ya hemos descubierto que la prohibición de la expansión de objetos conduce aproximadamente a la siguiente situación.


Consecuencias de la prohibición de expansión de objetos.

Para representar ambas propiedades del objeto, se Smi entidades Smi , y se necesita la última transición para marcar la forma del objeto como no extensible.

Ahora necesitamos cambiar la forma en que la propiedad y está representada por Double . Esto significa que debemos comenzar a buscar una forma de separación. En este caso, esta es la forma en que aparece la propiedad x . Pero ahora el V8 está confundido. El hecho es que la forma de separación era extensible, y la forma actual estaba marcada como no extensible. V8 no sabe cómo reproducir el proceso de transición en una situación similar. Como resultado, el motor simplemente se niega a tratar de resolverlo todo. En cambio, simplemente crea un formulario separado que no está conectado al árbol de formulario actual y no se comparte con otros objetos. Esto es algo así como una forma huérfana de un objeto.


Forma huérfana

Es fácil adivinar que esto, si esto sucede con muchos objetos, es muy malo. El hecho es que esto hace que todo el sistema de formas de objetos V8 sea inútil.

Cuando ocurrió un problema reciente de React, sucedió lo siguiente. Cada objeto de la clase FiberNode tenía campos destinados a almacenar marcas de tiempo cuando la creación de perfiles está habilitada.

 class FiberNode {  constructor() {    this.actualStartTime = 0;    Object.preventExtensions(this);  } } const node1 = new FiberNode(); const node2 = new FiberNode(); 

Estos campos (por ejemplo, actualStartTime ) se inicializaron a 0 o -1. Esto condujo al hecho de que Smi entidades Smi se usaban para representar sus significados Smi . Pero más tarde, guardaron sellos en tiempo real en el formato de números de punto flotante devueltos por el método performance.now (). Esto llevó al hecho de que estos valores ya no podían representarse en forma de Smi . Para representar estos campos, ahora se requerían entidades Double . Además de todo esto, React también evitó la expansión de instancias de la clase FiberNode .

Inicialmente, nuestro ejemplo simplificado podría presentarse de la siguiente forma.


Estado inicial del sistema

Hay dos instancias de la clase que comparten el mismo árbol de transiciones de la forma de los objetos. Estrictamente hablando, para esto está diseñado el sistema de formas de objetos en V8. Pero luego, cuando los sellos en tiempo real se almacenan en el objeto, V8 no puede entender cómo puede encontrar la forma de separación.


V8 está confundido

V8 asigna una nueva forma huérfana al node1 . Lo mismo sucede un poco más tarde con el objeto node2 . Como resultado, ahora tenemos dos formas "huérfanas", cada una de las cuales es utilizada por un solo objeto. En muchas aplicaciones reales de React, el número de tales objetos es mucho más que dos. Estos pueden ser decenas o incluso miles de objetos de la clase FiberNode . Es fácil entender que esta situación no afecta muy bien el rendimiento del V8.

Afortunadamente, solucionamos este problema en V8 v7.4 , y estamos explorando la posibilidad de hacer que la operación de cambiar la representación de campos de objetos requiera menos recursos. Esto nos permitirá resolver los problemas de rendimiento restantes que surgen en tales situaciones. V8, gracias a la solución, ahora se comporta correctamente en la situación del problema descrito anteriormente.


Estado inicial del sistema

Así es como se ve. Dos instancias de la clase FiberNode referencia a un formulario no extensible. En este caso, 'actualStartTime' se representa como un campo Smi . Cuando se realiza la primera operación de asignar un valor a la propiedad node1.actualStartTime , se crea una nueva cadena de transición y la cadena anterior se marca como obsoleta.


Resultados de asignar un nuevo valor a la propiedad Node1.actualStartTime

Tenga en cuenta que la transición a la forma no expandible ahora se reproduce correctamente en la nueva cadena. Esto es en lo que se mete el sistema después de cambiar el valor de node2.actualStartTime .


Los resultados de asignar un nuevo valor a la propiedad node2.actualStartTime

Después de asignar el nuevo valor a la propiedad node2.actualStartTime , ambos objetos hacen referencia al nuevo formulario y el recolector de basura puede destruir la parte obsoleta del árbol de transición.

Tenga en cuenta que las operaciones para marcar las formas de los objetos como obsoletas y su migración pueden parecer algo complicado. De hecho, tal como es. Sospechamos que en sitios web reales esto hace más daño (en términos de rendimiento, uso de memoria, complejidad) que bueno. Especialmente: después, en el caso de la compresión del puntero , ya no podemos usar este enfoque para almacenar campos Double en forma de valores incrustados en objetos. Como resultado, esperamos abandonar por completo el mecanismo de obsolescencia de las formas de objetos V8 y hacer que este mecanismo sea obsoleto.

Cabe señalar que el equipo React resolvió este problema por su cuenta, asegurándose de que los campos en los objetos de la clase FiberNodes inicialmente representados por valores Dobles:

 class FiberNode {  constructor() {    //     `Double`   .    this.actualStartTime = Number.NaN;    //       ,  :    this.actualStartTime = 0;    Object.preventExtensions(this);  } } const node1 = new FiberNode(); const node2 = new FiberNode(); 

Aquí, en lugar de Number.NaN , se puede Number.NaN cualquier valor de punto flotante que no se ajuste al rango Smi . Entre estos valores se encuentran 0.000001, Number.MIN_VALUE , -0 e Infinity .

Vale la pena señalar que el problema descrito en React era específico de V8, y que al crear algún código, los desarrolladores no necesitan esforzarse por optimizarlo en función de una versión específica de un determinado motor de JavaScript. Sin embargo, es útil poder arreglar algo optimizando el código en el caso de que las causas de algunos errores estén enraizadas en las características del motor.

Vale la pena recordar que en las entrañas de los motores JS hay muchas cosas increíbles. El desarrollador JS puede ayudar a todos estos mecanismos, si es posible sin asignar los mismos valores de variables de diferentes tipos. Por ejemplo, no debe inicializar campos numéricos como null , ya que esto negará todas las ventajas de observar la presentación del campo y mejorará la legibilidad del código:

 //   ! class Point {  x = null;  y = null; } const p = new Point(); px = 0.1; py = 402; 

En otras palabras, ¡escriba código legible y el rendimiento vendrá solo!

Resumen


En este artículo, examinamos los siguientes temas importantes:

  • JavaScript distingue entre valores "primitivos" y "objeto", y no se puede confiar en el tipo de resultados.
  • Incluso los valores que tienen el mismo tipo de JavaScript se pueden representar de diferentes maneras en las entrañas del motor.
  • V8 está tratando de encontrar la mejor manera de representar cada propiedad del objeto utilizado en los programas JS.
  • En ciertas situaciones, V8 realiza operaciones para marcar las formas de los objetos como obsoletos y realiza la migración de formas. Incluyendo: implementa transiciones asociadas con la prohibición de la expansión de objetos.

Con base en lo anterior, podemos proporcionar algunos consejos prácticos de programación de JavaScript que pueden ayudar a mejorar el rendimiento del código:

  • Siempre inicialice sus objetos de la misma manera. Esto contribuye al trabajo efectivo con formas de objetos.
  • Seleccione responsablemente los valores iniciales para los campos de objetos. Esto ayudará a los motores de JavaScript a elegir cómo representar internamente estos valores.

Estimados lectores! ¿Alguna vez ha optimizado su código basado en las características internas de ciertos motores de JavaScript?

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


All Articles