Le blog

Ci-JIT PHP

Publié le 13 avril 2023

Depuis peu, PHP 7.4 est en fin de maintenance pour laisser place à PHP 8. Lors de la sortie de cette version majeure, une nouvelle fonctionnalité est apparue, le JIT (la RFC date de janvier 2019). La promesse du JIT tient en une phrase : gagner en performances. Comment cela est-ce possible ?

Nous avons tous connaissance de la distinction entre langage compilé et langage interprété. Dans le premier, le compilateur observe le code écrit, en déduit des optimisations et de ces déductions, il produit un jeu d'instructions optimisé pour la machine. Tandis que pour un langage interprété, cette opération n'existe pas. C'est là que le JIT intervient.

#

Qu'est-ce que le JIT ?

JIT signifie Just In Time. Au moment de l'exécution du code, le programme observe comment le code se comporte, et à partir de là apparaît le même jeu de déductions et de création d'instructions. Cependant, dans un langage compilé, ce jeu d'instruction fait partie intégrante du programme livré. Ce n'est pas possible avec PHP, il faut un moyen pour que le langage se souvienne de ces optimisations lors de la prochaine exécution. Il va pour cela exploiter un cache.

Alors c'est très nouveau pour PHP, mais ce n'est pas tout jeune comme mécanisme, il date des années 80. D'ailleurs en creusant un peu, même pour PHP, c'est un concept qui est plus ancien qu'il n'y paraît.

Lors de sa sortie, ce sont Dmitry Stogov et Zeev Suraski qui sont mentionnés et c'est au moins leur troisième tentative d'implémentation. L'objectif est d'améliorer les performances, alors l'idée de JIT naît, mais plus l'idée mûrit, plus les 2 ingénieurs trouvent des moyens d'améliorer le langage sans JIT. C'est grâce à cela que le bond de performance entre PHP 5 et 7 est si bon.

#
HipHop

Historiquement, il y a eu de la recherche de performance réalisée par Facebook. Oui, si vous ne le saviez pas encore, Facebook possède énormément de PHP. En 2010, ils publient HipHop for PHP, un transpiler vers C++ qui supporte PHP. La majeure partie des fonctionnalités de PHP sont couvertes par l'outil. Seulement, parmi les fonctionnalités du langage, ses capacités dynamiques et son faible typage en font une complexité incroyable : 

  • PHP permet d'avoir des noms de variables qui n'existent que par concaténation de chaînes, issues de tableaux pouvant contenir n'importe quel type de valeur, provenant d'une source extérieure au code.
  • Dans PHP, une classe peut exister 2 fois dans le code source, deux classes peuvent porter le même nom, mais dans deux fichiers différents... 

Et bien si vous avez déjà des nœuds au cerveau, ces cas ont pu être gérés. C'est très dur à lire, mais pas impossible. Il reste cependant le type d'une variable qui peut changer du tout au tout lors de l'exécution.

La première approche pour résoudre ceci, c'est le type inference. Si nous sommes capables de détecter qu'un entier est passé sur toute la ligne, c'est facile de ne plus regarder, ni de vérifier les autres types et la validité de la valeur passée. On génère du code spécialisé pour intégrer. Il y a également le RTTI (RunTime Type Information). À l'exécution, les types utilisés sont collectés. Si la majeure partie du temps c'est un entier, alors on génère une version spécialisée du code pour un entier, et on l'utilisera autant dès qu'un entier est passé. Pour le reste, on repasse par la version lente. Enfin il y a aussi la possibilité de s'appuyer sur le typehint fourni via la documentation. Mais c'est optionnel, alors ce n'est pas assez fiable.

Malgré tout le travail fourni, HipHop ne pouvait inférer que 5 à 10% des variables. C'est pour cette raison que les équipes de développement ont commencé à concentrer leurs efforts sur la seconde méthode, le RTTI. Pour aider leur processus d'optimisation, il faut commencer par se débarrasser des Array.

C'est un fourre-tout, ingérable. Il faut proposer des classes ayant un comportement similaire, mais possédant des types spécialisés. Des classes Vector, Set, Map...

