类型推断分析

2021 年 1 月 28 日 | Tim Holy 和 Nathan Daly

这是关于包开发者如何降低 Julia 包的延迟的简短系列文章中的第二篇。在 第一篇文章 中,我们介绍了预编译及其一些限制。我们还指出,预编译与类型推断密切相关

因此,任何对预编译感兴趣的人都需要关注类型推断:它占用了多少时间,它在哪里花费时间,以及如何改进缓存?Julia 本身提供了“监视”推断所需的内部基础设施,用户空间实用程序位于 SnoopCompile 包中。Julia 1.2 提供了有限的“监视”推断的功能,这种基础设施对应于 SnoopCompile 的@snoopi宏。Julia 1.6 包含一些新的更改,这些更改允许更深入地了解推断正在做什么。SnoopCompile 非常恰当地将此称为@snoopi_deep

@snoopi_deep 收集的丰富数据可用于多种不同的目的。在这篇文章中,我们将描述基本的工具,并展示如何使用它来分析推断。后面的文章将展示其他使用数据来减少类型推断量或缓存其结果的方法。

收集数据

要查看@snoopi_deep 的实际应用,我们将使用以下演示

module SnoopDemo
    struct MyType{T} x::T end

    extract(y::MyType) = y.x

    function domath(x)
        y = x + x
        return y*x + 2*x + 5
    end

    dostuff(y) = domath(extract(y))

    function domath_with_mytype(x)
        y = MyType(x)
        return dostuff(y)
    end
end

主要调用domath_with_mytype将输入存储在一个struct中,然后调用提取字段值并对结果执行算术运算的函数。要分析此调用的推断,我们只需执行以下操作

julia> using SnoopCompile

julia> tinf = @snoopi_deep SnoopDemo.domath_with_mytype(1)
InferenceTimingNode: 0.009382/0.010515 on InferenceFrameInfo for Core.Compiler.Timings.ROOT() with 1 direct children
框 1 推断仅在使用这些特定类型的第一次调用方法时被调用。如果您想在第二次使用相同代码时使用@snoopi_deep 收集数据,则必须重新定义SnoopDemo 模块(通过重新执行我们用来定义它的命令)。这不会重新定义SnoopDemo 依赖的任何Base 方法,因此您仍然可能会看到在一个新会话中运行它与后续使用@snoopi_deep 之间的差异。

这可能看起来不多,但在tinf 中隐藏着大量信息。虽然可选,但在继续之前,建议检查tinf 以获取提示,表明 失效 可能影响了结果

julia> staleinstances(tinf)
SnoopCompileCore.InferenceTiming[]

staleinstances 提取包含一些“陈旧”生成的代码(不再可调用的代码)的MethodInstances。在本例中,它返回一个空列表,表示它没有找到任何陈旧的实例,这保证没有发生失效。幕后没有发生任何“奇怪”的事情会影响我们的结果。

框 2 与 SnoopCompile 导出的另一个宏@snoopr 不同,staleinstances 不区分这些陈旧代码是在您运行@snoopi_deep 块之前存在的,还是在运行该块期间失效的。对于失效的详细分析,@snoopr 是推荐的工具。

检查输出

在这篇文章中,我们将坚持检查@snoopi_deep 收集的数据的基础知识。首先,请注意,输出是一个InferenceTimingNode:它是此类节点树的根元素,所有节点都通过调用者-被调用者关系连接在一起。事实上,这个特定的节点是针对Core.Compiler.Timings.ROOT() 的,这是一个“虚拟”节点,它是所有此类树的根。

您可能已经注意到,此ROOT 节点打印了两个数字。如果我们首先显示整个树,那么它们的含义将更容易理解。我们可以使用 AbstractTrees 包来完成此操作

julia> using AbstractTrees

julia> print_tree(tinf)
InferenceTimingNode: 0.009382/0.010515 on InferenceFrameInfo for Core.Compiler.Timings.ROOT() with 1 direct children
└─ InferenceTimingNode: 0.000355/0.001133 on InferenceFrameInfo for Main.SnoopDemo.domath_with_mytype(::Int64) with 3 direct children
   ├─ InferenceTimingNode: 0.000122/0.000254 on InferenceFrameInfo for MyType(::Int64) with 1 direct children
   │  └─ InferenceTimingNode: 0.000132/0.000132 on InferenceFrameInfo for MyType{Int64}(::Int64) with 0 direct children
   ├─ InferenceTimingNode: 0.000071/0.000071 on InferenceFrameInfo for MyType(::Int64) with 0 direct children
   └─ InferenceTimingNode: 0.000122/0.000453 on InferenceFrameInfo for Main.SnoopDemo.dostuff(::MyType{Int64}) with 2 direct children
      ├─ InferenceTimingNode: 0.000083/0.000161 on InferenceFrameInfo for Main.SnoopDemo.extract(::MyType{Int64}) with 2 direct children
      │  ├─ InferenceTimingNode: 0.000045/0.000045 on InferenceFrameInfo for getproperty(::MyType{Int64}, ::Symbol) with 0 direct children
      │  └─ InferenceTimingNode: 0.000034/0.000034 on InferenceFrameInfo for getproperty(::MyType{Int64}, x::Symbol) with 0 direct children
      └─ InferenceTimingNode: 0.000170/0.000170 on InferenceFrameInfo for Main.SnoopDemo.domath(::Int64) with 0 direct children

