Saltar al contenido

VM de bytecode

La ruta de ejecución predeterminada es un recorrido del árbol: visitar cada nodo AST y devolver un valor. En las funciones muy utilizadas, que se llaman millones de veces, el costo de despacho por nodo es el cuello de botella. Phasis añade un compilador especulativo de bytecode a cierres de PHP para reducir ese costo.

Cuándo entra en acción la VM

Cada JsFunction tiene un contador de llamadas frecuentes. Cuando supera un umbral (100 de forma predeterminada), el motor intenta compilar el cuerpo de la función en un cierre de PHP mediante src/Bytecode/JsToPhp.php.

La compilación tiene éxito cuando el cuerpo de la función se ajusta a un conjunto conocido de formas:

  • Asignaciones de variables locales con tipos predecibles (numérico, objeto, función, matriz, cadena).
  • Operaciones aritméticas con números.
  • Lecturas de miembros en variables locales de tipo objeto Y en this (this.v, obj.x).
  • Llamadas a métodos en variables locales de tipo objeto (obj.method(args)), con el receptor transmitido como $thisValue.
  • Llamadas al constructor new Ctor(args): promueve el tipo de la variable local del lado izquierdo a object y protege con Bailout los retornos que no sean objetos.
  • Bucles for / while con formas de condición simples.
  • return con una expresión computable.
  • if / else con condiciones tipadas.

La compilación falla (y la función permanece en el recorrido del árbol) cuando el cuerpo contiene:

  • Instrucciones with.
  • try / catch / finally (lo gestiona el recorrido del árbol para garantizar la semántica correcta de finalización).
  • Llamadas eval().
  • Mutación directa de arguments.
  • Cualquier elemento que el compilador aún no sepa convertir a una forma más baja.

Cuando la compilación tiene éxito, el motor cambia la ruta de llamada de la función al cierre compilado. Las llamadas posteriores ejecutan PHP nativo con el costo de una llamada a función, en lugar de uno por cada nodo AST.

Abandonos

La ruta rápida compilada parte de ciertas suposiciones (los números siguen siendo números y las propiedades conservan la misma forma). Si una suposición se incumple durante la ejecución, el cierre lanza un Bailout y la ejecución se reinicia en el recorrido del árbol para esa llamada.

Aquí es donde más importa la corrección de la especificación. El recorrido del árbol debe poder volver a ejecutar desde cero la llamada que provocó el abandono sin observar efectos secundarios de la ruta rápida fallida. Phasis lo gestiona de dos maneras:

  1. Prefijos puros — el compilador busca efectos secundarios antes de cualquier especulación. Si la función realiza operaciones de E/S (console.log, funciones del host) antes del punto de especulación, la ruta rápida no las omite: las confirma y continúa.
  2. Rechazo en tiempo de compilación — para formas que exigirían revertir efectos secundarios observables (una asignación let r = fn() cuando fn() modifica el estado), el compilador se niega a compilar. La función permanece en el recorrido del árbol.

El caso que motivó esto: una asignación no numérica ejecuta fn() en la ruta rápida, lanza Bailout y luego se vuelve a ejecutar en el recorrido del árbol, con lo que fn() se ejecuta dos veces y produce dos conjuntos de efectos secundarios. El rechazo en tiempo de compilación impide que fn() se ejecute en la ruta rápida, así que el recorrido del árbol observa una sola llamada.

Despacho de la VM (la segunda capa)

Algunas funciones se compilan en cierres de PHP; otras recurren a un intérprete de bytecode aún más sencillo en src/Bytecode/VM.php. El bytecode es una matriz plana de opcodes (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …) que consume un despachador switch.

La VM existe para las funciones que no vale la pena compilar en un cierre de PHP, pero que se usan lo bastante como para beneficiarse de omitir el recorrido del AST. Las llamadas a métodos en objetos integrados (Array.prototype.map, String.prototype.split, …) siguen rutas rápidas de la VM que evitan el trampolín genérico de callFunction.

Pila de llamadas personalizada

