PHP 7のパフォーマンスが高い理由
Yahoo! JAPAN 黒帯シリーズ 第1回 ~ 「PHPコア黒帯」による最新PHP 7の解説
蒋池 東龍(ヤフー株式会社)[著] 2015/03/23 14:00
ヤフー株式会社には、技術や制作の分野において専門性に優れたエキスパート人財を「黒帯」に認定し、その活動を手厚く支援する黒帯制度があります。「ある分野に突出した知識とスキルを持っているその分野の第一人者」が黒帯として認定され、褒賞金と活動予算が付与され、それぞれの分野のエバンジェリストとして社内外で活躍します。この黒帯によるリレー連載として、第1回目は「PHPコア黒帯」が執筆します。
PHP 7のパフォーマンスが高い理由 (1/4):CodeZine(コードジン)
2015年11月にリリース予定の「PHP 7」
PHP 7が2015年11月にリリースされる予定です。現在PHP 5系が主流となっていますが、PHP 6はUTF-16の実装が難しくなり開発を中止したので、次のメジャーバージョンアップはPHP 7です。また、最近では、Facebookが開発したHHVM(HipHop Virtual Machine)が、JITコンパイルにより超高速に動作しPHP 5系の2倍近くのパフォーマンスを誇るということで話題となっていますが、PHP 7はこのHHVMのパフォーマンスにも匹敵することでも注目されているのです。
HHVMは、スクリプトを中間言語のHHBC(HipHop bytecode)に変換した後、x64の機械語に動的にコンパイルする過程を経るので、通常のインタプリタよりも早くなることは想像できます。公式ページによると、次の図のとおり、スクリプトをHHBCに変換し、その後に機械語に変換しています。
HHVMはこういった特殊な遷移を経てパフォーマンスを高めていますが、PHP 7はこれまでのバージョンと変わらないインタプリタの構造であり、スクリプトの文法も同じであるにも関わらず、以下のとおり劇的にパフォーマンスを高めています。
PHP 7ではどのように変わり、パフォーマンスが高くなったのか考えてみましょう。
-変数の管理
スクリプトで定義される$aのような変数は、整数型、浮動小数点型、文字列型、配列型、オブジェクト型、真偽型、null型、リソース型のすべてが、内部では同じ構造体zvalで管理されています。スクリプトで定義された変数だけではなく、PHPコアで扱われる値に関しても、zvalを使用することが多いです。このzvalがPHP 7になって大幅にチューニングされたことで、全体のパフォーマンス改善に貢献しています。
データ構造がどのように変わったかは重要なので、それを見てみましょう。PHP 5のzvalの定義は以下の通りです。ソースファイルの中では1つの構造体として定義されていませんが、見やすくするためにまとめています。これ以降も、可視性を高めるために、実際の定義方法とは違う書き方をすることがあります。
typedef struct _zval_struct { union { long lval; double dval; struct { char *val; int len; } str; HashTable *ht; struct { zend_object_handle handle; zend_object_handlers *handlers; } obj; } value; zend_uint refcount; zend_uchar type; zend_uchar is_ref; } zval;
バイトアライメントの関係で8バイトの倍数となるので、サイズは24バイトになります。PHP 7のzvalは以下のとおりです。
typedef struct _zval_struct { union { long lval; double dval; zend_refcounted *counted; zend_string *str; zend_array *arr; zend_object *obj; zend_resource *res; zend_reference *ref; void *ptr; } value; union { struct { zend_uchar type; zend_uchar flags; }; zend_uint type_info; }; zend_uint reserved; } zval;
zvalのサイズが16バイトに減っています。つまり、PHP 7になるとzvalのサイズがPHP 5の時の2/3になっていますが、24バイトがどのようにして16バイトに減ったのか、PHP 5からPHP 7への変更をみてみましょう。
整数型や浮動小数点型、配列型やオブジェクト型を管理する共用体valueが構造体を持たなくなったことにより、8バイト減っています。参照やコピーオンライトを管理するrefcountとis_refがなくなったので、6バイト減っています。変数の種類を管理するtypeは、管理方法が少し異なり、2バイト増えています。新しく加わったメンバはreservedで、この4バイトも増えています。これらを計算すると、以下のように24バイトから16バイトへとなります。
24 - 8 - 6 + 2 + 4 = 16
PHP 7では、整数型、浮動小数点型、真偽型などがコピーオンライトではなくなったので、refconutとis_refをzvalで持つことをやめました。コピーオンライトになっており、参照を管理している構造体zend_refcountedを持っているのは、文字列型、配列型、オブジェクト型、リソース型、参照型だけです。PHP 5では最大公約数的にどの型も等しく持っていたメンバを、PHP 7ではそれぞれの変数の種類によって最適化したといってよいでしょう。
いくつか代表的な型の構造体をみてみます。文字列型の構造体は以下です。
typedef struct _zend_string { zend_refcounted gc; zend_ulong h; size_t len; char val[1]; } zend_string;
gcは参照を管理しているメンバで、hには文字列のハッシュ値が入っています。lenは文字列の長さで、valは文字列のサイズによって大きくなります。ハッシュ値を持っているので、探索する時にパフォーマンスが高くなるでしょう。
次は配列型の構造体です。PHP 7から使われている構造体で、PHP 5では、zvalの中にHashTableがあったので必要ありませんでした。
typedef struct _zend_array { zend_refcounted gc; HashTable ht; } zend_array;
文字列型の構造体と同じように、参照を管理するgcがあります。htが配列のキーと値を管理しているハッシュテーブル構造体です。PHP 5ではハッシュテーブル構造体がzvalのメンバでしたが、PHP 7からはzvalからarrayポインタを1つたどるのでアクセスが悪くなったと言えます。ハッシュテーブルについては、のちほど詳しく説明します。
次はオブジェクト構造体です。まずはPHP 5から紹介します。
typedef struct _zend_object { zend_class_entry *ce; HashTable *properties; HashTable *guards; } zend_object;
PHP 7のオブジェクト構造体は以下です。
typedef struct _zend_object { zend_refcounted gc; uint32_t handle; zend_class_entry *ce; const zend_object_handlers *handlers; HashTable *properties; HashTable *guards; zval properties_table[1]; } zend_object;
比べてみると、PHP 7になるとメンバが増えています。これは、PHP 5ではあった構造体zend_object_valueがなくなったからで、オブジェクト識別子handleがzend_objectに移動しました。PHP 7からはproperties_tableが新しく増えており、オブジェクトを生成する時に、プロパティの数だけ動的に配列が確保されます。プロパティの値はプロパティと共にハッシュテーブルのpropertiesに入っています。プロパティだけが管理されたproperties_tableは連続した配列なので、プロパティを探索する際には早くなります。
PHP 7でもコピーオンライトを用いる場合に使用する構造体をみてみましょう。
struct _zend_refcounted { uint32_t refcount; union { struct { zend_uchar type; zend_uchar flags; uint16_t gc_info; } v; uint32_t type_info; } u; };
参照カウントrefcountはこの構造体で持っています。typeとflagsはzvalでも持っていますが、こちらでも持っているので、もとのzvalをたどるというオーバーヘッドがなくなっています。
次は具体的な例で、変数の扱いがどのように変わったのかを比べてみましょう。まずは整数型から。
$a = 1;
上記のスクリプトでは、PHP 5とPHP 7のメモリイメージは以下となります。
比べてみると、PHP 7の方がサイズが小さいことが改めて分かります。次に別の変数に代入してみます。
$b = $a;
上記のスクリプトでは、PHP 5とPHP 7のメモリイメージは以下となります。
PHP 5ではzvalが1つしかありませんが、PHP 7ではzvalが2つになっていることが分かるでしょう。PHP 7では整数型に対してコピーオンライトではなくなったので、実体を持っています。使用するメモリを比べてみると、PHP 5では40バイトあったものが、PHP 7では32バイトとなり、少なくなっています。
$c = $a;
さらに値を入れていくとこうなります。
PHP 5では引き続きzvalが1つしかありませんが、PHP 7ではzvalが3つになっています。使用するメモリを比べてみると、PHP 5では48バイトあり、PHP 7では48バイトとなり、同じになりました。このように、変数の代入が増えていくにつれて、PHP 5の方が使用するメモリは少なくなっていきますが、PHP 5では値を参照するためにはポインタをたどっていく必要があり、これがオーバーヘッドとなっていました。
次に文字列型をみてみましょう。
$a = 'abcdefgh';
上記のスクリプトでは、PHP 5とPHP 7のメモリイメージは以下となります。
PHP 5では40バイトを使用しており、PHP 7では48バイトを使用しています。PHP 7の方が多くメモリを使用していますが、文字列の実体へはPHP 5ではポインタを3回たどるのに対して、PHP 7ではポインタを2回しかたどりません。これは文字列の実体がzval_string内にあるためです。ポインタをたどる回数が少ないことで、メモリアクセスの効率がよくなっています。
ー
3
ハッシュテーブル
PHP 7ではハッシュテーブルも高速化されています。ハッシュテーブルとバケットの構造体を比較してみましょう。まずはPHP 5からです。
typedef struct bucket { ulong h; uint nKeyLength; void *pData; void *pDataPtr; struct bucket *pListNext; struct bucket *pListLast; struct bucket *pNext; struct bucket *pLast; char arKey[1]; } Bucket; typedef struct _hashtable { uint nTableSize; uint nTableMask; uint nNumOfElements; ulong nNextFreeElement; Bucket *pInternalPointer; Bucket *pListHead; Bucket *pListTail; Bucket **arBuckets; dtor_func_t pDestructor; zend_bool persistent; unsigned char nApplyCount; zend_bool bApplyProtection; } HashTable;
以上のとおり、HashTableは72バイト、Bucketも72バイトあります。続いてPHP 7です。
typedef struct _Bucket { zval val; zend_ulong h; zend_string *key; } Bucket; typedef struct _HashTable { union { struct { zend_uchar flags; zend_uchar nApplyCount; uint16_t reserve; } v; uint32_t flags; } u; uint32_t nTableSize; uint32_t nTableMask; uint32_t nNumUsed; uint32_t nNumOfElements; uint32_t nInternalPointer; zend_long nNextFreeElement; Bucket *arData; uint32_t *arHash; dtor_func_t pDestructor; } HashTable;
HashTableは56バイト、Bucketは32バイトに大きく減っています。サイズが少なくなっただけではありません。PHP 7ではzvalの実体をもっているので、動的にzvalを確保することがなく、オーバーヘッドが生じません。さらに、ハッシュテーブルの構造をみてみましょう。
PHP 5ではpListHeadからBucketを動的に確保してチェーンをつないでいましたが、PHP 7ではarDataの先には連続したBucketが確保されています。Bucketを動的に確保することがないので、オーバーヘッドが生じません。ポインタをたどる必要がないので、Bucketを走査する処理も明らかに早くなります。
ー
関数の引数の処理
関数の引数の処理についても速くなっています。関数の解析に使われている関数zend_parse_parameters()がCPUの5%を占めていました。これは、zend_parse_parameters()の引数に与えている、その関数の引数仕様を理解するために、scanf()のように文字列を解析しているからです。具体的には内部で以下のようなコードになっています。
if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "za|b", &value, &array, &strict) == FAILURE) { return; }
PHP 7になって、互換性を保つために、この関数も残されていますが、パフォーマンスが高いマクロも作られました。前述のコードは以下のように書き換えられます。
ZEND_PARSE_PARAMETERS_START() Z_PARAM_ZVAL(value) Z_PARAM_ARRAY(array) Z_PARAM_OPTIONAL Z_PARAM_BOOL(strict) ZEND_PARSE_PARAMETERS_END();
これだけではイメージできないので、中をみていきましょう。zend_parse_parameters()はこのようになっています。
…… for (spec_walk = type_spec; *spec_walk; spec_walk++) { c = *spec_walk; switch (c) { case 'l': case 'd': case 's': case 'b': case 'r': case 'a': case 'o': case 'O': case 'z': case 'Z': case 'C': case 'h': case 'f': case 'A': case 'H': case 'p': case 'S': case 'P': case 'L': max_num_args++; break; case '|': min_num_args = max_num_args; break; case '/': case '!': /* Pass */ break; case '*': case '+': …… default: …… } } ……
以上のように、文字を1文字ずつ読んでいき、解析しながら進めています。少しずつの積み重ねでも、関数を呼ぶたびに解析していくので負荷が高まっていくでしょう。一方、マクロで書くと以下のようになります。
…… do { \ if (UNEXPECTED(_num_args < _min_num_args) || \ (UNEXPECTED(_num_args > _max_num_args) && \ EXPECTED(_max_num_args >= 0))) { \ if (!(_flags & ZEND_PARSE_PARAMS_QUIET)) { \ zend_wrong_paramers_count_error(_num_args, _min_num_args, _max_num_args); \ } \ error_code = ZPP_ERROR_FAILURE; \ break; \ } \ _i = 0; \ _real_arg = ZEND_CALL_ARG(execute_data, 0); …… if (separate) { \ Z_PARAM_PROLOGUE(separate); \ zend_parse_arg_zval_deref(_arg, &dest, check_null); \ } else { \ if (UNEXPECTED(++_i >_num_args)) break; \ _real_arg++; \ zend_parse_arg_zval(_real_arg, &dest, check_null); \ } …… if (UNEXPECTED(!zend_parse_arg_array(_arg, &dest, check_null, 0))) { \ _expected_type = Z_EXPECTED_ARRAY; \ error_code = ZPP_ERROR_WRONG_ARG; \ break; \ } _optional = 1; …… } while (0); \ if (UNEXPECTED(error_code != ZPP_ERROR_OK)) { \ if (!(_flags & ZEND_PARSE_PARAMS_QUIET)) { \ if (error_code == ZPP_ERROR_WRONG_CALLBACK) { \ zend_wrong_callback_error(E_WARNING, _i, _error); \ } else if (error_code == ZPP_ERROR_WRONG_CLASS) { \ zend_wrong_paramer_class_error(_i, _error, _arg); \ } else if (error_code == ZPP_ERROR_WRONG_ARG) { \ zend_wrong_paramer_type_error(_i, _expected_type, _arg); \ } \ } \ failure; \ } \ } while (0) ……
行末に\が付いているのは、マクロなので1行に収める必要があるからです。do while文になっていますが、条件式が0となっているのでループしません。では、なぜdo while文になっているかというと、途中でbreakして抜け出せるようにするためです。引数の型を順番に取得していっているだけなので、zend_parse_parameters()と比べるとだいぶシンプルになっています。文字列を解析していくわけではなく、決められた順番で決められた型を処理していくので、処理が早くなっているのです。
まとめ
zval、ハッシュテーブル、関数の引数の処理とみてきましたが、PHP 5とPHP 7は機構が違ってきていることが分かります。メモリを使った方がよいところはメモリを使っていますが、全体的に無駄はなくなっているように思います。個人的には、PHP 5のzvalは、すべての型に対して同じような構造を有しており、美しいと思っていたので好きでした。PHP 7では、その美しさを捨てて、型を差別化することによって最適化して速くなったと言えるでしょう。
ー
PHPはどのように動くのか ~PHPコアから読み解く仕組みと定石
単行本(ソフトカバー) – 2015/9/17
|
内容紹介
同じようなスクリプトなのに、なぜパフォーマンスが違うのか?
オブジェクト指向だと、なぜ遅いのか?
PHP7は、なぜ速くなったか?
最も人気のあるWeb用プログラム言語であるPHPの知られざる内部構造を解説した、日本初の書。「メモリを節約したり、処理を軽くしたりするスクリプトを書くには」「パフォーマンスの高いExtensionを作るには」「Zend Engineをハックするには」といった、ほかにはない話題が満載です。
内容(「BOOK」データベースより)
同じようなスクリプトなのに、なぜパフォーマンスが違うのか?オブジェクト指向だと、なぜ遅いのか?未知のおもしろさが溢れる内部構造を覗く旅へ出かけよう。
著者略歴 (「BOOK著者紹介情報」より)
蒋池/東龍
1976年生まれ。大手IT企業でPHPコアのエバンジェリストを担う。Skyscanner Japan株式会社でCTOを務める。1999年にプログラマとしてセガへ新卒入社し、ゲーム開発に従事。2004年大手IT企業へ転職。(本データはこの書籍が刊行された当時に掲載されていたものです)
-