Aller au contenu

VM à bytecode

Le chemin d’exécution par défaut est un interpréteur arborescent : il visite chaque nœud de l’AST et renvoie une valeur. Pour les fonctions très sollicitées, appelées des millions de fois, le surcoût du dispatch par nœud devient le goulot d’étranglement. Phasis ajoute par-dessus un compilateur spéculatif qui convertit le bytecode en closures PHP afin de réduire ce surcoût.

Quand la VM se met en marche

Chaque JsFunction possède un compteur d’appels à chaud. Lorsqu’il dépasse un seuil (100 par défaut), le moteur tente de compiler le corps de la fonction en closure PHP via src/Bytecode/JsToPhp.php.

La compilation réussit lorsque le corps de la fonction correspond à un ensemble connu de formes :

  • Affectations de variables locales dont les types sont prévisibles (numérique, objet, fonction, tableau, chaîne).
  • Opérations arithmétiques sur des nombres.
  • Lectures de membres sur des variables locales de type objet ET sur this (this.v, obj.x).
  • Appels de méthodes sur des variables locales de type objet (obj.method(args)), avec transmission du récepteur sous la forme $thisValue.
  • Appels de constructeur new Ctor(args) — le type de la variable locale LHS devient object et un garde d’abandon vérifie que la valeur renvoyée n’est pas un objet.
  • Boucles for / while avec des conditions simples.
  • return avec une expression calculable.
  • if / else sur des conditions typées.

La compilation échoue (et la fonction reste exécutée par l’interpréteur arborescent) si le corps contient :

  • Des instructions with.
  • try / catch / finally (pris en charge par l’interpréteur arborescent pour respecter la sémantique d’achèvement).
  • Des appels eval().
  • Une modification directe de arguments.
  • Toute construction que le compilateur ne sait pas encore convertir.

Lorsque la compilation réussit, le moteur remplace le chemin d’appel de la fonction par la closure compilée. Les appels suivants exécutent du code natif PHP avec le surcoût d’un seul appel de fonction, au lieu d’un surcoût par nœud de l’AST.

Abandons

Le chemin rapide compilé repose sur des hypothèses (les nombres restent des nombres, les propriétés conservent la même forme). Si une hypothèse est invalidée à l’exécution, la closure déclenche un Bailout et l’exécution reprend dans l’interpréteur arborescent pour cet appel.

C’est là que la conformité à la spécification est essentielle. L’interpréteur arborescent doit pouvoir réexécuter depuis le début l’appel qui a déclenché l’abandon, sans observer d’effets de bord du chemin rapide ayant échoué. Phasis gère cela de deux façons :

  1. Préfixes purs — le compilateur recherche les effets de bord avant toute spéculation. Si la fonction effectue des E/S (console.log, fonctions hôtes) avant le point de spéculation, le chemin rapide ne les supprime pas : il valide les opérations et poursuit l’exécution.
  2. Refus à la compilation — pour les formes qui nécessiteraient d’annuler des effets de bord observables (une affectation let r = fn() non numérique où fn() modifie l’état), le compilateur refuse toute compilation. La fonction reste exécutée par l’interpréteur arborescent.

Cas à l’origine de cette approche : une affectation non numérique exécute fn() sur le chemin rapide, déclenche Bailout, puis est réexécutée par l’interpréteur arborescent — fn() s’exécute alors deux fois, avec deux séries d’effets de bord. Le refus à la compilation empêche fn() de s’exécuter sur le chemin rapide, si bien que l’interpréteur arborescent ne voit qu’un seul appel.

Dispatch de la VM (la deuxième couche)

Certaines fonctions sont compilées en closures PHP ; les autres passent par un interpréteur de bytecode encore plus simple dans src/Bytecode/VM.php. Le bytecode est un tableau plat d’opcodes (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …) exécutés par un dispatch switch.

La VM sert aux fonctions qui ne justifient pas une compilation en closure PHP, mais sont assez sollicitées pour profiter de l’absence de parcours de l’AST. Dans la VM, les appels de méthodes sur des objets intégrés (Array.prototype.map, String.prototype.split, …) empruntent des chemins rapides qui contournent le trampoline générique callFunction.

