Benchmarking PHP – Partie 3

Voici la suite du premier et du deuxième article de cette série sur les performances de PHP.

PHP est un langage particulièrement permissif et tolérant, qui continue notamment de fonctionner correctement même si une variable n’a pas été définie (à l’exception des propriétés d’objet).
En effet, PHP n’émet qu’un simple avertissement (de type E_NOTICE) dans ces cas-là et continue son exécution normalement.

Le code suivant :

$b = 1 + $a;

produira simplement le message :

Notice: Undefined variable: a in test.php on line 2

La grande tolérance à ce genre d’erreurs de PHP et de mauvaises habitudes de programmation ont entraîné la banalisation de ces messages et il est généralement admis de ne pas en tenir compte. PHP propose en effet de filtrer les messages d’erreur par type. Ainsi, la commande suivante est préconisée par de nombreuses distributions de PHP :

error_reporting(E_ALL ^ E_NOTICE);

Comme son nom l’indique vaguement, cette commande indique à PHP de rapporter toutes les erreurs, à l’exception du type E_NOTICE.

Mais le fait de ne pas rapporter ces erreurs n’a-t-il aucune autre conséquence sur l’exécution de PHP elle-même ?

Etat des lieux

Lorsqu’une erreur est déclenchée dans le code PHP, quel que soit son niveau (et donc y compris pour les erreurs de type E_NOTICE), un appel à la fonction native trigger_error() est exécuté.
Il est de notoriété publique que l’exécution de cette fonction soit lente.

Ne pas rapporter les erreurs concernées entraîne-t-il un gain de temps au niveau de l’exécution ?

Le protocole de test

Pour ce test, j’ai choisi d’exécuter les cas suivants :

  1. ajouter 10 millions de fois la valeur d’une variable non définie,
  2. exécuter la même addition sans rapporter les erreurs au journal global des erreurs de PHP,
  3. exécuter la même addition mais en testant à chaque itération l’existence de la variable,
  4. exécuter l’addition avec une variable définie

Tous les cas ont été exécutés avec la commande :

error_reporting(E_ALL ^ E_NOTICE);

Résultats

Les résultats sont édifiants :

Variable non définie + log des erreurs : 7.55 s
Variable non définie + pas de log des erreurs : 7.29 s
Variable non définie mais testée : 1.08 s
Variable définie : 1.09 s

La conclusion est simple. Les déclenchements de la fonction trigger_error() dans les cas de variables non définies prennent beaucoup de temps. Le code source s’exécute ici 7 fois plus lentement qu’avec une variable dument définie.

PHP incite-t-il au laxisme ?

Mon avis sur la question est tranché : Oui.

En effet, l’autre inconvénient de ne pas rapporter les erreurs de type E_NOTICE est de laisser passer des erreurs parfois dommageables.

L’exemple ci-dessous ne crée aucune erreur « visible » et pourtant n’apporte pas la solution attendue :

error_reporting(E_ALL ^ E_NOTICE);

function valide_si_conforme($txt) {
    return (empty($texte) || preg_match('/^[a-z]+$/', $txt));
}
echo valide_si_conforme('123456789');

En effet, la fonction valide_si_conforme() devrait renvoyer un résultat positif uniquement si le paramètre $txt est vide ou ne contient que des lettres minuscules.

L’appel :

valide_si_conforme('123456789')

devrait renvoyer 0 ou false.

Il n’en est rien et le script renvoie 1 ou true. L’erreur qui inscrit le paramètre $texte en lieu et place de $txt dans l’appel à empty() déclenche une erreur fonctionnelle mais PHP n’en tient pas compte et la détection de ce problème est alors laissée à l’appréciation du développeur. Et si celui-ci n’a pas la discipline ou l’expérience suffisante pour savoir où il faut et où il ne faut pas tester une valeur, de nombreuses erreurs sont susceptibles d’apparaître.

Mais alors que faire ?

La logique voudrait que toutes les erreurs soient rapportées et que les variables soient testées, tant sur leur valeur que sur leur existence.
Cette démarche m’a personnellement évité beaucoup de problèmes par le passé ou permis de corriger bon nombre de bugs dans des codes sources tiers.

Code du test

<?php
    error_reporting(E_ALL ^ E_NOTICE);
    header("Content-Type: text/plain; charset=UTF-8");

    printf("Variable non définie + log des erreurs : ");
    $start = microtime(true);
    $b = 0;
    for ($i = 0; $i < 10000000; $i++)
        $b += $a;
    $elapsed = microtime(true) - $start;
    printf("%0.2f s\n", $elapsed);

    printf("Variable non définie + pas de log des erreurs : ");
    ini_set('log_errors', 0);
    $start = microtime(true);
    $b = 0;
    for ($i = 0; $i < 10000000; $i++)
        $b += $a;
    $elapsed = microtime(true) - $start;
    printf("%0.2f s\n", $elapsed);

    printf("Variable non définie mais testée : ");
    $start = microtime(true);
    $b = 0;
    for ($i = 0; $i < 10000000; $i++) {
        if (isset($a))
            $b += $a;
    }
    $elapsed = microtime(true) - $start;
    printf("%0.2f s\n", $elapsed);

    printf("Variable définie : ");
    $start = microtime(true);
    $b = 0;
    $a = 1;
    for ($i = 0; $i < 10000000; $i++)
        $b += $a;
    $elapsed = microtime(true) - $start;
    printf("%0.2f s\n", $elapsed);
