JIT 中的類型導向優化

2022 年 4 月 26 日 · 作者:Björn Gustavsson

這篇文章探討了 Erlang/OTP 25 中新的類型導向優化,其中編譯器將類型資訊嵌入 BEAM 檔案中,以協助 JIT (即時編譯器) 產生更好的程式碼。

兩全其美 #

在 OTP 22 中引入的 基於 SSA 的編譯器傳遞執行了複雜的類型分析,這允許更多的優化和更好的程式碼生成。然而,Erlang 編譯器可以進行的優化類型有限制,因為 BEAM 檔案必須能夠在任何運行於 32 位元或 64 位元電腦上的 BEAM 機器上載入。因此,編譯器不能進行依賴於機器字中整數大小或 Erlang 項表示方式的優化。

JIT(在 OTP 24 中引入)知道它正在 64 位元電腦上運行,並且知道 Erlang 項的表示方式。JIT 在它可以進行的優化程度上仍然受到限制,因為它一次翻譯單個 BEAM 指令。例如,+ 運算子可以將浮點數或任何大小的整數或其任意組合相加。先前執行的 BEAM 指令可能已明確指出運算元只能是小整數,但 JIT 並不知道這一點,因為它一次只查看一個指令,因此它必須發出處理所有可能運算元的原生程式碼。

在 OTP 25 中,編譯器已更新為將類型資訊嵌入 BEAM 檔案中,並且 JIT 已擴展為根據該類型資訊發出更好的程式碼。

嵌入的類型資訊已版本化,以便我們可以在每個 OTP 版本中繼續改進基於類型的優化。載入器將忽略它不識別的版本,以便模組仍然可以在沒有基於類型的優化的情況下載入。

對 OTP 25 中 JIT 的期望 #

OTP 25 僅是類型導向優化的開始。我們希望在 OTP 26 中改進編譯器的類型資訊和 JIT 中的優化。

JIT 發出的原生程式碼好多少取決於模組中程式碼的性質。

最常應用的優化是簡化的測試。例如,對元組的測試通常可以從 5 個指令減少到 3 個指令,而對小整數運算元的測試通常可以從 5 個指令減少到 4 個指令。

較少應用但更重要的是,當已知整數為「小」(適合 60 位元)時可以進行簡化。例如,如果已知運算元是小整數,則 guard 中使用的關係運算子(例如 <)可以從 11 個指令減少到 4 個指令。這種優化最常應用於使用二進制模式匹配的模組中,因為從二進制中匹配出的整數具有明確定義的範圍。

在 Erlang/OTP 程式碼庫中,第一種優化(減少一兩個指令)的應用頻率大約是第二種的十倍。

我們將在本部落格文章的稍後部分看到,第二種優化應用於 base64 模組時產生了顯著的速度提升。

類型測試的簡化 #

讓我們直接深入探討一些範例。

考慮這個模組

-module(example).
-export([tuple_matching/1]).

tuple_matching(X) ->
    case increment(X) of
        {ok,Result} -> Result;
        error -> X
    end.

increment(X) when is_integer(X) -> {ok,X+1};
increment(_) -> error.

OTP 24 中編譯器發出的 tuple_matching/1 函數的 BEAM 程式碼是(經過一些簡化)

    {allocate,1,1}.
    {move,{x,0},{y,0}}.
    {call,1,{f,5}}.
    {test,is_tuple,{f,3},[{x,0}]}.
    {get_tuple_element,{x,0},1,{x,0}}.
    {deallocate,1}.
    return.
  {label,3}.
    {move,{y,0},{x,0}}.
    {deallocate,1}.
    return.

編譯器已發現 increment/1 會回傳原子 error 或第一個元素為 ok 的二元組。因此,為了區分這兩個可能的回傳值,一個指令就足夠了

    {test,is_tuple,{f,3},[{x,0}]}.

