介紹Brook+的kernel到IL的轉化方法和優化技巧

引言
     進行GPGPU編程,很多人(包括我在內)會選擇從Brook+入手。Brook+的kernel編寫是基於C語言的,易於編寫和理解,而且Brook+的運行時處理了很多繁瑣的細節,使到GPGPU編程變得非常的簡單。但是隨着設計和應用的深入,Brook+就不再是理想的GPGPU的編程語言了。主要原因有兩個:
      1. rook+的運行時效率不高,調用kernel有一定的額外開銷,而且無法支持多GPU
      2. rook+的kernel不支持高級的GPU命令,比如本地共享內存和原子操作等 
     基於以上的兩個原因,CAL(Compute Abstraction Layer)就更加適合深入的進行GPGPU開發。因爲它暴露更多的顯卡特性,允許程序員對GPGPU的資源和流程進行精細的控制。 
     在進行Brook+編程的時候,所有的Brook+的kernel會由brcc轉化成IL。但是,要使用CAL進行GPGPU開發,就必須進行IL的編寫。IL是指AMD Intermediate Language。是一種彙編形式的語言。有些人一聽到是彙編語言就會感到非常難以理解和編寫。實際上並不是這樣子。一旦有了Brook+的kernel我們就可以很容易的把它轉化爲IL並進行優化。本文將會通過一個例子介紹如何把Brook+的kernel轉化爲IL並且對其進行優化。

IL開發環境
     IL代碼和大多數的編程語言的代碼一樣,是純文本。因此,可以使用任何的文本編輯器進行編寫。這裏推薦使用AMD提供的一個工具,Stream KernelAnalyzer(以下簡稱SKA)。這個工具可以在AMD的官方網站上下載得到。GPGPU開發者可以在SKA上進行Brook+內核編寫,IL程序編寫,甚至是直接的顯卡彙編的編寫。SKA會對輸入的代碼進行語法檢測和編譯,給出對應的在每一個型號的GPU上的彙編碼和一些相關的性能分析以供參考。 
     SKA提供的性能分析可能並不是實際的執行結果,但是卻可以爲GPGPU開發者提供很好的參考。開發者可以通過設置SKA的顯示選項來查看更多的性能參數,在這篇文章裏面我們主要關注兩個性能參數,Ave Cycle和Throughput。越低的Cycle和越高的Throughput表明GPGPU程序越高效。


Bitonic排序的Brook+ kernel
     爲了說明如何進行IL開發,我們選取了Brook+開發包自帶的Bitonic排序作爲例子。在這篇文章裏面,我們不會詳細介紹Bitonic排序的原理。關於Brook+使用請參看相關的文檔。這裏主要關注Brook+的kernel,因爲這是Brook+中會被brcc編譯器轉化爲IL的代碼。的Brook+中自帶的Bitonic排序的kernel代碼如下:
代碼 1
kernel void
bitonic(float input[], out float output<>, float stageWidth, float offset, float twoOffset)
{
    float idx2;
    float sign, dir;
    float min, max;
    float idx1 = (float)instance().x;

    // Either compared with element above or below
    sign = (fmod(idx1, twoOffset) < offset) ? 1.0f : -1.0f;

    // "Arrow" direction in the bitonic search algorithm (see above reference)
    dir = (fmod(floor(idx1 / stageWidth), 2.0f) == 0.0f) ? 1.0f: -1.0f;

    // comparing elements idx1 and idx2
    idx2 = idx1 + sign * offset;

    min = (input[idx1] < input[idx2]) ? input[idx1] : input[idx2];
    max = (input[idx1] > input[idx2]) ? input[idx1] : input[idx2];

    output = (sign == dir) ? min : max;
}
     在Brook+中,這一段C語言的kernel代碼會被轉化成兩個版本的IL,一個是沒有進行地址轉換的,另一個是進行了地址轉換※(Address Translation,以下簡稱AT)。這兩份IL可以在KSA中通過選擇不同的function看到。在SKA中,把以上的代碼複製到SKA的代碼編寫區,就可以馬上在輸出區看到對應的IL代碼。並且可以在相關性能分析。我們可以看到在HD3870的顯卡上的Est. Cycles爲4.75,throughput爲2611M Thread/sec,並不是十分的高效。如果使用了AT,效率就更低了,Est. Cycles爲13.25,Throughput僅爲936M Thread/sec。另一方面,SKA生成的IL有很多冗餘的命令,雖然這些冗餘在轉化爲彙編的時候都會被IL編譯器優化掉,但是這些冗餘卻嚴重影響了程序的可讀性。基於效率和可讀性的原因,我們需要自己來編寫IL。本文除了介紹怎麼從上面的Brook+的 kernel轉化爲IL,還會介紹一些基本優化技巧,把Throughput提升到極限。


