淘汰旧的性能陷阱

2018年11月7日 · John Högberg 著

Erlang/OTP 22 将带来许多性能改进,但其中大多数都具有广泛的影响,并且不会影响您编写高效代码的方式。在这篇文章中,我想强调一些过去速度慢得令人惊讶但不再需要避免的事情。

具名函数递归 #

具名函数有一个很棒的小特性,第一眼可能不太明显;它们的名称像任何其他变量一样,您可以自由地将其传递给另一个函数,甚至将其返回。

deepfoldl(F, Acc0, L) ->
    (fun NamedFun([_|_]=Elem, Acc) -> lists:foldl(NamedFun, Acc, Elem);
         NamedFun([], Acc) -> Acc;
         NamedFun(Elem, Acc) -> F(Elem, Acc)
     end)(L, Acc0).

这很酷,但对编译器来说有点头疼。为了创建一个函数,我们将它的定义和自由变量传递给 make_fun2 指令,但我们不能将函数本身作为自由变量包含在内,因为它尚未被创建。在 OTP 22 之前,我们通过在函数内部创建一个新的等效函数来解决这个问题,但这使得递归在运行时和内存使用方面都非常昂贵。

从 OTP 22 开始,我们将递归转换为直接函数调用,从而避免创建新函数。其他情况仍然需要重新创建函数,但它们远不常见。

优化具名函数和函数包装的宏 #1973

具有大型操作数的列表减法(– 运算符) #

虽然 Erlang VM 看起来是为程序员预先调度的,但它在内部是协作调度的。当一个原生函数运行时,它会独占调度器直到返回,因此一个长时间运行的函数会严重损害系统的响应能力。因此,我们几乎所有这样的函数都以一种将工作分解为足够快完成的短单元的风格编写,但是不规范的函数列表正在稳步缩小,列表减法就是其中之一。

以这种风格重写函数通常很简单,但是旧算法在一个循环中围绕第一个列表处理第二个列表,这是有问题的,因为两个列表都可能非常长,并且在嵌套循环中恢复工作通常比预期的要棘手。

在这种情况下,完全摆脱嵌套循环更容易。新算法首先从右侧构建一个红黑树,然后再从左侧删除元素。由于树上的所有操作都具有 log n 的复杂度,我们知道它们将非常快地完成,因此我们只需要关心在外循环中让出。

这也产生了很好的副作用,将最坏情况下的复杂度从 O(n²) 降低到 O(n log n),并让我们从参考手册和效率指南中删除了一些警告。值得注意的是,新实现始终比建议的解决方法更快,并且在更快的情况下会回退到旧算法。

此更改将在 OTP 21.2 中推出,非常感谢 Dmytro Lytovchenko(GitHub 上的 @kvakvs)编写了其中较好的一半!

优化列表减法 (A – B) 并使其在大型输入上让步 #1998

位语法匹配中的前瞻 #

OTP 22 中完全重写了位语法匹配的优化过程,以利用新的基于 SSA 的中间格式。它应用与以前相同的优化,因此已经优化良好的代码不太可能看到任何好处,但它设法在更多情况下应用它们。

对于那些不熟悉的人来说,所有位语法匹配都在内部操作一个“匹配上下文”,这是一个可变对象,用于跟踪当前匹配位置。这在匹配复杂的模式时有很大帮助,因为它可以根据需要在前后移动,从而避免我们多次匹配组件。

这在匹配几种不同的模式时很棒,但在如下的循环中非常方便

trim_zero(<<0,Tail/binary>>) -> trim_zero(Tail);
trim_zero(B) when is_binary(B) -> B.

由于编译器可以看到 Tail 直接传递给 trim_zero,后者立即以位匹配开始,因此它可以跳过将 Tail 作为子二进制提取,而是传递匹配上下文。这是一种众所周知的优化,称为“匹配上下文重用”,在应用时可以大大提高性能,并且已经为此编写了许多代码。

像这样传递匹配上下文的难点在于,我们必须保持正在处理不可变的二进制的假象。每当它在非匹配表达式中使用时,我们需要将上下文转换为等效的二进制,或者承认失败并跳过优化。

虽然编译器在 OTP 22 之前做得很好,但在许多情况下它太容易放弃了,最微不足道的例子几乎很有趣

calls_wrapper(<<"hello",Tail/binary>>) ->
    count_ones(Tail).

%% This simple wrapper prevents context reuse in the call above. :(
count_ones(Bin) -> count_ones_1(Bin, 0).

count_ones_1(<<1, Tail/binary>>, Acc) -> count_ones_1(Tail, Acc + 1);
count_ones_1(<<_, Tail/binary>>, Acc) -> count_ones_1(Tail, Acc);
count_ones_1(<<>>, Acc) -> Acc.

可以在 string 模块中找到一个更棘手的示例

bin_search_inv_1(<<CP1/utf8, BinRest/binary>>=Bin0, Cont, Sep) ->
    case BinRest of
        %% 1
        <<CP2/utf8, _/binary>> when ?ASCII_LIST(CP1, CP2) ->
            case CP1 of
                Sep ->
                    %% 2
                    bin_search_inv_1(BinRest, Cont, Sep);
                _ ->
                    %% 3
                    [Bin0|Cont]
            end;
        %% ... snip ...

我们正在看的是 ASCII 字符的快速路径;当 CP1CP2 都是 ASCII 时,我们知道 CP1 不是字素簇的一部分,因此我们可以避免调用 unicode_util:gc/1。它不是一个特别昂贵的函数,但每个字符调用一次会很快累积起来。

乍一看,在 2 处传递上下文似乎是安全的,但这由于 Bin03 处返回而变得困难。由于上下文是可变的并且每当匹配成功时都会更改其位置,因此天真地将 Bin0 转换回二进制将给您 CP2 之后的内容。

现在,您可能想知道为什么我们不能在将 Bin0 转换回二进制之前简单地恢复位置。这是一件显而易见的事情,但在 OTP 22 之前,上下文不仅跟踪当前位置,还跟踪回溯时所需的先前位置。这些都保存在每个上下文的“槽”中,这些槽是可变的并且被大量重用,并且 1 处的匹配覆盖了恢复 Bin0 所需的槽。

这也意味着上下文在传递给另一个函数或进入 try/catch 后不能再次使用,这使得在需要前瞻的代码中几乎不可能应用此优化。

从 OTP 22 开始,这些位置存储在上下文之外,因此无需担心它们失效,从而可以优化上述情况。

在新 SSA 基础中间格式中重写 BSM 优化 #1958