Comparatif des performances des ORM PHP
Par Laurentj le jeudi, novembre 29 2007, 23:40 - Projets - Lien permanent
Dans un billet précédent, j'avais expliqué en quoi jDao était plus performant que d'autres framework ORM comme Doctrine ou Propel. Ce n'était cependant que de la théorie, et il m'a semblé après coup qu'il serait bon de voir, en pratique, si la théorie était vérifiée. J'ai donc fait une série de tests sur Doctrine, Propel, phpMyObject et jDao, afin de comparer leurs performances.
Avertissement, 03 dec 2007 : Je viens de remarquer que l'extension xdebug me donne des résultats qui ne permettent pas d'arriver aux mêmes conclusions qu'avec un simple calcul fait avec un microtime(). Aussi les résultats montrés dans ce billet et les conclusions ne sont pas bonnes. Je publierais de nouveau résultats sans xdebug
Disclaimer, 03 dec 2007 : I just discover that I cannot have same conclusions between results given by the xdebug extension (or when it is just activated) and results given by some simple microtime() (with xdebug deactivated). So following results and conclusions are wrong. I will publish new results without xdebug...
Petit rappel sur les frameworks ORM
Ce sont des outils permettant de manipuler les données d'une base au travers d'objets qui représentent généralement des enregistrements de tables. Ils évitent d'utiliser du SQL, et facilite donc la sélection, l'enregistrement ou la destruction des données. En fait, les frameworks ORM ont, en théorie, pour objectif de mimer les bases de données objets (hélas peu courantes sur le marché), en s'appuyant sur une base de donnée relationnelle.
Conditions des tests
Les tests ont été effectué sur une machine ayant un processeur Athlon 2600+, avec 512Mo de RAM et sur laquelle est installé un système Kubuntu 6.10. Les quatre frameworks ORM ont été testé avec la même base de donnée, qui contient une table city (que j'ai récupéré quelque part sur le web, et contenant 36 682 communes françaises) et une table departement (contenant les 96 departements français). En voici le schéma :
CREATE TABLE `city` ( `id` int(11) NOT NULL auto_increment, `name` varchar(200) NOT NULL default '', `region` varchar(2) NOT NULL default '', `subregion` varchar(200) NOT NULL default '', `postcode` varchar(8) NOT NULL default '', PRIMARY KEY (`id`), KEY `region` (`region`) ); CREATE TABLE `departement` ( `code` varchar(2) NOT NULL, `nom` varchar(50) NOT NULL, PRIMARY KEY (`code`) );
Le champs region de la table city est une clé étrangère vers la table departement.
Les logiciels utilisés sont ceux fournis dans la distribution Kubuntu : Mysql 5.0.24a, Apache 2.0.55 et PHP 5.1.6. Les frameworks ORM testés sont tous les dernières versions stables disponibles il y a presque trois semaines (les tests ont été fait durant le week-end du 11 novembre, désolé pour ce compte rendu tardif ;-)) : jDao livré dans Jelix 1.0beta3.1, Doctrine 1.0 beta 1, Propel 1.2.1, et phpMyObject 0.10.
Préparations des scripts de tests
L'installation des frameworks ORM est une installation standard, avec la configuration d'origine, donc sans "tuning" effectué. Seule exception : j'ai eu pitié de Doctrine, et j'ai donc utilisé la version "compilé", qui est en fait un fichier de 496ko (jDb+jDao ne fait que 81ko) contenant tout le code source de Doctrine, évitant les nombreux accès disque durant un test (m'enfin bon, vu les résultats, je ne pense pas que ça change grand chose).
Afin de lancer les tests, il faut écrire le code qui va permettre aux frameworks de connaitre le schéma de base de donnée.
Pour jDao
Il est intégré au framework Jelix (pas de version "standalone" disponible). J'ai donc réalisé un module avec un contrôleur, pour lancer les tests. Pour jDao, j'ai crée trois fichiers DAO pour les deux tables :
city.dao.xml
<dao xmlns="http://jelix.org/ns/dao/1.0">
<datasources> <primarytable name="city" realname="city" primarykey="id" /> </datasources>
<record>
<property name="id" datatype="autoincrement"/>
<property name="name" datatype="string" required="true"/>
<property name="region" datatype="string" required="true"/>
<property name="subregion" datatype="string" required="true"/>
<property name="postcode" datatype="string" required="true"/>
</record>
<factory>
<method name="getByLimit" type="select"> <parameter name="nb" /><limit offset="0" count="$nb"/></method>
</factory>
</dao>
departement.dao.xml
<dao xmlns="http://jelix.org/ns/dao/1.0">
<datasources> <primarytable name="departement" realname="departement" primarykey="code" /> </datasources>
<record>
<property name="code" datatype="string" required="true"/>
<property name="nom" datatype="string" required="true"/>
</record>
</dao>
citydep.dao.xml : c'est un objet jdao spécifique pour récupérer à la fois une ville et son département associé.
<dao xmlns="http://jelix.org/ns/dao/1.0">
<datasources>
<primarytable name="city" realname="city" primarykey="id" />
<foreigntable name="departement" realname="departement" primarykey="code" onforeignkey="region" />
</datasources>
<record>
<property name="id" datatype="autoincrement"/>
<property name="name" datatype="string" required="true"/>
<property name="region" datatype="string" required="true"/>
<property name="subregion" datatype="string" required="true"/>
<property name="postcode" datatype="string" required="true"/>
<property name="departement" fieldname="nom" table="departement" datatype="string" />
</record>
<factory>
<method name="getByLimit" type="select"> <parameter name="nb" /> <limit offset="0" count="$nb"/> </method>
</factory>
</dao>
Ces fichiers sont au premier lancement, transformés par jDao en classes PHP et stockées dans des fichiers de cache. Ces derniers seront alors chargé les fois suivantes comme des fichiers de classes classiques.
Pour Doctrine
Il faut écrire des classes décrivant le schema. Les voici pour les tests :
class city extends Doctrine_Record {
public function setTableDefinition() {
$this->hasColumn('id', 'integer',11, array('notnull' => true, 'primary' => true, 'unsigned' > true, 'autoincrement' => true));
$this->hasColumn('name', 'string',200, array('notnull' => true));
$this->hasColumn('region', 'string',2, array('notnull' => true));
$this->hasColumn('subregion', 'string',200, array('notnull' => true));
$this->hasColumn('postcode', 'string',8, array('notnull' => true));
}
public function setUp() {
$this->hasOne('departement', array('local' => 'region', 'foreign' => 'code'));
}
}
class departement extends Doctrine_Record {
public function setTableDefinition() {
$this->hasColumn('code', 'string',2, array('notnull' => true, 'primary' => true));
$this->hasColumn('nom', 'string',50, array('notnull' => true));
}
public function setUp() {
// notice the 'as' keyword here
$this->hasMany('city', array('local' => 'code','foreign' => 'region'));
}
}
Pour Propel
Comme jDao, le schema doit étre déclaré dans un fichier XML que voici :
<database name="insee" defaultIdMethod="native">
<table name="city" description="City Table">
<column name="id" type="integer" primaryKey="true" autoIncrement="true" required="true" description="Id"/>
<column name="name" type="varchar" size="200" required="true" description="name"/>
<column name="region" type="varchar" size="2" required="true" description="region foreign key"/>
<column name="subregion" type="varchar" size="200" required="true" description=""/>
<column name="postcode" type="varchar" size="8" required="true" description=""/>
<foreign-key foreignTable="departement">
<reference local="region" foreign="code"/>
</foreign-key>
</table>
<table name="departement" description="departement Table">
<column name="code" type="varchar" size="2" required="true" primaryKey="true" description="code"/>
<column name="nom" type="varchar" size="50" required="true" description="Name"/>
</table>
</database>
Il faut ensuite lancer un script qui converti ce fichier en un ensemble de classes que l'on peut alors appeler dans la page web.
Pour phpMyObject
Il n'y a pas besoin de déclarer le schema quelque part. Il le "devine" à chaque fois que l'on veut faire une requête, ce qui le pénalise d'ailleurs en terme de performance. Notez cependant que d'après son auteur, la prochaine version sera plus optimisée : les informations du schema seront apparement stockées dans un fichier cache, évitant cette "divination" à chaque requête.
Notez aussi que cette "divination" n'est pas forcément un avantage décisif pour PMO par rapport aux autres frameworks, parce que d'une part on ne peux pas créer un objet qui serait mappé seulement sur une partie d'une table (ce qui pourtant peut être intéressant dans certains cas pour des raisons de performances, en effet, il faut systématiquement faire un select * apparement), et d'autres part parce que les autres framework proposent un script en ligne de commande permettant de créer les classes ou fichiers XML très rapidement.
Les tests
Les tests consistent en une série de fonctions, qui effectuent un traitement correspondant à une requête SQL précise, et seul le temps d'exécution de ces fonctions est calculée. C'est à dire qu'il n'est pas pris en compte le temps chargement/parsing du script, l'initialisation du framework ORM etc.
Je n'ai fait que des tests effectuant des requêtes de type SELECT. D'une part, parce que les tests sont suffisamment long à faire vu le temps dont je dispose, et d'autre part parce que la majeur partie des requêtes SQL effectuées par un site web classique sont des SELECT. Par exemple, avec un CMS ou même un wiki, le contenu des pages est bien plus consulté qu'il n'est modifié durant toute la vie du site.
À la fin de chaque fonctions de test, l'objectif est d'obtenir un tableau PHP contenant les objets correspondants aux enregistrements sélectionnés.
Pour chaque type de tests, j'ai essayé d'utiliser la meilleure façon de faire pour chaque framework ORM, d'après ce qui est dit dans les manuels en ligne. Maintenant, n'étant pas expert non plus avec ces frameworks (mise à part jDao), peut-être existe-t-il des astuces permettant de faire plus performant avec tel ou tel framework. N'hésitez pas alors à me le signaler.
Les mesures indiquées sont la moyenne de cinq mesures consécutives effectuées d'affilé, et données par le module xdebug. Si dans une série il y avait une valeur exagérément trop haute (ça peut arriver si le test a été interrompu trop longtemps par un autre processus du système), elle était éliminée et le test relancé.
Test 1 : benchLittleSelect
Il s'agit ici de récupérer 20 villes de la table cities. C'est à mon sens une requête simple et représentative de bon nombres de petites requêtes que l'on trouve sur un site, pour remplir une listbox par exemple, ou afficher les messages d'un forum.
Doctrine :
$cities = $conn->query('FROM city LIMIT 20');
J'ai essayé, d'utiliser une autre manière, pour voir si il y avait plus performant, en faisant :
$cities = Doctrine_Query::create()->from('city')->limit(20)->execute();
Mais les résultats sont similaires.
Propel :
$c = new Criteria(); $c->setLimit(20); $list = CityPeer::doSelect($c);
PMO :
$controler = new PMO_MyController();
$map = $controler->queryController("SELECT * FROM city LIMIT 20");
jDao :
J'appelle simplement la méthode getByLimit définie dans le fichier XML
$liste = jDao::get('city')->getByLimit(20);
$results=array();
foreach($liste as $dep){
$results[]=$dep;
}
Pour les sélections, jDao renvoi toujours un itérateur sur les enregistrements fournis par la base de donnée, ce qui évite la construction d'un tableau et économise de la mémoire. Aussi, pour être à égalité avec les autres ORM en terme de résultats, c'est à dire avoir au final un tableau contenant les objets correspondants à chaque enregistrement, j'ai rajouté une boucle remplissant un tableau. Et ce sera la même chose dans les autres tests.
Résultats : Doctrine : 55 818; Propel : 29 209; PMO : 25 841; jDao : 13 690
Test 2 : benchLittleComplexSelect
On récupère 20 villes, mais avec les départements correspondants. Il s'agit donc ici d'un select avec une jointure simple entre la table city et la table departement.
Doctrine :
$cities = $conn->query('FROM city c, departement d WHERE c.region = d.code LIMIT 20');
Pas de résultat pour ce test. Il y a en effet un bug dans Doctrine, qui génère une requête SQL invalide : il manque une virgule entre les deux tables city et departement dans la clause FROM. En effet, la requête que l'on donne à Doctrine est un pseudo SQL. Doctrine parse ce pseudo SQL et régénère derrière une vraie requête SQL. J'ai essayé de voir si la version en développement de Doctrine corrigeait le bug, mais ce n'était pas le cas à l'époque du test, (je n'ai pas reessayé depuis). J'ai essayé aussi en faisant l'autre méthode :
$cities = Doctrine_Query::create()->from('city c')
->leftJoin("departement d")->where('c.region = d.code')->limit(20)->execute();
Mais la sanction est la même : erreur de syntaxe SQL.
Propel :
$c = new Criteria(); $c->setLimit(20); $liste = CityPeer::doSelectJoinDepartement($c);
PMO :
$controler = new PMO_MyController();
$map = $controler->queryController("SELECT * FROM city,departement WHERE city.region = departement.code LIMIT 20");
jDao :
$liste = jDao::get('citydep')->getByLimit(20);
$results=array();
foreach($liste as $dep){
$results[]=$dep;
}
Résultats : Doctrine : na; Propel : 31 149; PMO : 54 287; jDao : 13 597
On peut remarquer que PMO ne semble pas apprécier les jointures, vu la différence énorme avec les autres ORM et par rapport à une requête simple. Propel perd un peu en performance. Seul jDao reste constant.
Test 3 : benchMediumSelect
On va récupérer ici tous les départements de la table departement, ce qui correspond à une centaine d'enregistrements. Là encore, j'ai pris un exemple qui arrive quelque fois dans certains formulaires où il faut remplir une listbox.
Doctrine :
$deps = $conn->getTable('departement')->findAll();
Propel :
$list = DepartementPeer::doSelect(new Criteria());
PMO :
$controler = new PMO_MyController();
$map = $controler->queryController("SELECT * FROM departement");
jDao :
$liste = jDao::get('departement')->findAll();
$results=array();
foreach($liste as $dep){
$results[]=$dep;
}
Résultats : Doctrine : 217 274; Propel : 50 565; PMO : 117 030; jDao : 37 440
Bien qu'il n'y ait que deux champs sur la table departement (contre 5 dans city), les performances s'éffondrent pour Doctrine et PMO...
Test 4 (benchBigSelect) et test 6 (benchHugeSelect)
Il s 'agit du même test que le test 1, mais au lieu de récupérer 20 villes, on en récupère 1000 (test 4) et 10 000 (test 6). Ce genre de sélection n'a pas vraiment de réalité dans un contexte de génération de page web, mais peut en avoir une dans le cadre d'un script type "batch" (ou peut être aussi dans le cadre d'un service web, qui sait). Toutefois, l'objectif principal ici est de mieux observer les différences et d'aller aux limites des ORM.
Résultats test 4 : Doctrine : 2 870 100; Propel : 547 152; PMO : 1 330 650; jDao : 220 285
Résultats test 6 : Doctrine : ?; Propel : 5 155 526; PMO : 11 830 391; jDao : 2 041 419
Pas de résultats pour Doctrine pour le test 6 : après de longues secondes d'attentes, PHP met fin au script à cause du manque de mémoire, ou même de crash. J'ai réalisé un petit graphique qui montre l'évolution des performances en fonctions du nombre d'enregistrements récupérés :

On observe que pour Doctrine et PMO, c'est catastrophique. L'évolution est exponentielle. Je pense qu'il y a un souci au niveau de l'implémentation. À contrario, je trouve que Propel et jDao s'en sortent bien.
Test 5 : benchBigComplexSelect
Par curiosité, j'ai fait le même test que le test 2, avec une jointure entre city et departement, mais en récupérant cette fois-ci 1000 enregistrements.
Résultats : Doctrine : na; Propel : ?; PMO : 1 516 136; jDao : 230 706
Pour Doctrine, comme il s'agit d'une jointure, on a le même bug que dans le test 2, donc je n'ai pas pu mesurer. Propel, curieusement, n'arrive pas jusqu'au bout : mémoire insuffisante.
Test 7 : benchStressLittleSelect
Il s'agit de faire ici une sorte de simulation de charge : on calcule le temps que prend la récupération de 20 villes par 100 "pseudo" clients web. Concrêtement, on récupère 100 fois de suite 20 enregistrements. Cela nous permet donc d'avoir une idée du comportement de chaque ORM sous la charge. Mais je reconnais, c'est seulement une "idée", il faudrait faire des tests plus poussés, avec Tsung par exemple. Et encore, les résultats ici sont "avantageux", dans la mesure où on ne calcule que le temps de la requête, et on n'inclus donc pas le temps de parsing de PHP, d'initialisation du framework ORM etc.. Mais je pense que l'on peut obtenir un classement équivalent dans des conditions réèlles. À vérifier.
Doctrine :
for($i=0; $i < 100; $i++) {
$cities = $conn->query('FROM city LIMIT 20');
}
Propel :
for($i=0; $i < 100; $i++) {
$c = new Criteria();
$c->setLimit(20);
$list = CityPeer::doSelect($c);
}
PMO :
for($i=0; $i < 100; $i++) {
$controler = new PMO_MyController();
$map = $controler->queryController("SELECT * FROM city LIMIT 20");
}
jDao :
for($i=0; $i < 100; $i++) {
$liste = jDao::get('city')->getByLimit(20);
$results=array();
foreach($liste as $dep){
$results[]=$dep;
}
}
Résultats : Doctrine : 4 297 492; Propel : 1 414 115; PMO : 2 603 541; jDao : 566 579
Test 8 : benchStressLittleComplexSelect
Autre stress : on récupère 100 fois de suite 20 villes avec leurs départements (donc équivalent au test 2).
Résultats : Doctrine : na; Propel : 3 298 295; PMO : 3 249 585; jDao : 558 331
Pour doctrine, toujours la même erreur SQL.
Test 9 : benchStressMediumSelect
On récupère 100 fois de suite tous les départements (donc équivalent au test 3 dans une boucle);
Résultats : Doctrine : 13 620 416; Propel : 3 129 616; PMO : 8 907 413; jDao : 2 066 394
Test 10 : benchStressManyIndividualRecord
L'idée ici est de simuler une forte charge, mais sur la récupération d'un seul enregistrement. On simule donc le genre de requête que l'on fait pour afficher une page d'un article par exemple. Cela permet aussi de tester l'API qui récupére un seul enregistrement. Et on fait cela 1000 fois de suite dans une boucle. Notez aussi qu'il est indispensable de faire ce test répétitif pour mieux voir les différences, car le temps de récupération d'un enregistrement est assez minime et il serait difficile alors de les comparer.
Doctrine :
for($i=0; $i < 1000; $i++) {
$dep = $conn->getTable('departement')->find(34);
}
Propel :
for($i=0; $i < 1000; $i++) {
$dep = DepartementPeer::retrieveByPK('34');
}
PMO :
for($i=0; $i < 1000; $i++) {
$dep = PMO_MyObject::factory('departement');
$dep->code = '34';
$dep->load();
}
jDao :
for($i=0; $i < 1000; $i++) {
$dao = jDao::get('departement');
$dep = $dao->get('34');
}
Résultats : Doctrine : 15 845 267; Propel : 5 085 127; PMO : 3 322 029; jDao : 1 604 880
Test 11 : benchStressManyIndividualComplexRecord
Idem que le test 10, mais ici on récupère aussi le département associé à la ville.
Doctrine :
for($i=0; $i < 1000; $i++) {
$city = $conn->getTable('city')->find(3);
$dep = $city->departement;
}
Doctrine fait du "lazy loading". C'est à dire qu'ici on ne peut récupérer le département en même temps que la ville (Doctrine ne fait donc pas un seul select avec une jointure). Et pour charger le département, il faut explicitement l'utiliser, d'où la variable $dep.
Propel :
for($i=0; $i < 1000; $i++) {
$c = new Criteria();
$c->add(CityPeer::ID,3);
$list = CityPeer::doSelectJoinDepartement($c);
$city = $list[0];
}
PMO :
for($i=0; $i < 1000; $i++) {
$controler = new PMO_MyController();
$map = $controler->queryController("SELECT * FROM city,departement WHERE city.region = departement.code AND city.id = 3")
->getMap();
$city = $map[0];
}
jDao :
for($i=0; $i < 1000; $i++){
$dao = jDao::get('citydep');
$dep = $dao->get('3');
}
Résultats : Doctrine : 19 496 700; Propel : 8 220 690; PMO : 6 622 190; jDao : 1 633 613
Test 12 : benchStressSelectWithCriteria
Il s'agit de tester ici ce que j'appelle le "requêteur dynamique". C'est à dire le moyen de pouvoir faire des requêtes avec des critères non prévu à l'avance, tant en nombre qu'en valeur. Dans Propel on a l'objet Criteria, dans jDao l'objet jDaoConditions et dans Doctrine, il s'agit de l'objet Doctrine_Query. Dans PMO, on n'a rien : il faut construire la requête "à la main".
Doctrine :
$idCriteria = 200;
$postcodeCriteria = '%50';
for($i=0; $i < 1000; $i++) {
$cities = Doctrine_Query::create()
->from('city')
->where('id < ? AND postcode LIKE ?', array($idCriteria, $postcodeCriteria))
->execute();
}
Propel :
$idCriteria = 200;
$postcodeCriteria = '%50';
for($i=0; $i < 1000; $i++) {
$c = new Criteria();
$c->add(CityPeer::ID,$idCriteria, Criteria::LESS_THAN);
$c->add(CityPeer::POSTCODE,$postcodeCriteria, Criteria::LIKE);
$list = CityPeer::doSelect($c);
}
PMO :
$idCriteria = 200;
$postcodeCriteria = '%50';
for($i=0; $i < 1000; $i++) {
$sql = "SELECT * FROM city WHERE";
$sql.= " id < ".intval($idCriteria);
$sql.= " AND postcode LIKE '" .str_replace("'","''", $postcodeCriteria)."'";
$controler = new PMO_MyController();
$map = $controler->queryController($sql)->getMap();
$city = $map[0];
}
jDao :
for($i=0; $i < 1000; $i++) {
$dao = jDao::get('city');
$cond = jDao::CreateConditions();
$cond->addCondition ('id', '<', 200);
$cond->addCondition ('postcode', 'LIKE', '%50');
$cities = $dao->findBy($cond);
}
Résultats : Doctrine : ?; Propel : 21 089 532; PMO : ?; jDao : 2 043 507
J'y suis peut être aller fort avec 1000 itérations. Mais bon, si Doctrine et PMO n'arrive pas à suivre jDao ;-)... Les scripts ont en effet été interrompu par PHP, car ils prenaient trop de temps. Notez que l'API de PMO ne permet pas de faire ce genre de requête de manière simple : il faut utiliser les bonnes vieilles méthodes "à la main".
Conclusion
En terme de fonctionnalité ou d'API, disons le tout de suite : jDao, et même PMO, sont en retard par rapport à Doctrine et Propel. Par exemple, avec jDao, on ne peut définir de relation entre deux objets DAO. Et avec PMO, bien que l'on puisse récupérer en une seule requête un objet ville qui soit lié à un objet departement, on ne peut récupérer par exemple un objet departement quand on a déjà un objet ville chargé, ce qui n'est vraiment pas pratique.
Cependant, on constatera qu'à aucun moment on ne fait du SQL avec jDao, tout comme avec Propel d'ailleurs. Alors qu'avec Doctrine et pire encore, avec PMO, on n'y échappe pas. Dommage, car pour moi, un ORM doit permettre de faire du vrai mapping : on ne devrait pas faire du SQL, ou au moins, pas en faire pour des requêtes simples comme celles effectuées dans les tests.
À la vue des resultats, je pense que Doctrine n'est vraiment pas à la hauteur en terme de performance (je met de coté ce bug SQL génant qui je pense sera corrigé). Il est franchement à oublier pour le moment. À revoir peut être quand la 1.0 finale sortira, mais je pense que de toute façon, d'un point de vue architectural, il a vraiment un problème et qu'il est peu probable qu'il y ait de grosses améliorations de faite, sauf en cassant l'API de Doctrine. Au passage, je ne comprend pas pourquoi Doctrine remplacera parait-il, Propel, dans le framework Symfony. C'est à mon avis une grosse erreur.
PMO est à mon avis trop jeune et trop peu performant pour être utilisé sur des sites sérieux. D'ailleurs la qualité du code est vraiment moyenne selon moi, et il y a moyen d'améliorer tout ça. Mais, il y a eu quelques améliorations semble-t-il depuis les tests. À surveiller donc du coin de l'oeil.
Étant donné que jDao n'est pas utilisable en dehors de Jelix, le seul qui reste et que l'on peut utiliser en dehors d'une appli jelix, c'est Propel. Je pense qu'il n'a pas de performances catastrophiques, que ça reste un choix raisonnable, d'autant plus que son API n'est pas trop moche. Ce n'est pas pour autant que je l'incluerai dans Jelix car il reste tout de même lourd (c'est tout juste si il n'est pas aussi lourd que jelix en terme de volume de code), et compliqué à installer qui plus est.
jDao obtient les meilleurs performances. Normal quand on sait que les requêtes sont en dur dans le code des classes générées à partir des fichiers XML. Cet enthousiasme est cependant à modérer, car, bien qu'il soit suffisant dans une majorité de cas, il offre moins de possibilités que Propel et son API est un peu vieillote. Toutefois, je me suis déjà lancé dans le développement de son successeur, qui reprendra les mêmes principes sous-jacent de jDao tout en proposant une API plus moderne et du niveau de Propel. Et bien sûr, avec pour objectif principal d'avoir de meilleures performances que Propel (mais qui seront toutefois un peu moins bonnes que jDao).
Si vous voulez lancer vous même les tests que j'ai effectué, téléchargez l'archive contenant toute le code source (1.1Mo) et suivez les instructions du fichier INSTALL. Vous pouvez aussi télécharger le document contenant tous les résultats (format openoffice, 22ko)
Commentaires
Article intéressant. Ca confirme ce que tu annonçais sur le chan jelix.
Cependant j'ignorais que tu préparais le remplaçant de jDao. C'en est où dans le développement ? Tu le prévois pour quand ?
bonne continuation.
Merci pour ce test !
Sinon, pour faire mon relou, c'est quoi l'unité des résultats (des secondes, des millisecondes, des patates :p) ?
Très intéressant.
Dans la 2e partie : "Ils évitent de ne pas utiliser du SQL," ... Heu c'est pas plutôt le contraire ?
Impressionnant
@bast : le développement est encore à l'état embryonnaire. Il va donc falloir patienter quelques mois.
@vincent : il me semble que ce sont des millionièmes de secondes. 1 000 000 == 1s
@kilgore : oui effectivement, c'est une erreur, je corrige, merci :-)
Merci pour ce test !
Je serais curieux de voir les résultats de Zend_DB.
et de propel 1.3 qui d'aprés les premiers tests à de bons resultats
Oui moi aussi je serais curieux de voir les résultats de Zend_DB_Table :)
En tout cas c'est très intéressant comme test.
Très intéressant ce benchmark !
Bon, je ne sais pas si tu es complètement objectif mais bon .. ;)
Sinon, pourquoi "on ne devrait pas faire du SQL, ou au moins, pas en faire pour des requêtes simples comme celles effectuées dans les tests." ?
Pour info le lien vers l'archive contenant toute le code source ne fonctionne pas...
[Laurent: corrigé, merci ]
@neovov: oui je suis objectif, dans la mesure où je n'ai pas triché, en quoi que ce soit. Aucun tunning, ni pour jelix, ni pour aucun autre : les sources des différents framework sont exactement les mêmes que ceux qu'il y a sur leurs sites respectifs. Je pense que les tests sont plutôt honnêtes, et je livre les sources pour que tu puisses te rendre compte par toi même le contenu de ces sources et de pouvoir tester par toi même. Certes, tu ne retrouveras pas les même chiffres (car machine différente) mais tu arriveras aux mêmes conclusions (à priori...).
L'objectif d'un ORM c'est d'abstraire au maximum la base de donnée. Donc en théorie, tu ne devrais manipuler les données qu'au travers d'objets. Et pour les sélections, il devrait y avoir des méthodes évitant, pour les cas simple, de recourir au SQL. C'est ce que permettent en général les bases de données objets (bien qu'elles possèdent aussi un langage type SQL pour faire des sélections complexes).
Pour optimiser les retours dans doctrine, tu peux utiliser la syntaxe suivante:
->execute(array(), Doctrine::FETCH_ARRAY);
Il te retourne les résultats sur la forme d'un tableau.
Cela améliore beaucoup le temps de réponse.
@bertrand : ok mais on perd tout le bénéfice de l'objet. Et donc on n'obtient pas un résultat équivalent aux autres ORM à la fin des fonctions de tests, en terme de structure de données.
Is it possible for this article to be translated into English? I think benchmarking ORM sollutions for PHP is very interresting.
I agree, could you please provide a translation for this article? Seems really interesting.
S'il Vous Plaît :)
Salut Laurent,
J'ai déroulé tes tests plusieurs fois chez moi sur une machine en intel core 2 duo et je ne trouve pas les mêmes résultats que les tiens entre PMO et Jdao (les autres ne marchent pas chez moi).
Les résultats que j'ai:
Bench 1 1 select de 20 enregistrements Pmo : 0.014 secondes Jdao : 0.026 secondes Tes résultats: PMO : 25 841; jDao : 13 690
Bench 2 1 select de 20 enregistrements sur 2 tables jointes Pmo : 0.019 secondes Jdao : 0.025 secondes Tes résultats: PMO : 54 287; jDao : 13 597
Bench 3 1 select de 96 enregistrements Pmo : 0.026 secondes Jdao : 0.027 secondes Tes résultats: PMO : 117 030; jDao : 37 440
Bench 4 1 select de 1000 enregistrements Pmo : 0.11 secondes Jdao : 0.08 secondes Tes résultats: PMO : 1 330 650; jDao : 220 285
Bench 5 1 select de 1000 enregistrements avec deux tables jointes Pmo : 0,14 secondes Jdao : 0,09 secondes Tes résultats: PMO : 1 516 136; jDao : 230 706
Bench 6 1 select de 10000 enregistrements Pmo : 1,19 secondes Jdao : 0,33 secondes Pas vraiment d'utilité ce bench Tes résultats: PMO : 11 830 391; jDao : 2 041 419
Bench 7 1 select de 20 enregistrements pour 100 clients web Pmo : 0,54 secondes Jdao : 0,24 secondes Tes résultats: PMO : 2 603 541; jDao : 566 579
Bench 8: 1 select de 20 enregistrements sur 2 tables jointes par 100 clients web Pmo: 0,75 secondes Jdao : 0,22 secondes Tes résultats: PMO : 3 249 585; jDao : 558 331
Bench 9: 100 select de 96 enregistrements Pmo : 1,29 secondes Jdao: 0,36 secondes Tes résultats: PMO : 8 907 413; jDao : 2 066 394
Bench 10: 1000 select sur 1 enregistrement Pmo : 2,22 secondes Jdao : 0,96 secondes Tes résultats: PMO : 3 322 029; jDao : 1 604 880
Bench 11 1000 select sur 1 enregistrement avec deux tables jointes Pmo: 5,2 secondes Jdao : 1,03 secondes Tes résultats: PMO : 6 622 190; jDao : 1 633 613
Bench 12 1000 select avec des critères aléatoires Pmo: 6,95 secondes Jdao : 2,06 secondes Tes résultats: PMO : ?; jDao : 2 043 507
Ces tests ne mettent pas en évidence réellement l'abstraction car tu te focalises sur l'instanciation des objets. Alors que l'abstraction des objets te permet de gagner en temps d'execution sur le reste du script.
Pour mémo, PMO n'est pas un mapper SQL, mais un mapper de base de donnée. Il n'y a pas de raisons de mapper un langage normalisé connu par tous, utilisé par tout les SGBD :D PMO mappe uniquement des données en objet et vice-versa.
Autre exemple, les parties ou tu indiques que tu mets tes objets dans un array pour avoir des résultats équivalents aux autres ORM. Pmo utilise une map plus complexe qu'un simple array.
Mais cela ne change globalement pas grand chose, il y a des problèmes de performances connues dans cette version de PMO que tu as remonté qui apparaissent clairement dans les derniers tests et qui ont été corrigés dans la version qui va sortir (déjà dispo en beta).
Dernier point positif, je vois que ton point de vue sur l'abstraction a évolué et que tu n'exclues pas de perdre un peu de performances pour apporter des fonctionnalités supplémentaires.
Bizarrement une partie des résultats confirment plus ou moins les miens...
Oui et non. Oui, dans une page web, on n'a pratiquement jamais à traiter 10 000 enregistrements. Mais ce n'est pas le cas dans des scripts batchs.
Ouai, désolé, c'était pas un comparatif de hamburgers. C'est pourtant la récupération des résultats qui est le plus consommateur de ressources dans un script PHP. Et c'est ce que je peux seulement tester de manière précis. Parce que l'utilisation que l'on fait des résultats varie énormément d'une appli à un autre. Donc pour avoir des tests objectifs.. Et puis je ne peux pas tout tester n'est-ce pas, mon temps libre n'est pas extensible.
Je ne vois pas le rapport là. Tous les ORM testés ont ce genre d'abstraction.
Et ? On s'en fout non ?
Ça j'en ai rien à fiche de comment c'est implémenté en interne. Ce que tu essaye de me dire, c'est que j'aurais dù rajouter une boucle similaire dans les tests de PMO pour avoir un vrai tableau ? Tes perfs se seraient encore plus effondré :-p J'essayais juste d'avoir à la fin une structure de donnée plus ou moins équivalente. Et si je n'avais pas fait ce mapping dans un tableau pour jDao, tu aurais critiqué quoi encore ? Pourtant j'aurais bien voulu : en temps de réponse, jDao aurait encore écrasé plus encore les autres ORM.
PMO reste quand même le moins abstrait de tous (Jette un coup d'oeil aux SGBDOO).
@Bjarte Stien Karlsen && Joaquin Bravo : I don't really have time to translate it. I will try to do it later.
A short summary of the conclusion of my tests : performance of Doctrine are very very bad. jDao and PMO are not really equals to Doctrine or Propel in terms of features, so the best ORM for me is Propel, despites its bad performance comparing to jDao. But I won't include Propel in my framework Jelix (http://jelix.org) because it is too hudge, too "bloatware" for me (like Doctrine).
Quand tu penses abstraction (..) tu te focalises sur le requêteur SQL.
Jdao, Propel implémentent que partiellement SQL, les fonctionnalités les plus utilisées par les SGBD.
Que se passe-t-il quand le développeur veut justement accéder à 100% des fonctionnalités offertes par sql ?
De même c'est un sacré pari que de penser que les développeurs veulent apprendre à réecrire des requêtes SQL en php.
Autre chose, tu ne peux pas comparer ton array à des maps ;) Qu'est ce que tu vas faire avec ton array pour travailler sur tes objets ? ;)) Cela implique que tes utilisateurs vont recoder 50 fois à des endroits différents de leurs classes des foreach pour parcourir l'array (..)
Voila en gros pourquoi, il aurait été plus intéressant de mettre en uvre des cas d'utilisation. Mais bon je conçois que ça prend du temps, et que tu n'ai pas que ça a faire.
Il fait du SQL tout simplement ?
Je pense qu'il faut aussi arrêter de vouloir mettre de la magie voodoo de partout et qu'à partir d'un certain moment le meilleur outil pour enfoncer un clou c'est le marteau et pas autre chose.
Tres bien ce test : complet, documente et clair. Bravo bon boulot !
Par contre, il est dommage que Creole ne soit pas traite.
Je suis d'accord. Pour faire du SQL un tant soi peu complexe, j'ai arrêté de bidouiller avec des systèmes qui te wrappent ta requete dans 15 fichiers XML, compilent ça en classes imbitables et te ralentissent le tout.
Je continue d'utiliser jDao pour mes insert/update, et pour des sélections simples (comprendre : sans jointures ni sous requetes). Dès que je veux sélectionner un truc un poil plus complet, je fais du vrai SQL. C'est à mon sens plus lisible, plus débuggable, plus rapide et plus portable.
Bref, si quelqu'un a un benchmark équivalent pour de l'insert/delete/update, ça serait cool :)
De toutes façons la partie abstraction de BDD est à dissocier de la fonctionnalité ORM, ça n'a rien à voir: l'un se charge de s'affranchir du langage SQL quelque soit l'implémentation cible et l'autre de mapper un ou plusieurs enregistrement d'une table sur des objets instanciés ou une collection d'objets instanciés. Ici on a tendance à mélanger les hamburgers et les carottes Vichy - et en ce sens je rejoins code34 (qui ne mérite pas qu'on lui vole de cette façon dans les plumes, AMHA.)
Et même si je salue l'effort produit pour réaliser ce bench je doute franchement de sa pertinence face aux cas concrets d'utilisation. Voila.
Pour la premiere requête doctrine qui foire :
$cities = Doctrine_Query::create()->from('city c')
->leftJoin("departement d")->where('c.region = d.code')->limit(20)->execute();... tu n'as pas besoin d'écrire ->where('c.region = d.code') car cela est compris par Doctrine avec les méthodes setup du modèle.
Au final, je pense que tu devrais essayer avec cette syntaxe : $cities = Doctrine_Query::create()->from('city c')
->leftJoin("c.departement d")->limit(20)->execute();Tu devrais ainsi eviter les requetes SQL erronées, et obtenir des résultats ...
PMO implemente 0 au niveau SQL. Même si pour des requêtes complexes, il est préférable d'utiliser du SQL (ce que tu peux faire avec jDao), il est quand même bon aussi d'avoir un minimum de méthodes pour faire des requêtes simple, genre findAll() pour avoir le contenu entier, ou un système de critères comme Criteria ou jDaoConditions, ce qui évite d'avoir à créer à la main les requêtes simples et avec un minimum de sécurité, ce qui n'est pas possible avec PMO comme je l'ai montré.
Alors première chose : ce que renvoi jDao, ce n'est pas un array, mais un itérateur sur les résultats renvoyé par le SGBD. Ce qui veut dire que tu peux traiter directement les résultats sans avoir à les stocker dans un array ou un map == économie de mémoire (on peut utiliser un foreach sur un iterateur en php5)
Deuxième chose : avec ton map aussi, il va falloir boucler sur les résultats (que ce soit un foreach ou un while, c'est pareil). Donc je ne vois donc pas ce qu'un map apporte de plus par rapport à un simple array, et pire encore, par rapport à un iterateur.
@defro : creole n'est à mon sens pas au même niveau que les ORM. C'est juste une couche d'abstraction sur la base de donnée. C'est donc un niveau plus bas (il est plus à comparer à Pear::DB, à PDO ou jDb dans Jelix). D'ailleurs Propel repose sur Creole.
@Grumph : non, à mon sens, une couche d'abstraction de BD n'est pas de s'affranchir du SQL. C'est plutôt de s'affranchir des différences entre SGBD : avoir une API unifiée pour acceder à n'importe quel base de données. C'est la couche ORM (donc Propel, PMO &cie) qui doit s'occuper du mapping relationnel objet et donc de limiter l'usage du SQL. C'est son but. (encore une fois, regardez comment fonctionnent les bases de données Objets...). Quant à la pertinence de mes tests, j'ai bien souligné que pour certains d'entre eux, ce n'était pas réaliste dans le contexte d'un site web, mais par contre pouvaient l'être dans un contexte de script batch. Et je les ai réalisé pour mieux marquer les différences. Aussi, tu ne vas pas me dire que tester la récupération de 1, 20 ou 100 enregistrements comme je l'ai fait ne sont pas des cas concrets d'utilisation. Ou alors tu n'as jamais fait de dev web ou d'applis pro intranet.
@vdb : ok, j'essaierai merci.
@code34 : je confirme en fait tes résultats. Je viens en fait de remarquer que l'extension xdebug, que je croyais plus précise au niveau des temps d'exécution, fausse complètement les résultats. Donc mes résultats sont certainement invalides. Tout est à refaire :-(
C'est pas fait pour faire de la perf :D
Ca apporte une abstraction supplémentaire, t'as une structure de stockage avec des méthodes spécialisées pour un type d'objet.
C'est pas un concept facile à comprendre en php, étant donné que tu peux mettre n'importe quel type de donnée dans les array.
Juste quelques mots par rapport à ta conclusion. Il est clair qu'en termes de perfs, apparemment Doctrine est à la traine.
Mais je l'utilise tous les jours pour générer en très peu de temps des requetes avec des jointures sur 3 tables liées hierarchiquement ou plus. La même chose avec propel est faisable mais il faut réecrire les jointures à la main (1 jointure = 4 lignes de code). Certes on a un ORM (Doctrine) dont la rédaction des requetes oblige à connaitre le SQL. Mais quel temps gagné ! Et les résultats retournées sont 100% objets et ceux-ci sont imbriqués sur plusieurs niveaux pour respecter les jointures.
Perso, je resterai sur Doctrine sauf si sur des grosses bases, je vois que ca rame trop ..
Bonjour.
Deux centimes au sujet du débat sur la définition dun ORM après mes lectures personnelles à ce sujet : cest une couche pour traiter les tables, lignes et colonnes dune base de données comme des objets natifs. Il y a des bibliothèques qui permettent de générer du SQL adapté à telle base (parce que malgré les efforts de normalisation, pratiquement chaque base comprend sa variante du langage), des bibliothèques qui permettent de traiter les tables comme des objets, et des bibliothèques qui fonc les deux.
Je ne dirai rien sur les tests en eux-mêmes, je nai pas PHP sur ma machine, préférant de loin Python.
Je voulais faire une remarque sur la dernière phrase du billet : ce format est OpenDocument (OpenDocument Text si on veut être précis), pas seulement le format dOpenOffice.org. Ce serait aussi mauvais davoir un logiciel libre qui imposerait un format fermé que davoir un logiciel proprio qui impose un format fermé. Utiliser le nom du format met en avant son ouverture et son interopérabilité.
Cordialement :)
Quand penses tu publier les nouveaux résultats de tes tests ? Merci
Quand j'aurais le temps :-) Je suis plus occupé à finaliser jelix 1.0 en ce moment ;-)