Un point sur la consommation mémoire de PHP

Nous avons récemment donné une conférence au sujet de la consommation mémoire de PHP.

Ici, il est question de précisions quant au fonctionnement interne de PHP et à sa consommation de mémoire.
Comme vous le savez, si vous avez passé en revue les slides de la conférence, la consommation mémoire de PHP au runtime est en fait la consommation du tas (heap) de son processus. Tout code qui alloue de la mémoire dans la source de PHP (malloc() principalement) étendra l’empreinte mémoire du processus.
Mais qu’est ce qui consomme alors ??

Il faut distinguer 2 parties :
- Le code de PHP lui même, oui, pour fonctionner, PHP a besoin de mémoire ;
- Le code que le développeur PHP écrit (en PHP donc), et qui va demander au moteur d’allouer de la mémoire.

Le cas numéro un n’est que dépendant des développeurs du langage, et des extensions que vous utilisez. A moins d’aller en fouiller le code source, et l’optimiser, vous ne pourrez y faire grand chose.
Le cas numéro 2 est déja beaucoup plus intéressant. Comment maîtriser la consommation mémoire de PHP lorsqu’il exécute du code que le développeur a écrit ?

Une première réponse est déja de connaitre, à la louche, combien consomme chaque type. Nous apportons donc ici des précisions par rapport au support de conférence.

En PHP, toute variable est représentée par une structure zval dans laquelle la valeur correspond à une structure zvalue_value.

typedef union _zvalue_value {
long lval; /* long value */
double dval; /* double value */
struct {
char *val;
int len;
} str;
HashTable *ht; /* hash table value */
zend_object_value obj;
} zvalue_value;

struct _zval_struct {
/* Variable information */
zvalue_value value; /* value */
zend_uint refcount__gc;
zend_uchar type; /* active type */
zend_uchar is_ref__gc;
};

Un simple sizeof() nous indique qu’une structure zval pèse 24 octets (alignée bien sûr, et sur un système 64bits bien sûr aussi). Si vous la regardez de plus près, vous verrez qu’à cela il faut ajouter le type concerné.

  • Une chaine de caractères va donc peser en plus du poids de la zval le poids pointé par le char* qui la représente, facile : c’est le nombre total de caractères de la chaine.
  • Un entier va donc peser en plus du poids de la zval le poids d’un long qui le représente, facile : sizeof(long) vaut 8.
  • Un tableau va donc peser en plus du poids de a zval le poids de la structure qui supporte les tableaux : une hashtable, son poids est de 72 octets, et chaque case de tableau utilise un pointeur, soit 8 octets supplémentaires par case, sans compter la valeur stockée dans cette case de tableau. Un tableau peut donc vite peser, au plus il y a d’éléments, au plus il sera lourd. N’est ce pas logique ?
  • Un objet est beaucoup plus complexe, car il utilise beaucoup de structures, à la louche, c’est le poids de la zval bien sûr additionné au poids des structures zend_object_value (16 octets), zend_object_handlers (200 octets) et surtout sa classe : une zend_class_entry (680 octets) tout cela sans encore compter sur le poids de chaque attributs de classe (statiques) et d’objets. Attention tout de même, ces structures sont souvent partagées : 2 objets de la même classe ne vont pas allouer 2 fois le poids de la classe, celle-ci va bien sûr être partagée (pointée) en mémoire, et il y a encore beaucoup d’optimisations comme ça.

Tout cela, c’était le poids d’une seule variable (fonction de son type), vous imaginez le poids total avec toutes les variables d’un script PHP ?
Là encore, PHP optimise.
Imaginons une chaine “foo”. Celle-ci pèse pour PHP en mémoire le poids d’une zval + le poids de la chaine, soit très exactement (c’est facile pour les chaines) 28 octets (ne pas oublier le caractère de fin de chaines en C : \0).

<?php
$a = "foo"; /* Consomme 28 octets */

Que se passe-t-il si on copie la variable ?

<?php
$a = "foo";
$b = $a;

PHP ne duplique pas la zval associée (ni la chaine, ça va de soit). Affecter $a dans $b en fait revient à faire pointer $b sur la valeur de $a, et non pas à dupliquer la valeur de $a pour l’affecter dans $b. C’est toute la magie de PHP et le rôle du champ refcount__gc dans la structure zval.
Ce champ est tout simplement un entier qui mémorise le nombre de variables PHP qui pointent vers la structure. A chaque fois que vous effectuez une affectation supplémentaire, PHP ne fait qu’incrémenter ce champ, et inversement : à chaque fois que vous supprimez une variable, PHP décrémente ce compteur. Pour supprimer une variable, en général, vous utilisez la fonction unset().
Lorsque le compteur tombe à zéro, PHP sait qu’aucune variable ne pointe vers la structure zval consommatrice, et il va donc la détruire, et libérer la mémoire pour vous !

