目錄

初探 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 應該會是沒其他路的最後選擇。