CUDA C最佳實踐-CUDA Best Practices(二)

9. 內存優化

看頁數也知道,內存優化是性能提升最重要的途徑。目標在於通過最大化帶寬獲得對硬件的最大使用率。最好使用快速內存而減少慢速內存的訪問。這章就是各種討論內存優化。

9.1. 主機和設備之間的數據傳輸

設備內存的帶寬是上百G而PCIe總線的帶寬就8G,所以最重要的就是儘量不要傳輸數據,要把數據放到GPU上,即使在當前的Kernel用不到也要放在上頭。並且,由於傳輸數據消耗很大,要儘量把小批量的數據合併成大批量的數據。最後,使用頁鎖定內存能獲得更高的帶寬。

9.1.1. 頁鎖定內存

頁鎖定內存就不用多說了,是主存上的一種內存形式,可以使用cudaHostAlloc()來申請也可以用cudaHostRegister()將內存註冊爲頁鎖定內存。CUDA Sample裏的bandwidthTest這個例子就展示了這種內存的使用(打一波廣告:CUDA Samples).但是要注意了,頁鎖定內存雖好可不能貪杯哦,它佔用了很多內存空間又不能被替換出去,會降低系統的性能,而且從長遠來開,頁鎖定相比於其他內存分配對於系統來說消耗很大,所以與其他的優化一樣,要測試系統性能以獲得最佳的參數。

9.1.2. 數據傳輸與計算異步重疊

想要進行異步拷貝(cudaMemcpyAsync()),就要使用頁鎖定內存。而且異步傳輸可以將執行與數據傳輸重疊,代碼如下:

//最後一個參數是流的參數
cudaMemcpyAsync(a_d, a_h, size, cudaMemcpyHostToDevice, 0);
kernel<<<grid, block>>>(a_d);
//這個CPU程序也是重疊的,因爲內存拷貝和Kernel執行開始之後會馬上把控制權交個host
cpuFunction();

而使用多個流,就能夠更好地利用這種重疊。前提是數據可以被分解塊被Kernel計算。

cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
cudaMemcpyAsync(a_d, a_h, size, cudaMemcpyHostToDevice, stream1);
kernel<<<grid, block, 0, stream2>>>(otherData_d);

重點是多個流:

size=N*sizeof(float)/nStreams;
for (i=0; i<nStreams; i++) {
    offset = i*N/nStreams;
    cudaMemcpyAsync(a_d+offset, a_h+offset, size, dir, stream[i]);
    kernel<<<N/(nThreads*nStreams), nThreads, 0,stream[i]>>>(a_d+offset);
}

stream

綠色的條是數據傳輸的時間,紅色的條是執行的時間分別用tt,te來表示。當數據傳輸時間比較長的時候,總體時間是tt+te/n。如果反過來就是te+tt/n。

9.1.3. 零拷貝

這是2.2之後加入的特性。可以讓GPU直接使用主機內存。在集成的GPU上,這是有好處的因爲它避免了數據拷貝,但是對於獨立於CPU的GPU來說,如果數據就只用一次,這個開銷還是很大的。這個可以用於替代stream,因爲使用這個讓Kernel向數據傳輸自動與執行重疊而不用費心關於流的設置。

下面是關於零拷貝內存的代碼:

float *a_h, *a_map;
...
cudaGetDeviceProperties(&prop, 0);
//用來判斷是否支持零拷貝內存
if (!prop.canMapHostMemory)
    exit(0);
//在選擇設備和在進行CUDA調用之前,一定要執行下面的語句使得零拷貝內存可用
cudaSetDeviceFlags(cudaDeviceMapHost);
//使用下面的函數申請領考別內存
cudaHostAlloc(&a_h, nBytes, cudaHostAllocMapped);
cudaHostGetDevicePointer(&a_map, a_h, 0);
kernel<<<gridSize, blockSize>>>(a_map);

9.1.4. 統一虛擬地址

主機內存和設備內存有統一的虛擬地址。cudaPointerGetAttributes()這個函數可以讓內存指向你想要的地方,但是一般cudaHostAlloc分配好的可以直接指向規定的區域(有參數設置)。同時這對P2P也有很大幫助,詳情請看CUDA C Programming Guide裏有關UVA和P2P的章節。

9.2. 設備內存空間

CUDA使用的內存圖:

CUDA內存圖

下面是關於各種內存空間特性的表:

內存特性

一個十字花:在計算力2.x的時候允許cache L1 和L2,在更高的計算力下默認只cache L2,雖然也可以通過設置打開L1

倆十字花:在計算力2.x和3.xcache L1 and L2,在計算力5.x時默認L2

9.2.1. 聚合訪問全局內存

就是,一定一定一定要合併訪問全局內存,這樣才能減少事務的個數。

對於計算力2.x的設備,請求可以簡單的總結如下:線程束內線程並行地訪問將會聚合成一系列事務,事務的數量和爲warp的所有線程服務所需的cache 塊一樣。默認情況下,所有的訪問都經過L1(128個字節)。對於分散的訪問模式,爲了減少過度取數據,可以只用L2 cache,因爲它一塊有32個字節。

對於計算力3.x的設備,只經過L2。L1是用來給本地內存使用的。一些計算力比如3.5,3.7和5.2允許設置L1。

9.2.1.1. 一個簡單的訪問模式

這個簡單的模式是這樣的:

對齊訪問

這個訪問方式觸發一個128字節的內存事務。就算是如果有些數據沒用,但是還是會被全部取到cache裏。

9.2.1.2. 順序但非對齊的訪問模式

下面是非對齊的:

非對齊訪問

對於這樣非對齊的,就會導致兩個內存事務。

如果是用L2的話這種情況會有所改善:

使用L2

