本文将介紹如何提升 Python 程式的效率,讓它們運作飛快!
選自towardsdatascience,作者:Martin Heinz,機器之心編譯,參與:郭元晨、魔王。
讨厭 Python 的人總是會說,他們不想用 Python 的一個重要原因是 Python 很慢。而事實上,無論使用什麼程式設計語言,特定程式的運作速度很大程度上取決于編寫程式的開發人員以及他們優化程式、加快程式運作速度的技能。
那麼,讓我們證明那些人錯了!本文将介紹如何提升 Python 程式的效率,讓它們運作飛快!
計時與性能分析
在開始優化之前,我們首先需要找到代碼的哪一部分真正拖慢了整個程式。有時程式性能的瓶頸顯而易見,但當你不知道瓶頸在何處時,這裡有一些幫助找到性能瓶頸的辦法:
注:下列程式用作示範目的,該程式計算 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))
最懶惰的「性能分析」
首先,最簡單但說實話也很懶的方法——使用 Unix 的 time 指令:
~ $ 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 :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 :57(_path_join)
48 0.000 0.000 0.000 0.000 :59()
1 0.000 0.000 11.081 11.081 slow_program.py:1()
...
這裡,我們結合 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()
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
接着,将該裝飾器按如下方式應用在待測函數上:
@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 庫提供了 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-string 即可,它可讀性更強,代碼更加緊湊,并且速度更快!基于這一觀點,如下從快到慢列出了你可以使用的一系列方法:
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!
生成器本質上并不會更快,因為它們的目的是惰性計算,以節省記憶體而非節省時間。然而,節省的記憶體會讓程式運作更快。為什麼呢?如果你有一個大型資料集,并且你沒有使用生成器(疊代器),那麼資料可能造成 CPU 的 L1 緩存溢出,進而導緻訪存速度顯著變慢。
當涉及到效率時,非常重要的一點是 CPU 會将它正在處理的資料儲存得離自己越近越好,也就是儲存在緩存中。讀者可以看一看 Raymond Hettingers 的演講(https://www.youtube.com/watch?v=OSGv2VnC0go&t=8m17s),其中提到了這些問題。
總結
優化的第一要義就是「不要去做」。但如果你必須要做,我希望這些小技巧可以幫助到你。然而,優化代碼時一定要謹慎,因為該操作可能最終造成代碼可讀性變差、可維護性變差,這些弊端可能超過代碼優化所帶來的好處。