Pile d’appels personnalisée

Op::CALL, Op::CALL_METHOD et Op::NEW_CALL empruntent un chemin d’appel intégré qui conserve toute la pile d’appels JS dans VM::execute : aucune nouvelle frame PHP par appel JS. À l’entrée, l’état de l’appelant (pc, flux de code, pile des opérandes, variables locales, env, this, indicateur de mode strict) est empilé dans un pool savedFrames par VM d’instances SavedFrame, et la boucle de dispatch redirige ses variables locales vers celles de l’appelé. Op::RET dépile la frame sauvegardée et restaure l’état. Les exceptions remontent à travers les frames sauvegardées avant de revenir à l’interpréteur arborescent.

L’éligibilité est mémorisée dans JsFunction : vmInlineCallCache pour les appels ordinaires (corps compilé en bytecode, sans liaison avec bind(), non natif), vmInlineConstructCache pour les expressions new (avec en plus isConstructable, non dérivé, sans initialiseur de champ d’instance ni de méthode privée). Les autres cas passent toujours par le trampoline canonique callFunction / vmNewExpression.

Avantage architectural supplémentaire : la limite de pile PHP ne plafonne plus la profondeur de récursion de JS. CallStack::maxDepth est désormais la seule limite — fixée à 4 096, bien au-delà des cas légitimes de test262 (la plupart nécessitent moins de 2 k frames) et assez basse pour que les tests de récursion infinie pathologiques échouent rapidement, dans le délai d’attente de l’exécuteur. Le chemin des closures transpilées (JsToPhp) dispose de son propre garde-fou de 8 frames 192 pour le repli non intégré.

Générateurs à instantané de frame

Les corps de générateur simples — sans yield*, sans yield dans un bloc try/catch/finally, sans await — sont compilés en bytecode. Le compilateur émet un nouvel opcode Op::YIELD à l’endroit où le corps suspend son exécution ; Op::YIELD capture l’état du dispatch (cf, pc+1, pile, sp, variables locales, env, thisValue, strict) dans un objet GeneratorSnapshot du tas et renvoie un marqueur sentinelle YieldResult depuis VM::execute. Le pilote JsGenerator stocke l’instantané et le transmet à VM::execute au prochain next() / throw(), en remplaçant la valeur de reprise au sommet de la pile des opérandes.

Cela remplace le chemin Fiber PHP pour le sous-ensemble pris en charge. Le pilote fondé sur Fiber (executeGeneratorBody) reste utilisé pour les formes rejetées par le compilateur : délégation yield*, générateurs qui produisent une valeur dans un bloc try, générateurs asynchrones, fonctions asynchrones avec await. JsGenerator fonctionne dans les deux modes : à chaque next() / throw() / return(), il vérifie si l’instance possède un instantané ou une Fiber et effectue le dispatch en conséquence.

Aucun paramètre ajustable par benchmark : l’éligibilité est déterminée à la compilation et mémorisée dans JsFunction. Le gain correspond à un changement de contexte PHP en moins par valeur produite dans le chemin par instantané ; le chemin Fiber reste inchangé pour tous les autres cas.

Caches inline

Op::LOAD_MEMBER possède un cache inline par PC, indexé par identité du prototype, pour le cas d’une méthode de prototype appelée sur une instance (c.sum(...), arr.push(...), chaque dispatch de méthode de classe). Le cache est utilisé si le récepteur n’a pas de propriété propre portant ce nom, si la référence de prototype mise en cache est identique à celle de getPrototype() du récepteur, et si le PropertyMap::$version du prototype correspond toujours à la version enregistrée.

L’invalidation du cache repose sur PropertyMap::$version. Les mutations structurelles (définition ou suppression) l’incrémentent systématiquement. Les remplacements de valeur d’un emplacement de données ne l’incrémentent que si PropertyMap::$isUsedAsProto vaut vrai — un indicateur de barrière d’écriture activé dès que l’objet est observé pour la première fois comme [[Prototype]] d’un autre objet. Les objets d’instance qui ne servent jamais de prototypes n’ont aucun coût lié à l’infrastructure de cache inline à chaque obj.x = v.

