2010/11/15

使用CUDA實作image convolution

Image convolution是影像處理上常用的方法之一,可應用在影像模糊、影像強化、擷取邊緣...等等。在數學上,convolution代表兩個函數(ex: f and g)的重疊運算,結果會產生第3個函數(h) :

f * g = h

應用在影像處理時,函數f為原影像,另一個函數g則為filter,而h可視為原影像被convolution後的結果。 convolution在2維discrete domain的運算過程,可用下圖來表示: image

要計算source image上每一個像素的convolution結果,需要以該像素為中心取出與filter相同大小的區域(window),然後該區域的每一個像素與filter相對應的位置做點對點的相乘,最後計算相乘結果的總和,即為該像素convolution的結果。

在影像上計算每一像素的convolution時,會遇到邊界像素的window超出影像範圍的問題,超出的部分就稱為apron,如下圖所示:

image

而apron的尺寸會等於filter半徑的寬度。當apron所處的位置已超出影像範圍,必需特別給定此apron的值,才能與filter進行運算,而最常見的方法是補0,或者重複邊界像素的值。 進行2維convolution時,一般需要2維影像與2維的filter進行運算,然而某些2維的filter可由2個1維的filter來組成,當此filter可分離時,2維的計算可等效於連續使用2個1維的filter進行運算,如此一來,最大的好處是減少了計算量,例如當filter尺寸為n X m時,原本每個像素需要進行n*m次乘法,分離filter後,只需要n+m次乘法。

 

測試平台

  • OS: Win XP x64
  • CPU: Core 2 Extreme X9650 3.0GHz (4 cores)
  • VGA: Geforce GTX280
  • nVidia CUDA 3.1

 

使用CPU計算Image convolution

先使用CPU計算的程式來測試,使用3張不同尺寸的圖片,filter使用高斯濾波器,設定filter kernel的尺寸為17*17(半徑為8),並分離成2個一維的filter來計算。設定超出影像範圍的apron其值為0。效能測試中,皆進行50次運算,再計算平均花費時間。

CPU:

image

Parallelization with OpenMP:

image

 

CUDA程式實作

在nvidia CUDA SDK中,有convolution的範例程式convolutionSeparable及convolutionTexture,這兩支程式做了最佳化而有很好的效能,但它們並沒有實際接受影像輸入,而是用亂數產生float type的資料,模擬一個channel的影像。接下來,我們先不考慮最佳化,而用最直接的方法實作,再一步步改進程式,最後比較各種方法的差異。

 

The most naive approach

最基本的方法是將影像資料送到device上的global memory,再讓每個thread在計算convolution的kernel中存取它。整張影像將被分成許多block,如下圖所示,而每一個thread負責計算一個像素的結果,因此在這個應用中,將沒有任何idle的thread。設定每一個block的size為16 X 16。

image 

效能上比平行化的CPU程式快1.29倍。

image

convolution kernel的code如下:

1: __global__ void my2DConvKernel(float *d_Result, float *d_Data, int dataW, int dataH)

2: {

3:   // original image based coordinate

4:   int y = blockIdx.y * blockDim.y + threadIdx.y;

5:   int x = blockIdx.x * blockDim.x + threadIdx.x;

6: 

7:   int  BiasY = y - KERNEL_RADIUS;

8:   int  BiasX = x - KERNEL_RADIUS;

9: 

10:   float sum = 0;

11:   for(int j = 0; j < KERNEL_LENGTH; ++j)

12:   {

13:     //out of image range

14:     if (BiasY + j < 0 || BiasY + j >= dataH) 

15:       continue;

16: 

17:     for(int i = 0; i < KERNEL_LENGTH; ++i)

18:     {

19:       //out of image range

20:       if (BiasX + i < 0 || BiasX + i >= dataW) 

21:         continue;

22: 

23:       sum += d_Data[(BiasY + j) * dataW + BiasX + i] * 

24:              c_Kernel[KERNEL_LENGTH * j + i];

25:     }

26:   }

27: 

28:   d_Result[y * dataW + x] = sum;  

29: }

30: 


其中24行的c_Kernel為filter,在呼叫kernel前就先把它傳至constant memory中,雖然constant memory是唯讀,但具有快取,適合將唯讀的資料放於此,加速存取。


 


2D convolution using shared memory


上一個程式,只把影像資料存到global memory,每讀一次原圖的值都要存取一次,然而存取global memory所需要的時間(latency)是很長的,它也沒有快取,很容易讓運算的瓶頸卡在資料傳輸上。改善的方法是利用shared memory,存取的速度上會快很多,甚至與存取暫存器的速度相同,但它有大小限制,每一個multiprocessor只有16KB的shared memory。接下來,我們把一個block送進shared memory中,其中含要處理的像素以及apron區域,如下圖所示:


