PHPのelse if, elseif のパフォーマンス確認

2021-12-07
PHP

この記事は PHP Advent Calendar 2021 - Qiita の7日目です。

※2021/12/7 22:59追記 ブログ執筆初期と、結論が変わっております。お気をつけください。

抽象構文木 - Wikipedia

AST(抽象構文木)というものがあります。プログラミング言語においては、コンパイラやインタプリタが最終的に実行可能なコードに変換を行う前の中間表現として使われていたりします。

我らがPHPもバージョン7.0から、コンパイルの中間表現としてASTを生成するようになっています。詳しくは下記スライドを参照。


最近では、このASTをレビューに役立てたり、ソースコードに意味的な変更が行われたかどうかを検出する方法の一つとして利用されていたりします。

20 万行超のコードベースをテストせずにリファクタリングリリースした話 - MonotaRO Tech Blog

私が所属している会社でも、ASTはレビュープロセスの一つとして使われていまして、なんだか世の中進んでいるねという感じです。

発端

ある日、PHPにおける else ifelseif が異なるASTを出力していることに気が付きました。こういう細かいことって、調べてみると面白いので調べてみました。

忙しい貴方のための結論

とてつもなく、CPUバウンドな負荷にお困りで、パフォーマンスがとてつもなく大事な現場の方は、elseif を使ったら良いです。ちょっとだけパフォーマンスが良いです。

最適化後のOPCodeは両者で一致するので、パフォーマンスの変化は無いです。

Xdebugが有効な場合 -> elseif にしたほうが速い。これは、ステップ実行用のOPCodeが差し込まれるためです。本番でXdebug有効なことは無いと思うので、実際には無いケースだと思います。

サンプルソースコード

Loopで回して、偶数・奇数をチェックするような感じのわざとらしいコードです。

else if

1
2
3
4
5
6
7
8
<?php
$n = 30000000;

for($i=0; $i < $n ; $i++){
if($i%2 === 0){
}else if ($i%2 === 1){
}
}

elseif

1
2
3
4
5
6
7
8
<?php
$n = 30000000;

for($i=0; $i < $n ; $i++){
if($i%2 === 0){
}elseif ($i%2 === 1){
}
}

ASTの比較

利用するのは、PHPerおなじみのこちら。

GitHub - nikic/php-ast: Extension exposing PHP 7 abstract syntax tree

else if

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
AST_STMT_LIST
0: AST_ASSIGN
var: AST_VAR
name: "n"
expr: 30000000
1: AST_FOR
init: AST_EXPR_LIST
0: AST_ASSIGN
var: AST_VAR
name: "i"
expr: 0
cond: AST_EXPR_LIST
0: AST_BINARY_OP
flags: BINARY_IS_SMALLER (20)
left: AST_VAR
name: "i"
right: AST_VAR
name: "n"
loop: AST_EXPR_LIST
0: AST_POST_INC
var: AST_VAR
name: "i"
stmts: AST_STMT_LIST
0: AST_IF
0: AST_IF_ELEM
cond: AST_BINARY_OP
flags: BINARY_IS_IDENTICAL (16)
left: AST_BINARY_OP
flags: BINARY_MOD (5)
left: AST_VAR
name: "i"
right: 2
right: 0
stmts: AST_STMT_LIST
1: AST_IF_ELEM
cond: null
stmts: AST_STMT_LIST
0: AST_IF
0: AST_IF_ELEM
cond: AST_BINARY_OP
flags: BINARY_IS_IDENTICAL (16)
left: AST_BINARY_OP
flags: BINARY_MOD (5)
left: AST_VAR
name: "i"
right: 2
right: 1
stmts: AST_STMT_LIST

