Échangeons sur votre projet : premier rendez‑vous en visio offert Réserver un créneau

80 WordPress, un seul OPcache

Héberger les sites de nos clients nous amène quotidiennement à relever des défis techniques, aujourd'hui j'ai envie de vous partager un point qui nous a amené à mettre en place un patch pour PHP, actuellement en test sur notre infrastructure

Étude de cas · PHP 8.4 · OPcache

Sur un hébergement mutualisé, 80 sites PHP 8.4 occupaient environ 3 Go de cache d'opcodes alors que 80 % de leurs fichiers sont identiques. Nous avons écrit un patch OPcache qui stocke une seule fois le bytecode identique, quel que soit le chemin du fichier, puis nous l'avons mesuré sous charge, sur deux runs de 500 000 requêtes.

Vitalyn · octobre 2026 · PHP 8.4.26, patch opcache-dedup

site001/wp-includes/formatting.php:4751
esc_html: ; (lines=16, args=1, vars=2, tmps=1)
0000 CV0($text) = RECV 1
0001 INIT_FCALL 1 160 string("wp_check_invalid_utf8")
0002 SEND_VAR CV0($text) 1
0003 V2 = DO_UCALL
0004 CV1($safe_text) = QM_ASSIGN V2
0005 INIT_FCALL 2 192 string("_wp_specialchars")
...

Le même fichier dans un second site :

site002/wp-includes/formatting.php:4751
esc_html: ; (lines=16, args=1, vars=2, tmps=1)
0000 ... 0015 : mêmes opcodes, mêmes littéraux
→ un seul bloc en mémoire partagée

Propres à chaque site : filename, scope,
__FILE__, traces d'erreur
...
Indicateur Écart Détail
Mémoire OPcache -46,5 % 6 406 Mo → 3 428 Mo
RAM PHP-FPM (pic PSS) -18,6 % 6,55 Go → 5,34 Go
CPU par requête +0,8 % 170,3 ms → 171,7 ms, dans le bruit
Réponses en erreur 0 sur 1 000 000 requêtes, 0 crash de worker

Le problème : OPcache indexe par chemin

OPcache compile chaque fichier PHP en bytecode et le garde en mémoire partagée. Cette mémoire est créée par le master PHP-FPM puis héritée par tous ses workers, y compris ceux des pools qui tournent sous des utilisateurs Linux différents. C'est la configuration courante en mutualisé : un master par version de PHP, un pool et un utilisateur par site.

La clé du cache est le chemin du fichier. /home/site001/wp-includes/formatting.php et /home/site002/wp-includes/formatting.php sont deux entrées distinctes, même quand les fichiers sont identiques octet pour octet. Avec 80 copies de WordPress et des mêmes extensions, le même bytecode est stocké 80 fois.

Hacher le fichier source ne suffit pas : le bytecode d'un fichier contient son propre chemin. __FILE__ et __DIR__ sont résolus à la compilation, et chaque fonction porte le nom de son fichier pour les erreurs et les traces. Deux copies d'un même fichier ne donnent donc jamais un bytecode identique en entier. Leurs fonctions, en revanche, le sont presque toutes.

Le patch : un hash par fonction compilée

Le patch ajoute une directive, opcache.enable_dedup. Au moment où OPcache copie une fonction compilée en mémoire partagée, il calcule une empreinte de ses opcodes et de ses littéraux. Si un bloc identique existe déjà, la fonction pointe vers lui au lieu d'en stocker une copie.

site001 · esc_html()  (filename, scope, variables, arg_info, try/catch)
site002 · esc_html()  (filename, scope, variables, arg_info, try/catch)
site080 · esc_html()  (filename, scope, variables, arg_info, try/catch)
        │
        └──► Bloc partagé : opcodes + littéraux, stockés une fois, référencés par les trois fonctions
  • Jamais de partage sur le seul hash. Une empreinte identique est toujours confirmée par une comparaison intégrale du bloc. Une collision, accidentelle ou fabriquée par un client, ne peut pas faire exécuter le code d'un site à la place d'un autre.
  • Rien ne change pour l'isolation. Les scripts restent cherchés par chemin, avec les contrôles natifs (opcache.validate_permission, validate_root). Le bloc partagé n'est jamais adressable de l'extérieur.
  • Chaque site garde son identité. __FILE__, les traces d'exceptions et la Reflection restent ceux du site. Un bloc qui dépend du chemin diffère naturellement et n'est pas partagé.
  • Pas de nouveau verrou. Tout se passe pendant la persistance, déjà protégée par le verrou d'écriture d'OPcache. À l'exécution, les workers ne font que lire.
  • Interrupteur immédiat. Avec opcache.enable_dedup=0, aucune structure n'est allouée et OPcache se comporte exactement comme d'origine.

