程序运行太慢,想要提速,但不使用复杂的技术如 C 扩展或 JIT 编译器。
解决方案
程序优化的第一准则是“不要优化”,第二准则是“不要优化那些不重要的部分”。基于这两个原则,如果你的程序运行得很慢,你得先找出影响性能的问题所在。
多数时候我们发现程序把大量的时间花在几个热点位置,比如处理数据的内层循环。一旦确认了这些热点,就可以使用以下各小节中介绍的技术让程序运行得更快。
使用函数
很多人开始使用 Python 时都是用它来编写一些简单的脚本。最开始时,很容易陷入只管编写代码而不重视程序结构的怪圈。例如:
代码语言:javascript复制# somescript.py
import sys
import csv
with open(sys.argv[1]) as f:
for row in csv.reader(f):
# Some kind of processing
pass
一个鲜为人知的事实是,像上面这样定义在全局范围内的代码比定义在函数中的代码要慢。速度的差异与局部变量与全局变量的实现机制有关(涉及局部变量的操作要更快)。因此,如果想让程序运行得更快,可以将脚本中的语句放入函数中即可:
代码语言:javascript复制# somescript.py
import sys
import csv
def main(filename):
with open(filename) as f:
for row in csv.reader(f):
# Some kind of processing
pass
main(sys.argv[1])
运行速度的差异与具体执行的任务有关,但根据经验,提升 15% ~ 30% 的情况很常见。
消除属性访问
每次使用句点操作符(.)来访问对象的属性都会带来开销。在底层,这会触发调用特殊的方法。
通常可以用 from module import name 的导入形式以及选择性地使用绑定方法(bound method)来避免出现属性查询操作。我们用下面的代码片段来加以说明:
代码语言:javascript复制import math
def compute_roots(nums):
result = []
for n in nums:
result.append(math.sqrt(n))
return result
# Test
nums = range(1000000)
for n in range(100):
r = compute_roots(nums)
当在我们的机器上测试时,这个程序运行了大约 40 秒。现在将 compute_roots() 函数修改为如下形式:
代码语言:javascript复制from math import sqrt
def compute_roots(nums):
result = []
result_append = result.append
for n in nums:
result_append(sqrt(n))
return result
修改后的版本运行时间大约是 29 秒。唯一不同之处就是消除了属性访问。用 sqrt() 代替了 math.sqrt()。result.append() 方法被赋给一个局部变量 result_append,然后在内部循环中使用它。
但是,必须强调的是,只有在频繁执行的代码中做这些修改才有意义,比如在循环中。因此,这种优化技术适用的场景需要经过精心挑选。
理解变量所处的位置
前述提及,访问局部变量比全局变量要快。对于需要频繁访问的名称,想提高运行速度,可以通过尽量让这些变量尽可能成为局部变量来实现。例如:
代码语言:javascript复制import math
def compute_roots(nums):
sqrt = math.sqrt
result = []
result_append = result.append
for n in nums:
result_append(sqrt(n))
return result
在这个版本中,sqrt 方法已经从 math 模块中提取出来并放置在一个局部变量中。如果运行这份代码,执行时间大约是 25 秒,这比上一个版本的 29 秒又有所提升。根本原因就是查找局部变量比全局变量要快。
当使用类时,局部参数同样能起到提速的效果。一般来说,查找像 self.name 这样的值会比访问一个局部变量要慢很多。在内层循环中将需要经常访问的属性移到局部变量中来会很划算。例如:
代码语言:javascript复制# Slower
class SomeClass:
...
def method(self):
for x in s:
op(self.value)
# Faster
class SomeClass:
...
def method(self):
value = self.value
for x in s:
op(value)
避免不必要的抽象
装饰器(decorator)、属性(property)或者描述符(descriptor)包装过的代码,运行速度通常会变慢。参考以下代码:
代码语言:javascript复制class A:
def __init__(self, x, y):
self.x = x
self.y = y
@property
def y(self):
return self._y
@y.setter
def y(self, value):
self._y = value
测试一下:
代码语言:javascript复制>>> from timeit import timeit
>>> a = A(1,2)
>>> timeit('a.x', 'from __main__ import a')
0.07817923510447145
>>> timeit('a.y', 'from __main__ import a')
0.35766440676525235
>>>
使用内建的容器
内建的数据类型比如字符串、元组、列表、集合以及字典都是用 C 语言实现的,速度非常快。如果需要构建自己的数据结构作为替代(例如链表、二叉树等),想在性能上达到内建的速度几乎不可能,因此还是尽量使用内建的数据结构吧。
避免产生不必要的数据结构或者拷贝动作
有时候程序员可能会创建一些不必要的数据结构,比如下面的代码:
代码语言:javascript复制values = [x for x in sequence]
squares = [x*x for x in values]
也许这里的想法是首先将一些值收集到一个列表中,然后使用列表推导来执行操作。不过,第一个列表完全没有必要,可以简单的像下面这样写:
代码语言:javascript复制squares = [x*x for x in sequence]
与此相关,还要注意下那些对Python的共享数据机制过于偏执的程序所写的代码。有些人并没有很好的理解或信任Python的内存模型,滥用 copy.deepcopy() 之类的函数。通常在这些代码中是可以去掉复制操作的。
讨论
在进行优化之前,有必要研究一下使用的算法。选择一个复杂度为 O(n log n) 的算法要比你去调整一个复杂度为 O(n**2) 的算法所带来的性能提升要大得多。
如果优化代码势在必行,那么请从整体考虑。作为一般准则,不要对程序的每一个部分都去优化,因为这些修改会导致代码难以阅读和理解。你应该专注于优化产生性能瓶颈的地方,比如内部循环。
还要注意一些小的优化的结果。比如下面创建字典的两种方式:
代码语言:javascript复制a = {
'name' : 'AAPL',
'shares' : 100,
'price' : 534.22
}
b = dict(name='AAPL', shares=100, price=534.22)
后面一种写法更简洁一些(你不需要在关键字上输入引号)。不过,如果你将这两个代码片段进行性能测试对比时,会发现使用 dict() 的方式会慢了3倍。看到这个,你是不是有冲动把所有使用 dict() 的代码都替换成第一种。不过,聪明的程序员只会关注他应该关注的地方,比如内部循环。在其他地方,这点性能损失没有什么影响。
如果你的优化要求比较高,本节的这些简单技术满足不了,那么你可以研究下基于即时编译(JIT)技术的一些工具。例如,PyPy 工程是 Python 解释器的另外一种实现,它会分析你的程序运行并对那些频繁执行的部分生成本机机器码。它有时候能极大的提升性能,通常可以接近 C 代码的速度。不过可惜的是,到写这本书为止,PyPy 还不能完全支持 Python3。因此,这个是你将来需要去研究的。你还可以考虑下 Numba 工程, Numba 是一个在你使用装饰器来选择 Python 函数进行优化时的动态编译器。这些函数会使用LLVM被编译成本地机器码。它同样可以极大的提升性能。但是,跟 PyPy 一样,它对于 Python 3 的支持现在还停留在实验阶段。
最后我引用John Ousterhout说过的话作为结尾:“最好的性能提升就是从不工作转变为可以工作”。直到你真的需要优化的时候再去考虑它。确保你程序正确的运行通常比让它运行更快要更重要一些(至少开始是这样的)。
参考
- 《Python Cookbook》第三版
- http://python3-cookbook.readthedocs.org/zh_CN/latest/