这种树结构揭示了调用者-被调用者关系,显示了每个MethodInstance 使用的具体类型。事实上,正如对getproperty 的调用所揭示的那样,它超越了类型,甚至显示了 常量传播 的结果;getproperty(::MyType{Int64}, x::Symbol)(注意x::Symbol 而不是简单的::Symbol)意味着调用是getproperty(y, :x),这对应于extract 定义中的y.x

框 3 通常,我们谈论的是 调用图 而不是调用树。但由于推断结果被缓存(即,我们只“访问”每个节点一次),因此我们获得了作为完整调用图的深度优先搜索的树。

请记住,这有时会导致尝试改进推断时间时出现令人惊讶的结果。您可能会删除图中的一个节点,期望消除它及其所有子节点,但发现子节点现在位于一个不同的父节点下,这是下一个要推断的调用您的子节点的函数,并且之前依赖于其被缓存。

此树中的每个节点都带有一对数字。第一个数字是独占推断时间(以秒为单位),表示花费在推断特定 MethodInstance 上的时间,不包括花费在推断其被调用者上的时间。第二个数字是包含时间,它是独占时间加上花费在被调用者上的时间。因此,包含时间始终至少与独占时间一样大。

ROOT 节点有点不同:它的独占时间衡量的是花费在推断之外的所有操作上的时间。在本例中,我们看到整个调用大约需要 10 毫秒,其中 9.3 毫秒花费在除了推断之外的活动上。几乎所有这些都是代码生成,但也包括运行代码所需的时间。仅需 0.76 毫秒即可运行整个调用序列的类型推断。正如@snoopi_deep 的用户很快就会发现的那样,推断在更复杂的代码上花费的时间要长得多。

您可以使用以下方法提取正在每个节点推断的MethodInstance

julia> Core.MethodInstance(tinf)
MethodInstance for ROOT()

julia> Core.MethodInstance(tinf.children[1])
MethodInstance for domath_with_mytype(::Int64)

可视化输出

我们还可以使用 ProfileViewPProf 包将这棵树显示为火焰图

ProfileView.jl

julia> fg = flamegraph(tinf)
Node(FlameGraphs.NodeData(ROOT() at typeinfer.jl:75, 0x00, 0:10080857))

julia> using ProfileView

julia> ProfileView.view(fg)

您应该看到类似这样的东西

flamegraph

鼓励用户阅读 ProfileView 文档以了解如何解释这一点,但简而言之

您可以探索此火焰图并将其与print_tree 的输出进行比较。

在不太琐碎的示例中,这些火焰图可能看起来更有趣

flamegraph-complex

在这里,红色框对应于不能“自然”预编译的MethodInstances。当方法属于一个模块,但实参类型来自另一个无关的模块时,就会发生这种情况。我们将在后面的章节中看到如何处理这些问题。

您还会看到火焰图中的断裂。在这些期间,代码生成和运行时会创建新的对象,然后调用这些对象上的方法;如果其中一个调用需要重新进入推断,则会触发新的火焰的创建。因此,不同火焰的数量(这等于length(tinf.children))为您提供了推断链中断频率的粗略指标。这可能是由类型不稳定性、在@snoopi_deep 期间来自 REPL 的单独顶层调用、代码中的eval 调用或导致 Julia 启动类型推断的其他原因造成的。

PProf.jl

julia> fg = flamegraph(tinf)
Node(FlameGraphs.NodeData(ROOT() at typeinfer.jl:75, 0x00, 0:10080857))

julia> using PProf

julia> pprof(fg)
Serving web UI on http://localhost:57599

有关如何使用 PProf 的详细演练,请参阅 PProf.jl 自述文件,以及有关更完整指南的 google/pprof web interface 自述文件

我们发现,检查推断配置文件最实用的视图是/flamegraph/source 视图。如果您通过 VIEW -> Flame Graph 导航到火焰图视图,您应该看到以下内容