Le protocole

Un conteneur Docker, lui-même dans un conteneur LXC Debian sur un hôte Proxmox, limité à 10 vCPU et 18 Go. Deux runs identiques, sauf une ligne de configuration, sur le même PHP 8.4.26 patché, compilé en release.

Élément Configuration
Sites 80 WordPress installés : WooCommerce, Yoast SEO, Contact Form 7 et Elementor actifs, 12 produits et 10 articles chacun.
Versions 64 sites (80 %) sur la pile à jour, 16 sur une pile plus ancienne (WordPress 6.5.5, WooCommerce 9.0).
Isolation Un utilisateur Linux, un pool PHP-FPM (ondemand) et une base MariaDB par site, un seul master FPM. Les fichiers PHP sont de vraies copies par site, avec opcache.validate_permission=1.
OPcache 8 Go de mémoire partagée, 256 Mo d'interned strings, validate_timestamps=1, JIT désactivé. Cache vide au début de chaque run.
Charge h2load à travers nginx, 32 connexions, 500 000 requêtes par run, même séquence rejouée dans les deux runs. Mix : 35 % accueil, 25 % articles, 15 % fiches produit, 10 % boutique, 10 % API REST, 5 % recherche, réparti au hasard sur les 80 sites.
Mesures Chaque seconde : CPU de PHP-FPM et de MariaDB (/proc), PSS des workers, mémoire OPcache (opcache_get_status()). Latence de chaque requête, côté client.

Mémoire : -46,5 % de cache, -18,6 % de RAM

Sans dedup, OPcache occupe 6,3 Go en fin de run pour 252 812 scripts en cache. Avec dedup, il occupe 3,3 Go pour quasiment le même nombre de scripts (252 673) : 2,9 Go de copies d'opcodes ont été évitées, soit 2,4 millions de fonctions qui pointent vers un bloc déjà stocké.

La RAM réellement consommée par PHP-FPM, mesurée en PSS (la mémoire partagée y est comptée une fois), baisse moins en proportion, car chaque worker garde sa mémoire privée de requête : -18,6 % au pic, de 6,6 à 5,3 Go. Les 1,2 Go économisés sont de la mémoire rendue au système.

Notre pile de test est plus lourde que celle de la production d'origine : WooCommerce, Elementor et Yoast actifs sur chaque site, d'où 6,3 Go au lieu d'environ 3 Go. Avec la même proportion de code partagé, ces 3 Go devraient descendre vers 1,6 Go.

Mémoire OPcache utilisée : PHP 8.4 natif contre avec dedup
Mémoire OPcache utilisée. opcache_get_status() : memory_usage.used_memory, interned strings compris.
RAM totale des processus PHP-FPM : PHP 8.4 natif contre avec dedup
RAM totale des processus PHP-FPM. Somme des PSS de tous les workers : la mémoire partagée y est comptée une fois.

CPU et temps de réponse : inchangés

Le serveur tourne à saturation dans les deux runs (environ 9 cœurs sur 10) : c'est voulu, pour que tout surcoût se voie en débit. À l'exécution, rien ne change pour les workers : ils lisent les mêmes opcodes, simplement à une autre adresse.

Sur l'ensemble du run, le CPU par requête passe de 170,3 à 171,7 ms (+0,8 %) et le débit de 52,2 à 51,7 req/s. Découpé en tranches, l'écart en régime établi reste dans la variation propre à chaque run : de 167 à 173 ms par requête dans les deux cas, avec la même dérive lente due à la croissance des bases. La latence médiane est identique (523 contre 522 ms). Le p99 varie de 1 775 à 1 920 ms d'une fenêtre à l'autre dans le run avec dedup, ce qui couvre l'écart global (1 853 contre 1 933 ms).