沒有必要明確測試值 error,因為如果它不是元組,則**必須**是 error。同樣,沒有必要測試元組的第一個元素是否為 ok,因為它必須是。

在 OTP 24 中,JIT 將該指令轉換為 x86_64 的 5 個原生指令序列

# i_is_tuple_fs
    mov rsi, qword ptr [rbx]
    rex test sil, 1
    jne L2
    test byte ptr [rsi-2], 63
    jne L2

(以 # 開頭的行是註解。)

mov 指令將 BEAM 暫存器 {x,0} 的值提取到 CPU 暫存器 rsi。接下來的兩個指令測試該項是否是指向堆上物件的指標。如果是,則會測試堆物件的標頭字,以確保它是元組。需要第二個測試,因為堆物件可能是其他 Erlang 項,例如二進制、map 或不適合機器字的整數。

現在讓我們看看 OTP 25 中的編譯器和 JIT 如何處理這個指令。BEAM 程式碼現在是

    {test,is_tuple,
          {f,3},
          [{tr,{x,0},
               {t_union,{t_atom,[error]},
                        none,none,
                        [{{2,{t_atom,[ok]}},
                          {t_tuple,2,true,
                                   #{1 => {t_atom,[ok]},
                                     2 => {t_integer,any}}}}],
                        none}}]}.

在 OTP 24 中為 {x,0} 的運算元現在是一個元組

{tr,Register,Type}

也就是說,它是一個三元組,其中 tr 為第一個元素。tr 代表**類型化暫存器**。第二個元素是 BEAM 暫存器(在本例中為 {x,0}),第三個元素是編譯器內部類型表示中暫存器的類型。此類型等效於以下類型規範

'error' | {'ok', integer()}

JIT 無法利用這些類型中的詳細程度,因此編譯器將該類型的 簡化版本 嵌入到 BEAM 檔案中。嵌入的類型等效於

atom() | tuple()

通過了解 {x,0} 必須是原子或元組,OTP 25 中的 JIT 會發出以下簡化的原生程式碼

# i_is_tuple_fs
    mov rsi, qword ptr [rbx]
# simplified tuple test since the source is always a tuple when boxed
    rex test sil, 1
    jne label_3

(當類型資訊使簡化成為可能時,JIT 通常會發出註解。)

現在只需要第一個測試,因為根據類型資訊,如果該項是指向堆物件的指標,則它**必須**是元組。

關係運算子的簡化 #

作為另一個範例,讓我們看看 guard 中的關係運算子是如何轉換的。考慮這個函數

my_less_than(A, B) ->
    if
        A < B -> smaller;
        true -> larger_or_equal
    end.

BEAM 程式碼如下所示

    {test,is_lt,{f,9},[{x,0},{x,1}]}.
    {move,{atom,smaller},{x,0}}.
    return.
  {label,9}.
    {move,{atom,larger_or_equal},{x,0}}.
    return.

當關係運算子用作 guard 測試時,編譯器會將它們重寫為特殊指令。因此,< 運算子會被重寫為 is_lt 指令。

< 運算子可以比較任何 Erlang 項。JIT 發出處理所有類型項的程式碼是不切實際的。因此,JIT 發出直接處理最常見情況的程式碼,並呼叫通用常式來處理其他所有情況

# is_lt_fss
    mov rsi, qword ptr [rbx+8]
    mov rdi, qword ptr [rbx]
    mov eax, edi
    and eax, esi
    and al, 15
    cmp al, 15
    short jne L39
    cmp rdi, rsi
    short jmp L40
L39:
    call 5447639136
L40:
    jge label_9

讓我們逐步了解一下程式碼。前兩個指令

    mov rsi, qword ptr [rbx+8]
    mov rdi, qword ptr [rbx]

將 BEAM 暫存器 {x,1}{x,0} 提取到 CPU 暫存器中。