PProf Flamegraph in full

当然,如上所述,在本例中,推断只占总时间的一小部分。您可以通过单击节点放大节点,并将鼠标悬停在节点上将显示其完整文本和时间。放大仅推断时间的最佳方法是通过“隐藏”ROOT() 节点来过滤掉非推断时间。为此,请在Search regexp 栏中键入ROOT(),然后单击 REFINE -> Hide,或将?h=ROOT 添加到 URL 中。如果有多个对推断的顶层调用,这将非常有效,如下所示。通过搜索栏隐藏和过滤的好处之一是,所有节点的百分比将重新计算为新总计的百分比。

在搜索栏中键入值(不按回车键)将突出显示它们在图形中的位置,如以下图像所示,按回车键将过滤图形以包含与您的搜索匹配的路径。这些工具可用于识别经常出现的、体积较小的函数,这些函数在聚合在一起时会贡献大量的时间。

PProf Flamegraph filtered and highlighted

以下是 Top 视图的屏幕截图,同样隐藏了ROOT()/top?h=ROOT()

PProf Top view

有关使用 PProf 的更多详细信息超出了本文的范围,但这里有一些需要注意的事项

基本分析:flattenaccumulate_by_source

作为本文的最后一步,让我们将数据提取为列表

julia> flatten(tinf)
10-element Vector{SnoopCompileCore.InferenceTiming}:
 InferenceTiming: 0.000034/0.000034 on InferenceFrameInfo for getproperty(::Main.SnoopDemo.MyType{Int64}, x::Symbol)
 InferenceTiming: 0.000045/0.000045 on InferenceFrameInfo for getproperty(::Main.SnoopDemo.MyType{Int64}, ::Symbol)
 InferenceTiming: 0.000071/0.000071 on InferenceFrameInfo for Main.SnoopDemo.MyType(::Int64)
 InferenceTiming: 0.000083/0.000161 on InferenceFrameInfo for Main.SnoopDemo.extract(::Main.SnoopDemo.MyType{Int64})
 InferenceTiming: 0.000122/0.000453 on InferenceFrameInfo for Main.SnoopDemo.dostuff(::Main.SnoopDemo.MyType{Int64})
 InferenceTiming: 0.000122/0.000254 on InferenceFrameInfo for Main.SnoopDemo.MyType(::Int64)
 InferenceTiming: 0.000132/0.000132 on InferenceFrameInfo for Main.SnoopDemo.MyType{Int64}(::Int64)
 InferenceTiming: 0.000170/0.000170 on InferenceFrameInfo for Main.SnoopDemo.domath(::Int64)
 InferenceTiming: 0.000355/0.001133 on InferenceFrameInfo for Main.SnoopDemo.domath_with_mytype(::Int64)
 InferenceTiming: 0.009382/0.010515 on InferenceFrameInfo for Core.Compiler.Timings.ROOT()

默认情况下,这会按独占时间对节点进行排序,但 flatten(tinf; sortby=inclusive) 允许您按包含时间排序。最后,flatten(tinf; sortby=nothing) 会按照推断节点的顺序返回它们。

有时,您可能会为许多不同的特定参数类型推断出相同的方法,有时您可能想了解聚合成本。

julia> accumulate_by_source(flatten(tinf))
8-element Vector{Tuple{Float64, Union{Method, Core.MethodInstance}}}:
 (7.838100000000001e-5, getproperty(x, f::Symbol) in Base at Base.jl:33)
 (8.2955e-5, extract(y::Main.SnoopDemo.MyType) in Main.SnoopDemo at REPL[1]:4)
 (0.000121738, dostuff(y) in Main.SnoopDemo at REPL[1]:11)
 (0.000132328, Main.SnoopDemo.MyType{T}(x) where T in Main.SnoopDemo at REPL[1]:2)
 (0.000170205, domath(x) in Main.SnoopDemo at REPL[1]:6)
 (0.000193107, Main.SnoopDemo.MyType(x::T) where T in Main.SnoopDemo at REPL[1]:2)
 (0.000354527, domath_with_mytype(x) in Main.SnoopDemo at REPL[1]:13)
 (0.009381595, ROOT() in Core.Compiler.Timings at compiler/typeinfer.jl:75)

这显示了每个 Method 的成本,在所有特定 MethodInstance 中求和。

摘要

本文档中描述的工具允许对类型推断花费时间的位置进行更深入的了解。有时,仅此信息就足以向您展示如何更改代码以减少延迟。但是,大多数减少延迟的努力可能需要利用其他工具来帮助确定主要的干预机会。这些工具将在以后的文章中介绍。