初探 PHP Opcodes

PHP 的運作原理是先把 PHP 原始碼編譯成 opcodes 再開始進 Zend VM 和 CPU,這部分可以參考 2020 介紹 PHP 8 的簡報,裡面第 12 頁剛好有提到 PHP 從解析到進 CPU 的流程。

在討論 PHP 效能的時候,除了直接寫 benchmark 測試以外,還有另一個方法是查編譯出來的 opcodes 為何。而本篇文章的重點會是試著把 opcodes 抓出來,做些簡單的比較。

安裝與測試

目前可以使用的工具是 vld,雖然還在 beta 版,但做簡單的比較已足夠使用了。

安裝方法:

pecl install vld-0.17.1

裝好後,準備 hello world 程式來做測試:

<?php
// /path/to/helloworld.php

echo 'hello world';

接著執行下面這個指令,即可把 opcodes 抓出來:

$ php -dvld.active=1 helloworld.php
Finding entry points
Branch analysis from position: 0
1 jumps found. (Code = 62) Position 1 = -2
filename: /path/to/helloworld.php
function name: (null)
number of ops: 3
compiled vars: none
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
3 0 E > EXT_STMT
1 ECHO 'hello+world'
4 2 > RETURN 1

branch: # 0; line: 3- 4; sop: 0; eop: 2; out0: -2
path #1: 0,
hello world

opcodes 也是程式碼,所以裡面也有語法和參數等,像 line 3 的 op ECHO 後面的 operands 接 'hello+world',這應該就很熟悉。其他語法也會有類似的處理,差別在有沒有辦法看得懂而已。

另有線上版本,如有需要跟其他朋友討論的話也可以用。

牛刀小實驗

首先來個經典的問題:if else 和三元運算寫出一樣的功能的程式,到底哪個比較快?

先來試試 if else:

<?php
// ifelse.php

$condition = true;

if ($condition) {
$result = 1;
} else {
$result = 2;
}

結果:

function name:  (null)
number of ops: 10
compiled vars: !0 = $condition, !1 = $result
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
4 0 E > EXT_STMT
1 ASSIGN !0, <true>
6 2 EXT_STMT
3 > JMPZ !0, ->7
7 4 > EXT_STMT
5 ASSIGN !1, 1
6 > JMP ->9
9 7 > EXT_STMT
8 ASSIGN !1, 2
10 9 > > RETURN 1

branch: # 0; line: 4- 6; sop: 0; eop: 3; out0: 4; out1: 7
branch: # 4; line: 7- 7; sop: 4; eop: 6; out0: 9
branch: # 7; line: 9- 10; sop: 7; eop: 8; out0: 9
branch: # 9; line: 10- 10; sop: 9; eop: 9; out0: -2; out1: -2
path #1: 0, 4, 9,
path #2: 0, 7, 9,

再來是三元運算:

<?php
// three.php

$condition = true;

$result = $condition ? 1 : 2;

結果:

function name:  (null)
number of ops: 9
compiled vars: !0 = $condition, !1 = $result
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
4 0 E > EXT_STMT
1 ASSIGN !0, <true>
6 2 EXT_STMT
3 > JMPZ !0, ->6
4 > QM_ASSIGN ~3 1
5 > JMP ->7
6 > QM_ASSIGN ~3 2
7 > ASSIGN !1, ~3
7 8 > RETURN 1

branch: # 0; line: 4- 6; sop: 0; eop: 3; out0: 4; out1: 6
branch: # 4; line: 6- 6; sop: 4; eop: 5; out0: 7
branch: # 6; line: 6- 6; sop: 6; eop: 6; out0: 7
branch: # 7; line: 6- 7; sop: 7; eop: 8; out0: -2; out1: -2
path #1: 0, 4, 7,
path #2: 0, 6, 7,

這裡有幾個 opcode 先做解釋:

opcode 功能
EXT_STMT 官網沒有特別說明,推測是一個 statement 開始的標記
JMP 無條件 jump,很像 goto
JMPZ 當參數等於 0(zero)的時候,jump
ASSIGN 賦值,就像平常使用 $a = 1 一樣
QM_ASSIGN 全名叫 Question Mark Assign,指的應該就是三元運算子

這兩個 opcodes 主要差別從 #4 開始,主要是 if else 會在兩個不同的條件各執行一次 EXT_STMTASSIGN,而三元運算則是各執行一次 QM_ASSIGN 後,再用一次 ASSIGN 賦值。

單就 opcode 數量來看,if else 看起來多一個,應該會比較慢。實測結果如下:

+-------------+-------------------------------------+-----------+---------+---------+---------+--------+---------+
| benchmark | subject | memory | min | max | mode | rstdev | stdev |
+-------------+-------------------------------------+-----------+---------+---------+---------+--------+---------+
| IfElseBench | benchTrue () | 974.816kb | 0.395μs | 0.453μs | 0.414μs | ±4.83% | 0.020μs |
| IfElseBench | benchQuestionMarkAssignWhenTrue () | 974.848kb | 0.390μs | 0.438μs | 0.397μs | ±4.47% | 0.018μs |
| IfElseBench | benchFalse () | 974.816kb | 0.384μs | 0.420μs | 0.392μs | ±3.42% | 0.014μs |
| IfElseBench | benchQuestionMarkAssignWhenFalse () | 974.864kb | 0.379μs | 0.423μs | 0.399μs | ±3.52% | 0.014μs |
+-------------+-------------------------------------+-----------+---------+---------+---------+--------+---------+

測試程式: https://github.com/MilesChou/php-notice/blob/master/benchmarks/IfElseBench.php

就結果來看,感覺沒有太大差別。以上面的結果推測,EXT_STMT 應該沒有太大的損耗,另外是 ASSIGN 和 QM_ASSIGN 使用的資源也有所不同。QM_ASSIGN 可能有用到暫存資源,而 ASSIGN 則是使用 CPU 資源。

