Python性能优化指南
Python最为人诟病的就是其执行速度。如何让Python程序跑得更快一直是Python核心团队和社区努力的方向。作为Python开发者,我们同样可以采用某些原则和技巧,写出性能更好的Python代码。本文将带大家深入探讨Python程序性能优化方法。
文章目录
- 优化原则
- 优化工具
- cProfile
- %%timeit 和 %timeit
- timeit()方法
- 第三方工具line_profiler
- 解决瓶颈
- 选择合适的算法和数据结构
- 多用列表推导式
- 少用`.`操作
- 善用多重赋值
- 避免使用全局变量
- 尽量使用库方法
- 用join拼接字符串
- 善用生成器
- 利用加速工具
- 用C/C++/Rust实现核心功能
- 使用最新版本的Python
优化原则
有些优化原则是所有编程语言都适用的,当然对Python也同样适用。这些原则作为程序优化的“心法”,我们每个程序员都要牢记于心,并在日常开发中加以贯彻。
1. 切忌边开发边优化
编写程序时不要去考虑可能的优化,而是集中精力确保代码干净、正确、可读、易懂。如果在写完后发现它太大或太慢,那时再考虑如何优化它。大神高德纳(Donald Knuth)说过一句至理名言:
“过早优化是一切罪恶的根源。(Premature optimization is the root of all evil.)”
这其实也是曾国藩的处事哲学:“物来顺应,未来不迎,当时不杂,既过不恋”。
2. 牢记20/80法则
在许多领域,你都可以用20%的努力获得80%的结果(有时可能是10/90法则)。每当您要优化代码时,首先用分析工具找出80%的执行时间花在哪里,这样您就知道应该集中精力优化哪里了。
3. 一定要做优化前后的性能对比
如果不做优化前后的性能比较,我们无法知道优化是否产生了实际效果。如果优化后的代码只比优化前稍快一点,那么请撤消优化并返回优化前版本。因为用牺牲代码的清晰整洁、易读好懂为代价换来的一丁点性能提升不值得。
以上3条优化原则请大家牢记于心,无论今后大家使用何种语言,在做性能优化时都请遵守这3条法则。
优化工具
正如优化原则第二条所讲,我们需要将优化精力放在最耗时的地方。那么怎么找到程序中最耗时的地方呢?此时我们需要用优化工具收集程序运行中的数据,帮我们找到程序瓶颈所在。这个过程称为Profiling。Python中有多个Profiling工具,每个工具各自有其使用场景和重点,下面一一为大家介绍。
cProfile
Python中自带了Profiling工具,名叫cProfile
。这也是我推荐大家使用的Profiling工具,因为它功能最强大。它可以注入程序中的每一个方法,收集丰富的数据,包括:
ncalls
: 方法被调用的次数tottime
: 方法执行的总时间(不包含子函数的执行时间)percall
: 每次执行花费的平均时间,即tottime
除以ncalls
的商cumtime
: 方法执行的累计时间(包含子函数的执行时间),对递归同样准确有效percall
:cumtime
除以原始调用次数(不含递归调用)的商filename:lineno(function)
: 提供每个函数的相应数据
cProfile
可以直接在命令行里使用。
$ python -m cProfile main.py
假设我们的main.py
实现的是求1000000以内的素数和,代码如下:
import math
def is_prime(n: int) -> bool:
for i in range(2, math.floor(math.sqrt(n)) + 1):
if n % i == 0:
return False
return True
def main():
s = 0
for i in range(2, 1000000):
if is_prime(i):
s += i
print(s)
if __name__ == "__main__":
main()
那么执行cProfile
后,控制台会输出(部分)如下信息:
从输出看,整个程序运行花了3.091秒,共有3000064次函数调用,下面列表是每个函数的详细数据。cProfile默认用函数名排序,而我们更关注函数的执行时间,所以通常我们在使用cProfile时会带上-s time
,让cProfile按执行时间来排序输出。
$ python -m cProfile -s time .\main.py
按时间排序输出后的信息如下:
从上面的输出信息可以看到,最耗时的是is_prime
函数。如果要优化,is_prime
将是我们的优化重点。
%%timeit 和 %timeit
上面介绍的cProfile
主要在命令行中使用。但是在数据分析和机器学习中我们经常使用Jupyter作为交互式编程环境。在Jupyter或IPython等交互式编程环境下,cProfile
就无法使用了,我们需要用%%timeit
和%timeit
。
%%timeit
和%timeit
的区别在于,%%timeit
作用于整个代码块,统计整个代码块的执行时间;%timeit
作用于语句,统计该行语句的执行时间。还是求质数和的代码,我们看一下在Jupyter中如何获取其运行时间:
上面代码在代码块开头加入%%timeit
, 它会统计整个代码的运行时间。%%timeit
会多次运行,取平均运行时长。输出结果如下:
3.87 s ± 151 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
timeit()方法
有时我们可能仅仅是想知道代码中的某个函数或某行语句的执行情况,此时用cProfile
显得太重了(cProfile会输出所有函数的执行情况),我们可以import timeit
,用timeit()
包裹住我们要profile的函数或语句。例如:
import timeit
timeit.timeit('list(itertools.repeat("a", 100))', 'import itertools', number=10000000)
上面的代码会测试list(itertools.repeat("a", 100))
10000000次,计算平均运行时间。
10.997665435877963
timeit
同样可以在命令行中使用。例如:
$ python -m timeit "'-'.join(str(n) for n in range(100))"
20000 loops, best of 5: 10.5 usec per loop
第三方工具line_profiler
上面的介绍的工具都是Python或IPython自带的,提供的功能个更多是函数的运行时间。当我们需要深入地了解程序的执行情况时,上面介绍的3个工具就不够用了。此时我们需要请出代码优化神器–line_profiler。ine_profiler是Python的一个第三方库,其功能时基于函数的逐行代码分析工具。通过该库,可以对目标函数(允许分析多个函数)进行时间消耗分析,便于代码调优。
由于是第三方工具,使用line_profiler前需要安装
$ pip install line_profiler
安装成功后,我们就可以用@profile注解和kernprof命令来收集代码运行情况。我们将上面求质数和的例子改造成@profile注解的形式。
import math
import profile
@profile
def is_prime(n: int) -> bool:
for i in range(2, math.floor(math.sqrt(n)) + 1):
if n % i == 0:
return False
return True
@profile
def main():
s = 0
for i in range(2, 1000000):
if is_prime(i):
s += i
print(s)
if __name__ == "__main__":
main()
然后用运行kernprof
命令
$ kernprof -lv main.py
其中,参数-l
表示用line-by-line profiler代替cProfile。要想@profile注解生效,一定要加-l
参数。分析结果会保存到.lprof
文件里,加上-v
参数可以将结果写入文件后展示在控制台。可以用line_profiler
命令查看分析结果:
$ python -m line_profiler <.lprof文件>
解决瓶颈
选择合适的算法和数据结构
利用profiling工具我们可以轻松找到程序的瓶颈所在。接下来就是如何解决瓶颈。有统计,90%的程序性能问题与算法和数据结构有关。选择合适的算法对提升程序性能最关重要。比如,如果要对包含上千元素的列表进行排序,不要用时间复杂度为 O ( n 2 ) O(n^2) O(n2)的冒泡排序,用快速排序(时间复杂度为 O ( n log n ) O(n\log_n) O(nlogn))速度会快很多。
上面的例子讲的是算法对性能的影响。选择恰当的数据结构对性能影响也很大。比如,对海量数据进行查找。如果我们用列表存储数据,那么查找指定元素的时间复杂度为 O ( n ) O(n) O(n);但如果我们用二叉树来存储,那么查找速度将提升为 O ( log n ) O(\log_n) O(logn);如果我们用哈希表来存储,那么查找速度将变为 O ( 1 ) O(1) O(1)。
在描述算法复杂度时,我们通常使用大O标注法。大O标注法定义了算法所需时间的上界。例如插入排序,在最佳情况下需要线性时间,在最坏情况下需要二次时间。于是我们插入排序的时间复杂度是 O ( n 2 ) O(n^2) O(n2),这是算法所需时间的上界,任何情况下都不会超过这个时间。
我整理了Python中常见操作的时间复杂度,供大家参考。
∗
∗
∗
\ast \ast \ast
∗∗∗
除了选择合适的算法和数据结构外,Python开发过程中也有一些技巧可以提升程序执行速度。
多用列表推导式
能用列表推导式的地方尽量用列表推导式。比如找出10000以内3的倍数,我们可以这么写:
l = []
for i in range (1, 10000):
if i%3 == 0:
l.append(i)
用列表推导式来写会更好,不但代码简洁,性能也比上面的代码高,因为列表推导式比append
性能高。
l = [i for i in range (1, 100000) if i%3 == 0]
两段代码运行时间(%%timeit)对比
可见,循环100次取平均时间,列表推导式比用append要快。
少用.
操作
开发中尽量避免使用.
操作,比如
import math
val = math.sqrt(60)
应该替换为
from math import sqrt
val = sqrt(60)
以为当我们用.
调用方法时, 会先调用__getattribute()__
或 __getattr()__
,这两个方法中都包含一些字典操作,这些操作是会耗时的。
从上面的测试看,不用.
要快很多。所以多用 from module import function
直接将方法引入,避免用.
调用方法。
善用多重赋值
如果遇到连续变量赋值,比如
a = 2
b = 3
c = 4
d = 5
建议写成
a, b, c, d = 2, 3, 4, 5
避免使用全局变量
Python有global
关键字声明或关联全局变量。但是处理全局变量比局部变量要花费更多时间。因此如无必要,勿用全局变量。
尽量使用库方法
一个功能如果Python标准库或第三方库已经提供,就用库方法,不要自己去实现。库方法都是经过高度优化的,甚至很多底层是C语言实现的,我们自己写的方法大概率不会比库方法更高效,并且自己写也不符合DRY精神。
用join拼接字符串
很多语言都是用+
拼接字符串,当然Python也支持用+
拼接字符串,但我更推荐用join()
方法来拼接字符串,因为join()
拼接字符串比+
要快。+
会创建新字符串并将旧字符串的值复制过去,而join()
不会。
善用生成器
当我们要处理包含大量数据的列表时,用生成器语法会更快一点。
利用加速工具
有很多项目致力于通过提供更好的运行环境或运行时优化来提升Python的速度。其中成熟的有PyPy和Numba。PyPy比CPython快4.5倍;而Numba是一个JIT编译器,能将Python代码编译成机器码,极大提升科学计算的速度。所以如果条件允许,可以使用上面2个工具来加速Python代码。
用C/C++/Rust实现核心功能
C/C++/Rust都比Python快很多。Python的强大之处是可以和其他语言绑定。所以当处理某些对性能敏感的功能时,我们可以考虑用C/C++/Rust实现核心功能,然后绑定到Python语言上。Python中很多库都是这么做的,比如Numpy, Scipy, Pandas, PyPolars等。
使用最新版本的Python
Python的核心团队也在不懈地优化Python的性能。每一次新版本的发布都比上一版本更加优化,速度也更快。就在前不久,Python发布了最新的3.11.0,这个版本的性能得到极大提升,比3.10性能提升10% - 60%,比Python 2.7 还快 5%。所以,在条件允许的情况下,尽可能用更新版本的Python或获得性能上的提升。