讨厌 Python 的人总是说, 他们不想使用它的原因之一是它很慢. 不管使用什么编程语言, 程序是快还是慢都在很大程度上取决于编写程序的开发人员, 以及他们编写最优化快速程序的技能和能力. 在本文中, 让我们来证明一下某些人的 "误解", 看看如何提高 Python 程序的性能, 使它们变得非常快!
本文最初发布于 martinheinz.dev 博客, 经原作者授权由 InfoQ 中文站翻译并分享.
计时和性能分析
在我们开始优化任何东西之前, 我们首先需要找出到底是代码的哪些部分减慢了整个程序. 有时候, 程序的瓶颈可能是显而易见的, 但如果你不知道它在哪里, 那么以下选项可以帮你找出来.
这是我将用于演示的程序, 它计算 e 的 X 次方(摘自 Python 文档):
- # slow_program.py
- fromdecimalimport*
- def exp(x):
- getcontext().prec +=2
- i, lasts, s, fact, num =0,0,1,1,1
- whiles != lasts:
- lasts = s
- i +=1
- fact *= i
- num *= x
- s += num / fact
- getcontext().prec -=2
- return+s
- exp(Decimal(150))
- exp(Decimal(400))
- exp(Decimal(3000))
最懒的 "性能分析"
首先是最简单同时又非常懒惰的解决方案 --Unix time 命令:
- ~ $ time python3.8 slow_program.py
- real 0m11,058s
- user 0m11,050s
- sys 0m0,008s
如果你只是想计算整个程序的运行时间, 这就行了, 但这通常不能满足需求......
最详细的性能分析
另一个极端是 cProfile, 它提供的信息又太多了:
- ~$python3.8-mcProfile-stimeslow_program.py
- 1297functioncalls(1272primitivecalls)in11.081seconds
- Ordered by:internaltime
- ncallstottimepercallcumtimepercallfilename:lineno(function)
- 311.0793.69311.0793.693slow_program.py:4(exp)
- 10.0000.0000.0020.002{
- built-inmethod_imp.create_dynamic
- }
- 4/10.0000.00011.08111.081{
- built-inmethodbuiltins.exec
- }
- 60.0000.0000.0000.000{
- built-inmethod__new__oftypeobjectat0x9d12c0
- }
- 60.0000.0000.0000.000abc.py:132(__new__)
- 230.0000.0000.0000.000_weakrefset.py:36(__init__)
- 2450.0000.0000.0000.000{
- built-inmethodbuiltins.getattr
- }
- 20.0000.0000.0000.000{
- built-inmethodmarshal.loads
- }
- 100.0000.0000.0000.000<frozenimportlib._bootstrap_external>:1233(find_spec)
- 8/40.0000.0000.0000.000abc.py:196(__subclasscheck__)
- 150.0000.0000.0000.000{
- built-inmethodposix.stat
- }
- 60.0000.0000.0000.000{
- built-inmethodbuiltins.__build_class__
- }
- 10.0000.0000.0000.000__init__.py:357(namedtuple)
- 480.0000.0000.0000.000<frozenimportlib._bootstrap_external>:57(_path_join)
- 480.0000.0000.0000.000<frozenimportlib._bootstrap_external>:59(<listcomp>)
- 10.0000.00011.08111.081slow_program.py:1(<module>)
在这里, 我们使用 cProfile 模块和 time 参数运行测试脚本, 这样就可以根据内部时间 (cumtime) 对代码行进行排序. 这给了我们很多信息, 上面的内容大约是实际输出的 10%. 从这里, 我们可以看到 exp 函数是罪魁祸首(惊喜!), 现在我们可以得到更具体的时间和性能分析...
对具体的函数计时
现在我们知道了应该将注意力放在哪里, 我们可能希望对慢速函数进行计时, 而不需要测量代码的其余部分. 我们可以使用简单的装饰器:
- def timeit_wrapper(func):
- @wraps(func)
- defwrapper(*args, **kwargs):
- start =time.perf_counter() # Alternatively, you can usetime.process_time()
- func_return_val = func(*args, **kwargs)
- end =time.perf_counter()
- print('{0:<10}.{1:<8} : {2:<8}'.format(func.__module__, func.__name__,end-start))
- returnfunc_return_val
- returnwrapper
接下来, 可以把这个装饰器应用到函数上, 像下面这样:
- @timeit_wrapper
- defexp(x):
- ...
- print('{0:<10} {1:<8} {2:^8}'.format('module','function','time'))
- exp(Decimal(150))
- exp(Decimal(400))
- exp(Decimal(3000))
输出如下:
- ~ $ python3.8slow_program.py
- module functiontime
- __main__.exp:0.003267502994276583
- __main__.exp:0.038535295985639095
- __main__.exp:11.728486061969306
需要考虑的一件事是我们实际上 (想) 测量的是哪种时间. 时间包提供了 time.perf_counter 和 time.process_time. 它们的不同之处在于 perf_counter 返回绝对值, 其中包括 Python 程序进程不运行时的时间, 因此可能会受到机器负载的影响. 另一方面, process_time 只返回用户时间(不包括系统时间), 只是进程的时间.
使之变快
有趣的部分来了. 我们将让你的 Python 程序运行得更快一些. 我 (基本上) 不会向你展示一些能够神奇地解决性能问题的骇客技术, 技巧和代码片段. 这里介绍的更多的是一般的想法和策略, 当你使用它们时, 可以对性能产生巨大的影响, 在某些情况下可以提高 30% 的速度.
使用内置数据类型
这一点很明显. 内置数据类型非常快, 特别是与树或链表等自定义类型相比. 这主要是因为内置类型是用 C 实现的, 在用 Python 编码时, 我们无法在速度上与之匹配.
使用 lru_cache 缓存数据
我已经在之前的 博文 https://martinheinz.dev/blog/4 中介绍过这个, 但是我认为值得通过一个简单的例子再说明一下:
- importfunctools
- importtime
- # 最多缓存 12 个不同的结果
- @functools.lru_cache(maxsize=12)
- defslow_func(x):
- time.sleep(2)# 模拟长时间计算
- returnx
- slow_func(1)# ... 等待 2 秒才能获得结果
- slow_func(1)# 结果已缓存, 会立即返回
- slow_func(3)# ... 等待 2 秒才能获得结果
上面的函数使用 time.sleep 模拟大量计算. 第一次使用参数 1 调用时, 它将等待 2 秒, 然后才返回结果. 当再次调用时, 结果已经被缓存, 因此, 它会跳过函数体并立即返回结果. 要了解更多真实的例子, 请点击 这里 https://martinheinz.dev/blog/4 查看以前的博文.
使用局部变量
这与在每个作用域内查找变量的速度有关. 我会写每个作用域, 因为它不只关乎使用局部变量还是全局变量. 查找速度也确实存在差异, 函数中的局部变量最快, 类级属性 (例如 self.name) 次之, 而全局 (例如导入的函数 time.time) 变量最慢.
你可以像下面这样, 使用不必要的赋值来提升性能:
- # 示例 #1
- classFastClass:
- defdo_stuff(self):
- temp =self.value# 这可以加速循环中的查找
- foriinrange(10000):
- ...# 在这里使用 `temp` 做些操作
- # 示例 #2
- import random
- deffast_function():
- r = random.random
- foriinrange(10000):
- print(r())# 在这里调用 `r()`, 比全局的 random.random() 要快
使用函数
这看起来可能不符合直觉, 因为调用函数会将更多的东西放到堆栈中, 从函数返回时会产生开销, 但这与前面一点有关. 如果你只是将整个代码放入一个文件中, 而不将其放入函数中, 那么由于全局变量的关系, 速度会慢很多. 因此, 你只是将整个代码封装在 main 函数中并调用一次, 就可以加快你的代码, 像这样:
- defmain():
- ...# 之前所有的全局代码
- main()
不要访问属性
另一个可能降低程序速度的是点操作符 (.), 它可以用于访问对象属性. 这个操作符使用 _getattribute__ 触发字典查找, 这会在代码中产生额外的开销. 那么, 我们如何才能避免(限制) 使用它呢?
- # 慢:
- importre
- defslow_func():
- foriinrange(10000):
- re.findall(regex, line)# 慢!
- # 快:
- fromreimportfindall
- deffast_func():
- foriinrange(10000):
- findall(regex, line)# 较快!
提防字符串
在循环中运行诸如模数 (%s) 或. format() 之类的方法时, 对字符串的操作可能会变得非常慢. 我们还有什么更好的选择吗? 根据 Raymond Hettinger 最近的 推文 , 我们唯一应该使用的是 f-string, 它是最易读, 最简洁, 最快速的方法. 因此, 根据那条推文, 你可以使用以下方法 -- 从最快的到最慢的:
- f'{s} {t}'# 快!
- s +' '+ t
- ' '.join((s, t))
- '%s %s'% (s, t)
- '{} {}'.format(s, t)
- Template('$s$t').substitute(s=s, t=t)# 慢!
生成器本身并没有更快, 因为它们允许延迟计算, 这节省的是内存而不是时间. 但是, 节省的内存可能会使得程序在实际运行时更快. 为什么? 如果你有一个大型数据集, 并且没有使用生成器(迭代器), 那么数据可能会溢出 CPU L1 缓存, 这将显著降低在内存中查找值的速度.
说到性能, 很重要的一点是 CPU 可以将它正在处理的所有数据保存在缓存中. 你可以看下 Raymond Hettingers 的演讲 , 他提到了这些问题.
小结
优化的第一原则是不做优化. 但是, 如果你真的需要, 我希望这些小技巧能帮到你. 不过, 在优化代码时要注意, 因为它可能会使代码难于阅读, 难于维护, 甚至超过优化带来的好处.
来源: http://www.tuicool.com/articles/rmaERzm