这是关于包开发者如何降低 Julia 包的延迟的简短系列文章中的第二篇。在 第一篇文章 中,我们介绍了预编译及其一些限制。我们还指出,预编译与类型推断密切相关
预编译允许您缓存类型推断的结果,从而在您开始使用包中定义的方法时节省时间。
当更高比例的调用使用可推断的实参类型(即类型推断“成功”)进行时,缓存会变得更加有效。成功的推断会引入链接,以MethodInstance
反向边的形式,允许缓存整个调用图。
因此,任何对预编译感兴趣的人都需要关注类型推断:它占用了多少时间,它在哪里花费时间,以及如何改进缓存?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
@snoopi_deep
收集数据,则必须重新定义SnoopDemo
模块(通过重新执行我们用来定义它的命令)。这不会重新定义SnoopDemo
依赖的任何Base
方法,因此您仍然可能会看到在一个新会话中运行它与后续使用@snoopi_deep
之间的差异。这可能看起来不多,但在tinf
中隐藏着大量信息。虽然可选,但在继续之前,建议检查tinf
以获取提示,表明 失效 可能影响了结果
julia> staleinstances(tinf)
SnoopCompileCore.InferenceTiming[]
staleinstances
提取包含一些“陈旧”生成的代码(不再可调用的代码)的MethodInstances
。在本例中,它返回一个空列表,表示它没有找到任何陈旧的实例,这保证没有发生失效。幕后没有发生任何“奇怪”的事情会影响我们的结果。
@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)
我们还可以使用 ProfileView 或 PProf 包将这棵树显示为火焰图
julia> fg = flamegraph(tinf)
Node(FlameGraphs.NodeData(ROOT() at typeinfer.jl:75, 0x00, 0:10080857))
julia> using ProfileView
julia> ProfileView.view(fg)
您应该看到类似这样的东西
鼓励用户阅读 ProfileView 文档以了解如何解释这一点,但简而言之
水平轴是时间(宽框比窄框花费的时间更长),垂直轴是调用深度
将鼠标悬停在一个框上会显示正在推断的方法
左键单击一个框会导致完整的 MethodInstance 在您的 REPL 会话中打印出来
右键单击一个框会在您的编辑器中打开相应的方法(您必须适当地配置ENV["EDITOR"]
才能使此操作生效)
ctrl-单击可用于放大(您可以执行橡皮筋选择或放大/缩小)
空水平空间对应于除类型推断之外的活动;在本例中,相对较窄的火焰后面跟着大量的空空间,这表明所有类型推断都发生在开始时,并且只占总时间的很小一部分。
您可以探索此火焰图并将其与print_tree
的输出进行比较。
在不太琐碎的示例中,这些火焰图可能看起来更有趣
在这里,红色框对应于不能“自然”预编译的MethodInstances
。当方法属于一个模块,但实参类型来自另一个无关的模块时,就会发生这种情况。我们将在后面的章节中看到如何处理这些问题。
您还会看到火焰图中的断裂。在这些期间,代码生成和运行时会创建新的对象,然后调用这些对象上的方法;如果其中一个调用需要重新进入推断,则会触发新的火焰的创建。因此,不同火焰的数量(这等于length(tinf.children)
)为您提供了推断链中断频率的粗略指标。这可能是由类型不稳定性、在@snoopi_deep
期间来自 REPL 的单独顶层调用、代码中的eval
调用或导致 Julia 启动类型推断的其他原因造成的。
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 导航到火焰图视图,您应该看到以下内容
当然,如上所述,在本例中,推断只占总时间的一小部分。您可以通过单击节点放大节点,并将鼠标悬停在节点上将显示其完整文本和时间。放大仅推断时间的最佳方法是通过“隐藏”ROOT()
节点来过滤掉非推断时间。为此,请在Search regexp
栏中键入ROOT()
,然后单击 REFINE -> Hide,或将?h=ROOT
添加到 URL 中。如果有多个对推断的顶层调用,这将非常有效,如下所示。通过搜索栏隐藏和过滤的好处之一是,所有节点的百分比将重新计算为新总计的百分比。
在搜索栏中键入值(不按回车键)将突出显示它们在图形中的位置,如以下图像所示,按回车键将过滤图形以仅包含与您的搜索匹配的路径。这些工具可用于识别经常出现的、体积较小的函数,这些函数在聚合在一起时会贡献大量的时间。
以下是 Top 视图的屏幕截图,同样隐藏了ROOT()
(/top?h=ROOT()
)
有关使用 PProf 的更多详细信息超出了本文的范围,但这里有一些需要注意的事项
pprof 中的兄弟框架没有按时间排序,而是按字母顺序排序。上面的ProfileView
是一个更好的工具,可以了解计算过程中发生了什么。
PProf 非常适合交互式探索(可能非常大的)配置文件。它提供了对最大违规者的良好高级摘要,并且包含许多用于了解其详细信息的工具,包括source
视图、top
视图和过滤。
您可以通过out=
参数将配置文件导出到文件,该参数用于pprof()
,这些配置文件完全是自包含的,可以与合作者共享,以便他们使用 PProf 查看这些配置文件。
flatten
和accumulate_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
中求和。
本文档中描述的工具允许对类型推断花费时间的位置进行更深入的了解。有时,仅此信息就足以向您展示如何更改代码以减少延迟。但是,大多数减少延迟的努力可能需要利用其他工具来帮助确定主要的干预机会。这些工具将在以后的文章中介绍。