Le seul coût réel apparaît au démarrage à froid, quand tout est compilé : pendant les 5 premières minutes, 41 req/s avec dedup contre 46 sans. Ce coût vient des empreintes calculées à la compilation, et l'optimisation décrite plus bas l'a réduit depuis.

CPU consommé par PHP-FPM : PHP 8.4 natif contre avec dedup
CPU consommé par PHP-FPM. Cœurs utilisés en moyenne sur 30 s, workers et master.
Débit (req/s) : PHP 8.4 natif contre avec dedup
Débit (req/s). Requêtes terminées par seconde, moyenne sur 30 s.
Latence médiane (p50) : PHP 8.4 natif contre avec dedup
Latence médiane (p50). Par fenêtre de 30 s.
Latence p99 : PHP 8.4 natif contre avec dedup
Latence p99. Par fenêtre de 30 s.
Latence par percentile, sur tout le run : PHP 8.4 natif contre avec dedup
Latence par percentile sur tout le run, mesurée par h2load (ms).

Tous les chiffres

Mesure PHP 8.4 natif Avec dedup Écart
Requêtes 500 000 500 000
Durée du run 159,6 min 161,3 min +1,0 %
Débit moyen 52,2 req/s 51,7 req/s -1,0 %
Mémoire OPcache en fin de run 6 406 Mo 3 428 Mo -46,5 %
dont interned strings 155 Mo 155 Mo 0,0 %
Scripts en cache 252 812 252 673
Mémoire économisée par le dedup - 3 018 Mo
RAM PHP-FPM, pic PSS 6 711 Mo 5 466 Mo -18,6 %
CPU PHP-FPM total 1 418,8 min CPU 1 430,6 min CPU +0,8 %
CPU PHP-FPM par requête 170,25 ms 171,68 ms +0,8 %
Latence moyenne 612,9 ms 619,2 ms +1,0 %
Latence p50 523,3 ms 521,9 ms -0,3 %
Latence p95 1 285,5 ms 1 328,9 ms +3,4 %
Latence p99 1 853,0 ms 1 933,0 ms +4,3 %
Réponses 200 500 000 500 000
Réponses en erreur (hors 200) 0 0
Workers morts en SIGSEGV 0 0

Compilation à froid : -45 % dès le chargement

Un second banc isole la compilation : chaque site compile tous ses fichiers PHP (environ 9 000) via son propre pool, 8 sites en parallèle, sans servir de pages. Il mesure l'empreinte maximale, celle d'un cache où tout le code installé serait chargé. Le temps CPU y varie trop d'un run à l'autre sur un hôte partagé pour être comparé. Il est mesuré plus bas dans un processus isolé.

Sites PHP 8.4 natif Avec dedup Écart Blocs partagés
10 1 818 Mo 1 167 Mo -35,8 % 78,7 %
30 4 857 Mo 2 667 Mo -45,1 % 84,4 %

Chaque site supplémentaire coûte environ 75 Mo au lieu de 152 Mo. Le gain croît donc avec le nombre de sites identiques et tend vers 50 %. Le reste est surtout fait des structures de classes (tables de méthodes, propriétés, constantes), qui contiennent le chemin du fichier et ne sont pas partageables sans modifier le moteur Zend lui-même (voir Aller plus loin).

Optimisation : calculer chaque empreinte une seule fois

OPcache copie un script en mémoire partagée en deux passes : la première calcule la taille à réserver, la seconde copie. Les deux doivent prendre exactement les mêmes décisions de partage, sinon la taille réservée est fausse. La première version recalculait l'empreinte de chaque fonction dans chaque passe, soit trois à quatre sérialisations par fonction.

La version finale mémorise la décision de la première passe et l'applique telle quelle dans la seconde. Chaque fonction n'est plus sérialisée qu'une fois, la seconde passe ne relit jamais le code compilé, et les entrées de la table passent de 40 à 32 octets. Mesure : un processus qui compile trois sites complets, sept répétitions alternées.

