如何让 Python 像 Julia 一样快地运行

523 查看

Julia 与 Python 的比较

我是否应丢弃 Python 和其他语言,使用 Julia 执行技术计算?在看到 http://julialang.org/ 上的基准测试后,人们一定会这么想。Python
和其他高级语言在速度上远远有些落后。但是,我想到的第一个问题有所不同:Julia 团队能否以最适合 Python 的方式编写 Python 基准测试?

我对这种跨语言比较的观点是,应该根据要执行的任务来定义基准测试,然后由语言专家编写执行这些任务的最佳代码。如果代码全由一个语言团队编写,则存在其他语言未得到最佳使用的风险。

Julia 团队有一件事做得对,那就是他们将他们使用的代码发布到了 github 上。具体地讲,Python 代码可在此处找到。

第一眼看到该代码,就可以证实我所害怕的偏见。该代码是以 C 风格编写的,在数组和列表上大量使用了循环。这不是使用 Python 的最佳方式。

我不会责怪 Julia 团队,因为我很内疚自己也有同样的偏见。但我受到了残酷的教训:付出任何代价都要避免数组或列表上的循环,因为它们确实会拖慢 Python
中的速度,请参阅 Python 不是 C

考虑到对 C 风格的这种偏见,一个有趣的问题(至少对我而言)是,我们能否改进这些基准测试,更好地使用 Python 及其工具?

在我给出答案之前,我想说我绝不会试图贬低 Julia。在进一步开发和改进后,Julia 无疑是一种值得关注的语言。我只是想分析 Python
方面的事情。实际上,我正在以此为借口来探索各种可用于让代码更快运行的 Python 工具。

在下面的内容中,我使用 Docker 镜像在 Jupyter Notebook 中使用 Python 3.4.3,其中已安装了所有的 Python 科学工具组合。我还会通过
Windows 机器上的 Python 2.7.10,使用 Anaconda 来运行代码。计时是对 Python 3.4.3 执行的。包含下面的所有基准测试的完整代码的 Notebook 可在此处找到。

鉴于各种社交媒体上的评论,我添加了这样一句话:我没有在这里使用 Python 的替代性实现。我没有编写任何 C
代码:如果您不信,可试试寻找分号。本文中使用的所有工具都是 Anaconda 或其他发行版中提供的标准的 Cython 实现。下面的所有代码都在单个 Notebook中运行。

我尝试过使用来自 github 的 Julia 微性能文件,但不能使用 Julia 0.4.2 原封不动地运行它。我必须编辑它并将 @timeit 替换为
@time,它才能运行。在对它们计时之前,我还必须添加对计时函数的调用,否则编译时间也将包含在内。我使用的文件位于此处。我在用于运行 Python 的同一个机器上使用 Julia 命令行接口运行它。

计时代码

Julia 团队使用的第一项基准测试是 Fibonacci 函数的一段简单编码。

此函数的值随 n 的增加而快速增加,例如:

可以注意到,Python 任意精度 (arbitrary precision) 很方便。在 C 等语言中编写相同的函数需要花一些编码工作来避免整数溢出。在 Julia
中,需要使用 BigInt 类型。

所有 Julia 基准测试都与运行时间有关。这是 Julia 中使用和不使用 BigInt 的计时:

在 Python Notebook 中获得运行时间的一种方式是使用神奇的 %timeit。例如,在一个新单元中键入:

执行它会获得输出:

这意味着计时器执行了以下操作:

  1. 运行 fib(20) 100 次,存储总运行时间
  2. 运行 fib(20) 100 次,存储总运行时间
  3. 运行 fib(20) 100 次,存储总运行时间
  4. 从 3 次运行中获取最小的运行时间,将它除以 100,然后输出结果,该结果就是 fib(20) 的最佳运行时间

这些循环的大小(100 次和 3 次)会由计时器自动调整。可能会根据被计时的代码的运行速度来更改循环大小。

Python 计时与使用了 BigInt 时的 Julia 计时相比出色得多:3 毫秒与 12 毫秒。在使用任意精度时,Python 的速度是 Julia 的 4
倍。