在IL的角度理解Brook+ Kernel
     一個標準的IL程序由兩個個部分組成,分別爲聲明部分和程序體部分。其中聲明部分用於聲明IL中會用到的所有資源。包括輸入資源,輸出寄存器等。程序體部分是一系列的IL指令。GPU通過這些指令進行一系列的運算,並把最終結果寫到輸出寄存器。 
     因爲IL是一種中間語言,類似於彙編,因此其語法比較簡單,每一條IL命令只能完成一個運算操作。因此,爲了便於理解,我們需要把Brook+的kernel進行分解,轉化成一系列的操作序列。我們可以將其看作爲IL的僞代碼。因爲IL中的不存在變量的概念,只有寄存器的概念,而寄存器是變量名的,所以我們把變量名也用共用寄存器代替了。以下是根據代碼1編寫的IL僞代碼:
代碼2
[in i0[], out o0<>, c0, c1, c2] //declaration
{
    r1 = instance().x;          // idx1 = r1 = (float)instance().x;

    r3 = r1 % cb0[2]            // r3 = fmod(idx1, twoOffset)
    r3 = r3 < cb0[1]            // r3 = fmod(idx1, twoOffset) < offset
    r3 = r3 ? 1 : -1            // sign = r3 = (fmod(idx1, twoOffset) < offset) ? 1.0f : -1.0f;

    r4 = r1 / cb0[0]            // r4 = idx1 / stageWidth
    r4 = floor(r5)              // r4 = floor(idx1 / stageWidth)
    r4 = r4 % 2                 // r4 = floor(idx1 / stageWidth) % 2.0f
    r4 = r4 == 0                // r4 = fmod(floor(idx1 / stageWidth), 2.0f) == 0.0f
    r4 = r4 ? 1 : -1            // dir = r4 = (fmod(floor(idx1 / stageWidth), 2.0f) == 0.0f) ? 1.0f: -1.0f
    r2 = r3 * cb0[1]            // r2 = sign * offset;
    r2 = r2 + r1                // idx2 = r2 = idx1 + sign * offset;

    r5 = i0[r1]                 // r5 = input[idx1]
    r6 = i0[r2]                 // r6 = input[idx2]

    r7 = r5 < r6                // r7 = input[idx1] < input[idx2]
    r8 = r7 ? r5 : r6           // min = r8 = (input[idx1] < input[idx2]) ? input[idx1] : input[idx2]
    r9 = r7 ? r6 : r5           // max = r9 = (input[idx1] < input[idx2]) ? input[idx2] : input[idx1]

    r10 = r3 == r4              // r10 = sign == dir
    o0 = r10 ? r8 : r9          // output = (sign == dir) ? min : max;
}
     細心的讀者會發現在上面的僞代碼中的寄存器在一些指令中被用作爲浮點數,在一些指令中被用作爲布爾值。實際上,IL是一種無類型的語言。寄存器內數值所代表的類型由操作指令來決定。而不同的指令所消耗的cycle也是不一樣的,在GPU中單精度浮點數操作的速度最快,整數操作的速度相對較慢,而雙精度浮點數並不是所有的GPU都支持,即使據有GPU支持,效率也不高。這也是爲什麼Brook+例子中的Bitonic排序全部使用單精度浮點運算。因此這裏,我們介紹第一個優化技巧: 
     在不影響正確性的情況下,儘可能使用單精度浮點數運算。 
     然而,這個優化技巧在我們這裏並不適用。在這裏,我們要放棄使用浮點數而使用整數。我們這樣做的原因有兩個: 
          1. 用整數允許我們通過利用位操作來對程序進行優化。我們在後面的部分會介紹如何使用移位操作來代替取模運算來提高速度。 
          2. 精度浮點數有精度的問題,這個問題會導致在AT的IL中,單精度浮點數無法提供足夠的精度來表示大數組的索引。單精度浮點數的尾數位只有23位,對於大於2^23 = 8388608的數值因爲有效小數位不足,無法精確表示。但是在使用AT的情況下,轉化後的索引很輕易就會超過這個數值。 
     基於以上原因,我們的程序會使用主要使用整數操作。三個傳入的常量參數也要以整數方式傳入。