?>
Publicités

PHP – Jongler avec les dates

Lors de mes recherches pour une modification de WordPress, je suis tombé par hasard dans la documentation de PHP sur la fonction date_parse_from_format(). Cette fonction propose de décomposer une date à partir de sa représentation en chaîne de caractères. Cependant, à la différence de strtotime(), il est possible de passer en paramètre le format de la date à décomposer. Il est donc désormais possible de retrouver les éléments constitutifs d’une date (heure incluse) plus facilement qu’auparavant.

Exemple

Avant lorsque l’on souhaitait transposer une date SQL en date « française », on pouvait procéder comme suit :

$maDateSQL = '2011-07-18';
preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $maDateSQL, $regs);
$maDateFr = sprintf('%d/%d%d', $regs[3], $regs[2], $regs[1]);

Avec date_parse_from_format(), le code est tout de suite plus agréable à lire (de plus, certaines vérifications plus poussées sont effectuées au passage car le code ci-dessus ne vérifie que la présence de chiffres et pas leur cohérence) :

$maDateSQL = '2011-07-18';
$arr = date_parse_from_format('Y-m-d', $maDateSQL);
$maDateFr = sprintf('%d/%d/%d', $arr['day'], $arr['month'], $arr['year']);

Problème

Je disais précédemment qu’il était « désormais possible » d’utiliser cette fonction. A vrai dire, pas tout à fait. date_parse_from_format() n’est disponible qu’à partir de PHP 5.3 !

A vrai dire, lorsque l’on a la maîtrise de son hébergement, il s’agit d’un détail. Mais le passage à PHP 5.3 a des conséquences si l’on migre depuis la version 5.2 et tout le monde n’y est pas encore passé. De plus, lorsque l’on travaille avec un logiciel comme WordPress, massivement utilisé dans le monde, il faut pouvoir s’adapter au plus grand nombre et ne pas se limiter au haut du panier.

Résolution

Du coup, j’ai recodé la fonction directement en PHP et elle rend à peu près les mêmes résultats que la fonction native.

Le code source est disponible sous licence GPL version 2 et téléchargeable ici (requiert PHP 4.0.6 ou version ultérieure, c’est-à-dire à peu près tout le monde).

Benchmarking PHP – Partie 1

Pour ceux qui ne connaîtrait pas encore PHP, je vous laisse découvrir sa définition sur le site officiel.

Comme tous les langages de scripting, PHP est un outil très permissif et configurable à volonté qui facilite grandement le travail des développeurs.
Cependant, la permissivité des outils est parfois source de mauvaises habitudes pour les développeurs et ce qui peut apparaître comme étant une fonctionnalité intéressante pour gagner du temps, s’avère en fait être un problème en terme de performance au final.

Le premier benchmark de cette série porte sur les différentes façons d’accéder à une entrée de tableau associatif sans vérifier son existence au préalable ou, au contraire, en vérifiant son éligibilité selon plusieurs méthodes.

Protocole de test

Pour tester les performances de chacun des cas suivants, nous tentons d’accéder une entrée inexistante ‘id’ du tableau associatif $_GET et vérifions que sa valeur est différente de 0. Pour faire apparaître d’éventuelles différences dans nos tests, ils seront effectués sur un million d’itérations.

Voici, les différents tests qui seront effectués dans ce benchmark :

1. Aucune protection

Dans le premier test, nous n’effectuons aucun test d’existence de l’entrée dans le tableau. Le code se résume donc à :

if ($_GET['id'] != 0)

2. Protection avec array_key_exists() :

Dans le deuxième test, nous vérifions que la clé ‘id’ existe au sein du tableau $_GET grâce à la fonction array_key_exists(). Le code se résume cette fois-ci par :

if (array_key_exists('id', $_GET) && $_GET['id'] != 0)

3. Protection avec isset() :

Dans le troisième test, nous vérifions simplement si $_GET[‘id’] a été défini avant de tester sa valeur grâce à la fonction isset(). Le code est cette fois-ci :

if (isset($_GET['id']) && $_GET['id'] != 0)

4. Test avec empty() :

Dans le dernier test, nous vérifions uniquement si la variable $_GET[‘id’] est vide ou non grâce à la fonction empty(). Le code du test est donc :

if (!empty($_GET['id']))

Résultats

Voilà les résultats de l’exécution des tests :

Nombre d'itérations : 1000000

Aucune protection : 0.652613162994 ms
Protection avec array_key_exists() : 0.448069095612 ms
Protection avec isset() : 0.246340990067 ms
Protection avec empty() : 0.248647928238 ms

