Yii2.0-beta v.s. Laravel4.1 ベンチマーク

PHP5.5.13のビルトインサーバーで、Yii2.0-betaのDBアクセスを含めた実装をベンチマークテストしてみました。あ、ベンチマークは意味が無いとかいうのはナシです。

HelloWorldベンチだと、ルーティングとビューのオーバーヘッドを比較するしかできません。簡単にチートできてしまいます。データベース接続などのライブラリをプリロードしている方が不利になってしまいます。Yii1は公式発表のHelloWorldベンチがずば抜けて速かった(曰く、ほとんどのコードは必要になるまでロードされないことを表しているらしい)のですが、そういう部分だけを際立たせて、だから全体が速い/遅いと考えるのはおかしいです。

そこで、postとcommentテーブルを持つ同じデータベースに接続して、postデータを1件とそれに付随するコメントをすべて取得する(実際にはデータが1件だけある)処理を含みました。

比較対象は ... Laravel です。

比較したかった理由:

DBクエリの結果がArrayになる系のものは除外します。またDoctrineは、動的なActiveRecordとは動作特性が違いすぎました。同じ種類のもので比較したいとなったとき、Eloquent ORMがすぐに動くのがLaravelでした。

...という理由はそれほどたいした問題ではないです。

比較したかった真の理由:

Laravelは中身にSymfonyコンポーネントを使っていて、いわば二重の構造になっています。さらに、記述姓のために実行時にメタプログラミング的なトリックをしかけています。

Yii2はパフォーマンスを重視して、コア部分にサードパーティーライブラリの層を持たない方針で設計されています。また、IDEでの生産性も重視しており、その結果、PHPの型システムに素直に従ったコードになっています。

で、まあ、Laravelのほうが遅いのはわかっていて、実はポイントは、この作りの違いがPHPコードキャッシュでどれぐらいの差になって現れるのだろうか、というのを知りたかったというわけです。

テストに用いたデータの構造:

