Erlang/OTP 27 中的优化

2024 年 4 月 23 日 · 作者:Björn Gustavsson

这篇文章探讨了记录更新的新优化以及其他一些改进。它还简要回顾了 Erlang/OTP 27 之前最近的优化历史。

近期优化的简要历史 #

Erlang 优化的现代历史始于 2018 年 1 月。我们意识到,在 Erlang 编译器中对 BEAM 代码进行优化已经达到了极限。

  • Erlang/OTP 22 在编译器中引入了一种新的 基于 SSA 的中间表示。 请阅读 SSA 历史中的完整故事。

  • Erlang/OTP 24 引入了 JIT (即时编译器),它通过在加载时为 BEAM 指令发射本机代码来提高性能。

  • Erlang/OTP 25 在 JIT 中引入了 基于类型的优化,这允许 Erlang 编译器将类型信息传递给 JIT,以帮助它发出更好的本机代码。虽然这改进了 JIT 发出的本机代码,但编译器和 JIT 的限制都阻止了 JIT 充分利用类型信息。

  • Erlang/OTP 26 改进了基于类型的优化。 最显着的性能改进是使用位语法匹配和构造二进制文件。 这些改进,加上对 base64 模块本身的更改,使得编码为 Base64 的速度提高了约 4 倍,而从 Base64 解码的速度提高了 3 倍以上。

Erlang/OTP 27 中对 JIT 的期望 #

Erlang/OTP 27 中主要的编译器和 JIT 改进是对记录操作的优化,但也还有许多较小的优化,使代码更小和/或更快。

请在家里尝试一下! #

虽然这篇博文将展示许多生成的代码示例,但我已尝试用英语解释这些优化。 请随意跳过代码示例。

另一方面,如果你想要更多代码示例...

要检查已加载模块的本机代码,请像这样启动运行时系统

erl +JDdump true

所有已加载模块的本机代码都将转储到扩展名为 .asm 的文件中。

要检查模块的 BEAM 代码,请在编译时使用 -S 选项。 例如

erlc -S base64.erl

一个简单的记录优化 #

首先,让我们看一下在 Erlang/OTP 26 及更早版本中没有完成的一个简单的记录优化。假设我们有这个模块

-record(foo, {a,b,c,d,e}).

update(N) ->
    R0 = #foo{},
    R1 = R0#foo{a=N},
    R2 = R1#foo{b=2},
    R2#foo{c=3}.

这是用于记录操作的 BEAM 代码

    {update_record,{atom,reuse},
                   6,
                   {literal,{foo,undefined,undefined,undefined,undefined,
                                 undefined}},
                   {x,0},
                   {list,[2,{x,0}]}}.
    {update_record,{atom,copy},6,{x,0},{x,0},{list,[3,{integer,2}]}}.
    {update_record,{atom,copy},6,{x,0},{x,0},{list,[4,{integer,3}]}}.

也就是说,所有三个记录更新操作都保留为单独的 update_record 指令。 每个操作通过复制记录的未更改部分并在正确的位置填充新值来创建新记录。

Erlang/OTP 27 中的编译器实际上会将 update/1 重写为

update(N) ->
    #foo{a=N,b=2,c=3}.

这将为记录创建生成以下 BEAM 代码

    {put_tuple2,{x,0},
                {list,[{atom,foo},
                       {x,0},
                       {integer,2},
                       {integer,3},
                       {atom,undefined},
                       {atom,undefined}]}}.

这些优化在以下拉取请求中实现

就地更新记录 #

要探索 Erlang/OTP 27 中引入的更复杂的记录优化,请考虑以下示例

-module(count1).
-export([count/1]).

-record(s, {atoms=0,other=0}).

