Pila de llamadas JavaScript y más magia





A principios de abril, se publicó en Habré el artículo "JavaScript: Pila de llamadas y la magia de su tamaño" . Su autor llegó a la conclusión de que cada marco de pila ocupa (72 + 8 * número de_variables_locales) bytes: "Resulta que hemos contado todo correctamente y podemos afirmar que el tamaño de un ExecutionStack vacío en Chrome es de 72 bytes, y el tamaño de la pila de llamadas es un poco menos de un megabyte. ¡Gran trabajo! "



Para la siembra: cambiemos ligeramente el código utilizado AxemaFr para experimentos:



{let i = 0;

const func = () => {
  i += 1.00000000000001;
  func();
};

try {
  func();
} catch (e) {
  console.log(i);
}}
      
      





En lugar de 1, ahora en cada paso agregamos un poco más y, como resultado, en lugar de 13951, obtenemos 12556.000000000002, ¡como si se hubiera agregado una variable local a la función!



Repitamos las preguntas hechas por Senior Frontend Developer AxemaFr: “¿Por qué es así? ¿Qué cambió? ¿Cómo entender, mirando la función, cuántas veces se puede ejecutar de forma recursiva? "



Herramientas de cocina



En la línea de comandos de Chrome, puede pasar argumentos al motor JS; en particular, puede cambiar el tamaño de la pila de 984 KB a cualquier otra clave --js-flags=--stack-size=



.



Para averiguar cuánta pila requiere cada función, la clave --print-bytecode



ya mencionada nos ayudará . No se mencionó que la salida de depuración se envía a stdout, que estúpidamente Chrome en Windows no tiene, porque está compilado como una aplicación GUI. Es fácil de arreglar: haga una copia de chrome.exe, y en su editor hexadecimal favorito corrija el byte 0xD4



del valor 0x02



a 0x03



(para aquellos que no son amigos del editor hexadecimal, este byte ayudará a corregir el script de Python). Pero si está leyendo este artículo en este momento en Chrome y simplemente ejecuta el archivo parcheado, digamos que lo nombró cui_chrome.exe, se abrirá una nueva ventana en una instancia de navegador existente y --js-flags



se ignorará el argumento . Para iniciar una nueva instancia de Chrome, debe pasarle una nueva --user-data-dir



:

cui_chrome.exe --no-sandbox --js-flags="--print-bytecode --print-bytecode-filter=func" --user-data-dir=\Windows\Temp





Sin, --print-bytecode-filter



se ahogará en volcados de código de bytes de funciones integradas en Chrome.



Después de iniciar el navegador, abra la consola del desarrollador e ingrese el código utilizado AxemaFr:



{let i = 0;

const func = () => {
  i++;
  func();
};

func()}
      
      





Antes de presionar Enter, aparecerá un volcado en la ventana de la consola detrás de Chrome:

[código de bytes generado para la función: func (0x44db08635355 <SharedFunctionInfo func>)]
Recuento de parámetros 1
Register count 1
Frame size 8
   36 S> 000044DB086355EE @    0 : 1a 02             LdaCurrentContextSlot [2]
         000044DB086355F0 @    2 : ac 00             ThrowReferenceErrorIfHole [0]
         000044DB086355F2 @    4 : 4d 00             Inc [0]
         000044DB086355F4 @    6 : 26 fa             Star r0
         000044DB086355F6 @    8 : 1a 02             LdaCurrentContextSlot [2]
   37 E> 000044DB086355F8 @   10 : ac 00             ThrowReferenceErrorIfHole [0]
         000044DB086355FA @   12 : 25 fa             Ldar r0
         000044DB086355FC @   14 : 1d 02             StaCurrentContextSlot [2]
   44 S> 000044DB086355FE @   16 : 1b 03             LdaImmutableCurrentContextSlot [3]
         000044DB08635600 @   18 : ac 01             ThrowReferenceErrorIfHole [1]
         000044DB08635602 @   20 : 26 fa             Star r0
   44 E> 000044DB08635604 @ 22: 5d fa 01 CallUndefinedReceiver0 r0, [1]
         000044DB08635607 @ 25: 0d LdaUndefined
   52 S> 000044DB08635608 @ 26: ab Volver
Pool constante (tamaño = 2)
Handler Table (tamaño = 0)
Tabla de posición de origen (tamaño = 12)


¿Cómo cambiará el volcado si se i++;



reemplaza la línea por i += 1.00000000000001;



?

