跳至主要内容

性能

本文档收集了在 PyPy 下使代码运行更快的策略、策略和技巧。其中许多也是针对标准 Python 和其他语言的有用提示。为了对比,我们还描述了一些在 PyPy 中不需要的 CPython(标准 Python)优化。


性能分析:vmprof

一般来说,在考虑性能问题时,请遵循以下三点:首先测量它们(与虚构的性能问题作斗争是弊大于利);然后分析您的代码(优化错误的部分毫无用处)。只有在那之后才优化。

PyPy 2.6 引入了 vmprof,这是一个非常低开销的统计分析器。标准的非统计 cProfile 也受支持,并且可以在不关闭 JIT 的情况下启用。我们仍然推荐 vmprof,因为启用 cProfile 会扭曲结果(有时会非常大,但希望这种情况不会太常见)。


优化策略

这些建议适用于所有计算机语言。它们在这里提醒您在进行任何 Python 或 PyPy 特定的调整之前尝试的事情。

构建回归测试套件

在开始调整之前,为您的代码构建一个回归测试套件。这会预先投入大量工作,但意味着您可以尝试大量优化,而无需过多担心引入功能性错误。

测量,不要猜测

人类不善于猜测或直觉代码中的热点在哪里。测量,不要猜测;使用 分析器 来确定代码花费 80% 时间的 20% 代码,然后对其进行加速调整。

测量将节省您大量浪费在调整实际上并非瓶颈的代码部分上的精力。

在您调整时,经常重新分析,以便您可以看到最热点的变化。

I/O 绑定不同于计算绑定

注意计算绑定代码(由于执行大量指令而速度慢)和 I/O 绑定代码(由于磁盘或网络延迟而速度慢)之间的区别。

预计从优化计算绑定代码中获得大部分收益。通常(但并非总是)当 分析 显示应用程序的大部分时间都花在网络和磁盘 I/O 上时,表明您已接近值得调整的结束。

首先调整您的算法

通常,当您的代码执行与数据集中大小成 O(n**2) 或更大的操作时,这些操作的成本将超过您使用此处描述的技巧所能获得的任何微小收益。

首先调整您的算法。在您认为已优化了本质上昂贵的操作之后,才考虑应用我们的微调技巧列表。

也就是说,要做好在微调时发现更隐藏的算法问题的准备。您可能会多次经历这个循环。

专注于紧密循环

高时间成本极有可能潜伏在紧密循环中一些看似无害的代码中 - 特别是在执行搜索/匹配/查找操作或任何类型的图遍历的代码中。

在计算密集型代码中,最常见的性能杀手是隐藏在 O(n) 循环中的 O(n**2) 操作,它伪装成某种 O(n) 查找或匹配。

另一个常见的耗时操作是在紧密循环中执行的相对昂贵的通用设置操作,这些操作可以移到循环开始之前。(有关此的代表性案例,请参阅有关正则表达式编译的微调技巧。)

小即是快

现代计算机具有多个级别的内存缓存,其中一些直接位于处理器芯片上。在任何级别上导致缓存未命中都会产生与下一个外层(更慢)缓存的随机访问时间成正比的性能损失。

因此,小即是快。工作集足够小以适合快速缓存的程序或例程将与该缓存一样快。为了使您的代码更快,请通过使其更简单来减少它生成的 Python 或 JIT 编译器操作码序列的长度。

这里的权衡是算法调整通常以时间换空间——也就是说,它通过包含预计算或表或反向映射来增加算法工作集的大小,以避免 O(n**2) 操作。

无法提前预测这种权衡的最佳点在哪里。您必须尝试不同的方法并进行测量——这让我们回到了“测量,不要猜测”。您的回归测试套件的另一个功能可以作为速度基准。


微调技巧

这些技巧没有特定的顺序。

保持简单

简单胜过复杂。PyPy JIT 并不十分智能;您的代码越简单,运行速度就越快。但是,您再次面临权衡:您可能需要付出更多算法复杂性的代价,以避免 O(n**2) 或更糟的暴力操作。

