COLOPL Tech Blog

コロプラのエンジニアブログです

PHP の Carbon をだいたい 3 倍くらい高速化した話 (または composer-patches の使い方)

こんにちは。 Platform Engineer の工藤です。

今回は PHP で利用される日時ライブラリ nesbot/carbon の速度をだいたい 3 倍くらい高速化した話について紹介させていただきます。

nesbot/carbon について

carbon.nesbot.com

github.com

nesbot/carbon は PHP で日付・時刻を扱うためのライブラリで、 PHP 標準の ext-date を拡張し、より利用しやすい形で提供してくれます。PHP のアプリケーションフレームワークで有名な Laravel でも標準で採用されており、昨今のプロジェクトだと基本入っている場合が多いと思います。

バージョンアップでパフォーマンスが悪化

便利な nesbot/carbon ですが、 Laravel 6 の EOL による Laravel 9 へのアップグレード対応の際、大幅にパフォーマンスが劣化していることを SRE チームの方が発見してくれました。具体的に言うとシリアライズ / シリアライズ解除の処理がとてつもなく遅くなっているようでした。

このパフォーマンス劣化は以下のようにして簡単に再現することができます。軽く計測しただけでも 5 倍近く遅くなっていることがわかります。バージョンを確認していったところ、どうやら 2.61.0.0 以降で劇的にパフォーマンスが劣化しているようでした。

<?php declare(strict_types=1);

use const DIRECTORY_SEPARATOR as DS;

require __DIR__ . DS . 'vendor' . DS . 'autoload.php';

const ITER = 100000;

$carbon = new \Carbon\Carbon('now', 'Asia/Tokyo');

$start_at = \hrtime(\true);
for ($i = 0; $i < ITER; $i++) {
    \unserialize(\serialize($carbon));
}
$end_at = \hrtime(\true);

$elapsed = $end_at - $start_at;

echo "runtime version: ", \PHP_VERSION . "\n";
echo "nesbot/carbon: " . \Composer\InstalledVersions::getVersion('nesbot/carbon') . "\n";
echo "elapsed: {$elapsed}ns\n";
echo "per iteration: " . ($elapsed / ITER) . "ns\n";
$ composer require nesbot/carbon:~2.61.0 --quiet && php bench.php 
runtime version: 8.1.18
nesbot/carbon: 2.61.0.0
elapsed: 512420709ns
per iteration: 5124.20709ns

$ composer require nesbot/carbon:~2.60.0 --quiet && php bench.php 
runtime version: 8.1.18
nesbot/carbon: 2.60.0.0
elapsed: 181720459ns
per iteration: 1817.20459ns

どのバージョンでパフォーマンスが劣化したのかを突き止められたので、次は実際にどのような変更が行われたのかを確認していくことにします。どうやら serialize / unserialize を PHP ネイティブの仕組みではなく、ユーザーランドでマジックメソッドを用いて行うように変更されたようです。

とはいえ一見必要なさそうに見える対応だったので、 GitHub 上に issue を作成し、メンテナの方にお話を伺ってみることにしました。

github.com

すると、 PHP 8.2 の serialize / unserialize 周りにどうやら不具合があり、このような workaround を行わないとセグメンテーション違反を起こしてクラッシュするということがわかりました。

これは明らかに PHP 側のバグであったため、コアダンプやスタックトレース等を取り、 PHP 側にバグ報告を行いました。どうやらこれは ext-date 側の内部実装をより新しいシリアライズの仕組みに変更した時に発生したバグのようでした。これはすぐ修正されることになり、問題なく動作するようになりました。

github.com

一方で nesbot/carbon 側はシリアライズ結果の互換性維持のため、 2.61.0.0 で行った変更を元に戻すのは困難な状況となってしまいました。また特定状況下で Closure をシリアライズ化しようとしてしまう問題もあるようでした。

とはいえ、このパフォーマンス劣化の影響は厳しく、負荷試験結果にも影響を及ぼすような状況でした。どうしよう...

独自に Patch を書いて適用

コロプラでの利用状況を調べていくと、 PHP 8.2.8 (修正が取り込まれたバージョン) 以降であれば workaround は不要であり、 Closure のシリアル化の問題にも該当しないことがわかりました。

というわけで対応方針としては公式版から workaround 部分の実装を削除する、ということになったのですが、次はこれをどう実現するかという問題にぶち当たります。

cweagans/composer-patches の活用

github.com

PHP には様々なライブラリが用意されており、その中にはパッケージマネージャである composer の機能を利用し、よりメタな作業を自動化するプラグインも用意されています。

cweagans/composer-patches もその一つであり、このプラグインはインストールされたライブラリに自動的に patch を当てることができます。

patch の作成は簡単で、まずは nesbot/carbon をローカルに git clone し、行いたい変更を行った後に git diff した結果をファイルにまとめるだけです。今回はこのようなパッチが完成しました。

diff --git a/src/Carbon/CarbonInterface.php b/src/Carbon/CarbonInterface.php
index 4b6ce76c..7d4237c1 100644
--- a/src/Carbon/CarbonInterface.php
+++ b/src/Carbon/CarbonInterface.php
@@ -702,15 +702,6 @@ interface CarbonInterface extends DateTimeInterface, JsonSerializable
      */
     public function __isset($name);

-    /**
-     * Returns the values to dump on serialize() called on.
-     *
-     * Only used by PHP >= 7.4.
-     *
-     * @return array
-     */
-    public function __serialize(): array;
-
     /**
      * Set a part of the Carbon object
      *
@@ -754,15 +745,6 @@ interface CarbonInterface extends DateTimeInterface, JsonSerializable
      */
     public function __toString();

