加一行註釋,讓你的 Python 程序提速 10+ 倍!Numba 十分鐘上手指南

如果你在使用Python進行高性能計算,Numba提供的加速效果可以比肩原生的C/C++程序,只需要在函數上添加一行@jit的裝飾。它支持CPU和GPU,是數據科學家必不可少的編程利器。

劇照 | 《春光燦爛豬八戒》

之前的文章《源代碼如何被計算機執行》已經提到計算機只能執行二進制的機器碼,C、C++等編譯型語言依靠編譯器將源代碼轉化爲可執行文件後才能運行,Python、Java等解釋型語言使用解釋器將源代碼翻譯後在虛擬機上執行。對於Python,由於解釋器的存在,其執行效率比C語言慢幾倍甚至幾十倍。

以C語言爲基準,不同編程語言性能測試比較

上圖比較了當前流行的各大編程語言在幾個不同任務上的計算速度。C語言經過幾十年的發展,優化已經達到了極致。以C語言爲基準,大多數解釋語言,如Python、R會慢十倍甚至一百倍。Julia這個解釋語言是個“奇葩”,因爲它採用了JIT編譯技術。

解決Python執行效率低的問題,一種解決辦法是使用C/C++語言重寫Python函數,但是這要求程序員對C/C++語言熟悉,且調試速度慢,不適合絕大多數Python程序員。另外一種非常方便快捷的解決辦法就是使用Just-In-Time(JIT)技術,本文將解釋JIT技術的原理,並提供幾個案例,讓你十分鐘內學會JIT技術。

Python解釋器工作原理

Python是一門解釋語言,Python爲我們提供了基於硬件和操作系統的一個虛擬機,並使用解釋器將源代碼轉化爲虛擬機可執行的字節碼。字節碼在虛擬機上執行,得到結果。

Python解釋器工作原理

我們使用python example.py來執行一份源代碼時,Python解釋器會在後臺啓動一個字節碼編譯器(Bytecode Compiler),將源代碼轉換爲字節碼。字節碼是一種只能運行在虛擬機上的文件,Python的字節碼默認後綴爲.pyc,Python生成.pyc後一般放在內存中繼續使用,並不是每次都將.pyc文件保存到磁盤上。有時候我們會看到自己Python代碼文件夾裏有很多.pyc文件與.py文件同名,但也有很多時候看不到.pyc文件。pyc字節碼通過Python虛擬機與硬件交互。虛擬機的出現導致程序和硬件之間增加了中間層,運行效率大打折扣。相信使用過虛擬機軟件的朋友深有體會,在原生的系統上安裝一個虛擬機軟件,在虛擬機上再運行一個其他系統,經常感覺速度下降,體驗變差,這與Python虛擬機導致程序運行慢是一個原理。

Just-In-Time(JIT)技術爲解釋語言提供了一種優化,它能克服上述效率問題,極大提升代碼執行速度,同時保留Python語言的易用性。使用JIT技術時,JIT編譯器將Python源代碼編譯成機器直接可以執行的機器語言,並可以直接在CPU等硬件上運行。這樣就跳過了原來的虛擬機,執行速度幾乎與用C語言編程速度並無二致。

十分鐘上手Numba

Numba是一個針對Python的開源JIT編譯器,由Anaconda公司主導開發,可以對原生代碼進行CPU和GPU加速。

使用conda安裝Numba:

$ conda install numba

或者使用pip安裝:

$ pip install numba

使用時,只需要在原來的函數上添加一行"註釋":

from numba import jit
import numpy as np

SIZE = 2000
x = np.random.random((SIZE, SIZE))

"""
給定n*n矩陣,對矩陣每個元素計算tanh值,然後求和。
因爲要循環矩陣中的每個元素,計算複雜度爲 n*n。
"""
@jit
def jit_tan_sum(a):   # 函數在被調用時編譯成機器語言
    tan_sum = 0
    for i in range(SIZE):   # Numba 支持循環
        for j in range(SIZE):
            tan_sum += np.tanh(a[i, j])   # Numba 支持絕大多數NumPy函數
    return tan_sum

print(jit_tan_sum(x))

我們只需要在原來的代碼上添加一行@jit,即可將一個函數編譯成機器碼,其他地方都不需要更改。@符號裝飾了原來的代碼,所以稱類似寫法爲裝飾器

在我的Core i5處理器上,添加@jit裝飾器後,上面的代碼執行速度提升了23倍!而且隨着數據和計算量的增大,numba的性能提升可能會更大!很多朋友的代碼可能需要執行十幾個小時甚至一天,進行加速,完全有可能把一天的計算量縮短到幾個小時!

Numba的使用場景

Numba簡單到只需要在函數上加一個裝飾就能加速程序,但也有缺點。目前Numba只支持了Python原生函數和部分NumPy函數,其他一些場景可能不適用。比如Numba官方給出這樣的例子:

from numba import jit
import pandas as pd

x = {'a': [1, 2, 3], 'b': [20, 30, 40]}

@jit
def use_pandas(a): # Function will not benefit from Numba jit
    df = pd.DataFrame.from_dict(a) # Numba doesn't know about pd.DataFrame
    df += 1                        # Numba doesn't understand what this is
    return df.cov()                # or this!

print(use_pandas(x))

pandas是更高層次的封裝,Numba其實不能理解它裏面做了什麼,所以無法對其加速。一些大家經常用的機器學習框架,如scikit-learntensorflowpytorch等,已經做了大量的優化,不適合再使用Numba做加速。

此外,Numba不支持:

  • try…except 異常處理

  • with 語句

  • yield from

