Julia 1.6 开发的主要重点之一是减少延迟,即从开始会话到完成有用工作之间的延迟。这有时被称为“首次绘图时间”,尽管它不仅仅适用于绘图。虽然 Julia 1.6 在减少延迟方面做了很多工作(并取得了成功),但用户和开发者自然会希望进一步缩短延迟。这是关于包开发者可以为用户减少延迟的主题的简短系列文章的首篇文章。本文特别介绍了背景材料——一些关键的基础概念和结构——希望这些材料在后续文章中有所帮助。
precompile
减少延迟Julia 的大部分延迟都是由于代码加载和编译造成的。Julia 的动态特性也使其容易受到失效的影响,以及随后需要重新编译之前编译的代码;此主题已在之前的博客文章中介绍过,这里不再赘述。在本系列文章中,假设失效不是延迟的主要来源。(您无需阅读之前的博客文章即可理解本文。)
简单来说,using SomePkg
加载类型和/或方法定义,之后调用SomePkg.f(args...)
会强制编译SomePkg.f
(如果尚未编译)以适应args...
中的特定类型。本系列文章的主要重点是探索减少编译成本的机会。我们将重点关注预编译,
julia> using SomePkg
[ Info: Precompiling SomePkg [12345678-abcd-9876-efab-1234abcd5e6f]
或在 Julia 1.6 上更新包后出现的相关Precompiling project...
输出。在预编译期间,Julia 会以高效的序列化形式写入模块、类型和方法定义。预编译在其最基本的形式下几乎是自动发生的,但通过一些手动干预,开发者也有机会保存更多信息:编译的部分结果,特别是编译的类型推断阶段。由于类型推断需要时间,这可以减少包中方法首次使用时的延迟。
为了激发本系列文章,让我们从一个简单的演示开始,在这个演示中,向包中添加一行代码会导致延迟降低五倍。我们将从一个可以用几行代码定义的包开始(这要归功于 Julia 的元编程功能),并且依赖很少的外部代码,但它被设计为具有可衡量的延迟。您可以将以下内容复制/粘贴到 Julia 的 REPL 中(请注意,它会在您的当前目录中创建一个名为DemoPkg
的包目录)
julia> using Pkg; Pkg.generate("DemoPkg")
Generating project DemoPkg:
DemoPkg/Project.toml
DemoPkg/src/DemoPkg.jl
Dict{String, Base.UUID} with 1 entry:
"DemoPkg" => UUID("4d70085e-4304-44c2-b3c3-070197146bfa")
julia> typedefs = join(["struct DemoType$i <: AbstractDemoType x::Int end; DemoType$i(d::AbstractDemoType) = DemoType$i(d.x)" for i = 0:1000], '\n');
julia> codeblock = join([" d = DemoType$i(d)" for i = 1:1000], '\n');
julia> open("DemoPkg/src/DemoPkg.jl", "w") do io
write(io, """
module DemoPkg
abstract type AbstractDemoType end
$typedefs
function f(x)
d = DemoType0(x)
$codeblock
return d
end
end
""")
end
执行此操作后,您可以打开DemoPkg.jl
文件以查看f
的实际外观。如果我们加载包,则第一次调用DemoPkg.f(5)
需要一些时间
julia> push!(LOAD_PATH, "DemoPkg/");
julia> using DemoPkg
julia> tstart = time(); DemoPkg.f(5); tend=time(); tend-tstart
0.28725290298461914
但第二次(在同一会话中)速度要快得多
julia> tstart = time(); DemoPkg.f(5); tend=time(); tend-tstart
0.0007619857788085938
第一次调用的额外成本是编译方法所花费的时间。我们可以通过预编译它并将结果保存到磁盘来节省一些时间。我们只需在模块定义中添加一行代码:或者
f(5)
,它在包被预编译时执行f
(请记住,执行会触发编译,后者才是我们的实际目标)
precompile(f, (Int,))
,如果我们不需要f(5)
的输出,而只想触发对Int
参数的f
的编译。
这里我们将选择precompile
julia> open("DemoPkg/src/DemoPkg.jl", "w") do io
write(io, """
module DemoPkg
abstract type AbstractDemoType end
$typedefs
function f(x)
d = DemoType0(x)
$codeblock
return d
end
precompile(f, (Int,)) # THE CRUCIAL ADDITION!
end
""")
end
现在开始一个新的会话,加载包(您需要再次使用push!(LOAD_PATH, "DemoPkg/")
),然后计时
julia> tstart = time(); DemoPkg.f(5); tend=time(); tend-tstart
0.056242942810058594
julia> tstart = time(); DemoPkg.f(5); tend=time(); tend-tstart
0.0007371902465820312
它并没有消除所有延迟,但仅为原来的五分之一,这在响应能力方面是一个重大改进。precompile
节省的编译时间的比例取决于类型推断和其他代码生成方面之间的平衡,而这又强烈依赖于代码的性质:“类型密集型”代码,例如此示例,通常似乎受推断支配,而“类型稀疏型”代码(例如,使用少量类型和操作进行大量数值计算的代码)往往受代码生成的其它方面支配。
虽然目前precompile
只能节省类型推断所花费的时间,但从长远来看,希望 Julia 也可以保存编译后期阶段的结果。如果发生这种情况,precompile
将产生更大的影响,并且节省的程度将不再那么依赖于类型推断和其他形式的代码生成之间的平衡。
这种魔法是如何运作的?在包预编译期间,Julia 会创建一个*.ji
文件,通常存储在.julia/compiled/v1.x/
中,其中1.x
是您的 Julia 版本。您的*.ji
文件存储常量、类型和方法的定义;这在构建包时会自动发生。可选地(如果您使用了precompile
指令,或在构建包时执行了方法),它还可以包含类型推断的结果。
框 1 您可能会自然地想知道,“precompile
是如何帮助的呢?它是否只是将编译成本转移到我加载包的时间?”答案是“否”,因为*.ji
文件不是您在定义模块时执行的所有步骤的记录:相反,它是这些步骤结果的快照。如果您定义一个包
module PackageThatPrints
println("This prints only during precompilation")
function __init__()
println("This prints every time the package is loaded")
end
end
您会发现短暂发生的事情不会“进入”预编译文件:第一个println
仅在您构建包时显示,而第二个在后续的using PackageThatPrints
中打印,即使这不需要重新构建包。
要“进入”预编译文件,语句必须与常量、类型、方法和其他持久代码结构相关联。__init__
函数很特殊,因为它如果存在,会在模块加载结束时自动调用。
precompile
指令在预编译期间运行,但与*.ji
文件相关的唯一内容是它产生的结果(编译后的代码)。已编译的对象(特别是下面描述的MethodInstance
)可能会写入*.ji
文件,当您加载包时,这些对象也会被加载。加载类型推断的结果确实需要一些时间,但通常比从头开始计算推断结果快得多。
现在我们已经介绍了precompile
的承诺,是时候承认这个主题很复杂了。您如何知道您的延迟有多少是由于类型推断造成的?此外,即使类型推断是延迟的主要来源,您仍然可能会发现自己处于难以消除其大部分成本的环境中。在以前的 Julia 版本中,这个事实导致了使用precompile
时出现了一些挫折。一个麻烦的来源是失效,它经常在早期 Julia 版本中“破坏”预编译,但这在 Julia 1.6 中得到了极大的改进(主要是在幕后,即无需包开发人员执行任何操作)。随着失效问题基本消除,预编译中最棘手的剩余方面是代码所有权:预编译的结果应该存储在哪里?当一段代码需要来自一个包或库的方法和来自另一个包或库的类型时,您(或 Julia)如何决定在哪里存储编译后的代码?
在这篇博文中,我们退后一步,开始深入了解内部原理。目标是理解为什么precompile
有时会带来显著的好处,为什么有时几乎没有任何好处,以及当它失败时如何挽救局面。为此,我们必须了解将 Julia 代码的各个部分联系在一起的“依赖链”中的一些内容。
我们将通过一个简单的演示介绍这些概念(鼓励用户尝试此操作并跟随操作)。首先,让我们打开 Julia REPL 并定义以下方法
double(x::Real) = 2x
calldouble(container) = double(container[1])
calldouble2(container) = calldouble(container)
calldouble2
调用calldouble
,后者对container
中的第一个元素调用double
。让我们创建一个container
对象并运行此代码
julia> c64 = [1.0]
1-element Vector{Float64}:
1.0
julia> calldouble2(c64) # running it compiles the methods for these types
2.0
现在,让我们简要地了解一些内部细节,以了解 Julia 编译器在准备运行该语句时做了什么。使用MethodAnalysis包最简单
julia> using MethodAnalysis
julia> mi = methodinstance(double, (Float64,))
MethodInstance for double(::Float64)
methodinstance
很像which
,只是它询问的是类型推断代码。我们要求methodinstance
查找一个为单个Float64
参数推断的double
实例;它返回了MethodInstance
而不是nothing
,这表明此实例已经存在——该方法已经为此参数类型进行了推断,因为我们运行了calldouble(c64)
,它间接调用了double(::Float64)
。如果您目前尝试methodinstance(double, (Int,))
,您应该得到nothing
,因为我们从未用Int
参数调用过double
。
类型推断的关键特性之一是它会跟踪依赖关系
julia> using AbstractTrees
julia> print_tree(mi)
MethodInstance for double(::Float64)
└─ MethodInstance for calldouble(::Vector{Float64})
└─ MethodInstance for calldouble2(::Vector{Float64})
这表明calldouble2(::Vector{Float64})
的类型推断结果依赖于calldouble(::Vector{Float64})
的结果,后者又依赖于double(::Float64)
。这应该是可以理解的:除非 Julia 理解其被调用者的行为,否则它无法知道calldouble2
返回什么类型。这是我们第一个依赖链的例子,它将成为理解 Julia 如何决定在哪里存储编译结果的关键组成部分。在编码此依赖链时,被调用者(例如,double
)会存储指向调用者(例如,calldouble
)的链接;因此,这些链接通常称为反向边。
框 2 反向边不仅适用于您自己编写的代码,而且可以跨模块链接代码。例如,要实现2x
,我们的double(::Float64)
调用*(::Int, ::Float64)
julia> mi = methodinstance(*, (Int, Float64))
MethodInstance for *(::Int64, ::Float64)
我们可以看到此实例来自哪个Method
julia> mi.def
*(x::Number, y::Number) in Base at promotion.jl:322
这在 Julia 自身的Base
模块中定义。如果我们运行了calldouble2(c64)
,我们自己的double
将被列为其反向边之一
julia> direct_backedges(mi)
5-element Vector{Core.MethodInstance}:
MethodInstance for parse_inf(::Base.TOML.Parser, ::Int64)
MethodInstance for init(::Int64, ::Float64)
MethodInstance for show_progress(::IOContext{IOBuffer}, ::Pkg.MiniProgressBars.MiniProgressBar)
MethodInstance for show_progress(::IO, ::Pkg.MiniProgressBars.MiniProgressBar)
MethodInstance for double(::Float64)
direct_backedges
顾名思义,返回已编译的直接调用者的列表。(all_backedges
返回直接和间接调用者。)您在此处获得的特定列表可能取决于您已加载的其他包,并且
julia> print_tree(mi)
MethodInstance for *(::Int64, ::Float64)
├─ MethodInstance for parse_inf(::Parser, ::Int64)
│ └─ MethodInstance for parse_number_or_date_start(::Parser)
│ └─ MethodInstance for parse_value(::Parser)
│ ├─ MethodInstance for parse_entry(::Parser, ::Dict{String, Any})
│ │ ├─ MethodInstance for parse_inline_table(::Parser)
│ │ │ ⋮
│ │ │
│ │ └─ MethodInstance for parse_toplevel(::Parser)
│ │ ⋮
│ │
│ └─ MethodInstance for parse_array(::Parser)
│ └─ MethodInstance for parse_value(::Parser)
│ ⋮
│
├─ MethodInstance for init(::Int64, ::Float64)
│ └─ MethodInstance for __init__()
├─ MethodInstance for show_progress(::IOContext{IOBuffer}, ::MiniProgressBar)
│ └─ MethodInstance for (::var"#59#63"{Int64, Bool, MiniProgressBar, Bool, PackageSpec})(::IOContext{IOBuffer})
├─ MethodInstance for show_progress(::IO, ::MiniProgressBar)
└─ MethodInstance for double(::Float64)
└─ MethodInstance for calldouble(::Vector{Float64})
└─ MethodInstance for calldouble2(::Vector{Float64})
如果加载并使用了执行大量计算的大型包,则可能非常复杂。
方框 3 通常,反向边的集合是一个图,而不是一棵树:在实际代码中,f
可能调用自身(例如,fibonacci(n) = fibonacci(n-1) + fibonacci(n-2)
),或者 f
可能调用 g
,而 g
又调用 f
。在跟随反向边时,MethodAnalysis 会省略之前出现过的 MethodInstances
,从而执行图的“搜索”。此搜索模式的结果可以可视化为一棵树。
类型推断的行为类似:它会缓存其结果,因此每个 MethodInstance
只推断一次。(一个细节是常量传播,它可能导致为不同的常量值重新推断相同的 MethodInstance
。)因此,推断也执行调用图的深度优先搜索。
反向边的创建比乍一看要微妙得多。为了开始了解一些复杂性,首先要注意,目前这些是这些方法的唯一推断实例
julia> methodinstances(double)
1-element Vector{Core.MethodInstance}:
MethodInstance for double(::Float64)
julia> methodinstances(calldouble)
1-element Vector{Core.MethodInstance}:
MethodInstance for calldouble(::Vector{Float64})
julia> methodinstances(calldouble2)
1-element Vector{Core.MethodInstance}:
MethodInstance for calldouble2(::Vector{Float64})
虽然 methodinstance(f, typs)
返回一个特定的 MethodInstance
,但 methodinstances(f)
返回 f
的所有推断实例。
让我们看看是否可以使 Julia 添加一些额外的实例:让我们创建一个新的容器,但这次我们将使用一个具有抽象元素类型的容器,以便 Julia 的类型推断无法准确预测容器中元素的类型。我们的容器的元素类型将是 AbstractFloat
,一个具有多个子类型的抽象类型;每个实际实例都必须具有一个具体类型,并且为了确保它是一个新类型(触发新的编译),我们将使用 Float32
julia> cabs = AbstractFloat[1.0f0] # store a `Float32` inside a `Vector{AbstractFloat}`
1-element Vector{AbstractFloat}:
1.0f0
julia> calldouble2(cabs) # compile for these new types
2.0f0
现在让我们看看可用的实例
julia> mis = methodinstances(double)
3-element Vector{Core.MethodInstance}:
MethodInstance for double(::Float64)
MethodInstance for double(::AbstractFloat)
MethodInstance for double(::Float32)
我们看到 double
有三个类型推断实例:一个用于 Float64
,一个用于 Float32
,一个用于 AbstractFloat
。让我们检查每个实例的反向边
julia> print_tree(mis[1])
MethodInstance for double(::Float64)
└─ MethodInstance for calldouble(::Vector{Float64})
└─ MethodInstance for calldouble2(::Vector{Float64})
julia> print_tree(mis[2])
MethodInstance for double(::AbstractFloat)
julia> print_tree(mis[3])
MethodInstance for double(::Float32)
为什么第一个有到 calldouble
然后到 calldouble2
的反向边,而后面两个没有?此外,为什么 calldouble
的每个实例都有到 calldouble2
的反向边
julia> mis = methodinstances(calldouble)
2-element Vector{Core.MethodInstance}:
MethodInstance for calldouble(::Vector{Float64})
MethodInstance for calldouble(::Vector{AbstractFloat})
julia> print_tree(mis[1])
MethodInstance for calldouble(::Vector{Float64})
└─ MethodInstance for calldouble2(::Vector{Float64})
julia> print_tree(mis[2])
MethodInstance for calldouble(::Vector{AbstractFloat})
└─ MethodInstance for calldouble2(::Vector{AbstractFloat})
这似乎与某些 double
实例缺少到 calldouble
的反向边的事实相矛盾?这里的结果反映了具体类型推断的成功或失败。与 Float64
和 Float32
相比,AbstractFloat
不是一个具体类型
julia> isconcretetype(Float32)
true
julia> isconcretetype(AbstractFloat)
false
一些读者可能会惊讶地发现 Vector{AbstractFloat}
是具体的
julia> isconcretetype(Vector{Float32})
true
julia> isconcretetype(Vector{AbstractFloat})
true
容器是具体的——它在内存中具有完全指定的存储方案和布局——即使元素不是。
AbstractVector{AbstractFloat}
是抽象的还是具体的?AbstractVector{Float32}
呢?使用 isconcretetype
检查你的答案。要更深入地了解具体性和推断的影响,一个有用的工具是 @code_warntype
。你可以看到 c64
和 cabs
之间的区别,尤其是在你自己在 REPL 中运行此代码时,你可以在那里看到红色突出显示
julia> @code_warntype calldouble2(c64)
Variables
#self#::Core.Const(calldouble2)
container::Vector{Float64}
Body::Float64
1 ─ %1 = Main.calldouble(container)::Float64
└── return %1
julia> @code_warntype calldouble2(cabs)
Variables
#self#::Core.Const(calldouble2)
container::Vector{AbstractFloat}
Body::Any
1 ─ %1 = Main.calldouble(container)::Any
└── return %1
请注意,只有返回类型(::Float64
与 ::Any
)在这两者之间有所不同;这就是 calldouble
在这两种情况下都具有到 calldouble2
的反向边的原因,因为在这两种情况下,都可以成功推断出特定的调用者/被调用者链。真正大的差异出现在下一层
julia> @code_warntype calldouble(c64)
Variables
#self#::Core.Const(calldouble)
container::Vector{Float64}
Body::Float64
1 ─ %1 = Base.getindex(container, 1)::Float64
│ %2 = Main.double(%1)::Float64
└── return %2
julia> @code_warntype calldouble(cabs)
Variables
#self#::Core.Const(calldouble)
container::Vector{AbstractFloat}
Body::Any
1 ─ %1 = Base.getindex(container, 1)::AbstractFloat
│ %2 = Main.double(%1)::Any
└── return %2
在第一种情况下,getindex
保证返回 Float64
,但在第二种情况下,它只知道是 AbstractFloat
。此外,类型推断无法预测 double(::AbstractFloat)
返回值的具体类型,尽管它可以预测 double(::Float64)
的返回值。因此,使用 ::AbstractFloat
的调用是通过运行时分派进行的,其中执行暂停,Julia 请求对象的具体类型,然后它对 double
进行适当的调用(在 cabs[1]
的情况下,对 double(::Float32)
进行调用)。
为了完整起见,如果我们添加另一个具有具体 eltype 的容器会发生什么?
julia> c32 = [1.0f0]
1-element Vector{Float32}:
1.0
julia> calldouble2(c32)
2.0f0
julia> mis = methodinstances(double)
3-element Vector{Core.MethodInstance}:
MethodInstance for double(::Float64)
MethodInstance for double(::AbstractFloat)
MethodInstance for double(::Float32)
julia> print_tree(mis[1])
MethodInstance for double(::Float64)
└─ MethodInstance for calldouble(::Vector{Float64})
└─ MethodInstance for calldouble2(::Vector{Float64})
julia> print_tree(mis[2])
MethodInstance for double(::AbstractFloat)
julia> print_tree(mis[3])
MethodInstance for double(::Float32)
└─ MethodInstance for calldouble(::Vector{Float32})
└─ MethodInstance for calldouble2(::Vector{Float32})
因此,现在 double
的两个具体推断版本都一直链接回 calldouble2
,但前提是容器的元素类型也是具体的。一个 MethodInstance
可以被多个 MethodInstance
调用,但最常见的情况是,只有当调用可以推断时才会创建反向边。
练习 2 Julia 是否会为抽象类型编译方法并引入反向边?启动一个新的会话,而不是使用上面的定义,使用@nospecialize
定义 double
double(@nospecialize(x::Real)) = 2x
现在比较使用 c64
和 cabs
获取的反向边类型。在尝试这两种不同的容器类型之间退出你的会话并重新启动可能最具信息量。你会发现,当涉及到专门化时,Julia 确实是一个机会主义者!
让我们将上面的示例转换为一个包
julia> using Pkg; Pkg.generate("BackedgeDemo")
Generating project BackedgeDemo:
BackedgeDemo/Project.toml
BackedgeDemo/src/BackedgeDemo.jl
Dict{String, Base.UUID} with 1 entry:
"BackedgeDemo" => UUID("35dad884-25a6-48ad-b13b-11b63ee56c40")
julia> open("BackedgeDemo/src/BackedgeDemo.jl", "w") do io
write(io, """
module BackedgeDemo
double(x::Real) = 2x
calldouble(container) = double(container[1])
calldouble2(container) = calldouble(container)
precompile(calldouble2, (Vector{Float32},))
precompile(calldouble2, (Vector{Float64},))
precompile(calldouble2, (Vector{AbstractFloat},))
end
""")
end
282
你可以看到我们创建了一个包并定义了这三个方法。至关重要的是,我们还添加了三个 precompile
指令,所有这些指令都针对顶级 calldouble2
。我们没有为其被调用者 calldouble
、double
或 double
所需的任何内容(如实现 2*x
的 *
)添加任何显式的 precompile
指令。
现在让我们加载此包并查看我们是否有任何 MethodInstance
julia> push!(LOAD_PATH, "BackedgeDemo/")
4-element Vector{String}:
"@"
"@v#.#"
"@stdlib"
"BackedgeDemo/"
julia> using BackedgeDemo
[ Info: Precompiling BackedgeDemo [44c70eed-03a3-46c0-8383-afc033fb6a27]
julia> using MethodAnalysis
julia> methodinstances(BackedgeDemo.double)
3-element Vector{Core.MethodInstance}:
MethodInstance for double(::Float32)
MethodInstance for double(::Float64)
MethodInstance for double(::AbstractFloat)
万岁!即使我们没有在本会话中使用此代码,类型推断的 MethodInstance
也已经存在!(这仅在我们使用这些 precompile
指令的情况下才成立。)你还可以验证是否创建了与我们在上面交互式运行此代码时相同的反向边。我们已成功保存了类型推断的结果。
这些 MethodInstance
已缓存在 BackedgeDemo.ji
中。值得注意的是,即使 precompile
指令是从此包发出的,其他包或库中定义的方法的 MethodInstances
也可以保存。例如,Julia 没有预先构建 Int * Float32
的推断代码:在一个新的会话中,
julia> using MethodAnalysis
julia> mi = methodinstance(*, (Int, Float32))
返回 nothing
(MethodInstance
不存在),而如果我们已加载 BackedgeDemo
,则
julia> mi = methodinstance(*, (Int, Float32))
MethodInstance for *(::Int64, ::Float32)
julia> mi.def # what Method is this MethodInstance from?
*(x::Number, y::Number) in Base at promotion.jl:322
因此,即使该方法是在 Base
中定义的,但由于 BackedgeDemo
需要此类型推断代码,因此它被存储在 BackedgeDemo.ji
中。
这太棒了,因为它意味着可以保存类型推断的完整结果,即使它们跨越包和库之间的边界。然而,此存储其他模块 MethodInstance
的能力存在重大限制。最重要的是,*.ji
文件只能保存它们“拥有”的代码,即
对于在包中定义的方法
通过反向边链到包中定义的方法
练习 3 要查看此限制在实际中的作用,请从 BackedgeDemo.jl
中删除 precompile(calldouble2, (Vector{Float32},))
指令,以便它只有
precompile(calldouble2, (Vector{Float64},))
precompile(calldouble2, (Vector{AbstractFloat},))
但随后添加
precompile(*, (Int, Float32))
以尝试强制推断该方法。
启动一个新的会话并加载包(它应该再次预编译),并检查 methodinstance(*, (Int, Float32))
是否返回 MethodInstance
或 nothing
。还对 methodinstances(BackedgeDemo.double)
中每个项目的返回值运行 print_tree
。
在没有“所有权链”到 BackedgeDemo
的情况下,Julia 不知道将 precompile
创建的 MethodInstance
存储在哪里;这些 MethodInstance
会被创建,但不会合并到 *.ji
文件中,因为没有特定的模块拥有的 MethodInstance
与它们链接。因此,我们不能自行预编译其他模块中定义的方法;只有当这些方法通过反向边链接到此包时,我们才能这样做。
在实践中,这意味着即使包添加了 precompile
指令,如果存在大量类型推断失败,结果也可能非常不完整,由此产生的节省可能很小。
测验 向 BackedgeDemo
添加一个新类型
export SCDType
struct SCDType end
以及 Base.push!
的预编译指令
precompile(push!, (Vector{SCDType}, SCDType))
现在加载包并检查相应的 MethodInstance
是否存在。如果没有,你能想到一种方法将该 MethodInstance
添加到 *.ji
文件中吗?
答案在本文的底部.
方框 4 precompile
也可以传递一个完整的 Tuple
类型:precompile(calldouble2, (Vector{AbstractFloat},))
可以改写为
precompile(Tuple{typeof(calldouble2), Vector{AbstractFloat}})
如果 precompile
指令由检查 MethodInstance
的代码发出,则此形式经常出现,因为此签名位于 MethodInstance
的 specType
字段中
julia> mi = methodinstance(BackedgeDemo.double, (AbstractFloat,))
MethodInstance for double(::AbstractFloat)
julia> mi.specTypes
Tuple{typeof(BackedgeDemo.double), AbstractFloat}
方框 5 我们尚未讨论的另一个主题是,当 precompile
失败时,历史上(在 Julia 1.7 之前)它几乎是静默地失败的
julia> methods(double)
# 1 method for generic function "double":
[1] double(x::Real) in BackedgeDemo at /tmp/BackedgeDemo/src/BackedgeDemo.jl:3
julia> precompile(double, (String,))
false
即使 double
无法为 String
编译,相应的 precompile
也不会报错,它只会返回 false
。Julia 1.7 将警告不活动的预编译指令。
在本教程中,我们学习了 MethodInstance
、反向边、推断和预编译。一些重要的要点是
你可以使用显式的 precompile
指令存储类型推断的结果
为了有用,precompile
必须能够建立到某个包的所有权链
当类型推断成功时,所有权链更大且更完整
一个重要的结论是,当类型推断成功时,预编译效果更好。对于某些包,投入时间改进可推断性可以使你的 precompile
指令工作得更好。
未来的部分将重点介绍一些强大的新工具
用于衡量推断如何花费时间的工具
用于帮助做出关于(反)专门化决策的工具
用于检测和修复推断失败的工具
用于生成有效的 precompile
指令的工具
敬请期待!
测验答案 直接预编译 push!(::Vector{SCDType}, ::SCDType)
会失败,因为虽然你的包“拥有”SCDType
,但它不拥有 push!
的方法。
但是,如果你添加一个调用 push!
的方法,然后预编译它,
dopush() = push!(SCDType[], SCDType())
precompile(dopush, ())
则 push!(::Vector{SCDType}, ::SCDType)
的 MethodInstance
将通过到 dopush
(你拥有的)的反向边添加到包中。
这是一个人为的例子,但在更典型的情况下,这会通过包的功能自然发生。但同样,这仅适用于可推断的调用。