IL聲明的編寫
     IL的類型與版本
     所有IL中的第一條語句用於聲明IL的版本和類型。 
     il_ps_2_0
     表明這個IL使用Pixel Shader的方式,版本爲2.0版。另外一個可能的IL類型是cs。cs只能用在支持computating shader的顯卡上。本文並不會詳細介紹。 
     輸入的聲明
     觀察Bitonic排序的kernel,我們可以發現這個kernel有一個輸入流,一個輸出流和三個常量。這些需要轉化爲IL中的聲明。對輸出流的聲明,因爲只有一個輸出流,只需要定義o0一個輸出寄存器。IL程序的最終輸出結果要寫到這個寄存器中。 
     dcl_output_generic o0
     對輸入流的聲明是一個資源與一個輸入座標,通過輸入座標對資源進行採樣可以獲得輸入流的值 
     dcl_resource_id(0)_type(2d,unnorm)_fmtx(float)_fmty(float)_fmtz(float)_fmtw(float)
     dcl_input_position_interp(linear_noperspective) v0.xy__
     Brook+的kernel中的所有輸入常量,在IL中都被組織到一個常量緩存中。因爲這個kernel有三個常量輸入,在沒有AT的IL中我們聲明瞭三個常量寄存器,並使用這三個寄存器的x分量。實際上,我們也可以只聲明一個寄存器,然後用他的x、y和z分量分別表示這三個常量。這都取決於CAL中的資源分配。在我們的例子中,這個三個常量都是int型的。 
     dcl_cb cb0[3]
     另外,我們還需要定義一些會被用到的立即數,-1,1和0。 
     dcl_literal l1, 0xFFFFFFFF, 0x00000001, 0x00000000, 0xFFFFFFFF
     有了這些聲明,我們就可以進入IL程序體的編寫了。


IL程序體編寫
     有了前面的分析,編寫的工作就相對簡單得多了。我們可以逐行使用IL進行轉換。


instance()函數
     instance()是Brook+ kernel的一個內置函數,返回的是一個線程的索引。在IL中我們已經定義了輸入座標寄存器v0,在非AT的IL中這個寄存器只有x分量有意義。默認情況下這個x分量中存放的類型是單精度浮點數。這個在GPU中索引序列並沒有取整,是以0.5f爲起始值,以1.0f爲增量的數列(0.5f,1.5f,2.5f……)。因此,要得到取整後的索引,我們需要對其進行處理。處理方法有很多,比如直接減0.5f,floor操作,bias操作,轉化成整型。因爲我們主要使用整型,因此我們只需要把這個值轉化成整型就可以了。 
     ftoi r1.x___, v0.x000


