代碼跑得慢甩鍋Python?手把手教你如何給代碼提速30%
大數據文摘出品
來源:Medium
編譯:王轉轉
Python已經得到了全球程序員的喜愛,但是還是遭到一些人的詬病,原因之一就是認為它運行緩慢。
其實某個特定程序(無論使用何種編程語言)的運行速度是快還是慢,在很大程度上取決于編寫該程序的開發人員自身素質,以及他們編寫優化而高效代碼的能力。
Medium上一位小哥就詳細講了講如何讓python提速30%,以此證明代碼跑得慢不是python的問題,而是代碼本身的問題。
時序分析
在開始進行任何優化之前,我們首先需要找出代碼的哪些部分使整個程序變慢。有時程序的問題很明顯,但是如果你一時不知道問題出在哪里,那么這里有一些可能的選項:
注意:這是我將用于演示的程序,它將進行指數計算(取自Python文檔):
- # slow_program.py
- from decimal import *
- def exp(x):
- getcontext().prec += 2
- i, lasts, s, fact, num = 0, 0, 1, 1, 1
- while s != 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 python3.8 slow_program.py
- real 0m11,058s
- user 0m11,050s
- sys 0m0,008s
如果你只能直到整個程序的運行時間,這樣就夠了,但通常這還遠遠不夠。
最詳細的分析
另外一個指令是cProfile,但是它提供的信息過于詳細了。
- ~ $ python3.8 -m cProfile -s time slow_program.py
- 1297 function calls (1272 primitive calls) in 11.081 seconds
- Ordered by: internal time
- ncalls tottime percall cumtime percall filename:lineno(function)
- 3 11.079 3.693 11.079 3.693 slow_program.py:4(exp)
- 1 0.000 0.000 0.002 0.002 {built-in method _imp.create_dynamic}
- 4/1 0.000 0.000 11.081 11.081 {built-in method builtins.exec}
- 6 0.000 0.000 0.000 0.000 {built-in method __new__ of type object at 0x9d12c0}
- 6 0.000 0.000 0.000 0.000 abc.py:132(__new__)
- 23 0.000 0.000 0.000 0.000 _weakrefset.py:36(__init__)
- 245 0.000 0.000 0.000 0.000 {built-in method builtins.getattr}
- 2 0.000 0.000 0.000 0.000 {built-in method marshal.loads}
- 10 0.000 0.000 0.000 0.000 <frozen importlib._bootstrap_external>:1233(find_spec)
- 8/4 0.000 0.000 0.000 0.000 abc.py:196(__subclasscheck__)
- 15 0.000 0.000 0.000 0.000 {built-in method posix.stat}
- 6 0.000 0.000 0.000 0.000 {built-in method builtins.__build_class__}
- 1 0.000 0.000 0.000 0.000 __init__.py:357(namedtuple)
- 48 0.000 0.000 0.000 0.000 <frozen importlib._bootstrap_external>:57(_path_join)
- 48 0.000 0.000 0.000 0.000 <frozen importlib._bootstrap_external>:59(<listcomp>)
- 1 0.000 0.000 11.081 11.081 slow_program.py:1(<module>)
在這里,我們使用cProfile模塊和time參數運行測試腳本,以便按內部時間(cumtime)對行進行排序。這給了我們很多信息,你在上面看到的行大約是實際輸出的10%。由此可見,exp函數是罪魁禍首,現在我們可以更詳細地了解時序和性能分析。
時序特定功能
現在我們知道了應當主要關注哪里,我們可能想對運行速度緩慢的函數計時,而不用測量其余的代碼。為此,我們可以使用一個簡單的裝飾器:
- def timeit_wrapper(func):
- @wraps(func)
- def wrapper(*args, **kwargs):
- start = time.perf_counter() # Alternatively, you can use time.process_time()
- funcfunc_return_val = func(*args, **kwargs)
- end = time.perf_counter()
- print('{0:<10}.{1:<8} : {2:<8}'.format(func.__module__, func.__name__, end - start))
- return func_return_val
- return wrapper
然后可以將此裝飾器應用于待測功能,如下所示:
- @timeit_wrapper
- def exp(x):
- ...
- print('{0:<10} {1:<8} {2:^8}'.format('module', 'function', 'time'))
- exp(Decimal(150))
- exp(Decimal(400))
- exp(Decimal(3000))
這給出我們如下輸出:
- ~ $ python3.8 slow_program.py
- module function time
- __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緩存/記憶
我已經在上一篇博客中展示了此內容,但我認為值得用簡單的示例來重復它:
- import functools
- import time
- # caching up to 12 different results
- @functools.lru_cache(maxsize=12)
- def slow_func(x):
- time.sleep(2) # Simulate long computation
- return x
- slow_func(1) # ... waiting for 2 sec before getting result
- slow_func(1) # already cached - result returned instantaneously!
- slow_func(3) # ... waiting for 2 sec before getting result
上面的函數使用time.sleep模擬大量計算。第一次使用參數1調用時,它將等待2秒鐘,然后才返回結果。再次調用時,結果已經被緩存,因此它將跳過函數的主體并立即返回結果。有關更多實際示例,請參見以前的博客文章。
使用局部變量
這與在每個作用域中查找變量的速度有關,因為它不只是使用局部變量還是全局變量。實際上,即使在函數的局部變量(最快),類級屬性(例如self.name——較慢)和全局(例如,導入的函數)如time.time(最慢)之間,查找速度實際上也有所不同。
你可以通過使用看似不必要的分配來提高性能,如下所示:
- # Example #1
- class FastClass:
- def do_stuff(self):
- temp = self.value # this speeds up lookup in loop
- for i in range(10000):
- ... # Do something with `temp` here
- # Example #2
- import random
- def fast_function():
- r = random.random
- for i in range(10000):
- print(r()) # calling `r()` here, is faster than global random.random()
使用函數
這似乎違反直覺,因為調用函數會將更多的東西放到堆棧上,并從函數返回中產生開銷,但這與上一點有關。如果僅將整個代碼放在一個文件中而不將其放入函數中,則由于全局變量,它的運行速度會慢得多。因此,你可以通過將整個代碼包裝在main函數中并調用一次來加速代碼,如下所示:
- def main():
- ... # All your previously global code
- main()
不訪問屬性
可能會使你的程序變慢的另一件事是點運算符(.),它在獲得對象屬性時被使用。此運算符使用__getattribute__觸發字典查找,這會在代碼中產生額外的開銷。那么,我們如何才能真正避免(限制)使用它呢?
- # Slow:
- import re
- def slow_func():
- for i in range(10000):
- re.findall(regex, line) # Slow!
- # Fast:
- from re import findall
- def fast_func():
- for i in range(10000):
- findall(regex, line) # Faster!
當心字符串
使用模數(%s)或.format()進行循環運行時,字符串操作可能會變得非常慢。我們有什么更好的選擇?根據雷蒙德·海廷格(Raymond Hettinger)最近的推特,我們唯一應該使用的是f字符串,它是最易讀,最簡潔且最快的方法。根據該推特,這是你可以使用的方法列表——最快到最慢:
- f'{s} {t}' # Fast!
- s + ' ' + t
- ' '.join((s, t))
- '%s %s' % (s, t)
- '{} {}'.format(s, t)
- Template('$s $t').substitute(ss=s, tt=t) # Slow!
生成器本質上并沒有更快,因為它們被允許進行延遲計算,從而節省了內存而不是時間。但是,保存的內存可能會導致你的程序實際運行得更快。這是怎么做到的?如果你有一個很大的數據集,而沒有使用生成器(迭代器),那么數據可能會溢出CPU L1緩存,這將大大減慢內存中值的查找速度。
在性能方面,非常重要的一點是CPU可以將正在處理的所有數據盡可能地保存在緩存中。你可以觀看Raymond Hettingers的視頻,他在其中提到了這些問題。
結論
優化的首要規則是不要優化。但是,如果確實需要,那么我希望上面這些技巧可以幫助你。但是,在優化代碼時要小心,因為它可能最終使你的代碼難以閱讀,因此難以維護,這可能超過優化的好處。
相關報道:
https://towardsdatascience.com/making-python-programs-blazingly-fast-c1cd79bd1b32
【本文是51CTO專欄機構大數據文摘的原創譯文,微信公眾號“大數據文摘( id: BigDataDigest)”】