Op::CALL, Op::CALL_METHOD y Op::NEW_CALL usan una ruta de llamada en línea que mantiene toda la pila de llamadas de JS dentro de VM::execute, sin crear un nuevo marco de PHP por cada llamada a JS. Al entrar, el estado del llamador (pc, flujo de código, pila de operandos, variables locales, entorno, this y marca de modo estricto) se guarda en un grupo de instancias SavedFrame de savedFrames por VM, y el bucle de despacho redirige sus variables locales a la función llamada. Op::RET extrae el marco guardado y restaura el estado. Las excepciones se propagan por los marcos guardados antes de volver al recorrido del árbol.

La elegibilidad se memoriza en JsFunction: vmInlineCallCache para llamadas normales (cuerpo compilado en BC, no enlazado a bind(), no nativo); vmInlineConstructCache para expresiones de new (además, isConstructable, no derivadas, sin inicializadores de campos de instancia ni de métodos privados). Todo lo demás sigue pasando por el trampolín canónico callFunction / vmNewExpression.

La ventaja arquitectónica adicional: el límite de la pila de PHP ya no restringe la profundidad de recursión de JS. Ahora CallStack::maxDepth es el único límite: está establecido en 4 096, muy por encima de lo necesario para cualquier caso legítimo de test262 (la mayoría requiere menos de 2 k marcos) y es suficientemente estricto para que las pruebas patológicas de recursión infinita fallen rápido, dentro del tiempo límite del ejecutor. La ruta de cierres transpilados (JsToPhp) tiene su propia protección de 8 marcos de 192 para la alternativa que no está integrada.

Generadores con instantáneas de marcos

Los cuerpos de generador sencillos —sin yield*, sin yield dentro de try/catch/finally, sin await— se compilan a bytecode. El compilador emite un nuevo opcode Op::YIELD donde se suspende el cuerpo; Op::YIELD captura el estado del despacho (cf, pc+1, pila, sp, variables locales, entorno, thisValue, strict) en un objeto de montón GeneratorSnapshot y devuelve un valor centinela YieldResult desde VM::execute. El JsGenerator que lo ejecuta almacena la instantánea y se la devuelve a VM::execute en la siguiente llamada a next() / throw(), sustituyendo el valor de reanudación en la parte superior de la pila de operandos.

Esto sustituye la ruta de Fiber de PHP para el subconjunto compatible. El controlador basado en Fiber (executeGeneratorBody) sigue en uso para las formas que el compilador rechaza: delegación de yield*, generadores que producen valores dentro de un bloque try, generadores asíncronos y funciones asíncronas con await. JsGenerator funciona en ambos modos: cada next() / throw() / return() comprueba si la instancia tiene una instantánea o un Fiber y actúa en consecuencia.

No hay ningún parámetro ajustable con pruebas de rendimiento; la elegibilidad se decide en tiempo de compilación y se memoriza en JsFunction. La ventaja es un cambio de contexto PHP menos por cada valor producido en la ruta de instantáneas; la ruta de Fiber no cambia para los demás casos.

Cachés en línea

Op::LOAD_MEMBER tiene una caché en línea por PC, basada en la identidad del prototipo y usada para el patrón de método del prototipo en una instancia (c.sum(...), arr.push(...) y todos los despachos de métodos de clase). Se considera un acierto si el receptor no tiene un espacio propio para el nombre, la referencia al prototipo en caché es idéntica a la de getPrototype() del receptor y PropertyMap::$version del prototipo aún coincide con la versión capturada.

La invalidación de caché se basa en PropertyMap::$version. Las mutaciones estructurales (asignación / eliminación) lo incrementan siempre. Las sobrescrituras de valores de espacios de datos lo incrementan solo cuando PropertyMap::$isUsedAsProto es verdadero: una marca de barrera de escritura que se activa la primera vez que se observa el objeto como [[Prototype]] de otro objeto. Los objetos de instancia que nunca sirven como prototipos no pagan el costo de la infraestructura de la caché en línea en cada obj.x = v.

Los accesores getter nunca se almacenan en caché (deben volver a ejecutarse en cada lectura).