<?php
$a = "foo"; /* Consomme immédiatement 28 octets, la zval contenant "foo" a un refcount__gc = 1 */
$b = $a; /* Aucune consommation supplémentaire, la zval contenant "foo" a un refcount__gc = 2  */
$c = $b; /* Aucune consommation supplémentaire, la zval contenant "foo" a un refcount__gc = 3  */

unset($c); /* Aucune consommation supplémentaire, la zval contenant "foo" a un refcount__gc = 2  */
$b = 8; /* Consomme immédiatement 32 octets, la zval contenant "foo" a un refcount__gc = 1, la zval contenant 8 a un refcount__gc = 1  */
unset($a); /* le refcount__gc de la zval contenant "foo" tombe à zéro, et PHP libère immédiatement 28 octets de mémoire */

?> /* PHP se termine, et nettoie son environnement tout seul, il va, entre autre, supprimer $b et libérer les 32 octets correspondants */

Si vous voulez que PHP libère de la mémoire, il est recommandé de se souvenir un peu tout au long d’un script quelles sont les variables lourdes et combien de variables PHP pointent vers elles.
Pour connaitre l’état du compteur refount__gc , vous pouvez utiliser Xdebug et sa fonction xdebug_debug_zval()

Lorsque vous utilisez des fonctions (ou des méthodes), c’est aussi assez simple et logique.
Chaque fonction possède sa propre “table de symboles”, c’est à dire que chaque variable (non statique) utilisée dans une fonction sera allouée à l’utilisation et automatiquement nettoyée (mémoire libérée) à la sortie de la fonction.
Aussi, lorsque vous passez une variable en argument à une fonction (sans parler de références), le refcount__gc de cette variable est augmenté de 1 car la fonction devient utilisatrice de votre variable.

<?php

function foo($var)
{
    $a = $var . 'foo'; /* $a a un refcount__gc à 1 */
    return $a; /* Le refcount__gc reste à 1, car la valeur retournée est utilisée, par $c plus bas, notamment */
}

$b = 'bar'; /* $b a un refcount__gc à 1 */
$c = foo($b); /* $b a un refcount__gc à 2, car maintenant $var pointe dessus à l'appel de la fonction */

PHP gère donc pour vous la mémoire et compte en permanence le nombre de variables PHP qui pointent vers une valeur en mémoire (zval). Tant que vous ne jouez pas avec des références, le comportement est relativement intuitif. Les références viennent changer ce comportement, d’une manière qui peut dérouter. Assurez-vous de bien les comprendre, elle feront possiblement l’objet d’un article ultérieur.
Souvenez vous aussi que si vous voulez libérer de la mémoire pour une variable donnée (car vous savez par exemple qu’elle est lourde), vous devez vous assurez que sa donnée n’est plus utilisée à aucune endroit, dans aucune fonction. Là seulement, PHP libèrera la mémoire de la zval en question, pas avant.

Un dernier point auquel on pense peu, mais qui peut avoir un impact non négligeable sur la consommation mémoire de PHP à l’exécution : le parsing de code.
PHP lit le code source que vous écrivez, et le transforme en instructions élémentaires que son moteur d’exécution est capable de comprendre, on appelle ces instructions des “opcodes”. Cette étape de transformation est lourde en temps CPU, surtout si PHP doit y passer à chaque invocation du script. C’est là le rôle du cache d’opcodes : éviter cette étape de transformation du code PHP en opcodes à chaque lancement du script.

Mais ces opcodes doivent être mémorisés, il le sont soit le temps d’être exécutés, puis détruits : c’est le cas classique sans cache d’opcodes, soit définitivement jusqu’à mort de PHP (cas avec cache d’opcodes, où PHP ne meurt jamais, ou très rarement).
Or, ces opcodes représentent tout le code source PHP, que celui-ci soit exécuté ou non, et leur densité est en moyenne 1,5 fois plus grande que celle du code PHP leurs ayant donné naissance.
Ainsi, si vous demandez à PHP d’analyser beaucoup de code, mais qu’au final, une petite partie de celui-ci sera exécuté : vous stockez beaucoup d’opcodes en mémoire pour finalement n’en exécuter que peu : vous gaspillez donc de la mémoire.
Voyons déja à quoi ressemble de l’opcode.

Soit le code PHP suivant :

<?php
function format_name($name)
{
    return ucfirst(strtolower(htmlentities($name)));
}

if (isset($_GET['name'])) {
    echo "Welcome " . format_name($_GET['name']);
} else {
    require 'error.php';
    $e = new Error;
    $e->display();
}

Une fois transformé en opcode, ça donne ça :

Filename:           /tmp/foo.php
Function:           format_name
Number of oplines:  9
Compiled variables: !0 = $name

  line  #     opcode                           result  operands
  -----------------------------------------------------------------------------
  2     0     RECV                             !0      1, f(0)
  4     1     SEND_VAR                                 !0, 1
        2     DO_FCALL                         $0      'htmlentities'
        3     SEND_VAR_NO_REF                          $0, 1
        4     DO_FCALL                         $1      'strtolower'
        5     SEND_VAR_NO_REF                          $1, 1
        6     DO_FCALL                         $2      'ucfirst'
        7     RETURN                                   $2

  5     8     RETURN                                   null


