8

我正在尝试测试 Julia ODE 求解器的速度。我在教程中使用了洛伦兹方程:

using DifferentialEquations
using Plots
function lorenz(t,u,du)
du[1] = 10.0*(u[2]-u[1])
du[2] = u[1]*(28.0-u[3]) - u[2]
du[3] = u[1]*u[2] - (8/3)*u[3]
end

u0 = [1.0;1.0;1.0]
tspan = (0.0,100.0)
prob = ODEProblem(lorenz,u0,tspan)
sol = solve(prob,reltol=1e-8,abstol=1e-8,saveat=collect(0:0.01:100))

一开始加载包大约需要 25 秒,而代码在 Jupyter 笔记本的 Windows 10 四核笔记本电脑上运行了 7 秒。我知道 Julia 需要先预编译包,这就是加载时间这么长的原因吗?我发现 25 秒无法忍受。此外,当我使用不同的初始值再次运行求解器时,运行时间(~1s)要少得多,这是为什么呢?这是典型的速度吗?

4

1 回答 1

22

Tl;博士:

  1. Julia 包有一个预编译阶段。这有助于使所有进一步using的调用更快,但第一个调用会存储一些编译数据。这仅在每次包更新时触发。
  2. using必须拉入需要一点时间的包(取决于可以预编译多少)。
  3. 预编译不是“完整的”,所以当你第一次运行一个函数时,即使是从一个包中,它也必须编译。
  4. Julia 开发人员知道这一点,并且已经计划通过使预编译更完整来摆脱 (2) 和 (3)。还有计划减少编译时间,我不知道细节。
  5. 所有 Julia 函数都专门针对给定的类型,并且每个函数都是一个单独的类型,因此 DiffEq 的内部函数专门针对您给定的每个 ODE 函数。
  6. 在大多数需要长时间计算的情况下,(5) 实际上并不重要,因为您不会经常更改函数(如果是,请考虑更改参数)。
  7. 但是(6)在交互使用时确实很重要。它使它感觉不那么“平滑”。
  8. 我们可以摆脱 ODE 函数的这种特殊化,但它不是默认的,因为它会导致 2x-4x 的性能命中。也许这将是未来的默认值。
  9. 我们在这个问题上的预编译时间仍然比像 SciPy 的包装 Fortran 求解器这样的问题要好 20 倍。所以这都是编译时问题,而不是运行时问题。编译时间基本上是恒定的(调用相同函数的较大问题具有大约相同的编译),所以这实际上只是一个交互性问题。
  10. 我们(以及整个 Julia)在未来可以并且会在交互性方面做得更好。

完整说明

这真的不是DifferentialEquations.jl 的东西,这只是Julia 包的东西。25s 必须包括预编译时间。第一次加载 Julia 包时,它会进行预编译。然后在下一次更新之前不需要再次发生这种情况。这可能是最长的初始化时间,而且对于DifferentialEquations.jl 来说也很长,但同样只有在您每次更新包代码时才会发生这种情况。然后,每次using. DiffEq 相当大,所以它确实需要一些时间来初始化:

@time using DifferentialEquations
5.201393 seconds (4.16 M allocations: 235.883 MiB, 4.09% gc time)

然后如评论中所述,您还拥有:

@time using Plots
6.499214 seconds (2.48 M allocations: 140.948 MiB, 0.74% gc time)

然后,第一次运行

function lorenz(t,u,du)
  du[1] = 10.0*(u[2]-u[1])
  du[2] = u[1]*(28.0-u[3]) - u[2]
  du[3] = u[1]*u[2] - (8/3)*u[3]
end

u0 = [1.0;1.0;1.0]
tspan = (0.0,100.0)
prob = ODEProblem(lorenz,u0,tspan)
@time sol = solve(prob,reltol=1e-8,abstol=1e-8,saveat=collect(0:0.01:100))

6.993946 seconds (7.93 M allocations: 436.847 MiB, 1.47% gc time)

但是第二次和第三次:

0.010717 seconds (72.21 k allocations: 6.904 MiB)
0.011703 seconds (72.21 k allocations: 6.904 MiB)

那么这里发生了什么?Julia 第一次运行一个函数时,它会编译它。所以第一次运行时solve,它会在运行时编译所有内部函数。所有进行的时间都将没有编译。DifferentialEquations.jl 也专门研究函数本身,所以如果我们改变函数:

function lorenz2(t,u,du)
  du[1] = 10.0*(u[2]-u[1])
  du[2] = u[1]*(28.0-u[3]) - u[2]
  du[3] = u[1]*u[2] - (8/3)*u[3]
end

u0 = [1.0;1.0;1.0]
tspan = (0.0,100.0)
prob = ODEProblem(lorenz2,u0,tspan)

我们将再次产生一些编译时间:

@time sol = 
solve(prob,reltol=1e-8,abstol=1e-8,saveat=collect(0:0.01:100))
3.690755 seconds (4.36 M allocations: 239.806 MiB, 1.47% gc time)

所以这就是什么,现在是为什么。这里有几件事。首先,Julia 包没有完全预编译。它们不会在会话之间保留实际方法的缓存编译版本。这是 1.x 版本列表中要做的事情,这将摆脱第一次命中,类似于调用 C/Fortran 包,因为它会提前大量编译 (AOT)功能。所以这很好,但现在只需注意有一个启动时间。

