hnwの日記

PHP7から文字列の無駄なコピーが減った話

このエントリは闇PHP Advent Calendar 2015の14日目です。


本稿では、PHP7のzend_string構造体導入によるメリットの話をします。

PHP5とPHP7の文字列型の扱い

PHP5では、文字列型の変数は次のようにメモリに割り当てられます(横幅いっぱいが8バイト)。



文字列の本体以外はzval構造体で管理し、文字列の本体は別途メモリ確保するという形になっています。一方、PHP7では次のようになります。



1つの文字列変数が、zval構造体とzend_string構造体の組み合わせで実現されています。


これだけ見ると、PHP7では文字列長と参照カウンタrefconuntzvalから追い出されてzend_stringに移動したくらいで、PHP5とPHP7のメモリ消費量に大きな違いは無いように思えるかもしれません(あるいはPHP7の方が不利に見えるかもしれません)。しかし、実際には参照カウンタがzend_stringに移動したことで文字列のコピー回数を抑えることができ、メモリの節約を実現できています。


以下、実例を2つ紹介していきます。

文字列操作関数での文字列コピーが減った

まずはPHPの関数strtolower()の挙動について見てみましょう。下記のコードを実行すると、$a$bの値はどちらも"a"になります。

<?php
$a = chr(0x61); // "a"
$b = strtolower($a); // "a"


ところで、PHP5で上記プログラムを実行すると、$a$bとの文字列の実体は2つに分かれています。



一方、PHP7で動かした場合は$a$bとの文字列の実体は同じになります。



なぜこのような差が生まれるのでしょうか。PHP5のstrtolower関数の実体は次のようなコードになっています。

/* ext/standard/string.c */
PHP_FUNCTION(strtolower)
{
        char *str;
        int arglen;

        if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s", &str, &arglen) == FAILURE) {
                return;
        }

        str = estrndup(str, arglen);
        php_strtolower(str, arglen);
        RETURN_STRINGL(str, arglen, 0);
}


元の文字列を複製してから、php_strtolower()で小文字にするという処理になっています。この実装だと文字列の実体が別になってしまうのは必然です。PHP7の方はどうでしょうか。処理の実体であるphp_string_tolower()を見てみましょう。

/* ext/standard/string.c */
PHPAPI zend_string *php_string_tolower(zend_string *s)
{
        unsigned char *c, *e;

        c = (unsigned char *)ZSTR_VAL(s);
        e = c + ZSTR_LEN(s);

        while (c < e) {
                if (!islower(*c)) {
                        register unsigned char *r;
                        zend_string *res = zend_string_alloc(ZSTR_LEN(s), 0);

                        if (c != (unsigned char*)ZSTR_VAL(s)) {
                                memcpy(ZSTR_VAL(res), ZSTR_VAL(s), c - (unsigned char*)ZSTR_VAL(s));
                        }
                        r = c + (ZSTR_VAL(res) - ZSTR_VAL(s));
                        while (c < e) {
                                *r = tolower(*c);
                                r++;
                                c++;
                        }
                        *r = '\0';
                        return res;
                }
                c++;
        }
        return zend_string_copy(s);
}

/* Zend/zend_string.h */
static zend_always_inline zend_string *zend_string_copy(zend_string *s)
{
        if (!ZSTR_IS_INTERNED(s)) {
                GC_REFCOUNT(s)++;
        }
        return s;
}


php_string_tolower()の引数zend_string *sが元々全て小文字なら参照カウンタを1増やして同じ文字列を返し、それ以外のときは新たにメモリを確保して小文字化した文字列を返すという処理になっているため、無駄な文字列コピーを減らすことができています。


逆に、PHP5でコピーが行われてしまう理由はphp_strtolower()の引数がchar *型であるためだとも言えます。char *には参照カウンタのような概念は無いので、同じポインタをそのまま返すというわけにはいきません(参照カウンタ無しに文字列を共有してしまうと、誰がいつメモリを解放していいかわからなくなります)。かといって、文字列が入っているとは限らないzval *を渡すわけにもいきませんから、PHP5のデータ構造のままでは改善しにくい点だと言えるでしょう。一方、PHP7では内部的な文字列操作関数にzend_stringを渡せるようになったので、参照カウンタを活用して無駄なコピーを避ける実装が現実的になったというわけです。

ハッシュキーでの文字列コピーが減った

今度は配列のキーに文字列を使った場合に注目してみましょう。さて、次のコードの$k$vとでは文字列のコピーが発生するでしょうか?

<?php
$a = chr(0x61); // "a"
$b = strtolower($a); // "a"
foreach ([$a => $b] as $k => $v) {
    var_dump($k, $v);
}


PHP5でも、$v$bと同じ実体となり、コピーは発生しません。一方、$k$aと別の実体になります。PHP5のハッシュ実装では文字列キーをzval *でなくchar *で管理しているため、文字列のコピーが避けられません。文字列操作関数のときと同じ問題がここでも起こっているわけです。



PHP7の場合は$a,$b,$k,$v全ての文字列の実体が同一になります。PHP7ではハッシュの文字列キーがzend_stringで管理されるようになったので、参照カウンタを使って文字列を共有することができるのです。


おまけ

この記事を書いていてstrtolower()/strtoupper()の文字列コピーをもう少し削減できることに気付いたのでPull Requestを投げたところ、無事採用されたようです(「Optimize strtolower()/strtoupper()」)。誤差程度だろうと思いますが、PHP 7.1.0からPHPが更に速くなるかもしれません。

まとめ

PHP7では内部的な文字列の管理がzend_string構造体に統一されました。zend_stringはメンバ変数として参照カウンタを持っており、これを利用して文字列コピーの回数を減らせていることを紹介しました。zvalなど他のデータ構造の変更と同様、高速化および省メモリ化を意識した変更だと言えるでしょう。