<span style="font-family: Consolas; color: rgb(0, 0, 0);">AMD OpenCL大學課程是非常好的入門級OpenCL教程,通過看教程中的PPT,我們能夠很快的瞭解OpenCL機制以及編程方法。下載地址:<a target=_blank target="_blank" title="http://developer.amd.com/zones/OpenCLZone/universities/Pages/default.aspx" href="http://developer.amd.com/zones/OpenCLZone/universities/Pages/default.aspx" style="color: rgb(51, 102, 153); text-decoration: none;">http://developer.amd.com/zones/OpenCLZone/universities/Pages/default.aspx</a></span>
<span style="font-family: Consolas; color: rgb(0, 0, 0);"> 教程中的英文很簡單,我相信學OpenCL的人都能看得懂,而且看原汁原味的英文表述,更有利於我們瞭解各種術語的來龍去脈。</span>
<span style="font-family: Consolas; color: rgb(0, 0, 0);"> 我把這些教程翻譯成自己的中文表述,主要是強化理解需要,其實我的英文很爛。</span>
<span style="font-family: Consolas; color: rgb(0, 0, 0);">一、並行計算概述</span>
<span style="font-family: Consolas; color: rgb(0, 0, 0);"> 在計算機術語中,並行性是指:<span style="color: rgb(155, 0, 211);">把一個複雜問題,分解成多個能同時處理的子問題的能力</span>。要實現並行計算,首先我們要有物理上能夠實現並行計算的硬件設備,比如多核CPU,每個核能同時實現算術或邏輯運算。</span>
<span style="font-family: Consolas; color: rgb(0, 0, 0);"> 通常,我們通過GPU實現兩類並行計算:</span>
<span style="font-family: Consolas; color: rgb(0, 0, 0);"> 任務並行:<span style="color: rgb(0, 0, 255);">把一個問題分解爲能夠同時執行的多個任務</span>。</span>
<span style="font-family: Consolas; color: rgb(0, 0, 0);"> 數據並行:<span style="color: rgb(0, 0, 255);">同一個任務內,它的各個部分同時執行</span>。</span>
<span style="font-family: Consolas; color: rgb(0, 0, 0);"> 下面我們通過一個農場主僱傭工人摘蘋果的例子來描述不同種類的並行計算。</span>
<a target=_blank target="_blank" href="http://images.cnblogs.com/cnblogs_com/mikewolf2002/201201/201201301920165978.png" style="color: rgb(51, 102, 153); text-decoration: none;"><img title="image" alt="image" src="http://images.cnblogs.com/cnblogs_com/mikewolf2002/201201/201201301920189174.png" border="0" height="176" width="167" style="border: 0px none; max-width: 100%; padding-left: 0px; padding-right: 0px; display: inline; padding-top: 0px;" /></a>
-
摘蘋果的工人就是硬件上的並行處理單元(process elements)。
樹就是要執行的任務。
蘋果就是要處理的數據。
串行的任務處理就如下圖所示,一個工人揹着梯子摘完所有樹上的蘋果(<span style="color: rgb(0, 0, 255);">一個處理單元處理完所有任務的數據</span>)。
數據並行就好比農場主僱傭了好多工人來摘完一個樹上的蘋果(多個處理單元並行完成一個任務中的數據),這樣就能很快摘完一顆樹上的蘋果。
農場主也可以爲每棵樹安排一個工人,這就好比任務並行。在每個任務內,由於只有一個工人,所以是串行執行的,但任務之間是並行的。
對一個複雜問題,影響並行計算的因素很多。通常,我們都是通過分解問題的方式來實施並算法行。
這又包括兩方面內容:
- 任務分解:把算法分解成很多的小任務,就像前面的例子中,把果園按蘋果樹進行劃分,這時我們並不關注數據,也就是說不關注每個樹上到底有多少個蘋果。
- 數據分解:就是把很多數據,分成不同的、離散的小塊,這些數據塊能夠被並行執行,就好比前面例子中的蘋果。
通常我們按照算法之間的依賴關係來分解任務,這樣就形成了一個任務關係圖。一個任務只有沒有依賴任務的時候,才能夠被執行。
這有點類似於數據結構中的有向無環圖,兩個沒有連通路徑的任務之間可以並行執行。下面再給一個烤麪包的例子,如果所示,預熱烤箱和購買麪粉糖兩個任務之間可以並行執行。
對大多數科學計算和工程應用來說,數據分解一般都是基於輸出數據,例如:
- 在一副圖像中,對一個滑動窗口(例如:3*3像素)內的像素實施濾波操作,可以得到一個輸出像素的卷積。
- 第一個輸入矩陣的第i行乘以第二個輸入矩陣的第j列,得到的向量和即爲輸出矩陣第i行,第j列的元素。
這種方法對於輸入和輸出數據是一對一,或者多對一的對應關係比較有效。
也有的數據分解算法是基於輸入數據的,這時,輸入數據和輸出數據一般是一對多的關係,比如求圖像的直方圖,我們要把每個像素放到對應的槽中(bins,對於灰度圖,bin數量通常是256)。一個搜索函數,輸入可能是多個數據,輸出卻只有一個值。對於這類應用,我們一般用每個線程計算輸出的一部分,然後通過同步以及原子操作得到最終的值,OpenCL中求最小值的kernel函數就是典型代表[可以看下ATI Stream Computing OpenCL programming guide第二章中求最小值的kernel例子]。
通常來說,怎樣分解問題和具體算法有關,而且還要考慮自己使用的硬件和軟件,比如AMD GPU平臺和Nvdia GPU平臺的優化就有很多不同。
二、常用基於硬件和軟件的並行
在上個實際90年代,並行計算主要研究如何在cpu上實施指自動的指令級並行。
- 同時發射多條指令(之間沒有依賴關係),並行執行這些指令。
- 在本教程中,我麼不講述自動的硬件級並行,感興趣的話,可以看看計算機體系結構的教程。
高層的並行,比如線程級別的並行,一般很難自動化,需要程序員告訴計算機,該做什麼,不該做什麼。這時,程序員還要考慮硬件的具體指標,通常特定硬件都是適應於某一類並行編程,比如多核cpu就適合基於任務的並行編程,而GPU更適應於數據並行編程。
Hardware type |
Examples |
Parallelism |
Multi-core superscalar processors |
Phenom II CPU |
Task |
Vector or SIMD processors |
SSE units (x86 CPUs) |
Data |
Multi-core SIMD processors |
Radeon 5870 GPU |
Data |
現代的GPU有很多獨立的運算核(processor)組成,在AMD GPU上就是stream core,這些core能夠執行SIMD操作(單指令,多數據),所以特別適合數據並行操作。通常GPU上執行一個任務,都是把任務中的數據分配到各個獨立的core中執行。
在GPU上,我們一般通過循環展開,Loop strip mining 技術,來把串行代碼改成並行執行的。比如在CPU上,如果我們實現一個向量加法,代碼通常如下:
1: for(i = 0; i < n; i++)
2: {
3: C[i] = A[i] + B[i];
4: }
在GPU上,我們可以設置n個線程,每個線程執行一個加法,這樣大大提高了向量加法的並行性。
1: __kernel void VectorAdd(__global const float* a, __global const float* b, __global float* c, int n)
2: {
3: int i = get_global_id(0);
4: c[i] = a[i] + b[i];
5: }
上面這個圖展示了向量加法的SPMD(單指令多線程)實現,從圖中可以看出如何實施Loop strip mining 操作的。
GPU的程序一般稱作Kernel程序,它是一種SPMD的編程模型(the Single Program Multiple Data )。SPMD執行同一段代碼的多個實例,每個實例對數據的不同部分進行操作。
在數據並行應用中,用loop strip mining來實現SPMD是最常用的方法:
- 在分佈式系統中,我們用Message Passing Interface (MPI)來實現SPMD。
- 在共享內存並行系統中,我們用POSIX線程來實現SPMD。
- 在GPU中,我們就是用Kernel來顯現SPMD。
在現代的CPU上,創建一個線程的開銷還是很大的,如果要在CPU上實現SPMD,每個線程處理的數據塊就要儘量大點,做更多的事情,以便減少平均線程開銷。但在GPU上,都是輕量級的線程,創建、調度線程的開銷比較小,所以我們可以做到把循環完全展開,一個線程處理一個數據。
GPU上並行編程的硬件一般稱作SIMD。通常,發射一條指令後,它要在多個ALU單元中執行(ALU的數量即使simd的寬度),這種設計減少了控制流單元以級ALU相關的其他硬件數量。
SIMD的硬件如下圖所示:
在向量加法中,寬度爲4的SIMD單元,可以把整個循環分爲四個部分同時執行。在工人摘蘋果的例子中,工人的雙手類似於SIMD的寬度爲2。另外,我們要知道,現在的GPU硬件上都是基於SIMD設計,GPU硬件隱式的把SPMD線程映射到SIMD core上。對開發有人員來說,我們並不需要關注硬件執行結果是否正確,我們只需要關注它的性能就OK了。
CPU一般都支持並行級的原子操作,這些操作保證不同的線程讀寫數據,相互之間不會干擾。有些GPU支持系統範圍的並行操作,但會有很大開銷,比如Global memory的同步。
1、OpenCL架構
OpenCL可以實現混合設備的並行計算,這些設備包括CPU,GPU,以及其它處理器,比如Cell處理器,DSP等。使用OpenCL編程,可以實現可移植的並行加速代碼。[但由於各個OpenCL device不同的硬件性能,可能對於程序的優化還要考慮具體的硬件特性]。
通常OpenCL架構包括四個部分:
- 平臺模型(Platform Model)
- 執行模型(Execution Model)
- 內存模型(Memory Model)
- 編程模型(Programming Model)
2、OpenCL平臺模型
不同廠商的OpenCL實施定義了不同的OpenCL平臺,通過OpenCL平臺,主機能夠和OpenCL設備之間進行交互操作。現在主要的OpenCL平臺有AMD、Nvida,Intel等。OpenCL使用了一種Installable Client Driver模型,這樣不同廠商的平臺就能夠在系統中共存。在我的計算機上就安裝有AMD和Intel兩個OpenCL Platform[現在的OpenCL driver模型不允許不同廠商的GPU同時運行]。
OpenCL平臺通常包括一個主機(Host)和多個OpenCL設備(device),每個OpenCL設備包括一個或多個CU(compute units),每個CU包括又一個或多個PE(process element)。 每個PE都有自己的程序計數器(PC)。主機就是OpenCL運行庫宿主設備,在AMD和Nvida的OpenCL平臺中,主機一般都指x86 CPU。
對AMD平臺來說,所有的CPU是一個設備,CPU的每一個core就是一個CU,而每個GPU都是獨立的設備。
3、OpenCL編程的一般步驟
下面我們通過一個實例來了解OpenCL編程的步驟,假設我們用的是AMD OpenCL平臺(因爲本人的GPU是HD5730),安裝了AMD Stream SDK 2.6,並在VS2008中設置好了include,lib目錄等。
首先我們建立一個控制檯程序,最初的代碼如下:
1: #include "stdafx.h"
2: #include <CL/cl.h>
3: #include <stdio.h>
4: #include <stdlib.h>
5:
6: #pragma comment (lib,"OpenCL.lib")
7:
8: int main(int argc, char* argv[])
9: {
10: return 0;
11: }
第一步,我們要選擇一個OpenCL平臺,所用的函數就是
通常,這個函數要調用2次,第一次得到系統中可使用的平臺數目,然後爲(Platform)平臺對象分配空間,第二次調用就是查詢所有的平臺,選擇自己需要的OpenCL平臺。代碼比較長,具體可以看下AMD Stream SDK 2.6中的TemplateC例子,裏面描述如何構建一個robust的最小OpenCL程序。爲了簡化代碼,使程序看起來不那麼繁瑣,我直接調用該函數,選取系統中的第一個OpenCL平臺,我的系統中安裝AMD和Intel兩家的平臺,第一個平臺是AMD的。另外,我也沒有增加錯誤檢測之類的代碼,但是增加了一個status的變量,通常如果函數執行正確,返回的值是0。
1: #include "stdafx.h"
2: #include <CL/cl.h>
3: #include <stdio.h>
4: #include <stdlib.h>
5:
6: #pragma comment (lib,"OpenCL.lib")
7:
8: int main(int argc, char* argv[])
9: {
10: cl_uint status;
11: cl_platform_id platform;
12:
13: status = clGetPlatformIDs( 1, &platform, NULL );
14:
15: return 0;
16: }
第二步是得到OpenCL設備,
這個函數通常也是調用2次,第一次查詢設備數量,第二次檢索得到我們想要的設備。爲了簡化代碼,我們直接指定GPU設備。
1: #include "stdafx.h"
2: #include <CL/cl.h>
3: #include <stdio.h>
4: #include <stdlib.h>
5:
6: #pragma comment (lib,"OpenCL.lib")
7:
8: int main(int argc, char* argv[])
9: {
10: cl_uint status;
11: cl_platform_id platform;
12:
13: status = clGetPlatformIDs( 1, &platform, NULL );
14:
15: cl_device_id device;
16:
17: clGetDeviceIDs( platform, CL_DEVICE_TYPE_GPU,
18: 1,
19: &device,
20: NULL);
21:
22: return 0;
23: }
下面我們來看下OpenCL中Context的概念:
通常,Context是指管理OpenCL對象和資源的上下文環境。爲了管理OpenCL程序,下面的一些對象都要和Context關聯起來:
—設備(Devices):執行Kernel程序對象。
—程序對象(Program objects): kernel程序源代碼
—Kernels:運行在OpenCL設備上的函數。
—內存對象(Memory objects): device處理的數據對象。
—命令隊列(Command queues): 設備之間的交互機制。
注意:創建一個Context的時候,我們必須把一個或多個設備和它關聯起來。對於其它的OpenCL資源,它們創建時候,也要和Context關聯起來,一般創建這些資源的OpenCL函數的輸入參數中,都會有context。
這個函數中指定了和context關聯的一個或多個設備對象,properties參數指定了使用的平臺,如果爲NULL,廠商選擇的缺省值被使用,這個函數也提供了一個回調機制給用戶提供錯誤報告。
現在的代碼如下:
1: #include "stdafx.h"
2: #include <CL/cl.h>
3: #include <stdio.h>
4: #include <stdlib.h>
5:
6: #pragma comment (lib,"OpenCL.lib")
7:
8: int main(int argc, char* argv[])
9: {
10: cl_uint status;
11: cl_platform_id platform;
12:
13: status = clGetPlatformIDs( 1, &platform, NULL );
14:
15: cl_device_id device;
16:
17: clGetDeviceIDs( platform, CL_DEVICE_TYPE_GPU,
18: 1,
19: &device,
20: NULL);
21: cl_context context = clCreateContext( NULL,
22: 1,
23: &device,
24:
25:
26: return 0;
27: }
接下來,我們要看下命令隊列。在OpenCL中,命令隊列就是主機的請求,在設備上執行的一種機制。
- 在Kernel執行前,我們一般要進行一些內存拷貝的工作,比如把主機內存中的數據傳輸到設備內存中。
另外要注意的幾點就是:對於不同的設備,它們都有自己的獨立的命令隊列;命令隊列中的命令(kernel函數)可能是同步的,也可能是異步的,它們的執行順序可以是有序的,也可以是亂序的。
命令隊列在device和context之間建立了一個連接。
命令隊列properties指定以下內容:
- 是否亂序執行(在AMD GPU中,好像現在還不支持亂序執行)
- 是否啓動profiling。Profiling通過事件機制來得到kernel執行時間等有用的信息,但它本身也會有一些開銷。
如下圖所示,命令隊列把設備和context聯繫起來,儘管它們之間不是物理連接。
添加命令隊列後的代碼如下:
1: #include "stdafx.h"
2: #include <CL/cl.h>
3: #include <stdio.h>
4: #include <stdlib.h>
5:
6: #pragma comment (lib,"OpenCL.lib")
7:
8: int main(int argc, char* argv[])
9: {
10: cl_uint status;
11: cl_platform_id platform;
12:
13: status = clGetPlatformIDs( 1, &platform, NULL );
14:
15: cl_device_id device;
16:
17: clGetDeviceIDs( platform, CL_DEVICE_TYPE_GPU,
18: 1,
19: &device,
20: NULL);
21: cl_context context = clCreateContext( NULL,
22: 1,
23: &device,
24: NULL, NULL, NULL);
25:
26: cl_command_queue queue = clCreateCommandQueue( context,
27: device,
28: CL_QUEUE_PROFILING_ENABLE, NULL );
29:
30: return 0;
31: }
OpenCL內存對象:
OpenCL內存對象就是一些OpenCL數據,這些數據一般在設備內存中,能夠被拷入也能夠被拷出。OpenCL內存對象包括buffer對象和image對象。
buffer對象:連續的內存塊----順序存儲,能夠通過指針、行列式等直接訪問。
image對象:是2維或3維的內存對象,只能通過read_image() 或 write_image()來讀取。image對象可以是可讀或可寫的,但不能同時既可讀又可寫。
該函數會在指定的context上創建一個buffer對象,image對象相對比較複雜,留在後面再講。
flags參數指定buffer對象的讀寫屬性,host_ptr可以是NULL,如果不爲NULL,一般是一個有效的host buffer對象,這時,函數創建OpenCL buffer對象後,會把對應host buffer的內容拷貝到OpenCL buffer中。
在Kernel執行之前,host中原始輸入數據必須顯式的傳到device中,Kernel執行完後,結果也要從device內存中傳回到host內存中。我們主要通過函數clEnqueue{Read|Write}{Buffer|Image}來實現這兩種操作。從host到device,我們用clEnqueueWrite,從device到host,我們用clEnqueueRead。clEnqueueWrite命令包括初始化內存對象以及把host 數據傳到device內存這兩種操作。當然,像前面一段說的那樣,也可以把host buffer指針直接用在CreateBuffer函數中來實現隱式的數據寫操作。
這個函數初始化OpenCL內存對象,並把相應的數據寫到OpenCL內存關聯的設備內存中。其中,blocking_write參數指定是數拷貝完成後函數才返回還是數據開始拷貝後就立即返回(阻塞模式於非阻塞模式)。Events參數指定這個函數執行之前,必須要完成的Event(比如先要創建OpenCL內存對象的Event)。
OpenCL程序對象:
程序對象就是通過讀入Kernel函數源代碼或二進制文件,然後在指定的設備上進行編譯而產生的OpenCL對象。
這個函數通過源代碼(strings),創建一個程序對象,其中counts指定源代碼串的數量,lengths指定源代碼串的長度(爲NULL結束的串時,可以省略)。當然,我們還必須自己編寫一個從文件中讀取源代碼串的函數。
對context中的每個設備,這個函數編譯、連接源代碼對象,產生device可以執行的文件,對GPU而言就是設備對應shader彙編。如果device_list參數被提供,則只對這些設備進行編譯連接。options參數主要提供一些附加的編譯選項,比如宏定義、優化開關標誌等等。
如果程序編譯失敗,我們能夠根據返回的狀態,通過調用clGetProgramBuildInfo來得到錯誤信息。
加上創建內存對象以及程序對象的代碼如下:
1:
2: #include "stdafx.h"
3: #include <CL/cl.h>
4: #include <stdio.h>
5: #include <stdlib.h>
6: #include <time.h>
7: #include <iostream>
8: #include <fstream>
9:
10: using namespace std;
11: #define NWITEMS 262144
12:
13: #pragma comment (lib,"OpenCL.lib")
14:
Kernel對象:
Kernel就是在程序代碼中的一個函數,這個函數能在OpenCL設備上執行。一個Kernel對象就是kernel函數以及其相關的輸入參數。
Kernel對象通過程序對象以及指定的函數名字創建。注意:函數必須是程序源代碼中存在的函數。
運行時編譯:
在運行時,編譯程序和創建kernel對象是有時間開銷的,但這樣比較靈活,能夠適應不同的OpenCL硬件平臺。程序動態編譯一般只需一次,而Kernel對象在創建後,可以反覆調用。
創建Kernel後,運行Kernel之前,我們還要爲Kernel對象設置參數。我們可以在Kernel運行後,重新設置參數再次運行。
arg_index指定該參數爲Kernel函數中的第幾個參數(比如第一個參數爲0,第二個爲1,…)。內存對象和單個的值都可以作爲Kernel參數。下面是2個設置Kernel參數的例子:
clSetKernelArg(kernel, 0, sizeof(cl_mem), (void*)&d_iImage);
clSetKernelArg(kernel, 1, sizeof(int), (void*)&a);
在Kernel運行之前,我們先看看OpenCL中的線程結構:
大規模並行程序中,通常每個線程處理一個問題的一部分,比如向量加法,我們會把兩個向量中對應的元素加起來,這樣,每個線程可以處理一個加法。
下面我看一個16個元素的向量加法:兩個輸入緩衝A、B,一個輸出緩衝C
在這種情況下,我們可以創建一維的線程結構去匹配這個問題。
每個線程把自己的線程id作爲索引,把相應元素加起來。
OpenCL中的線程結構是可縮放的,Kernel的每個運行實例稱作WorkItem(也就是線程),WorkItem組織在一起稱作WorkGroup,OpenCL中,每個Workgroup之間都是相互獨立的。
通過一個global id(在索引空間,它是唯一的)或者一個workgroup id和一個work group內的local id,我就能標定一個workitem。
在kernel函數中,我們能夠通過API調用得到global id以及其他信息:
get_global_id(dim)
get_global_size(dim)
這兩個函數能得到每個維度上的global id。
get_group_id(dim)
get_num_groups(dim)
get_local_id(dim)
get_local_size(dim)
這幾個函數用來計算group id以及在group內的local id。
get_global_id(0) = column, get_global_id(1) = row
get_num_groups(0) * get_local_size(0) == get_global_size(0)
OpenCL內存模型
OpenCL的內存模型定義了各種各樣內存類型,各種內存模型之間有層級關係。各種內存之間的數據傳輸必須是顯式進行的,比如從host memory到device memory,從global memory到local memory等等。
WorkGroup被映射到硬件的CU上執行(在AMD 5xxx系列顯卡上,CU就是simd,一個simd中有16個pe,或者說是stream core),OpenCL並不提供各個workgroup之間的一致性,如果我們需要在各個workgroup之間共享數據或者通信之類的,要自己通過軟件實現。
Kernel函數的寫法
每個線程(workitem)都有一個kenerl函數的實例。下面我們看下kernel的寫法:
1: __kernel void vecadd(__global const float* A, __global const float* B, __global float* C)
2: {
3: int id = get_global_id(0);
4: C[id] = A[id] + B[id];
5: }
每個Kernel函數都必須以__kernel開始,而且必須返回void。每個輸入參數都必須聲明使用的內存類型。通過一些API,比如get_global_id之類的得到線程id。
內存對象地址空間標識符有以下幾種:
__global – memory allocated from global address space
__constant – a special type of read-only memory
__local – memory shared by a work-group
__private – private per work-item memory
__read_only/__write_only – used for images
Kernel函數參數如果是內存對象,那麼一定是__global,__local或者constant。
運行Kernel
首先要設置線程索引空間的維數以及workgroup大小等。
我們通過函數clEnqueueNDRangeKerne把Kernel放在一個隊列裏,但不保證它馬上執行,OpenCL driver會管理隊列,調度Kernel的執行。注意:每個線程執行的代碼都是相同的,但是它們執行數據卻是不同的。
該函數把要執行的Kernel函數放在指定的命令隊列中,globald大小(線程索引空間)必須指定,local大小(work group)可以指定,也可以爲空。如果爲空,則系統會自動根據硬件選擇合適的大小。event_wait_list用來選定一些events,只有這些events執行完後,該kernel纔可能被執行,也就是通過事件機制來實現不同kernel函數之間的同步。
當Kernel函數執行完畢後,我們要把數據從device memory中拷貝到host memory中去。
釋放資源:
大多數的OpenCL資源都是指針,不使用的時候需要釋放掉。當然,程序關閉的時候這些對象也會被自動釋放掉。
釋放資源的函數是:clRelase{Resource} ,比如: clReleaseProgram(), clReleaseMemObject()等。
錯誤捕捉:
如果OpenCL函數執行失敗,會返回一個錯誤碼,一般是個負值,返回0則表示執行成功。我們可以根據該錯誤碼知道什麼地方出錯了,需要修改。錯誤碼在cl.h中定義,下面是幾個錯誤碼的例子.
CL_DEVICE_NOT_FOUND -1
CL_DEVICE_NOT_AVAILABLE -2
CL_COMPILER_NOT_AVAILABLE -3
CL_MEM_OBJECT_ALLOCATION_FAILURE -4
…
下面是一個OpenCL機制的示意圖
程序模型
數據並行:work item和內存對象元素之間是一一映射關係;workgroup可以顯示指定,也可以隱式指定。
任務並行:kernel的執行獨立於線程索引空間;用其他方法表示並行,比如把不同的任務放入隊列,用設備指定的特殊的向量類型等等。
同步:workgroup內work item之間的同步;命令隊列中不同命令之間的同步。
完整代碼如下:
1: #include "stdafx.h"
2: #include <CL/cl.h>
3: #include <stdio.h>
4: #include <stdlib.h>
5: #include <time.h>
6: #include <iostream>
7: #include <fstream>
8:
9: using namespace std;
10: #define NWITEMS 262144
11:
12: #pragma comment (lib,"OpenCL.lib")
13:
14: //把文本文件讀入一個string中
15: int convertToString(const char *filename, std::string& s)
16: {
17: size_t size;
18: char* str;
19:
20: std::fstream f(filename, (std::fstream::in | std::fstream::binary));
21:
22: if(f.is_open())
23: {
24: size_t fileSize;
25: f.seekg(0, std::fstream::end);
26: size = fileSize = (size_t)f.tellg();
27: f.seekg(0, std::fstream::beg);
28:
29: str = new char[size+1];
30: if(!str)
31: {
32: f.close();
33: return NULL;
34: }
35:
36: f.read(str, fileSize);
37: f.close();
38: str[size] = '\0';
39:
40: s = str;
41: delete[] str;
42: return 0;
43: }
44: printf("Error: Failed to open file %s\n", filename);
45: return 1;
46: }
47:
48: int main(int argc, char* argv[])
49: {
50: //在host內存中創建三個緩衝區
51: float *buf1 = 0;
52: float *buf2 = 0;
53: float *buf = 0;
54:
55: buf1 =(float *)malloc(NWITEMS * sizeof(float));
56: buf2 =(float *)malloc(NWITEMS * sizeof(float));
57: buf =(float *)malloc(NWITEMS * sizeof(float));
58:
59: //初始化buf1和buf2的內容
60: int i;
61: srand( (unsigned)time( NULL ) );
62: for(i = 0; i < NWITEMS; i++)
63: buf1[i] = rand()%65535;
64:
65: srand( (unsigned)time( NULL ) +1000);
66: for(i = 0; i < NWITEMS; i++)
67: buf2[i] = rand()%65535;
68:
69: for(i = 0; i < NWITEMS; i++)
70: buf[i] = buf1[i] + buf2[i];
71:
72: cl_uint status;
73: cl_platform_id platform;
74:
75: //創建平臺對象
76: status = clGetPlatformIDs( 1, &platform, NULL );
77:
78: cl_device_id device;
79:
80: //創建GPU設備
81: clGetDeviceIDs( platform, CL_DEVICE_TYPE_GPU,
82: 1,
83: &device,
84: NULL);
85: //創建context
86: cl_context context = clCreateContext( NULL,
87: 1,
88: &device,
89: NULL, NULL, NULL);
90: //創建命令隊列
91: cl_command_queue queue = clCreateCommandQueue( context,
92: device,
93: CL_QUEUE_PROFILING_ENABLE, NULL );
94: //創建三個OpenCL內存對象,並把buf1的內容通過隱式拷貝的方式
95: //拷貝到clbuf1,buf2的內容通過顯示拷貝的方式拷貝到clbuf2
96: cl_mem clbuf1 = clCreateBuffer(context,
97: CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
98: NWITEMS*sizeof(cl_float),buf1,
99: NULL );
100:
101: cl_mem clbuf2 = clCreateBuffer(context,
102: CL_MEM_READ_ONLY ,
103: NWITEMS*sizeof(cl_float),NULL,
104: NULL );
105:
106: status = clEnqueueWriteBuffer(queue, clbuf2, 1,
107: 0, NWITEMS*sizeof(cl_float), buf2, 0, 0, 0);
108:
109: cl_mem buffer = clCreateBuffer( context,
110: CL_MEM_WRITE_ONLY,
111: NWITEMS * sizeof(cl_float),
112: NULL, NULL );
113:
114: const char * filename = "add.cl";
115: std::string sourceStr;
116: status = convertToString(filename, sourceStr);
117: const char * source = sourceStr.c_str();
118: size_t sourceSize[] = { strlen(source) };
119:
120: //創建程序對象
121: cl_program program = clCreateProgramWithSource(
122: context,
123: 1,
124: &source,
125: sourceSize,
126: NULL);
127: //編譯程序對象
128: status = clBuildProgram( program, 1, &device, NULL, NULL, NULL );
129: if(status != 0)
130: {
131: printf("clBuild failed:%d\n", status);
132: char tbuf[0x10000];
133: clGetProgramBuildInfo(program, device, CL_PROGRAM_BUILD_LOG, 0x10000, tbuf, NULL);
134: printf("\n%s\n", tbuf);
135: return -1;
136: }
137:
138: //創建Kernel對象
139: cl_kernel kernel = clCreateKernel( program, "vecadd", NULL );
140: //設置Kernel參數
141: cl_int clnum = NWITEMS;
142: clSetKernelArg(kernel, 0, sizeof(cl_mem), (void*) &clbuf1);
143: clSetKernelArg(kernel, 1, sizeof(cl_mem), (void*) &clbuf2);
144: clSetKernelArg(kernel, 2, sizeof(cl_mem), (void*) &buffer);
145:
146: //執行kernel
147: cl_event ev;
148: size_t global_work_size = NWITEMS;
149: clEnqueueNDRangeKernel( queue,
150: kernel,
151: 1,
152: NULL,
153: &global_work_size,
154: NULL, 0, NULL, &ev);
155: clFinish( queue );
156:
157: //數據拷回host內存
158: cl_float *ptr;
159: ptr = (cl_float *) clEnqueueMapBuffer( queue,
160: buffer,
161: CL_TRUE,
162: CL_MAP_READ,
163: 0,
164: NWITEMS * sizeof(cl_float),
165: 0, NULL, NULL, NULL );
166: //結果驗證,和cpu計算的結果比較
167: if(!memcmp(buf, ptr, NWITEMS))
168: printf("Verify passed\n");
169: else printf("verify failed");
170:
171: if(buf)
172: free(buf);
173: if(buf1)
174: free(buf1);
175: if(buf2)
176: free(buf2);
177:
178: //刪除OpenCL資源對象
179: clReleaseMemObject(clbuf1);
180: clReleaseMemObject(clbuf2);
181: clReleaseMemObject(buffer);
182: clReleaseProgram(program);
183: clReleaseCommandQueue(queue);
184: clReleaseContext(context);
185: return 0;
186: }
187:
也可以在http://code.google.com/p/imagefilter-opencl/downloads/detail?name=amdunicourseCode1.zip&can=2&q=#makechanges上下載完整版本。
GPU架構
內容包括:
1.OpenCLspec和多核硬件的對應關係
- AMD GPU架構
- Nvdia GPU架構
- Cell Broadband Engine
2.一些關於OpenCL的特殊主題
- OpenCL編譯系統
- Installable client driver
首先我們可能有疑問,既然OpenCL具有平臺無關性,我們爲什麼還要去研究不同廠商的特殊硬件設備呢?
- 瞭解程序中的循環和數據怎樣映射到OpenCL Kernel中,便於我們提高代碼質量,獲得更高的性能。
- 瞭解AMD和Nvdia顯卡的區別。
- 瞭解各種硬件的區別,可以幫助我們使用基於這些硬件的一些特殊的OpenCL擴展,這些擴展在後面課程中會講到。
3、傳統的CPU架構
- 對單個線程來說,CPU優化能獲得最小時延,而且CPU也適合處理控制流密集的工作,比如if、else或者跳轉指令比較多的任務。
- 控制邏輯單元在芯片中佔用的面積要比ALU單元多。
- 多層次的cache設計被用來隱藏時延(可以很好的利用空間和時間局部性原理)
- 有限的寄存器數量使得同時active的線程不能太多。
- 控制邏輯單元記錄程序的執行、提供指令集並行(ILP)以及最小化CPU管線的空置週期(stalls,在該時鐘週期,ALU沒做什麼事)。
4、現代的GPGPU架構
- 對於現代的GPU,通常的它的控制邏輯單元比較簡單(和cpu相比),cache也比較小
- 線程切換開銷比較小,都是輕量級的線程。
- GPU的每個“核”有大量的ALU以及很小的用戶可管理的cache。[這兒的核應該是指整個GPU]。
- 內存總線都是基於帶寬優化的。150GB/s的帶寬可以使得大量ALU同時進行內存操作。
5、AMD GPU硬件架構
現在我們簡單看下AMD 5870顯卡(cypress)的架構
- 20個simd引擎,每個simd引擎包含16個simd。
- 每個simd包含16個stream core
- 每個stream core都是5路的乘法-加法運算單元(VLIW processing)。
- 單精度運算可以達到 Teraflops。
- 雙精度運算可以達到544Gb/s
上圖爲一個simd引擎的示意圖,每個simd引擎由一系列的stream core組成。
- 每個stream core是一個5路的VLIW處理器,在一個VLIW指令中,可以最多發射5個標量操作。標量操作在每個pe上執行。
- CU(8xx系列cu對應硬件的simd)內的stream core執行相同的VLIW指令。
- 在CU(或者說simd)內同時執行的work item放在一起稱作一個wave,它是cu中同時執行的線程數目。在5870中wave大小是64,也就是說一個cu內,最多有64個work item在同時執行。
注:5路的運算對應(x,y,z,w),以及T(超越函數),在cayman中,已經取消了T,改成四路了。
我們現在看下AMD GPU硬件在OpenCL中的對應關係:
- 一個workitme對應一個pe,pe就是單個的VLIW core
- 一個cu對應多個pe,cu就是simd引擎。
上圖是AMD GPU的內存架構(原課件中的圖有點小錯誤,把Global memory寫成了LDS)
- 對每個cu來說,它使用的內存包括onchip的LDS以及相關寄存器。在5870中,每個LDS是32K,共32個bank,每個bank 1k,讀寫單位4 byte。
- 對沒給cu來說,有8K的L1 cache。(for 5870)
- 各個cu之間共享的L2 cache,在5870中是512K。
- fast Path只能執行32位或32位倍數的內存操作。
- complete path能夠執行原子操作以及小於32位的內存操作。
AMD GPU的內存架構和OpenCL內存模型之間的對應關係:
- LDS對應local memeory,主要用來在一個work group內的work times之間共享數據。steam core訪問LDS的速度要比Global memory快一個數量級。
- private memory對應每個pe的寄存器。
- constant memory主要是利用了L1 cache
注意:對AMD CPU,constant memory的訪問包括三種方式:Direct-Addressing Patterns,這種模式要求不包括行列式,它的值都是在kernel函數初始化的時候就決定了,比如傳入一個固定的參數。Same Index Patterns,所有的work item都訪問相同的索引地址。Globally scoped constant arrays,行列式會被初始化,如果小於16K,會使用L1 cache,從而加快訪問速度。
當所有的work item訪問不同的索引地址時候,不能被cache,這時要在global memory中讀取。
6、Nvdia GPU Femi架構
GTX480-Compute 2.0 capability:
- 有15個core或者說SM(Streaming Multiprocessors )。
- 每個SM,一般有32 cuda處理器。
- 共480個cuda處理器。
- 帶ECC的global memory
- 每個SM內的線程按32個單位調度執行,稱作warp。每個SM內有2個warp發射單元。
- 一個cuda核由一個ALU和一個FPU組成,FPU是浮點處理單元。
SIMT和SIMD
SIMT是指單指令、多線程。
- 硬件決定了多個ALU之間要共享指令。
- 通過預測來處理多個線程間的Diverage(是指同一個warp中的指令執行路徑產生不同)。
- NV把一個warp中執行的指令當作一個SIMT。SIMT指令指定了一個線程的執行以及分支行爲。
SIMD指令可以得到向量的寬度,這點和X86 SSE向量指令比較類似。
SIMD的執行和管線相關
- 所有的ALU執行相同的指令。
- 根據指令可以管線分爲不同的階段。當第一條指令完成的時候(4個週期),下條指令開始執行。
Nvida GPU內存機制:
- 每個SM都有L1 cache,通過配置,它可以支持shared memory,也可以支持global memory。
- 48 KB Shared / 16 KB of L1 cache,16 KB Shared / 48 KB of L1 cache
- work item之間數據共享通過shared memory
- 每個SM有32K的register bank
- L2(768K)支持所有的操作,比如load,store等等
- Unified path to global for loads and stores
和AMD GPU類似,Nv的GPU 內存模型和OpenCL內存模型的對應關係是:
- shared memory對應local memory
- 寄存器對應private memory
7、Cell Broadband Engine
由索尼,東芝,IBM等聯合開發,可用於嵌入式平臺,也可用於高性能計算(SP3次世代遊戲主機就用了cell處理器)。
- Bladecenter servers提供OpenCL driver支持
- 如圖所示,cell處理器由一個Power Processing Element (PPE) 和多個Synergistic Processing Elements (SPE)組成。
- Uses the IBM XL C for OpenCL compiler 11
- Cell Power/VMX CPU 的設備類型是CL_DEVICE_TYPE_CPU,Cell SPU 的設備類型是CL_DEVICE_TYPE_ACCELERATOR。
- OpenCL Accelerator設備和CPU共享內存總線。
- 提供一些擴展,比如Device Fission、Migrate Objects來指定一個OpenCL對象駐留在什麼位置。
- 不支持OpenCL image對象,原子操作,sampler對象以及字節內存地址。
8、OpenCL編譯系統
- LLVM-底層的虛擬機
- Kernel首先在front-end被編譯成LLVM IR
- LLVM是一個開源的編譯器,具有平臺獨立性,可以支持不同廠商的back_end編譯,網址:http://llvm.org
9、Installable Client Driver
- ICD支持不同廠商的OpenCL實施在系統中共存。
- 代碼緊被鏈接接到libOpenCL.so
- 應用程序可在運行時選擇不同的OpenCL實施(就是選擇不同platform)
- 現在的GPU驅動還不支持跨廠商的多個GPU設備同時工作。
- 通過clGetPlatformIDs() 和clGetPlatformInfo() 來檢測不同廠商的OpenCL平臺。
1、GPU總線尋址介紹
假定X是一個指向整數(32位整數)數組的指針,數組的首地址爲0x00001232。一個線程要訪問元素X[0],
int tmp = X[0];
假定memory總線寬度爲256位(HD5870就是如此,即爲32字節),因爲基於字節地址的總線要訪問memeory,必須和總線寬度對齊,也就是說按必須32字節對齊來訪問memory,比如訪問0x00000000,0x00000020,0x00000040,…等,所以我們要得到地址0x00001232中的數據,比如訪問地址0x00001220,這時,它會同時得到0x00001220到 0x0000123F 的所有數據。因爲我們只是取的一個32位整數,所以有用的數據是4個字節,其它28的字節的數據都被浪費了,白白消耗了帶寬。
2、合併內存訪問
爲了利用總線帶寬,GPU通常把多個線程的內存訪問儘量合併到較少的內存請求命令中去。
假定下面的OpenCL kernel代碼:int tmp = X[get_global_id(0)];
數組X的首地址和前面例子一樣,也是0x00001232,則前16個線程將訪問地址:0x00001232 到 0x00001272。假設每個memory訪問請求都單獨發送的話,則有16個request,有用的數據只有64字節,浪費掉了448字節(16*28)。
假定多個線程訪問32個字節以內的地址,它們的訪問可以通過一個memory request完成,這樣可以大大提高帶寬利用率,在專業術語描述中這樣的合併訪問稱作coalescing。
例如上面16個線程訪問地址0x00001232 到 0x00001272,我們只需要3次memory requst。
在HD5870顯卡中,一個wave中16個連續線程的內存訪問會被合併,稱作quarter-wavefront,是重要的硬件調度單位。
下面的圖是HD5870中,使用memory訪問合併以及沒有使用合併的bandwidth比較:
下圖是GTX285中的比較:
3、Global memory的bank以及channel訪問衝突
我們知道內存由bank,channel組成,bank是實際存儲數據的單元,一個mc可以連接多個channel,形成單mc,多channel的連接方式。在物理上,不同bank的數據可以同時訪問,相同的bank的數據則必須串行訪問,channel也是同樣的道理。但由於合併訪問的緣故,對於global memory來說,bank conflit影響要小很多,除非是非合併問,不同線程訪問同一個bank。理想情況下,我們應該做到不同的workgroup訪問的不同的bank,同一個group內,最好用合併操作。
下面我簡單的畫一個圖,不知道是否準確,僅供參考:
在HD5870中,memory地址的低8位表示一個bank中的數據,接下來的3位表示channel(共8個channel),bank位的多少依賴於顯存中bank的多少。
4、local memory的bank conflit
bank訪問衝突對local memory操作有更大的影響(相比於global memory),連續的local memory訪問地址,應該映射到不同的bank上,
在AMD顯卡中,一個產生bank訪問衝突wave將會等待所有的local memory訪問完成,硬件不能通過切換到另一個wave來隱藏local memory訪問時延。所以對local memory訪問的優化就很重要。HD5870顯卡中,每個cu(simd)有32bank,每個bank 1k,按4字節對齊訪問。如果沒有bank conflit,每個bank能夠沒有延時的返回一個數據,下面的圖就是這種情況。
如果多個memory訪問對應到一個bank上,則conflits的數量決定時延的大小。下面的訪問方式將會有3倍的時延。
但是,如果所有訪問都映射到一個bank上,則系統會廣播數據訪問,不會產生額外時延。
GPU線程及調度
本節主要講述OpenCL中的Workgroup如何在硬件設備中被調度執行。同時也會講一下同一個workgroup中的workitem,如果它們執行的指令發生diverage(就是執行指令不一致)對性能的影響。學習OpenCL並行編程,不僅僅是對OpenCL Spec本身瞭解,更重要的是瞭解OpenCL硬件設備的特性,現階段來說,主要是瞭解GPU的的架構特性,這樣才能針對硬件特性優化算法。
現在OpenCL的Spec是1.1,隨着硬件的發展,相信OpenCL會支持更多的並行計算特性。基於OpenCL的並行計算纔剛剛起步,…
1、workgroup到硬件線程
在OpenCL中,Kernel函數被workgroup中的workitem(線程,我可能混用這兩個概念)執行。在硬件層次,workgroup被映射到硬件的cu(compute unit)單元來執行具體計算,而cu一般由更多的SIMT(單指令,線程)pe(processing elements)組成。這些pe執行具體的workitem計算,它們執行同樣的指令,但操作的數據不一樣,用simd的方式完成最終的計算。
由於硬件的限制,比如cu中pe數量的限制,實際上workgroup中線程並不是同時執行的,而是有一個調度單位,同一個workgroup中的線程,按照調度單位分組,然後一組一組調度硬件上去執行。這個調度單位在nv的硬件上稱作warp,在AMD的硬件上稱作wavefront,或者簡稱爲wave。
上圖顯示了workgroup中,線程被劃分爲不同wave的分組情況。wave中的線程同步執行相同的指令,但每個線程都有自己的register狀態,可以執行不同的控制分支。比如一個控制語句
if(A)
{
… //分支A
}
else
{
… //分支B
}
假設wave中的64個線程中,奇數線程執行分支A,偶數線程執行分支B,由於wave中的線程必須執行相同的指令,所以這條控制語句被拆分爲兩次執行[編譯階段進行了分支預測],第一次分支A的奇數線程執行,偶數線程進行空操作,第二次偶數線程執行,奇數線程空操作。硬件系統有一個64位mask寄存器,第一次是它爲01…0101,第二次會進行反轉操作10…1010,根據mask寄存器的置位情況,來選擇執行不同的線程。可見對於分支多的kernel函數,如果不同線程的執行發生diverage的情況太多,會影響程序的性能。
2、AMD wave調度
AMD GPU的線程調度單位是wave,每個wave的大小是64。指令發射單元發射5路的VLIW指令,每個stream core(SC)執行一條VLIW指令,16個stream core在一個時鐘週期執行16條VLIW指令。每個時鐘週期,1/4wave被完成,整個wave完成需要四個連續的時鐘週期。
另外還有以下幾點值得我們瞭解:
- 發生RAW hazard情況下,整個wave必須stall 4個時鐘週期,這時,如果其它的wave可以利用,ALU會執行其它的wave以便隱藏時延,8個時鐘週期後,如果先前等待wave已經準備好了,ALU會繼續執行這個wave。
- 兩個wave能夠完全隱藏RAW時延。第一個wave執行時候,第二個wave在調度等待數據,第一個wave執行完時,第二個wave可以立即開始執行。
3、nv warp調度
work group以32個線程爲單位,分成不同warp,這些warp被SM調度執行。每次warp中一半的線程被髮射執行,而且這些線程能夠交錯執行。可以用的warp數量依賴於每個block的資源情況。除了大小不一樣外,wave和warp在硬件特性上很相似。
4、Occupancy開銷
在每個cu中,同時激活的wave數量是受限制的,這和每個線程使用register和local memory大小有關,因爲對於每個cu,register和local memory總量是一定的。
我們用術語Occupancy來衡量一個cu中active wave的數量。如果同時激活的wave越多,能更好的隱藏時延,在後面性能優化的章節中,我們還會更具體討論Occupancy。
5、控制流和分支預測(prediction)
前面我說了if else的分支執行情況,當一個wave中不同線程出現diverage的時候,會通過mask來控制線程的執行路徑。這種預測(prediction)的方式基於下面的考慮:
- 分支的代碼都比較短
- 這種prediction的方式比條件指令更高效。
- 在編譯階段,編譯器能夠用predition替換switch或者if else。
prediction 可以定義爲:根據判斷條件,條件碼被設置爲true或者false。
__kernel void test() { int tid= get_local_id(0) ; if( tid %2 == 0) Do_Some_Work() ; else Do_Other_Work() ; }
例如上面的代碼就是可預測的,
Predicate = True for threads 0,2,4….
Predicate = False for threads 1,3,5….
下面在看一個控制流diverage的例子
- 在case1中,所有奇數線程執行DoSomeWork2(),所有偶數線程執行DoSomeWorks,但是在每個wave中,if和else代碼指令都要被髮射。
- 在case2中,第一個wave執行if,其它的wave執行else,這種情況下,每個wave中,if和else代碼只被發射一個。
在prediction下,指令執行時間是if,else兩個代碼快執行時間之和。
6、Warp voting
warp voting是一個warp內的線程之間隱式同步的機制。
比如一個warp內線程同時寫Local meory某個地址,在線程併發執行時候,warp voting機制可以保證它們的前後順序正確。更詳細的warp voting大家可以參考cuda的資料。
在OpenCL編程中,由於各種硬件設備不同,導致我們必須針對不同的硬件進行優化,這也是OpenCL編程的一個挑戰,比如warp和wave數量的不同,使得我們在設計workgroup大小時候,必須針對自己的平臺進行優化,如果選擇32,對於AMD GPU,可能一個wave中32線程是空操作,而如果選擇64,對nv GPU來說,可能會出現資源競爭的情況加劇,比如register以及local meomory的分配等等。這兒還不說混合CPU device的情況,OpenCL並行編程的道路還很漫長,期待新的OpenCL架構的出現。
性能優化
1、線程映射
所謂線程映射是指某個線程訪問哪一部分數據,其實就是線程id和訪問數據之間的對應關係。
合適的線程映射可以充分利用硬件特性,從而提高程序的性能,反之,則會降低performance。
請參考Static Memory Access Pattern Analysis on a Massively Parallel GPU這篇paper,文中講述線程如何在算法中充分利用線程映射。這是我在google中搜索到的下載地址:http://www.ece.neu.edu/~bjang/patternAnalysis.pdf
使用不同的線程映射,同一個線程可能訪問不同位置的數據。下面是幾個線程映射的例子:
我們考慮一個簡單的串行矩陣乘法:這個算法比較適合輸出數據降維操作,通過創建N*M個線程,我們移去兩層外循環,這樣每個線程執行P個加法乘法操作。現在需要我們考慮的問題是,線程索引空間究竟應該是M*N還是N*M?
當我們使用M*N線程索引空間時候,Kernel如下圖所示:
而使用N*M線程索引空間時候,Kernel如下圖所示:
使用兩種映射關係,程序執行結果是一樣的。下面是在nv的卡GeForce 285 and 8800 GPUs上的執行結果。可以看到映射2(及N*M線程索引空間),程序的performance更高。
performance差異主要是因爲在兩種映射方式下,對global memory訪問的方式有所不同。在行主序的buffer中,數據都是按行逐個存儲,爲了保證合併訪問,我們應該把一個wave中連續的線程映射到矩陣的列(第二維),這樣在A*B=C的情況下,會把矩陣B和C的內存讀寫實現合併訪問,而兩種映射方式對A沒有影響(A又i3決定順序)。
完整的源代碼請從:http://code.google.com/p/imagefilter-opencl/downloads/detail?name=amduniCourseCode4.zip&can=2&q=#makechanges下載,程序中我實現了兩種方式的比較。結果確實第二種方式要快一些。
下面我們再看一個矩陣轉置的例子,在例子中,通過改變映射方式,提高了global memory訪問的效率。
矩陣轉置的公式是:Out(x,y) = In(y,x)
從上圖可以看出,無論纔去那種映射方式,總有一個buffer是非合併訪問方式(注:在矩陣轉置時,必須要把輸入矩陣的某個元素拷貝到臨時位置,比如寄存器,然後才能拷貝到輸出矩陣)。我們可以改變線程映射方式,用local memory作爲中間元素,從而實現輸入,輸出矩陣都是global memory合併訪問。
下面是AMD 5870顯卡上,兩種線程映射方式實現的矩陣轉置性能比較:
2、Occupancy
前面的教程中,我們提到過Occupancy的概念,它主要用來描述CU中資源的利用率。
OpenCL中workgroup被映射到硬件的CU中執行,在一個workgroup中的所有線程執行完之後,這個workgroup纔算執行結束。對一個特定的cu來說,它的資源(比如寄存器數量,local memory大小,最大線程數量等)是固定的,這些資源都會限制cu中同時處於調度狀態的workgroup數量。如果cu中的資源數量足夠的的話,映射到同一個cu的多個workgroup能同時處於調度狀態,其中一個workgroup的wave處於執行狀態,當處於執行狀態的workgroup所有wave因爲等待資源而切換到等待狀態的話,不同workgroup能夠從就緒狀態切換到ALU執行,這樣隱藏memory訪問時延。這有點類似操作系統中進程之間的調度狀態。我簡單畫個圖,以供參考:
- 對於一個比較長的kernel,寄存器是主要的資源瓶頸。假設kernel需要的最大寄存器數目爲35,則workgroup中的所有線程都會使用35個寄存器,而一個CU(假設爲5870)的最大寄存器數目爲16384,則cu中最多可有16384/35=468線程,此時,一個workgroup中的線程數目(workitem)不可能超過468,
- 考慮另一個問題,一個cu共16384個寄存器,而workgroup固定爲256個線程,則使用的寄存器數量可達到64個。
每個CU的local memory也是有限的,對於AMD HD 5XXX顯卡,local memory是32K,NV的顯卡local memory是32-48K(具體看型號)。和使用寄存器的情況相似,如果kernel使用過多的local memory,則workgroup中的線程數目也會有限制。
GPU硬件還有一個CU內的最大線程數目限制:AMD顯卡256,nv顯卡512。
NV的顯卡對於每個CU內的激活線程有數量限制,每個cu 8個或16個warp,768或者1024個線程。
AMD顯卡對每個CU內的wave數量有限制,對於5870,最多496個wave。
這些限制都是因爲有限的資源競爭引起的,在nv cuda中,可以通過可視化的方式查看資源的限制情況。
3、向量化
向量化允許一個線程同時執行多個操作。我們可以在kernel代碼中,使用向量數據類型,比如float4來獲得加速。向量化在AMD的GPU上效果更爲明顯,這是因爲AMD的顯卡的stream core是(x,y,z,w)這樣的向量運算單元。
下圖是在簡單的向量賦值運算中,使用float和float4的性能比較。
kernel代碼爲: