我正在嘗試?yán)斫鉃槭裁次业牟⑿衝umba函數(shù)的行為方式是這樣的。特別是,為什么它對arrays的使用方式如此敏感。
我有以下功能:
@njit(parallel=True)
def f(n):
g = lambda i,j: zeros(3) + sqrt(i*j)
x = zeros((n,3))
for i in prange(n):
for j in range(n):
tmp = g(i,j)
x[i] += tmp
return x
相信n足夠大,并行計(jì)算才有用。由于某些原因,這實(shí)際上使用更少的內(nèi)核運(yùn)行得更快?,F(xiàn)在,當(dāng)我做一個(gè)小改變時(shí)(x[i]
->x[i, :]
)。
@njit(parallel=True)
def f(n):
g = lambda i,j: zeros(3) + sqrt(i*j)
x = zeros((n,3))
for i in prange(n):
for j in range(n):
tmp = g(i,j)
x[i, :] += tmp
return x
性能明顯更好,并且可以隨內(nèi)核數(shù)量適當(dāng)擴(kuò)展(即,內(nèi)核越多越快)。為什么切片會(huì)使性能更好?更進(jìn)一步說,另一個(gè)有很大不同的變化是將lambda
函數(shù)轉(zhuǎn)換為外部njt函數(shù)。
@njit
def g(i,j):
x = zeros(3) + sqrt(i*j)
return x
@njit(parallel=True)
def f(n):
x = zeros((n,3))
for i in prange(n):
for j in range(n):
tmp = g(i,j)
x[i, :] += tmp
return x
這再次破壞了性能和擴(kuò)展,恢復(fù)到與第一種情況相同或更低的運(yùn)行時(shí)。為什么這個(gè)外部函數(shù)會(huì)破壞性能?可以使用以下兩個(gè)選項(xiàng)恢復(fù)性能。
@njit
def g(i,j):
x = sqrt(i*j)
return x
@njit(parallel=True)
def f(n):
x = zeros((n,3))
for i in prange(n):
for j in range(n):
tmp = zeros(3) + g(i,j)
x[i, :] += tmp
return x
@njit(parallel=True)
def f(n):
def g(i,j):
x = zeros(3) + sqrt(i*j)
return x
x = zeros((n,3))
for i in prange(n):
for j in range(n):
tmp = g(i,j)
x[i, :] += tmp
return x
為什么parallel=True
numba修飾函數(shù)對arrays的使用方式如此敏感?我知道arrays不是簡單的可并行化的,但這些變化顯著影響性能的確切原因?qū)ξ襾碚f并不明顯。
TL;DR:分配和內(nèi)聯(lián)當(dāng)然是不同版本之間性能差距的根源。
在Numpy陣列上操作通常比在Numba中查看要貴一點(diǎn)。在這種情況下,問題似乎是Numba在使用
x[i]
時(shí)執(zhí)行分配,而不使用{%12}。問題是分配是昂貴的,特別是在并行代碼中,因?yàn)榉峙淦魍豢缮炜s(由于內(nèi)部鎖或序列化執(zhí)行的原子變量)。我不確定這是否錯(cuò)過了優(yōu)化,因?yàn)?code>x[i]和x[i,:]
的行為可能略有不同。此外,Numba使用JIT編譯器(LLVM-Lite)來執(zhí)行積極的優(yōu)化。LLVM能夠跟蹤分配,以便在簡單的情況下刪除它們(如函數(shù)在同一范圍內(nèi)執(zhí)行分配并釋放數(shù)據(jù),而不會(huì)產(chǎn)生副作用)。問題是Numba分配正在調(diào)用編譯器無法優(yōu)化的外部函數(shù),因?yàn)樗诰幾g時(shí)不知道內(nèi)容(由于Numba運(yùn)行時(shí)接口當(dāng)前的工作方式),并且該函數(shù)理論上可能會(huì)產(chǎn)生副作用。
為了顯示發(fā)生了什么,我們需要深入研究匯編代碼??傊?,Numba為
f
在N threads中調(diào)用xxx_numba_parfor_gufunc_xxx
函數(shù)生成了一個(gè)函數(shù)。最后一個(gè)函數(shù)執(zhí)行并行循環(huán)的內(nèi)容。兩種實(shí)現(xiàn)的調(diào)用者函數(shù)都相同。兩個(gè)版本的主要計(jì)算功能不同。這是我機(jī)器上的裝配代碼:與第二部分相比,第一個(gè)版本的代碼是巨大的。總的來說,我們可以看到計(jì)算量最大的部分大致相同:
雖然第一個(gè)版本的代碼比第二個(gè)版本的效率稍低,但這一差異顯然遠(yuǎn)遠(yuǎn)不足以解釋時(shí)間上的巨大差距(~65ms VS<0.6ms)。
我們還可以看到,兩個(gè)版本的匯編代碼中的函數(shù)調(diào)用不同:
NRT_Allocate
、NRT_Free
、NRT_decref
和NRT_incref
函數(shù)調(diào)用表明編譯后的代碼在熱循環(huán)的中間創(chuàng)建了一個(gè)新的Python對象,這是非常低效的。同時(shí),第二個(gè)版本不執(zhí)行任何NRT_incref
,我懷疑NRT_decref
實(shí)際上從未被調(diào)用過(或者可能只調(diào)用一次)。第二個(gè)代碼不執(zhí)行Numpy數(shù)組分配。看起來對PyErr_Clear
、numba_do_raise
和numba_unpickle
的調(diào)用是為了管理可能引發(fā)的異常(但在第一個(gè)版本中并非如此,因此可能與視圖的使用有關(guān))。最后,在第一個(gè)版本中對memcpy
的調(diào)用表明,新創(chuàng)建的數(shù)組確實(shí)復(fù)制到了x
。分配和復(fù)制使第一個(gè)版本非常低效。我很驚訝Numba沒有為
zeros(3)
生成分配。這是grea,但您應(yīng)該避免在這樣的熱循環(huán)中創(chuàng)建arrays,因?yàn)闆]有g(shù)arantee Numba。Numba將始終優(yōu)化這樣的調(diào)用。事實(shí)上,它通常不會(huì)。您可以使用基本循環(huán)復(fù)制切片的所有項(xiàng),以避免任何分配。如果在編譯時(shí)知道切片的大小,這通常會(huì)更快。切片復(fù)制可能會(huì)更快,因?yàn)閺?fù)印機(jī)可能會(huì)更好地對代碼進(jìn)行矢量化,但在實(shí)踐中,這樣的循環(huán)相對較好auto-vectorized。
可以注意到,兩個(gè)版本的代碼中都有
vsqrtsd
指令,因此lambda實(shí)際上是內(nèi)聯(lián)的。當(dāng)您將lambda從函數(shù)中移出并將其內(nèi)容放入另一個(gè)jitted函數(shù)中時(shí),LLVM可能不會(huì)內(nèi)聯(lián)該函數(shù)。您可以在生成中間表示(IR代碼)之前請求Numba手動(dòng)內(nèi)聯(lián)函數(shù),以便LLVM生成類似的代碼。這可以使用
inline="always"
標(biāo)志來完成。這會(huì)增加編譯時(shí)間(因?yàn)檎{(diào)用函數(shù)中的代碼接近c(diǎn)opy-pasted)。內(nèi)聯(lián)對于應(yīng)用許多進(jìn)一步的優(yōu)化(恒定傳播、SIMD矢量化、etc.)至關(guān)重要,這可能會(huì)導(dǎo)致巨大的性能提升。