hnwの日記

PHP7からstrlen関数に特化した高速化が採用された


(2016/01/01 02:20追記)mbstring.func_overloadの章を盛大に書き換えました。なぜか廃止されたと思い込んでたんですが、特に廃止もされてなくて、PHP 7でも動くことは動きます。ただ、仕組み上strlenだけ言うことを聞かなくなっていますので、使い道としては厳しいと思います。

(2017/5/14 追記PHP 7でmbstring.func_overloadを有効にしてもstrlen()は期待通り動作しないと書いていましたが、期待通りmb_strlen()として動作していました。つまり、「2016/01/01 02:20追記」が一部嘘でしたので、記事を修正・追記しました。


みなさん、もうPHP 7は試してみましたか?


PHP 7のセールスポイントと言えば高速化ですよね。その高速化ですが、個人的には「そこ速くする余地あったの?」と思えるような箇所が高速化されていたりします。本稿では、そうした意外な高速化ポイントの一つとしてstrlen関数に関する高速化について紹介します。

strlen関数と最適化

念のため説明しておきますと、strlen関数というのは文字列の長さを返す関数です。ところで、PHPでは文字列の長さはあらかじめ文字列本体とは別に格納されています(PHPの文字列はヌル文字でターミネートされていないので、文字列長がないと文字列末尾がわかりません)。つまり、元々わかっている数字を返すだけですから、strlen関数自体は大した仕事はしません。


大した仕事をしていない関数が最適化の対象になるというのは少し不思議な気もしますが、そういうわけではありません。実はPHPの関数呼び出しはコストが比較的高いので、関数呼び出し自体が最適化の対象になっているというわけなのです。


実際に、PHP 7では関数呼び出し自体のコストを下げるような最適化も行われました(参考:「PHP 7調査(16)高速な引数パーサの導入」)。しかし、strlen関数については「そもそも関数呼び出しをしない」という最適化が採用されています。なんと、strlen関数は関数呼び出しではなくZend VMの1命令に格上げになっています。


このことを確認してみましょう。vld拡張でstrlen関数のコンパイル結果を確認してみると、見慣れないSTRLENというopcodeが見つかります。

$ php -dextension=vld.so -dvld.active=1 -r 'strlen($foo);'
(略)
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   1     0  E >   STRLEN                                           ~1      !0
         1        FREE                                                     ~1
         2      > RETURN                                                   null
(略)
$


貴重なopcodeをこんなことで1個消費していいのか?他の関数では同じ最適化をしないのか?という疑問も湧いてきますが、頻繁に呼ばれる割に大したことをしていない数少ない関数ということなのかもしれません。

対応するCソースコードを確認する

PHP 7のZend/zend_compile.cを見ると、次のように一部の関数を特別扱いしている処理が見つかります。

int zend_try_compile_special_func(znode *result, zend_string *lcname, zend_ast_list *args, zend_function *fbc) /* {{{ */
{
        if (fbc->internal_function.handler == ZEND_FN(display_disabled_function)) {
                return FAILURE;
        }

        if (zend_string_equals_literal(lcname, "strlen")) {
                return zend_compile_func_strlen(result, args);
        } else if (zend_string_equals_literal(lcname, "is_null")) {
                return zend_compile_func_typecheck(result, args, IS_NULL);
        } else if (zend_string_equals_literal(lcname, "is_bool")) {
                return zend_compile_func_typecheck(result, args, _IS_BOOL);
        } else if (zend_string_equals_literal(lcname, "is_long")
                || zend_string_equals_literal(lcname, "is_int")
                || zend_string_equals_literal(lcname, "is_integer")
        ) {
                return zend_compile_func_typecheck(result, args, IS_LONG);
        } else if (zend_string_equals_literal(lcname, "is_float")
                || zend_string_equals_literal(lcname, "is_double")
                || zend_string_equals_literal(lcname, "is_real")
        ) {
                return zend_compile_func_typecheck(result, args, IS_DOUBLE);
        } else if (zend_string_equals_literal(lcname, "is_string")) {
                return zend_compile_func_typecheck(result, args, IS_STRING);
        } else if (zend_string_equals_literal(lcname, "is_array")) {
                return zend_compile_func_typecheck(result, args, IS_ARRAY);
        } else if (zend_string_equals_literal(lcname, "is_object")) {
                return zend_compile_func_typecheck(result, args, IS_OBJECT);
        } else if (zend_string_equals_literal(lcname, "is_resource")) {
                return zend_compile_func_typecheck(result, args, IS_RESOURCE);
        } else if (zend_string_equals_literal(lcname, "defined")) {
                return zend_compile_func_defined(result, args);
        } else if (zend_string_equals_literal(lcname, "call_user_func_array")) {
                return zend_compile_func_cufa(result, args, lcname);
        } else if (zend_string_equals_literal(lcname, "call_user_func")) {
                return zend_compile_func_cuf(result, args, lcname);
        } else if (zend_string_equals_literal(lcname, "assert")) {
                return zend_compile_assert(result, args, lcname, fbc);
        } else {
                return FAILURE;
        }
}
/* }}} */


たしかにstrlen関数のための分岐があるのがわかります。


また、strlen関数以外にもis_long関数などが特別扱いされていることがわかります。実はこれらの関数も関数呼び出しを行わないような最適化が行われています。


また、assert関数も特別扱いされていることがわかります。PHP 7からassert関数の内側に任意の式を書けるようになったわけですが、assertの引数を普通の引数と同じように評価するわけにはいかないので、式として保存するような処理をしてるんだろう、と想像がついたりするわけです。

strlen関数の最適化とmbstring.func_overloadの関係

(ごめんなさい、この章は思い込みで書いていたので全面的に書き換えました…)


PHPにはmbstring.func_overloadを指定することでsubstr関数の呼び出しに対してmb_substr関数が呼ばれるような機能があります。マルチバイト非対応のPHPアプリケーションを無理矢理マルチバイト対応にするための大昔のハックなんだと思います。


ところで、この機能は今回紹介した最適化と密接な関係があります。というのも、strlenもmbstring関数への置き換え対象だからです。この機能は関数テーブルの置き換えにより実現されているのですが、今回のように別opcodeにコンパイルされてしまうと当然関数呼び出しは行われないため、関数の挙動を変更できません。


そこで、PHP 7ではmbstring.func_overloadが設定されていた場合にはstrlenのopcode化は行われず、従来通り関数呼び出しの形でコンパイルされます。つまり、期待通りmb_strlenが呼び出されることになります。

まとめ

  • PHP 7からstrlen関数のためだけにopcodeが1個増えた
  • PHP 7では、strlenを含むいくつかの関数が関数呼び出しされなくなった
  • mbstringのオシャレ機能mbstring.func_overloadは生き残っている
    • PHP 7.2からようやく非推奨になった