優化取模操作
     接着我們就要對r1進行取模運算了。在IL中提供了兩個取模的操作,分別是MOD和UMOD,前者針對浮點數,後者針對整數。其中MOD要比UMOD的效率高很多。因爲我們要對整數進行取模,我們就需要使用UMOD,這對性能的影響是很大的。幸好,通過觀察,我們發現輸入的常量cb0[2]的值是2的N次方。在這個基礎上,我們就可以用位操作來代替取模操作。因爲,a %b在b爲2的N次方的情況下與a&(b-1)是等價的。因此取模操作我們可以寫成一次減法與一次按位與,效率大大提高。 
     iadd r3.x___, cb0[2].x000, l0.x000              //r3 = cb0[2] - 1
     and r3.x___, r1.x000, r3.x000                   // r3 = r3 & r1
     這裏介紹第二個優化技巧 在不影響正確性的前提下,儘量用位操作來代替數值計算。


問號三目運算符
     對於問號三目運算符的需要分兩步。首先要進行比較運算。IL比較操作的單精度版本有LT(小於),GE(大於等於),EQ(等於)和NE(不等)。大於和小於等於可以通過對LT和GE返回值取反獲得。我們使用的這些比較運算的整數版本ILT,IGE,IEQ和INE。這些操作符返回值爲布爾值TRUE和FALSE。在IL中TRUE定義爲0xFFFFFFFF,FALSE定義爲0x00000000。 
     有了布爾值以後就可以用邏輯運算符進行判斷了。IL命令CMOV_LOGICAL可以完成問號三目運算符的操作。該命令通過判斷寄存器中布爾值,選擇兩個輸入寄存器的數值輸出到目標寄存器中。因此,問號三目運算符被寫成以下兩條IL命令。 
     ilt r3.x___, r3.x000, cb0[1].x000                                // r3 = r3 < cb0[1]
     cmov_logical r3.x___, r3.x000, l0.y000, l0.x000                  // r3 = r3 ? 1 : -1
     有一點值得注意的是,在使用CMOV_LOGICAL命令的時候要儘量避免同一個寄存器既是目標寄存器,又是源寄存器。這樣會導致IL生成的彙編被添加額外的操作,從而減低性能。實際上,對於所有的命令也都應該避免同一個寄存器既爲輸入又爲輸出,這樣會可以爲IL的編譯器提供更多的優化空間。但引入太多的符號會影響IL程序的可讀性。因此,在IL編程中,在這兩個方面是需要進行一些取捨的。


用移位代替乘除法
     接着我們要進行一個整數除法運算IDIV。整數除法的效率比單精度除法DIV的效率低很多。雖然,在非AT的版本我們可以通過類型轉換把整型轉化爲浮點,使用DIV來計算的方法來提高效率,這也是我們提到的第一個優化小技巧。然而,這樣做在AT的時候會有精度問題。我們需要有更好的方法。 觀察Bitonic排序的Brook+實現,我們發現被除數stageWidth取值爲2,4,8,16……。這是在kernel外,通過計算2的n次方計算得到的。爲了進行優化,我們選擇不進行乘方計算,直接傳入n作爲cb0[0]。這樣,這個整數除法就可以用移位的方式進行了。這樣做,之後的floor操作也可以省略了。這也符合我們提到的第二個優化小技巧。而後面對2取模的運算也可以用與1進行按位與的方式來代替。接着的幾個命令就可以寫成。 
     ishr r4.x___, r1.x000, cb0[0].x000
     and r4.x___, r4.x000, l0.y000
對輸入進行採樣
     在我們的程序中我們需要兩次讀取輸入流,其標誌是中括號運算符(在Brook+ kernel中是四次,不過在生產彙編的時候,多餘的採樣操作會被優化掉)。在IL中,我們已經聲明瞭輸入的資源了,對輸入資源的讀取就寫成兩個採樣操作。 
     sample_resource(0)_sampler(0) r5, v0.x000
     sample_resource(0)_sampler(0) r6, r2.x000
     這裏有兩點需要注意的,首先,因爲我們只是一維顯存,因此我們只適用寄存器的x分量作爲索引。在後面AT的IL中我們就需要用到xy分量了。第二,採樣的索引必須爲單精度浮點數,而我們一直是用整數的方式計算的,因此在這裏我們需要先把r2.x裏面的整數轉型爲單精度浮點數