image 


在此仍然讓一個thread只處理一個像素的結果,在load資料至shared memory時,每個thread負責一個像素的傳輸,而在計算階段,只有負責傳輸白色區域的thread需要計算出結果,負責傳輸apron的thread會idle,因此雖然利用到了shared memory,但卻減少能同時計算的thread數量,有得也有失。然而在此測試中,filter半徑為8時,若白色區域尺寸維持16 X 16,會使block尺寸達到32 X 32,如此一來會超過每個block容許512個thread的上限(顯卡硬體限制,因不同顯卡而異),所以在此設定白色區域尺寸為4 X 4。


效能上比上一個方法"慢"1.96倍。


image


convolution kernel的code如下:

1: __global__ void NaiveSharedMemoryGPUKernel(float *d_Result, float *d_Data, int dataW, int dataH)

2: {

3:     __shared__ float data[KERNEL_RADIUS * 2 + ACTIVE_T_W][KERNEL_RADIUS * 2 + ACTIVE_T_H];

4: 

5:   // global mem address for this thread

6:   const int gLoc = blockIdx.y * ACTIVE_T_H * dataW + 

7:                    blockIdx.x * ACTIVE_T_W +

8:                    threadIdx.y * dataW + threadIdx.x - 

9:                    KERNEL_RADIUS * dataW - KERNEL_RADIUS;

10: 

11:   // original image based coordinate

12:   int x = blockIdx.x * ACTIVE_T_W - KERNEL_RADIUS + threadIdx.x;

13:   int y = blockIdx.y * ACTIVE_T_H - KERNEL_RADIUS + threadIdx.y;

14: 

15:   if (x < 0 || x > dataW - 1 || y < 0 || y > dataH - 1 )

16:     data[threadIdx.x][threadIdx.y] = 0;

17:   else

18:     data[threadIdx.x][threadIdx.y] = d_Data[gLoc];

19: 

20:   __syncthreads();

21: 

22:   float sum = 0;

23:   if(threadIdx.x >= KERNEL_RADIUS && 

24:     threadIdx.x < KERNEL_RADIUS + ACTIVE_T_W && 

25:     threadIdx.y >= KERNEL_RADIUS && 

26:     threadIdx.y < KERNEL_RADIUS + ACTIVE_T_H)

27:   {

28:     // row wise

29:     for (int i = -KERNEL_RADIUS; i <= KERNEL_RADIUS; ++i) 

30:     {

31:       // col wise

32:       for (int j = -KERNEL_RADIUS; j <= KERNEL_RADIUS; ++j) 

33:       {

34:         sum += data[threadIdx.x + i][threadIdx.y + j] * 

35:                c_Kernel[KERNEL_RADIUS + i] * 

36:                c_Kernel[KERNEL_RADIUS + j];

37:       }

38:     }

39: 

40:     d_Result[gLoc] = sum;

41:   }    

42: }


以上的ACTIVE_T_W與ACTIVE_T_H為白色區的長與寬,皆設定為4。第3行利用__shared__來宣告一個使用shared memory的變數,data。第20行的__syncthreads()為CUDA函式,讓同一個block內的thread在此同步,才能繼續執行,在此因為要把data的資料都填完才能做後續的計算,因此需要做同步的動作。


 


Separable convolution using shared memory


此方法先將2D的filter分解成2個一維的filter,再接連對影像做X方向及Y方向的convolution。當只考慮X方向的convolution時,每個block只需包含X方向的apron,如下圖:


image


如此一來,不用考慮Y方向的apron,即可增加每個block中用來計算的thread。另外,也可減少影像上同一區域被重複讀進shared memory的次數,原本2D convolution中,同一區域最多會被讀取9次,separable convolution方法中最多只會被讀取6次。


效能上比上一個方法快4.63倍。


image


convolution kernel的code如下:

1: __global__ void mySeparableRowKernel(float *d_Result, float *d_Data, int dataW, int dataH)