Version CPU de compilation (moyenne) Écart vs natif Fonctions partagées
PHP 8.4 natif 7 742 ms - -
Dedup, version du banc de charge 8 439 ms +9,0 % 110 665
Dedup, version finale 7 912 ms +2,2 % 110 665

Ce coût ne concerne que la compilation, donc les premières minutes après un redémarrage ou une mise à jour de fichiers. Une fois le cache rempli, il est nul.

Run de confirmation avec la version finale, sous la même charge : 60 000 requêtes sur les 80 sites, 100 % de réponses 200, aucun worker mort. OPcache occupe 3 379 Mo pour 248 780 scripts, dont 2 978 Mo de copies évitées : le partage est pratiquement le même qu'avec la version du banc de charge.

Comment nous avons vérifié que rien ne casse

  • 15 tests dédiés (ext/opcache/tests/dedup/). Ils passent sur PHP 8.2, 8.3, 8.4, 8.5, 8.6 (RC3) et la branche de développement 8.7 : __FILE__ et traces par site, littéraux qui diffèrent d'un octet ou dont les clés de hachage entrent en collision, deux docroots de même structure dont 20 fonctions diffèrent d'un détail (opérateur, branche, littéral, type...), classes et traits, invalidation, mémoire protégée.
  • La suite de PHP (Zend, tests/, OPcache : 6 437 tests) relancée avec le dedup forcé et opcache.protect_memory=1 : aucune régression.
  • Valgrind et AddressSanitizer sur les tests, sur la suite OPcache de PHP et sur la compilation de deux sites WordPress complets dans un même processus : aucune erreur mémoire, aucune fuite attribuable au patch. Limite connue des deux outils : ils n'instrumentent pas l'intérieur de la mémoire partagée, dont OPcache gère lui-même le découpage.
  • Un fuzzing différentiel. Des milliers de programmes PHP aléatoires mais déterministes sont compilés en deux copies identiques (pour forcer le partage) puis exécutés, avec et sans dedup. La sortie doit être identique dans les deux modes, et le run avec dedup doit avoir effectivement partagé du code. 3 000 itérations sous AddressSanitizer et des milliers d'autres sans : aucune divergence, aucun crash. C'est le fuzzing demandé lors de la revue du patch, au niveau qui compte ici (comportement et mémoire hors SHM).
  • Les deux runs du banc de charge : quasiment le même nombre de scripts en cache, aucun worker mort, mêmes codes de réponse.
  • L'isolation entre tenants, contre le natif. Deux sites, deux utilisateurs Linux, un master, quatre runs (dedup désactivé et activé, avec et sans opcache.restrict_api). Un code identique est partagé, puis chaque site modifie sa copie : l'autre ne voit jamais le changement, car un bloc partagé n'est jamais modifié ni libéré. La lecture du fichier d'un autre site, par symlink ou par chemin, est refusée avant comme après sa mise en cache. L'API OPcache depuis un site (statut, invalidation, reset) est refusée avec restrict_api, et acceptée sans, exactement comme en natif. Les sorties des runs avec dedup sont identiques à celles sans. La clé de la table est en outre salée aléatoirement et toute égalité de clé est confirmée par une comparaison intégrale : un test force des collisions et vérifie qu'aucune n'est partagée.
  • Les logs d'erreurs, site par site. Une même bibliothèque, déposée sur les 80 sites, déclenche un warning, une variable indéfinie, une exception, un appel de fonction inexistante et une division par zéro, puis affiche __FILE__, __DIR__, __LINE__, __CLASS__, __METHOD__, les traces et la Reflection. Résultat : 480 requêtes par mode et 1 680 chemins vérifiés. Chaque ligne du log nomme le fichier et la ligne du site appelant, chaque worker tourne sous l'utilisateur de son site, et la sortie est identique octet pour octet à celle de PHP natif. Les fonctions qui contiennent __FILE__ ne sont pas partagées. Celles qui le sont, comme celle qui lève les erreurs, rapportent pourtant le bon site, car le chemin vient de la fonction appelante et non du bloc partagé.