count(L) ->
    count(L, #s{}).

count([X|Xs], #s{atoms=C}=S) when is_atom(X) ->
    count(Xs, S#s{atoms=C+1});
count([_|Xs], #s{other=C}=S) ->
    count(Xs, S#s{other=C+1});
count([], S) ->
    S.

count(List) 计算给定列表中原子和其它项的数量。 例如

1> -record(s, {atoms=0,other=0}).
ok
2> count1:count([a,b,c,1,2,3,4,5]).
#s{atoms = 3,other = 5}

以下是为 count/2 发出的 BEAM 代码

    {test,is_nonempty_list,{f,6},[{x,0}]}.
    {get_list,{x,0},{x,2},{x,0}}.
    {test,is_atom,{f,5},[{x,2}]}.
    {get_tuple_element,{x,1},1,{x,2}}.
    {gc_bif,'+',{f,0},3,[{tr,{x,2},{t_integer,{0,'+inf'}}},{integer,1}],{x,2}}.
    {test_heap,4,3}.
    {update_record,{atom,inplace},
                   3,
                   {tr,{x,1},
                       {t_tuple,3,true,
                                #{1 => {t_atom,[s]},
                                  2 => {t_integer,{0,'+inf'}},
                                  3 => {t_integer,{0,'+inf'}}}}},
                   {x,1},
                   {list,[2,{tr,{x,2},{t_integer,{1,'+inf'}}}]}}.
    {call_only,2,{f,4}}. % count/2
  {label,5}.
    {get_tuple_element,{x,1},2,{x,2}}.
    {gc_bif,'+',{f,0},3,[{tr,{x,2},{t_integer,{0,'+inf'}}},{integer,1}],{x,2}}.
    {test_heap,4,3}.
    {update_record,{atom,inplace},
                   3,
                   {tr,{x,1},
                       {t_tuple,3,true,
                                #{1 => {t_atom,[s]},
                                  2 => {t_integer,{0,'+inf'}},
                                  3 => {t_integer,{0,'+inf'}}}}},
                   {x,1},
                   {list,[3,{tr,{x,2},{t_integer,{1,'+inf'}}}]}}.
    {call_only,2,{f,4}}. % count/2
  {label,6}.
    {test,is_nil,{f,3},[{x,0}]}.
    {move,{x,1},{x,0}}.
    return.

前两条指令测试 {x,0} 中的第一个参数是否为非空列表,如果是,则提取列表的第一个元素

    {test,is_nonempty_list,{f,6},[{x,0}]}.
    {get_list,{x,0},{x,2},{x,0}}.

下一条指令测试第一个元素是否为原子。 如果不是,则跳转到第二个子句的代码。

    {test,is_atom,{f,5},[{x,2}]}.

接下来,从记录中获取已看到的原子计数器,并将其加一

    {get_tuple_element,{x,1},2,{x,2}}.
    {gc_bif,'+',{f,0},3,[{tr,{x,2},{t_integer,{0,'+inf'}}},{integer,1}],{x,2}}.

接下来是堆空间的分配和记录的更新

    {test_heap,4,3}.
    {update_record,{atom,inplace},
                   3,
                   {tr,{x,1},
                       {t_tuple,3,true,
                                #{1 => {t_atom,[s]},
                                  2 => {t_integer,{0,'+inf'}},
                                  3 => {t_integer,{0,'+inf'}}}}},
                   {x,1},
                   {list,[3,{tr,{x,2},{t_integer,{1,'+inf'}}}]}}.

test_heap 指令确保堆上有足够的空间用于复制记录(4 个字)。

update_record 指令是在 Erlang/OTP 26 中引入的。它的第一个操作数是一个原子,它是来自编译器的提示,用于帮助 JIT 发出更好的代码。在 Erlang/OTP 26 中,使用提示 reusecopy。 有关这些提示的更多信息,请参阅 在 OTP 26 中更新记录

在 Erlang/OTP 27 中,有一个新的提示称为 inplace。当编译器确定在运行时系统的任何地方,除了用于 update_record 指令的引用之外,没有对元组的其它引用时,编译器会发出该提示。换句话说,从编译器的角度来看,如果运行时系统直接更新现有记录而不先复制它,程序的可见行为不会改变。正如很快就会看到的,从运行时系统的角度来看,直接更新记录并不总是安全的。

这个新的优化由 Frej Drejhammar 实现。 它建立在 Erlang/OTP 26 中为 附加到二进制文件添加的编译器传递的基础上,并对其进行了扩展。

现在让我们看看当 record_update 指令具有 inplace 提示时 JIT 将会做什么。 以下是该指令的完整本机代码

# update_record_in_place_IsdI
    mov rax, qword ptr [rbx+8]
    mov rcx, qword ptr [rbx+16]
    test cl, 1
    short je L38           ; Update directly if small integer.

    ; The new value is a bignum.
    ; Test whether the tuple is in the safe part of the heap.

    mov rdi, [r13+480]     ; Get the high water mark
    cmp rax, r15           ; Compare tuple pointer to heap top
    short jae L39          ; Jump and copy if above
    cmp rax, rdi           ; Compare tuple pointer to high water
    short jae L38          ; Jump and overwrite if above high water

    ; The tuple is not in the safe part of the heap.
    ; Fall through to the copy code.

L39:                       ; Copy the current record
    vmovups ymm0, [rax-2]
    vmovups [r15], ymm0
    lea rax, qword ptr [r15+2] ; Set up tagged pointer to copy
    add r15, 32            ; Advance heap top past the copy

L38:
    mov rdi, rcx           ; Get new value for atoms field
    mov qword ptr [rax+22], rdi
    mov qword ptr [rbx+8], rax

(以 # 开头的行是 JIT 发出的注释,而 ; 后面的文本是我添加的澄清注释。)

BEAM 加载器将带有 inplace 提示的 update_record 指令重命名为 update_record_in_place

前两条指令将要更新的元组加载到 CPU 寄存器 rax 中,并将新的计数器值 (C + 1) 加载到 rcx 中。

    mov rax, qword ptr [rbx+8]
    mov rcx, qword ptr [rbx+16]

接下来的两条指令测试新的计数器值是否为适合一个字的小整数。 该测试已简化为更有效的测试,只有当该值已知为整数时才是安全的。 如果它是一个小整数,则始终可以安全地跳转到更新现有元组的代码

    test cl, 1
    short je L38           ; Update directly if small integer.

如果它不是一个小整数,则它必须是一个大数,也就是一个不适合 60 位的有符号整数,因此必须存储在堆上,rcx 包含一个指向堆上大数的标记指针。

如果 rcx 是一个指向堆上项的指针,则直接更新现有元组并不总是安全的。这是因为 Erlang 分代垃圾回收器的工作方式。每个 Erlang 进程都有两个堆用于保存 Erlang 项:年轻堆和老堆。 允许年轻堆上的项引用老堆上的项,反之则不行。 这意味着,如果要更新的元组位于老堆上,则更新它的一个元素以使其引用年轻堆上的项是不安全的。

因此,JIT 需要发出代码来确保指向元组的指针位于年轻堆的“安全部分”中

    mov rdi, [r13+480]     ; Get the high water mark
    cmp rax, r15           ; Compare tuple pointer to heap top
    short jae L39          ; Jump and copy if above
    cmp rax, rdi           ; Compare tuple pointer to high water
    short jae L38          ; Jump and overwrite if above high water

堆的安全部分位于高水位线和堆顶之间。如果元组低于高水位线,如果它仍然存活,它将在下一次垃圾回收中被复制到老堆。

如果元组位于安全部分,则通过跳转到将新值存储到现有元组的代码来跳过复制代码。

如果不是,下一部分会将现有记录复制到堆中。

L39:                       ; Copy the current record
    vmovups ymm0, [rax-2]
    vmovups [r15], ymm0
    lea rax, qword ptr [r15+2] ; Set up tagged pointer to copy
    add r15, 32            ; Advance heap top past the copy

复制使用 AVX 指令完成。

接下来是 将新值写入元组的代码

L38:
    mov rdi, rcx           ; Get new value for atoms field
    mov qword ptr [rax+22], rdi
    mov qword ptr [rbx+8], rax

如果写入现有记录的所有新值已知永远不是标记指针,则可以简化本机指令。考虑这个模块

-module(whatever).
-export([main/1]).

-record(bar, {bool,pid}).

main(Bool) when is_boolean(Bool) ->
    flip_state(#bar{bool=Bool,pid=self()}).

flip_state(R) ->
    R#bar{bool=not R#bar.bool}.

update_record 指令如下所示

    {update_record,{atom,inplace},
                   3,
                   {tr,{x,0},
                       {t_tuple,3,true,
                                #{1 => {t_atom,[bar]},
                                  2 => {t_atom,[false,true]},
                                  3 => pid}}},
                   {x,0},
                   {list,[2,{tr,{x,1},{t_atom,[false,true]}}]}}.

基于新值的类型 {t_atom,[false,true]},JIT 能够生成比上一个示例短得多的代码

# update_record_in_place_IsdI
    mov rax, qword ptr [rbx]
# skipped copy fallback because all new values are safe
    mov rdi, qword ptr [rbx+8]
    mov qword ptr [rax+14], rdi
    mov qword ptr [rbx], rax

对字面量(例如 [1,2,3])的引用也是安全的,因为字面量存储在特殊的字面量区域中,并且垃圾回收器会对其进行特殊处理。考虑以下代码

-record(state, {op, data}).

update_state(R0, Op0, Data) ->
    R = R0#state{data=Data},
    case Op0 of
        add -> R#state{op=fun erlang:'+'/2};
        sub -> R#state{op=fun erlang:'-'/2}
    end.

case 中的两个记录更新都可以就地完成。 以下是第一个子句中记录更新的 BEAM 代码

    {update_record,{atom,inplace},
                   3,
                   {tr,{x,0},{t_tuple,3,true,#{1 => {t_atom,[state]}}}},
                   {x,0},
                   {list,[2,{literal,fun erlang:'+'/2}]}}.

由于要写入的值是字面量,因此 JIT 发出更简单的代码,而没有复制回退

# update_record_in_place_IsdI
    mov rax, qword ptr [rbx]
# skipped copy fallback because all new values are safe
    long mov rdi, 9223372036854775807  ; Placeholder for address to fun
    mov qword ptr [rax+14], rdi
    mov qword ptr [rbx], rax

大整数 9223372036854775807 是一个占位符,当字面量 fun 的地址已知后,它将在稍后进行修补。

这是用于就地更新元组的拉取请求

通过减少垃圾生成来优化 #

当就地更新记录时,省略现有记录的复制应该是一个明显的胜利,除非对于非常小的记录。

不太清楚的是对垃圾回收的影响。 就地更新元组是通过减少垃圾生成进行优化的一个示例。 通过减少垃圾生成,期望垃圾回收应该减少发生,这应该提高程序的性能。

由于执行垃圾回收的执行时间变化很大,因此很难对减少垃圾生成量的优化进行基准测试。通常,基准测试的结果不适用于在实际应用程序中执行相同的任务。

我自己的 轶事证据表明,在大多数情况下,通过减少垃圾生成,没有可衡量的性能提升。

我还记得,当减少 Erlang 项大小的优化导致基准测试持续变慢时。该优化的作者花费了数天的时间进行调查,以确认基准测试的减速不是他的优化的错,而是通过减少垃圾生成,垃圾回收发生在稍后的时间,那时它碰巧变得更加昂贵。

平均而言,我们预计这种优化应该会提高性能,尤其是对于大型记录。

函数的优化 #

Erlang/OTP 27 中运行时系统中 fun 的内部表示已更改,从而可以进行多项新的优化。

例如,考虑以下函数

madd(A, C) ->
    fun(B) -> A * B + C end.

在 Erlang/OTP 26 中,创建 fun 的本机代码如下所示

# i_make_fun3_FStt
L38:
    long mov rsi, 9223372036854775807 ; Placeholder for dispatch table
    mov edx, 1
    mov ecx, 2
    mov qword ptr [r13+80], r15
    mov rbp, rsp
    lea rsp, qword ptr [rbx-128]
    vzeroupper
    mov rdi, r13
    call 4337160320       ; Call helper function in runtime system
    mov rsp, rbp
    mov r15, qword ptr [r13+80]
# Move fun environment
    mov rdi, qword ptr [rbx]
    mov qword ptr [rax+40], rdi
    mov rdi, qword ptr [rbx+8]
    mov qword ptr [rax+48], rdi
# Create boxed ptr
    or al, 2
    mov qword ptr [rbx], rax

大整数 9223372036854775807 是一个占位符,其值将在稍后填充。

创建 fun 对象的大部分工作是通过调用运行时系统中的一个辅助函数(call 4337160320 指令)来完成的。

在 Erlang/OTP 27 中,位于调用进程堆上的 fun 部分已简化,使其现在比 Erlang/OTP 26 中更小,最重要的是,不包含任何在内联代码中难以初始化的内容。

创建 fun 的代码不仅更短,而且也不需要调用运行时系统中的任何函数。

# i_make_fun3_FStt
L38:
    long mov rax, 9223372036854775807 ; Placeholder for dispatch table
# Create fun thing
    mov qword ptr [r15], 196884
    mov qword ptr [r15+8], rax
# Move fun environment
# (moving two items)
    vmovups xmm0, xmmword ptr [rbx]
    vmovups xmmword ptr [r15+16], xmm0
L39:
    long mov rdi, 9223372036854775807 ; Placeholder for fun reference
    mov qword ptr [r15+32], rdi
# Create boxed ptr
    lea rax, qword ptr [r15+2]
    add r15, 40
    mov qword ptr [rbx], rax

与 Erlang/OTP 26 的区别在于,仅在加载和卸载代码时才需要的 fun 部分不再存储在堆上。相反,这些部分存储在模块的已加载代码的文字池区域中,并由同一 fun 的所有实例共享。

与 Erlang/OTP 26 相比,位于进程堆上的 fun 部分小了两个字。

fun 环境的创建也得到了优化。在 Erlang/OTP 26 中,需要四个指令

# Move fun environment
    mov rdi, qword ptr [rbx]
    mov qword ptr [rax+40], rdi
    mov rdi, qword ptr [rbx+8]
    mov qword ptr [rax+48], rdi

在 Erlang/OTP 27 中,使用 AVX 指令,可以使用仅两个指令来移动两个变量(AC

# Move fun environment
# (moving two items)
    vmovups xmm0, xmmword ptr [rbx]
    vmovups xmmword ptr [r15+16], xmm0

通过更改 fun 表示形式实现的另一个优化是测试 fun 是否具有特定的 arity(调用时所需的参数数量)。例如

ensure_fun_0(F) when is_function(F, 0) -> ok.

这是 Erlang/OTP 26 中 JIT 发出的本机代码

# is_function2_fss
    mov rdi, qword ptr [rbx]   ; Fetch `F` from {x,0}.

    rex test dil, 1            ; Test whether the term is a tagged pointer...
    short jne label_3          ; ... otherwise fail.

    mov eax, dword ptr [rdi-2] ; Pick up the header word.
    cmp eax, 212               ; Test whether it is a fun...
    short jne label_3          ; ... otherwise fail.

    cmp byte ptr [rdi+22], 0   ; Test whether the arity is 0...
    short jne label_3          ; ... otherwise fail.

在 Erlang/OTP 27 中,fun 的 arity(预期参数的数量)存储在 fun 术语的标头字中,这意味着对 fun 的测试可以与其 arity 的测试相结合

# is_function2_fss
    mov rdi, qword ptr [rbx]   ; Fetch `F` from {x,0}.

    rex test dil, 1            ; Test whether the term is a tagged pointer...
    short jne label_3          ; ... otherwise fail.

    cmp word ptr [rdi-2], 20   ; Test whether this is a fun with arity 0...
    short jne label_3          ; ... otherwise fail.

现在所有外部 fun 都作为字面量存储在所有进程堆之外。例如,考虑以下函数

my_fun() ->
    fun ?MODULE:some_function/0.

mfa(M, F, A) ->
    fun M:F/A.

在 Erlang/OTP 26 中,my_fun/0 返回的外部 fun 不会占用调用进程堆上的任何空间,而 mfa/3 返回的动态外部 fun 则需要在调用进程堆上占用 5 个字。

在 Erlang/OTP 27 中,这两个 fun 都不会占用调用进程堆上的任何空间。

这些优化在以下拉取请求中实现

整数算术改进 #

去年 6 月底,我们发布了 Erlang/OTP 26 的 OTP 26.0.2 补丁,该补丁使 binary_to_integer/1 更快。

要了解速度提高了多少,请运行此基准测试

bench() ->
    Size = 1_262_000,
    String = binary:copy(<<"9">>, Size),
    {Time, _Val} = timer:tc(erlang, binary_to_integer, [String]),
    io:format("Size: ~p, seconds: ~p\n", [Size, Time / 1_000_000]).

它测量将包含 1,262,000 位数字的二进制文件转换为整数的时间。

在我 2017 年基于 Intel 的 iMac 上运行未打补丁的 Erlang/OTP 26,基准测试大约在 10 秒内完成。

使用 Erlang/OTP 26.0.2 运行相同的基准测试,大约在 0.4 秒内完成。

速度提升是通过三个单独的优化实现的

  • binary_to_integer/1 最初在 C 中作为 BIF 实现,使用了扩展性不佳的朴素算法。它被一个用 Erlang 实现的 分治算法 所取代。(将新算法作为 BIF 实现并不比 Erlang 版本快。)

  • 运行时系统中用于执行大整数乘法的函数已修改为使用 Karatsuba 算法,该算法是 20 世纪 60 年代发明的分治乘法算法。

  • 当 C 编译器支持时,修改了一些用于大整数(bignums)算术的底层辅助函数,以利用 64 位 CPU 上的 128 位整数数据类型。

这些改进是在以下拉取请求中实现的

在 Erlang/OTP 27 中,还实现了一些额外的整数算术改进。这使 binary_to_integer/1 基准测试的执行时间缩短至大约 0.3 秒。

这些改进可以在以下拉取请求中找到

这些算术增强提高了 pidigits 基准测试的运行时间

版本  
26.0   7.635
26.2.1   2.959
27.0   2.782

(在我的 M1 MacBook Pro 上运行。)

众多其他增强功能 #

对许多指令的代码生成进行了许多增强,并且对 Erlang 编译器也进行了一些增强。下面是一个简单的示例,展示了对 =:= 运算符的改进之一

ensure_empty_map(Map) when Map =:= #{} ->
    ok.

以下是此示例中使用的 =:= 运算符的 BEAM 代码

    {test,is_eq_exact,{f,1},[{x,0},{literal,#{}}]}.

这是 Erlang/OTP 26 的本机代码

# is_eq_exact_fss
L45:
    long mov rsi, 9223372036854775807
    mov rdi, qword ptr [rbx]
    cmp rdi, rsi
    short je L44                  ; Succeeded if the same term.

    rex test dil, 1
    short jne label_1             ; Fail quickly if not a tagged pointer.

    ; Call the general runtime function for comparing two terms.
    mov rbp, rsp
    lea rsp, qword ptr [rbx-128]
    vzeroupper
    call 4549723200
    mov rsp, rbp

    test eax, eax
    short je label_1               ; Fail if unequal.
L44:

该代码首先进行一些测试以快速成功或失败,但实际上这些测试不太可能在此示例中触发,这意味着几乎总是会调用运行时系统中用于比较两个术语的通用例程。

在 Erlang/OTP 27 中,JIT 发出特殊代码来测试术语是否为空映射

# is_eq_exact_fss
# optimized equality test with empty map
    mov rdi, qword ptr [rbx]
    rex test dil, 1
    short jne label_1              ; Fail if not a tagged pointer.

    cmp dword ptr [rdi-2], 300
    short jne label_1              ; Fail if not a map.

    cmp dword ptr [rdi+6], 0
    short jne label_1              ; Fail if size is not zero.

以下是 Erlang/OTP 27 中其他增强功能的主要拉取请求