分析 Julia 编译器延迟的来源:方法失效

2020 年 8 月 26 日 | Tim Holy、Jeff Bezanson 和 Jameson Nash

  1. 方法失效:它是什么以及它何时发生?
    1. 一个例子:对具有未知元素类型的容器进行编译
    2. 触发方法失效
  2. 方法失效有多常见?
  3. 方法失效的影响是什么?
  4. 可以并且应该修复它吗?
  5. Julia 中减少失效次数的更改
  6. 用于分析和修复失效的工具
  7. 总结
    1. 迄今为止进展的影响
    2. 未来展望

Julia 编程语言 在类型方面具有极佳的灵活性,这使您能够以意想不到的方式组合包以解决新型问题。至关重要的是,它在不牺牲运行时性能的情况下实现了这种灵活性。它通过运行针对您使用的特定类型而“专门化”的代码或算法版本来做到这一点。创建这些专门化是编译,而当在运行时进行(在 Julia 中很常见)时,这被称为“即时”(JIT)编译。不幸的是,JIT 编译需要时间,这会增加第一次运行 Julia 代码时的延迟。这个问题通常总结为“第一次绘图时间”,尽管除了绘图库往往包含大型代码库这一事实之外,没有关于绘图的任何特殊内容,这些代码库必须进行 JIT 编译。

许多人花费大量时间分析和减少 Julia 的延迟。这些努力取得了相当大的成功:Julia 1.5 的感觉比任何记忆中的版本都要快,基准测试也支持这种印象。但是减少延迟的工作尚未结束。最近在 Julia 的 master 分支上的工作在显著减少特定延迟来源(方法失效)方面取得了很大进展。这篇文章将回答以下问题:

方法失效:它是什么以及它何时发生?

一个例子:对具有未知元素类型的容器进行编译

当 Julia 为特定类型编译方法时,它会保存生成的代码,以便任何调用者都可以使用它。这对性能至关重要,因为它意味着编译通常只需要针对特定类型进行一次。为了让事情变得非常简单,我们将使用一个非常人为的例子。

f(x::Int) = 1
applyf(container) = f(container[1])

这里我们定义了两个函数;f 支持 Int,而 applyf 支持 Any 类型的参数。当您调用 applyf 时,Julia 将根据您此时使用的 container 的特定类型按需编译专门化版本,即使它的定义中没有类型注释。这些针对特定输入类型专门化的版本是 MethodInstance,它是 Core.Compiler 中的内部类型。

如果您调用 applyf([100]),Julia 将编译并使用专门为 Vector{Int} 创建的 applyf 版本。由于元素类型 (Int) 是 container 类型的一部分,它将在编译此专门化时知道只会使用 f(::Int):该调用将被“硬连线”到 Julia 生成的代码中。您可以使用 @code_typed 查看此代码

julia> applyf([100])
1

julia> @code_typed applyf([100])
CodeInfo(
1 ─     Base.arrayref(true, container, 1)::Int64
└──     return 1
) => Int64

编译器知道答案将是 1,只要输入数组的元素索引可被 1 索引。(该 arrayref 语句强制执行边界检查,并确保如果您将 applyf 传递给一个空容器,Julia 将抛出一个适当的错误。)

就这篇博客文章而言,如果我们使用一个可以存储不同类型元素的容器(这里,类型为 Any),情况就会变得特别有趣

julia> c = Any[100];

julia> applyf(c)
1