但是,Python 比 Julia 默认的 64 位整数要慢。我们看看如何在 Python 中强制使用 64 位整数。

使用 Cython 编译

一种编译方式是使用 Cython 编译器。这个编译器是使用 Python
编写的。它可以通过以下命令安装:

pip install Cython

如果使用 Anaconda,安装会有所不同。因为安装有点复杂,所以我编写了一篇相关的博客文章:将 Cython For Anaconda 安装在 Windows 上

安装后,我们使用神奇的 %load_ext 将 Cython 加载到 Notebook 中:

然后就可以在我们的 Notebook 中编译代码。我们只需要将想要编译的代码放在一个单元中,包括所需的导入语句,使用神奇的 %%cython 启动该单元:

执行该单元会无缝地编译这段代码。我们为该函数使用一个稍微不同的名称,以反映出它是使用 Cython
编译的。当然,一般不需要这么做。我们可以将之前的函数替换为相同名称的已编译函数。

对它计时会得到:

哇,几乎比最初的 Python 代码快 3 倍!我们现在比使用 BigInt 的 Julia 快 100 倍。

我们还可以尝试静态类型。使用关键字 cpdef 而不是 def 来声明该函数。它使我们能够使用相应的 C 类型来键入函数的参数。我们的代码变成了:

执行该单元后,对它计时会得到:

太棒了,我们现在只花费了 36 微秒,比最初的基准测试快约 100 倍!这与 Julia 所花的 80 毫秒相比更出色。

有人可能会说,静态类型违背了 Python
的用途。一般来讲,我比较同意这种说法,我们稍后将查看一种在不牺牲性能的情况下避免这种情形的方法。但我并不认为这是一个问题。Fibonacci
函数必须使用整数来调用。我们在静态类型中失去的是 Python 所提供的任意精度。对于 Fibonacci,使用 C 类型 long
会限制输入参数的大小,因为太大的参数会导致整数溢出。

请注意,Julia 计算也是使用 64 位整数执行的,因此将我们的静态类型版本与 Julia 的对比是公平的。

缓存计算

我们在保留 Python 任意精度的情况下能做得更好。fib 函数重复执行同一种计算许多次。例如,fib(20) 将调用 fib(19) 和
fib(18)。fib(19) 将调用 fib(18) 和 fib(17)。结果 fib(18) 被调用了两次。简单分析表明,fib(17) 将被调用 3
次,fib(16) 将被调用 5 次,等等。

在 Python 3 中,我们可以使用 functools 标准库来避免这些重复的计算。

对此函数计时会得到:

速度又增加了 40 倍,比最初的 Python 代码快约 3,600 倍!考虑到我们仅向递归函数添加了一条注释,此结果非常令人难忘。

Python 2.7 中没有提供这种自动缓存。我们需要显式地转换代码,才能避免这种情况下的重复计算。

请注意,此代码使用了 Python 同时分配两个局部变量的能力。对它计时会得到:

我们又快了 20 倍!让我们在使用和不使用静态类型的情况下编译我们的函数。请注意,我们使用了 cdef 关键字来键入局部变量。

我们可在一个单元中对两个版本计时:

结果为:

静态类型代码现在花费的时间为 51.9 纳秒,比最初的基准测试快约 60,000(六万)倍。

如果我们想计算任意输入的 Fibonacci 数,我们应坚持使用无类型版本,该版本的运行速度快 3,500 倍。还不错,对吧?

使用 Numba 编译

让我们使用另一个名为 Numba 的工具。它是针对部分 Python 版本的一个即时
(jit) 编译器。它不是对所有 Python 版本都适用,但在适用的情况下,它会带来奇迹。

安装它可能很麻烦。推荐使用像 Anaconda 这样的 Python 发行版或一个已安装了 Numba 的 Docker 镜像。完成安装后,我们导入它的 jit 编译器:

它的使用非常简单。我们仅需要向想要编译的函数添加一点修饰。我们的代码变成了:

对它计时会得到: