编译器和 JIT 中的更多优化

2023 年 4 月 19 日 · 作者:Björn Gustavsson

这篇文章探讨了 Erlang/OTP 26 中增强的基于类型的优化和其他性能改进。

对 OTP 26 中 JIT 的期望 #

在 OTP 25 中,编译器已更新为在 BEAM 文件中嵌入类型信息,并且 JIT 已扩展为基于该类型信息发出更好的代码。 这些改进在博客文章 JIT 中的基于类型的优化 中进行了描述。

正如该博客文章中提到的,编译器和 JIT 中都存在限制,这阻止了许多优化。 在 OTP 26 中,编译器将生成更好的类型信息,并且 JIT 将更好地利用改进的类型信息,通常会减少冗余类型测试并减小本机代码大小。

OTP 26 中引入的一个新的 BEAM 指令使记录更新速度更快,尽管幅度较小但可测量。

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 指令现在具有其操作数的范围。

让我们仔细看一下前三个指令,它们对应于守卫测试 is_integer(X), 0 =< X, X < 16#400

首先是整数的守卫检查

    {test,is_integer,{f,19},[{x,0}]}.

紧随其后的是守卫测试 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 >= X1023 >= 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}}

组合守卫测试 #

在 OTP 25 中,JIT 会为守卫中的每个 BEAM 指令单独发出本机代码。 当单独翻译时,每个变量的三个守卫测试都需要 11 个本机指令,或者所有三个测试都需要 33 个指令。

通过使 BEAM 加载程序将三个守卫测试组合成单个 is_int_range 指令,JIT 能够更好地完成工作并发出仅 6 个本机指令。

这怎么可能?

作为单独的 BEAM 指令,每个守卫测试都需要 5 个指令从 {x,0} 中获取值并测试该值是否为小整数。 作为组合指令,只需执行一次。 守卫测试的其他部分在组合指令中也变得冗余,可以省略。 例如,如果其参数是大数(不适合机器字的整数),则 is_integer/1 类型测试也会成功。 显然,大数将远远超出 0 到 1023 的范围,因此如果参数不是小整数,则组合的守卫测试将立即失败。

有了这些和其他一些简化,我们最终得到以下本机指令

# 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 将内联许多最常用的守卫 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}

两个 is_ge 指令由 BEAM 加载程序组合成一个 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 位计算机内存的映射的大小始终是一个小整数,因此会跳过对小整数的测试

# 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 用于更新记录中的任意数量的字段。

  • 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.

这里使用 setelement/3 更新 e 字段,然后使用 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 使用专用指令。只要段是字节对齐的,专用指令就会内联完成所有操作。(用于 AArch64/ARM64 的 OTP 25 中的 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/1append: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 中,编译器有一个新的优化,可以在正确且安全的情况下将 append 操作替换为 private_append 操作。此优化由 Frej Drejhammar 实现。也就是说,该优化将重写 append:expand/2 以使用 private_append,但不会重写 examples:bins/2

append:expand/1append: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 倍。

拉取请求 #

以下是这篇博文中提到的优化的主要拉取请求