因此,讓block的大小是warp的倍數很重要,想象一下如果不是倍數關係,那第二個、第三個塊都是不對齊的,會造成多大的浪費。

9.2.1.3. 高效地對齊訪問

爲了驗證我們的結果,設計了以下的實驗:

__global__ void offsetCopy(float *odata, float* idata, int offset)
{
  //offset取值從0-32
    int xid = blockIdx.x * blockDim.x + threadIdx.x + offset;
    odata[xid] = idata[xid];
}

不同的offset下有不同的帶寬,實驗結果如下:

offset結果

雖然根據上文的分析,應該是non-caching的效率會更高,但是實驗結果卻不是這樣,這是因爲線程束使用了它們相鄰線程束所取到的數據。如果相鄰的線程束依賴關係不那麼多,纔會出現我們理想的結果。

9.2.1.4. 有步長的訪問

由上面可以得出一點建議就是儘可能充分使用你取到的數據。下面我們再看另一種情況:

__global__ void strideCopy(float *odata, float* idata, int stride)
{
    int xid = (blockIdx.x*blockDim.x + threadIdx.x)*stride;
    odata[xid] = idata[xid];
}

這會導致fetch到的數據有一半都用不着,隨着stride的增加,利用率會極速下降:

stride

所以這種情況一定要避免。

9.2.2. 共享內存

共享內存是片上的,高帶寬低延時,但是有存儲片衝突。

9.2.2.1. 共享內存和存儲片

存儲片和存儲片衝突可以看這個:GPU 共享內存bank衝突(shared memory bank conflicts)

重點是,硬件竟然可以把有衝突的請求分解成沒衝突的。通過利用一個和內存請求數相等的因子來降低有效帶寬。而且,共享內存還有個廣播機制。

對於不同的計算能力,存儲片的構造是不一樣的,有些大有些小,詳細情況請查看CUDA C Programming Guide。

9.2.2.2. 使用共享內存計算矩陣乘法(C=AB)

矩陣乘

講真,我覺得這節很多地方都寫錯了。。。所以還是直接上程序吧:

__global__ void sharedABMultiply(float *a, float* b, float *c,int N)
{
    //申請兩個臨時數組存放a,b的塊
    __shared__ float aTile[TILE_DIM][TILE_DIM],bTile[TILE_DIM][TILE_DIM];
    //這是當前線程操作的座標,注意這裏線程的座標已經是兩維的了
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    //0.0f標記單精度浮點數,加速且防止與主機交換數據產生錯誤
    float sum = 0.0f;
    //把數值賦值給臨時數組
    aTile[threadIdx.y][threadIdx.x] = a[row*TILE_DIM+threadIdx.x];
    bTile[threadIdx.y][threadIdx.x] = b[threadIdx.y*N+col];
    //要等待所有的線程都賦值完
    __syncthreads();
    //利用循環乘加
    for (int i = 0; i < TILE_DIM; i++) {
        sum += aTile[threadIdx.y][i]* bTile[i][threadIdx.x];
    }
    //再賦值給c
    c[row*N+col] = sum;
}

9.2.2.3. 使用共享內存計算矩陣乘法 (C=AAT)

這節就和上一節一樣,不過是轉置的矩陣相乘:

__global__ void coalescedMultiply(float *a, float *c, int M)
{
    __shared__ float aTile[TILE_DIM][TILE_DIM],transposedTile[TILE_DIM][TILE_DIM];
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    float sum = 0.0f;
    aTile[threadIdx.y][threadIdx.x] = a[row*TILE_DIM+threadIdx.x];
    //這個就是找個方法計算出其轉置的那個位置
    transposedTile[threadIdx.x][threadIdx.y] =a[(blockIdx.x*blockDim.x + threadIdx.y)*TILE_DIM +threadIdx.x];
    __syncthreads();
    for (int i = 0; i < TILE_DIM; i++) {
        sum += aTile[threadIdx.y][i]* transposedTile[i][threadIdx.x];
    }
    c[row*M+col] = sum;
}

9.2.3. 本地內存

本地內存實際上是片外的。因此訪問本地內存和訪問全局內存一樣開銷很大。local只被用來放自動變量,這是由NVCC控制,當它發現木有足夠的寄存器來放變量的時候,就會把變量放到Local裏。自動變量就是那些比寄存器大得多的數據,比如數組或者很大的結構體。通過看PTX代碼可以知道哪些變量被放在local裏了。還能使用–ptxas-options=-v這個選項來看Local到底用了多少。

9.2.4. 紋理內存

其實一直對紋理內存都是拒絕的,不知道爲啥

在地址確定的情況下,從紋理內存取數據要比從全局內存或者常量內存取數據快得多。

9.2.4.1. 額外的紋理能力

使用tex1D() , tex2D() , or tex3D()可能比tex1Dfetch()快。

9.2.5. 常量內存

設備上一共64KB的常量內存。在訪問的時候不同的線程只能順序訪問不同的地址,如果訪問相同的地址就會變得很快。

9.2.6. 寄存器

雖然訪問寄存器幾乎不耗費時間,但是讀後寫等訪問模式是造成訪問寄存器時延的一大原因。不過這一時延被多線程很好的掩蓋了。而且,對於寄存器的訪問,編譯器也會盡量優化防止衝突,當一個線程塊有64個線程的時候衝突最小。

9.2.6.1. 寄存器壓力

當沒有足夠的寄存器分配給任務的時候就會出現寄存器壓力。即時每個SM都要上千個32位寄存器,但會被併發的線程共享。爲了阻止編譯器分配過多的寄存器,使用-maxrregcount=N命令來控制分配給每個線程的最大寄存器數量。

9.3. 內存分配

使用cudaMalloc() 和 cudaFree()來申請和釋放內存的開銷很大,因此數據能重用就用哇~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章