編譯器與 JIT 的更多最佳化
這篇文章探討了 Erlang/OTP 26 中強化的類型最佳化以及其他效能改進。
OTP 26 中 JIT 的期望 #
在 OTP 25 中,編譯器已更新為在 BEAM 檔案中嵌入類型資訊,並且擴展了 JIT,以便根據該類型資訊發出更好的程式碼。這些改進在部落格文章JIT 中的類型最佳化中有說明。
如該部落格文章所述,編譯器和 JIT 中都存在限制,阻礙了許多最佳化。在 OTP 26 中,編譯器將產生更好的類型資訊,而 JIT 將更好地利用改進的類型資訊,通常會減少多餘的類型測試並縮小原生程式碼大小。
OTP 26 中引入的一條新的 BEAM 指令透過少量但可衡量的幅度加快了 record 的更新速度。
OTP 26 中最顯著的效能改進可能是在使用位元語法來匹配和建構二進位檔時。這些改進,加上 base64
模組本身的變更,使得編碼為 Base64 的速度快了約 4 倍,而從 Base64 解碼的速度快了 3 倍以上。
請在家中嘗試!#
雖然這篇部落格文章將顯示許多產生的程式碼範例,但我嘗試用英文解釋最佳化。您可以略過程式碼範例。
另一方面,如果您想要更多程式碼範例...
若要檢查載入模組的原生程式碼,請這樣啟動執行階段系統
erl +JDdump true
所有載入模組的原生程式碼都會轉儲到副檔名為 .asm
的檔案中。
若要檢查模組的 BEAM 程式碼,請在編譯時使用 -S
選項。例如
erlc -S base64.erl
OTP 25 中類型最佳化的快速概觀 #
讓我們快速總結 OTP 25 中的類型最佳化。如需更多詳細資訊,請參閱前述的部落格文章。
首先考慮在不知道其類型的情況下,將兩個值相加
add1(X, Y) ->
X + Y.
BEAM 程式碼如下所示
{gc_bif,'+',{f,0},2,[{x,0},{x,1}],{x,0}}.
return.
在沒有任何有關運算元資訊的情況下,JIT 必須發出可以處理運算元所有可能類型的程式碼。對於 x86_64 架構,需要 14 條原生指令。
如果已知運算元的類型是足夠小的整數,使得溢位成為不可能,則 JIT 只需要發出 5 條原生指令即可進行加法運算。
以下是一個範例,其中 +
運算元的類型和範圍是已知的
add5(X, Y) when X =:= X band 16#3FF,
Y =:= Y band 16#3FF ->
X + Y.
此函式的 BEAM 程式碼如下
{gc_bif,'band',{f,24},2,[{x,0},{integer,1023}],{x,2}}.
{test,is_eq_exact,
{f,24},
[{tr,{x,0},{t_integer,any}},{tr,{x,2},{t_integer,{0,1023}}}]}.
{gc_bif,'band',{f,24},2,[{x,1},{integer,1023}],{x,2}}.
{test,is_eq_exact,
{f,24},
[{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.
暫存器運算元 ({x,0}
和 {x,1}
) 現在已使用類型資訊進行註釋
{tr,Register,Type}
也就是說,每個暫存器運算元都是一個三元組,其中 tr
作為第一個元素。tr
代表類型暫存器。第二個元素是 BEAM 暫存器 (在此案例中為 {x,0}
或 {x,1}
),而第三個元素是編譯器內部類型表示法中暫存器的類型。{t_integer,{0,1023}}
表示該值是包含範圍 0 到 1023 的整數。
有了該類型資訊,JIT 會為 +
運算子發出以下原生程式碼
# i_plus_ssjd
# add without overflow check
mov rax, qword ptr [rbx]
mov rsi, qword ptr [rbx+8]
and rax, -16 ; Zero the tag bits
add rax, rsi
mov qword ptr [rbx], rax
(以 #
開頭的行是 JIT 發出的註解,而 ;
後面的文字是我為了澄清而添加的註解。)
程式碼大小從 14 條指令減少到 5 條指令固然很好,但必須使用 band
以如此複雜的方式來表達範圍檢查,實在稱不上好或自然。
如果我們嘗試以更自然的方式表達範圍檢查
add4(X, Y) when is_integer(X), 0 =< X, X < 16#400,
is_integer(Y), 0 =< Y, Y < 16#400 ->
X + Y.
OTP 25 中的編譯器將無法再找出運算元的範圍。這是 BEAM 程式碼
{test,is_integer,{f,22},[{x,0}]}.
{test,is_ge,{f,22},[{tr,{x,0},{t_integer,any}},{integer,0}]}.
{test,is_lt,{f,22},[{tr,{x,0},{t_integer,any}},{integer,1024}]}.
{test,is_integer,{f,22},[{x,1}]}.
{test,is_ge,{f,22},[{tr,{x,1},{t_integer,any}},{integer,0}]}.
{test,is_lt,{f,22},[{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.
由於編譯器的值範圍分析中存在如此嚴重的限制,我寫了
我們的目標是在 OTP 26 中改進類型分析和最佳化,並為此範例產生更好的程式碼。
OTP 26 中增強的類型最佳化 #
使用 OTP 26 編譯相同的範例,結果為
{test,is_integer,{f,19},[{x,0}]}.
{test,is_ge,{f,19},[{tr,{x,0},{t_integer,any}},{integer,0}]}.
{test,is_ge,{f,19},[{integer,1023},{tr,{x,0},{t_integer,{0,'+inf'}}}]}.
{test,is_integer,{f,19},[{x,1}]}.
{test,is_ge,{f,19},[{tr,{x,1},{t_integer,any}},{integer,0}]}.
{test,is_ge,{f,19},[{integer,1023},{tr,{x,1},{t_integer,{0,'+inf'}}}]}.
{gc_bif,'+',
{f,0},
2,
[{tr,{x,0},{t_integer,{0,1023}}},{tr,{x,1},{t_integer,{0,1023}}}],
{x,0}}.
+
運算子的 BEAM 指令現在具有其運算元的範圍。
讓我們仔細看看前三個指令,它們對應於 guard 測試 is_integer(X), 0 =< X, X < 16#400
。
第一個是整數的 guard 檢查
{test,is_integer,{f,19},[{x,0}]}.
接下來是 guard 測試 0 =< X
(編譯器將其重寫為 X >= 0
)
{test,is_ge,{f,19},[{tr,{x,0},{t_integer,any}},{integer,0}]}.
由於 is_integer/1
測試,已知 {x,0}
是一個整數。
第三個指令對應於 X < 16#400
,編譯器已將其重寫為 16#3FF >= X
(1023 >= X
)
{test,is_ge,{f,19},[{integer,1023},{tr,{x,0},{t_integer,{0,'+inf'}}}]}.
在 {x,0}
暫存器的類型中,OTP 26 有一些新的東西。它說範圍是 0 到 '+inf'
,也就是說,從 0 到正無限大。將該範圍與此指令的範圍結合,Erlang 編譯器可以推斷出,如果此指令成功,則 {x,0}
的類型為 t_integer,{0,1023}}
。
合併 guard 測試 #
在 OTP 25 中,JIT 會個別發出 guard 中每個 BEAM 指令的原生程式碼。當單獨轉換時,每個變數的三個 guard 測試各需要 11 條原生指令,或者全部三個測試需要 33 條指令。
透過讓 BEAM 載入器將三個 guard 測試合併成單一的 is_int_range
指令,JIT 能夠做得更好,並發出僅 6 條原生指令。
這怎麼可能?
作為個別的 BEAM 指令,每個 guard 測試需要 5 條指令才能從 {x,0}
中提取值並測試該值是否為小整數。作為合併的指令,只需要執行一次。guard 測試的其他部分在合併的指令中也會變得多餘,因此可以省略。例如,is_integer/1
類型測試如果其引數是大數 (不適合機器字的整數) 也會成功。顯然,大數將遠遠超出 0 到 1023 的範圍,因此如果引數不是小整數,合併的 guard 測試將立即失敗。
透過這些以及其他一些簡化,我們最終得到以下原生指令
# is_int_in_range_fScc
mov rax, qword ptr [rbx]
sub rax, 15
test al, 15
short jne label_19
cmp rax, 16368
short ja label_19
第一個指令會將 {x,0}
的值提取到 CPU 暫存器 rax
mov rax, qword ptr [rbx]
下一個指令會減去範圍下限的標記值。由於範圍的下限是 0,而小整數的標記是 15,因此要減去的值是 16 * 0 + 15
或簡單的 15。(對於小整數,執行階段系統使用字的 4 個最低有效位元作為標記位元。)如果下限是 1,則要減去的值將是 16 * 1 + 15
或 31
sub rax, 15
減法一次實現兩個目標。首先,它簡化了下兩個指令中的標記測試,因為如果 {x,0}
的值是小整數,則 4 個最低有效位元現在將為零
test al, 15
short jne label_19
test al, 15
指令會對 CPU 暫存器 rax
的較低位元組執行位元 AND 運算,捨棄結果,但根據值設定 CPU 旗標。下一個指令測試結果是否為非零 (標記不是小整數的標記),在這種情況下,測試失敗,並跳轉到失敗標籤。
減法的第二個目標是簡化範圍檢查。如果被測試的值低於下限,則 rax
的值在減法後將為負數。
由於整數以二補數表示法表示,因此解釋為無符號整數的有符號負整數將是一個非常大的整數。因此,可以使用將 rax
中的值視為無符號的舊技巧,一次檢查兩個界限
cmp rax, 16368
short ja label_19
cmp rax, 16368
指令會將 rax
中的值與已標記的上限和已標記的下限之差進行比較,也就是說
(16 * 1023 + 15) - (16 * 0 + 15)
ja
代表「(如果) 大於則跳轉」,也就是說,如果 CPU 旗標表示先前無符號整數的比較中,第一個整數大於第二個整數,則會跳轉。由於以二補數表示法表示的負數在解釋為無符號整數時看起來像一個巨大的整數,因此 short ja label_19
將控制轉移到值的失敗標籤 (無論是低於下限還是高於上限)。
更多程式碼產生改進 #
OTP 26 中的 JIT 會為關係運算子的常見組合產生更好的程式碼。為了減少 JIT 需要處理的組合數量,編譯器會盡可能將 <
運算子重寫為 >=
。在先前的範例中,顯示編譯器將 X < 1024
重寫為 1023 >= X
。
讓我們看一個做作的範例來展示 (炫耀) 程式碼產生中的一些更多改進
add6(M) when is_map(M) ->
A = map_size(M),
if
9 < A, A < 100 ->
A + 6
end.
BEAM 程式碼的主要部分如下所示
{test,is_map,{f,41},[{x,0}]}.
{gc_bif,map_size,{f,0},1,[{tr,{x,0},{t_map,any,any}}],{x,0}}.
{test,is_ge,
{f,43},
[{tr,{x,0},{t_integer,{0,288230376151711743}}},{integer,10}]}.
{test,is_ge,
{f,43},
[{integer,99},{tr,{x,0},{t_integer,{10,288230376151711743}}}]}.
{gc_bif,'+',{f,0},1,[{tr,{x,0},{t_integer,{10,99}}},{integer,6}],{x,0}}.
return.
在 OTP 26 中,JIT 將內嵌許多最常用的 guard BIF 的程式碼。以下是 map_size/1
呼叫的原生程式碼
# bif_map_size_jsd
mov rax, qword ptr [rbx] ; Fetch map from {x,0}
# skipped type check because the argument is always a map
mov rax, qword ptr [rax+6] ; Fetch size of map
shl rax, 4
or al, 15 ; Tag as small integer
mov qword ptr [rbx], rax ; Store size in {x,0}
BEAM 載入器會將兩個 is_ge
指令合併為 is_in_range
指令
# is_in_range_ffScc
# simplified fetching of BEAM register
mov rdi, rax
# skipped test for small operand since it always small
sub rdi, 175
cmp rdi, 1424
ja label_43
第一個指令是 OTP 26 中的新最佳化。通常,使用指令 mov rax, qword ptr [rbx]
提取 {x,0}
。但是,在這種情況下,上一個 BEAM 指令中的最後一個指令是指令 mov qword ptr [rbx], rax
。因此,由於已知 {x,0}
的內容已在 CPU 暫存器 rax
中,因此該指令可以簡化為
# simplified fetching of BEAM register
mov rdi, rax
適合 64 位元電腦記憶體的 map 大小始終是一個小整數,因此會跳過小整數的測試
# skipped test for small operand since it always small
sub rdi, 175 ; Subtract 16 * 10 + 15
cmp rdi, 1424 ; Compare with (16*99+15)-(16*10+15)
ja label_43
+
運算子的原生程式碼如下所示
# i_plus_ssjd
# add without overflow check
mov rax, qword ptr [rbx]
add rax, 96 ; 16 * 6 + 0
mov qword ptr [rbx], rax
OTP 26 中的新 BEAM 指令 #
先前合併 guard 測試的範例顯示,如果將多個 BEAM 指令合併為一個指令,JIT 通常可以產生更好的程式碼。雖然BEAM 載入器能夠合併指令,但讓 Erlang 編譯器發出合併的指令通常更為實用。
OTP 26 引入了兩個新指令,每個指令都取代了任意數量的簡單指令序列
-
update_record
用於更新 record 中的任意數量的欄位。 -
bs_match
用於比對多個固定大小的區段。
在 OTP 25 中,引入了 bs_create_bin
指令,用於建構具有任意多個區段的二進制資料,但其產生有效程式碼的全部潛力在 OTP 25 中並未被充分利用。
更新 OTP 25 中的記錄 #
考慮以下記錄定義和三個更新記錄的函式範例
-record(r, {a,b,c,d,e}).
update_a(R) ->
R#r{a=42}.
update_ce(R) ->
R#r{c=99,e=777}.
update_bcde(R) ->
R#r{b=2,c=3,d=4,e=5}.
在 OTP 25 及更早版本中,記錄的更新方式取決於要更新的欄位數量和記錄的大小。
當記錄中的單個欄位被更新時,如 update_a/1
中所示,會呼叫 setelement/3 BIF。
{test,is_tagged_tuple,{f,34},[{x,0},6,{atom,r}]}.
{move,{x,0},{x,1}}.
{move,{integer,42},{x,2}}.
{move,{integer,2},{x,0}}.
{call_ext_only,3,{extfunc,erlang,setelement,3}}.
當更新一個以上的欄位,但少於大約一半的欄位時,如 update_ce/1
中所示,會發出類似以下的程式碼
{test,is_tagged_tuple,{f,37},[{x,0},6,{atom,r}]}.
{allocate,0,1}.
{move,{x,0},{x,1}}.
{move,{integer,777},{x,2}}.
{move,{integer,6},{x,0}}.
{call_ext,3,{extfunc,erlang,setelement,3}}.
{set_tuple_element,{integer,99},{x,0},3}.
{deallocate,0}.
return.
這裡的 e
欄位使用 setelement/3
更新,然後使用 set_tuple_element
破壞性地更新 c
欄位。Erlang 不允許修改詞彙,但這裡是以安全的方式「在底層」完成的。
當大多數欄位被更新時,如 update_bcde/1
中所示,會建立一個新的元組。
{test,is_tagged_tuple,{f,40},[{x,0},6,{atom,r}]}.
{test_heap,7,1}.
{get_tuple_element,{x,0},1,{x,0}}.
{put_tuple2,{x,0},
{list,[{atom,r},
{x,0},
{integer,2},
{integer,3},
{integer,4},
{integer,5}]}}.
return.
更新 OTP 26 中的記錄 #
在 OTP 26 中,所有記錄都使用新的 BEAM 指令 update_record
更新。例如,以下是 update_1
的 BEAM 程式碼的主要部分
{test,is_tagged_tuple,{f,34},[{x,0},6,{atom,r}]}.
{test_heap,7,1}.
{update_record,{atom,reuse},6,{x,0},{x,0},{list,[2,{integer,42}]}}.
return.
最後一個運算元是元組中的位置列表及其對應的新值。
第一個運算元 {atom,reuse}
是給 JIT 的提示,表示來源元組可能已經是最新的,不需要更新。提示運算元的另一個可能值是 {atom,copy}
,表示來源元組絕對不是最新的。
JIT 會為 update_record
指令發出以下機器碼
# update_record_aIsdI
mov rax, qword ptr [rbx]
mov rdi, rax
cmp qword ptr [rdi+14], 687
je L130
vmovups xmm0, [rax-2]
vmovups [r15], xmm0
mov qword ptr [r15+16], 687
vmovups ymm0, [rax+22]
vmovups [r15+24], ymm0
lea rax, qword ptr [r15+2]
add r15, 56
L130:
mov qword ptr [rbx], rax
讓我們逐步了解這些指令。首先,提取 {x,0}
的值
mov rax, qword ptr [rbx]
由於提示運算元是原子 reuse
,因此可能不需要複製元組。因此,JIT 會發出一個指令序列,以測試 a
欄位(元組中的位置 2)是否已經包含值 42。如果是,則可以重複使用來源元組
mov rdi, rax
cmp qword ptr [rdi+14], 687 ; 42
je L130 ; Reuse source tuple
接下來是複製和更新序列。首先,使用 AVX 指令複製元組的標頭字和其第一個元素(r
原子)
vmovups xmm0, [rax-2]
vmovups [r15], xmm0
接下來,將值 42 儲存到元組副本的位置 2 中
mov qword ptr [r15+16], 687 ; 42
最後,複製元組的其餘四個元素
vmovups ymm0, [rax+22]
vmovups [r15+24], ymm0
剩下的就是建立一個指向新建立的元組的帶標籤指標,並增加堆積指標
lea rax, qword ptr [r15+2]
add r15, 56
最後一個指令將指向原始元組或更新元組的帶標籤指標儲存到 {x,0}
L130:
mov qword ptr [rbx], rax
update_ce/1
的 BEAM 程式碼與 update_a/1
的程式碼非常相似
{test,is_tagged_tuple,{f,37},[{x,0},6,{atom,r}]}.
{test_heap,7,1}.
{update_record,{atom,reuse},
6,
{x,0},
{x,0},
{list,[4,{integer,99},6,{integer,777}]}}.
return.
機器碼如下所示
# update_record_aIsdI
mov rax, qword ptr [rbx]
vmovups ymm0, [rax-2]
vmovups [r15], ymm0
mov qword ptr [r15+32], 1599 ; 99
mov rdi, [rax+38]
mov [r15+40], rdi
mov qword ptr [r15+48], 12447 ; 777
lea rax, qword ptr [r15+2]
add r15, 56
mov qword ptr [rbx], rax
請注意,儘管有 reuse
提示,複製和更新是無條件完成的。JIT 可以自由忽略提示。當多個欄位被更新時,測試是否不需要更新會更昂貴,而且所有欄位都保持不變的可能性也小得多。因此,嘗試重複使用原始元組更有可能是一種 負優化,而不是一種優化。
在 OTP 25 中比對和建構二進制資料 #
為了探索二進制資料的優化,將使用以下範例
bin_swap(<<A:8,B:24>>) ->
<<B:24,A:8>>.
在 OTP 25 中,編譯器發出的 BEAM 程式碼的主要部分(經過一定程度的簡化)如下所示
{test,bs_start_match3,{f,1},1,[{x,0}],{x,1}}.
{bs_get_position,{x,1},{x,0},2}.
{test,bs_get_integer2,
{f,2},
2,
[{x,1},
{integer,8},
1,
{field_flags,[unsigned,big]}],
{x,2}}.
{test,bs_get_integer2,
{f,2},
3,
[{x,1},
{integer,24},
1,
{field_flags,[unsigned,big]}],
{x,3}}.
{test,bs_test_tail2,{f,2},[{x,1},0]}.
{bs_create_bin,{f,0},
0,4,1,
{x,0},
{list,[{atom,integer},
1,1,nil,
{tr,{x,3},{t_integer,{0,16777215}}},
{integer,24},
{atom,integer},
2,1,nil,
{tr,{x,2},{t_integer,{0,255}}},
{integer,8}]}}.
return.
讓我們逐步了解程式碼。第一個指令會設定一個 比對內容
{test,bs_start_match3,{f,1},1,[{x,0}],{x,1}}.
比對內容會保留比對二進制資料所需的數個資訊。
下一個指令會儲存如果二進制資料的比對由於某種原因而失敗時將需要的資訊
{bs_get_position,{x,1},{x,0},2}.
接下來的兩個指令會比對出兩個區段作為整數(註解由我新增)
{test,bs_get_integer2,
{f,2}, % Failure label
2, % Number of live X registers (needed for GC)
[{x,1}, % Match context register
{integer,8}, % Size of segment in units
1, % Unit value
{field_flags,[unsigned,big]}],
{x,2}}. % Destination register
{test,bs_get_integer2,
{f,2},
3,
[{x,1},
{integer,24},
1,
{field_flags,unsigned,big]}],
{x,3}}.
下一個指令會確保現在已到達二進制資料的末尾
{test,bs_test_tail2,{f,2},[{x,1},0]}.
下一個指令會建立區段交換位置的二進制資料
{bs_create_bin,{f,0},
0,4,1,
{x,0},
{list,[{atom,integer},
1,1,nil,
{tr,{x,3},{t_integer,{0,16777215}}},
{integer,24},
{atom,integer},
2,1,nil,
{tr,{x,2},{t_integer,{0,255}}},
{integer,8}]}}.
在 OTP 25 之前,二進制資料的建立是使用多個指令完成的,類似於在 OTP 25 中仍然如何進行二進制資料比對的方式。在 OTP 25 中建立 bs_create_bin
指令的原因是能夠在二進制資料建構失敗時提供改進的錯誤資訊,類似於 改進的 BIF 錯誤資訊。
當比對大小為 8、16、32 或 64 的區段時,會針對 x86_64 使用專用指令。只要區段是對齊位元組的,專用指令就會全部內嵌完成。(OTP 25 中用於 AArch64/ARM64 的 JIT 沒有這些專用指令。)以下是比對大小為 8 的區段的指令
# i_bs_get_integer_8_Stfd
mov rcx, qword ptr [rbx+8]
mov rsi, qword ptr [rcx+22]
lea rdx, qword ptr [rsi+8]
cmp rdx, qword ptr [rcx+30]
ja label_25
rex test sil, 7
short je L91
mov edx, 64
call L92
short jmp L90
L91:
mov rdi, qword ptr [rcx+14]
shr rsi, 3
mov qword ptr [rcx+22], rdx
movzx rax, byte ptr [rdi+rsi]
shl rax, 4
or rax, 15
L90:
mov qword ptr [rbx+16], rax
前兩個指令會取得指向比對內容的指標,並從比對內容取得二進制資料中的目前位元偏移量
mov rcx, qword ptr [rbx+8] ; Load pointer to match context
mov rsi, qword ptr [rcx+22] ; Get offset in bits into binary
接下來的三個指令會確保二進制資料的長度至少為 8 位元
lea rdx, qword ptr [rsi+8] ; Add 8 to the offset
cmp rdx, qword ptr [rcx+30] ; Compare offset+8 with size of binary
ja label_25 ; Fail if the binary is too short
接下來的五個指令會測試二進制資料中的目前位元組是否對齊位元組邊界。如果不是,則會呼叫輔助程式碼片段
rex test sil, 7 ; Test the 3 least significant bits
short je L91 ; Jump if 0 (meaning byte-aligned)
mov edx, 64 ; Load size and flags
call L92 ; Call helper fragment
short jmp L90 ; Done
輔助程式碼片段是一個共享的程式碼區塊,可以從為 BEAM 指令產生的機器碼呼叫,通常用於處理不常見和/或需要比內嵌實際更多的機器指令的情況。每個這樣的程式碼片段都有其自己的呼叫慣例,通常是為了盡可能方便呼叫者而量身定制的。(有關輔助程式碼片段的詳細資訊,請參閱JIT 的進一步冒險。)
其餘的指令會從記憶體中讀取一個位元組,將其轉換為帶標籤的 Erlang 詞彙,將其儲存在 {x,2}
中,並增加比對內容中的位元偏移量
L91:
mov rdi, qword ptr [rcx+14] ; Load base pointer for binary
shr rsi, 3 ; Convert bit offset to byte offset
mov qword ptr [rcx+22], rdx ; Update bit offset in match context
movzx rax, byte ptr [rdi+rsi] ; Read one byte from the binary
shl rax, 4 ; Multiply by 16...
or rax, 15 ; ... and add tag for a small integer
L90:
mov qword ptr [rbx+16], rax ; Store extracted integer
當比對的區段大小不是先前提到特殊大小之一時,JIT 將始終發出對通用例程的呼叫,該例程可以處理具有任何對齊方式、位元組序和符號的任何整數區段的比對。
在 OTP 25 中,bs_create_bin
指令的完整優化潛力尚未實現。每個區段的建構都是透過呼叫建構區段的輔助例程來完成的。以下是 bs_create_bin
指令中建構整數區段部分的機器碼
# construct integer segment
mov edx, 24
mov rsi, qword ptr [rbx+24]
xor ecx, ecx
lea rdi, qword ptr [rbx-80]
call 4387496416
# construct integer segment
mov edx, 8
mov rsi, qword ptr [rbx+16]
xor ecx, ecx
lea rdi, qword ptr [rbx-80]
call 4387496416
OTP 26 中的二進制模式比對 #
在 OTP 26 中,有一個新的 BEAM bs_match
指令,用於比對在編譯時已知大小的區段。bin_swap/1
函式標頭中比對程式碼的 BEAM 程式碼如下所示
{test,bs_start_match3,{f,1},1,[{x,0}],{x,1}}.
{bs_get_position,{x,1},{x,0},2}.
{bs_match,{f,2},
{x,1},
{commands,[{ensure_exactly,32},
{integer,2,{literal,[]},8,1,{x,2}},
{integer,3,{literal,[]},24,1,{x,3}}]}}.
前兩個指令與 OTP 25 中的對應指令相同。
bs_match
指令的第一個運算元 {f,2}
是失敗標籤,第二個運算元 {x,2}
是保存比對內容的暫存器。第三個運算元 {commands,[...]}
是比對指令的列表。
commands
列表中的第一個指令 {ensure_exactly,32}
會測試要比對的二進制資料中剩餘的位元數是否正好是 32。如果不是,則會跳轉到失敗標籤。
第二個指令會提取 8 位元的整數並將其儲存在 {x,2}
中。第三個指令會提取 24 位元的整數並將其儲存在 {x,3}
中。
在單個 BEAM 指令中包含多個區段的比對,使 JIT 更容易產生有效程式碼。以下是機器碼將會執行的動作
-
測試二進制資料中是否正好剩下 32 位元。
-
如果區段是對齊位元組的,則從二進制資料中讀取一個 4 位元組的字組並將其儲存在 CPU 暫存器中。
-
如果區段未對齊位元組,則從二進制資料中讀取一個 8 位元組的字組並移位以提取所需的 32 位元。
-
移位並遮罩出 8 位元並標記為整數。儲存到
{x,2}
中。 -
移位並遮罩出 24 位元並標記為整數。儲存到
{x,3}
中。
bs_match
指令的機器碼(略有簡化)如下所示
# i_bs_match_fS
# ensure_exactly 32
mov rsi, qword ptr [rbx+8]
mov rax, qword ptr [rsi+30]
mov rcx, qword ptr [rsi+22]
sub rax, rcx
cmp rax, 32
jne label_3
# read 32
mov rdi, qword ptr [rsi+14]
add qword ptr [rsi+22], 32
mov rax, rcx
shr rax, 3
add rdi, rax
and ecx, 7
jnz L38
movbe edx, dword ptr [rdi]
add ecx, 32
short jmp L40
L38:
mov rdx, qword ptr [rdi-3]
shr rdx, 24
bswap rdx
L40:
shl rdx, cl
# extract integer 8
mov rax, rdx
# store extracted integer as a small
shr rax, 52
or rax, 15
mov qword ptr [rbx+16], rax
shl rdx, 8
# extract integer 24
shr rdx, 36
or rdx, 15
mov qword ptr [rbx+24], rdx
程式碼的第一部分確保二進制資料中正好剩下 32 位元
# ensure_exactly 32
mov rsi, qword ptr [rbx+8] ; Get pointer to match context
mov rax, qword ptr [rsi+30] ; Get size of binary in bits
mov rcx, qword ptr [rsi+22] ; Get offset in bits into binary
sub rax, rcx
cmp rax, 32
jne label_3
程式碼的下一部分並未直接對應於 bs_match
BEAM 指令中的指令。相反,程式碼會從二進制資料中讀取 32 位元
# read 32
mov rdi, qword ptr [rsi+14]
add qword ptr [rsi+22], 32 ; Increment bit offset in match context
mov rax, rcx
shr rax, 3
add rdi, rax
and ecx, 7 ; Test alignment
jnz L38 ; Jump if segment not byte-aligned
; Read 32 bits (4 bytes) byte-aligned and convert to big-endian
movbe edx, dword ptr [rdi]
add ecx, 32
short jmp L40
L38:
; Read a 8-byte word and extract the 32 bits that are needed.
mov rdx, qword ptr [rdi-3]
shr rdx, 24
bswap rdx ; Convert to big-endian
L40:
; Shift the read bytes to the most significant bytes of the word
shl rdx, cl
讀取的 4 個位元組將轉換為大端序,並放置為 CPU 暫存器 rdx
的最高有效位元組,暫存器的其餘部分為零。
以下指令會提取第一個區段的 8 位元,並將其作為帶標籤的整數儲存在 {x,2}
中
# extract integer 8
mov rax, rdx
# store extracted integer as a small
shr rax, 52
or rax, 15
mov qword ptr [rbx+16], rax
shl rdx, 8
以下指令會提取第二個區段的 24 位元,並將其作為帶標籤的整數儲存在 {x,3}
中
# extract integer 24
shr rdx, 36
or rdx, 15
mov qword ptr [rbx+24], rdx
OTP 26 中的二進制資料建構 #
對於 OTP 26 中的二進制資料建構,編譯器會發出與 OTP 25 中相同的 bs_create_bin
BEAM 指令。但是,OTP 26 中 JIT 為該指令發出的機器碼與 OTP 25 發出的機器碼幾乎沒有相似之處。機器碼會執行以下操作
-
在堆積上為二進制資料配置空間,並使用內嵌機器碼初始化它。如果堆積上沒有剩餘足夠的空間,則會呼叫輔助程式碼片段來執行垃圾回收。
-
從
{x,3}
中讀取整數並取消標籤。 -
從
{x,2}
中讀取整數並取消標籤。將該值與先前的 24 位元值組合以取得 32 位元值。 -
將組合的 32 位元寫入二進制資料中。
以下是 bs_create_bin
指令的完整機器碼(略有簡化)
# i_bs_create_bin_jItd
# allocate heap binary
lea rdx, qword ptr [r15+56]
cmp rdx, rsp
short jbe L43
mov ecx, 4
.db 0x90
call 4343630296
L43:
lea rax, qword ptr [r15+2]
mov qword ptr [rbx-120], rax
mov qword ptr [r15], 164
mov qword ptr [r15+8], 4
add r15, 16
mov qword ptr [rbx-64], r15
mov qword ptr [rbx-56], 0
add r15, 8
# accumulate value for integer segment
xor r8d, r8d
mov rdi, qword ptr [rbx+24]
sar rdi, 4
or r8, rdi
# accumulate value for integer segment
shl r8, 8
mov rdi, qword ptr [rbx+16]
sar rdi, 4
or r8, rdi
# construct integer segment from accumulator
bswap r8d
mov rdi, qword ptr [rbx-64]
mov qword ptr [rbx-56], 32
mov dword ptr [rdi], r8d
讓我們逐步了解它。
程式碼的第一部分,從 # allocate heap binary
開始,在下一個註解行之前結束,會配置具有內嵌機器碼的堆積二進制資料。對輔助程式碼片段的唯一呼叫是在堆積上沒有剩餘足夠空間的情況下。
接下來是二進制資料區段的建構。
不是一次將每個區段的值寫入記憶體,而是將多個區段累加到 CPU 暫存器中。以下是要建構的第一個區段(24 位元)的程式碼
# accumulate value for integer segment
xor r8d, r8d ; Initialize accumulator
mov rdi, qword ptr [rbx+24] ; Fetch {x,3}
sar rdi, 4 ; Untag
or r8, rdi ; OR into accumulator
以下是第二個區段(8 位元)的程式碼
# accumulate value for integer segment
shl r8, 8 ; Make room for 8 bits
mov rdi, qword ptr [rbx+16] ; Fetch {x,2}
sar rdi, 4 ; Untag
or r8, rdi ; OR into accumulator
由於沒有剩餘的二進制資料區段,因此將累加值寫出到記憶體
# construct integer segment from accumulator
bswap r8d ; Make accumulator big-endian
mov rdi, qword ptr [rbx-64] ; Get pointer into binary
mov qword ptr [rbx-56], 32 ; Update size of binary
mov dword ptr [rdi], r8d ; Write 32 bits
在 OTP 25 中附加到二進制資料 #
古老的 OTP R12B 版本引入了針對有效附加到二進制資料的優化。讓我們看一個範例來了解實際運作的優化
-module(append).
-export([expand/1, expand_bc/1]).
expand(Bin) when is_binary(Bin) ->
expand(Bin, <<>>).
expand(<<B:8,T/binary>>, Acc) ->
expand(T, <<Acc/binary,B:16>>);
expand(<<>>, Acc) ->
Acc.
expand_bc(Bin) when is_binary(Bin) ->
<< <<B:16>> || <<B:8>> <= Bin >>.
append:expand/1
和 append:expand_bc/1
都會採用二進制資料,並透過將每個位元組擴展為兩個位元組來將其大小加倍。例如
1> append:expand(<<1,2,3>>).
<<0,1,0,2,0,3>>
2> append:expand_bc(<<4,5,6>>).
<<0,4,0,5,0,6>>
兩個函式都只接受二進制資料
3> append:expand(<<1,7:4>>).
** exception error: no function clause matching append:expand(<<1,7:4>>,<<>>)
4> append:expand_bc(<<1,7:4>>).
** exception error: no function clause matching append:expand_bc(<<1,7:4>>)
在查看 BEAM 程式碼之前,讓我們使用 erlperf 進行一些基準測試,以找出哪個函式更快
erlperf --init_runner_all 'rand:bytes(10_000).' \
'r(Bin) -> append:expand(Bin).' \
'r(Bin) -> append:expand_bc(Bin).'
Code || QPS Time Rel
r(Bin) -> append:expand_bc(Bin). 1 7936 126 us 100%
r(Bin) -> append:expand(Bin). 1 4369 229 us 55%
--init_runner_all
選項的運算式使用 rand:bytes/1 來建立一個具有 10,000 個隨機位元組的二進制資料,該二進制資料將傳遞給兩個擴展函式。
從基準測試結果可以看出,expand_bc/1
函式幾乎快了兩倍。
為了找出原因,讓我們比較兩個函式的 BEAM 程式碼。以下是在 expand/1
中附加到二進制資料的指令
{bs_create_bin,{f,0},
0,3,8,
{x,1},
{list,[{atom,append}, % Append operation
1,8,nil,
{tr,{x,1},{t_bitstring,1}}, % Source/destination
{atom,all},
{atom,integer},
2,1,nil,
{tr,{x,2},{t_integer,{0,255}}},
{integer,16}]}}.
第一個區段是 append
作業。運算元 {tr,{x,1},{t_bitstring,1}}
表示作業的來源和目標。也就是說,{x,1}
引用的二進制資料將會被修改。Erlang 通常不允許修改,但此修改是在底層以從外部無法觀察到的方式完成的。這使得附加操作比必須複製來源二進制資料的情況更有效率。
在 expand_bc/1
中的二元組列表解析中,有一個類似的 BEAM 指令可以附加到二元組。
{bs_create_bin,{f,0},
0,3,1,
{x,1},
{list,[{atom,private_append}, % Private append operation
1,1,nil,
{x,1},
{atom,all},
{atom,integer},
2,1,nil,
{tr,{x,2},{t_integer,{0,255}}},
{integer,16}]}}.
主要的區別在於二元組列表解析使用更有效率的 private_append
操作,而不是 append
。
append
操作有更多的額外負擔,因為它必須為以下程式碼產生正確的結果:
bins(Bin) ->
bins(Bin, <<>>).
bins(<<H,T/binary>>, Acc) ->
[Acc|bins(T, <<Acc/binary,H>>)];
bins(<<>>, Acc) ->
[Acc].
執行它
1> example:bins(<<"abcde">>).
[<<>>,<<"a">>,<<"ab">>,<<"abc">>,<<"abcd">>,<<"abcde">>]
在 expand/1
函式中,只需要最後附加的二元組值。在 bins/1
中,所有二元組的中間值都會被收集到一個列表中。為了正確性,append
操作必須確保在將 H
附加到 Acc
之前複製二元組 Acc
。為了能夠知道何時需要複製二元組,append
操作會進行一些額外的簿記工作,這不是免費的。
在 OTP 26 中附加到二元組 #
在 OTP 26 中,編譯器有一個新的優化,只要是正確且安全的情況,就會用 private_append
操作取代 append
操作。此優化由 Frej Drejhammar 實作。也就是說,此優化會重寫 append:expand/2
以使用 private_append
,但不會重寫 examples:bins/2
。
現在 append:expand/1
和 append:expand_bc/1
之間的差異已經小很多了。
erlperf --init_runner_all 'rand:bytes(10_000).' \
'r(Bin) -> append:expand(Bin).' \
'r(Bin) -> append:expand_bc(Bin).'
Code || QPS Time Rel
r(Bin) -> append:expand_bc(Bin). 1 13164 75988 ns 100%
r(Bin) -> append:expand(Bin). 1 12419 80550 ns 94%
expand_bc/1
仍然快一點,因為編譯器為它發出的二元組匹配程式碼比 expand/1
函式的效率更高一些。
is_binary/1
守衛的好處 #
expand/1
函式有一個 is_binary/1
守衛測試,看起來似乎沒有必要。
expand(Bin) when is_binary(Bin) ->
expand(Bin, <<>>).
此守衛測試對於正確性來說並非必要,因為如果 expand/2
的參數不是二元組,它會引發 function_clause
例外。但是,使用守衛測試會為 expand/2
產生更好的程式碼。
使用守衛測試,expand/2
中的第一個 BEAM 指令是:
{bs_start_match4,{atom,no_fail},2,{x,0},{x,0}}.
不使用守衛測試,第一個 BEAM 指令是:
{test,bs_start_match3,{f,3},2,[{x,0}],{x,2}}.
bs_start_match4
指令效率更高,因為它不必測試 {x,0}
是否包含二元組。
基準測試結果顯示,如果移除守衛測試,expand/1
的執行時間會明顯增加。
erlperf --init_runner_all 'rand:bytes(10_000).' \
'r(Bin) -> append:expand(Bin).' \
'r(Bin) -> append:expand_bc(Bin).'
Code || QPS Time Rel
r(Bin) -> append:expand_bc(Bin). 1 13273 75366 ns 100%
r(Bin) -> append:expand(Bin). 1 11875 84236 ns 89%
重新檢視 base64
模組 #
傳統上,直到 OTP 25,base64
模組中執行將二元組編碼為 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>>).
原因在於,匹配大小為 8 的片段一直以來都經過特殊優化,並且比匹配大小為 6 的片段快得多。但在 OTP 26 中不再如此。隨著此部落格文章中描述的二元組匹配改進,此子句可以用更自然的方式編寫:
encode_binary(<<B1:6, B2:6, B3:6, B4:6, Ls/bits>>, A) ->
encode_binary(Ls,
<<A/bits,
(b64e(B1)):8,
(b64e(B2)):8,
(b64e(B3)):8,
(b64e(B4)):8>>);
(這不是 OTP 26 中的確切程式碼,因為後來加入了其他功能。)
在 OTP 25 中,將 1,000,000 位元組的隨機二元組編碼為 Base64 的基準測試結果是:
erlperf --init_runner_all 'rand:bytes(1_000_000).' \
'r(Bin) -> base64:encode(Bin).'
Code || QPS Time
r(Bin) -> base64:encode(Bin). 1 61 16489 us
在 OTP 26 中,將 1,000,000 位元組的隨機二元組編碼為 Base64 的基準測試結果是:
erlperf --init_runner_all 'rand:bytes(1_000_000).' \
'r(Bin) -> base64:encode(Bin).'
Code || QPS Time
r(Bin) -> base64:encode(Bin). 1 249 4023 us
也就是說,編碼速度大約快了 4 倍。
提取請求 #
以下是此部落格文章中提到的優化的主要提取請求:
- 編譯器:改進類型分析
- JIT:優化關係運算子的常見組合
- JIT:小型優化,其中包含避免提取已在 CPU 暫存器中的運算元的優化。
- 編譯器:優化記錄更新
- JIT:優化固定寬度片段的二元組匹配
- JIT:優化二元組的建立
- 編譯器:二元組的
private_append
優化