2: {

3:   __shared__ float data[KERNEL_RADIUS * 2 + ACTIVE_T_W][ACTIVE_T_H];

4: 

5:   // global mem address for this thread

6:   const int gLoc = blockIdx.y * ACTIVE_T_H * dataW + 

7:                    blockIdx.x * ACTIVE_T_W * dataW + 

8:                    threadIdx.x - 

9:                    KERNEL_RADIUS + threadIdx.y;

10: 

11:   // original image based coordinate

12:   int x = blockIdx.x * ACTIVE_T_W - KERNEL_RADIUS + threadIdx.x;

13: 

14:   if (x < 0 || x > dataW - 1)

15:     data[threadIdx.x][threadIdx.y] = 0;

16:   else

17:     data[threadIdx.x][threadIdx.y] = d_Data[gLoc];

18: 

19:     __syncthreads();

20: 

21:   float sum = 0;

22:   if(threadIdx.x >= KERNEL_RADIUS && 

23:      threadIdx.x < KERNEL_RADIUS + ACTIVE_T_W)

24:   {

25:     // row wise

26:     for (int i = -KERNEL_RADIUS; i <= KERNEL_RADIUS; ++i) 

27:     {

28:       sum += data[threadIdx.x + i][threadIdx.y] * 

29:              c_Kernel[KERNEL_RADIUS + i];

30:     }

31: 

32:     d_Result[gLoc] = sum;

33:   }  

34: }

1: __global__ void mySeparableColKernel(float *d_Result, float *d_Data, int dataW, int dataH)

2: {

3:   __shared__ float data[ACTIVE_T_W][KERNEL_RADIUS * 2 + ACTIVE_T_H];

4: 

5:     // global mem address for this thread

6:   const int gLoc = blockIdx.y * ACTIVE_T_H * dataW + 

7:                    blockIdx.x * ACTIVE_T_W + 

8:                    threadIdx.y * dataW + 

9:                    threadIdx.x - 

10:                    KERNEL_RADIUS * dataW;

11: 

12:   // original image based coordinate

13:   int y = blockIdx.y * ACTIVE_T_H - KERNEL_RADIUS + threadIdx.y;

14: 

15:   if (y < 0 || y > dataH - 1)

16:     data[threadIdx.x][threadIdx.y] = 0;

17:   else

18:     data[threadIdx.x][threadIdx.y] = d_Data[gLoc];

19: 

20:     __syncthreads();

21: 

22:   float sum = 0;

23:   if(threadIdx.y >= KERNEL_RADIUS && 

24:      threadIdx.y < KERNEL_RADIUS + ACTIVE_T_H)

25:   {

26:     // col wise

27:     for (int j = -KERNEL_RADIUS; j <= KERNEL_RADIUS; ++j) 

28:     {

29:       sum += data[threadIdx.x][threadIdx.y + j] * 

30:              c_Kernel[KERNEL_RADIUS + j];

31:     }

32: 

33:     d_Result[gLoc] = sum;

34:   }  

35: }


 


nvidia convolution application: convolutionSeparable


nvidia SDK中的範例程式convolutionSeparable,使用了一些最佳化技巧來提升程式執行效率,在此介紹一下。首先在記憶體存取上,存取global memory資料時有符合"coalescence",滿足的條件有2:



  1. 每一個half-wrap的第一個thread有aligned在適當的記憶體位置 (該位置必需是每個thread存取的資料大小的16倍)
  2. "連續"地讀取記憶體

為了滿足第一項條件,以row的convolution來說,每個block除了包含需計算結果的區域及apron區外,還多讀取了一些額外的資料,如下圖的綠色區域,其目的就是要讓讀取記憶體的初始位置能align在適當的位置。


image


從範例程式中得知,這個位置的所在是從白色區域往前算的第16個資料的倍數,當appron寬度小於16時,就往前多載入16個資料,若寬度大於16,則載入16的倍數的資料,如此一來讀取的範圍既可涵蓋appron又滿足位置的aligned。 接下來談第二項條件,連續讀取記憶體的部分,先瞭解GPU的運作方式,當一個thread去存取記憶體並等待資料時,GPU會先切換到下個thread並執行,因此存取記憶體的順序應該是:


image 


所以下圖讓thread 0存取紅色部分,thread 1與2各別存取黃色與綠色部分,並沒有滿足連續存取記憶體。


image 


而是要如下圖所示,才能滿足連續存取記憶體。


image


nvidia的程式透過memory coalescence的最佳化,並將某些迴圈以loop unrolling的方式展開,來提高執行效率,結果如下:


效能上比上一個方法快1.24倍。


image


 


測試結果


下圖為本篇所有測試的效能比較圖。


image


最後結果,nvidia的方法比沒有平行化的CPU程式還快上12.63倍,也比平行化的CPU程式快上3.78倍。其中2D convolution使用shared memory那一項,因為每個block只有4X4個thread能執行計算,比其他的測試少,因此效能甚至比直接存取global memory還不好。但整體而言,使用shared memory仍對效能有所幫助。


