我們將使用“不安全”的Python將一些Numpy程式碼加速100倍。 假設你在用pygame編寫一個遊戲,並且你需要經常調整影象大小。我們可以使用pygame或openCV調整影象大小:
from contextlib import contextmanager import time import pygame as pg import numpy as np import cv2 @contextmanager def Timer(name): start = time.time() yield finish = time.time() print(f'{name} took {finish-start:.4f} sec') IW = 1920 IH = 1080 OW = IW // 2 OH = IH // 2 repeat = 10 # 譯者注:原文此處為100,參考作者github程式碼和後續執行結果,應該是10。 isurf = pg.Surface((IW,IH), pg.SRCALPHA) with Timer('pg.Surface with smoothscale'): for i in range(repeat): pg.transform.smoothscale(isurf, (OW,OH)) def cv2_resize(image): return cv2.resize(image, (OH,OW), interpolation=cv2.INTER_AREA) i1 = np.zeros((IW,IH,3), np.uint8) with Timer('np.zeros with cv2'): for i in range(repeat): o1 = cv2_resize(i1)
輸出為:
pg.Surface with smoothscale took 0.2002 sec np.zeros with cv2 took 0.0145 sec
使用openCV加速了10倍,所以我們回到遊戲中,使用pygame.surfarray.pixels3d以零複製的方式訪問畫素作為Numpy陣列,然後使用cv2.resize,然而,一切都變慢了!
i2 = pg.surfarray.pixels3d(isurf) with Timer('pixels3d with cv2'): for i in range(repeat): o2 = cv2_resize(i2)
結果:
pixels3d with cv2 took 1.3625 sec
如果你去檢視陣列的.shape和.dtype,會發現他倆一樣。但是,同一個函式(cv2_resize)在一個數組上執行比另一個數組慢 100 倍,為什麼捏? SDL 應該不會在一些特別難以訪問的 RAM 區域分配畫素(即使它在理論上可以透過核心的一點幫助來做到這一點,比如建立一個不可快取的記憶體區域之類的)。或者Surface儲存在 GPU 中,透過 PCI 獲取每個畫素?!它不是這樣工作的,是嗎?-這些東西有一些可怕的記憶體一致性協議,我錯過了什麼嗎?如果不是——如果它們是相同形狀和大小的相同型別的記憶體——是什麼不同導致我們減速 100 倍?
結果證明…我承認我是偶然發現的,在放棄這個並轉向其他事情之後。完全偶然的是,那個其他事情涉及將 numpy 資料傳遞給 C 程式碼,所以我不得不學習這個資料在 C 中的樣子。所以,事實證明,shape和datatype並不是 numpy 陣列的全部:
print('input strides',i1.strides,i2.strides) print('output strides',o1.strides,o2.strides)
結果,輸出的步幅(stride)是不同的:
input strides (3240, 3, 1) (4, 7680, -1) output strides (1620, 3, 1) (1620, 3, 1)
所以是步幅的差異導致了程式碼慢了100倍?我們可以修復這個問題嗎?但首先我們要去解釋為什麼步幅不同。
numpy array記憶體佈局
所以步幅(stride)是什麼?步幅告訴您從一個畫素到下一個畫素需要跨越多少位元組。例如,假設我們有一個三維陣列,比如一個 RGB 影象。然後,給定陣列的基指標和三個步幅, array[x,y,z]的地址將是 base+x∗xstride+y∗ystride+z∗zstride (對於影象,z 的值為 0、1 或 2,分別對應 RGB 影象的三個通道之一)。
換句話說,步幅定義了陣列在記憶體中的佈局。無論好壞,numpy 在陣列形狀和資料型別方面非常靈活,因為它支援許多不同的步幅值。
手頭的兩種佈局 : numpy 的預設佈局和 SDL 的佈局 – 嗯,我甚至不知道哪個更冒犯我。從步幅值可以看出,numpy 預設用於 3D 陣列的佈局是 base+x∗3∗height+y∗3+z 。
這意味著一個畫素的 RGB 值儲存在 3 個相鄰的位元組中,一列的畫素在記憶體中連續儲存 – 以列為主序。我覺得這種方法很冒犯,因為影象傳統上是以行為主序儲存的,尤其是影象感測器以這種方式傳送影象(並以這種方式捕捉影象,正如您可以從滾動快門看到的 – 每一行在稍微不同的時間點進行捕捉,而不是按列進行)
“為什麼,我們確實也遵循這一受人尊敬的傳統,”流行的基於numpy的影象庫說道。“看看你自己——將一個形狀為 (1920, 1080) 的陣列儲存為 PNG 檔案,你會得到一張 1080×1920 的影象”。 這是真的,當然這使情況變得更糟:如果你使用 arr[x,y] 進行索引,那麼 x,也就是維度零,實際上對應於相應 PNG 檔案中的垂直維度;而 y,也就是維度一,對應於水平維度。因此,numpy 陣列的列對應於 PNG 影象的行。這在某種意義上使 numpy 影象佈局成為”行優先”,但代價是 x 和 y 的含義與通常相反。
…除非你從 pygame Surface 物件中獲取 numpy 陣列,否則 x 實際上是索引到水平維度的。因此,相對於 pygame.image.save(surface) 建立的 PNG 檔案,使用 imageio 儲存 pixels3d(surface) 將會產生一個轉置的 PNG。而且,如果這種侮辱還不夠,cv2.resize 使用 (width, height) 元組作為目標大小,將產生一個形狀為 (height, width) 的輸出陣列。
在這些侮辱和傷害的背景下,SDL 擁有一個誘人的、文明的佈局,其中 x 是 x,y 是 y,資料以誠實的行優先順序儲存,對於“行”的所有含義都是如此。但是仔細一看,這個佈局只是踐踏了我的感情: base+x∗4+y∗4∗width−z 。
像是我們在步幅中有 4 而不是 3 的部分,對於 RGB 影象我可以理解。當我們將 SRCALPHA 傳遞給 Surface 建構函式時,我們確實要求一個帶有 alpha 通道的 RGBA 影象。所以我猜它將 alpha 通道與 RGB 畫素一起保留,並且步幅中的 4 需要跳過 RBGA 中的 A。但是,我想問,為什麼有單獨的 pixels3d 和 pixels_alpha 函式?在使用 numpy 和 pygame Surface時,分別處理 RGB 和 alpha 總是很麻煩。為什麼不是一個單一的 pixels4d 函式呢?
…好吧,4 而不是 3 我可以接受。但是 zstride 為-1?負一?你從紅色畫素的地址開始,要到綠色,你要往回走一個位元組?!現在你只是在拿我開玩笑。
原來 SDL 支援 RGB 和 BGR 佈局(特別是,顯然從檔案載入的surface是 RGB,而在記憶體中建立的surface是 BGR?..或者比這更復雜?..)如果您使用 pygame 的 API,則無需擔心 RGB 與 BGR,API 會透明地處理它。如果您使用 pixels3d 進行 numpy 互操作,您也無需擔心 RGB 與 BGR,因為 numpy 的步幅靈活性使 pygame 可以為您提供一個看起來像 RGB 的陣列,儘管它在記憶體中是 BGR。為此,z 步幅設定為-1,並且陣列的基指標指向第一個畫素的紅色值-比陣列記憶體開始的位置提前兩個畫素,即第一個畫素的藍色值所在的位置。
等一下……現在我明白為什麼我們有 pixels3d 和 pixels_alpha,但沒有 pixels4d 了!!因為 SDL 有 RGBA 和 BGRA 影象——BGRA,而不是 ABGR——你無法使 BGRA 資料看起來像一個 RGBA numpy 陣列,無論你使用怎樣奇怪的步幅值。佈局靈活性是有限的……或者更確切地說,實際上沒有任何限制超過可計算限制,但幸運的是 numpy 止步於可配置步幅,並不允許您為完全可程式設計的佈局 指定一個通用回撥函式 addr(base, x, y, z) 。
爲了透明地支援 RGBA 和 BGRA,pygame 被迫給我們提供 2 個 numpy 陣列 – 一個用於 RGB(或 BGR,取決於surface),另一個用於 alpha 通道。這些 numpy 陣列具有正確的形狀,並讓我們訪問正確的資料,但它們的佈局與其形狀的普通陣列非常不同。
不同的記憶體佈局肯定可以解釋效能上的主要差異。我們可以試圖弄清楚為什麼效能差異幾乎是 100 倍。但是如果可能的話,我更願意擺脫垃圾,而不是詳細研究它。所以,我們不會深入理解這個問題,而是簡單地展示佈局差異確實解釋了 100 倍的差異,然後在不改變佈局的情況下襬脫減速,這就是“不安全的 Python”最終發揮作用的地方。
如何證明僅僅是佈局,而不是 pygame Surface 資料的其他屬性(比如分配的記憶體)導致了減速?我們可以對一個我們自己建立的具有與 pixels3d 相同佈局的 numpy 陣列進行 cv2.resize 的基準測試。
# create a byte array of zeros, and attach # a numpy array with pygame-like strides # to this byte array using the buffer argument. i3 = np.ndarray((IW, IH, 3), np.uint8, strides=(4, IW*4, -1), buffer=b''*(IW*IH*4), offset=2) # start at the "R" of BGR with Timer('pixels3d-like layout with cv2'): for i in range(repeat): o2 = cv2_resize(i3)
確實,這幾乎和我們在 pygame Surface 資料上測量的一樣慢:
pixels3d-like layout with cv2 took 1.3829 sec
出於好奇,我們可以檢查如果僅僅在這些佈局之間複製資料會發生什麼:
i4 = np.empty(i2.shape, i2.dtype) with Timer('pixels3d-like copied to same-shape array'): for i in range(repeat): i4[:] = i2 with Timer('pixels3d-like to same-shape array, copyto'): for i in range(repeat): np.copyto(i4, i2)
賦值運算子和 copyto 都非常慢,幾乎和 cv2.resize 一樣慢。
pixels3d-like copied to same-shape array took 1.2681 sec pixels3d-like to same-shape array, copyto took 1.2702 sec
愚弄程式碼以使其執行更快
我們能做什麼?我們無法改變 pygame Surface 資料的佈局。我們也絕對不想複製 cv2.resize 的 C++程式碼,因為它具有各種平臺特定的最佳化,看看我們是否能夠適應 Surface 佈局而不會丟失效能。我們可以做的是使用帶有 numpy 預設佈局的陣列將 Surface 資料饋送給 cv2.resize(而不是直接傳遞由 pixel3d 返回的陣列物件)。
請注意,這實際上並不適用於任何給定的函式。但它將特別適用於調整大小,因為它實際上並不關心資料的某些方面,我們實際上會公然歪曲:
• 調整大小的程式碼不在乎特定通道代表紅色還是藍色。(與將 RGB 轉換為灰度不同,後者會在意。)如果您給出 BGR 資料並謊稱它是 RGB,則程式碼將產生與給出實際 RGB 資料時相同的結果。
• 同樣,調整大小時,陣列維度代表寬度和高度的順序並不重要。
現在,讓我們再次來看看 pygame 的 BGRA 陣列的記憶體表示,其形狀是 (width, height) 。
這個表示實際上與一個形狀為 (height, width) 的 RGBA 陣列具有 numpy 的預設步幅是一樣的!我的意思是,不完全一樣 – 如果我們將這個資料重新解釋為 RGBA 陣列,我們將紅色通道(R)的值視為藍色(B),反之亦然。同樣地,如果我們將這個資料重新解釋為一個具有 numpy 的預設步幅的 (height, width) 陣列,我們將隱式地對影象進行轉置。但是調整大小並不在乎!
而且,作為額外的好處,我們將得到一個單獨的 RGBA 陣列,並且只需要一次呼叫 cv2.resize 來調整大小,而不是分別調整 pixels3d 和 pixels_alpha。耶!
下面的一段程式碼接收一個 Pygame surface並返回底層 RGBA 或 BGRA 陣列的基礎指標,以及一個指示它是 BGR 還是 RGB 的標誌
import ctypes def arr_params(surface): pixels = pg.surfarray.pixels3d(surface) width, height, depth = pixels.shape assert depth == 3 xstride, ystride, zstride = pixels.strides oft = 0 bgr = 0 if zstride == -1: # BGR image - walk back # 2 bytes to get to the first blue pixel oft = -2 zstride = 1 bgr = 1 # validate our assumptions about the data layout assert xstride == 4 assert zstride == 1 assert ystride == width*4 base = pixels.ctypes.data_as(ctypes.c_void_p) ptr = ctypes.c_void_p(base.value + oft) return ptr, width, height, bgr
既然我們獲得了畫素資料的基礎 C 指標,我們可以使用預設步長將其包裝在一個 numpy 陣列中,隱式轉置影象並交換 R&B 通道。一旦我們將帶有預設步長的 numpy 陣列“附加”到輸入和輸出資料上,我們對 cv2.resize 的呼叫將快 100 倍!
def rgba_buffer(p, w, h): # attach a WxHx4 buffer to the base pointer type = ctypes.c_uint8 * (w * h * 4) return ctypes.cast(p, ctypes.POINTER(type)).contents def cv2_resize_surface(src, dst): iptr, iw, ih, ibgr = arr_params(src) optr, ow, oh, obgr = arr_params(dst) # our trick only works if both surfaces are BGR, # or they're both RGB. if their layout doesn't match, # our code would actually swap R & B channels assert ibgr == obgr ibuf = rgba_buffer(iptr, iw, ih) # numpy's default strides are height*4, 4, 1 iarr = np.ndarray((ih,iw,4), np.uint8, buffer=ibuf) obuf = rgba_buffer(optr, ow, oh) oarr = np.ndarray((oh,ow,4), np.uint8, buffer=obuf) cv2.resize(iarr, (ow,oh), oarr, interpolation=cv2.INTER_AREA)
果然,我們最終從使用 cv2.resize 對 Surface 資料進行調整中獲得了加速而不是減速,我們的速度與調整 RGBA numpy.zeros 陣列相同(最初我們對 RGB 陣列進行基準測試,而不是 RGBA)
osurf = pg.Surface((OW,OH), pg.SRCALPHA) with Timer('attached RGBA with cv2'): for i in range(repeat): cv2_resize_surface(isurf, osurf) i6 = np.zeros((IW,IH,4), np.uint8) with Timer('np.zeros RGBA with cv2'): for i in range(repeat): o6 = cv2_resize(i6)
基準測試顯示我們獲得了 100 倍的回報:
attached RGBA with cv2 took 0.0097 sec np.zeros RGBA with cv2 took 0.0066 sec
上面所有醜陋的程式碼都在 GitHub[2] 上。由於這些程式碼很醜陋,你不能確定它是否正確地調整了影象大小,因此還有一些程式碼在那裏測試非零影象的調整大小。如果你執行它,你將得到以下華麗的輸出影象:
我們真的獲得了 100 倍的加速嗎?這取決於你如何計算。相對於直接使用 pixel3d 陣列呼叫它,我們使 cv2.resize 的執行速度提高了 100 倍。但是特別是對於調整大小,pygame 有 smoothscale,相對於它,我們的加速比是 13-15 倍。在 GitHub 上還有一些其他函式的基準測試,其中一些沒有相應的 pygame API。
• copying with dis[:] = src : 28x
• Inverting with dst[:] 255-src: 24x
• cv2.warpAffine: 12x
• cv2.mediaBlur: 15x
• cv2.GaussianBlur: 200x
無論如何,我會感到驚訝,如果有很多人使用 Python 從 SDL 來處理這個特定問題,以便使這個問題得到廣泛的關注。但我猜測,具有奇怪佈局的 numpy 陣列也可能在其他地方出現,因此這種技巧可能在其他地方也是相關的。
Unsafe Python
上面的程式碼使用“C 風格的知識”來加快速度(Python 通常會隱藏資料佈局,而 C 則會自豪地暴露它。)不幸的是,它具有 C 的記憶體(不)安全性 – 我們獲得了畫素資料的 C 基指標,從那一點開始,如果我們搞砸了指標算術,或者在資料被釋放後繼續使用資料,我們就會崩潰或損壞資料。然而我們沒有編寫任何 C 程式碼 – 這全部都是 Python。
Rust 有一個”unsafe”關鍵字,編譯器強制你意識到你正在呼叫一個會破壞正常安全性保證的 API。但是 Rust 編譯器並不會讓你把包含 unsafe 程式碼塊的函式標記為”unsafe”。相反,它相信你可以決定你的函式本身是否安全。
在我們的示例中, cv2_resize_surface 是一個安全的 API,假設我沒有 Bug,因為沒有恐怖逃逸到外部世界 – 在外部,我們只看到輸出表麵被輸出資料填充。但 arr_params 是一個完全不安全的 API,因為它返回一個 C 指標,你可以對其做任何操作。 rgba_buffer 也是不安全的——儘管我們返回一個 numpy 陣列,一個“安全”的物件,但在資料被釋放後,你仍然可以使用它,例如。在一般情況下,沒有靜態分析可以告訴你是否從不安全的構建模組構建了安全的東西。
Python 沒有 unsafe 關鍵字 – 這在動態語言和稀疏靜態註釋方面是符合特點的。但除此之外,Python + ctypes + C 庫在精神上有點類似於帶有 unsafe 的 Rust。該語言預設是安全的,但在需要時可以使用逃生通道。
《不安全的 Python》是一個通用原則的例證:Python 中大量使用了 C 語言。C 語言是 Python 的邪惡孿生兄弟,或者按時間順序來說,Python 是 C 語言的友好孿生兄弟。C 語言提供了效能,不關心可用性或安全性;如果其中任何一個導致問題,告訴你的醫療保健提供者,C 語言不感興趣。另一方面,Python 提供了安全性,並且基於十年來對初學者可用性的研究。不過,Python 不關心效能。它們都針對兩種相反的目標進行了激烈的最佳化,忽視了對方的目標代價。
但更重要的是,Python 從一開始就考慮到了與 C 擴充套件的相容性。今天,從我的角度來看,Python 作為一個流行的 C/C++ 庫的打包系統。我很少有下載和構建 OpenCV 以在 C++ 中使用它的興趣,相較於使用 Python 中的 OpenCV 二進制檔案,因為 C++ 沒有標準的包管理系統,而 Python 有。在 Python 中呼叫這些高效能庫(例如在科學計算和深度學習中)的程式碼比在 C/C++ 中更多。另一方面,如果想要嚴格最佳化的 Python 程式碼和較小的部署檔案大小/低啟動時間,你可以使用 Cython 來生成一個“仿寫成 C 所寫”的擴充套件,以節省類似 numba 這樣“更 Pythonic”的基於 JIT 的系統的開銷。
Python 中不僅有很多 C 程式碼,而且它們是某種意義上的對立物,它們相互補充得相當好。使 Python 程式碼快速的好方法是以正確的方式使用 C 庫。相反,安全使用 C 的好方法是用 C 編寫核心,然後在 Python 中編寫大量邏輯。Python 和 C/C++/Rust 混合——無論是具有大量 Python 擴充套件 API 的 C 程式,還是在 C 中完成所有繁重工作的 Python 程式——似乎在高效能、數值、桌面/伺服器領域佔據主導地位。雖然我不確定這個事實是否非常鼓舞人心,但我認為這是一個事實 ,而且這種情況將會持續很長時間。
引用連結
原文:A 100x speedup with unsafe Python: https://yosefk.com/blog/a-100x-speedup-with-unsafe-python.html
GitHub: https://github.com/yosefk/BlogCodeSamples/blob/main/numpy-perf.py