以简单的方式编写普通代码。PyPy JIT 有许多针对不常见使用模式的常见使用模式进行优化的生产。

全局变量

在 CPython 中,全局变量和函数(包括包导入)比局部变量的引用要昂贵得多;避免使用它们。(这也是良好的模块化实践)。

CPython 全局引用的成本很高,例如,如果您在频繁访问的内部循环中具有大量使用 int() 的代码,那么在封闭块中使用“int = int”创建局部副本可能值得。

但是,这在 JIT 的 PyPy 代码中适用。“int = int”技巧不会为您带来性能提升,它只是一个额外的副本。避免全局变量的模块化原因仍然有效。

正则表达式

正则表达式编译很昂贵。如果搜索、匹配或替换操作中的正则表达式模式是静态的(在运行时不会发生变异),请重构以使其只执行一次。

如果正则表达式编译在类方法中,请考虑将其作为正则表达式值的静态(共享)类成员的初始化器执行,并在您的操作中使用该类成员。

如果正则表达式编译在自由函数中,请考虑将其移动到模块级别并引用生成的正则表达式对象(但请参阅上面关于全局变量的警告)。

旧式类与新式类

新式类允许更快的属性访问,并且每个实例占用的核心比旧式类少。但是,如果属性名称不是常量,那么大部分优势可能会丢失。例如:x.a = y 甚至 setattr(x, 'a', y) 将比动态版本快得多:setattr(x, 'a' + some_variable, y)。

从新式类和旧式类继承的类非常慢;不惜一切代价避免使用它们。

在 PyPy 中,针对旧式类调用的 isinstance() 在 2.0 之前非常慢。

字符串连接很昂贵

在 CPython 中,您可能希望替换

s = head + body + maybe + tail

用以下不太易读的代码替换

s = "%(head)s%(body)s%(maybe)s%(tail)s" % locals()

甚至

s = "{head}{body}{maybe}{tail}".format(**locals())

后两种形式都避免了多次分配的开销。但 PyPy 的 JIT 使得线性代码中中间连接的开销消失,该代码保持连接数量较小、有界且恒定。(并且 locals() 在 PyPy 的 JIT 中相当慢。)

另一方面,在像这样的代码中,其中 foo() 函数返回字符串

for x in mylist:
    s += foo(x)

JIT 无法优化掉中间副本。由于对越来越大的前缀段的重复字符串复制,这段代码实际上是 mylist 字符串总大小的二次方。(对于字节数组,此类代码始终很好,因为在这种情况下 += 是一个就地操作。)

这个

parts = []
for x in mylist:
    parts.append(foo(x))
s = "".join(parts)

可能快得多,因为最后一行中的所有字符串连接只创建了一个新的字符串对象,其中包含一个 C 级别的复制序列(列表操作相对便宜)。

框架自省和跟踪很慢

某些函数调用可以禁用 PyPy 的速度选项,这些选项跨越称为“JIT 范围”的周围代码段。

像 PyPy 这样的 JIT 基于这样的假设:唯一值得优化的东西是经常执行的循环。每当解释器进入解释程序中的循环时,JIT 都会记录解释器所做的事情,创建一个跟踪。此跟踪被优化,编译成机器代码,并在循环以跟踪期间观察到的条件命中时执行。此跟踪是一种 JIT 范围。

另一种重要的 JIT 范围是函数,它被视为内联的单位。

请注意,JIT 范围是运行时现象,而不是编译时现象。它不受源代码模块边界的限制。频繁调用的循环或内联函数中的库或外部模块调用将是其 JIT 范围的一部分。

locals()、globals()、sys._getframe()、sys.exc_info() 和 sys.settrace 在 PyPy 中有效,但它们会带来性能损失,这可能会通过禁用封闭 JIT 范围上的 JIT 而变得非常大。

(感谢 Eric S. Raymond 提供以上文字)

内部人士的观点

本节从项目的内部人士的角度描述性能问题;如果您计划在该领域做出贡献,这将特别有趣。