[código de bytes generado para la función: func (0x44db0892d495 <SharedFunctionInfo func>)]
Recuento de parámetros 1
Registro de recuento 2
Tamaño de cuadro 16
   36 S> 000044DB0892D742 @ 0: 1a 02 LdaCurrentContextSlot [2]
         000044DB0892D744 @ 2: ac 00 ThrowReferenceErrorIfHole [0]
         000044DB0892D746 @ 4:26 fa Estrella r0
         000044DB0892D748 @ 6: 12 01 LdaConstant [1]
         000044DB0892D74A @    8 : 35 fa 00          Add r0, [0]
         000044DB0892D74D @   11 : 26 f9             Star r1
         000044DB0892D74F @   13 : 1a 02             LdaCurrentContextSlot [2]
   37 E> 000044DB0892D751 @   15 : ac 00             ThrowReferenceErrorIfHole [0]
         000044DB0892D753 @   17 : 25 f9             Ldar r1
         000044DB0892D755 @   19 : 1d 02             StaCurrentContextSlot [2]
   60 S> 000044DB0892D757 @   21 : 1b 03             LdaImmutableCurrentContextSlot [3]
         000044DB0892D759 @   23 : ac 02             ThrowReferenceErrorIfHole [2]
         000044DB0892D75B @   25 : 26 fa             Star r0
   60 E> 000044DB0892D75D @   27 : 5d fa 01          CallUndefinedReceiver0 r0, [1]
         000044DB0892D760 @   30 : 0d                LdaUndefined
   68 S> 000044DB0892D761 @ 31: ab Volver
Pool constante (tamaño = 3)
Handler Table (tamaño = 0)
Tabla de posición de origen (tamaño = 12)


Ahora averigüemos qué ha cambiado y por qué.



Explorando ejemplos



Todos los códigos de operación V8 se describen en github.com/v8/v8/blob/master/src/interpreter/interpreter-generator.cc

El primer volcado se decodifica así:

LdaCurrentContextSlot [2]; a: = contexto [2]
ThrowReferenceErrorIfHole [0]; si (a === indefinido)
                                    ; throw ("ReferenceError:% s no está definido", const [0])
Inc [0]; a ++
Star r0; r0: = a
LdaCurrentContextSlot [2]; a: = contexto [2]
ThrowReferenceErrorIfHole [0]; si (a === indefinido)
                                    ; throw ("ReferenceError:% s no está definido", const [0])
Ldar r0; a: = r0
StaCurrentContextSlot [2]           ; context[2] := a
LdaImmutableCurrentContextSlot [3]  ; a := context[3]
ThrowReferenceErrorIfHole [1]       ; if (a === undefined)
                                    ;   throw("ReferenceError: %s is not defined", const[1])
Star r0                             ; r0 := a
CallUndefinedReceiver0 r0, [1]      ; r0()
LdaUndefined                        ; a := undefined
Return


El último argumento codifica Inc



y CallUndefinedReceiver0



establece el espacio de retroalimentación, en el que el optimizador recopila estadísticas sobre los tipos utilizados. Esto no afecta la semántica del bytecode, por lo que hoy no nos interesa en absoluto.



Debajo del volcado hay una posdata: "Grupo constante (tamaño = 2)" - y de hecho vemos que el código de bytes usa dos líneas - "i"



y "func"



- para la sustitución en el mensaje de excepción cuando los símbolos con tales nombres no están definidos. Hay una posdata encima del volcado: "Tamaño de marco 8", de acuerdo con el hecho de que la función usa un registro de intérprete ( r0



).



El marco de pila de nuestra función consta de:



  • argumento único this



    ;
  • direcciones de retorno;
  • el número de argumentos pasados ​​( arguments.length



    );
  • referencias al grupo constante con cadenas usadas;
  • enlaces al contexto con variables locales;
  • tres punteros más que necesita el motor; y finalmente
  • espacio para un registro.


Total 9 * 8 = 72 bytes, como signor AxemaFry averiguado.



De los siete términos enumerados, en teoría, tres pueden cambiar: el número de argumentos, la presencia de un grupo constante y el número de registros. ¿Qué obtuvimos en la variante con 1.00000000000001?



LdaCurrentContextSlot [2]; a: = contexto [2]
ThrowReferenceErrorIfHole [0]; si (a === indefinido)
                               ; throw ("ReferenceError:% s no está definido", const [0])
Star r0; r0: = a
LdaConstant [1]; a: = constante [1]
Agregar r0, [0]; a + = r0
Star r1; r1: = a
                               ; ... más lejos como antes


Primero, la constante agregada ocupó el tercer lugar en el grupo de constantes; en segundo lugar, se necesitaba un registro más para cargarlo, por lo que el marco de pila de la función creció en 8 bytes.



Si no usa símbolos con nombre en la función, puede prescindir del grupo constante. En github.com/v8/v8/blob/master/src/execution/frame-constants.h#L289 describió el formato de marco de pila V8 y declaró que cuando el grupo constante no está en uso, el tamaño del marco de pila se reduce en un puntero . ¿Cómo puedes estar seguro de esto? A primera vista, parece que una función que no usa símbolos con nombre no puede ser recursiva; pero echa un vistazo:



