【進(jìn)階】嫌棄Python慢,試試這幾個(gè)方法?
計(jì)時(shí)與性能分析
在開始優(yōu)化之前,我們首先需要找到代碼的哪一部分真正拖慢了整個(gè)程序。有時(shí)程序性能的瓶頸顯而易見,但當(dāng)你不知道瓶頸在何處時(shí),這里有一些幫助找到性能瓶頸的辦法:
注:下列程序用作演示目的,該程序計(jì)算 e 的 X 次方(摘自 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))
最懶惰的「性能分析」
首先,最簡單但說實(shí)話也很懶的方法——使用 Unix 的 time 命令:
- ~ $ time python3.8 slow_program.py
- real 0m11,058s
- user 0m11,050s
- sys 0m0,008s
如果你只想給整個(gè)程序計(jì)時(shí),這個(gè)命令即可完成目的,但通常是不夠的……
最細(xì)致的性能分析
另一個(gè)極端是 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>)
- ...
這里,我們結(jié)合 cProfile 模塊和 time 參數(shù)運(yùn)行測(cè)試腳本,使輸出行按照內(nèi)部時(shí)間(cumtime)排序。這給我們提供了大量信息,上面你看到的行只是實(shí)際輸出的 10%。從輸出結(jié)果我們可以看到 exp 函數(shù)是罪魁禍?zhǔn)?驚不驚喜,意不意外),現(xiàn)在我們可以更加專注于計(jì)時(shí)和性能分析了……
計(jì)時(shí)專用函數(shù)
現(xiàn)在我們知道了需要關(guān)注哪里,那么我們可能只想要給運(yùn)行緩慢的函數(shù)計(jì)時(shí)而不去管代碼的其他部分。我們可以使用一個(gè)簡單的裝飾器來做到這點(diǎn):
- def timeit_wrapper(func):
- @wraps(func)
- def wrapper(*args, **kwargs):
- start = time.perf_counter() # Alternatively, you can use time.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))
- return func_return_val
- return wrapper
接著,將該裝飾器按如下方式應(yīng)用在待測(cè)函數(shù)上:
- @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
此時(shí)我們需要考慮想要測(cè)量哪一類時(shí)間。time 庫提供了 time.perf_counter 和 time.process_time 兩種時(shí)間。其區(qū)別在于,perf_counter 返回絕對(duì)值,其中包括了 Python 程序并不在運(yùn)行的時(shí)間,因此它可能受到機(jī)器負(fù)載的影響。而 process_time 只返回用戶時(shí)間(除去了系統(tǒng)時(shí)間),也就是只有進(jìn)程運(yùn)行時(shí)間。
讓程序更快
現(xiàn)在到了真正有趣的部分了,讓 Python 程序跑得更快!我不會(huì)告訴你一些奇技淫巧或代碼段來神奇地解決程序的性能問題,而更多是關(guān)于通用的想法和策略。使用這些策略,可以對(duì)程序性能產(chǎn)生巨大的影響,有時(shí)甚至可以帶來高達(dá) 30% 的提速。
使用內(nèi)置的數(shù)據(jù)類型
這一點(diǎn)非常明顯。內(nèi)置的數(shù)據(jù)類型非??欤绕湎啾扔跇浠蜴湵淼茸远x類型而言。這主要是因?yàn)閮?nèi)置數(shù)據(jù)類型使用 C 語言實(shí)現(xiàn),使用 Python 實(shí)現(xiàn)的代碼在運(yùn)行速度上和它們沒法比。
使用 lru_cache 實(shí)現(xiàn)緩存/記憶
我在之前的博客中介紹過這一技巧,但我認(rèn)為它值得用一個(gè)簡單例子再次進(jìn)行說明:
- 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
上面的函數(shù)使用 time.sleep 模擬了繁重的計(jì)算過程。當(dāng)我們第一次使用參數(shù) 1 調(diào)用函數(shù)時(shí),它等待了 2 秒鐘后返回了結(jié)果。當(dāng)再次調(diào)用時(shí),結(jié)果已經(jīng)被緩存起來,所以它跳過了函數(shù)體,直接返回結(jié)果。
使用局部變量
這和每個(gè)作用域中變量的查找速度有關(guān)。我之所以說「每個(gè)作用域」,是因?yàn)檫@不僅僅關(guān)乎局部變量或全局變量。事實(shí)上,就連函數(shù)中的局部變量、類級(jí)別的屬性和全局導(dǎo)入函數(shù)這三者的查找速度都會(huì)有區(qū)別。函數(shù)中的局部變量最快,類級(jí)別屬性(如 self.name)慢一些,全局導(dǎo)入函數(shù)(如 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()
使用函數(shù)
這也許有些反直覺,因?yàn)檎{(diào)用函數(shù)會(huì)讓更多的東西入棧,進(jìn)而在函數(shù)返回時(shí)為程序帶來負(fù)擔(dān),但這其實(shí)和之前的策略相關(guān)。如果你只是把所有代碼扔進(jìn)一個(gè)文件而沒有把它們放進(jìn)函數(shù),那么它會(huì)因?yàn)楸姸嗟娜肿兞慷兟R虼?,你可以通過將所有代碼封裝在 main 函數(shù)中并調(diào)用它來實(shí)現(xiàn)加速,如下所示:
- def main():
- ... # All your previously global code
- main()
不要訪問屬性
另一個(gè)可能讓程序變慢的東西是用來訪問對(duì)象屬性的點(diǎn)運(yùn)算符(.)。這個(gè)運(yùn)算符會(huì)引起程序使用__getattribute__進(jìn)行字典查找,進(jìn)而為程序帶來不必要的開銷。那么,我們?cè)趺幢苊?或者限制)使用它呢?
- # 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!
當(dāng)心字符串
當(dāng)在循環(huán)中使用取模運(yùn)算符(%s)或 .format() 時(shí),字符串操作會(huì)變得很慢。有沒有更好的選擇呢?根據(jù) Raymond Hettinger 近期發(fā)布的推文,我們只需要使用 f-string 即可,它可讀性更強(qiáng),代碼更加緊湊,并且速度更快!基于這一觀點(diǎn),如下從快到慢列出了你可以使用的一系列方法:
- f'{s} {t}' # Fast!
- s + ' ' + t
- ' '.join((s, t))
- '%s %s' % (s, t)
- '{} {}'.format(s, t)
- Template('$s $t').substitute(s=s, t=t) # Slow!
生成器本質(zhì)上并不會(huì)更快,因?yàn)樗鼈兊哪康氖嵌栊杂?jì)算,以節(jié)省內(nèi)存而非節(jié)省時(shí)間。然而,節(jié)省的內(nèi)存會(huì)讓程序運(yùn)行更快。為什么呢?如果你有一個(gè)大型數(shù)據(jù)集,并且你沒有使用生成器(迭代器),那么數(shù)據(jù)可能造成 CPU 的 L1 緩存溢出,進(jìn)而導(dǎo)致訪存速度顯著變慢。
當(dāng)涉及到效率時(shí),非常重要的一點(diǎn)是 CPU 會(huì)將它正在處理的數(shù)據(jù)保存得離自己越近越好,也就是保存在緩存中。讀者可以看一看 Raymond Hettingers 的演講(https://www.youtube.com/watch?v=OSGv2VnC0go&t=8m17s),其中提到了這些問題。
總結(jié)
優(yōu)化的第一要義就是「不要去做」。但如果你必須要做,我希望這些小技巧可以幫助到你。然而,優(yōu)化代碼時(shí)一定要謹(jǐn)慎,因?yàn)樵摬僮骺赡茏罱K造成代碼可讀性變差、可維護(hù)性變差,這些弊端可能超過代碼優(yōu)化所帶來的好處。