elseif

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
AST_STMT_LIST
0: AST_ASSIGN
var: AST_VAR
name: "n"
expr: 30000000
1: AST_FOR
init: AST_EXPR_LIST
0: AST_ASSIGN
var: AST_VAR
name: "i"
expr: 0
cond: AST_EXPR_LIST
0: AST_BINARY_OP
flags: BINARY_IS_SMALLER (20)
left: AST_VAR
name: "i"
right: AST_VAR
name: "n"
loop: AST_EXPR_LIST
0: AST_POST_INC
var: AST_VAR
name: "i"
stmts: AST_STMT_LIST
0: AST_IF
0: AST_IF_ELEM
cond: AST_BINARY_OP
flags: BINARY_IS_IDENTICAL (16)
left: AST_BINARY_OP
flags: BINARY_MOD (5)
left: AST_VAR
name: "i"
right: 2
right: 0
stmts: AST_STMT_LIST
1: AST_IF_ELEM
cond: AST_BINARY_OP
flags: BINARY_IS_IDENTICAL (16)
left: AST_BINARY_OP
flags: BINARY_MOD (5)
left: AST_VAR
name: "i"
right: 2
right: 1
stmts: AST_STMT_LIST

特徴的な部分のみの比較

要するに何が起きているのかというと…

else if

if〜else で文が区切られて、その後にもう一度 if が入っています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1: AST_IF_ELEM
cond: null
stmts: AST_STMT_LIST
0: AST_IF
0: AST_IF_ELEM
cond: AST_BINARY_OP
flags: BINARY_IS_IDENTICAL (16)
left: AST_BINARY_OP
flags: BINARY_MOD (5)
left: AST_VAR
name: "i"
right: 2
right: 1
stmts: AST_STMT_LIST

elseif

elseif で一つの文として比較が完了しています。なのでASTが一段浅い。

1
2
3
4
5
6
7
8
9
10
1: AST_IF_ELEM
cond: AST_BINARY_OP
flags: BINARY_IS_IDENTICAL (16)
left: AST_BINARY_OP
flags: BINARY_MOD (5)
left: AST_VAR
name: "i"
right: 2
right: 1
stmts: AST_STMT_LIST

つまり、こういうこと

if else if

else 節の後ろに、もう一つ if 文が入っています。

1
2
3
4
5
6
7
8

if($cond){

}else {
if($cond2){

}
}

if elseif

elseif 節一つで、条件分岐が終わっています。

1
2
3
4
5
if($cond){

}elseif($cond2){

}

OPCode最適化の影響チェック

ASTは、あくまで中間表現です。ということは、コンパイルで最適化が行われた後であれば、どちらの表現でも変わらないのではないか?

OPCacheを使うと、最適化前後のOPCodeを出力することが出来ます。実際に、最適化前後で出力して比較してみます。

else if

OPCode最適化前
※2021/12/7 22:59追記 Xdebugを無効化してやり直しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ php -dopcache.enable_cli=1 -dopcache.opt_debug_level=0x10000 ./code_1.php

$_main:
; (lines=14, args=0, vars=2, tmps=8)
; (before optimizer)
; /php_if_else/code_1.php:1-8
; return [] RANGE[0..0]
0000 ASSIGN CV0($n) int(30000000)
0001 ASSIGN CV1($i) int(0)
0002 JMP 0011
0003 T4 = MOD CV1($i) int(2)
0004 T5 = IS_IDENTICAL T4 int(0)
0005 JMPZ T5 0007
0006 JMP 0010
0007 T6 = MOD CV1($i) int(2)
0008 T7 = IS_IDENTICAL T6 int(1)
0009 JMPZ T7 0010
0010 PRE_INC CV1($i)
0011 T9 = IS_SMALLER CV1($i) CV0($n)
0012 JMPNZ T9 0003
0013 RETURN int(1)

OPCode最適化後

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ php -dopcache.enable_cli=1 -dopcache.opt_debug_level=0x20000 ./code_1.php

$_main:
; (lines=12, args=0, vars=2, tmps=2)
; (after optimizer)
; /php_if_else/code_1.php:1-8
0000 ASSIGN CV0($n) int(30000000)
0001 ASSIGN CV1($i) int(0)
0002 JMP 0009
0003 T3 = MOD CV1($i) int(2)
0004 T2 = IS_IDENTICAL T3 int(0)
0005 JMPNZ T2 0008
0006 T3 = MOD CV1($i) int(2)
0007 T2 = IS_IDENTICAL T3 int(1)
0008 PRE_INC CV1($i)
0009 T2 = IS_SMALLER CV1($i) CV0($n)
0010 JMPNZ T2 0003
0011 RETURN int(1)

