
# 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](https://phasis.inline0.dev/fr/docs/advanced/benchmarks#roadmap).

## 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 :

```bash
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.
