hnwの日記

PHP 7のforeachを&つきで回すと配列の消費メモリが倍増する話

2015年12月にPHP 7.0.0がリリースされてから2年と少し経ってしまいました。今月頭には無事PHP 7.2.0がリリースされたわけですが、皆様の参加プロジェクトのPHP 7導入は進んでいるでしょうか?まだPHP 5系なんだよね、という方もPHP 7が高速だという噂くらいは聞いているかと思います。


さて、そのPHP 7.0では内部のデータ構造を大幅に変更し、PHP 5系に対する後方互換性を確保しつつ大きな性能改善を果たしたわけですが、PHP 7で相対的に不利になった機能があるのをご存じでしょうか?答えは参照です。参照を使うと通常の変数よりメモリを消費しますし、読み書きも若干遅くなります。特にforeachを&つきで回すような場合にその影響は顕著になります。


この話題は一部の人には当然の内容かと思いますが、あまり知られていないように感じたので、本稿で詳細を紹介します。

foreachの文法おさらい

まずはPHPの文法をおさらいしておきましょう。PHPのforeach文は配列をループさせる記法で、for文よりシンプルかつ高速なのでよく使われる構文です。

<?php
$sum = 0;
$arr = range(1,100);
foreach ($arr as $v) {
    $sum += $v;
}
echo $sum; // 5050


このように、foreachでは配列中の要素をループ中で取り出して利用することができます。それだけでなく、&つきで参照として取り出すことで、ループ変数を使って配列値を書き換えることもできます。

<?php
$sum = 0;
$arr = range(1,100);
foreach ($arr as &$v) {
    $v *= 2;
}
echo array_reduce($arr, function ($sum, $v) { return $sum+$v; }); // 10100


foreachを&つきで回すのは人によって好き嫌いがあるとは思いますが、なにげなく使っている人も多い機能ではないでしょうか。

foreachループ前後のメモリ使用量を観察してみる

さて、foreachループを回しただけでメモリ消費量が増えるなんてことが起こるのかどうか確認してみましょう。まずは&なしのforeachループで確認します。利用したPHPのバージョンは7.2.0です。

<?php
$array_size = 1024*1024;
$array = range(1, $array_size);
$sum = 0;
$start_memory = memory_get_usage(true);
$start_time = microtime(true);
foreach ($array as $v) {
    $sum += $v;
}
$end_time = microtime(true);
$end_memory = memory_get_usage(true);
printf("array_size = %d, %f sec, %+d bytes\n",
       $array_size, $end_time-$start_time, $end_memory-$start_memory);


上記コードでは要素数1Mの配列を生成し、foreachでループさせたときの実行時間を測定し、さらにループ開始前と終了後とのメモリ消費量の比較を行っています。これを実行すると次のような結果を得ます。

array_size = 1048576, 0.022955 sec, +0 bytes


foreach前後でメモリ消費量は変わっていません。まあ当然ですよね。では、上のプログラムのforeach文の$v&$vと書き換えて実行してみましょう。すると次のような結果になります。

array_size = 1048576, 0.052640 sec, +25165824 bytes


なんということでしょう!foreachを&つきでループするとメモリ消費量が25MBほど増えてしまいました。配列1要素あたりピッタリ24バイトの増加ということになります。PHP 7の配列は1要素あたり約32バイトを消費しますので、+75%程度のメモリ量の増加になっています*1。また、実行時間も倍以上になっています。


念のため補足しておくと、このループでは配列値を読み出しているだけで、特に配列操作をしているわけではありません。配列値を参照で受けとっただけでメモリ消費量が変わるのです。

zvalと参照の関係

上で見た不思議な現象を説明するにはPHPの内部構造を知る必要があります。PHP 7の変数の実体はCソースコードレベルではzvalと呼ばれる16bytesの構造体になります。このzvalのうち先頭8バイトが変数の値に対応します。また、残りの8バイトに型の情報やその他の情報が格納されます。たとえば$a = 123;とした場合はメモリ上で次のように格納されます。



この変数の値を別の変数に代入する場合は単にzvalの16バイトをコピーします。$b = $a;であれば次のようになります。



一方、$b = &$a;のように参照として代入する場合はzend_referenceという24バイトのデータ構造が作られ、参照を共有する変数はzend_referenceへのポインタを持つようになります。



つまり、参照として代入すると元の$aの構造ごと変わってしまうのです。foreachを&つきでループしたときに1要素あたり24バイト増加した理由も同じで、配列の全要素を$vに参照で渡すときにzend_referenceが作られたためです。読み出しているだけなのにメモリ使用量が増えるのは不思議に思えるかもしれませんが、参照の性質上仕方のないことです*2

補足:参照は常に悪いのか

上記の内容だけを見て既存ソースコードの参照を全部取り除こう、と考えた人がいるかもしれませんが、それは早計かもしれません。


本稿で議論しているのは1変数あたり24バイトの増加です。そんな程度の差を気にするくらいなら、コードの可読性を上げたり、テストカバレッジを上げたり、新機能を実装したりする方が有意義かもしれません。巨大な配列の場合には一定の影響を及ぼす可能性はありますが、それでも処理全体のパフォーマンスに与える影響はさほど大きくないように思います。


そもそもPHP 7の参照は「相対的に」不利になっただけであって、PHP 5と比べればなお互角または若干高速なくらいです。メモリ消費量の観点でも、全要素が参照になっているPHP 7の配列の方がPHP 5の通常の配列より断然コンパクトだったりします。性能の観点で見直すというのであれば、実コードでベンチマークテストを行ってからにすべきでしょう。


また、性能以外の理由で参照の善し悪しを整理する観点もあるかもしれません。これは本稿の範疇外ですが、下記ページなどはうまく論点がまとめられているように思います。

まとめ

PHP 7から参照で変数代入するとメモリ消費量が増えること、foreach文で&を使うとその影響が全要素に及ぶことを紹介しました。この結果だけをもって参照の善し悪しを議論するのは早計だと思いますが、こうした部分を入り口にPHPの内部構造に興味を持つのは良いことだと思います。


また、PHP 5の頃は配列の全要素の値を書き換えるような場合に&つきのforeachを使うのが最速でしたが、PHP 7では新たな配列を作るのとほとんど差がなくなりました。速度が理由で&をつけている人がもしいれば、PHP 7でのベンチマークテストをオススメします。


本稿を書き終わるあたりで似た内容を「PHP7はなぜ速いのか(zval編)」で書いていることに気づきましたが、少し切り口が違うので良いことにします。

*1:本稿タイトルで倍増と言っているのは25%ほど誇張です

*2:例示したコードを救うためだけの最適化は不可能ではありませんが、実装するメリットは薄そうです