Python性能特征
我正在调试一个自己的小项目,想提高它的性能。我已经使用了性能分析工具来找出瓶颈,但我觉得更好地理解Python的性能特点会对我今后的工作很有帮助。
我有几个想了解的点:
它的优化器有多聪明?
一些现代编译器配备了非常聪明的优化器,能把简单的代码优化得比人手动调优还要快。根据优化器的聪明程度,我的代码可能越简单越好。
虽然Python是一种“解释型”语言,但它似乎会编译成某种字节码(.pyc)。那么在这个过程中,它的智能程度如何呢?
- 它会合并常量吗?
- 它会把小函数内联或者展开短循环吗?
- 它会进行复杂的数据/流分析吗?我可能解释不太清楚。
以下操作的速度如何(相对比较)
- 函数调用
- 类实例化
- 算术运算
- 一些“重”的数学运算,比如sqrt()
数字是如何在内部处理的?
Python是如何存储数字的?它们是作为整数/浮点数存储,还是以字符串的形式移动?
NumPy
NumPy能带来多大的性能提升?这个应用大量使用向量和相关的数学运算。使用NumPy来加速这些操作能带来多大的差别?
还有其他有趣的内容吗
如果你想到其他值得了解的内容,欢迎提出来。
一些背景信息...
因为有些人提到“先看看你的算法”的建议(这确实是个很合理的建议,但对我提问的目的帮助不大),我想在这里补充一些背景,说明我为什么会问这些问题。
我提到的小项目是一个用Python写的光线追踪器。它目前还在初期阶段,只能对场景中的两个物体(一个三角形和一个球体)进行碰撞检测。没有进行阴影、光照等计算。算法基本上是:
for each x, y position in the image:
create a ray
hit test vs. sphere
hit test vs. triangle
colour the pixel based on the closest object, or black if no hit.
光线追踪中的算法优化通常是通过尽早排除场景中的物体来实现的。这对于复杂场景可以提供很大的提升,但如果这个光线追踪器连两个物体的碰撞检测都做不好,那它就很难处理更多的物体。
我知道,基于Python的光线追踪器的性能无法与基于C的相比。像Arauna这样的实时光线追踪器在我的电脑上能以15-20帧每秒的速度渲染复杂场景(640x480),我觉得在Python中渲染一个非常简单的500x500图像应该能在一秒内完成。
目前,我的代码需要38秒。我觉得这时间实在太长了。
性能分析显示,大部分时间都花在了这些形状的碰撞检测上。这在光线追踪器中并不意外,也是我预期的。这些碰撞检测的调用次数都是250,000(正好是500x500),说明它们的调用频率是正常的。这是一个典型的优化案例,建议进行优化。
我计划在改进代码时进行全面的计时和测量。不过,如果没有对Python中各种操作成本的基本了解,我的调优尝试就像是在黑暗中摸索。我觉得了解一些基础知识会对我有所帮助。
6 个回答
S.Lott说得对:数据结构和算法是最重要的。如果你经常进行输入输出操作,管理这些操作的方式也会有很大的影响。
不过,如果你对编译器的内部工作感兴趣:编译器会对常量进行优化,但不会把函数内联或者展开循环。在动态语言中,函数内联是个比较复杂的问题。
你可以通过反汇编一些编译后的代码来看看编译器做了什么。把一些示例代码放在my_file.py里,然后使用:
python -m dis my_file.py
这个源代码:
def foo():
return "BAR!"
for i in [1,2,3]:
print i, foo()
会生成:
1 0 LOAD_CONST 0 (<code object foo at 01A0B380, file "\foo\bar.py", line 1>)
3 MAKE_FUNCTION 0
6 STORE_NAME 0 (foo)
4 9 SETUP_LOOP 35 (to 47)
12 LOAD_CONST 1 (1)
15 LOAD_CONST 2 (2)
18 LOAD_CONST 3 (3)
21 BUILD_LIST 3
24 GET_ITER
>> 25 FOR_ITER 18 (to 46)
28 STORE_NAME 1 (i)
5 31 LOAD_NAME 1 (i)
34 PRINT_ITEM
35 LOAD_NAME 0 (foo)
38 CALL_FUNCTION 0
41 PRINT_ITEM
42 PRINT_NEWLINE
43 JUMP_ABSOLUTE 25
>> 46 POP_BLOCK
>> 47 LOAD_CONST 4 (None)
50 RETURN_VALUE
注意,只有模块中的顶层代码会被反汇编。如果你想看到函数定义的反汇编结果,你需要自己写一些额外的代码来遍历嵌套的代码对象。
Python的编译器设计得非常简单,这样可以让它运行得更快,也更容易预测。除了进行一些常量折叠,它基本上生成的字节码就是忠实地反映了你的源代码。有人已经提到过dis,这确实是查看你得到的字节码的好方法。例如,for i in [1, 2, 3]:
实际上并没有进行常量折叠,而是动态生成这个列表;而for i in (1, 2, 3):
(循环一个元组而不是列表)是可以进行常量折叠的(原因是,列表是可变对象,编译器为了保持“简单”的原则,不会检查这个特定的列表是否从未被修改,因此它可以被优化成一个元组)。
所以在这里有很多手动微优化的空间,特别是“提升”(hoisting)。也就是说,可以将
for x in whatever():
anobj.amethod(x)
重写为
f = anobj.amethod
for x in whatever():
f(x)
以节省重复查找的时间(编译器不会检查一连串的anobj.amethod
是否会改变anobj
的绑定等,因此下次需要重新查找——它只是做了简单的事情,也就是没有提升,这样可以保证正确性,但绝对不能保证速度飞快;-)。
timeit模块(我认为最好在命令行中使用)可以很简单地测量编译和字节码解释的整体效果(只要确保你测量的代码片段没有会影响计时的副作用,因为timeit
确实会在循环中多次运行它;-)。例如:
$ python -mtimeit 'for x in (1, 2, 3): pass'
1000000 loops, best of 3: 0.219 usec per loop
$ python -mtimeit 'for x in [1, 2, 3]: pass'
1000000 loops, best of 3: 0.512 usec per loop
你可以看到重复构建列表的成本——并通过尝试一个小的调整来确认这确实是我们观察到的情况:
$ python -mtimeit -s'Xs=[1,2,3]' 'for x in Xs: pass'
1000000 loops, best of 3: 0.236 usec per loop
$ python -mtimeit -s'Xs=(1,2,3)' 'for x in Xs: pass'
1000000 loops, best of 3: 0.213 usec per loop
将可迭代对象的构建移动到-s
设置中(只运行一次且不计时)显示,循环在元组上稍微快一点(可能快10%),但第一个对(列表比元组慢超过100%)的主要问题还是在于构建。
掌握了timeit
和编译器故意非常简单的优化方式后,我们可以轻松回答你其他的问题:
以下操作的速度如何(相对而言)
* Function calls * Class instantiation * Arithmetic * 'Heavier' math operations such as sqrt()
$ python -mtimeit -s'def f(): pass' 'f()'
10000000 loops, best of 3: 0.192 usec per loop
$ python -mtimeit -s'class o: pass' 'o()'
1000000 loops, best of 3: 0.315 usec per loop
$ python -mtimeit -s'class n(object): pass' 'n()'
10000000 loops, best of 3: 0.18 usec per loop
所以我们看到:实例化一个新式类和调用一个空函数的速度差不多,实例化可能快一点,大约5%;而实例化一个旧式类是最慢的(慢了大约50%)。像5%或更小的差异当然可能是噪音,所以建议多试几次;但像50%的差异肯定是超出了噪音的范围。
$ python -mtimeit -s'from math import sqrt' 'sqrt(1.2)'
1000000 loops, best of 3: 0.22 usec per loop
$ python -mtimeit '1.2**0.5'
10000000 loops, best of 3: 0.0363 usec per loop
$ python -mtimeit '1.2*0.5'
10000000 loops, best of 3: 0.0407 usec per loop
在这里我们看到:调用sqrt
的速度比用运算符(使用**
幂运算符)进行相同计算要慢,大约相当于调用一个空函数的成本;所有算术运算符的速度差不多,噪音范围内的微小差异(3或4纳秒的差异肯定是噪音;-)。检查常量折叠是否会干扰:
$ python -mtimeit '1.2*0.5'
10000000 loops, best of 3: 0.0407 usec per loop
$ python -mtimeit -s'a=1.2; b=0.5' 'a*b'
10000000 loops, best of 3: 0.0965 usec per loop
$ python -mtimeit -s'a=1.2; b=0.5' 'a*0.5'
10000000 loops, best of 3: 0.0957 usec per loop
$ python -mtimeit -s'a=1.2; b=0.5' '1.2*b'
10000000 loops, best of 3: 0.0932 usec per loop
...我们看到确实如此:如果其中一个或两个数字被作为变量查找(这会阻止常量折叠),我们就要支付“实际”的成本。变量查找本身也有成本:
$ python -mtimeit -s'a=1.2; b=0.5' 'a'
10000000 loops, best of 3: 0.039 usec per loop
而且在我们试图测量如此微小的时间时,这个成本并不小。实际上常量查找也不是免费的:
$ python -mtimeit -s'a=1.2; b=0.5' '1.2'
10000000 loops, best of 3: 0.0225 usec per loop
如你所见,虽然小于变量查找,但还是相当可观——大约是后者的一半。
如果在仔细分析和测量后,你决定某些计算的核心部分急需优化,我建议尝试cython——它是C和Python的结合,力求既保持Python的简洁,又达到C的速度,虽然不能100%做到,但确实做得相当不错(特别是,它生成的二进制代码比它的前身语言pyrex快得多,而且功能也更丰富)。对于最后几%的性能提升,你可能还是需要深入到C(或者在一些特殊情况下使用汇编/机器码),但这种情况真的非常少见。