-    /**
-     * Set locale if specified on unserialize() called.
-     *
-     * Only used by PHP >= 7.4.
-     *
-     * @return void
-     */
-    public function __unserialize(array $data): void;
-
     /**
      * Add given units or interval to the current instance.
      *
diff --git a/src/Carbon/Traits/Serialization.php b/src/Carbon/Traits/Serialization.php
index 53fead69..96f7409a 100644
--- a/src/Carbon/Traits/Serialization.php
+++ b/src/Carbon/Traits/Serialization.php
@@ -136,48 +136,6 @@ trait Serialization
         return $properties;
     }

-    /**
-     * Returns the values to dump on serialize() called on.
-     *
-     * Only used by PHP >= 7.4.
-     *
-     * @return array
-     */
-    public function __serialize(): array
-    {
-        // @codeCoverageIgnoreStart
-        if (isset($this->timezone_type)) {
-            return [
-                'date' => $this->date ?? null,
-                'timezone_type' => $this->timezone_type,
-                'timezone' => $this->timezone ?? null,
-            ];
-        }
-        // @codeCoverageIgnoreEnd
-
-        $timezone = $this->getTimezone();
-        $export = [
-            'date' => $this->format('Y-m-d H:i:s.u'),
-            'timezone_type' => $timezone->getType(),
-            'timezone' => $timezone->getName(),
-        ];
-
-        // @codeCoverageIgnoreStart
-        if (\extension_loaded('msgpack') && isset($this->constructedObjectId)) {
-            $export['dumpDateProperties'] = [
-                'date' => $this->format('Y-m-d H:i:s.u'),
-                'timezone' => serialize($this->timezone ?? null),
-            ];
-        }
-        // @codeCoverageIgnoreEnd
-
-        if ($this->localTranslator ?? null) {
-            $export['dumpLocale'] = $this->locale ?? null;
-        }
-
-        return $export;
-    }
-
     /**
      * Set locale if specified on unserialize() called.
      *
@@ -214,38 +172,6 @@ trait Serialization
         $this->cleanupDumpProperties();
     }

-    /**
-     * Set locale if specified on unserialize() called.
-     *
-     * Only used by PHP >= 7.4.
-     *
-     * @return void
-     */
-    public function __unserialize(array $data): void
-    {
-        // @codeCoverageIgnoreStart
-        try {
-            $this->__construct($data['date'] ?? null, $data['timezone'] ?? null);
-        } catch (Throwable $exception) {
-            if (!isset($data['dumpDateProperties']['date'], $data['dumpDateProperties']['timezone'])) {
-                throw $exception;
-            }
-
-            try {
-                // FatalError occurs when calling msgpack_unpack() in PHP 7.4 or later.
-                ['date' => $date, 'timezone' => $timezone] = $data['dumpDateProperties'];
-                $this->__construct($date, unserialize($timezone));
-            } catch (Throwable $ignoredException) {
-                throw $exception;
-            }
-        }
-        // @codeCoverageIgnoreEnd
-
-        if (isset($data['dumpLocale'])) {
-            $this->locale($data['dumpLocale']);
-        }
-    }
-
     /**
      * Prepare the object for JSON serialization.
      *

後はこの patch を適切な場所に保存し、 composer.json で以下のように指定した後 composer install を実行しなおすことでパッチが適用されます。

{
    "extra": {
        "patches": {
            "nesbot/carbon": {
                "Remove __serialize/__unserialize from Carbon": "./resources/patches/carbon-downgrade-serialization.patch"
            }
        }
    }
}

cweagans/composer-patches はいいぞ

OSS として公開されているライブラリにパッチを当てたくなる場面はアプリケーションの運用が長くなる上で稀に発生します。cweagans/composer-patches は Composer というエコシステムの中でこれを実現してくれます。インストールされるライブラリのバージョンは composer.lock でロックされているので、パッチの影響でアプリケーションが壊れる心配もありません。実際に静的解析ツールである PHPStan などでも使われており、実績も十分です。

なにげに知らない人も多い cweagans/composer-patches ですが、活用することでこのような利用方法があるよ、というお話でした。

おまけ: みんなちゃんとバグ報告をしよう

実はこの ext-date のバグ、存在が確認されていながらもしばらく放置されていました。何なら nesbot/carbon 側で workaround してしまっています。本来こういうものはしっかりとダンプやトレースを添えて upstream にバグ報告をすべきものです。基本的に SEGV したときはだいたいの確率でバグなので、積極的にバグ報告していくと良いと思います。

…コアダンプやスタックトレースをどう取れば良いかわからない?大丈夫!私もわからなかったので PHP Conference 2023 にできるようになるためのアレコレについて CfP を提出しました!採用されたら話します (されなくてもブログにはまとめます) →採用されませんでしたが、後日ブログ記事として公開します!

fortee.jp

 


ColoplTechについて

コロプラでは、勉強会やブログを通じてエンジニアの方々に役立つ技術や取り組みを幅広く発信していきます。
connpassおよびTwitterで情報発信していますので、是非メンバー登録とフォローをよろしくお願いいたします。

colopl.connpass.com

twitter.com

 

また、コロプラではゲームや基盤開発のバックエンド・インフラエンジニアを積極採用中です!
興味を持っていただいた方はぜひお気軽にご連絡ください。