PropertyMap tiene un segundo contador, $mutationVersion, que se incrementa siempre en cada escritura (sin barrera de escritura). La caché JSON.stringify fija este contador en cada objeto o matriz anidado que visita, para que una mutación exclusiva de la instancia, como o.a = 99, invalide correctamente la cadena almacenada en caché, incluso cuando $version de la caché en línea habría omitido el incremento.

Caché de JSON.stringify

JsObject almacena $jsonCacheString + $jsonCacheVersions (una lista de pares [obj, mutationVersion] para cada objeto anidado visitado durante el recorrido de conversión trivial a cadena). En la siguiente llamada a JSON.stringify(o) sin función de reemplazo ni espacio, la ruta rápida trivial comprueba que todas las versiones registradas siguen iguales y devuelve la cadena almacenada sin volver a recorrer ni codificar. Los patrones frecuentes de clonación profunda, como JSON.parse(JSON.stringify(o)), aceleran aproximadamente 2× la parte de conversión a cadena.

La caché se descarta silenciosamente si no hay coincidencia (cualquier versión registrada es distinta) y se vuelve a llenar en el siguiente recorrido. Los objetos con toJSON, getters, accesores, símbolos o espacios [[PrimitiveValue]] pasan a la serialización conforme a la especificación y nunca llenan la caché; por eso, la ruta en caché solo procesa datos estructuralmente uniformes.

Rutas rápidas para funciones integradas

Las mayores mejoras de rendimiento se lograron enseñando a la VM a reconocer funciones integradas específicas:

  • Atomics.load / store / compareExchange con planificación cooperativa de loadSpinHook / storeNotifyHook: recuperaron 4 pruebas de bucle de espera de Atomics.
  • Date.prototype.getTimezoneOffset con caché DST de mapa hash directo: mejora de velocidad del 45 % en pruebas de estrés DST de SpiderMonkey.
  • String.fromCharCode con una ruta rápida ASCII integrada: ahorra ~1 M de despachos por ejecución típica.
  • decodeURI / decodeURIComponent con una ruta ultrarrápida de 12 caracteres %XX%XX%XX%XX que usa decodificación masiva de hex2bin.
  • Object.keys / Object.values / Object.entries con acceso directo al mapa de propiedades.
  • Array.prototype.map / filter / forEach / reduce con iteración nativa sobre la matriz PHP subyacente.

Cada ruta rápida está protegida por una «comprobación de forma»: si el receptor no coincide con la forma esperada (e.g, una vista TypedArray que no está respaldada por un PHP string simple), la VM recurre a la ruta lenta que cumple la especificación.

Lo que esto no hace

La VM de bytecode no es un JIT en el sentido de V8. No genera código máquina nativo por función ni especula sobre la base de tipos observados, más allá de la comprobación monomórfica de identidad del prototipo de la caché en línea. La integración en los límites entre funciones se aproxima mediante la pila de llamadas personalizada (sin marco PHP adicional por llamada a JS), en lugar de convertir el cuerpo de la función llamada a bytecode de la función llamadora.

El transpilador JsToPhp es lo más parecido a un JIT que tiene el motor: genera código fuente PHP para cuerpos de funciones muy utilizados que el JIT de trazado de la versión 8.5 de PHP compila después a código nativo. JsToPhp pasa por eval(), así que paga un costo de compilación inicial y el JIT de trazado tiene que detectar la función como si fuera código de usuario. Omitir el paso eval() emitiendo directamente opcodes de opcache reduciría aún más la diferencia; consulta Hoja de ruta de pruebas de rendimiento.

Pruebas de rendimiento

bench/microbench.js ejecuta más de una docena de pruebas pequeñas (loop-arith, loop-fib, fn-recurse, fn-deep-recurse, obj-create, obj-prop, proto-method, gen-iterate, arr-push, arr-map, str-concat, str-split-join, json-roundtrip, closure, destructure). Tiempo de ejecución mediano:

Shell
php bench/run.php

Los resultados actuales se guardan en BENCH.md después de cada ejecución del flujo de trabajo bench. Los flujos de trabajo de bench y de la matriz test262 se inician manualmente.