现在让我们谈谈更改功能。Julia 中的每个函数都会自动专门处理其参数(有关详细信息,请参阅此博客文章)。这里的关键思想是 Julia 中的每个函数都是一个单独的具体类型。所以,由于这里的问题类型是参数化的,改变函数会触发编译。请注意,就是这种关系:您可以更改函数的参数(如果您有参数),您可以更改初始条件等,但它只是更改触发重新编译的类型。

这值得么?也许。我们希望专门为难以计算的东西快速处理。编译时间是恒定的(即你可以解决一个 6 小时的 ODE,它仍然需要几秒钟),因此计算成本高的计算在这里不会受到影响。在这里运行数千个参数和初始条件的蒙特卡罗模拟不会受到影响,因为如果您只是更改初始条件和参数的值,那么它将不会重新编译。但是,在您更改功能的地方进行交互使用确实会在其中受到第二次左右的打击,这并不好。Julia 开发人员对此的一个回答是在 Julia 1.0 之后的时间里加快编译时间,这是我不知道细节的事情,但我确信这里有一些唾手可得的成果。

我们能摆脱它吗?是的。DiffEq Online不会为每个函数重新编译,因为它面向在线使用。

function lorenz3(t,u,du)
  du[1] = 10.0*(u[2]-u[1])
  du[2] = u[1]*(28.0-u[3]) - u[2]
  du[3] = u[1]*u[2] - (8/3)*u[3]
  nothing
end

u0 = [1.0;1.0;1.0]
tspan = (0.0,100.0)
f = NSODEFunction{true}(lorenz3,tspan[1],u0)
prob = ODEProblem{true}(f,u0,tspan)

@time sol = solve(prob,reltol=1e-8,abstol=1e-8,saveat=collect(0:0.01:100))

1.505591 seconds (860.21 k allocations: 38.605 MiB, 0.95% gc time)

现在我们可以改变函数而不产生编译成本:

function lorenz4(t,u,du)
  du[1] = 10.0*(u[2]-u[1])
  du[2] = u[1]*(28.0-u[3]) - u[2]
  du[3] = u[1]*u[2] - (8/3)*u[3]
  nothing
end

u0 = [1.0;1.0;1.0]
tspan = (0.0,100.0)
f = NSODEFunction{true}(lorenz4,tspan[1],u0)
prob = ODEProblem{true}(f,u0,tspan)

@time sol = 
solve(prob,reltol=1e-8,abstol=1e-8,saveat=collect(0:0.01
:100))

0.038276 seconds (242.31 k allocations: 10.797 MiB, 22.50% gc time)

并且tada,通过将函数包装在NSODEFunction(内部使用FunctionWrappers.jl)中,它不再专门针对每个函数,并且您在每个 Julia 会话中都达到编译时间一次(然后一旦被缓存,每次包更新一次)。但请注意,这大约有 2 到 4 倍的成本,所以我不确定它是否会默认启用. 我们可以在问题类型构造函数中默认实现这一点(即默认情况下没有额外的专业化,但用户可以以交互性为代价选择更快的速度)但我不确定这里更好的默认值是什么(随意用你的想法评论这个问题)。但它肯定会在 Julia 更改其关键字参数后很快被记录下来,因此“无编译”模式将成为使用它的标准方式,即使不是默认方式。

但只是把它放在透视图上,

import numpy as np
from scipy.integrate import odeint
y0 = [1.0,1.0,1.0]
t = np.linspace(0, 100, 10001)
def f(u,t):
    return [10.0*(u[1]-u[0]),u[0]*(28.0-u[2])-u[1],u[0]*u[1]-(8/3)*u[2]]
%timeit odeint(f,y0,t,atol=1e-8,rtol=1e-8)

1 loop, best of 3: 210 ms per loop

我们正在研究是否应该将这种交互式便利默认设置为比 SciPy 的默认设置快 5 倍而不是 20 倍(尽管我们的默认设置通常比默认 SciPy 使用的准确得多,但这是另一个时间的数据,可以在基准中找到或只是询问)。一方面它易于使用是有意义的,但另一方面,如果重新启用长计算的专业化并且不知道蒙特卡洛(这是你真正想要速度的地方),那么那里的很多人会受到 2 到 4 倍的性能影响,这可能需要额外的几天/几周的计算。呃……艰难的选择。

因此,最终混合了优化选择和 Julia 缺少的一些预编译功能,这些功能会影响交互性而不影响真正的运行时速度。如果您希望使用一些大的 Monte Carlo 来估计参数,或者解决大量的 SDE,或者解决一个大的 PDE,我们可以解决这个问题。那是我们的第一个目标,我们确保尽可能地达到目标。但是在 REPL 中玩耍确实有 2-3 秒的“故障”,我们也不能忽视(当然比在 C/Fortran 中玩耍要好,但对于 REPL 来说仍然不理想)。为此,我已经向您展示了已经在开发和测试的解决方案,因此希望明年的这个时候,我们可以为该特定案例提供更好的答案。

附言

还有两点需要注意。如果您只使用 ODE 求解器,您可以using OrdinaryDiffEq继续下载/安装/编译/导入所有 DifferentialEquations.jl(手册中对此进行了描述)。此外,saveat像这样使用可能不是解决这个问题的最快方法:用更少的点来解决它并根据需要使用密集的输出可能会更好。

编辑

我打开了一个问题,详细说明了我们如何减少“函数之间”的编译时间,而不会失去专业化提供的加速。我认为这是我们可以在短期内优先考虑的事情,因为我同意我们可以在这里做得更好。

于 2017-11-27T09:46:23.093 回答