這次的測試程式(包含nvidia code),其實只能在一些理想條件下運作,例如影像只能有單channel、影像的長與寬必需是block尺寸的倍數、filter的尺寸不能太大以免單一block超過512個thread…等等。所以若要能處理各類型的影像與不同大小filter進行convolution,這些程式都還需要再加以改進。

2010/11/08

迷迭香與非洲菫

朋友突然說想要種非洲菫,因為這種花有清淨空氣的功用,而且又適合室內栽培,好像很好養,morris想說也來種看看,就說好了一起去買。
除了非洲菫,其實morris一直很想種迷迭香,除了香氣蠻特殊的,更重要的是可以做迷迭香烤雞啊!  於是今天就將這兩種花草一起入手了。

morris在大潤發前的花園買了迷迭香,現場有兩個品種,一種是直立迷迭香,另一種是匍匐迷迭香,後來買了小盆的直立迷迭香。
計畫把迷迭香擺在陽台,因為它需要日照,另外藉助它可驅蟲的功效,當做擋小蟲的防護罩。
迷迭香

小小一株迷迭香
迷迭香


大潤發花園裡賣的非洲菫,這一株是花開最好看的一株。非洲菫

後來想說貨比三家,去逛了體育館旁的假日花市,那邊可以看到其他的花色,甚至連花瓣的形式也不同。
最後挑了這株花色白中透著粉紅的非洲菫。
非洲菫

這種花蠻適合懶人種的,老闆說一週澆一次水就好,希望它在本人照顧下能好好活著,並扮演好空氣清靜機的角色 XD

    

2010/10/14

[秋遊北海道] 札幌 北海道神宮

2009.10.12   晴
今天是秋遊北海道的最後一天了,有什麼還沒看的還沒買的都要在這一天做個了斷了,雖然如此,今日行程還是悠閒到不行,只有去一個景點,就是北海道最大的神宮---北海道神宮。

搭乘地下鐵東西線至圓山公園站下車,再步行15分鐘就可以到達圓山公園,而神宮就處於公園之中。公園內的步道舖滿了碎石子,走在上面不時發出沙沙沙的聲音,彷彿向這裡的神明萬物告知訪客的到來。

在到達神宮之前先看到許多較小的神社,如開拓神社、札幌鎮靈社等。
DSCF3050


差不多要穿越圓山公園時,終於快見到神宮的本體建物囉,進神宮前要先到這個手水舍洗手並漱口,稍微觀察一下當地人的動作,要先用右手拿水杓,盛水洗左手,再用相同邏輯洗好右手,然後再換右手拿水杓,盛水在左手後~~漱口,不要喝下去咧!
進去前要先洗手與漱口

北海道神宮
DSCF3135
北海道神宮DSCF3133

在神前參拜,要遵守特定的禮儀
拜拜的地方,丟個銅板,拍拍手就能拜拜囉

這時候適逢"七五三祭典",似乎是為了慶祝小孩成長的儀式,許多3、5、7歲的小朋友穿著和服過來拜拜,這些可愛小朋友自然成為我們鏡頭補捉的焦點。尤其這位紅衣小妹,因為她爸爸在前面照相,所以笑得特別開心。
嗨,茄子

除了遇到七五三祭典之外,又很幸運遇到有新人來神社進行結婚典禮,穿著傳統服飾的他們,好像讓周遭的時光倒流了。結果這兩位也受到一堆大小炮的瞄準,不管是親友的,還是遊客的,大家用照片記錄這幸福的一刻。
DSCF3075

話說,剛好遇到一個台灣團也來神宮玩,好多婆婆媽媽,她們超愛做國民外交的,到處跟小朋友還有新娘拍照(冷落新郎),而且還把小朋友抱起來呢,展現了台灣人的熱情啊!   只是有部分日本小朋友被這股熱情嚇哭而已。


神籤和繪馬懸掛的地方
DSCF3127

看到神宮的"工作人員"(抱歉,不知道正式稱呼),直覺想到棋靈王...
DSCF3086

可愛的巫女(應該吧)
讓我想到棋靈王...


神宮前有幾個小小的攤販,像個迷你型的夜市似的,其中一攤的商品包裝得好特別,那是在賣棉花糖耶,第一次看到棉花糖不是用透明塑膠袋裝的。
棉花糖的包裝並不隨便唷


在神宮正面的道路是所謂的表參道,我們直到要離去才經過此地,總覺得經過這一條鬱鬱蔥蔥地、筆直的大道,每走一步心就會更為平靜。這邊春天應該是開滿了櫻花啊,能想像那個美景嗎?
表參道
DSCF3152


