[PHP8対応] PHP拡張モジュールを自作してBlurHashの計算を高速化する

# BlurHashについて
https://blurha.sh/

Webサイトにおける画像のプレースホルダーとして提案されているものです。画像の読み込みには時間がかかるので、読み込みが完了するまでの間に仮に表示させておく画像がプレースホルダーです。
ASCII文字30文字程度という非常に少ない情報量で保存できるので、HTMLに埋め込んでおくことも可能です。それでいて復元後の画像は色調をある程度再現しているので、クライアント側で画像を復元して表示することにより、真っ白な画像を出すよりもUXを向上できるでしょう。見た目がぼかしフィルターをかけたようなのでBlurとついています。

例を示しましょう。以下の画像をBlurHashに変換し、BlurHashからプレースホルダー画像を生成したものです。この色調をASCII文字28文字で表現しています。

![sample.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/0e8c4960-1a01-b19d-c861-7e3c16423d1e.jpeg)
↓encode
``LnIX{#t6Ntf6yGs:X7j?I@juxGj[`` (BlurHash)
↓decode
![decoded.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/a123fe09-cd0c-ca4d-c245-b8424d6d40a5.png)

特にWebアプリで重宝されるアルゴリズムなので、PHPでも使いたいケースが出てくるかと思います。PHPの実装も公開されていますが、このPHP実装によるエンコード処理が非常に遅いのです。

# C言語の実装 vs PHPの実装

- C言語版: https://github.com/woltapp/blurhash/tree/master/C
- PHP版: https://github.com/kornrunner/php-blurhash

実験条件は以下とします。
- Ubuntu 22.04.4 LTS
- CPU: Celeron(R) CPU 847 @ 1.10GHz（10年前くらいのNUCです）
- GCC 11.4.0
- PHP 8.1.2

## コマンド

```bash:Terminal
# C言語
./blurhash_encoder 4 3 sample.jpg

# PHP
php blurhash_encoder.php 4 3 sample.jpg
```

PHPのスクリプトは以下のようにしています。上記「PHP版」のリポジトリのReadme「Encoding with GD」のサンプルほぼそのままで、autoloadを使わないように書いた点が主な差分です。

```blurhash_encoder.php
<?php
require_once('php-blurhash/src/Blurhash.php');
require_once('php-blurhash/src/Color.php');
require_once('php-blurhash/src/DC.php');
require_once('php-blurhash/src/AC.php');
require_once('php-blurhash/src/Base83.php');

use kornrunner\Blurhash\Blurhash;

$components_x = int($argv[1]);
$components_y = int($argv[2]);
$file = $argv[3];
$image = imagecreatefromstring(file_get_contents($file));
$width = imagesx($image);
$height = imagesy($image);

$pixels = [];
for ($y = 0; $y < $height; ++$y) {
    $row = [];
    for ($x = 0; $x < $width; ++$x) {
        $index = imagecolorat($image, $x, $y);
        $colors = imagecolorsforindex($image, $index);

        $row[] = [$colors['red'], $colors['green'], $colors['blue']];
    }
    $pixels[] = $row;
}

$blurhash = Blurhash::encode($pixels, $components_x, $components_y);
echo $blurhash;
```

## 結果

timeコマンドで処理時間を計測し、結果を比較します。
入力画像サイズは640×427pxです。

```
C implementation
real    0m0.693s
user    0m0.689s
sys     0m0.004s

PHP implementation
real    0m4.884s
user    0m4.668s
sys     0m0.216s
```
PHP実装では、なんとC言語実装の約7倍の処理時間がかかることが分かりました。

## PHP実装の何が遅い？

見るからに↓は遅そうですよね。

```php
for ($y = 0; $y < $height; ++$y) {
    $row = [];
    for ($x = 0; $x < $width; ++$x) {
        $index = imagecolorat($image, $x, $y);
        $colors = imagecolorsforindex($image, $index);

        $row[] = [$colors['red'], $colors['green'], $colors['blue']];
    }
    $pixels[] = $row;
}
```

1ピクセルずつ画素値を取得して配列に入れることを繰り返しています。配列といっても、C言語のように同じデータ型の値が規則的に並んでいることを仮定していません。どんなデータ型でも入れられる代わりに実行速度は遅いです。ループ処理についても、インタプリタ言語で典型的に遅くなるパターンのような気がします。
BlurHashの計算部分の実装もPure PHP（全部の処理がPHPで書いてある）であり、このようなループ処理が随所に書かれています。

# PHPのWebアプリからでも高速に実行する方法

ここで思いつく改善策として、C言語の実装をPHPの拡張モジュールとしてコンパイルすることが挙げられます。やってみましょう。
これ以降の内容は、主に以下の各ページの内容をもとにしています。

https://www.zend.com/resources/writing-php-extensions

https://qiita.com/nomunomu0504/items/4e590b68a12b831ad84b

## 準備

開発用のパッケージをインストールします。おそらく以下のパッケージがあればよいのではないかと思います。（不要なものがあるかもしれません）
```bash:Terminal
sudo apt install autoconf automake libtool m4 php-dev
```

PHPのソースコードをチェックアウトします。今回は実行環境がPHP 8.1.2なので、合わせておきます。

```bash:Terminal
git clone git@github.com:php/php-src.git
cd php-src
git checkout php-8.1.2
```

## 拡張モジュールのひな形を作成する

```bash:Terminal
cd ext
./ext_skel.php --ext blurhash

(snip)
Success. The extension is now ready to be compiled. To do so, use the
following steps:

cd /path/to/php-src/ext/blurhash
phpize
./configure
make
(snip)
```

``blurhash`` というディレクトリができています。拡張モジュールとして作成する場合、ここに置いておく必要はないので、別の場所に移動しておきます。先ほどのコマンドで指示された通りに拡張モジュールをコンパイルします。

```bash:Terminal
mv blurhash /path/to/work/php-blurhash
cd /path/to/work/php-blurhash
phpize
./configure
make
```

うまくいけば、これで ``modules/blurhash.so`` という拡張モジュールが生成されます。
拡張モジュールが読み込めるかテストします。

```bash:Terminal
## 以下 (1) (2) のいずれかを行う
# (1) php.ini に設定を記入して読み込む
echo "extension=modules/blurhash.so" > php.ini
php -c php.ini -m

# (2) コマンドラインで設定を直接指定する
php -d extension=modules/blurhash.so -m
```

(1) (2) どちらの方法でも同じ結果になりますが、``[PHP Modules]`` のリストの中に ``blurhash`` が現れたら成功です。
実際に呼び出せるか試します。(1) のようにphp.iniが作成されているものとして進めます。

```bash:Terminal
php -c php.ini -r 'test1();'
> The extension test is loaded and working!
php -c php.ini -r 'echo test2("world\n");'
> Hello world
```

## 拡張モジュールの実装を眺める

このひな形では、test1, test2 の2つの関数が定義されています。実装は ``blurhash.c`` に書かれています。コメントは私が追加したものです。

```blurhash.c
PHP_FUNCTION(test1)
{
        ZEND_PARSE_PARAMETERS_NONE(); // 引数のない関数

        php_printf("The extension %s is loaded and working!\r\n", "blurhash"); // stdoutに出力
}

PHP_FUNCTION(test2)
{
        char *var = "World"; // 引数を受けるための変数、デフォルト値が指定されている
        size_t var_len = sizeof("World") - 1;
        zend_string *retval;

        ZEND_PARSE_PARAMETERS_START(0, 1)     // 0個以上1個以下の引数を受け取る
                Z_PARAM_OPTIONAL              // これ以降の引数はオプション引数
                Z_PARAM_STRING(var, var_len)  // 文字列引数、varで文字列本体を、var_lenで長さを受け取る
        ZEND_PARSE_PARAMETERS_END();

        retval = strpprintf(0, "Hello %s", var); // sprintfのような処理をして文字列 (zend_string) を返す

        RETURN_STR(retval); // 戻り値として文字列を返す
}
```

だいたい以下のような流れになっていることが分かります。
- 引数を受け取るための変数を宣言
- 引数の個数と型を宣言
- 実際の処理
- RETURN

以下のページにもう少し詳しい話が書いてあります。
https://www.zend.com/resources/php-extensions/adding-new-functionality
https://www.zend.com/resources/php-extensions/basic-php-structures

## BlurHashの実装を書く

拡張モジュールの実装は、オリジナルのC言語の実装に倣いましょう。
必要なファイルは ``common.h``, ``encode.c``, ``encode.h``, ``stb_image.h`` の4つです。このうち、ヘッダファイル3つは拡張モジュールの作業場所の ``include`` ディレクトリに置き、``encode.c`` はとりあえず拡張モジュールの作業場所のルートに置いておきます[^stb_image]。なお、前述のC言語の実装はチェックアウトされているものとします。

[^stb_image]: ``stb_image.h`` の[オリジナル実装](https://github.com/nothings/stb/blob/master/stb_image.h)から持ってくる方がよいと思われます。

```bash:Terminal
cd /path/to/blurhash/C # C言語の実装が置かれている場所
cp encode.c /path/to/work/php-blurhash
cp common.h encode.h stb_image.h /path/to/work/php-blurhash/include
```

``encode.c`` をソースファイルとして追加します。
```diff:config.m4
-  PHP_NEW_EXTENSION(blurhash, blurhash.c, $ext_shared)
+  PHP_NEW_EXTENSION(blurhash, blurhash.c encode.c, $ext_shared)
```

``blurhash.c`` にはヘッダファイルの記述を追加します。

```diff:blurhash.c
 #include "php.h"
 #include "ext/standard/info.h"
 #include "php_blurhash.h"
 #include "blurhash_arginfo.h"
+#include "encode.h"
+
+#define STB_IMAGE_IMPLEMENTATION
+#include "stb_image.h"
```

次に、PHPからの関数の呼び出し方ですが、オリジナルの ``encode_stb.c`` を参考に、今回は ``blurHashForFile`` という関数名で呼び出せるようにしてみます。プロトタイプは以下のようにします。
```php
blurHashForFile(int $xComponents, int $yComponents, string $filename)
```

``blurhash.c`` に  ``PHP_FUNCTION`` を追加し、実装を書きます。

```blurhash.c
PHP_FUNCTION(blurHashForFile)
{
    zend_long xComponents, yComponents;
    char *filename = "";
    size_t filename_len = 0;
    const char *blurHash;
    unsigned char *data = NULL;
    int width, height, channels;

    ZEND_PARSE_PARAMETERS_START(3, 3) // 引数を3個受け取る
      Z_PARAM_LONG(xComponents)
      Z_PARAM_LONG(yComponents)
      Z_PARAM_STRING(filename, filename_len)  // ファイル名をchar*として受け取るとともに、その長さを受け取る（今回は長さ情報は使用しない）
    ZEND_PARSE_PARAMETERS_END();

    data = stbi_load(filename, &width, &height, &channels, 3);
    if(!data) {
        blurHash = "";
    } else {
        blurHash = blurHashForPixels(xComponents, yComponents, width, height, data, width * 3);
    }
    stbi_image_free(data); // 画像データのメモリを解放
    RETURN_STRING(blurHash); // BlurHashを文字列として返却
}
```

``blurhash_arginfo.h`` にも ``blurHashForFile`` の情報を登録します。

```diff:blurhash_arginfo.h
 ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_test1, 0, 0, IS_VOID, 0)
 ZEND_END_ARG_INFO()
 
 ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_test2, 0, 0, IS_STRING, 0)
         ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, str, IS_STRING, 0, "\"\"")
 ZEND_END_ARG_INFO()

+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_blurHashForFile, 0, 3, IS_STRING, 0)
+    ZEND_ARG_INFO(0, xComponents)
+    ZEND_ARG_INFO(0, yComponents)
+    ZEND_ARG_INFO(0, filename)
+ZEND_END_ARG_INFO()


 ZEND_FUNCTION(test1);
 ZEND_FUNCTION(test2);
+ZEND_FUNCTION(blurHashForFile);


 static const zend_function_entry ext_functions[] = {
         ZEND_FE(test1, arginfo_test1)
         ZEND_FE(test2, arginfo_test2)
+        ZEND_FE(blurHashForFile, arginfo_blurHashForFile)
         ZEND_FE_END
 };
```

再度コンパイルすれば、``blurHashForFile``関数が使えるようになるはずです。
```bash:Terminal
phpize
./configure
make
```

## 動作確認

```blurhash_encoder_module.php
<?php
$components_x = (int)$argv[1];
$components_y = (int)$argv[2];
$filename = $argv[3];

$blurhash = blurHashForFile($components_x, $components_y, $filename);
echo $blurhash;
```

実行します。

```bash
php -c php.ini blurhash_encoder_module.php 4 3 sample.jpg
```

BlurHash値がターミナルに出力されたら成功です。
C言語実装と同じBlurHashが得られることをご確認ください。

## 復元してみる

C言語実装をコンパイルすると得られる ``blurhash_decoder`` コマンドを使って確認します。
先ほど得られたBlurHashの値、元の画像サイズ、出力先画像ファイル (png) を指定します。
```bash:Terminal
./blurhash_decoder 'LnIX{#t6Ntf6yGs:X7j?I@juxGj[' 640 427 decoded.png
```

![decoded.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/a123fe09-cd0c-ca4d-c245-b8424d6d40a5.png)

# 気になる速度を測定

```bash:Terminal
time php -c php.ini blurhash_encoder_module.php 4 3 sample.jpg
>LnIX{#t6Ntf6yGs:X7j?I@juxGj[
>real    0m1.067s
>user    0m1.039s
>sys     0m0.028s
```

高速化されたのはBlurHashの計算部分だけですし、モジュール呼び出しのオーバーヘッドもあるでしょうから、すべてC言語で書かれた場合に比べると劣ります。しかし、Pure PHPの実装と比べると計算時間は1/4以下になりました。

# まとめ

BlurHashの計算という題材でPHPの拡張モジュールを作り、処理の高速化ができることを確認しました。ドキュメントを詳細に理解しようとすると大変なのですが、基本的な関数呼び出しだけであれば見よう見まねでも案外作れることが分かりました。
他の機能については、使いたくなったら調べればよいでしょう。前述のZendのページが参考になりそうです。