Ensuite il faut permettre de typer le plus possible les variables, mais tout en continuant de permettre le côté dynamique du langage.
Une nouvelle mouture nommée HHVM (HipHop Virtual Machine) remplace HPHPc.

#
HHVM

Puisque c'est la suite de HipHop, l'équipe de Facebook concentre ses efforts sur le typage afin de pouvoir proposer une meilleure optimisation du code de PHP en le transformant en code machine. Mais en 2011, c'est PHP 5.4. Nous avons 8 ans devant nous avant de voir PHP 7 arriver. Ils vont donc améliorer le langage. Leur amélioration sera rendue publique en 2014 sous le nom de Hack.

#
HACK

Que trouve-t-on dans ce langage ?

Un soft type hint <<__Soft>> Foo qui permet de typer mais qui ne crash pas si le type passé est mauvais.

function probably_int(<<__Soft>> int $x): @int {
  return $x + 1;
}

On trouve aussi :

  • Des Generics
  • Des NullableTypes
  • Le type Nonnull
  • Des Shapes (array typés)
  • Des Tuples
  • Les types NonReturn et Nothing
  • PropertyPromotion
  • Le typage des propriétés, des arguments, des types de retours
  • Propriétés Readonly
  • De l'asynchrone
  • Des attributs <<__Memoize>>
  • La memoization des retours de méthodes

Et s'il y a des amateurs de React ici, il y avait déjà ce qui inspirera plus tard JSX, le XHP :

<?hh

use type Facebook\XHP\HTML\{div, i, strong};

class MyBasicUsageExampleClass {
  public function getInt(): int {
    return 4;
  }
}

function basic_usage_examples_get_string(): string {
  return "Hello";
}

function basic_usage_examples_get_float(): float {
  return 1.2;
}

<<__EntryPoint>>
async function basic_usage_examples_embed_hack(): Awaitable<void> {
  $xhp_float = <i>{basic_usage_examples_get_float()}</i>;

  $xhp =
    <div>
      {(new MyBasicUsageExampleClass())->getInt()}
      <strong>{basic_usage_examples_get_string()}</strong>
      {$xhp_float /* this embeds the <i /> element as a child of the <div /> */}
    </div>;
  echo await $xhp->toStringAsync();
}

Bref, nous sommes bien outillés, reste à combler la part dynamique. C'est là que HHVM tire son épingle du jeu : il va analyser au runtime le comportement du code pour en déduire les meilleures optimisations possibles. Autrement dit du JIT pour PHP, enfin... pour Hack.

#

Et le JIT de PHP ?

Historiquement, PHP est un langage interprété. Interprété par qui ou quoi ? Et bien par son moteur, Zend. Si vous travaillez depuis assez longtemps en PHP, c'est un nom qui ne vous est pas inconnu, notamment la Zend Virtual Machine. Le fait que ce soit virtuel ajoute la couche d'abstraction nécessaire pour être comprise par un maximum de machines, et de leurs processeurs.

Avec PHP 7, les OpCache et OpCode ont été introduits et peuvent être mis en cache. Qui plus est, leur mise en place est apparue avec l'introduction d'un AST (Abstract Syntax Tree), ce qui a permis de séparer d'autant plus le parser du compiler.

#
OpCache & OpCode

Avons nous entièrement perdu la machine virtuelle ? Non. Mais elle n'est plus utilisée lorsqu'il s'agit de la compilation Just In Time.

Ce qui devrait soulever une réflexion, si en PHP nous faisons la distinction avec le Just In Time, c'est qu'il est Ahead Of Time (AOT) comme les langages compilés, non ? Oui, mais sans configuration particulière il est AOT mais il oublie tout le temps à chaque appel !