最常見的比較是在兩個整數之間。根據大小,整數可以用兩種不同的方式表示。在 64 位元電腦上,適合 60 位元的帶符號整數將直接儲存在 64 位元字中。字中的其餘 4 位元用於 標記,對於小整數,標記為 15。如果整數不適合,它將表示為**大數**,它是指向堆上物件的指標。

以下是測試兩個運算元是否為小數的原生程式碼

    mov eax, edi
    and eax, esi
    and al, 15
    cmp al, 15
    short jne L39

如果一個或兩個運算元具有其他標記而非 15(不是小整數),則控制權會轉移到標籤 L39 處的程式碼,該程式碼會處理所有其他類型的項。

接下來的幾行程式碼會比較小整數。程式碼以稍微迂迴的方式編寫,以便將控制權轉移到失敗標籤的條件跳轉 (jge label_9) 可以與通用程式碼共用

    cmp rdi, rsi
    short jmp L40
L39:
    call 5447639136
L40:
    jge label_9

因此,在沒有類型資訊的情況下,需要 11 個指令來實作 is_lt

現在讓我們看看當類型可用時會發生什麼

my_less_than(A, B) when is_integer(A), is_integer(B) ->
    .
    .
    .

當由 OTP 25 中的編譯器編譯時,BEAM 程式碼為

    {test,is_integer,{f,7},[{x,0}]}.
    {test,is_integer,{f,7},[{x,1}]}.
    {test,is_lt,{f,9},[{tr,{x,0},{t_integer,any}},{tr,{x,1},{t_integer,any}}]}.
    {move,{atom,smaller},{x,0}}.
    return.
  {label,9}.
    {move,{atom,larger_or_equal},{x,0}}.
    return.

現在 is_lt 指令的運算元具有類型。BEAM 暫存器 {x,0}{x,1} 的類型為 {t_integer,any},這表示具有未知範圍的整數。

了解了這些類型,JIT 可以為小整數發出稍微短一點的測試

# simplified small test since all other types are boxed
    mov eax, edi
    and eax, esi
    test al, 1
    short je L39

為了做得更好,JIT 需要更好的類型資訊。例如

map_size_less_than(Map1, Map2) ->
    if
        map_size(Map1) < map_size(Map2) -> smaller;
        true -> larger_or_equal
    end.

BEAM 程式碼如下所示

    {gc_bif,map_size,{f,12},2,[{x,0}],{x,0}}.
    {gc_bif,map_size,{f,12},2,[{x,1}],{x,1}}.
    {test,is_lt,
          {f,12},
          [{tr,{x,0},{t_integer,{0,288230376151711743}}},
           {tr,{x,1},{t_integer,{0,288230376151711743}}}]}.
    {move,{atom,smaller},{x,0}}.
    return.
  {label,12}.
    {move,{atom,larger_or_equal},{x,0}}.
    return.

is_lt 的兩個運算元的類型現在都為 {t_integer,{0,288230376151711743}},表示範圍在 0 到 288230376151711743 的整數(也就是說,(1 bsl 58) - 1)。map 中元素數的數量沒有明確的文件上限,但在可預見的未來,map 中元素的數量不會超過甚至接近 288230376151711743。

由於 {x,0}{x,1} 的下限和上限都適合 60 位元,因此沒有必要測試運算元的類型

# is_lt_fss
    mov rsi, qword ptr [rbx+8]
    mov rdi, qword ptr [rbx]
# skipped test for small operands since they are always small
    cmp rdi, rsi
L42:
L43:
    jge label_12

由於運算元始終很小,因此已省略了對通用常式的呼叫(在標籤 L42 之後)。

加法的簡化 #

查看算術指令,我們將看到 JIT 進行良好簡化的潛力,但不幸的是,我們也會看到 OTP 25 中 Erlang 編譯器所做類型分析的限制。

讓我們看看為此函數產生的程式碼

add1(X, Y) ->
    X + Y.

BEAM 程式碼如下所示

    {gc_bif,'+',{f,0},2,[{x,0},{x,1}],{x,0}}.
    return.