注 Numba當前支持的功能:http://numba.pydata.org/numba-doc/latest/reference/pysupported.html

那如何決定是否使用Numba呢?

Numba的@jit裝飾器就像自動駕駛,用戶不需要關注到底是如何優化的,Numba去嘗試進行優化,如果發現不支持,那麼Numba會繼續用Python原來的方法去執行該函數,即圖 Python解釋器工作原理中左側部分。這種模式被稱爲object模式。前文提到的pandas的例子,Numba發現無法理解裏面的內容,於是自動進入了object模式。object模式還是和原生的Python一樣慢,還有可能比原來更慢。

Numba真正牛逼之處在於其nopython模式。將裝飾器改爲@jit(nopython=True)或者@njit,Numba會假設你已經對所加速的函數非常瞭解,強制使用加速的方式,不會進入object模式,如編譯不成功,則直接拋出異常。nopython的名字會有點歧義,我們可以理解爲不使用很慢的Python,強制進入圖 Python解釋器工作原理中右側部分。

實踐上,一般推薦將代碼中計算密集的部分作爲單獨的函數提出來,並使用nopython方式優化,這樣可以保證我們能使用到Numba的加速功能。其餘部分還是使用Python原生代碼,在計算加速的前提下,避免過長的編譯時間。(有關編譯時間的問題下節將會介紹。)Numba可以與NumPy緊密結合,兩者一起,常常能夠得到近乎C語言的速度。儘管Numba不能直接優化pandas,但是我們可以將pandas中處理數據的for循環作爲單獨的函數提出來,再使用Numba加速。

編譯開銷

編譯源代碼需要一定的時間。C/C++等編譯型語言要提前把整個程序先編譯好,再執行可執行文件。Numba庫提供的是一種懶編譯(Lazy Compilation)技術,即在運行過程中第一次發現代碼中有@jit,纔將該代碼塊編譯。用到的時候才編譯,看起來比較懶,所以叫懶編譯。使用Numba時,總時間 = 編譯時間 + 運行時間。相比所能節省的計算時間,編譯的時間開銷很小,所以物有所值。對於一個需要多次調用的Numba函數,只需要編譯一次,後面再調用時就不需要編譯了。

from numba import jit
import numpy as np
import time

SIZE = 2000
x = np.random.random((SIZE, SIZE))

"""
給定n*n矩陣,對矩陣每個元素計算tanh值,然後求和。
因爲要循環矩陣中的每個元素,計算複雜度爲 n*n。
"""
@jit
def jit_tan_sum(a):   # 函數在被調用時編譯成機器語言
    tan_sum = 0
    for i in range(SIZE):   # Numba 支持循環
        for j in range(SIZE):
            tan_sum += np.tanh(a[i, j])   # Numba 支持絕大多數NumPy函數
    return tan_sum

# 總時間 = 編譯時間 + 運行時間
start = time.time()
jit_tan_sum(x)
end = time.time()
print("Elapsed (with compilation) = %s" % (end - start))

# Numba將加速的代碼緩存下來
# 總時間 = 運行時間
start = time.time()
jit_tan_sum(x)
end = time.time()
print("Elapsed (after compilation) = %s" % (end - start))

代碼中兩次調用Numba優化函數,第一次執行時需要編譯,第二次使用緩存的代碼,運行時間將大大縮短:

Elapsed (with compilation) = 0.49199914932250977
Elapsed (after compilation) = 0.0364077091217041

原生Python速度慢的另一個重要原因是變量類型不確定。聲明一個變量的語法很簡單,如a = 1,但沒有指定a到底是一個整數和一個浮點小數。Python解釋器要進行大量的類型推斷,會非常耗時。同樣,引入Numba後,Numba也要推斷輸入輸出的類型,才能轉化爲機器碼。針對這個問題,Numba給出了名爲Eager Compilation的優化方式。

from numba import jit, int32

@jit("int32(int32, int32)", nopython=True)
def f2(x, y):
    # A somewhat trivial example
    return x + y

@jit(int32(int32, int32))告知Numba你的函數在使用什麼樣的輸入和輸出,括號內是輸入,括號左側是輸出。這樣不會加快執行速度,但是會加快編譯速度,可以更快將函數編譯到機器碼上。

Numba到底有多快

網上有很多對Numba進行性能評測的文章,在一些計算任務上,Numba結合NumPy,可得到接近C語言的速度。

Numba性能測試

Numba的更多功能

除了上面介紹的加速功能,Numba還有很多其他功能。@vectorize裝飾器可以將一個函數向量化,變成類似NumPy函數一樣,直接處理矩陣和張量。R語言用戶可能非常喜歡這個功能。

Numba還可以使用GPU進行加速,目前支持英偉達的CUDA和AMD的ROC。GPU的工作原理和編程方法與CPU略有不同,本專欄會在後續文章中介紹GPU編程。

Numba原理

Numba編譯過程

Numba使用了LLVM和NVVM技術,這個技術可以將Python、Julia這樣的解釋語言直接翻譯成CPU或GPU可執行的機器碼。

小結

無論你是在做金融量化分析,還是計算機視覺,如果你在使用Python進行高性能計算,處理矩陣和張量,或包含其他計算密集型運算,Numba提供的加速效果可以比肩原生的C/C++程序,只需要在函數上添加一行@jit的裝飾。它支持CPU和GPU,是數據科學家必不可少的編程利器。

優質文章,推薦閱讀:

Python 中如何實現參數化測試?

對比 C++ 和 Python,談談指針與引用

如何給列表降維?sum()函數的妙用

join()方法的神奇用處與Intern機制的軟肋

感謝創作者的好文

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章