ZendEngineにえこひいきされる標準関数たち (後篇)

こんにちは！ どんどんえこひいきしていきましょう！

https://qiita.com/tadsan/items/643a7cd0b4d7f27dd3ed

というわけで、後篇のこの記事では最適化の恩恵が受けられる場合において、どのような関数がその対象になるのかを見ていきましょう。

:::note
この記事ではPHP 8.2.0を対象に紹介します
:::

:::note warn
この記事ではZendEngine命令名のプレフィクス `ZEND_`を省略しています (vldスタイル)
:::

## コードを読む

関数の最適化を担っているのは `zend_try_compile_special_func` 関数です。

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L4353-L4436

プログラムを読める人ならこれを見てもらえれば何をやっているのかは一目瞭然なのですが、せっかくなので紹介していきましょう。

### `strlen()`

言わずと知れた文字列長を返す関数です。より正確に言うならバイトシーケンスの長さを返すのですが、今回の最適化とは直接関係がないので置いておきましょう。

https://www.php.net/strlen

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L3808-L3825

この関数は引数が `strlen('str')` のようなリテラルだったらその長さ(つまり`3`)に展開します。そうでない場合(変数や式など)の場合は`STRLEN`という専用命令にコンパイルします。また、`strlen()`自体が内部的には別にちまちまバイト数を数えてるわけじゃなくて`zend_string`構造体のメンバー変数`len`を返してるだけなので遅くはないです。

:::note
`INIT_FCALL 'var_dump'`のように文字列で関数コールするのと専用命令があるのは電話番号をポチポチ押して電話を掛けるのと、受話器を上げるだけで繋がる専用線で直通しているくらい違う気がします。いや、言いすぎか…
:::

### `is_*()` 型チェック関数

`is_null()`, `is_bool()`, `is_int()`, `is_float()`, `is_string()`, `is_array()`, `is_object()`, `is_resource()`, `is_scalar()`が該当します。

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L3828-L3846

関数呼び出しを`ZEND_TYPE_CHECK`命令に置き換えます。

### `*val()` 型キャスト関数

`boolval()`, `intval()`, `floatval()`, `strval()`が該当します。

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L3863-L3881

`ZEND_CAST`命令(boolのみ`ZEND_BOOL`)に置き換えます。もちろんこれはキャスト構文`(string)$val`と同じ結果なので、`strval($val)`と書くべきかは単純に**好みの問題**だといえます。

:::note
強いていうなら`(string)`の方が `use function strval;`と書かなくてもパフォーマンス低下の心配がない
:::

### `chr()`

`chr()`は8ビットの数値(1〜255)を1バイトのバイナリ(文字列)に変換する関数です。

https://www.php.net/chr

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L3915-L3931

`chr(97)`のように書くと、文字列の`'a'`として展開されます。

たとえば `echo chr(240) . chr(159) . chr(144) . chr(152);`は `ECHO '🐘'`という命令に変換されます。

https://3v4l.org/v2mli/vld

### `ord()`

`ord()`はバイナリ列(文字列)の先頭のバイトを8ビットの数値(1〜255)に変換する関数です。

https://www.php.net/ord

`ord('a')` のように書くと、intの`97`として展開されます。

## `call_user_func()` / `call_user_func_array()`

`call_user_func()` と `call_user_func_array()` は関数／メソッドを動的呼び出しする関数です。

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L3948-L4085

いままでの処理と比べるとコードがちょっと長いのですが、ふたつの関数は共通処理が多いのでまとめて書きます。

 * 呼び出しの関数名に文字列リテラルで指定されていれば `INIT_FCALL` に置き換え
     * つまり普通の関数呼び出しと同じ形にコンパイルする
 * 関数名が固定でなければ `INIT_USER_CALL` 命令に置き換え

`call_user_func('var_dump', 1);`のように第一引数に関数を文字列で書いていると`var_dump(1);`と同じようなコードにコンパイルしてくれます。一瞬「すごーい」と思ったのですが、それ最初から`var_dump(1)`って書けばよくね…？ となるので存在意義が不明ではあります。

近年では `$f = 'var_dump'; $f(1);` や `var_dump(...$args);` のように `call_user_func()` / `call_user_func_array()` なしで関数の動的呼び出しが簡単になっています。

<details><summary>関数名固定・パラメータ可変のコンパイル結果比較 (折り畳み)</summary>

```php
<?php

$args = [1];
call_user_func('var_dump', ...$args);
call_user_func_array('var_dump', $args);
var_dump(...$args);
```

https://3v4l.org/2Vkk4/vld

```
line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
    3     0  E >   ASSIGN                                                   !0, <array>
    4     1        INIT_FCALL                                               'call_user_func'
          2        SEND_VAL                                                 'var_dump'
          3        SEND_UNPACK                                              !0
          4        CHECK_UNDEF_ARGS                                         
          5        DO_ICALL                                                 
    5     6        INIT_FCALL                                               'var_dump'
          7        SEND_ARRAY                                               !0
          8        CHECK_UNDEF_ARGS                                         
          9        DO_FCALL                                      0          
    6    10        INIT_FCALL                                               'var_dump'
         11        SEND_UNPACK                                              !0
         12        CHECK_UNDEF_ARGS                                         
         13        DO_ICALL                                                 
         14      > RETURN                                                   1
```

</details>

<details><summary>関数名可変・パラメータ固定のコンパイル結果比較 (折り畳み)</summary>

```php
<?php
$f = 'var_dump';
call_user_func($f, 1);
call_user_func_array($f, [1]);
$f(1);
```

https://3v4l.org/RrDUs/vld