Pour réduire ces temps, il est possible d'utiliser du cache. De l'opCache. Une fois que vous l'activez, lorsque le moteur de PHP exécute votre code, il commence par l'interpréter, et le transforme dans une représentation intermédiaire. Les OpCodes. Il les exploite ensuite dans sa Machine Virtuelle. Ceci dit il possède des limites, si vous utilisez de l'héritage de classe par exemple, une classe A hérite d'une classe B, si cette classe est dans un autre fichier (Comme souvent), le fichier OpCached A, ne connaît rien du fichier OpCached B. Il fera la liaison au runtime, et en cas de modification d'un fichier, il invalidera ce qu'il connaît de lui.

En lui seul, l'usage de OpCache offre un gain de performances pouvant accélérer votre code jusqu'à 5x plus vite. Regardons le code suivant :

<?php

$v = 0;
for ($a=0; $a < 100_000_000; $a++) {
	$v++;
}

exit(0);

Combien de temps met-il pour s'exécuter sans opCache ?

time php jit.php

0,94s user 0,02s system 97% cpu 0,984 total

Combien de temps met-il pour s'exécuter avec opCache ?

time php -d opcache.enable_cli=1 jit.php

1,27s user 0,02s system 98% cpu 1,306 total

C'est plus lent ?

Lors de la demande d'exécution de l'opCache, de nombreuses passes sont réalisées pour obtenir une optimisation de notre code. Sur ma commande un grand écart est visible, mais si je le lance plusieurs fois, selon la disponibilités de mon processeur, je vais pouvoir retrouver des temps identiques à avant.

Et oui, c'est normal en ligne de commande et sur un code aussi petit, je ne vais pas gagner grand chose. OpCache économise le temps du Lexer et de la construction de l'AST de PHP, mais ici il est tout petit, mon impact sera plus important sur un projet de plus grande envergure comme Symfony.

Regardons ce que sait faire PHP avec l'AST justement.

#
Abstract Syntax Tree

C'est une représentation de notre code, dans une version au plus proche du langage machine. Première étape, le lexer transforme nos symboles, en symboles intermédiaires, des tokens.

Pour passer de ceci :

<?php

echo $a + $b;

exit(0);

À cela :

Line 1: T_OPEN_TAG ('<?php')
Line 2: T_ECHO ('echo')
Line 2: T_WHITESPACE (' ')
Line 2: T_VARIABLE ('$a')
Line 2: T_WHITESPACE (' ')
Line 2: T_WHITESPACE (' ')
Line 2: T_VARIABLE ('$b')

Vous pouvez tester avec un morceau de code comme celui-ci :

<?php

$tokens = token_get_all(<<<'PHP'
<?php
echo $a + $b;
PHP
);

foreach ($tokens as $token) {
    if (is_array($token)) {
        echo "Line {$token[2]}: ", token_name($token[0]), " ('{$token[1]}')", PHP_EOL;
    }
}

exit(0);

Ces tokens sont utilisés pour produire un AST. Prenons une séquence simple mais plus grande.

<?php

echo "1 + 1 font-ils 11?";

if ((1+1) == 11) {
  echo "Oui ! Et ça c'est beau.";
} else {
  echo "Non :(";
}

exit(0);

L'AST produit est le suivant :

AST_ECHO
 expr: "1 + 1 font-ils 11?"
AST_IF
    AST_IF_ELEM
     cond: AST_BINARY_OP
         flags: BINARY_IS_EQUAL
         left: AST_BINARY_OP
             flags: BINARY_ADD
             left: 1
             right: 1
         right: 11
     stmts: AST_STMT_LIST
         AST_ECHO
          expr: "Oui ! Et ça c'est beau."
    AST_IF_ELEM
     cond: null
     stmts: AST_STMT_LIST
            AST_ECHO
             expr: "Non :("

Ici, nous savons que les valeurs évaluées sont entières et statiques. Il y quelques optimisations que le moteur devrait pouvoir réaliser, comme remplacer l'addition par son résultat.

AST_ECHO
 expr: "1 + 1 font-ils 11?"
AST_IF
    AST_IF_ELEM
     cond: AST_BINARY_OP
         flags: BINARY_IS_EQUAL
         left: 2
         right: 11
     stmts: AST_STMT_LIST
         AST_ECHO
          expr: "Oui ! Et ça c'est beau."
    AST_IF_ELEM
     cond: null
     stmts: AST_STMT_LIST
            AST_ECHO
             expr: "Non :("