Filename:           /tmp/foo.php
Function:           main
Number of oplines:  19
Compiled variables: !0 = $e

  line  #     opcode                           result  operands
  -----------------------------------------------------------------------------
  2     0     NOP                                      
  7     1     FETCH_IS                         $0      '_GET'
        2     ISSET_DIM_OBJ                    ~1      $0, 'name'
        3     JMPZ                                     ~1, ->11

  8     4     FETCH_R                          $2      '_GET'
        5     FETCH_DIM_R                      $3      $2, 'name'
        6     SEND_VAR                                 $3, 1
        7     DO_FCALL                         $4      'format_name'
        8     CONCAT                           ~5      'Welcome ', $4
        9     ECHO                                     ~5
  9     10    JMP                                      ->18

  10    11    REQUIRE                                  'error.php'
  11    12    FETCH_CLASS                      ~7      'Error', f(4)
        13    NEW                              $8      ~7, ->15
        14    DO_FCALL_BY_NAME                         
        15    ASSIGN                                   !0, $8
  12    16    INIT_METHOD_CALL                         !0, 'display'
        17    DO_FCALL_BY_NAME                         

  14    18    RETURN                                   1

Vous voyez que la densité de l’opcode est plus grande, il y a plus d’opcode que de code PHP y correspondant, c’est normal car PHP a une syntaxe très dynamique, et une courte ligne de code PHP peut en réalité cacher beaucoup d’instructions, donc beaucoup d’opcodes. Ces opcodes sont par la suite exécutés par la machine virtuelle de PHP , c’est un peu comme le C qui est transformé en code binaire que le processeur cible saura exécuter. C’est aussi un fonctionnement similaire à Java, qui compile bien votre code java en un code intermédiaire exécuté par la JVM (il existe par contre beaucoup de différences avec PHP).

Le compilateur PHP effectue déja quelques optimisations de l’opcode généré, mais il n’est pas capable, par exemple, de savoir à l’avance quelle branche de code sera ou ne sera pas exécutée, il est donc obligé de tout compiler (de tout transformer en opcode), pour ensuite lancer l’exécution.

Stocker cet opcode coûte de la mémoire. Prenez le cas d’un super framework basé à quasiment 100% sur des classes, qui s’héritent l’une de l’autre. Il n’est alors pas rare de voir PHP analyser toute la classe et ses enfants (et il peut y en avoir beaucoup avec l’héritage), les compiler en opcode, mais le code n’appelle que quelques méthodes de la classe fille. Il en résulte un gaspillage mémoire qui peut être très important.

Voici un exemple simple, qui inclut un autoload de Symfony et la classe ContainerBuiler, mais ne l’utilise pas (à titre d’exemple). Analysons la consommation mémoire avant l’inclusion, et juste après :

<?php
echo memory_get_usage() / 1024 / 1024;

require_once "symfony/vendor/autoload.php";
require_once "Symfony/Component/DependencyInjection/ContainerBuilder.php";

/* Utilisons ContainerBuilder ici */

echo memory_get_usage() / 1024 / 1024;

Sur PHP 5.3.21, le résultat est le suivant (en Mo) :

0.60477447509766
1.1735153198242

Sur PHP 5.4.11, le résultat est le suivant (en Mo) :

0.20654487609863
0.56396484375

On constate que le stockage de l’opcode résultant du parsing du code PHP consomme environ 600Ko sous PHP5.3 et environ 300Ko sous PHP5.4. Oui, on le constate ici encore : PHP5.4 est beaucoup plus efficace en consommation mémoire que PHP5.3, dans tous les domaines.
Sinon, extrapolez ces résultats à tout un framework et vous aurez rapidement compris, une application Symfony2, de nos jours, c’est environ entre 50 et 100Mo d’opcodes.
A titre d’exemple, nos frontaux consomment 93Mo d’opcodes pour notre site web, www.blablacar.com, et nous utilisons bien sûr Symfony2 (PHP5.3, nous espérons 5.4 prochainement ;-) )

2 thoughts on “Un point sur la consommation mémoire de PHP

  1. Merci pour cet article (Il est toujours intéressant de connaitre la gestion des zval, du concept des shared pointer)

    Ceci étant sur le sujet de la taille prise par le “p-code”, je ne vois pas trop ce que l’on peut y faire franchement. C’est un peu comme si on me disait dans un projet en C++ :
    “Tiens tu as linké avec user32.dll alors que tu n’utilises de cette dll que 2 ou 3 fonctions !?”

    Pédagogique là aussi, mais inapplicable dans le monde réel… Où alors dans des cas vraiment bien particuliers !? (et encore)

  2. Pingback: Gestion de la mémoire avec PHP | Blog de françois DAMBRINE