itof r2.x___, r2.x000
     細心觀察的讀者還會發現,我們對於r5的採樣採用v0做索引,而v0的值不依賴於之前的所有命令,因此實際上這個採樣操作可以放在r5被使用前的任何位置。我個人比較喜歡把它放到程序的最開始處。
完整的程序(非AT)
     經過以上的分析,我們就可以寫出完整的程序了。 
     il_ps_2_0
     dcl_output_generic o0
     dcl_resource_id(0)_type(1d,unnorm)_fmtx(float)_fmty(float)_fmtz(float)_fmtw(float)
     dcl_input_position_interp(linear_noperspective) v0.xy__
     dcl_literal l0, 0xFFFFFFFF, 0x00000001, 0x00000000, 0xFFFFFFFF
     dcl_cb cb0[3]

     sample_resource(0)_sampler(0) r5, v0.x000

     ftoi r1.x___, v0.x000
     iadd r3.x___, cb0[2].x000, l0.x000
     and r3.x___, r1.x000, r3.x000
     ilt r3.x___, r3.x000, cb0[1].x000
     cmov_logical r3.x___, r3.x000, l0.y000, l0.x000

     ishr r4.x___, r1.x000, cb0[0].x000
     and r4.x___, r4.x000, l0.y000
     ieq r4.x___, r4.x000, l0.z000
     cmov_logical r4.x___, r4.x000, l0.y000, l0.x000

     imul r2.x___, r3.x000, cb0[1].x000
     iadd r2.x___, r2.x000, r1.x000

     itof r2.x___, r2.x000
     sample_resource(0)_sampler(0) r6, r2.x000

     lt r7, r5, r6
     cmov_logical r8, r7, r5, r6
     cmov_logical r9, r7, r6, r5

     ieq r10, r3.x000, r4.x000
     cmov_logical o0.x___, r10, r8, r9
     endmain
     end
     這個程序的運行結果完全正確。然而我們可以在SKA上看到,經過優化後,這段IL在HD3870的顯卡上的Est. Cycles爲2.50,throughput爲4960M Thread/sec。比原來的生成的IL效率提高了一倍以上。