離開北海道神宮後,回到旅館帶上了大行李,就要踏上歸途了,這一天的天空晴朗地不像話,賜給我們充滿陽光的北海道。為了再多買幾張明信片,特地走去舊道廳,這裡有賣夜光的明信片,蠻特別的。
DSCF3162

今日的午餐是在大通公園地下街裡的味之時計台解決,這一間也算小有名氣的拉麵連鎖店,同事H點了中華拉麵,碗裡有個囍字,這個應該是結婚時用的吧...
中華拉麵

同事B的玉米拉麵,很豪爽地灑滿玉米。morris想說:沒吃到玉米別說你來過北海道...(螃蟹請息怒)
玉米拉麵的玉米真多

morris則點了經典口味,味增拉麵,仍然是油油亮亮閃閃動人,不意外。濃郁的味道,滿足~
DSCF3170


一行人回到了睽違了十天的新千歲機場,機場內的商店絕對是另一個血拚的戰場,知名商店都在此設店,給你最後一次機會,沒買到沒吃到錢沒花掉的,通通在此解決吧。經過一場血戰,把銀彈都打光了,才帶著大包小包去趕飛機。

回程的機上,四周坐了不少日本人,點餐時學那些日本人叫了啤酒,還是sapporo的喔,藉此享受這最後的日本味吧。
回程飛機餐

2010/10/08

[秋遊北海道] 大沼公園

2009.10.11
從函館搭乘13:28的北斗列車出發,只要20分鐘就到達了大沼公園。路途中都在把玩剛才買的紀念品,一隻可愛的小狐狸玩偶,明明只有一條尾巴可動,還是能讓人把玩很久。
大沼公園內有一個告示牌,說這裡是新日本三景,不知道這個排行榜多久更新一次 (又不是唱片排行榜)。
日本新三景之一

從大沼公園車站出來後,右手邊就是遊客中心,不曉得怎麼搞的,此行中遇到的遊客中心,常常有著莫明的吸引力,然後我們就被會吸過去,看有沒有地圖或折價卷之類的東西。但這一次卻看到意料之外的東西,是萬聖節的大南瓜!  好幾顆在門口列隊,第一次親眼見到那麼大的南瓜耶。
萬聖節的南瓜


大沼公園是個三湖一山的風景區,三個湖分別是大沼、小沼、蕙菜沼,一座山為駒岳,有湖光山色的迷人景緻。
在短時間內遊覽此地的方式有兩種,一種是租自行車,另一種則是搭遊船。這裡有一間蠻有名的自行車出租店,為什麼有名呢?  因為看好多跟團的行程都有排這個自行車,老闆也做熟台灣人生意了,店裡又是國旗又是歡迎標語的,不過最有特色的還是要屬自行車接龍了,一次可以把幾十台三輪車串在一起,變成超級長的協力車耶。

超級長的協力車


我們選擇搭船遊湖,船會在大沼跟小沼繞一趟,遊覽時間30分鐘(¥960)。
大沼公園
乘船處
大沼公園


搭完了遊船還有時間,便到了湖畔散步,湖中蠻多小島的,也因此有許多橋連接彼此,讓遊客可以打跳島作戰(太平洋戰爭嗎…)。
大沼公園
大沼公園
大沼公園


北海道的鳥鴉,只能說超級無敵多。想不到morris有跟其中一隻合照。
難得跟烏鴉一起合照


來這裡除了看風景,是不是少了些什麼呢?  看到吃的,買就對了,morris買了一支墨魚冰淇淋,吃完的感想是,除了會把嘴巴弄黑這點很特別之外,根本就是不折不扣的牛奶冰淇淋。
墨魚冰淇淋

同事H則買了章魚丸子,長得跟一般的章魚丸子差很多,看起來像火鍋料的黃金魚蛋,放在保麗龍的盤子上,醬料也只淋了點醬油,因為morris沒吃所以也不知道味道,倒是發生了件恐怖的事,盛丸子的盤子竟然出現了溶解的情況,有點扯。


搭上了往札幌的火車,這班列車會經過長萬部這一站,可向列車小姐訂知名的長萬部便當,前提是要在抵達長萬部的一個小時前下訂,morris就靠著椅背上的menu,指指點點向小姐訂了螃蟹便當(不過當時距長萬部不到一小時了)。經過小小的等待,就看到小姐提著便當過來囉,是一個熱騰騰的木盒便當,打開一看,滿滿的螃蟹肉,下面是好吃的白飯,配菜則不多,完全看螃蟹這個主角了,味道上,不要跟朝市的比較,其實以便當來說挺不錯的。

背影長萬部便當
滿滿的螃蟹肉