CREATE TABLE `post` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `body` text NOT NULL,
  `created_at` int(11) NOT NULL,
  `updated_at` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `fk_post_category_id` (`category_id`),
  CONSTRAINT `fk_post_category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `comment` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `post_id` int(11) NOT NULL,
  `body` text NOT NULL,
  `created_at` int(11) NOT NULL,
  `updated_at` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `fk_comment_post_id` (`post_id`),
  CONSTRAINT `fk_comment_post_id` FOREIGN KEY (`post_id`) REFERENCES `post` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Yii2

コントローラ

<?php
namespace frontend\controllers;

use common\models\Post;

class PostController extends \yii\web\Controller
{
    public function actionBenchmark($id)
    {
        $model = Post::findOne($id);
        if (!$model) {
            throw new NotFoundHttpException();
        }

        $this->layout = 'benchmark-layout';
        return $this->render('benchmark', [
            'model' => $model,
        ]);
    }
}

ビュー

<?php
use yii\helpers\Html;

/**
 * @var yii\web\View $this
 * @var common\models\Post $model
 */
?>
<h1><?= Html::encode($model->title) ?></h1>
<p><?= nl2br(Html::encode($model->body)) ?></p>

<ul>
<?php foreach ($model->comments as $comment): ?>
    <li><?= nl2br(Html::encode($comment->body)) ?></li>
<?php endforeach; ?>
</ul>

レイアウト

<?php
use yii\helpers\Html;

/**
 * @var \yii\web\View $this
 * @var string $content
 */
?>
<?php $this->beginPage() ?>
<!DOCTYPE html>
<html lang="<?= Yii::$app->language ?>">
<head>
    <meta charset="<?= Yii::$app->charset ?>"/>
    <title><?= Html::encode($this->title) ?></title>
    <?php $this->head() ?>
</head>
<body>
<?php $this->beginBody() ?>
    <div class="container">
        <?= $content ?>
    </div>
<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>

ActiveRecord

<?php
namespace common\models;

use Yii;
use yii\db\ActiveRecord;

/**
 * This is the model class for table "post".
 *
 * @property integer $id
 * @property string $title
 * @property string $body
 * @property integer $created_at
 * @property integer $updated_at
 *
 * @property Comment[] $comments
 */
class Post extends ActiveRecord
{
    /**
     * @inheritdoc
     */
    public static function tableName()
    {
        return 'post';
    }

    // 途中割愛

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getComments()
    {
        return $this->hasMany(Comment::className(), ['post_id' => 'id'])->orderBy(['created_at' => SORT_ASC]);
    }
}
<?php
namespace common\models;

use Yii;
use yii\db\ActiveRecord;

/**
 * This is the model class for table "comment".
 *
 * @property integer $id
 * @property integer $post_id
 * @property string $body
 * @property integer $created_at
 * @property integer $updated_at
 *
 * @property Post $post
 */
class Comment extends ActiveRecord
{
    /**
     * @inheritdoc
     */
    public static function tableName()
    {
        return 'comment';
    }

    // 途中割愛

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getPost()
    {
        return $this->hasOne(Post::className(), ['id' => 'post_id']);
    }
}

Laravel4

<?php

use Illuminate\Database\Eloquent\ModelNotFoundException;

class PostController extends Controller
{
    public function benchmark($id)
    {
        try {
            $post = Post::findOrFail($id);
        } catch(ModelNotFoundException $ex) {
            App::abort(404);
        }

        return View::make('post.benchmark', array(
            'post' => $post,
        ));
    }
}

ビュー

@extends('layouts.benchmark-layout')

<?php
/* @var $post Post 手で追加 */
/* @var $comment Comment 手で追加 */
?>

@section('main')

<h1>{{{ $post->title }}}</h1>
<p><?php echo nl2br(htmlspecialchars($post->body, ENT_NOQUOTES, 'UTF-8')) ?></p>
<ul>
    @foreach ($post->comments as $comment)
    <li><?php echo nl2br(htmlspecialchars($comment->body, ENT_NOQUOTES, 'UTF-8')) ?></li>
    @endforeach
</ul>

@stop

レイアウト

<!doctype html>
<html>
    <head>
       <meta charset="utf-8">
       <title></title>
   </head>
    <body>
        <div class="container">
            @yield('main')
        </div>
    </body>
</html>

Eloquent ORM

<?php

/**
 * @property string $title 手で追加
 * @property string $body 手で追加
 */
class Post extends Eloquent
{
    protected $table = 'post';

    public function comments()
    {
        return $this->hasMany('Comment', 'post_id')->orderBy('created_at', 'asc');
    }
}
<?php

/**
 * @property string $body 手で追加
 */
class Comment extends Eloquent
{
    protected $table = 'comment';

    public function comments()
    {
        return $this->belongsTo('Post', 'post_id');
    }
}

コード比較

Yiiのほうがコードが長いですが、大部分は自動的に生成されたコードです。最初はアセットマネージャー、CSRF対策、Flashメッセージなどがあったのですが、それらがベンチマーク結果に影響しないほうがいいと考え、ビューから取り除きました。それでもまだ、ビューにいくつか謎のメソッドコールがありますが、それはフレームワークの機構との関係が強いため、通常外すことがないものです。そこは最低必要なオーバーヘッドだと考えて残しました。

LaravelはIDE用のdocコメントを追加する手間がありました。ide-helper を使ったのですが、hasMany() の先に orderBy() メソッドがないという警告と、App::abort(404); が例外で脱出するのを理解せずに $post 未初期化の可能性の警告が消せませんでした。それを除けば、コード量は十分少なく、やっていることはダイレクトです。

パフォーマンス比較

ともに、PHPビルトインサーバーで動かします。Webサーバが絡む現実のシステム負荷ではなく、純粋にPHPだけの負荷を際立たせたかったので。

OPCache なし/あり で確認。YiiはDB関連のキャッシュを2段階設けました。LaravelのDBログにテーブル定義を見ている様子がなかったので、Yiiはスキーマキャッシュがあるものが標準だと考えるのがよさそうです。接続は4コアCPUなので4本並列で。

siege -c 4 -t 10S -b -q http://localhost:8082/post/benchmark/2

f:id:tanakahisateru:20140616022654p:plain f:id:tanakahisateru:20140616022721p:plain

OPCacheなし

  • Laravel = 10.16 trans/sec
  • Yii チューニングなし = 14.36 trans/sec
  • Yii DBスキーマキャッシュあり = 18.00 trans/sec
  • Yii さらにクエリキャッシュあり = 18.31 trans/sec

OPCacheあり

  • Laravel = 27.63 trans/sec
  • Yii チューニングなし = 47.91 trans/sec
  • Yii DBスキーマキャッシュあり = 69.53 trans/sec
  • Yii さらにクエリキャッシュあり = 72.86 trans/sec

OPCacheの効果

  • Laravel = 2.7倍
  • Yii チューニングなし = 3.3倍
  • Yii DBスキーマキャッシュあり = 3.9倍
  • Yii さらにクエリキャッシュあり = 4.0倍

OPCacheなしで見ると、やっぱり、2層あるLaravelは、1層のYiiに対して、倍は行かないまでも、それなりの遅さになるなぁという差が出ました。これは、Laravelが、コアの堅牢さをサードパーティに委ねることで、上のレイヤーに開発リソースを割けるのがいいという方針で、自覚して進めた結果ですし、それほど驚くところじゃないですね。

それよりも、注目なのはOPCacheの効果です。OPCacheを入れると何倍速くなるか。Laravelが得るものに対して、PHPの型に素直なコードであるYiiのほうが、より多くを得られることがわかります。コードが予測できれば、ランタイム最適化に有利なのかもしれません。

もし将来、PHPのコードキャッシュがJVMやV8のようなネイティブコードレベルの最適化を推し進めたら、Javaでリフレクションがボトルネックになる的な問題にならないかな...?