{let i = 0;

function func() {
  this()();
};

const helper = () => (i++, func.bind(helper));

try {
  helper()();
} catch (e) {
  console.log(i);
}}
      
      





[generated bytecode for function: func (0x44db0878e575 <SharedFunctionInfo func>)]
Parameter count 1
Register count 1
Frame size 8
   37 S> 000044DB0878E8DA @    0 : 5e 02 02 00       CallUndefinedReceiver1 <this>, <this>, [0]
         000044DB0878E8DE @    4 : 26 fa             Star r0
   43 E> 000044DB0878E8E0 @    6 : 5d fa 02          CallUndefinedReceiver0 r0, [2]
         000044DB0878E8E3 @    9 : 0d                LdaUndefined
   47 S> 000044DB0878E8E4 @   10 : ab                Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 8)


Se ha logrado el objetivo: "Grupo constante (tamaño = 0)"; pero el desbordamiento de la pila, como antes, se produce a través de llamadas 13951. Esto significa que incluso cuando no se usa el grupo constante, el marco de pila de la función todavía contiene un puntero a él.



¿Es posible lograr un tamaño de marco de pila más pequeño que el calculado? AxemaFr¿valor mínimo? - sí, si no se usa ningún registro dentro de la función:

{function func() {
  this();
};

let chain = ()=>null;
for(let i=0; i<15050; i++)
  chain = func.bind(chain);

chain()}
      
      





[código de bytes generado para la función: func (0x44db08c34059 <SharedFunctionInfo func>)]
Recuento de parámetros 1
Número de registros 0
Tamaño de cuadro 0
   25 S> 000044DB08C34322 @ 0: 5d 02 00 CallUndefinedReceiver0 <esto>, [0]
         000044DB08C34325 @ 3: 0d LdaUndefined
   29 S> 000044DB08C34326 @ 4: ab Regresar
Pool constante (tamaño = 0)
Handler Table (tamaño = 0)
Tabla de posición de origen (tamaño = 6)


(En este caso, una cadena de llamadas de 15051 ya conduce a "RangeError: Tamaño máximo pila de llamadas excedido".)



Por lo tanto, la conclusión de la signor AxemaFrque "el tamaño de un ExecutionStack vacío en Chrome es de 72 bytes" ha sido refutado con éxito.



Refinando predicciones



Podemos argumentar que el tamaño mínimo del marco de pila para una función JS en Chrome es de 64 bytes. A esto, debe agregar 8 bytes por cada parámetro formal declarado, otros 8 bytes por cada parámetro real que exceda el número de los declarados y otros 8 bytes por cada registro utilizado. Se asigna un registro para cada variable local, para cargar constantes, para acceder a variables desde un contexto externo, para pasar parámetros reales durante las llamadas, etc. Difícilmente es posible determinar el número exacto de registros usados ​​a partir del código fuente en JS. Vale la pena señalar que el intérprete JS admite un número ilimitado de registros; no están relacionados con los registros del procesador en el que se ejecuta el intérprete.



Ahora está claro por qué:
  • (func = (x) => { i++; func(); };



    ) , ;
  • (func = () => { i++; func(1); };



    ) , — :
    [generated bytecode for function: func (0x44db08e12da1 <SharedFunctionInfo func>)]
    Parameter count 1
    Register count 2
    Frame size 16
       34 S> 000044DB08E12FE2 @    0 : 1a 02             LdaCurrentContextSlot [2]
             000044DB08E12FE4 @    2 : ac 00             ThrowReferenceErrorIfHole [0]
             000044DB08E12FE6 @    4 : 4d 00             Inc [0]
             000044DB08E12FE8 @    6 : 26 fa             Star r0
             000044DB08E12FEA @    8 : 1a 02             LdaCurrentContextSlot [2]
       35 E> 000044DB08E12FEC @   10 : ac 00             ThrowReferenceErrorIfHole [0]
             000044DB08E12FEE @   12 : 25 fa             Ldar r0
             000044DB08E12FF0 @   14 : 1d 02             StaCurrentContextSlot [2]
       39 S> 000044DB08E12FF2 @   16 : 1b 03             LdaImmutableCurrentContextSlot [3]
             000044DB08E12FF4 @   18 : ac 01             ThrowReferenceErrorIfHole [1]
             000044DB08E12FF6 @   20 : 26 fa             Star r0
             000044DB08E12FF8 @   22 : 0c 01             LdaSmi [1]
             000044DB08E12FFA @   24 : 26 f9             Star r1
       39 E> 000044DB08E12FFC @   26 : 5e fa f9 01       CallUndefinedReceiver1 r0, r1, [1]
             000044DB08E13000 @   30 : 0d                LdaUndefined
       48 S> 000044DB08E13001 @   31 : ab                Return
    Constant pool (size = 2)
    Handler Table (size = 0)
    Source Position Table (size = 12)
  • 1.00000000000001 — r1



    , .







All Articles