這章將會說明一些kernel優化的小技巧。
8.1 kernel合並或者拆分
一個復雜的應用程序可能包含很多步驟。對於OpenCL的移植性和優化,可能會問需要開發有多少個kernel。這個問題很難回答,因為這涉及到很多的因素。下面是一些准則:
- 內存和計算之間的平衡。
- 足夠多的wave來隱藏延遲。
- 沒有寄存器溢出。
上面的要求可以通過執行以下操作實現:
- 如果這樣做能夠帶來更好的數據並行,將一個大的kernel拆分成多個小的kernel。
- 如果內存的流量能夠減少而且同樣能保證並行性,可以將多個kernel合並成一個kernel,例如workgroup的尺寸能夠足夠地大。
8.2 編譯選項
OpenCL支持一些編譯選項,參考文獻的《The OpenCLSpecification》的5.6.4節中進行了定義。編譯選項可以通過APIsclCompileProgram和clBuildProgram傳遞。多個編譯選項可以結合,如下所示。
clBuildProgram( myProgram,
numDevices,
pDevices,
“-cl-fast-relaxed-math ”,
NULL,
NULL );
通過這些選項,開發者能夠針對他們自己的需求使能某些功能。比如,使用-cl-fast-relaxed-math,kernel會編譯成使用快速數學函數而不是OpenCL標准函數,每一個OpenCL的說明中OpenCL標准函數都有很高的精度要求。
8.3 一致性 vs. 快速 vs. vs. 內部的數學函數
OpenCL標准在OpenCL C語言中定義了許多數學函數,默認情況下,因為OpenCL規范說明書的要求,所有的數學函數都必須滿足IEEE 754 單精度的浮點精度數學要求。Adreno GPU有一個內嵌的硬件模塊,EFU(elementary function unit 基本函數單元),來加速一些初級的數學函數。對於許多EFU不能直接支持的數學函數,可以通過結合EFU和ALU操作來優化,或者通過編譯器使用復雜的算法來模擬進行優化。表8-1展示了OpenCL-GPU 數學函數的列表,並按照他們的相對性能來分類的。使用更好性能的函數是個較好的方法,比如使用A類中的函數
表8-1 OpenCL數學函數的性能(符合IEEE 754標准)
類別 |
實現 |
函數(可參考OpenCL標准獲取更多細節) |
A |
僅簡單使用ALU指令 |
ceil,copysign,fabs,fdim, floor,fmax, fmin, fract,frexp,ilogb, mad, maxmag,minmag,modf,nan,nextafter,rint,round,trunk |
B |
僅使用EFU,或者EFU機上簡單的ALU指令 |
asin,asinpi,atan,atanh,atanpi,cosh,exp,exp2,rsqrt,sqrt,tanh |
C |
ALU,EFU,和位操作的結合 |
acos,acosh, acospi,asinh, atan, atan2pi,cbrt,cos,cospi,exp10,expml,fmod,hypot,ldexp,log,log10,loglp,log2,logb,pow,remainder,remquo,sin,sincos,sinh,sinpi |
D |
復雜的軟件模擬 |
erf,erfc,fma,lgamma,lgamma_r,pown,powr,rootn,tan,tanpi,tgamma |
另外,如果應用程序對精度不敏感的話,開發者可以選擇使用內部的或者快速的數學函數來替代標准的數學函數。表8-2 總結了使用數學函數時的3個選項。
- 使用快速函數時,在調用函數clBuildProgram時使能-cl-fast-relaxed-math。
- 使用內部的數學函數:
- 許多函數有內部實現,比如:native_cos, native_exp,native_exp2, native_log, native_log2, native_log10, native_powr,native_recip, native_rsqrt, native_sin, native_sqrt, native_tan ;
-
- 下面使用內部數學函數的例子:
原始的:int c = a/b ;// a和b都是整數。
使用內部指令:
int c =(int)native_divide((float)(a)),(float)(b));
表8-2 基於精度/性能的數學函數選擇
數學函數 |
定義 |
怎么使用 |
精度要求 |
性能 |
典型應用 |
標准 |
符合IEEE754單精度浮點要求 |
默認 |
嚴格 |
低 |
科學計算,對精度敏感的情況下 |
快速 |
低精度的快速函數 |
kernel編譯選項 -cl-fast-relaxed-math |
中等 |
中等 |
許多圖像,音頻和視覺的用例中 |
內部 |
直接使用硬件計算 |
使用native_function替換kernel中的函數 |
低,與供應商有關 |
高 |
對精度損失不敏感的情況下的圖像,音頻,和視覺用例中 |
8.4 循環展開
循環展開通常是一個好方法,因為它能夠減少指令執行的耗時從而提高性能。Adreno編譯器通常能基於試探法自動地將循環展開。然而,有時候編譯器選擇不將循環完全展開,因為基於考慮到,寄存器的分配預算,或者編譯器因為缺少某些信息不能將它展開等因素。在這些情況下,開發者可以給編譯器一個提示,或者手動的強制將循環展開,如下所示:
- kernel可以使用__attribute__((opencl_unroll_hint))或者__attribute__((opencl_unroll_hint(n))) 給出提示。
- 另外,kernel可以直接使用#pragma unroll展開循環。
- 最后一個選擇是手動展開循環。
8.5 避免分支
一般地,當在同一個wave中的work item有不同的執行路徑時,那么GPU就不是那么高效率。對於某些分支,一些work time必須執行,從而導致較低的GPU使用率,就像圖8-1所示。而且,像if-else的條件判斷代碼通常會引起硬件的控制流邏輯,這個是非常耗時的。
圖8-1 繪圖表示出現在兩個wave中的分支情況
有一些方法可以用來避免或者減少分支和條件判斷。在算法層面,一種方法是將進入同一分支的work item組成一個不可分的wave。在kernel層面,一些簡單的分叉/條件判斷可以轉變成快速的ALU操作。在9.2.6節中一個例子中,有耗時的控制流邏輯的一個三元操作被轉變成一個ALU操作。其他的方式是使用類似於select函數,這個可能會使用快速的ALU操作來替代控制流邏輯。
8.6 處理圖像邊界
許多操作可能會獲取圖像邊界外的像素點,比如濾波,變換等。為了更好地處理邊界,可以考慮下面的選擇:
- 如果可能的話,對圖像進行擴邊。
- 使用帶有合適的采樣器的image對象(texture引擎會自動處理這個)
- 編寫單獨的kernel函數去處理邊界,或者讓CPU處理邊界。
8.7 32位 vs. 64位GPU內存訪問
從Adreno A5X GPU開始,64位操作系統逐漸成為主流,而且許多的Adreno GPU支持64位操作系統。64位操作系統中最重要的改變是內存空間將能完全覆蓋4GB,而且CPU支持64位指令集。
當GPU可以獲取64位內存空間時,它的使用將會引起額外的復雜性,而且可能會影響性能。
8.8 避免使用size_t
64位的內存地址在許多情況下會提升編寫OpenCL kernel的復雜度,開發者必須要小心。強烈建議避免在kernels中定義size_t類型的變量。對於64位操作系統,在kernel中定義成size_t的變量可能會被當成64位長度的數據。Adreno GPUs必須使用32位寄存器來模擬64位。因此,size_t類型的變量會需要更多的寄存器資源,從而因為可用的wave變少和更小的workgroup大小導致性能退化。所以,開發者應該使用32位或者更短的數據類型來替代size_t.
對於OpenCL中返回size_t的內嵌函數,編譯器會根據它所知道的信息嘗試推導並限制數據范圍。比如, get_local_id返回的數據類型為size_t,盡管local_id永遠不會超過32位。在這種情況,編譯器嘗試使用一個短的數據類型來替代。但是,更好的方法是,給編譯器提供關於數據類型的最充分的信息,然后編譯器可以產生更好的優化代碼。
8.9 一般的內存空間
OpenCL2.0 介紹了一個新的特性,叫做一般性的內存地址空間,在這個地址空間中,指針不需要指定它的地址空間,在OpenCL2.0之前,指針必須指定它的地址空間,比如指定為是local,private,或者global。在一般性的地址空間中,指針可以動態地被指定為不同的地址空間。
這個特性降低了開發者的代碼基礎而且能重復使用已經存在的代碼,使用一般性的內存地址空間會有輕微的性能損失,因為GPU SP硬件需要動態的指出真正的地址空間。如果開發者清楚知道變量的內存空間,建議清晰地定義內存地址。這將會減少編譯器的歧義,從而會有更好的機器代碼進而提升性能。
8.10 其他
還有很多其他的優化技巧,這些技巧看起來很小,但是同樣可以提高性能,這些技巧如下所示:
- 已經計算過的數據,而且不會在kernel中被改變的。
- 如果一個數據可以在外面(host端)計算好,那么放到kernel中計算會很浪費。
- 已經計算好的數據可以通過kernel參數傳遞給kernel,或者用#define的方式。
- 使用快速的整型的內嵌函數。使用mul24計算24位的整型乘法,和使用mad24計算24位的整型乘加。
- Adreno GPU的內部硬件支持mul24,而32位的整型乘法需要用更多的指令模擬。
- 如果是在24位范圍內的整型數據,使用mul24會比直接使用32位的乘法更快。
- 減少EFU函數
- 比如,像r=a/select(c,d,b<T)這樣的代碼(其中a,b和T是浮點變量,c和d是常數),可以寫成r= a * select(1/c,1/d,b<T),這樣會避免EFU中倒數函數,因為1/c和1/d可以在編譯器編譯階段計算出來。
- 避免除法操作,特別是整型的除法。
- 整型的除法在Adreno GPUs上是極其耗時的。
- 不使用除法,可以使用native_recip計算倒數,像8.3節描述的那樣。
- 避免整型的模操作,這個也很耗時。
- 對於常數的數組,比如說查找表,濾波tap等,在kernel的外面進行聲明。
- 使用mem_fence 函數來分開或者組合代碼段。
- 編譯器會從全局優化的角度,使用復雜的算法產生最優的代碼。
- mem_fonce 可以用來阻止編譯器混排和混合前面或者后面的代碼。
- mem_fonce 可以讓開發者單獨操作代碼的某個部分來進行優化和調試。
- 使用位移操作替換乘法。