```
line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
    2     0  E >   ASSIGN                                                   !0, 'var_dump'
    3     1        INIT_USER_CALL                                1          'call_user_func', !0
          2        SEND_USER                                                1
          3        DO_FCALL                                      0          
    4     4        INIT_USER_CALL                                0          'call_user_func_array', !0
          5        SEND_ARRAY                                               <array>
          6        CHECK_UNDEF_ARGS                                         
          7        DO_FCALL                                      0          
    5     8        INIT_DYNAMIC_CALL                                        !0
          9        SEND_VAL_EX                                              1
         10        DO_FCALL                                      0          
         11      > RETURN                                                   1
```

</details>

`INIT_FCALL`, `INIT_USER_CALL`, `INIT_DYNAMIC_CALL`のパフォーマンス的な比較はできていないので本稿においてはコンパイル結果の比較に留めておきます。

### `in_array()`

`in_array()` は要素が配列に含まれるかどうかをチェックする関数です。

https://www.php.net/in_array

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L4137-L4220

コードの書きかたが若干複雑なのですが、

 * `in_array($v, [1, 2, 3], strict: true)` のように `strict: true` で配列がリテラルで要素が文字列または整数
 * `in_array($v, ['a', 'b', 'c'])` のように配列がリテラルで要素がすべて文字列

このどちらかの条件のとき、内部的にハッシュテーブルを構築した上で`IN_ARRAY`命令にコンパイルすることで高速に探索できるようになっています。

:::note
いま私はこの記事を書きながら`in_array()`の最適化を知って感動しています。
この最適化はDmitry Stogovさん[^1]によって2017年に[PHP 7.2.0で導入](https://github.com/php/php-src/commit/7c7383067f6719324004407e17c0f5d6c740c39f)されていました。
:::

なお、`IN_ARRAY`命令を使わない場合の`in_array()`と`isset`の比較については以下の記事のコメントに書きました。

https://qiita.com/okasir4444/items/2aceed75b9ae6f488d99#comment-e3d28cd54cfab7394ec8

https://github.com/zonuexe/bench-in_array

[^1]: PHPバス係数の1。参考: [インフィニットループは PHP の継続的な発展を目指す The PHP Foundation に寄付をしました](https://www.infiniteloop.co.jp/tech-blog/2021/11/php-foundation/)

### `count()`

`count()`は配列やオブジェクトの要素数を返す関数です。 エイリアス: `sizeof()`

https://www.php.net/count

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L4222-L4237

これは引数の数が変じゃない限りは無条件で`COUNT`命令に置換しています。

せっかくなので実行時に呼ばれる `COUNT` 命令のハンドラの方も読んでみましょう。

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_vm_def.h#L9144-L9197

これエラーハンドリングなんですが。

```c
zend_type_error("%s(): Argument #1 ($value) must be of type Countable|array, %s given", opline->extended_value ? "sizeof" : "count", zend_zval_type_name(op1));
```

……なんだかあたかも関数からエラーが出たかのように偽装されていて微笑ましいですね。

### `get_class()` / `get_called_class()` / `gettype()`

それぞれ別物の機能なのですが、最適化の実装は似ているのでまとめて書きます。

https://www.php.net/get_class

https://www.php.net/get_called_class

https://www.php.net/gettype

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L4239-L4280

引数の数が間違ってない限り、`GET_CLASS`, `GET_CALLED_CLASS`, `GET_TYPE` 命令に置き換える。以上です。

### `func_num_args()` / `func_get_args()`

PHP 7でパラメータ宣言に`...`が導入される前に任意個の引数をとりたいときに使われていた機能です。

https://www.php.net/func_num_args

https://www.php.net/func_get_args

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L4282-L4302

おもしろみがないのですが、これも `FUNC_NUM_ARGS`、 `FUNC_GET_ARGS`命令にコンパイルしているだけです。

### `array_slice()`

配列の一部を切り取る関数です。

https://www.php.net/manual/ja/function.array-slice.php

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L4320-L4351

C言語のコードなんて見たらわかるだろ… と思ったらわからない。なんでこんなことに…

### `array_key_exists()`

配列にキーが存在するかを返す関数です。

https://www.php.net/array_key_exists

https://github.com/php/php-src/blob/php-8.2.0/Zend/zend_compile.c#L4304-L4318

これは関数呼び出しを`ARRAY_KEY_EXISTS`命令に変換します。

大昔は「`isset`は言語構造だから高速だが`array_key_exists()`は関数だから重い」というのはPHPのパフォーマンス鉄板ネタだったのですが、[PHP 7.4.0から専用命令になった](https://github.com/php/php-src/commit/f5044a12ddb6f79d59f72c9c12a6cad36d5f6492)ので現在では大きな差はなくなっているはずです。

`isset($array['key'])`と`array_key_exists('key', $array)`は記法の違いの好みだけでなく機能が異なる(`null`の扱いが違う)のですが、すくなくとも `isset($array['key']) || array_key_exists('key', $array)` のようなことをしてパフォーマンスを稼ぐ必要はなくなったのです。21世紀の文明だ。

https://twitter.com/effy_staffs/status/930828895984631809


## まとめ

Zend Engineにひいきしてもらったところでパフォーマンス改善に役立つかというと、損はしないがこれ目当てでもボトルネックを削減できるようなパターンになることは稀な、微妙な技です。フレームワークだったりテンプレートエンジンだったり、あと頻繁にデータにアクセスするようなツールだと効くようなこともあるかも。

あと、せっかくなのでPHP勉強会@東京でLT発表してきました。ということで以下の記事に続く。

https://tadsan.fanbox.cc/posts/4968263