Nos grands gagnants sont donc isset() et empty().

Comme pouvait le laisser supposer l’introduction de cet article, ne pas protéger ses tests de variable est permis par PHP mais entraîne une réduction de performances. Le surcoût de vouloir accéder à une variable qui n’existe pas est que PHP déclenche une erreur de type E_NOTICE. Le réglage par défaut de PHP induit que cette erreur ne sera pas reportée à l’écran pour l’utilisateur via un message d’erreur, mais le déclenchement de cette erreur est bien réel.

Le cheminement de PHP dans ce cas-là est le suivant (qu’il s’agisse du gestionnaire d’erreur par défaut ou non) :

  1. Utilisation d’une entrée de tableau associatif qui n’existe pas
  2. Déclenchement d’une erreur
  3. Récupération de l’erreur par le gestionnaire d’erreur
  4. Test du niveau de reporting actuel via error_reporting()
  5. Affichage ou non d’un message d’erreur

On constate donc que ce chemin est certes le plus court pour le développeur, mais est relativement long pour PHP.

Certes, l’exécution d’un million d’itérations dure moins d’une seconde, il est donc tout à fait légitime de négliger cet aspect, mais gardons à l’esprit qu’un simple !empty($_GET[‘id’]) est trois fois plus rapide pour PHP, pour une longueur de code similaire et pour le même résultat.

En changeant cette habitude permissive, nous devrions pouvoir améliorer les performances générales de nos codes sources. Même si cela ne constitue qu’un infime gain, sur un environnement concurrentiel à très forte demande, cela peut contribuer à une meilleure qualité de service.

Note : comme le montre le code ci-dessous, les tests originaux ont été effectués sur un tableau vide. Les mêmes tests ont été effectués sur un tableau de 100000 entrées (ne contenant toujours pas l’entrée recherchée) et les valeurs sont similaires.

Code du test

<?php
    header('Content-type: text/plain; charset=UTF-8');
    set_time_limit(0);
    error_reporting(E_ALL ^ E_NOTICE);

    define('ITERATION_COUNT', 1000000);
    printf("Nombre d'itérations : %d\n\n", ITERATION_COUNT);

    $start = microtime(true);
    for ($i = 0; $i < ITERATION_COUNT; $i++) {
        if ($_GET['id'] != 0)
            echo 'ok';
    }
    $elapsed = microtime(true) - $start;
    echo "Aucune protection : {$elapsed} ms\n";

    $start = microtime(true);
    for ($i = 0; $i < ITERATION_COUNT; $i++) {
        if (array_key_exists('id', $_GET) && $_GET['id'] != 0)
            echo 'ok';
    }
    $elapsed = microtime(true) - $start;
    echo "Protection avec array_key_exists() : {$elapsed} ms\n";

    $start = microtime(true);
    for ($i = 0; $i < ITERATION_COUNT; $i++) {
        if (isset($_GET['id']) && $_GET['id'] != 0)
            echo 'ok';
    }
    $elapsed = microtime(true) - $start;
    echo "Protection avec isset() : {$elapsed} ms\n";

    $start = microtime(true);
    for ($i = 0; $i < ITERATION_COUNT; $i++) {
        if (!empty($_GET['id']))
            echo 'ok';
    }
    $elapsed = microtime(true) - $start;
    echo "Protection avec empty() : {$elapsed} ms\n";
?>

ImageMagick, Mac OS X et PHP

Ayant troqué récemment mon vieux Pentium IV pour un iMac 27″ flambant neuf, j’ai dû réinstaller un certain nombre de logiciels, dont ImageMagick, qui est un peu le couteau Suisse du développeur en matière de graphisme.

La tâche fut relativement aisée pour configurer l’exécution en ligne de commande, mais cela fut beaucoup plus compliqué d’exécuter le logiciel via PHP. Malheureusement, ImageMagick étant utilisé sur certains de mes projets professionnels, je me dois d’adapter son utilisation aux conditions réelles de la production finale. Je devais donc passer par PHP pour « créer » les images qui m’intéressaient.

Les tutorials sur ce point n’étant pas légion sur internet (ou alors bien cachés), voici la solution que j’ai employée :

<?php
 $home = getenv('HOME');
 putenv("MAGICK_HOME={$home}/Applications/ImageMagick");
 $path = getenv('PATH');
 $mg_home = getenv('MAGICK_HOME');
 putenv("PATH={$mg_home}/bin:{$path}");
 putenv("DYLD_LIBRARY_PATH={$mg_home}/lib");

 $path = realpath('./gradient.jpg');
 $cmd = "convert -size 100x100 gradient:red-yellow \"{$path}\"";
 exec($cmd);
?>

A partir du moment où vous aurez installé ImageMagick dans un sous-dossier Applications/ImageMagick de votre dossier utilisateur Mac OS X, ce script PHP marchera.

Voici l’image (très simpliste) qu’il génère :