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 2

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

Je me suis intéressé cette fois-ci à un autre « côté pervers » des langages de scripting tels que PHP, à savoir leur aspect faiblement typé.

En programmation, deux tendances s’opposent : le typage fort (ou typage strict) et le typage faible. Comme son nom le laisse entendre, le typage se rapporte aux types de données utilisés couramment : nombre entiers, nombres réels, chaînes de caractères, etc…

Dans un langage fortement typé, les fonctions ne peuvent être appelées qu’avec les types de données pour lesquels elles ont été conçues. Si une fonction requiert que son premier paramètre soit en entier, vous obtiendrez une erreur de compilation si vous essayez de lui envoyer une chaîne de caractères.

La plupart des langage de scripting sont, quant à eux, faiblement typés, c’est-à-dire, qu’il est possible d’appeler des fonctions avec un autre type de données que celui avec lequel elles ont été conçues. Dans certains cas, comme JavaScript ou PHP avant sa version 5, les paramètres de fonction n’ont même pas de type, uniquement un nom. Cela simplifie la programmation mais complique le débogage dans certains cas, notamment avec les objets. Avec l’arrivée de PHP 5, il est possible d’appliquer un typage fort sur certains paramètres de fonction. La raison principale de ce comportement est que les langages de scripting sont compilés au moment de leur exécution.

Etat des lieux

Dans le cas de PHP, c’est à sa capacité à traiter des chaînes de caractères comme des nombres que j’ai choisi de m’intéresser.

Le code suivant est parfaitement légal en PHP :

$a = 3;
$b = '4';
$c = $a + $b;

Le résultat sera bien 7.

Mais travailler avec des chaînes de caractères comme avec des entiers n’est-il pas plus long que de travailler directement avec des entiers ?
La réponse théorique est évidente, mais dans la pratique, certains éléments font que les choses ne sont pas si simples.

Le protocole de test

Pour ce test, j’ai choisi d’effectuer 1000 fois l’addition de tableaux d’entiers contenant :

  1. 100 000 entiers aléatoires natifs
  2. 100 000 entiers aléatoires sous forme de chaînes de caractères
  3. 100 000 entiers aléatoires sous forme de chaînes de caractères qui seront convertis « manuellement » au moment du traitement

Tous les entiers aléatoires sont compris entre 0 et 100 inclus.

Résultats

Les résultats sont évidents :

Somme de 100 000 entiers natifs (1000 itérations) : 2.017847 s
Somme de 100 000 entiers sous forme de strings (1000 itérations) : 9.314373 s
Somme de 100 000 entiers sous forme de strings convertis en natifs (1000 itérations) : 57.042635 s

Les entiers natifs sont les grands vainqueurs, avec un calcul plus de 4x plus rapide que les entiers sous forme de chaîne de caractères.

J’ai poussé les tests avec des entiers compris entre 0 et 1 000 000. Les résultats montrent que la longueur des chaînes de caractères (plus le nombre est grand, plus la chaîne est longue) influent sur les performances globales. Dans le cas présent, les entiers natifs sont presque 6x plus rapide que les chaînes de caractères.

Somme de 100 000 entiers natifs (1000 itérations) : 2.561392 s
Somme de 100 000 entiers sous forme de strings (1000 itérations) : 12.046772 s
Somme de 100 000 entiers sous forme de strings convertis en natifs (1000 itérations) : 55.341405 s

PHP incite-t-il à l’usage des chaînes de caractères ?

A vrai dire, cela n’est pas le cas. Le typage faible de PHP permet cette utilisation aberrante des chaînes de caractères, mais rien dans son fonctionnement ne force à aller dans ce sens. En revanche, l’utilisation de PHP avec MySQL incite fortement à ce comportement.

Les extensions de PHP qui permettent de communiquer avec MySQL présentent une étrangeté. Toutes les valeurs retournées par MySQL lors d’une requête (exceptée NULL) sont des chaînes de caractères, que le champ original contienne un entier ou bien tout à fait autre chose. Du coup, ce que montre ce test, c’est que nous gagnerions beaucoup à ce que MySQL permette de renvoyer à PHP les types natifs de ses champs, mais aujourd’hui aucune possibilité à ma connaissance n’est proposée dans ce sens (que ce soit du côté de MySQL ou de PHP).

Mais alors pourquoi vouloir utiliser des entiers natifs ?

Il y a quelques cas que j’ai rencontrés personnellement, récemment, où l’utilisation d’entiers natifs a son avantage. Le plus courant est l’encodage JSON, qui permet de faire transiter de manière simplifiée des données.

Considérons les deux alternatives que sont un tableau d’entiers sous forme de chaînes de caractères (A) et le même tableau sous forme d’entiers (B), voilà ce qui ressort en JSON :

A = ["1","2","3","4","5"]
B = [1,2,3,4,5]

Vous pourrez constater deux choses :

  1. les nombres sont entourés de guillemets (« ) pour indiquer à JSON qu’il s’agit de chaînes de caractères et donc le résultat encodé en JSON est plus long
  2. lorsque ces informations encodées en JSON vont être décodées de l’autre côté, ce sont des chaînes de caractères qui vont être générées. Si le destinataire est un langage faiblement typé, tout va bien, mais s’il s’agit d’un langage fortement typé comme Java, attention aux dégâts.

Astuce : convertir des chaînes en entiers

Voilà un petit code source très sommaire mais qui résume à lui seul la méthode à employer pour convertir un tableau d’entiers sous forme de chaînes de caractères en tableau d’entiers natifs :

$tableau = array('1', '2', '3');
$tableau_natifs = array_map('intval', $tableau);

Code du test

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

    // Construction du tableau d'entiers natifs
    $intArray = array();
    for ($i = 0; $i < 100000; $i++)
        $intArray[] = mt_rand(0, 1000000);

    // Construction du tableau d'entiers sous forme de strings
    $strArray = array_map('strval', $intArray);

    // Temps de calcul avec le tableau d'entiers natifs
    $debut = microtime(true);
    for ($i = 0; $i < 1000; $i++)
        array_sum($intArray);
    $elapsed = microtime(true) - $debut;
    printf("Somme de 100 000 floats natifs (1000 itérations) : %f s\n\n", $elapsed);

    // Temps de calcul avec le tableau d'entiers sous forme de strings
    $debut = microtime(true);
    for ($i = 0; $i < 1000; $i++)
        array_sum($strArray);
    $elapsed = microtime(true) - $debut;
    printf("Somme de 100 000 floats sous forme de strings (1000 itérations) : %f s\n\n", $elapsed);

    // Temps de calcul avec le tableau d'entiers sous forme de strings avec conversion vers entiers natifs
    $debut = microtime(true);
    for ($i = 0; $i < 1000; $i++)
        array_sum(array_map('intval', $strArray));
    $elapsed = microtime(true) - $debut;
    printf("Somme de 100 000 floats sous forme de strings convertis en natifs (1000 itérations) : %f s\n\n", $elapsed);
?>

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 :