JIT 將 + 指令轉換為以下原生指令

# i_plus_ssjd
    mov rsi, qword ptr [rbx]
    mov rdx, qword ptr [rbx+8]
# are both operands small?
    mov eax, esi
    and eax, edx
    and al, 15
    cmp al, 15
    short jne L15
# add with overflow check
    mov rax, rsi
    mov rcx, rdx
    and rcx, -16
    add rax, rcx
    short jno L14
L15:
    call 4328985696
L14:
    mov qword ptr [rbx], rax

前兩個指令

    mov rsi, qword ptr [rbx]
    mov rdx, qword ptr [rbx+8]

+ 運算 BEAM 暫存器的運算元載入到 CPU 暫存器中。

接下來的 5 個指令測試小運算元

# are both operands small?
    mov eax, esi
    and eax, edx
    and al, 15
    cmp al, 15
    short jne L15

程式碼幾乎與我們先前檢查的 is_lt 指令中的程式碼相同。唯一的區別是使用了其他 CPU 暫存器。如果一個或兩個運算元不是小整數,則會跳到標籤 L15,如下所示

L15:
    call 4328985696

此程式碼會呼叫通用常式,該常式可以加上小數、大數或浮點數的任意組合。通用常式還會處理非數字運算元,方法是引發 badarith 例外。

如果兩個運算元確實是小數,則以下程式碼會將它們相加並檢查是否溢位

# add with overflow check
    mov rax, rsi
    mov rcx, rdx
    and rcx, -16
    add rax, rcx
    short jno L14

如果加法溢位,則會呼叫通用加法常式。否則,控制權會轉移到以下指令

    mov qword ptr [rbx], rax

將結果儲存在 {x,0} 中。

總而言之,加法本身(包括處理 標記)需要 4 個指令。但是,還需要另外 10 個指令才能

  • 從 BEAM 暫存器提取運算元。
  • 檢查運算元是否為小整數(最多 60 位元)。
  • 呼叫通用加法常式。
  • 將結果儲存到 BEAM 暫存器。

現在讓我們看看如果引入類型會發生什麼。

考慮

add2(X0, Y0) ->
    X = 2 * X0,
    Y = 2 * Y0,
    X + Y.

BEAM 程式碼如下所示

    {gc_bif,'*',{f,0},2,[{x,0},{integer,2}],{x,0}}.
    {gc_bif,'*',{f,0},2,[{x,1},{integer,2}],{x,1}}.
    {gc_bif,'+',{f,0},2,[{tr,{x,0},number},{tr,{x,1},number}],{x,0}}.
    return.

類型從算術指令傳播到其他算術指令。由於 * 的結果(如果成功)是一個數字(整數或浮點數),因此 + 指令的運算元現在具有 number 類型。

根據我們向 < 運算子新增類型的經驗,我們可能會猜到我們只會在類型測試中節省一個指令。我們會是對的

# simplified test for small operands since both are numbers
    mov eax, esi
    and eax, edx
    test al, 1
    short je L22

回到加法且沒有乘法的簡單範例,讓我們新增一個 guard 以確保 XY 是整數

add3(X, Y) when is_integer(X), is_integer(Y) ->
    X + Y.

這會產生以下 BEAM 程式碼

    {test,is_integer,{f,5},[{x,0}]}.
    {test,is_integer,{f,5},[{x,1}]}.
    {gc_bif,'+',
            {f,0},
            2,
            [{tr,{x,0},{t_integer,any}},{tr,{x,1},{t_integer,any}}],
            {x,0}}.
    return.

兩個運算元的類型現在都是 {t_integer,any}。但是,這仍然會產生相同的簡化四指令序列來測試小整數,因為整數可能不適合 60 位元。

顯然,根據我們使用 is_lt 的經驗,我們需要為 XY 建立一個範圍。一個合理的方法是

add4(X, Y) when is_integer(X), 0 =< X, X < 16#400,
                is_integer(Y), 0 =< Y, Y < 16#400 ->
    X + Y.