PyPy 项目的目标之一是提供一个快速且兼容的 Python 解释器。我们实现此目标的一些方法是提供高性能垃圾收集器 (GC) 和高性能即时编译器 (JIT)。在 速度网站 上可以找到比较 PyPy 和 CPython 的结果。这些基准测试不是随机收集的:它们是现实世界 Python 程序的组合——最初包含在(现已停产的)Unladen Swallow 项目中的基准测试——以及我们发现 PyPy 速度很慢(并已改进)的基准测试。有关详细信息,请参阅每个基准测试的描述。

但是,JIT 并不是万能药。对于不习惯一般 JIT 或 PyPy JIT 的人来说,JIT 的一些特性可能会让他们感到惊讶。JIT 通常擅长加速直接的 Python 代码,这些代码在字节码调度循环中花费大量时间,即运行实际的 Python 代码——而不是运行仅由 Python 代码调用的东西。良好的示例包括数值计算或任何类型的面向对象的程序。不好的示例包括使用大型长整数进行计算——这是由不可优化的支持代码执行的。当 JIT 无法提供帮助时,PyPy 通常比 CPython 慢。

更具体地说,已知 JIT 不适用于

  • 测试:理想的单元测试会执行每个测试代码段一次。这没有给 JIT 预热的时间。

  • 非常短的运行脚本:经验法则是,如果某些东西的运行时间低于 0.2 秒,JIT 就没有机会,但这很大程度上取决于所讨论的程序。一般来说,在运行基准测试之前,请确保您预热了程序,如果您正在测量像服务器这样长时间运行的东西。JIT 预热所需的时间会有所不同;至少给它几秒钟。(PyPy 的 JIT 预热时间特别长。)

  • 长时间运行的运行时函数:这些是 PyPy 运行时提供的执行大量工作的函数。PyPy 的运行时通常不像 CPython 的运行时那样优化,我们预计这些函数的运行时间与 CPython 相同或两倍。例如,这包括使用长整数进行计算或对大型列表进行排序。一个反例是正则表达式:虽然它们需要时间,但它们有自己的 JIT。

我们知道 PyPy 在以下方面速度很慢的无关事项(请注意,我们可能正在努力解决这个问题)

  • CPython C 扩展模块: 任何使用 PyPy 重新编译的 C 扩展模块都会在性能上遭受重大损失。PyPy 支持 C 扩展模块仅仅是为了提供基本功能。如果扩展模块仅仅是为了加速目的,那么目前在 PyPy 中使用它毫无意义。相反,请移除它并使用原生 Python 实现,这也有利于 JIT 优化。如果扩展模块既是性能关键,又是与某个 C 库的接口,那么可以考虑将其重写为使用 CFFI 作为接口的纯 Python 版本。

  • 缺少 RPython 模块: 标准库中的一些模块(如 csvcPickle)在 CPython 中是用 C 编写的,但在 PyPy 中是用纯 Python 原生编写的。有时 JIT 能够很好地处理它们,有时则不能。在大多数情况下(如 csvcPickle),我们比 CPython 慢,但 jsonheapq 是显著的例外。

  • 滥用 itertools: itertools 模块经常被“滥用”,因为它被用于错误的目的。从我们的角度来看,itertools 非常适合处理数百万个项目的迭代,但对于大多数其他情况则不适用。它用 3 行函数式风格代码替换了 10 行 Python 循环(更长,但可以说更容易阅读)。即使在 CPython 上,纯 Python 版本通常也不会更慢,而在 PyPy 上,它允许 JIT 更好地工作——简单的 Python 代码很快。同样的论点也适用于 filter()reduce(),以及在一定程度上适用于 map()(尽管简单的情况会被 JIT 处理),以及我们能想到的所有 operator 模块的使用。

  • Ctypes: Ctypes 比 CPython 上的 Ctypes 慢。考虑使用 CFFIHPy,它们在 JIT 内部有特殊的路径。

我们通常认为 PyPy 上比 CPython 慢的东西是 PyPy 的 bug。如果您发现这里没有记录的任何问题,请将其报告到我们的 bug 跟踪器 以便调查。