DoctrineとPropelのパフォーマンス比較

# 2009/09/23 22:45 Fivestarさんからコメントで教えていただいたDoctrineのINSERTについてテスト1に追記しました。
# 2009/09/24 01:03 Fivestarさんからコメントで教えていただいたDoctrineのQueryCacheについてテスト3に追記しました。



symfonyとしては「これからはDoctrineがメイン」という方向性(symfony 1.3ではデフォルトのORMがDoctrineになっていますし)のようなので、いろいろな機能がDoctrineを基準に実装されていくことになるのだろうと思われますが、実際の案件に使っていくには、やはりパフォーマンスが気になるところです。

そもそもPropelでもPDOが採用された1.3が出るまではさんざん「遅い」と言われていて、それが「symfonyってもっさり」の原因になっていたのではないかと個人的に思っていたりします。クエリの書き方やライブラリの構造的にはDoctrineの方が先進的なのは確かなのですが、少なくともPropel 1.3と比較して「気にならない」程度の速度が出ているのかどうか、自分で確認してみました。


以下のような簡単な処理について、時間を計測しています。

  • 10000件のレコードをINSERT
  • 10000件のレコードからプライマリキーでランダムに1件を取得する処理を10000回


使用したバージョンや環境変数などは以下の通りです。

  • symfony 1.3.0-DEV
  • Doctrine 1.2.0-ALPHA1(symfony 1.3に付属のもの)
  • Propel 1.3.0-DEV(symfony 1.3に付属のもの)
  • PHP 5.2.6
  • memory_limit 32M
  • テーブル「address」 単純な住所を格納するためのテーブル。リレーション等なし。autoincrementの主キーのみ。


また、以下のようにPropelとDoctrineのプロジェクトを作成しています。

  • symfony 1.3ブランチをsvnからチェックアウトし、このライブラリを使用してDoctrineのプロジェクトを作成する
  • Doctrineのプロジェクトでschema.ymlを設定→データベースにテーブルを生成
  • 同じライブラリを使用して、Propelのプロジェクトを作成する
  • データベースからスキーマを生成(symfony propel:build-schema)し、スキーマからモデルを生成(symfony propel:build-model)

テスト1:10000レコードINSERT

Doctrine/Propelのプロジェクトでそれぞれタスクを作成し、1万件のレコードをORMを使ってINSERTします。単純な以下のようなコードを書きました。

【Doctrine/Propel共通】

<?php
for($i=0; $i<10000;++$i){
    echo $i . "\n";
    $address = new Address();
    $address->setZip1('111');
    $address->setZip2('2222');
    $address->setPrefName('岐阜県');
    $address->setAddr1('ああああああああああああああああああああ');
    $address->save();
    unset($address);
}

このタスクを実行した結果は以下の通りです。