然而,由於編譯器的數值範圍分析存在限制,+ 運算子的類型將不會改善

    {test,is_integer,{f,19},[{x,0}]}.
    {test,is_ge,{f,19},[{tr,{x,0},{t_integer,any}},{integer,0}]}.
    {test,is_lt,{f,19},[{tr,{x,0},{t_integer,any}},{integer,1024}]}.
    {test,is_integer,{f,19},[{x,1}]}.
    {test,is_ge,{f,19},[{tr,{x,1},{t_integer,any}},{integer,0}]}.
    {test,is_lt,{f,19},[{tr,{x,1},{t_integer,any}},{integer,1024}]}.
    {gc_bif,'+',
            {f,0},
            2,
            [{tr,{x,0},{t_integer,any}},{tr,{x,1},{t_integer,any}}],
            {x,0}}.
    return.

更糟的是,前 6 個指令無法被 JIT 簡化,因為沒有足夠的類型資訊。也就是說,is_ltis_ge 指令將各自包含 11 個指令。

我們的目標是在 OTP 26 中改進類型分析和最佳化,並為此範例產生更好的程式碼。我們也考慮在 OTP 26 中新增一個新的 guard BIF,用於測試一個 term 是否在給定的整數範圍內。

同時,在我們等待 OTP 26 的同時,在 OTP 25 中有一種方法可以編寫一個等效的 guard,它將產生更有效率的程式碼,並且XY 建立已知的範圍

add5(X, Y) when X =:= X band 16#3FF,
                Y =:= Y band 16#3FF ->
    X + Y.

我們展示這種編寫 guard 的方式僅供說明之用;我們不建議以這種方式重寫你的 guard。

如果 band 運算子的兩個運算元都不是整數,則會失敗,因此不需要 is_integer/1 測試。如果相應的變數超出 016#3FF 的範圍,=:= 比較將返回 false

這將產生以下 BEAM 程式碼,其中編譯器現在已經能夠找出 + 運算子的運算元的可能範圍

    {gc_bif,'band',{f,21},2,[{x,0},{integer,1023}],{x,2}}.
    {test,is_eq_exact,
          {f,21},
          [{tr,{x,0},{t_integer,any}},{tr,{x,2},{t_integer,{0,1023}}}]}.
    {gc_bif,'band',{f,21},2,[{x,1},{integer,1023}],{x,2}}.
    {test,is_eq_exact,
          {f,21},
          [{tr,{x,1},{t_integer,any}},{tr,{x,2},{t_integer,{0,1023}}}]}.
    {gc_bif,'+',
            {f,0},
            2,
            [{tr,{x,0},{t_integer,{0,1023}}},{tr,{x,1},{t_integer,{0,1023}}}],
            {x,0}}.
    return.

此外,+ 指令之前的 4 個指令現在相對有效率。

band 指令需要測試運算元,並準備處理不適合 60 位元的整數

# i_band_ssjd
    mov rsi, qword ptr [rbx]
    mov eax, 16383
# is the operand small?
    mov edi, esi
    and edi, 15
    cmp edi, 15
    short jne L97
    and rax, rsi
    short jmp L98
L97:
    call 4456532680
    short je label_25
L98:
    mov qword ptr [rbx+16], rax

is_eq_exact 指令受益於執行 band 指令得出的類型資訊。由於已知右側運算元是一個適合機器字的短整數,因此只需進行簡單的比較,而無需使用回退程式碼來處理其他 Erlang term

# is_eq_exact_fss
# simplified check since one argument is an immediate
    mov rdi, qword ptr [rbx+16]
    cmp qword ptr [rbx], rdi
    short jne label_25

JIT 為 + 運算子產生以下程式碼

# i_plus_ssjd
# add without overflow check
    mov rax, qword ptr [rbx]
    mov rsi, qword ptr [rbx+8]
    and rax, -16
    add rax, rsi
    mov qword ptr [rbx], rax