julia> @code_typed applyf(c)
CodeInfo(
1 ─ %1 = Base.arrayref(true, container, 1)::Any
│   %2 = (isa)(%1, Int64)::Bool
└──      goto #3 if not %2
2 ─      goto #4
3 ─ %5 = Main.f(%1)::Core.Const(1, false)
└──      goto #4
4 ┄ %7 = φ (#2 => 1, #3 => %5)::Core.Const(1, false)
└──      return %7
) => Int64

这可能看起来复杂得多,但要点实际上很简单。首先,看一下 arrayref 语句:它被注释为 ::Any,这意味着 Julia 无法提前预测它将返回什么。在引用之后,请注意 isa 语句后跟一个 φ;包括最终返回值,所有这些实际上等同于

if isa(x, Int)
    value = 1
else
    value = f(x)::Int
end
return value

编译器可能事先不知道 container[1] 将是什么类型,但它知道 f 有一种针对 Int 专门化的方法。为了提高性能,它会(在运行时尽可能高效地)检查该方法是否可能适用,如果适用,则调用它所知道的方法。在本例中,f 只是返回一个常数,因此如果适用,编译器甚至会为您硬连线返回值。

但是,编译器也承认 container[1]不会Int 的可能性。这允许上面的代码抛出一个 MethodError

julia> applyf(Any[true])
ERROR: MethodError: no method matching f(::Type{Bool})
Closest candidates are:
  f(::Int64) at REPL[1]:1
[...]

这类似于我们查看类型良好的调用如何被推断

julia> @code_typed applyf([true])
CodeInfo(
1 ─ %1 = Base.getindex(container, 1)::Bool
│        Main.f(%1)::Union{}
└──      unreachable
) => Union{}

在本例中,编译器知道调用不会成功。注释为 ::Union{} 的调用不会返回。

触发方法失效

注意:此演示在 Julia 的 master 分支(将成为 1.6)上运行。根据您使用的 Julia 版本,您可能会得到不同的结果,或者可能需要为 f 定义多个额外的函数才能看到此处显示的结果。

Julia 是交互式的:我们可以动态定义新函数并在其中进行尝试。在我们的会话中已经运行了上面的代码,让我们看看当我们为 f 定义一个新函数时会发生什么

f(::Bool) = 2

虽然 Vector{Int} 类型的 applyf 版本没有因为此新定义而改变(试一试就知道了),但 Bool 类型的版本和无类型的版本都与之前不同

julia> @code_typed applyf([true])
CodeInfo(
1 ─     Base.arrayref(true, container, 1)::Bool
└──     return 2
) => Int64

julia> @code_typed applyf(Any[true])
CodeInfo(
1 ─ %1 = Base.arrayref(true, container, 1)::Any
│   %2 = Main.f(%1)::Int64
└──      return %2
) => Int64

这表明为 f 定义一个新函数会强制这些 MethodInstance 重新编译

您可以看到 Julia 不再使用该优化来检查 container[1] 是否恰好是 Int。经验表明,一旦一个函数具有几个专门化,它可能会积累更多专门化,为了减少所需的重新编译量,Julia 会迅速放弃尝试优化处理具有未知元素类型容器的情况。如果您仔细观察,您会注意到一个剩余的优化:对 Main.f 的调用被类型断言为 Int,因为 f 的两种已知方法都返回一个 Int。对于使用 applyf 的输出的任何代码来说,这可能是一个非常有用的性能优化。但定义 f 的另外两个方法,

julia> f(::String) = 3
f (generic function with 4 methods)

julia> f(::Dict) = 4
f (generic function with 4 methods)

julia> @code_typed applyf(Any[true])
CodeInfo(
1 ─ %1 = Base.arrayref(true, container, 1)::Any
│   %2 = Main.f(%1)::Any
└──      return %2
) => Any

您会看到即使这种优化也被放弃了。这是因为检查许多函数以查看它们是否都返回 Int 是一个成本过高的操作,不值得在所有情况下都这样做。

总之,apply(::Vector{Any}) 已被编译三次:一次是在 f(::Int)f 的唯一方法时,一次是在有两个方法时,以及一次是在有四个方法时。如果 Julia 事先知道我们会走到这一步,它永远不会费心去编译那些中间版本;重新编译需要时间,我们希望如果绝对没有必要,就避免浪费这些时间。幸运的是,从现在开始,Julia 已经停止对 f 将有多少个方法进行假设,所以从现在开始,即使我们定义了 f 的更多方法,我们也不需要为 Vector{Any} 重新编译 applyf

重新编译由两个事件触发

本文将重点关注函数定义的时刻,它会触发其他先前编译的代码失效。

方法失效有多常见?

不幸的是,方法失效非常(或者更确切地说,曾经非常)常见。首先,让我们获取一些基线统计数据。使用 MethodAnalysis 包,您可以发现一个新的 Julia 会话在其缓存中大约有 50,000 个 MethodInstance(更准确地说,在 Julia 1.5 上大约有 56,000 个,在 Julia 1.6 上大约有 45,000 个)。这些大多用于 Base 和标准库。(还有一些额外的 MethodInstance 是为了加载 MethodAnalysis 包并进行此分析而创建的,但这些肯定只是总数中的一小部分。)

使用 SnoopCompile,我们可以统计将各种包加载到一个新的 Julia 会话中时触发的失效次数

版本# 失效,Julia 1.5# 失效,master 分支
示例0.5.300
Revise2.7.3230
FixedPointNumbers0.8.43350
StaticArrays0.12.4218144
Images0.22.42881301
Optim0.22.0290259
SIMD2.8.0294911
Plots1.5.83156118
Makie0.11.13273129
Flux0.10.43461x
DataFrames0.21.641262390
JuMP0.21.342811952
DifferentialEquations6.15.063732309

('x' 表示该包无法加载。Julia 的 master 分支结果来自提交 1c9c24170c53273832088431de99fe19247af172,这是 Julia 1.6 开发过程中的一个阶段。)

您可以看到,Julia 生态系统中大部分用户使用的关键包在历史上失效了数百或数千个 MethodInstance,有时甚至超过加载该包之前存在的 MethodInstance 总数的 10%。在 Julia 1.6 上,情况已经得到了显著改善,但仍然需要做更多工作,特别是对于某些特定的包来说。

方法失效的影响是什么?

下次您想要调用失效的功能时,您必须等待重新编译。我们可以在 Julia 1.5 上用大家最喜欢的例子(绘图)来演示这一点

julia> using Plots

julia> @time display(plot(rand(5)))
  7.717729 seconds (15.27 M allocations: 797.207 MiB, 3.59% gc time)

众所周知,第二次运行要快得多,因为它已经编译好了

julia> @time display(plot(rand(5)))
  0.311226 seconds (19.93 k allocations: 775.055 KiB)

此外,如果您决定要通过以下方式添加一些额外的功能

julia> using StaticArrays

julia> @time display(plot(rand(5)))
  0.305394 seconds (19.96 k allocations: 781.836 KiB)

您可以看到它实际上速度一样快。但是,StaticArrays 已经存在,因为它被 Plots 在内部加载了——它导致的所有失效都发生在我们发出第二个绘图命令之前。让我们将其与加载一个会进行大量失效的包时发生的情况进行对比(同样,这发生在 Julia 1.5 及更低版本上)

julia> using SIMD

julia> @time display(plot(rand(5)))
  7.238336 seconds (26.50 M allocations: 1.338 GiB, 7.88% gc time)

由于加载 SIMD 导致了如此多的失效,Julia 必须重新编译许多函数才能再次生成绘图,因此“第二次绘图时间”几乎与“第一次绘图时间”一样糟糕。

这不仅会影响绘图或包。例如,加载包可能会短暂地导致 REPL、下一个 Pkg 更新或下一个对分布式工作者的调用延迟几秒钟。

您可以通过在开始时加载所有包来最大限度地减少失效的一些成本——如果所有失效都在您开始编译太多代码之前发生,那么第一次编译就已经考虑了所有方法。

在 Julia 1.5 及更低版本中,包加载时间由于失效而大大增加。在 Julia 1.6 中,失效很少影响包加载时间,这主要是因为 Pkg 和加载代码已经变得更加抗失效。

可以并且应该修复它吗?

失效本身会影响延迟,但也会影响其他潜在的减少延迟策略。例如,julia 会创建“预编译”(*.ji)文件以加快包使用速度。目前,它会将类型推断但未保存到其预编译文件中的 本机代码。原则上,我们可以通过保存本机代码来大大减少延迟,因为这样可以完全消除进行任何编译的必要。然而,如果大多数代码最终失效,这种策略将是无效的。事实上,它可能会使延迟变得更糟糕,因为您正在做一些没有太大回报的工作(从磁盘加载更多内容)。但是,如果我们消除了大多数失效,那么我们可以预期从缓存本机代码中获得更多收益,因此如果社区消除了大多数失效,那么开发这种新功能将变得更有意义。

关于“是否可以修复失效”,这取决于目标。我们永远无法完全消除失效;正如上面的 applyf 示例所示,如果你想要兼顾良好的运行时性能和交互式使用,失效有时是必要的,而这种结合是 Julia 最棒的特性之一。真正的问题是是否存在 **不必要的** 失效,或者是否存在未被利用的策略来限制其影响。虽然上面的表格有力地证明了降低失效频率的可行性,但要更进一步,我们需要了解失效的常见来源以及可以采取哪些措施来修复它们。

Julia 中减少失效次数的更改

Julia 1.6 在减少失效方面取得的许多进展都来自于编译器中通用机制的改变。一个 关键的改变 是认识到,一些失效以前是由特定参数组合的 **模糊特异性** 的新方法触发的;因为使用这种参数组合调用函数会导致 MethodError,所以没有必要使它们的(名义上的)依赖项失效。

另一个 成对基本 改变导致了 Julia 如何专门化推断不佳的代码以反映当前世界的微妙但显著的改变。通过使 Julia 在这种情况下不太积极地进行专门化,大量的包生态系统变得更加健壮,可以抵御失效。

最后,正如 applyf 示例所示,从一开始就推断良好的版本 - 当我们传递 container = [100](一个 Vector{Int})时 - 从不需要失效;只有为 Vector{Any} 编译的那些 MethodInstance 需要更新。因此,我们减少失效的最后一个主要组成部分是改进 Julia 自身代码的可推断性。通过记录和分析失效,我们对 Julia 自身代码在可推断性方面的弱点有了更深入的了解。我们利用这些信息改进了几十种方法的实现,并在更多方法中添加了类型注释,以帮助 Julia 生成更好的代码。你可以通过在已关闭的拉取请求中搜索 延迟标签 来找到许多示例。

值得注意的是,改进代码的任务永远不会完成,这里也是如此。在解决这个问题的过程中,我们最终开发了许多新的工具来分析失效的来源并修复相应的推断问题。我们将在下面简要介绍最后一个主题。

用于分析和修复失效的工具

最近,SnoopCompile 包获得了分析失效并帮助开发者修复它们的能力。由于这些工具可能会随着时间的推移而改变,这篇博文只会在表面上进行介绍;想要帮助修复失效的人员建议阅读 SnoopCompile 的文档 以了解更多详情。还有一个 视频 提供了修复现实世界中失效的实时会话,这可能是一个有用的示例。

但是,为了让你了解一下,这里有两张截图。这些截图是在 Julia 的 REPL 中拍摄的,但你也可以在 vscode 或其他环境中使用这些工具。

首先,让我们看一下收集包数据的简单方法(它并不总是精确的,请参阅 SnoopCompile 的文档)

using SnoopCompile
trees = invalidation_trees(@snoopr using SIMD)

在 REPL 中看起来像这样

snoopr_simd

这会打印出触发失效(以黄色显示)的函数定义列表(以浅紫色显示)。简单来说,你可以通过子节点的数量来判断它们的“重要性”;子节点较多的那些排在最后。你可以使用 ascend 来更深入地了解究竟发生了什么以及后果是什么

snoopr_simd_ascend

SnoopCompile 文档 中包含有关如何浏览此交互式菜单以及如何解释结果以确定修复方法的信息,但简单来说,发生的事情是 deserialize_msg 创建了一个 Distributed.CallWaitMsg,其先验类型信息非常差(所有参数都被推断为 Any 类型);由于 CallWaitMsgargs 字段必须是 Tuple,并且由于推断不知道提供的参数已经是 Tuple,因此它调用 convert(Tuple, args) 以确保能够构建 CallWaitMsg 对象。但是 SIMD 定义了一个新的 convert(Tuple, v::Vec) 方法,它具有更高的特异性,因此加载 SIMD 包会触发所有依赖于特异性较低的方法 convert(Tuple, ::Any) 的失效。

通常情况下,有多种方法可以解决这个问题:我们可以删除 struct CallWaitMsg 定义中的 ::Tuple,因为如果对象已知具有正确的类型(如果我们删除了字段类型规范,该类型将为 Any,而 Any 并不严格),则没有必要调用 convert。或者,我们可以保留类型规范中的 ::Tuple,并利用我们拥有的外部知识,断言 argsdeserialize_msg 中调用 CallWaitMsg 时已经是 Tuple,从而告知 Julia 它可以跳过对 convert 的调用。这仅仅是为了让你了解修复失效所涉及的内容;更详细的描述可在 SnoopCompile 的文档视频 中找到。

但这还传达了一个重要的观点:大多数失效都来自推断不佳的代码,因此通过修复失效,你通常会在其他方面提高质量。Julia 的 性能提示 页面提供了大量有关避免不可推断代码的良好建议,在特定情况下(你可能比推断本身能够确定的类型信息更多),你可以通过添加类型断言来帮助推断。同样,最近合并到 Julia 的一些“延迟”拉取请求可能也是有启发性的示例。

总结

迄今为止进展的影响

这些以及其他改变在 Julia 1.6 开发过程中的累积影响是显而易见的。例如,在 Julia 1.5 中,我们有额外的延迟来加载下一个包(由于加载代码失效)以及在加载其他包时执行已编译的代码(如果它失效)。

julia> @time using Plots
  7.252560 seconds (13.27 M allocations: 792.701 MiB, 3.19% gc time)

julia> @time using Example
  0.698413 seconds (1.60 M allocations: 79.200 MiB, 4.90% gc time)

julia> @time display(plot(rand(5)))
  6.605240 seconds (11.02 M allocations: 568.698 MiB, 1.75% gc time)

julia> using SIMD

julia> @time display(plot(rand(5)))
  5.724359 seconds (16.36 M allocations: 825.491 MiB, 7.77% gc time)

相反,在 Julia 1.6 之前的当前开发分支(提交 1c9c24170c53273832088431de99fe19247af172)中,我们有

julia> @time using Plots
  3.133783 seconds (7.49 M allocations: 526.797 MiB, 6.90% gc time)

julia> @time using Example
  0.002499 seconds (2.69 k allocations: 193.344 KiB)

julia> @time display(plot(rand(5)))
  5.481113 seconds (9.71 M allocations: 562.839 MiB, 2.28% gc time)

julia> using SIMD

julia> @time display(plot(rand(5)))
  0.115085 seconds (395.11 k allocations: 22.646 MiB)

你可以看到第一次加载速度快得多,这部分原因是由于减少了失效(减少失效有助于防止 Julia 加载 Plots 所需的一系列包时,加载代码本身的顺序失效)。加载 Example 的效果清晰地表明了这一点,在 Julia 1.5 中,由于加载 Plots 触发的失效,这导致了 0.7 秒的重新编译;在 Julia 的 master 分支中,由于 Plots 没有使加载代码失效,因此这部分成本消失了。

同样,第二次调用 display(plot(...)) 速度快得多,因为加载 SIMD 没有广泛地使为绘图编译的关键代码失效。

也许最大的谜团是,为什么第一次使用速度更快?可能有多种原因

以最后两个为例,在 Julia 1.5 中,display(plot(...)) 调用期间最昂贵的推断调用(可以使用 SnoopCompile 的 @snoopi 宏测量)是 recipe_pipeline!(::Plots.Plot{Plots.GRBackend}, ::Dict{Symbol,Any}, ::Tuple{Array{Float64,1}})),它本身需要近 0.4 秒的推断时间。Plot 实际上试图预编译此语句,但它无法从结果中获益,因为此语句依赖的一些代码在加载 Plots 时会失效。在 Julia 的 master 分支中,由于预编译的代码仍然有效,因此不需要进行此推断。

