這些方法,能夠讓你的Python程序快如閃電

討厭 Python 的人總是會說,他們不想用 Python 的一個重要原因是 Python 很慢。而事實上,無論使用什麼編程語言,特定程序的運行速度很大程度上取決於編寫程序的開發人員以及他們優化程序、加快程序運行速度的技能。

那麼,讓我們證明那些人錯了!本文將介紹如何提升 Python 程序的效率,讓它們運行飛快!

計時與性能分析

在開始優化之前,我們首先需要找到代碼的哪一部分真正拖慢了整個程序。有時程序性能的瓶頸顯而易見,但當你不知道瓶頸在何處時,這裏有一些幫助找到性能瓶頸的辦法:

注:下列程序用作演示目的,該程序計算 e 的 X 次方(摘自 Python 文檔):

# slow_program.pyfrom 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 +sexp(Decimal(150))exp(Decimal(400))exp(Decimal(3000))

最懶惰的「性能分析」

首先,最簡單但說實話也很懶的方法——使用 Unix 的 time 命令:

~ $ time python3.8 slow_program.pyreal    0m11,058suser    0m11,050ssys     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_wrapperdef 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.pymodule     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 functoolsimport time# caching up to 12 different [email protected]_cache(maxsize=12)def slow_func(x):    time.sleep(2)  # Simulate long computation    return xslow_func(1)  # ... waiting for 2 sec before getting resultslow_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 #1class 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 #2import randomdef 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 codemain()

不要訪問屬性

另一個可能讓程序變慢的東西是用來訪問對象屬性的點運算符(.)。這個運算符會引起程序使用__getattribute__進行字典查找,進而爲程序帶來不必要的開銷。那麼,我們怎麼避免(或者限制)使用它呢?

#  Slow:import redef slow_func():    for i in range(10000):        re.findall(regex, line)  # Slow!#  Fast:from re import findalldef 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 的演講(Transforming Code into Beautiful, Idiomatic Python - YouTube

總結

優化的第一要義就是「不要去做」。但如果你必須要做,我希望這些小技巧可以幫助到你。然而,優化代碼時一定要謹慎,因爲該操作可能最終造成代碼可讀性變差、可維護性變差,這些弊端可能超過代碼優化所帶來的好處。

參考鏈接: https://towardsdatascience.com/making-python-programs-blazingly-fast-c1cd79bd1b32