Doctrine Doctrine
(freeあり)
Doctrine
(Doctrine_Connection#insert)
Propel
4698件まででメモリアロケーションエラー 46秒程度で完了 12秒程度で完了 27秒程度で完了

なぜかDoctrineの場合はメモリを使い切ってしまってエラーになってしまいました。
これについてDoctrineのマニュアルを調べたところ、明示的にfree()メソッドを呼び出す必要があるようです。

したがって、以下のようにコードを書き換えたところ、無事10000件のレコードを追加できました。
【Doctrine】

<?php
for($i=0; $i<10000;++$i){
    echo $i . "\n";
    $address = new Address();
    $address->setZip1('111');
    $address->setZip2('2222');
    $address->setPrefName('岐阜県');
    $address->setAddr1('ああああああああああああああああああああ');
    $address->save();

    $address->free(true);  // ★明示的に解放

    unset($address);
}


追記1:コメント欄にてid:Fivestarさんから教えていただいたDoctrine_Connection#insertメソッドを使用する方法を試しました。(結果は上の表に追加)コードは以下の通りです。
【Doctrine(Doctrine_Connection#insert)】

<?php
$table = Doctrine::getTable('Address');
$conn = $table->getConnection();

for($i=0; $i<10000;++$i){

    $conn->insert($table,array(
        'zip1'=>'111',
        'zip2'=>'2222',
        'pref_name'=>'岐阜県',
        'addr1'=>'ああああああああああああああああああああ',
        'addr2'=>'',
        'addr3'=>'',
        'created_at'=>date('Y/m/d H:i:s'),
        'updated_at'=>date('Y/m/d H:i:s'),
    ));
}

内容としては、まずループの外で対象テーブルのインスタンスとDoctrine_Connectionの参照を取得しておき、ループ内ではDoctrine_Connection#insertメソッドを使用してレコードを追加します。
注意が必要なのは、Doctrine_Connection#insertを使った処理ではORMを(ほとんど)経由せずに、PDOでINSERTクエリを投げるという点です。ですので、上記コードのようにcreated_atやupdated_atなどのフィールドの値も自前で設定する必要があります。

ここまで来ると、ほぼPDOで直に書いているのと同じくらいの速度が出ていますね。「だったらPDOで生で書けばいいじゃん」と言われそうですが、Doctrine_Connection#insertの方が、書き方は楽なように私は思います。


というわけで、この部分の結論としては、「Doctrineで大量にINSERTする場合はDoctrine_Connection#insertを使うべし」ということでしょうか。
Fivestarさんありがとうございました。

テスト2:10000回主キーでレコード取得

次に、テスト1で挿入した10000件のレコードから、ランダムに主キーを指定して1件を取得する操作を10000回繰り返すというコードを書いてみました。

【Doctrine】

<?php
for($i=0; $i<10000;++$i){
    $rec = Doctrine::getTable('Address')->find((int)rand(1,10000));
    unset($rec);
}

【Propel】

<?php
for($i=0; $i<10000;++$i){
    $rec = AddressPeer::retrieveByPk((int)rand(1,10000));
    unset($rec);
}

このコードの実行結果は以下のようになりました。

  Doctrine Propel Propel
(InstancePooling無効)
1回目 46.4秒 7.5秒 11.1秒
2回目 46.6秒 7.8秒 11.4秒

DoctrineでもPropelでも、上記コードで使った主キーから簡単にレコードを取得するメソッドは、それぞれの直接クエリを組み立てる方法を内部で使っている点は同じです。
ただし、PropelではInstancePoolingという機能がデフォルトで有効になっています(Propel 1.3からの機能)。具体的には、モデルクラスのpopulateObjectsメソッド内で、特定の主キーに対応するオブジェクトがキャッシュ(連想配列)に保存され、次回のretrieveByPk呼び出しの際に連想配列からオブジェクトが返されます。
上記の計測結果では、InstancePoolingの効果が*ある程度*効いたために速くなっているようです。

試しにInstancePoolingを無効にして計測したのが一番右の列の数値です。InstancePoolingを無効にするには、以下のようにします。

<?php
Propel::disableInstancePooling();

PropelでInstancePoolingを無効にしても、Doctrineに対して約1/4の実行時間です。

Hydrationしないで比較

「DoctrineはHydrationが遅いから」という声がどこからか聞こえて来たので、Hydrationをしないようにして比較してみました。
※Hydrationとは、データベースから取得したレコードに対応するオブジェクトを生成し、そのオブジェクトにレコードのデータを設定する処理のことです。

【Doctrine】

<?php
for($i=0; $i<10000;++$i){
    $q = Doctrine_Query::create()->from('Address a')->where('id = ?', (int)rand(1,10000))->limit(1);
    $rec = $q->execute(array(), Doctrine::HYDRATE_NONE);
    $q->free();
    unset($rec,$q);
}

【Propel】

<?php
for($i=0; $i<10000;++$i){
    $c = new Criteria();
    $c->addSelectColumn(AddressPeer::ID);
    $c->addSelectColumn(AddressPeer::ZIP1);
    $c->addSelectColumn(AddressPeer::ZIP2);
    $c->addSelectColumn(AddressPeer::PREF_NAME);
    $c->addSelectColumn(AddressPeer::ADDR1);
    $c->addSelectColumn(AddressPeer::ADDR2);
    $c->addSelectColumn(AddressPeer::ADDR3);
    $c->setPrimaryTableName(AddressPeer::TABLE_NAME);
    $c->add(AddressPeer::ID, (int)rand(1,10000));
    $c->setLimit(1);
    $stmt = BasePeer::doSelect($c);
    $stmt->fetch(PDO::FETCH_ASSOC);
    unset($stmt);
    unset($c);
}

Doctrineでは、execute()のオプションにDoctrine::HYDRATE_NONEを指定すると、直接配列が返されます。
同様にPropelではBasePeerのdoSelect()メソッドを使ってPDOStatementを取得し、結果を配列で取得しています。
実行結果は以下の通りです。

  Doctrine Doctrine
(Query Cacheあり)
Doctrine
(Query Cache/Result Cacheあり)
Propel
1回目 36.7秒 11.0秒 8.7秒 9.8秒
2回目 36.8秒 11.1秒 8.6秒 9.9秒

Doctrineはテスト2の結果よりだいぶ速くなりました。Propelの場合は、テスト2でInstancePoolingを無効にした場合より速くなっていますね。(もちろんテスト3ではPoolingは行われません)
なお、Doctrineのコードで「$q->free()」とfree()メソッドを呼び出していますが、この行がないと、途中でメモリアロケーションエラーが発生してしまいます。

注)それぞれ、クエリの初期化処理をループの外に出すことで多少の高速化はできます。


追記1:コメント欄にてid:Fivestarさんから教えていただいたQuery Cacheを試してみました。(要APC
APCがインストールされていない場合はインストールして有効化し、以下のようにコードを変更します。
【Doctrine(Query Cache版)】

<?php
$manager = Doctrine_Manager::getInstance();
$manager->setAttribute(Doctrine::ATTR_QUERY_CACHE, new Doctrine_Cache_Apc());

$q = Doctrine_Query::create()->from('Address a')->where('id = :id');
for($i=0; $i<10000;++$i){
	$rec = $q->fetchOne(array(':id'=>(int)rand(1,10000)), Doctrine::HYDRATE_NONE);
	unset($rec);
}

DoctrineのQuery Cacheを最初に有効化しています。また、最適化のためにクエリの共通部分をループの外に出しました。これでかなりPropelの速度に近づいています。

ただし、Propel側はこういったキャッシュは使っていません。PropelではdoSelectの呼び出し時点でクエリが組み立てられますので、この組み立て処理の結果を同様にAPCにうまくキャッシュしてやれば相当高速化できるのかもしれません。ただしPropelにはクエリを組み立てる前に「どういったクエリなのか」という情報(= APCに保存するためのキーとなる)がないため、上手く実現できるかどうかは分かりません。Doctrineの場合はDQLをハッシュ化した文字列がキーとして使われています。


さらにResult Cacheもほぼ同様の設定で有効化できます。試したところ、PropelのInstancePoolingと同程度に高速化しました(表の3列目)。こちらの場合はPropelのInstancePoolingとは異なり、APCでキャッシュしているので、リクエストをまたいでの効果も期待できます。

ここでの結論としては、Doctrineを使う場合はAPCとQuery Cacheを有効化すべし、ということでしょうか。
コメントいただいたFivestarさん、ありがとうございます!


注)このテストはCLIで行っていますが、CLIAPCを有効にするには、「apc.enable_cli=1」に設定しないといけません。(これでしばらくハマってました)



結論

私はまだまだDoctrineについての経験が浅いので、もっと効果的な書き方や常套手段による高速化・最適化手法があるのかもしれません(追記した通り、いくつかの方法でそこそこ高速化できます)。が、少なくとも標準の状態ではPropel 1.3に対してパフォーマンス的にかなり見劣りしてしまいます。

ネット上で見かけた意見としては、「フロントエンドはどうせORMなんか使わないから」という意見もありました。確かにレスポンス性能などが特に求められる箇所では、直接PDOを使用せざるを得ません。

なので、「レスポンス性能はさほど重要ではない」箇所でORMを使って開発効率を上げるということになるのでしょうけど、そういった処理・画面でも、「許容できる応答時間」というのがありますよね。また、塵も積もればということで、細かな処理でもたくさん行う必要がある場合は、「許容できる応答時間」内に詰め込める処理の量に影響してしまいます。


Doctrineは確かに先進性のあるライブラリで、書き方もSQLに近いため、Propelと比較して学習コストが少なくて済むのかもしれません。しかし、私個人としては、少なくともPropelと同程度の応答性能が出るようになるまでは、実際の案件でDoctrine使用するのは待ちたいなぁと感じました。


[2010/01/23追記]
と書きつつも、使い込んでいくうちにだんだんDoctrineが好きになってきました。



P.S.
Doctrineを使い込んでいる方、「こうすれば速い」というようなアドバイスなどがあればお願いします!


※Doctrineのcompile()とか使えばもっと速いのかもしれません・・・
http://www.doctrine-project.org/documentation/manual/1_1/ja/improving-performance#%E3%82%B3%E3%83%B3%E3%83%91%E3%82%A4%E3%83%AB