elseif

OPCode最適化前
※2021/12/7 22:59追記 Xdebugを無効化してやり直しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ php -dopcache.enable_cli=1 -dopcache.opt_debug_level=0x10000 ./code_2.php

$_main:
; (lines=14, args=0, vars=2, tmps=8)
; (before optimizer)
; /php_if_else/code_2.php:1-8
; return [] RANGE[0..0]
0000 ASSIGN CV0($n) int(30000000)
0001 ASSIGN CV1($i) int(0)
0002 JMP 0011
0003 T4 = MOD CV1($i) int(2)
0004 T5 = IS_IDENTICAL T4 int(0)
0005 JMPZ T5 0007
0006 JMP 0010
0007 T6 = MOD CV1($i) int(2)
0008 T7 = IS_IDENTICAL T6 int(1)
0009 JMPZ T7 0010
0010 PRE_INC CV1($i)
0011 T9 = IS_SMALLER CV1($i) CV0($n)
0012 JMPNZ T9 0003
0013 RETURN int(1)

OPCode最適化後

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ php -dopcache.enable_cli=1 -dopcache.opt_debug_level=0x20000 ./code_2.php

$_main:
; (lines=12, args=0, vars=2, tmps=2)
; (after optimizer)
; /php_if_else/code_2.php:1-8
0000 ASSIGN CV0($n) int(30000000)
0001 ASSIGN CV1($i) int(0)
0002 JMP 0009
0003 T3 = MOD CV1($i) int(2)
0004 T2 = IS_IDENTICAL T3 int(0)
0005 JMPNZ T2 0008
0006 T3 = MOD CV1($i) int(2)
0007 T2 = IS_IDENTICAL T3 int(1)
0008 PRE_INC CV1($i)
0009 T2 = IS_SMALLER CV1($i) CV0($n)
0010 JMPNZ T2 0003
0011 RETURN int(1)

ようするに

OPCodeなんて、長々と見せられても良く分からんとおもいますので、かいつまんで説明します。

最適化後のOPCodeにおいて、両者の違いは EXT_STMT の数です。else ifの方が1つだけ文の数が多いということになります。意外でしたが、最適化が行われた後でも両者には違いがありました。

※2021/12/7 22:59追記 Xdebugを無効化したことで、 EXT_STMT が挿入されることがなくなり、両者は最適化レベルの差に依らず同じOPCodeを出力するようになりました。

ということで、みんな大好きマイクロベンチを取ります。

マイクロベンチ

30000000件の繰り返しによるテストです。

※2021/12/7 22:59追記 Xdebugを無効化してやり直しました。

else if

1
2
3
4
5
$ time php code_1.php

real 0m2.280s
user 0m2.217s
sys 0m0.046s

elseif

1
2
3
4
5
$ time php code_2.php

real 0m2.249s
user 0m2.205s
sys 0m0.036s

elseif の方が、10%程度性能が良いという結果です。まあ、Webアプリではこんな回数のLoopを行わないので、問題にならないでしょうが、割と性能差が出て驚きです。

※2021/12/7 22:59追記 OPCodeが同じなので、実行時間の差は誤差程度に収まるようになりました。同じだから、そりゃそうです。

まとめ

Webアプリにおいては問題にならないでしょうが、PHPを使ってCPUを酷使するようなプログラムを行っている人におかれましては、置き換え可能であれば、else if を elseif にすると良いかもしれません。

一見同じに見える、ソースコードも、深く追ってみると色々と発見があって面白いですね。

※2021/12/7 22:59追記 というわけで、ASTは異なっていても、OPCodeの時点で両者は等価に変換されるので、どっちで書いても問題なし!!!!

ただし、ローカル環境でXdebugが有効化されている状態だと、パフォーマンスに差が出ます。お気をつけください。

追加調査

EXT_STMT を差し込まれている理由について、詳細調査を追加でやりますので、そのうちブログにまとめます。