PHP 的運作原理是先把 PHP 原始碼編譯成 opcodes 再開始進 Zend VM 和 CPU,這部分可以參考 2020 介紹 PHP 8 的簡報,裡面第 12 頁剛好有提到 PHP 從解析到進 CPU 的流程。
在討論 PHP 效能的時候,除了直接寫 benchmark 測試以外,還有另一個方法是查編譯出來的 opcodes 為何。而本篇文章的重點會是試著把 opcodes 抓出來,做些簡單的比較。
安裝與測試
目前可以使用的工具是 vld,雖然還在 beta 版,但做簡單的比較已足夠使用了。
安裝方法:
裝好後,準備 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
$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
$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_STMT
和 ASSIGN
,而三元運算則是各執行一次 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_CV
和 TYPE_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 應該會是沒其他路的最後選擇。