Ensuite puisque la comparaison serait toujours fausse, de la remplacer par sa valeur booléenne.

AST_ECHO
 expr: "1 + 1 font-ils 11?"
AST_IF
    AST_IF_ELEM
     cond: AST_CONST
		name: AST_NAME
			flags: NAME_NOT_FQ
			name: "false"
     stmts: AST_STMT_LIST
         AST_ECHO
          expr: "Oui ! Et ça c'est beau."
    AST_IF_ELEM
     cond: null
     stmts: AST_STMT_LIST
            AST_ECHO
             expr: "Non :("

Et enfin sachant que ce sera faux quoi qu'il arrive, nous pouvons éliminer une branche du CFG (Control Flow Graph) pour ne rester qu'avec les expressions.

AST_ECHO
 expr: "1 + 1 font-ils 11?"
AST_ECHO
 expr: "Non :("

Vérifions notre supposition avec les OpCodes obtenus par l'interprétation de PHP. Il existe plusieurs moyens de les obtenir. PHPDBG, Vulcain Logic Dumper (VLD), et OpCache directement. Ce qui tombe bien puisque le JIT est une partie d'OpCache. Ce dernier permet de passer la configuration `opcache.opt_debug_level` (par défaut désactivée à 0). Les valeurs admises sont :

  • opcache.opt_debug_level=0x10000 : OPCodes avant optimisations.
  • opcache.opt_debug_level=0x20000 : OPCodes après optimisations.
  • opcache.opt_debug_level=0x40000 : OPCodes avant optimisations CFG.
  • opcache.opt_debug_level=0x80000 : OPCodes après optimisations CFG.
  • opcache.opt_debug_level=0x200000 : OPCodes avant optimisations SSA (Single Static Assignment).
  • opcache.opt_debug_level=0x400000 : OPCodes après optimisations SSA.
php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 jit.php

$_main:
     ; (lines=6, args=0, vars=0, tmps=0)
     ; (before optimizer)
     ; /Users/gheb/Desktop/jit.php:1-9
     ; return  [] RANGE[0..0]
0000 ECHO string("1 + 1 font-ils 11?")
0001 JMPZ bool(false) 0004
0002 ECHO string("Oui ! Et ça c'est beau.")
0003 JMP 0005
0004 ECHO string("Non :(")
0005 RETURN int(1)

Dès le départ PHP va éliminer l'opération mathématique. Voyons à présent après optimisations :

php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 jit.php

$_main:
     ; (lines=3, args=0, vars=0, tmps=0)
     ; (after optimizer)
     ; /Users/gheb/Desktop/jit.php:1-8
0000 ECHO string("1 + 1 font-ils 11?")
0001 ECHO string("Non :(")
0002 RETURN int(1)

Plutôt pas mal ! Ajoutons une variable dans notre code.

<?php

echo '1 + 1 font-ils 11?';

$a = 1;

if ($a + 1 === 11) {
  echo 'Oui ! Et ça c\'est beau.';
} else {
  echo 'Non :(';
}

exit(0);
$_main:
     ; (lines=9, args=0, vars=1, tmps=3)
     ; (before optimizer)
     ; /Users/gheb/Desktop/jit.php:1-9
     ; return  [] RANGE[0..0]
0000 ECHO string("1 + 1 font-ils 11?")
0001 ASSIGN CV0($a) int(1)
0002 T2 = ADD CV0($a) int(1)
0003 T3 = IS_IDENTICAL T2 int(11)
0004 JMPZ T3 0007
0005 ECHO string("Oui ! Et ça c'est beau.")
0006 JMP 0008
0007 ECHO string("Non :(")
0008 RETURN int(1)

Et voyons le résultat une fois optimisé.

$_main:
     ; (lines=9, args=0, vars=1, tmps=2)
     ; (after optimizer)
     ; /Users/gheb/Desktop/jit.php:1-9