Le premier bench a trouvé un vrai bug, invisible dans les tests unitaires. Sous charge, en build optimisée, des workers mouraient en SIGSEGV. Un tableau constant partagé entre deux fonctions d'un même fichier était libéré par OPcache après la copie de la première, puis relu par le dedup pour la seconde. Dans la version finale, la passe de copie ne relit plus jamais le code compilé : elle applique les décisions prises lors de la passe de calcul, qui ne libère rien. Un test reproduit le cas, et Valgrind confirme l'absence d'accès invalide.

Le vrai doute : peut-on injecter du code chez un voisin ?

Puisque la fonction esc_html() de 80 sites pointe désormais vers un seul bloc en mémoire, la question qui compte est : un site compromis peut-il s'en servir pour exécuter son code chez les 79 autres ? Nous avons déroulé la chaîne d'attaque complète (énumérer, cibler, injecter) contre le code d'OPcache. Elle casse à la deuxième étape pour un attaquant qui n'a que du PHP.

Distinction qui gouverne tout le reste : un attaquant PHP exécute du code sous son utilisateur, dans les règles du moteur. Un attaquant natif a déjà obtenu une exécution de code machine dans un worker (bug mémoire de PHP, extension vulnérable, FFI). Ce dernier peut déjà tout lire et tout écrire dans la mémoire partagée, avec ou sans ce patch : la question n'a de sens qu'au niveau PHP.

1. Lister les fichiers en cache des autres : oui, mais c'est OPcache natif

opcache_get_status(true) parcourt toute la table du master et renvoie le chemin de chaque script de chaque site. Ce comportement existe sans le patch, et il se ferme avec opcache.restrict_api. Le dedup n'ajoute que des compteurs globaux (blocs partagés, mémoire économisée) : aucun chemin, aucune donnée par site. L'énumération n'est donc pas aggravée.

2. Relier un fichier aux opcodes d'une fonction précise : non

C'est l'étape qui bloque. Aucune fonction PHP ne renvoie d'opcodes : ni l'API OPcache (sept fonctions, toutes relues, aucune ne rend du bytecode), ni la Reflection. La table de déduplication n'est pas exposée à PHP : on ne peut ni l'énumérer, ni l'interroger, ni savoir quels sites partagent un bloc. Et pour seulement amener le code compilé d'un voisin dans son propre processus, il faudrait inclure son fichier, donc avoir le droit de le lire, ce que les permissions Unix et validate_permission interdisent. Depuis PHP, impossible de localiser "le bloc de esc_html() du voisin".

3. Patcher l'opcode pour injecter : non au niveau PHP

Aucune fonction PHP n'écrit d'opcodes. Et même quand deux sites pointent vers le même bloc, cette mémoire est immuable : les opcodes sont en lecture seule côté moteur, et les littéraux (y compris les tableaux constants) sont marqués immuables à la mise en cache. Un site qui modifie une valeur constante qu'il a reçue modifie une copie privée, jamais la copie partagée. Les variables statiques et les caches d'exécution, eux, ne sont pas partagés du tout. Un test le prouve : deux sites partagent une fonction qui renvoie un tableau de configuration, l'un martèle ce tableau de toutes les façons possibles (écrase un mot de passe, ajoute des clés, écrit par référence), l'autre reçoit toujours la valeur d'origine intacte.

Au niveau natif, l'injection redevient possible, mais elle l'était déjà sans le patch, car la mémoire partagée est inscriptible par tout worker. Ce que le dedup change alors, c'est l'amplification : patcher un bloc partagé touche tous les sites qui le partagent en une écriture, au lieu d'un par site. C'est un argument pour borner le nombre de sites par master, pas pour l'étendre.

Conclusion. La chaîne casse à l'étape 2 pour un attaquant PHP : il peut lister des chemins (natif, à fermer avec restrict_api), mais ne peut ni lire les opcodes, ni les relier à une fonction, ni les écrire. Les étapes 2 et 3 exigent une exécution native, qui donne déjà un accès total à la mémoire partagée, dedup ou pas. La seule contribution du patch au risque est la concentration de la cible une fois cet accès natif obtenu. D'où les deux garde-fous : restrict_api obligatoire, et des masters de 15 à 20 sites de confiance équivalente plutôt qu'un master pour tout un parc.