Les accesseurs getter ne sont jamais mis en cache (ils doivent être réexécutés à chaque lecture).

PropertyMap possède un second compteur, $mutationVersion, qui s’incrémente systématiquement à chaque écriture (sans barrière d’écriture). Le cache JSON.stringify épingle ce compteur pour chaque objet ou tableau imbriqué visité afin qu’une mutation d’une instance seulement, comme o.a = 99, invalide bien la chaîne mise en cache même lorsque le $version du cache IC aurait omis l’incrément.

Cache JSON.stringify

JsObject conserve $jsonCacheString + $jsonCacheVersions (une liste de paires [obj, mutationVersion] pour chaque objet imbriqué rencontré lors du parcours trivial de stringify). Au prochain appel JSON.stringify(o) sans fonction de remplacement ni indentation, le chemin rapide trivial vérifie que chaque version épinglée est inchangée, puis renvoie la chaîne en cache sans nouveau parcours ni encodage. Les schémas courants de clonage profond, comme JSON.parse(JSON.stringify(o)), accélèrent d’environ 2× la partie stringify.

En cas d’échec (une version épinglée a changé), le cache est supprimé sans notification et rempli de nouveau lors du prochain parcours. Les objets avec toJSON, getters, accesseurs, symboles ou emplacements [[PrimitiveValue]] passent par le sérialiseur conforme à la spécification et ne remplissent jamais le cache : le chemin mis en cache ne traite donc que des données uniformes sur le plan structurel.

Chemins rapides intégrés

Les gains de performances les plus importants sont venus de l’ajout de chemins rapides spécifiques à certains éléments intégrés dans la VM :

  • Atomics.load / store / compareExchange avec planification coopérative loadSpinHook / storeNotifyHook — quatre tests de boucles d’attente Atomics récupérés.
  • Date.prototype.getTimezoneOffset avec un cache DST fondé directement sur une hashmap — gain de vitesse de 45 % sur les tests de résistance DST de SpiderMonkey.
  • String.fromCharCode avec un chemin rapide ASCII intégré — environ 1 M de dispatchs économisés par parcours type.
  • decodeURI / decodeURIComponent avec un chemin ultra-rapide %XX%XX%XX%XX de 12 caractères, utilisant le décodage groupé hex2bin.
  • Object.keys / Object.values / Object.entries avec accès direct à la map des propriétés.
  • Array.prototype.map / filter / forEach / reduce avec itération native sur le tableau PHP sous-jacent.

Chaque chemin rapide est soumis à une « vérification de forme » : si le récepteur ne correspond pas à la forme attendue (e.g, une vue TypedArray qui ne repose pas sur un PHP string simple), la VM revient au chemin lent conforme à la spécification.

Ce que cela ne fait pas

La VM à bytecode n’est pas un JIT au sens de V8. Elle ne génère pas de code machine natif pour chaque fonction et ne spécule pas sur la base des types observés, au-delà du contrôle monomorphe d’identité de prototype du cache IC. L’intégration aux frontières des fonctions est simulée par la pile d’appels personnalisée (aucune frame PHP supplémentaire par appel JS), plutôt que par la conversion du corps de l’appelé en bytecode de l’appelant.

Le transpileur JsToPhp est ce qui se rapproche le plus d’un JIT dans le moteur : il génère du code source PHP pour les corps des fonctions à chaud, que le JIT de traçage de PHP 8.5 compile ensuite en code natif. JsToPhp passe par eval(), ce qui entraîne un coût de compilation initial ; le JIT de traçage doit ensuite découvrir la fonction comme s’il s’agissait de code utilisateur. Éviter l’étape eval() en émettant directement des opcodes opcache réduirait davantage l’écart — voir Benchmarks § Feuille de route.

Benchmarks

bench/microbench.js exécute une quinzaine de petits benchmarks (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). Temps mural médian :

Shell
php bench/run.php

Les chiffres actuels sont enregistrés dans BENCH.md après chaque exécution de workflow bench. Les workflows bench et de la matrice test262 sont tous deux lancés manuellement.