0000 ECHO string("1 + 1 font-ils 11?")
0001 ASSIGN CV0($a) int(1)
0002 T2 = ADD CV0($a) int(1)
0003 T1 = IS_IDENTICAL T2 int(11)
0004 JMPZ T1 0007
0005 ECHO string("Oui ! Et ça c'est beau.")
0006 RETURN int(1)
0007 ECHO string("Non :(")
0008 RETURN int(1)

Ah bah c'est beaucoup moins bien maintenant. Vraiment ? Regardez bien la ligne 6. Effectivement la seule économie est un saut. Parce que ma variable, techniquement, pourrait contenir n'importe quoi et être modifiée jusqu'à l'exécution du code. C'est-à-dire jusqu'au RUNTIME.

Une constante alors ?

$_main:
     ; (lines=10, args=0, vars=0, tmps=3)
     ; (before optimizer)
     ; /Users/gheb/Desktop/jit.php:1-9
     ; return  [] RANGE[0..0]
0000 ECHO string("1 + 1 font-ils 11?")
0001 DECLARE_CONST string("A") int(1)
0002 T0 = FETCH_CONSTANT string("A")
0003 T1 = ADD T0 int(1)
0004 T2 = IS_IDENTICAL T1 int(11)
0005 JMPZ T2 0008
0006 ECHO string("Oui ! Et ça c'est beau.")
0007 JMP 0009
0008 ECHO string("Non :(")
0009 RETURN int(1)

Bien essayé, mais non.

$_main:
     ; (lines=10, args=0, vars=0, tmps=2)
     ; (after optimizer)
     ; /Users/gheb/Desktop/jit.php:1-9
0000 ECHO string("1 + 1 font-ils 11?")
0001 DECLARE_CONST string("A") int(1)
0002 T0 = FETCH_CONSTANT string("A")
0003 T1 = ADD T0 int(1)
0004 T0 = IS_IDENTICAL T1 int(11)
0005 JMPZ T0 0008
0006 ECHO string("Oui ! Et ça c'est beau.")
0007 RETURN int(1)
0008 ECHO string("Non :(")
0009 RETURN int(1)