Mise en place

Le patch tient en un fichier, zend_accelerator_dedup.c, et une quarantaine de lignes d'ancrage dans OPcache. Il est disponible pour chaque branche de PHP 8, prêt pour git am.

# 1. Appliquer le patch sur les sources de la version déployée
git clone --depth 1 --branch php-8.4.26 https://github.com/php/php-src.git
cd php-src && git am /chemin/vers/patches/php-8.4.patch

# 2. Compiler avec les options habituelles de la distribution
./buildconf --force && ./configure ... && make -j"$(nproc)" && make install

# 3. Activer, puis redémarrer PHP-FPM (directives PHP_INI_SYSTEM)
opcache.enable_dedup = 1
opcache.jit = disable      ; "off" laisse le JIT activable et bloque le dedup

# 4. Suivre l'effet
php -r 'print_r(opcache_get_status(false)["dedup"]);'
#    => 1   => ...   => ...   => ...
  • Un seul master FPM. Le dedup partage la mémoire d'un même master. Si chaque site a son propre master, il n'y a rien à mutualiser.
  • Désactivé automatiquement si opcache.file_cache est défini ou si le JIT est disponible, avec un warning dans le log OPcache. Attention : opcache.jit=off, le défaut des paquets Sury, laisse le JIT activable à l'exécution et suffit à désactiver le dedup. Il faut opcache.jit=disable.
  • Retour arrière immédiat : opcache.enable_dedup=0 puis redémarrage de PHP-FPM, sans recompiler.
  • Ce qui reste propre à chaque site. Une fonction est partagée si son bytecode ne contient pas le chemin du fichier. __FILE__ et __DIR__ sont figés à la compilation, comme le reste des constantes magiques (__LINE__, __CLASS__, etc., qui sont en revanche identiques d'une copie à l'autre). Les constantes utilisateur ne le sont pas : avec const ROOT = __DIR__ . '/www'; ou define(), seul le fichier qui déclare contient le chemin, et toutes les fonctions qui utilisent ROOT lisent la constante par son nom à l'exécution, donc restent partageables. Deux pièges : __DIR__ en valeur par défaut d'un paramètre, et self::CONSTANTE construite avec __DIR__, que l'optimiseur recopie dans la méthode. C'est pour cela que WordPress, qui passe presque partout par ABSPATH, se partage à 92 % : le code bloqué par le chemin ne pèse que 6,5 % de son bytecode.
  • Statistiques. Les compteurs de opcache_get_status() peuvent révéler qu'un autre site fait tourner le même code. Comme pour les statistiques OPcache existantes, opcache.restrict_api les réserve à l'administrateur.

Aller plus loin : ce que le patch ne partage pas, et pourquoi

Le patch partage les opcodes et les littéraux de chaque fonction, méthodes de classes comprises. C'est environ 58 % de la mémoire d'un site, et l'essentiel du gain mesuré. Le reste est dupliqué volontairement : l'objectif était un patch léger et fiable, confiné à l'étape où OPcache copie le code compilé en mémoire partagée. Il ne touche ni au moteur Zend, ni à l'exécution, ni au JIT, et avec opcache.enable_dedup=0 le comportement est strictement celui d'origine.

Chaque piste ci-dessous franchit cette frontière : elle demande de modifier le cœur de PHP, donc une discussion avec ses mainteneurs, parfois un RFC. L'idée peut être poussée plus loin, mais ce n'était pas l'objet de ce patch.