使用地址轉換
     目前GPU對一維顯存的長度有限制,如HD4870上是8192個元素。爲了支持更多元素的Bitonic排序,我們需要實現AT技術。Brook+中的AT是自動支持的,brcc會生成一份AT的IL。該AT爲每一個AT流多傳入兩個參數表明其物理的顯存的維度(二維顯存的高寬)和邏輯顯存的維度,我們的Brook+ kernel有一個輸入流和一個輸出流,都要進行AT。這樣,brcc生成的AT的IL輸入的常量數就從三個增加到了七個了。 
     AT的IL會數值計算把物理顯存的索引轉化爲邏輯顯存的索引進行計算,然後再把邏輯顯存的索引轉化爲物理顯存的索引進行採樣操作。這些額外的操作也是導致AT的IL效率比非AT的IL效率低。因爲Brook+生成的代碼需要考慮各種應用,這是不可避免的。但是,我們的Bitonic排序IL只針對我們的應用。因此,我們可以根據我們自身情況進行優化。在我們的應用中,輸入輸出流的物理和邏輯維度是一樣的,而且物理維度確定是一維的。因此,在我們的IL中只需要輸入物理二維顯存的寬度就可以了。 
     AT的IL有一些變化,首先是聲明部分。 
     dcl_resource_id(0)_type(2d,unnorm)_fmtx(float)_fmty(float)_fmtz(float)_fmtw(float)
     dcl_cb cb0[4]
     中的2d表明這個輸入的資源是以2D的方式進行採樣的。採樣語句會根據輸入寄存器的xy分量進行二維的採樣。這個IL定義了4個輸入常量,cb03.x的值是二維顯存的寬度。 以下語句把聲明的二維單精度索引轉化爲一維的整型索引 
     ftoi r1.xy__, v0.xy00
     imul r1._y__, r1.0y00, cb0[3].0x00
     iadd r1.x___, r1.y000, r1.x000
     以下語句把一維的整型索引轉化爲二維的單精度索引,以便進行採樣操作: 
     udiv r2._y__, r2.0x00, cb0[3].0x00
     imul r22.x___, r2.y000, cb0[3].x000_neg(x)
     iadd r2.x___, r2.x000, r22.x000
     itof r2, r2 這裏我們也給出AT的IL: 
     il_ps_2_0
     dcl_output_generic o0
     dcl_resource_id(0)_type(2d,unnorm)_fmtx(float)_fmty(float)_fmtz(float)_fmtw(float)
     dcl_input_position_interp(linear_noperspective) v0.xy__
     dcl_literal l0, 0xFFFFFFFF, 0x00000001, 0x00000000, 0xFFFFFFFF
     dcl_cb cb0[4]

     sample_resource(0)_sampler(0) r5, v0.xy00

     ftoi r1.xy__, v0.xy00
     imul r1._y__, r1.0y00, cb0[3].0x00
     iadd r1.x___, r1.y000, r1.x000

     ;r3 = idx1 % offset_2
     iadd r3.x___, cb0[2].x000, l0.x000
     and r3.x___, r1.x000, r3.x000
     ilt r3.x___, r3.x000, cb0[1].x000
     cmov_logical r3.x___, r3.x000, l0.y000, l0.x000

     ishr r4.x___, r1.x000, cb0[0].x000
     and r4.x___, r4.x000, l0.y000
     ieq r4.x___, r4.x000, l0.z000
     cmov_logical r4.x___, r4.x000, l0.y000, l0.x000

     imul r2.x___, r3.x000, cb0[1].x000
     iadd r2.x___, r2.x000, r1.x000

     udiv r2._y__, r2.0x00, cb0[3].0x00
     imul r22.x___, r2.y000, cb0[3].x000_neg(x)
     iadd r2.x___, r2.x000, r22.x000
     itof r2, r2

     sample_resource(0)_sampler(0) r6, r2.xy00

     lt r7, r5, r6
     cmov_logical r8, r7, r5, r6
     cmov_logical r9, r7, r6, r5

     ieq r10, r3.x000, r4.x000
     cmov_logical o0.x___, r10, r8, r9

     endmain
     end
     這一段AT的IL,在HD3870的顯卡上,Est. Cycles爲5.50,Throughput爲2255 M Thread/sec。也比brcc生成的代碼提高了一倍以上的效率。雖然這樣,其實這段代碼還有優化的空間。觀察可以發現,我們使用了IMUL和UDIV的操作。如果可以保證輸入的二維顯存的寬度爲2的n次方,我們還可以繼續使用移位操作來代替乘除法。這其實不難做到,因爲Bitonic排序必須保證輸入顯存的長度必須爲2的n次方。


總結
     本文介紹了怎麼根據Brook+的kernel來編寫IL並進行一些優化。實際上使用IL編寫GPGPU程序並沒有想象中那麼困難。而通過使用IL,我們可以享受CAL帶來的精確靈活的資源管理。 然而,IL的優化只是GPGPU程序優化的一部分。而往往GPGPU程序的瓶頸存在於數據傳輸,資源管理等其他方面。對於GPGPU程序優化需要從全局出發。
________________________________________
     ※目前因爲顯卡分配的一維顯存空間的長度存在限制,如HD4870爲8192個元素。如果要提供大型一維數組,或者多維數組的支持,必須使用地址轉換技術使用二維數組來模擬。但是,進行地址轉換,就需要在內核中增加數據索引計算的操作,會減低計算的效率。

發佈了25 篇原創文章 · 獲贊 7 · 訪問量 16萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章