認真來做一次

!isset($v) 語法結構、is_null($v) function、$v === null 比較,總共有三種判斷變數是否為 null 的方法,究竟效能誰快誰慢呢?

先把上面三個判斷全寫在同個檔裡,一次解決:

<?php

$v = null;

$result = !isset($v);
$result = is_null($v);
$result = $v === null;

產生的 opcodes:

function name:  (null)
number of ops: 13
compiled vars: !0 = $v, !1 = $result
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
3 0 E > EXT_STMT
1 ASSIGN !0, null
5 2 EXT_STMT
3 ISSET_ISEMPTY_CV ~3 !0
4 BOOL_NOT ~4 ~3
5 ASSIGN !1, ~4
6 6 EXT_STMT
7 TYPE_CHECK 2 ~6 !0
8 ASSIGN !1, ~6
7 9 EXT_STMT
10 TYPE_CHECK 2 ~8 !0
11 ASSIGN !1, ~8
12 > RETURN 1

有兩個新的 opcode ISSET_ISEMPTY_CVTYPE_CHECK,但因為無法找到官網的介紹,所以就跳過這部分說明。

這裡會發現 is_null($v)$v === null 產出來的 opcode 一模一樣,可以推測 compiler 有對部分的 function 做最佳化。另外 isset($v) 則是使用不一樣的 opcode ISSET_ISEMPTY_CV 處理。就行為來看,它多處理了變數不存在的例外,而且又多了 NOT 運算,理論上應該會比較慢。實測的結果如下:

+------------------+-----------------------+-----------+---------+---------+---------+---------+---------+
| benchmark | subject | memory | min | max | mode | rstdev | stdev |
+------------------+-----------------------+-----------+---------+---------+---------+---------+---------+
| IssetIsNullBench | benchIsset () | 966.360kb | 0.035μs | 0.045μs | 0.036μs | ±10.54% | 0.004μs |
| IssetIsNullBench | benchIsNull () | 966.360kb | 0.033μs | 0.035μs | 0.033μs | ±2.11% | 0.001μs |
| IssetIsNullBench | benchConditionNull () | 966.376kb | 0.032μs | 0.035μs | 0.034μs | ±2.61% | 0.001μs |
+------------------+-----------------------+-----------+---------+---------+---------+---------+---------+

測試程式: https://github.com/MilesChou/php-notice/blob/master/benchmarks/IssetIsNullBench.php

有試過多測幾次,但都差不多,因此這三個方法在追求效能的前提下,是可以互相取代使用的。

但使用 is_null() 要小心一個問題是,當有 namespace 的時候,它會多一層 function call,這時就會變慢。

<?php

namespace Miles;

$v = null;

$result = is_null($v);
$result = $v === null;

Opcode 如下:

function name:  (null)
number of ops: 12
compiled vars: !0 = $v, !1 = $result
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
3 0 E > NOP
5 1 EXT_STMT
2 ASSIGN !0, null
7 3 EXT_STMT
4 INIT_NS_FCALL_BY_NAME 'Miles%5Cis_null'
5 SEND_VAR_EX !0
6 DO_FCALL 0 $3
7 ASSIGN !1, $3
8 8 EXT_STMT
9 TYPE_CHECK 2 ~5 !0
10 ASSIGN !1, ~5
11 > RETURN 1

branch: # 0; line: 3- 8; sop: 0; eop: 11; out0: -2
path #1: 0,

這時候的效能就會降低約 25% 以上:

25% 的計算方法: (0.045 - 0.035) / 0.035 = 28.6%

+------------------+-----------------------+-----------+---------+---------+---------+--------+---------+
| benchmark | subject | memory | min | max | mode | rstdev | stdev |
+------------------+-----------------------+-----------+---------+---------+---------+--------+---------+
| IssetIsNullBench | benchIsset () | 966.360kb | 0.035μs | 0.039μs | 0.035μs | ±4.40% | 0.002μs |
| IssetIsNullBench | benchIsNull () | 966.360kb | 0.044μs | 0.047μs | 0.045μs | ±2.43% | 0.001μs |
| IssetIsNullBench | benchConditionNull () | 966.376kb | 0.033μs | 0.033μs | 0.033μs | ±0.32% | 0.000μs |
+------------------+-----------------------+-----------+---------+---------+---------+--------+---------+

解決方法是把 use function is_null; 加上去,讓 compiler 知道這個 function 是要用 global 最佳化過的 is_null()

<?php

namespace Miles;

use function is_null;

$v = null;

$result = is_null($v);
$result = $v === null;

Opcodes:

function name:  (null)
number of ops: 6
compiled vars: !0 = $v, !1 = $result
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
7 0 E > ASSIGN !0, null
9 1 TYPE_CHECK 2 ~3 !0
2 ASSIGN !1, ~3
10 3 TYPE_CHECK 2 ~5 !0
4 ASSIGN !1, ~5
5 > RETURN 1

branch: # 0; line: 7- 10; sop: 0; eop: 5; out0: -2
path #1: 0,

若是極度追求效能考量的話,要確認變數是否為 null 的話,建議使用 === null 來判斷,因為 use function is_null; 不一定會記得加。(像一開始在寫 isset() 與 empty() 的差異 就忘了加…)

而判斷變數是否存在的場景才改用 isset(),這樣語言是正向表述的,而且能少一個 NOT 運算。

結論

以上面 is_null() 的例子來說,看 opcode 確實是有機會改善效能的,但成本實在是太高了,很多 opcode 都不大確定它的用途或效能,因此還是建議從做 unit test 以及 benchmark 為主就好了,opcode 應該會是沒其他路的最後選擇。