Ce qui reste dupliqué Pourquoi le patch n'y touche pas Ce qu'il faudrait
Structures de classes : zend_class_entry, tables de méthodes, de propriétés et de constantes, valeurs par défaut. Environ 31 % de la mémoire d'un site : le plus gros levier restant. Une classe porte l'identité de son script (fichier, lignes, commentaires) et des liens propres à son chargement : classe parente, interfaces et traits, résolus à l'édition de liens. Une partie est aussi modifiée à l'exécution : constantes et valeurs par défaut évaluées au premier usage, propriétés statiques, cache d'héritage. Les opcodes des méthodes, eux, sont déjà partagés. Séparer la partie immuable d'une classe de sa partie liée au script, et faire passer le moteur par une indirection pour la seconde. Cela touche l'héritage et la persistance des classes dans Zend : c'est l'étape suivante naturelle, mais elle relève du moteur, pas d'OPcache.
En-têtes de fonctions (zend_op_array), arg_info, noms de variables, plages de vie, blocs try/catch. L'essentiel des ~11 % restants. Petites structures par fonction, qui pointent vers le fichier, la portée et le cache d'exécution de leur script. arg_info et les noms de variables sont pourtant identiques d'une copie à l'autre. Partager arg_info et les noms de variables, déjà immuables, est le premier pas le plus simple. Gain modeste : laissé hors du premier patch pour garder sa surface minimale.
Code qui contient le chemin du fichier : __FILE__, __DIR__, clés internes de déclaration des classes. 3,8 % de la mémoire d'un site (6,5 % du bytecode de WordPress). Le compilateur fige ces valeurs dans le bytecode : deux copies d'un même fichier diffèrent donc par construction, et le patch ne partage que l'identique. Deux pistes : des clés de déclaration sans chemin, faisables dans OPcache seul (~1,8 % des opcodes), et __FILE__/__DIR__ résolus à l'exécution, qui demandent une option de compilation et un nouvel opcode, donc un RFC. Ensemble, le gain passerait d'environ -46,5 % à -49 %.
Le JIT : incompatible, le dedup se désactive s'il est disponible. Le JIT réécrit en place, en mémoire partagée, les handlers des instructions avec du code machine propre à une fonction : sa portée, ses constantes, son cache. Partagées, ces instructions feraient exécuter à un site le code compilé pour un autre. Que le JIT travaille sur une copie privée des instructions des seules fonctions chaudes. C'est une modification du JIT. Sur des sites de type WordPress, son gain est de toute façon faible.
Le cache fichier (opcache.file_cache) : incompatible. Son format sérialise chaque script isolément et ne sait pas représenter un pointeur vers le bloc d'un autre script. Un format de cache fichier capable de références entre scripts.
Le partage entre masters FPM. Chaque master a sa propre mémoire partagée, et cette séparation est aussi une frontière d'isolation. Un segment commun à plusieurs masters. Non souhaitable : c'est l'inverse des garde-fous défendus plus haut (des masters de taille bornée).
Les builds 32 bits. Les opérandes y sont des adresses absolues, propres à chaque copie. Rien de prévu : plateforme marginale pour ce cas d'usage.

Pourquoi s'arrêter là. Les opcodes et les littéraux donnent l'essentiel du gain : -46,5 % mesurés, et un gain qui sature dès quelques dizaines de sites. Chaque étape suivante ajouterait au moteur des règles que tout le reste de PHP devrait respecter (immutabilité, adressage indirect), et une surface de revue sans commune mesure avec ce patch. Un premier patch confiné à OPcache, désactivé par défaut et vérifiable de bout en bout, est la condition pour que la suite puisse être discutée. Pour les mainteneurs, les mesures ci-dessus donnent l'ordre de grandeur de chaque étape. La plus rentable est la partie immuable des classes.

Et maintenant

Le patch est écrit comme une contribution à PHP : conventions de code de php-src, tests au format phpt, entrées UPGRADING et php.ini. Il cible la branche de développement, la seule où le projet PHP accepte de nouvelles fonctionnalités. Les versions 8.2 à 8.6 sont des rétroportages à appliquer soi-même (8.0 et 8.1, en fin de vie, ne sont plus fournies).

Pour le moment ce patch reste privé nous souhaitons l'auditer en interne encore quelque temps, le faire tourner en dev et potentiellement en prod et ensuite si le patch correspond à nos attentes nous déciderons d'une contribution à PHP afin de voir avec eux un intégration dans une future version. Si c'est accepté nous mettrons a disposition le patch pour que les versions actuelles puissent aussi en profiter.


  • Mesures réalisées les 19 et 20 octobre 2026 dans un conteneur Docker de 10 vCPU.*

Vous avez un projet web ?

Création de site internet, e-commerce ou référencement : agence web à Paris et Besançon.

Devis gratuit