Et il en sera de même avec declare(strict_types=1. OpCache conservera la branche dans son AST et optimisera l'usage mémoire en ré-assignant T0

PHP ne prend pas de risque. Lorsqu'un script extérieur permet de réaliser des modifications de comportement sur le code analysé, PHP fait attention à conserver ce comportement, lorsque des valeurs sont assignées il conserve des flux d'action même s'il sait qu'il ne seront jamais exécutés dans un souci d'éviter des fuites de mémoire. 

Lorsque OpCache calcule ses optimisations, il les réalise en passes : 

  •  Liveness Range Calculation : pour vérifier à chaque moment si de la mémoire doit être libérée.
  •  Peephole : pour réduire les assignations lorsque des résultats sont prédictibles.
  •  Jump pour effectuer des RETURN le plus tôt possible. 
  •  CALL afin de simplifier les appels de fonctions lorsque les arguments passés et lorsque les valeurs de retours sont prédictibles. 
  •  CFG et SSA, évoquées plus tôt.
  •  Temporary variable pour réassigner de l'espace mémoire lorsque c'est possible.
  •  NOP Removal lors des étapes précédentes certaines instructions utilisateurs à présent mortes au profit d'une meilleure structure sont remplacées par un NOP. Ici, OpCache les supprime.
  •  Literal Compaction supprime tous les groupes d'instructions qui ne semblent pas utilisés.
  •  Variable Compaction supprime toutes les variables qui ne semblent pas utilisées.

Une fois que PHP a pu optimiser mon code et générer un AST, quoi de plus ?

#
Preload

Puisque que Preload met en mémoire un code exécutable, on élimine de facto, les codes trop dynamiques. Par exemple une classe qui serait chargé de manière conditionnelle au RunTime au risque de rencontrer un warning au démarrage du serveur : 

Can't preload unlinked class App\A: Unknown parent App\B

Activer ceci vous permet à nouveau un gain de performance jusqu'à 22%.

Pour se rendre compte de ce qui est caché, vous pouvez afficher le résultat de opcache_get_status() dans un script PHP, vous trouverez la liste des méthodes, classes et scripts préchargés dans la clé preload_statistics. Attention tout de même, cette mémoire est partagée pour un serveur. Il faut que ce dernier soit dédié au projet.

#
JIT

Malgré ces optimisations, JIT amène une nouvelle couche de performances. Mais dans des cas très spécifiques. JIT c'est aussi une couche opcache. Tout ce que nous avons vu jusqu'ici ce sont des gains de performances au sein de la VM capable d'interpréter les OpCodes et de les retranscrire pour le CPU. La prochaine étape d'optimisation est de ne plus avoir cet intermédiaire, de se débarrasser de la VM pour être au plus proche du CPU. Pour compiler son code, PHP utilise un outil nommé DynASM. Il vient de chez LuaJIT.

JIT a 3 principaux mécanismes : stocker, inspecter et invoquer le code de manière transparente soit avec la machine virtuelle ou directement en utilisant le code machine stocké dans un buffer mémoire.

Il faut commencer par définir la taille du stockage avec opcache.jit_buffer_size.

Pour invoquer le code ce sont les triggers qui sont responsables de détecter lorsqu'un morceau de code PHP peut exploiter une version compilée, et aller l'exécuter. Et ce code provient du résultat de la fonction tracer. La fonctionnalité de traçage JIT inspecte le code avant, après et/ou pendant son exécution et détermine quel code est "chaud" (quelles structures peuvent être compilées avec le JIT).

Comme PHP est faiblement typé, un type peut changer à tout moment durant son cycle de vie. Le code strictement typé et les fonctions avec des types scalaires peuvent aider le JIT à déduire les types et à utiliser les registres du CPU et les instructions spécialisées lorsque c'est possible. Par exemple, une fonction pure (qui n'a pas d'effets secondaires) avec des types stricts activés et avec des types de paramètres et de retour pourrait être un candidat parfait :

<?php 

declare(strict_types=1) ;

function sum(float $a, float $b) : float {
    return $a + $b ;
}

exit(0);

Et en faisant abstraction de la complexité que représente cette étape, c'est tout ce que fait le JIT : être capable de transcrire en code machine, un code PHP prédictible D'ailleurs, certaines des améliorations de PHP 7 proviennent de ces optimisations qui permettent d'éliminer le code mort et d'améliorer le comptage des références. Cela signifie qu'un code plus strictement typé donne plus d'opportunités à PHP d'optimiser le code au niveau d'Opcache, et aussi au niveau du JIT.

Prenons ce code :

<?php

declare(strict_types=1);

function test(int $a, int &$b) {
  if ($a + 1 === 11) {
    $b--;
  } else {
    $b++;
  }
}

$b = 0;

for ($i = 0; $i < 100_000_000; $i++) {
  test (1, $b);
}

echo PHP_EOL;

exit(0);
time php jit.php                                 

php jit.php  7,64s user 0,05s system 99% cpu 7,705 total
time php -d opcache.enable_cli=1 jit.php

php -d opcache.enable_cli=1 jit.php  5,55s user 0,02s system 99% cpu 5,584 total

Nous voyons bien que malgré une belle optimisation, c'est très lent. Mais comme les types sont prédictibles, les résultats le sont !

time php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=256m jit.php

php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=256m jit.php  0,96s user 0,02s system 98% cpu 0,997 total

Pour des applications comme Symfony, je ne suis pas sûr que vous obteniez des améliorations aussi bonnes mais ça ouvre la porte à d'autres types d'applications boudés par souci de performances, comme l'IA, le jeu vidéo, les statistiques...

Merci de m'avoir lu ! Vous souhaitez améliorer les performances de votre applicatif ? N'hésitez pas à nous contacter.

Grégoire Hébert

Grégoire Hébert

Principal developer

Mots-clésJIT, PHP

Le blog

Pour aller plus loin