还有另一种方法可以更全面地了解 Julia 的状态以及自 1.5 以来取得的进展:由于大多数失效都来自推断不佳的代码,因此我们可以分析所有现有的 MethodInstance 并确定哪些是针对“问题”签名推断的(例如,isequal(::Int, ::Any),而我们更希望第二个参数是一个具体类型)。除了对它们进行计数外,还值得注意的是有多少其他 MethodInstance 依赖于这些“不良” MethodInstance,因此如果我们恰好定义了正确(或错误)的新方法,它们将失效。下面是比较 Julia 1.5(顶部)和 master 分支(底部)的,针对每个有问题的推断签名进行依赖项计数的直方图

backedges

master 分支中非常大的零箱表明 Julia 1.5 上的许多问题签名甚至没有出现在 master 的预编译代码中。此图表明取得了很大进展,但尾部表明这项工作还没有结束。

未来展望

Julia 令人惊叹的灵活性以及出色的代码生成开辟了许多新的视野。这些优势也带来了一些成本,我们这里探讨了其中之一,即方法失效。虽然 Julia 的核心开发者很早就意识到了其成本,但我们现在才开始获得适合更广泛的用户和开发者群体的工具来对其进行分析。由于以前难以衡量,因此仍有许多改进的机会等待着被发现。特别是,虽然 Base Julia 及其标准库在很大程度上已经“免受”失效的影响,但许多包可能并非如此。人们希望 Julia 的下一个开发阶段可能会在使包能够优雅地协同工作而不相互踩踏方面取得重大进展。