base64 的簡化 #

據我們所知,base64 是 OTP 中從 OTP 25 的改進中受益最多的模組。

以下是在 Github issue 中包含的基準測試結果。首先是在我的電腦上 OTP 24 的結果

== Testing with 1 MB ==
fun base64:encode/1: 1000 iterations in 19805 ms: 50 it/sec
fun base64:decode/1: 1000 iterations in 20075 ms: 49 it/sec

在同一台電腦上 OTP 25 的結果

== Testing with 1 MB ==
fun base64:encode/1: 1000 iterations in 16024 ms: 62 it/sec
fun base64:decode/1: 1000 iterations in 18306 ms: 54 it/sec

在 OTP 25 中,編碼所花費的時間是 OTP 24 所需時間的 80%。解碼速度也快了一秒以上。

base64 模組在 OTP 25 中沒有修改,因此這些改進完全歸功於編譯器和 JIT 的改進。

這是 base64 模組中 encode_binary/2 的子句,它完成了將二進位編碼為 Base64 的大部分工作

encode_binary(<<B1:8, B2:8, B3:8, Ls/bits>>, A) ->
    BB = (B1 bsl 16) bor (B2 bsl 8) bor B3,
    encode_binary(Ls,
                  <<A/bits,(b64e(BB bsr 18)):8,
                    (b64e((BB bsr 12) band 63)):8,
                    (b64e((BB bsr 6) band 63)):8,
                    (b64e(BB band 63)):8>>).

函數頭中的二進位匹配為變數 B1B2B3 建立了範圍。(所有三個變數的類型都將是 {t_integer,{0,255}}。)

由於這些範圍,接下來的所有 bslbsrbandbor 運算都不需要任何類型檢查。此外,在建立二進位時,由於已知所有值都是短整數,因此無需測試二進位建立是否成功。

b64e/1 函數的 4 個呼叫已內聯。該函數如下所示

-compile({inline, [{b64e, 1}]}).
b64e(X) ->
    element(X+1,
	    {$A, $B, $C, $D, $E, $F, $G, $H, $I, $J, $K, $L, $M, $N,
	     $O, $P, $Q, $R, $S, $T, $U, $V, $W, $X, $Y, $Z,
	     $a, $b, $c, $d, $e, $f, $g, $h, $i, $j, $k, $l, $m, $n,
	     $o, $p, $q, $r, $s, $t, $u, $v, $w, $x, $y, $z,
	     $0, $1, $2, $3, $4, $5, $6, $7, $8, $9, $+, $/}).

在 OTP 25 中,JIT 將最佳化對 element/2 的呼叫,其中位置引數是一個整數,而 tuple 引數是一個字面 tuple。對於 element/2be64e/1 中的使用方式,所有類型測試和範圍檢查都將被移除

# bif_element_jssd
# skipped tuple test since source is always a literal tuple
L302:
    long mov rsi, 9223372036854775807
    mov rdi, qword ptr [rbx+24]
    lea rcx, qword ptr [rsi-2]
# skipped test for small position since it is always small
    mov rax, rdi
    sar rax, 4
# skipped check for position =:= 0 since it is always >= 1
# skipped check for negative position and position beyond tuple
    mov rax, qword ptr [rcx+rax*8]
L300:
L301:
    mov qword ptr [rbx+24], rax

這是有 7 個指令,沒有條件分支。

請在家嘗試! #

如果你想跟著操作並檢查已載入模組的機器碼,請像這樣啟動執行階段系統

erl +JDdump true

所有已載入模組的機器碼都將轉儲到副檔名為 .asm 的檔案中。

要找到已被 JIT 簡化的程式碼,請使用此命令

egrep "simplified|skipped|without overflow" *.asm

要檢查模組的 BEAM 程式碼,請使用 -S 選項。例如

erlc -S base64.erl

提取請求 #

以下是實作基於類型的最佳化的主要提取請求