深入淺出談CUDA

 

“CUDA 是 NVIDIA 的 GPGPU 模型,它使用 C 語言爲基礎,可以直接以大多數人熟悉的 C 語言,寫出在顯示芯片上執行的程序,而不需要去學習特定的顯示芯片的指令或是特殊的結構。”
CUDA是什麼?能吃嗎?

編者注:NVIDIA的GeFoce 8800GTX發佈後,它的通用計算架構CUDA經過一年多的推廣後,現在已經在有相當多的論文發表,在商業應用軟件等方面也初步出現了視頻編解碼、金融、地質勘探、科學計算等領域的產品,是時候讓我們對其作更深一步的瞭解。爲了讓大家更容易瞭解CUDA,我們徵得Hotball的本人同意,發表他最近親自撰寫的本文。這篇文章的特點是深入淺出,也包含了hotball本人編寫一些簡單CUDA程序的親身體驗,對於希望瞭解CUDA的讀者來說是非常不錯的入門文章,PCINLIFE對本文的發表沒有作任何的刪減,主要是把一些臺灣的詞彙轉換成大陸的詞彙以及作了若干"編者注"的註釋。

現代的顯示芯片已經具有高度的可程序化能力,由於顯示芯片通常具有相當高的內存帶寬,以及大量的執行單元,因此開始有利用顯示芯片來幫助進行一些計算工作的想法,即 GPGPU。CUDA 即是 NVIDIA 的 GPGPU 模型。

NVIDIA 的新一代顯示芯片,包括 GeForce 8 系列及更新的顯示芯片都支持 CUDA。NVIDIA 免費提供 CUDA 的開發工具(包括 Windows 版本和 Linux 版本)、程序範例、文件等等,可以在 CUDA Zone 下載。

GPGPU 的優缺點

使用顯示芯片來進行運算工作,和使用 CPU 相比,主要有幾個好處:

  1. 顯示芯片通常具有更大的內存帶寬。例如,NVIDIA 的 GeForce 8800GTX 具有超過 50GB/s 的內存帶寬,而目前高階 CPU 的內存帶寬則在 10GB/s 左右。
  2. 顯示芯片具有更大量的執行單元。例如 GeForce 8800GTX 具有 128 個 "stream processors",頻率爲 1.35GHz。CPU 頻率通常較高,但是執行單元的數目則要少得多。
  3. 和高階 CPU 相比,顯卡的價格較爲低廉。例如目前一張 GeForce 8800GT 包括 512MB 內存的價格,和一顆 2.4GHz 四核心 CPU 的價格相若。

當然,使用顯示芯片也有它的一些缺點:

  1. 顯示芯片的運算單元數量很多,因此對於不能高度並行化的工作,所能帶來的幫助就不大。
  2. 顯示芯片目前通常只支持 32 bits 浮點數,且多半不能完全支持 IEEE 754 規格, 有些運算的精確度可能較低。目前許多顯示芯片並沒有分開的整數運算單元,因此整數運算的效率較差。
  3. 顯示芯片通常不具有分支預測等複雜的流程控制單元,因此對於具有高度分支的程序,效率會比較差。
  4. 目前 GPGPU 的程序模型仍不成熟,也還沒有公認的標準。例如 NVIDIA 和 AMD/ATI 就有各自不同的程序模型。

整體來說,顯示芯片的性質類似 stream processor,適合一次進行大量相同的工作。CPU 則比較有彈性,能同時進行變化較多的工作。

CUDA 架構

CUDA 是 NVIDIA 的 GPGPU 模型,它使用 C 語言爲基礎,可以直接以大多數人熟悉的 C 語言,寫出在顯示芯片上執行的程序,而不需要去學習特定的顯示芯片的指令或是特殊的結構。

在 CUDA 的架構下,一個程序分爲兩個部份:host 端和 device 端。Host 端是指在 CPU 上執行的部份,而 device 端則是在顯示芯片上執行的部份。Device 端的程序又稱爲 "kernel"。通常 host 端程序會將數據準備好後,複製到顯卡的內存中,再由顯示芯片執行 device 端程序,完成後再由 host 端程序將結果從顯卡的內存中取回。

由於 CPU 存取顯卡內存時只能透過 PCI Express 接口,因此速度較慢(PCI Express x16 的理論帶寬是雙向各 4GB/s),因此不能太常進行這類動作,以免降低效率。

在 CUDA 架構下,顯示芯片執行時的最小單位是 thread。數個 thread 可以組成一個 block。一個 block 中的 thread 能存取同一塊共享的內存,而且可以快速進行同步的動作。

每一個 block 所能包含的 thread 數目是有限的。不過,執行相同程序的 block,可以組成 grid。不同 block 中的 thread 無法存取同一個共享的內存,因此無法直接互通或進行同步。因此,不同 block 中的 thread 能合作的程度是比較低的。不過,利用這個模式,可以讓程序不用擔心顯示芯片實際上能同時執行的 thread 數目限制。例如,一個具有很少量執行單元的顯示芯片,可能會把各個 block 中的 thread 順序執行,而非同時執行。不同的 grid 則可以執行不同的程序(即 kernel)。

Grid、block 和 thread 的關係,如下圖所示:

每個 thread 都有自己的一份 register 和 local memory 的空間。同一個 block 中的每個 thread 則有共享的一份 share memory。此外,所有的 thread(包括不同 block 的 thread)都共享一份 global memory、constant memory、和 texture memory。不同的 grid 則有各自的 global memory、constant memory 和 texture memory。這些不同的內存的差別,會在之後討論。

執行模式

由於顯示芯片大量並行計算的特性,它處理一些問題的方式,和一般 CPU 是不同的。主要的特點包括:

  1. 內存存取 latency 的問題:CPU 通常使用 cache 來減少存取主內存的次數,以避免內存 latency 影響到執行效率。顯示芯片則多半沒有 cache(或很小),而利用並行化執行的方式來隱藏內存的 latency(即,當第一個 thread 需要等待內存讀取結果時,則開始執行第二個 thread,依此類推)。
  2. 分支指令的問題:CPU 通常利用分支預測等方式來減少分支指令造成的 pipeline bubble。顯示芯片則多半使用類似處理內存 latency 的方式。不過,通常顯示芯片處理分支的效率會比較差。

因此,最適合利用 CUDA 處理的問題,是可以大量並行化的問題,纔能有效隱藏內存的 latency,並有效利用顯示芯片上的大量執行單元。使用 CUDA 時,同時有上千個 thread 在執行是很正常的。因此,如果不能大量並行化的問題,使用 CUDA 就沒辦法達到最好的效率了。

CUDA Toolkit的安裝

目前 NVIDIA 提供的 CUDA Toolkit(可從這裏下載)支持 Windows (32 bits 及 64 bits 版本)及許多不同的 Linux 版本。

CUDA Toolkit 需要配合 C/C++ compiler。在 Windows 下,目前只支持 Visual Studio 7.x 及 Visual Studio 8(包括免費的 Visual Studio C++ 2005 Express)。Visual Studio 6 和 gcc 在 Windows 下是不支援的。在 Linux 下則只支援 gcc。

這裏簡單介紹一下在 Windows 下設定並使用 CUDA 的方式。

下載及安裝

在 Windows 下,CUDA Toolkit 和 CUDA SDK 都是由安裝程序的形式安裝的。CUDA Toolkit 包括 CUDA 的基本工具,而 CUDA SDK 則包括許多範例程序以及鏈接庫。基本上要寫 CUDA 的程序,只需要安裝 CUDA Toolkit 即可。不過 CUDA SDK 仍值得安裝,因爲裏面的許多範例程序和鏈接庫都相當有用。

CUDA Toolkit 安裝完後,預設會安裝在 C:/CUDA 目錄裏。其中包括幾個目錄:

  • bin -- 工具程序及動態鏈接庫
  • doc -- 文件
  • include -- header 檔
  • lib -- 鏈接庫檔案
  • open64 -- 基於 Open64 的 CUDA compiler
  • src -- 一些原始碼

安裝程序也會設定一些環境變量,包括:

  • CUDA_BIN_PATH -- 工具程序的目錄,默認爲 C:/CUDA/bin
  • CUDA_INC_PATH -- header 文件的目錄,默認爲 C:/CUDA/inc
  • CUDA_LIB_PATH -- 鏈接庫文件的目錄,默認爲 C:/CUDA/lib

在 Visual Studio 中使用 CUDA

CUDA 的主要工具是 nvcc,它會執行所需要的程序,將 CUDA 程序代碼編譯成執行檔 (或 object 檔) 。在 Visual Studio 下,我們透過設定 custom build tool 的方式,讓 Visual Studio 會自動執行 nvcc。

這裏以 Visual Studio 2005 爲例:

  1. 首先,建立一個 Win32 Console 模式的 project(在 Application Settings 中記得勾選 Empty project),並新增一個檔案,例如 main.cu。
  2. 在 main.cu 上右鍵單擊,並選擇 Properties。點選 General,確定 Tool 的部份是選擇 Custom Build Tool
  3. 選擇 Custom Build Step,在 Command Line 使用以下設定:
    • Release 模式:"$(CUDA_BIN_PATH)/nvcc.exe" -ccbin "$(VCInstallDir)bin" -c -DWIN32 -D_CONSOLE -D_MBCS -Xcompiler /EHsc,/W3,/nologo,/Wp64,/O2,/Zi,/MT -I"$(CUDA_INC_PATH)" -o $(ConfigurationName)/$(InputName).obj $(InputFileName)
    • Debug 模式:"$(CUDA_BIN_PATH)/nvcc.exe" -ccbin "$(VCInstallDir)bin" -c -D_DEBUG -DWIN32 -D_CONSOLE -D_MBCS -Xcompiler /EHsc,/W3,/nologo,/Wp64,/Od,/Zi,/RTC1,/MTd -I"$(CUDA_INC_PATH)" -o $(ConfigurationName)/$(InputName).obj $(InputFileName)
  4. 如果想要使用軟件仿真的模式,可以新增兩個額外的設定:
    • EmuRelease 模式:"$(CUDA_BIN_PATH)/nvcc.exe" -ccbin "$(VCInstallDir)bin" -deviceemu -c -DWIN32 -D_CONSOLE -D_MBCS -Xcompiler /EHsc,/W3,/nologo,/Wp64,/O2,/Zi,/MT -I"$(CUDA_INC_PATH)" -o $(ConfigurationName)/$(InputName).obj $(InputFileName)
    • EmuDebug 模式:"$(CUDA_BIN_PATH)/nvcc.exe" -ccbin "$(VCInstallDir)bin" -deviceemu -c -D_DEBUG -DWIN32 -D_CONSOLE -D_MBCS -Xcompiler /EHsc,/W3,/nologo,/Wp64,/Od,/Zi,/RTC1,/MTd -I"$(CUDA_INC_PATH)" -o $(ConfigurationName)/$(InputName).obj $(InputFileName)
  5. 對所有的配置文件,在 Custom Build StepOutputs 中加入 $(ConfigurationName)/$(InputName).obj。
  6. 選擇 project,右鍵單擊選擇 Properties,再點選 Linker。對所有的配置文件修改以下設定:
    • General/Enable Incremental Linking:No
    • General/Additional Library Directories:$(CUDA_LIB_PATH)
    • Input/Additional Dependencies:cudart.lib

這樣應該就可以直接在 Visual Studio 的 IDE 中,編輯 CUDA 程序後,直接 build 以及執行程序了。

“CUDA 是 NVIDIA 的 GPGPU 模型,它使用 C 語言爲基礎,可以直接以大多數人熟悉的 C 語言,寫出在顯示芯片上執行的程序,而不需要去學習特定的顯示芯片的指令或是特殊的結構。”
第一個CUDA程序

CUDA 目前有兩種不同的 API:Runtime API 和 Driver API,兩種 API 各有其適用的範圍。由於 runtime API 較容易使用,一開始我們會以 runetime API 爲主。

CUDA 的初始化

首先,先建立一個檔案 first_cuda.cu。如果是使用 Visual Studio 的話,則請先按照這裏的設定方式設定 project。

要使用 runtime API 的時候,需要 include cuda_runtime.h。所以,在程序的最前面,加上

#include <stdio.h>
#include <cuda_runtime.h>

接下來是一個 InitCUDA 函式,會呼叫 runtime API 中,有關初始化 CUDA 的功能:

bool InitCUDA()
{
    int count;

    cudaGetDeviceCount(&count);
    if(count == 0) {
        fprintf(stderr, "There is no device./n");
        return false;
    }

    int i;
    for(i = 0; i < count; i++) {
        cudaDeviceProp prop;
        if(cudaGetDeviceProperties(&prop, i) == cudaSuccess) {
            if(prop.major >= 1) {
                break;
            }
        }
    }

    if(i == count) {
        fprintf(stderr, "There is no device supporting CUDA 1.x./n");
        return false;
    }

    cudaSetDevice(i);

    return true;
}

這個函式會先呼叫 cudaGetDeviceCount 函式,取得支持 CUDA 的裝置的數目。如果系統上沒有支持 CUDA 的裝置,則它會傳回 1,而 device 0 會是一個仿真的裝置,但不支持 CUDA 1.0 以上的功能。所以,要確定系統上是否有支持 CUDA 的裝置,需要對每個 device 呼叫 cudaGetDeviceProperties 函式,取得裝置的各項數據,並判斷裝置支持的 CUDA 版本(prop.major 和 prop.minor 分別代表裝置支持的版本號碼,例如 1.0 則 prop.major 爲 1 而 prop.minor 爲 0)。

透過 cudaGetDeviceProperties 函式可以取得許多數據,除了裝置支持的 CUDA 版本之外,還有裝置的名稱、內存的大小、最大的 thread 數目、執行單元的頻率等等。詳情可參考 NVIDIA 的 CUDA Programming Guide。

在找到支持 CUDA 1.0 以上的裝置之後,就可以呼叫 cudaSetDevice 函式,把它設爲目前要使用的裝置。

最後是 main 函式。在 main 函式中我們直接呼叫剛纔的 InitCUDA 函式,並顯示適當的訊息:

int main()
{
    if(!InitCUDA()) {
        return 0;
    }

    printf("CUDA initialized./n");

    return 0;
}

這樣就可以利用 nvcc 來 compile 這個程序了。使用 Visual Studio 的話,若按照先前的設定方式,可以直接 Build Project 並執行。

nvcc 是 CUDA 的 compile 工具,它會將 .cu 檔拆解出在 GPU 上執行的部份,及在 host 上執行的部份,並呼叫適當的程序進行 compile 動作。在 GPU 執行的部份會透過 NVIDIA 提供的 compiler 編譯成中介碼,而 host 執行的部份則會透過系統上的 C++ compiler 編譯(在 Windows 上使用 Visual C++ 而在 Linux 上使用 gcc)。

編譯後的程序,執行時如果系統上有支持 CUDA 的裝置,應該會顯示 CUDA initialized. 的訊息,否則會顯示相關的錯誤訊息。

利用 CUDA 進行運算


到目前爲止,我們的程序並沒有做什麼有用的工作。所以,現在我們加入一個簡單的動作,就是把一大堆數字,計算出它的平方和。

首先,把程序最前面的 include 部份改成:

#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>

#define DATA_SIZE 1048576

int data[DATA_SIZE];

並加入一個新函式 GenerateNumbers

void GenerateNumbers(int *number, int size)
{
    for(int i = 0; i < size; i++) {
        number[i] = rand() % 10;
    }
}

這個函式會產生一大堆 0 ~ 9 之間的隨機數。

要利用 CUDA 進行計算之前,要先把數據複製到顯卡內存中,才能讓顯示芯片使用。因此,需要取得一塊適當大小的顯卡內存,再把產生好的數據複製進去。在 main 函式中加入:

    GenerateNumbers(data, DATA_SIZE);

    int* gpudata, *result;
    cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
    cudaMalloc((void**) &result, sizeof(int));
    cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
        cudaMemcpyHostToDevice);

上面這段程序會先呼叫 GenerateNumbers 產生隨機數,並呼叫 cudaMalloc 取得一塊顯卡內存(result 則是用來存取計算結果,在稍後會用到),並透過 cudaMemcpy 將產生的隨機數複製到顯卡內存中。cudaMalloc 和 cudaMemcpy 的用法和一般的 malloc 及 memcpy 類似,不過 cudaMemcpy 則多出一個參數,指示覆制內存的方向。在這裏因爲是從主內存複製到顯卡內存,所以使用 cudaMemcpyHostToDevice。如果是從顯卡內存到主內存,則使用 cudaMemcpyDeviceToHost。這在之後會用到。

接下來是要寫在顯示芯片上執行的程序。在 CUDA 中,在函式前面加上 __global__ 表示這個函式是要在顯示芯片上執行的。因此,加入以下的函式:

__global__ static void sumOfSquares(int *num, int* result)
{
    int sum = 0;
    int i;
    for(i = 0; i < DATA_SIZE; i++) {
        sum += num[i] * num[i];
    }

    *result = sum;
}

在顯示芯片上執行的程序有一些限制,例如它不能有傳回值。其它的限制會在之後提到。

接下來是要讓 CUDA 執行這個函式。在 CUDA 中,要執行一個函式,使用以下的語法:

    函式名稱<<<block 數目, thread 數目, shared memory 大小>>>(參數...);

呼叫完後,還要把結果從顯示芯片複製回主內存上。在 main 函式中加入以下的程序:

    sumOfSquares<<<1, 1, 0>>>(gpudata, result);

    int sum;
    cudaMemcpy(&sum, result, sizeof(int), cudaMemcpyDeviceToHost);
    cudaFree(gpudata);
    cudaFree(result);

    printf("sum: %d/n", sum);

因爲這個程序只使用一個 thread,所以 block 數目、thread 數目都是 1。我們也沒有使用到任何 shared memory,所以設爲 0。編譯後執行,應該可以看到執行的結果。

爲了確定執行的結果正確,我們可以加上一段以 CPU 執行的程序代碼,來驗證結果:

    sum = 0;
    for(int i = 0; i < DATA_SIZE; i++) {
        sum += data[i] * data[i];
    }
    printf("sum (CPU): %d/n", sum);

編譯後執行,確認兩個結果相同。

計算運行時間
 

CUDA 提供了一個 clock 函式,可以取得目前的 timestamp,很適合用來判斷一段程序執行所花費的時間(單位爲 GPU 執行單元的頻率)。這對程序的優化也相當有用。要在我們的程序中記錄時間,把 sumOfSquares 函式改成:

 

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)
{
    int sum = 0;
    int i;
    clock_t start = clock();
    for(i = 0; i < DATA_SIZE; i++) {
        sum += num[i] * num[i];
    }

    *result = sum;
    *time = clock() - start;
}

main 函式中間部份改成:

    int* gpudata, *result;
    clock_t* time;
    cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
    cudaMalloc((void**) &result, sizeof(int));
    cudaMalloc((void**) &time, sizeof(clock_t));
    cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
        cudaMemcpyHostToDevice);

    sumOfSquares<<<1, 1, 0>>>(gpudata, result, time);

    int sum;
    clock_t time_used;
    cudaMemcpy(&sum, result, sizeof(int), cudaMemcpyDeviceToHost);
    cudaMemcpy(&time_used, time, sizeof(clock_t),
        cudaMemcpyDeviceToHost);
    cudaFree(gpudata);
    cudaFree(result);

    printf("sum: %d time: %d/n", sum, time_used);

編譯後執行,就可以看到執行所花費的時間了。

如果計算實際運行時間的話,可能會注意到它的執行效率並不好。這是因爲我們的程序並沒有利用到 CUDA 的主要的優勢,即並行化執行。在下一段文章中,會討論如何進行優化的動作。

改良第一個 CUDA程序

上一篇文章中,我們做了一個計算一大堆數字的平方和的程序。不過,我們也提到這個程序的執行效率並不理想。當然,實際上來說,如果只是要做計算平方和的動作,用 CPU 做會比用 GPU 快得多。這是因爲平方和的計算並不需要太多運算能力,所以幾乎都是被內存帶寬所限制。因此,光是把數據複製到顯卡內存上的這個動作,所需要的時間,可能已經和直接在 CPU 上進行計算差不多了。

不過,如果進行平方和的計算,只是一個更復雜的計算過程的一部份的話,那麼當然在 GPU 上計算還是有它的好處的。而且,如果數據已經在顯卡內存上(例如在 GPU 上透過某種算法產生),那麼,使用 GPU 進行這樣的運算,還是會比較快的。

剛纔也提到了,由於這個計算的主要瓶頸是內存帶寬,所以,理論上顯卡的內存帶寬是相當大的。這裏我們就來看看,倒底我們的第一個程序,能利用到多少內存帶寬。

程序的並行化

我們的第一個程序,並沒有利用到任何並行化的功能。整個程序只有一個 thread。在 GeForce 8800GT 上面,在 GPU 上執行的部份(稱爲 "kernel")大約花費 640M 個頻率。GeForce 8800GT 的執行單元的頻率是 1.5GHz,因此這表示它花費了約 0.43 秒的時間。1M 個 32 bits 數字的數據量是 4MB,因此,這個程序實際上使用的內存帶寬,只有 9.3MB/s 左右!這是非常糟糕的表現。

爲什麼會有這樣差的表現呢?這是因爲 GPU 的架構特性所造成的。在 CUDA 中,一般的數據複製到的顯卡內存的部份,稱爲 global memory。這些內存是沒有 cache 的,而且,存取 global memory 所需要的時間(即 latency)是非常長的,通常是數百個 cycles。由於我們的程序只有一個 thread,所以每次它讀取 global memory 的內容,就要等到實際讀取到數據、累加到 sum 之後,才能進行下一步。這就是爲什麼它的表現會這麼的差。

由於 global memory 並沒有 cache,所以要避開巨大的 latency 的方法,就是要利用大量的 threads。假設現在有大量的 threads 在同時執行,那麼當一個 thread 讀取內存,開始等待結果的時候,GPU 就可以立刻切換到下一個 thread,並讀取下一個內存位置。因此,理想上當 thread 的數目夠多的時候,就可以完全把 global memory 的巨大 latency 隱藏起來了。

要怎麼把計算平方和的程序並行化呢?最簡單的方法,似乎就是把數字分成若干組,把各組數字分別計算平方和後,最後再把每組的和加總起來就可以了。一開始,我們可以把最後加總的動作,由 CPU 來進行。

首先,在 first_cuda.cu 中,在 #define DATA_SIZE 的後面增加一個 #define,設定 thread 的數目:

#define DATA_SIZE    1048576
#define THREAD_NUM   256

接着,把 kernel 程序改成:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)
{
    const int tid = threadIdx.x;
    const int size = DATA_SIZE / THREAD_NUM;
    int sum = 0;
    int i;
    clock_t start;
    if(tid == 0) start = clock();
    for(i = tid * size; i < (tid + 1) * size; i++) {
       sum += num[i] * num[i];
    }

    result[tid] = sum;
    if(tid == 0) *time = clock() - start;
}

程序裏的 threadIdx 是 CUDA 的一個內建的變量,表示目前的 thread 是第幾個 thread(由 0 開始計算)。以我們的例子來說,會有 256 個 threads,所以同時會有 256 個 sumOfSquares 函式在執行,但每一個的 threadIdx.x 則分別會是 0 ~ 255。利用這個變量,我們就可以讓每一份函式執行時,對整個數據不同的部份計算平方和。另外,我們也讓計算時間的動作,只在 thread 0(即 threadIdx.x = 0 的時候)進行。

同樣的,由於會有 256 個計算結果,所以原來存放 result 的內存位置也要擴大。把 main 函式中的中間部份改成:

    int* gpudata, *result;
    clock_t* time;
    cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
    cudaMalloc((void**) &result, sizeof(int) * THREAD_NUM);
    cudaMalloc((void**) &time, sizeof(clock_t));
    cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
        cudaMemcpyHostToDevice);


    sumOfSquares<<<1, THREAD_NUM, 0>>>(gpudata, result, time);

    int sum[THREAD_NUM];
    clock_t time_used;
    cudaMemcpy(&sum, result, sizeof(int) * THREAD_NUM,
        cudaMemcpyDeviceToHost);

    cudaMemcpy(&time_used, time, sizeof(clock_t),
        cudaMemcpyDeviceToHost);

    cudaFree(gpudata);
    cudaFree(result);
    cudaFree(time);

可以注意到我們在呼叫 sumOfSquares 函式時,指定 THREAD_NUM 爲 thread 的數目。最後,在 CPU 端把計算好的各組數據的平方和進行加總:

    int final_sum = 0;
    for(int i = 0; i < THREAD_NUM; i++) {
        final_sum += sum[i];
    }

    printf("sum: %d  time: %d/n", final_sum, time_used);

    final_sum = 0;
    for(int i = 0; i < DATA_SIZE; i++) {
        sum += data[i] * data[i];
    }
    printf("sum (CPU): %d/n", final_sum);

編譯後執行,確認結果和原來相同。

這個版本的程序,在 GeForce 8800GT 上執行,只需要約 8.3M cycles,比前一版程序快了 77 倍!這就是透過大量 thread 來隱藏 latency 所帶來的效果。

不過,如果計算一下它使用的內存帶寬,就會發現其實仍不是很理想,大約只有 723MB/s 而已。這和 GeForce 8800GT 所具有的內存帶寬是很大的差距。爲什麼會這樣呢?

內存的存取模式

顯卡上的內存是 DRAM,因此最有效率的存取方式,是以連續的方式存取。前面的程序,雖然看起來是連續存取內存位置(每個 thread 對一塊連續的數字計算平方和),但是我們要考慮到實際上 thread 的執行方式。前面提過,當一個 thread 在等待內存的數據時,GPU 會切換到下一個 thread。也就是說,實際上執行的順序是類似

    thread 0 -> thread 1 -> thread 2 -> ...

因此,在同一個 thread 中連續存取內存,在實際執行時反而不是連續了。要讓實際執行結果是連續的存取,我們應該要讓 thread 0 讀取第一個數字,thread 1 讀取第二個數字…依此類推。所以,我們可以把 kernel 程序改成如下:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)
{
    const int tid = threadIdx.x;
    int sum = 0;
    int i;
    clock_t start;
    if(tid == 0) start = clock();
    for(i = tid; i < DATA_SIZE; i += THREAD_NUM) {
       sum += num[i] * num[i];
    }

    result[tid] = sum;
    if(tid == 0) *time = clock() - start;
}

編譯後執行,確認結果相同。

僅僅是這樣簡單的修改,實際執行的效率就有很大的差別。在 GeForce 8800GT 上,上面的程序執行需要的頻率是 2.6M cycles,又比前一版程序快了三倍。不過,這樣仍只有 2.3GB/s 的帶寬而已。

這是因爲我們使用的 thread 數目還是不夠多的原因。理論上 256 個 threads 最多隻能隱藏 256 cycles 的 latency。但是 GPU 存取 global memory 時的 latency 可能高達 500 cycles 以上。如果增加 thread 數目,就可以看到更好的效率。例如,可以把 THREAD_NUM 改成 512。在 GeForce 8800GT 上,這可以讓執行花費的時間減少到 1.95M cycles。有些改進,但是仍不夠大。不幸的是,目前 GeForce 8800GT 一個 block 最多只能有 512 個 threads,所以不能再增加了,而且,如果 thread 數目增加太多,那麼在 CPU 端要做的最後加總工作也會變多。

更多的並行化

前面提到了 block。在之前介紹呼叫 CUDA 函式時,也有提到 "block 數目" 這個參數。到目前爲止,我們都只使用一個 block。究竟 block 是什麼呢?

在 CUDA 中,thread 是可以分組的,也就是 block。一個 block 中的 thread,具有一個共享的 shared memory,也可以進行同步工作。不同 block 之間的 thread 則不行。在我們的程序中,其實不太需要進行 thread 的同步動作,因此我們可以使用多個 block 來進一步增加 thread 的數目。

首先,在 #define DATA_SIZE 的地方,改成如下:

#define DATA_SIZE   1048576
#define BLOCK_NUM   32
#define THREAD_NUM   256

這表示我們會建立 32 個 blocks,每個 blocks 有 256 個 threads,總共有 32*256 = 8192 個 threads。

接着,我們把 kernel 部份改成:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)
{
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    int sum = 0;
    int i;
    if(tid == 0) time[bid] = clock();
    for(i = bid * THREAD_NUM + tid; i < DATA_SIZE;
        i += BLOCK_NUM * THREAD_NUM) {
       sum += num[i] * num[i];
    }

    result[bid * THREAD_NUM + tid] = sum;
    if(tid == 0) time[bid + BLOCK_NUM] = clock();
}

blockIdx.xthreadIdx.x 一樣是 CUDA 內建的變量,它表示的是目前的 block 編號。另外,注意到我們把計算時間的方式改成每個 block 都會記錄開始時間及結束時間。

main 函式部份,修改成:

    int* gpudata, *result;
    clock_t* time;
    cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
    cudaMalloc((void**) &result,
        sizeof(int) * THREAD_NUM * BLOCK_NUM);
    cudaMalloc((void**) &time, sizeof(clock_t) * BLOCK_NUM * 2);
    cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
        cudaMemcpyHostToDevice);

    sumOfSquares<<<BLOCK_NUM, THREAD_NUM, 0>>>(gpudata, result,
        time);

    int sum[THREAD_NUM * BLOCK_NUM];
    clock_t time_used[BLOCK_NUM * 2];
    cudaMemcpy(&sum, result, sizeof(int) * THREAD_NUM * BLOCK_NUM,
        cudaMemcpyDeviceToHost);
    cudaMemcpy(&time_used, time, sizeof(clock_t) * BLOCK_NUM * 2,
        cudaMemcpyDeviceToHost);
    cudaFree(gpudata);
    cudaFree(result);
    cudaFree(time);

    int final_sum = 0;
    for(int i = 0; i < THREAD_NUM * BLOCK_NUM; i++) {
        final_sum += sum[i];
    }

    clock_t min_start, max_end;
    min_start = time_used[0];
    max_end = time_used[BLOCK_NUM];
    for(int i = 1; i < BLOCK_NUM; i++) {
        if(min_start > time_used[i])
            min_start = time_used[i];
        if(max_end < time_used[i + BLOCK_NUM])
            max_end = time_used[i + BLOCK_NUM];
    }

    printf("sum: %d  time: %d/n", final_sum, max_end - min_start);

基本上我們只是把 result 的大小變大,並修改計算時間的方式,把每個 block 最早的開始時間,和最晚的結束時間相減,取得總運行時間。

這個版本的程序,執行的時間減少很多,在 GeForce 8800GT 上只需要約 150K cycles,相當於 40GB/s 左右的帶寬。不過,它在 CPU 上執行的部份,需要的時間加長了(因爲 CPU 現在需要加總 8192 個數字)。爲了避免這個問題,我們可以讓每個 block 把自己的每個 thread 的計算結果進行加總。

Thread 的同步

前面提過,一個 block 內的 thread 可以有共享的內存,也可以進行同步。我們可以利用這一點,讓每個 block 內的所有 thread 把自己計算的結果加總起來。把 kernel 改成如下:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)

{
    extern __shared__ int shared[];
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    int i;
    if(tid == 0) time[bid] = clock();
    shared[tid] = 0;
    for(i = bid * THREAD_NUM + tid; i < DATA_SIZE;
        i += BLOCK_NUM * THREAD_NUM) {

       shared[tid] += num[i] * num[i];
    }

    __syncthreads();
    if(tid == 0) {
        for(i = 1; i < THREAD_NUM; i++) {
            shared[0] += shared[i];
        }
        result[bid] = shared[0];
    }

    if(tid == 0) time[bid + BLOCK_NUM] = clock();
}

利用 __shared__ 聲明的變量表示這是 shared memory,是一個 block 中每個 thread 都共享的內存。它會使用在 GPU 上的內存,所以存取的速度相當快,不需要擔心 latency 的問題。

__syncthreads() 是一個 CUDA 的內部函數,表示 block 中所有的 thread 都要同步到這個點,才能繼續執行。在我們的例子中,由於之後要把所有 thread 計算的結果進行加總,所以我們需要確定每個 thread 都已經把結果寫到 shared[tid] 裏面了。

接下來,把 main 函式的一部份改成:

    int* gpudata, *result;
    clock_t* time;
    cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
    cudaMalloc((void**) &result, sizeof(int) * BLOCK_NUM);
    cudaMalloc((void**) &time, sizeof(clock_t) * BLOCK_NUM * 2);
    cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
        cudaMemcpyHostToDevice);


    sumOfSquares<<<BLOCK_NUM, THREAD_NUM,
        THREAD_NUM * sizeof(int)>>>(gpudata, result, time);


    int sum[BLOCK_NUM];
    clock_t time_used[BLOCK_NUM * 2];
    cudaMemcpy(&sum, result, sizeof(int) * BLOCK_NUM,
        cudaMemcpyDeviceToHost);

    cudaMemcpy(&time_used, time, sizeof(clock_t) * BLOCK_NUM * 2,
        cudaMemcpyDeviceToHost);

    cudaFree(gpudata);
    cudaFree(result);
    cudaFree(time);

    int final_sum = 0;
    for(int i = 0; i < BLOCK_NUM; i++) {
        final_sum += sum[i];
    }

可以注意到,現在 CPU 只需要加總 BLOCK_NUM 也就是 32 個數字就可以了。

不過,這個程序由於在 GPU 上多做了一些動作,所以它的效率會比較差一些。在 GeForce 8800GT 上,它需要約 164K cycles。

當然,效率會變差的一個原因是,在這一版的程序中,最後加總的工作,只由每個 block 的 thread 0 來進行,但這並不是最有效率的方法。理論上,把 256 個數字加總的動作,是可以並行化的。最常見的方法,是透過樹狀的加法:

把 kernel 改成如下:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)

{
    extern __shared__ int shared[];
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    int i;
    int offset = 1, mask = 1;
    if(tid == 0) time[bid] = clock();
    shared[tid] = 0;
    for(i = bid * THREAD_NUM + tid; i < DATA_SIZE;
        i += BLOCK_NUM * THREAD_NUM) {

       shared[tid] += num[i] * num[i];
    }

    __syncthreads();
    while(offset < THREAD_NUM) {
        if((tid & mask) == 0) {
            shared[tid] += shared[tid + offset];
        }
        offset += offset;
        mask = offset + mask;
        __syncthreads();
    }

    if(tid == 0) {
        result[bid] = shared[0];   
        time[bid + BLOCK_NUM] = clock();

    }
}

後面的 while 循環就是進行樹狀加法。main 函式則不需要修改。

這一版的程序,在 GeForce 8800GT 上執行需要的時間,大約是 140K cycles(相當於約 43GB/s),比完全不在 GPU 上進行加總的版本還快!這是因爲,在完全不在 GPU 上進行加總的版本,寫入到 global memory 的數據數量很大(8192 個數字),也對效率會有影響。所以,這一版程序不但在 CPU 上的運算需求降低,在 GPU 上也能跑的更快。

進一步改善

上一個版本的樹狀加法是一般的寫法,但是它在 GPU 上執行的時候,會有 share memory 的 bank conflict 的問題(詳情在後面介紹 GPU 架構時會提到)。採用下面的方法,可以避免這個問題:

    offset = THREAD_NUM / 2;
    while(offset > 0) {
        if(tid < offset) {
            shared[tid] += shared[tid + offset];
        }
        offset >>= 1;
        __syncthreads();
    }

這樣同時也省去了 mask 變數。因此,這個版本的執行的效率就可以再提高一些。在 GeForce 8800GT 上,這個版本執行的時間是約 137K cycles。當然,這時差別已經很小了。如果還要再提高效率,可以把樹狀加法整個展開:

    if(tid < 128) { shared[tid] += shared[tid + 128]; }
    __syncthreads();
    if(tid < 64) { shared[tid] += shared[tid + 64]; }
    __syncthreads();
    if(tid < 32) { shared[tid] += shared[tid + 32]; }
    __syncthreads();
    if(tid < 16) { shared[tid] += shared[tid + 16]; }
    __syncthreads();
    if(tid < 8) { shared[tid] += shared[tid + 8]; }
    __syncthreads();
    if(tid < 4) { shared[tid] += shared[tid + 4]; }
    __syncthreads();
    if(tid < 2) { shared[tid] += shared[tid + 2]; }
    __syncthreads();
    if(tid < 1) { shared[tid] += shared[tid + 1]; }
    __syncthreads();

當然這隻適用於 THREAD_NUM 是 256 的情形。這樣可以再省下約 1000 cycles 左右(約 44GB/s)。最後完整的程序文件可以從這裏下載

第二個 CUDA程序

前面介紹的計算平方和的程序,似乎沒有什麼實用價值。所以我們的第二個 CUDA 程序,要做一個確實有(某些)實用價值的程序,也就是進行矩陣乘法。而且,這次我們會使用浮點數。

雖然矩陣乘法有點老套,不過因爲它相當簡單,而且也可以用來介紹一些有關 CUDA 的有趣性質。

矩陣乘法

爲了單純起見,我們這裏以方形的矩陣爲例子。基本上,假設有兩個矩陣 A 和 B,則計算 AB = C 的方法如下:

    for(i = 0; i < n; i++) {
        for(j = 0; j < n; j++) {
            C[i][j] = 0;
            for(k = 0; k < n; k++) {
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }

一開始,我們先準備好產生數據、設定 CUDA 等等的工作:

    int main()
    {
        float *a, *b, *c, *d;
        int n = 1000;

        if(!InitCUDA()) return 0;

        a = (float*) malloc(sizeof(float) * n * n);
        b = (float*) malloc(sizeof(float) * n * n);
        c = (float*) malloc(sizeof(float) * n * n);
        d = (float*) malloc(sizeof(float) * n * n);

        srand(0);

        matgen(a, n, n);
        matgen(b, n, n);

        clock_t time = matmultCUDA(a, n, b, n, c, n, n);

        matmult(a, n, b, n, d, n, n);
        compare_mat(c, n, d, n, n);

        double sec = (double) time / CLOCKS_PER_SEC;
        printf("Time used: %.2f (%.2lf GFLOPS)/n", sec,
            2.0 * n * n * n / (sec * 1E9));

        return 0;
    }

InitCUDA 函式和第一個 CUDA 程序一樣,可以直接參考前面的文章。以下是上面用到的一些其它的函式:

產生矩陣:

    void matgen(float* a, int lda, int n)
    {
        int i, j;

        for(i = 0; i < n; i++) {
            for(j = 0; j < n; j++) {
                a[i * lda + j] = (float) rand() / RAND_MAX +
                    (float) rand() / (RAND_MAX * RAND_MAX);
            }
        }
    }

這個函式只是利用隨機數生成器把矩陣填滿 0 ~ 1 之間的數字。特別注意到因爲 C 語言中無法聲明變動大小的二維矩陣,所以我們使用 i * lda + j 的方式。

進行矩陣乘法:

    void matmult(const float* a, int lda, const float* b, int ldb,
        float* c, int ldc, int n)
    {
        int i, j, k;

        for(i = 0; i < n; i++) {
            for(j = 0; j < n; j++) {
                double t = 0;
                for(k = 0; k < n; k++) {
                    t += a[i * lda + k] * b[k * ldb + j];
                }
                c[i * ldc + j] = t;
            }
        }
    }

這是以 CPU 進行矩陣乘法、用來進行驗證答案正確與否的程序。特別注意到它用 double 來儲存暫時的計算結果,以提高精確度。

驗證結果:

    void compare_mat(const float* a, int lda,
        const float* b, int ldb, int n)

    {
        float max_err = 0;
        float average_err = 0;
        int i, j;

        for(i = 0; i < n; i++) {
            for(j = 0; j < n; j++) {
                if(b[i * ldb + j] != 0) {
                    float err = fabs((a[i * lda + j] -
                        b[i * ldb + j]) / b[i * ldb + j]);

                    if(max_err < err) max_err = err;
                    average_err += err;
                }
            }
        }

        printf("Max error: %g Average error: %g/n",
            max_err, average_err / (n * n));

    }

這個函式計算兩個矩陣的最大相對誤差和平均相對誤差,並把結果印出來。

最後是 CUDA 的矩陣乘法的部份:

    #define NUM_THREADS 256

    clock_t matmultCUDA(const float* a, int lda,
        const float* b, int ldb, float* c, int ldc, int n)
    {
        float *ac, *bc, *cc;
        clock_t start, end;

        start = clock();
        cudaMalloc((void**) &ac, sizeof(float) * n * n);
        cudaMalloc((void**) &bc, sizeof(float) * n * n);
        cudaMalloc((void**) &cc, sizeof(float) * n * n);

        cudaMemcpy2D(ac, sizeof(float) * n, a, sizeof(float) * lda,
            sizeof(float) * n, n, cudaMemcpyHostToDevice);
        cudaMemcpy2D(bc, sizeof(float) * n, b, sizeof(float) * ldb,
            sizeof(float) * n, n, cudaMemcpyHostToDevice);

        int blocks = (n + NUM_THREADS - 1) / NUM_THREADS;
        matMultCUDA<<<blocks * n, NUM_THREADS>>>
            (ac, n, bc, n, cc, n, n);

        cudaMemcpy2D(c, sizeof(float) * ldc, cc, sizeof(float) * n,
        sizeof(float) * n, n, cudaMemcpyDeviceToHost);

        cudaFree(ac);
        cudaFree(bc);
        cudaFree(cc);

        end = clock();

        return end - start;
    }

這個函式相當單純,就是在顯卡內存中配置存放矩陣的內存,然後把主內存中的矩陣數據複製到顯卡內存上。不過,因爲我們的矩陣乘法函式可以指定 pitch(即 lda、ldb、和 ldc),所以如果用一般的 cudaMemcpy 函式來複制內存的話,會需要每個 row 都分開復制,那會需要呼叫很多次 cudaMemcpy 函式,會使效率變得很差。因此,在這裏我們用了一個新的 cudaMemcpy2D 函式,它是用來複制二維數組,可以指定數組的 pitch。這樣就可以透過一次函數調用就可以了。

進行計算的 kernel 如下:

    __global__ static void matMultCUDA(const float* a, size_t lda,
        const float* b, size_t ldb, float* c, size_t ldc, int n)
    {
        const int tid = threadIdx.x;
        const int bid = blockIdx.x;

        const int idx = bid * blockDim.x + tid;
        const int row = idx / n;
        const int column = idx % n;
        int i;

        if(row < n && column < n) {
            float t = 0;
            for(i = 0; i < n; i++) {
                t += a[row * lda + i] * b[i * ldb + column];
            }
            c[row * ldc + column] = t;
        }
    }

這個函式一開始先從 bid 和 tid 計算出這個 thread 應該計算的 row 和 column,在判斷 row 和 column 在範圍內之後,就直接進行計算,並把結果寫到 c 矩陣中,是非常單純的函式。

在 GeForce 8800GT 上實際執行的結果如下:

    Max error: 2.01484e-006 Average error: 3.36637e-007
    Time used: 1.1560 (1.73 GFLOPS)

可以看到兩個問題:

1.     很明顯的,執行效率相當低落。

2.     

最大相對誤差偏高。理想上應該要低於 1e-6。

計算結果的誤差偏高的原因是,在 CPU 上進行計算時,我們使用 double(即 64 bits 浮點數)來累進計算過程,而在 GPU 上則只能用 float(32 bits 浮點數)。在累加大量數字的時候,由於累加結果很快會變大,因此後面的數字很容易被捨去過多的位數。

由於 CUDA 的浮點數運算,在進行加、減、乘法時是符合 IEEE 754 規定的精確度的,因此,我們可以利用 Kahan's Summation Formula 來提高精確度。把程序改成:

    if(row < n && column < n) {
        float t = 0;
        float y = 0;
        for(i = 0; i < n; i++) {
            float r;
            y -= a[row * lda + i] * b[i * ldb + column];
            r = t - y;
            y = (r - t) + y;
            t = r;
        }
    }

修改後的程序的執行結果是:

    Max error: 1.19209e-007 Average error: 4.22751e-008
    Time used: 1.1560 (1.73 GFLOPS)

可以看到相對誤差有很大的改善,效率則沒什麼變化。

由於 Kahan's Summation Formula 需要的運算量提高,但是效率卻沒有什麼改變,可以看出這個 kernel 主要的瓶頸應該是在內存的存取動作上。這是因爲有大量的內存讀取是重複的。例如,矩陣 a 的一個 row 在每次進行計算時都被重複讀入,但這是相當浪費的。這樣的計算方式,總共需要讀取 2*n3 次內存。如果讓一個 row 只需要讀入一次的話,就可以減到爲 n3+n2 次。

第一個改良

 

和我們的第一個 CUDA 程序一樣,我們可以利用 shared memory 來儲存每個 row 的數據。不過,因爲只有同一個 block 的 threads 可以共享 shared memory,因此現在一個 row 只能由同一個 block 的 threads 來進行計算。另外我們也需要能存放一整個 row 的 shared memory。因此,把先把呼叫 kernel 的部份改成:

        matMultCUDA<<<n, NUM_THREADS, sizeof(float) * n>>>
            (ac, n, bc, n, cc, n, n);

kernel 的部份則改成:

    __global__ static void matMultCUDA(const float* a, size_t lda,
        const float* b, size_t ldb, float* c, size_t ldc, int n)
    {
        extern __shared__ float data[];
        const int tid = threadIdx.x;
        const int row = blockIdx.x;
        int i, j;

        for(i = tid; i < n; i += blockDim.x) {
            data[i] = a[row * lda + i];
        }

        __syncthreads();

        for(j = tid; j < n; j += blockDim.x) {
            float t = 0;
            float y = 0;
            for(i = 0; i < n; i++) {
                float r;
                y -= data[i] * b[i * ldb + j];
                r = t - y;
                y = (r - t) + y;
                t = r;
            }
            c[row * ldc + j] = t;
        }
    }


第一個部份先把整個 row 讀到 shared memory 中,而第二個部份則進行計算,並沒有太大的變化。主要的差別是現在一個 row 只由一個 block 進行計算。

在 GeForce 8800GT 上,執行的結果是:

    Max error: 1.19209e-007  Average error: 4.22751e-008
    Time used: 0.4220   (4.74 GFLOPS)

很明顯的,計算的結果並沒有改變,不過速度則提高了超過一倍。雖然如此,但是這樣的效率仍不盡理想,因爲理論上 GeForce 8800GT 有超過 300GFLOPS 的運算性能。即使是把 Kahan's Summation Formula 所需要的額外運算考慮進去,這樣的效率仍然連理論最大值的十分之一都不到。

會有這樣的結果,原因其實還是同樣的:對內存的存取次數太多了。雖然現在 A 矩陣的 row 的數據已經不再需要重複讀取,但是 B 矩陣的 column 的數據仍然一直被重複讀取。

另一個問題比較不是那麼明顯:對 B 矩陣的讀取,雖然看起來不連續,但實際上它是連續的。這是因爲不同的 thread 會讀取不同的 column,因此同時間每個 thread 讀取的各個 column 加起來,就是一個連續的內存區塊。那麼,爲什麼效率還是不佳呢?這是因爲,GPU 上的內存控制器,從某個固定的倍數地址開始讀取,纔會有最高的效率(例如 16 bytes 的倍數)。由於矩陣大小並不是 16 的倍數(這裏使用的是 1000x1000 的矩陣),所以造成效率不佳的情形。

要解決這個問題,我們可以在 cudaMalloc 的時候稍微修改一下,讓寬度變成 適當的倍數就可以了。但是,適當的倍數是多少呢?幸運的是,我們並不需要知道這些細節。CUDA 提供了一個 cudaMallocPitch 的函式,可以自動以最佳的倍數來配置內存。因此,我們可以把 cudaMalloc 的部份改成:

    size_t pitch_a, pitch_b, pitch_c;
    cudaMallocPitch((void**) &ac, &pitch_a, sizeof(float) * n, n);
    cudaMallocPitch((void**) &bc, &pitch_b, sizeof(float) * n, n);
    cudaMallocPitch((void**) &cc, &pitch_c, sizeof(float) * n, n);

cudaMallocPitch 函式會以適當的倍數配置內存,並把配置的寬度傳回。因此,在把矩陣複製到顯卡內存上時,要使用它傳回的寬度:

    cudaMemcpy2D(ac, pitch_a, a, sizeof(float) * lda,
        sizeof(float) * n, n, cudaMemcpyHostToDevice);
    cudaMemcpy2D(bc, pitch_b, b, sizeof(float) * ldb,
        sizeof(float) * n, n, cudaMemcpyHostToDevice);

呼叫 kernel 的部份也需要修改:

    matMultCUDA<<<n, NUM_THREADS, sizeof(float) * n>>>
        (ac, pitch_a / sizeof(float), bc, pitch_b / sizeof(float),
        cc, pitch_c / sizeof(float), n);

同樣的,把計算結果複製回到主內存時,也要使用傳回的寬度值:

    cudaMemcpy2D(c, sizeof(float) * ldc, cc, pitch_c,
        sizeof(float) * n, n, cudaMemcpyDeviceToHost);

這樣就完成了。Kernel 部份則不需要修改。

這樣的修改有多大的效果呢?在 GeForce 8800GT 上執行,結果如下:

    Max error: 1.19209e-007  Average error: 4.22751e-008
    Time used: 0.1250   (16.00 GFLOPS)

可以看到,執行速度又再大幅提高了三倍多!而這只是把內存的配置方式稍微修改一下而已。

雖然執行速度提高了很多,但是,和前面提到的理論值相比,其實還是有相當的差距。這是因爲,前面也提到過,這樣的做法需要 n3+n2 次的內存讀取,和 n2 次的內存寫入動作。由於 n = 1000,每個數字的大小是 32 bits,所以總共的內存存取數據量約爲 4GB。除以實際執行的時間 0.125 秒,得到的帶寬數值是約 32GB/s,這已經接近 GeForce 8800GT 顯卡內存的帶寬了。由於我們計算時間的時候,把配置內存、以及數據的複製動作也計算進去,因此實際上花費在 kernel 的時間是更短的(約 0.09 秒)。因此,可以很明顯的看出,這個程序的效率,是受限於內存帶寬的。

進一步的改良

上一節的結論顯示出,矩陣乘法的程序,效率是受限於內存帶寬的。那有沒有辦法降低內存的存取次數呢?答案當然是有的,不然就不會有這一節了 :)

要進一步降低內存帶寬的使用,可以注意到,在上一節的方法中,雖然 A 矩陣的存取次數被減至最低,但是 B 矩陣的存取次數並沒有減少。這是因爲我們只將 A 矩陣的 row 加載到 shared memory 中,但是 B 矩陣的 column 也是有被重複使用的。理想上應該也可以避免重複加載纔對。不過,由於 B 矩陣的 column 使用的時機,和 A 矩陣的 row 是不同的,所以並不能直接這樣做。

解決方法是 "blocking"。也就是把整個矩陣乘法的動作,切割成很多小矩陣的乘法。例如,要計算 C 矩陣的 (0, 0) ~ (15, 15) 的值,可以把它想成:

    A(0~15, 0~15) * B(0~15, 0~15) + A(0~15,16~31) * B(16~31, 0~15)
    + A(0~15, 32~47) * B(32~47, 0~15) + ...

這樣一來,我們就可以把兩個小矩陣加載到 shared memory,則小矩陣本身的乘法就不需要再存取任何外部的內存了!這樣一來,假設小矩陣的大小是 k,則實際上需要的內存存取次數就會變成約 2k2(n/k)3 = 2n3/k。

由於目前 CUDA 每個 block 的 thread 數目最多是 512,因此 k = 16 似乎是一個相當理想的數字(共 256 個 threads)。因此,對於一個 n = 1000 的矩陣來說,我們可以把內存存取的量減少到約 500MB,也就是上一節的存取量的 1/8。理論上,這樣應該可以讓效率提高八倍(假設沒有遇到別的瓶頸)。

爲了方便進行區塊的計算,我們讓每個 block 有 16x16 個 threads,再建立 (n/16)x(n/16) 個 blocks。把呼叫 kernel 的地方改成:

    int bx = (n + BLOCK_SIZE - 1) / BLOCK_SIZE;
    dim3 blocks(bx, bx);
    dim3 threads(BLOCK_SIZE, BLOCK_SIZE);
    matMultCUDA<<<blocks, threads>>>(ac, pitch_a / sizeof(float),
        bc, pitch_b / sizeof(float), cc, pitch_c / sizeof(float), n);

BLOCK_SIZE 則是定義成 16。dim3 是 CUDA 的一種數據型態,表示一個 3D 的向量。在這裏,我們透過 dim3 來建立 16x16 個 threads 的 block,和 (n/16)x(n/16) 個 blocks。

Kernel 程序的部份,則改成:

    __global__ static void matMultCUDA(const float* a, size_t lda,
        const float* b, size_t ldb, float* c, size_t ldc, int n)
    {
        __shared__ float matA[BLOCK_SIZE][BLOCK_SIZE];
        __shared__ float matB[BLOCK_SIZE][BLOCK_SIZE];
        const int tidc = threadIdx.x;
        const int tidr = threadIdx.y;
        const int bidc = blockIdx.x * BLOCK_SIZE;
        const int bidr = blockIdx.y * BLOCK_SIZE;
        int i, j;

        float results = 0;
        float comp = 0;

        for(j = 0; j < n; j += BLOCK_SIZE) {
            if(tidr + bidr < n && tidc + j < n) {
                matA[tidr][tidc] = a[(tidr + bidr) * lda + tidc + j];
            }
            else {
                matA[tidr][tidc] = 0;
            }

            if(tidr + j < n && tidc + bidc < n) {
                matB[tidr][tidc] = b[(tidr + j) * ldb + tidc + bidc];
            }
            else {
                matB[tidr][tidc] = 0;
            }

            __syncthreads();

            for(i = 0; i < BLOCK_SIZE; i++) {
                float t;
                comp -= matA[tidr][i] * matB[i][tidc];
                t = results - comp;
                comp = (t - results) + comp;
                results = t;
            }

            __syncthreads();
        }

        if(tidr + bidr < n && tidc + bidc < n) {
            c[(tidr + bidr) * ldc + tidc + bidc] = results;
        }
    }

注意到因爲我們現在使用 16x16 的 threads,因此 threadIdx 變量可以取得 threadIdx.xthreadIdx.y,範圍分別是 0 ~ 15。blockIdx.xblockIdx.y 變量也是同樣的情形,範圍分別是 0 ~ n/16。

在程序中,因爲矩陣的大小不一定會是 16 的倍數,因此需要使用 if 判斷式檢查是否超出矩陣範圍。

這個版本在 GeForce 8800GT 上的執行結果如下:

    Max error: 1.19209e-007  Average error: 4.22751e-008
    Time used: 0.0780   (25.64 GFLOPS)

速度雖然提高了,但是似乎並沒有達到預期中的八倍。當然,前面提到過,我們在計算時間時,把一些複製內存、配置內存的動作也計算在內,這些動作的時間並不會縮短。實際上 kernel 的運行時間,大約是 0.053 秒左右(約略相當於 38GFLOPS),比上一節的版本快了將近一倍。

如果這一版程序已經不再限於內存帶寬,那爲什麼沒有達到預期的效率呢?這是因爲這一版程序已經是限於指令週期了。除了使用 Kahan's Summation Formula 會需要更多的運算之外,程序中也有大量計算矩陣地址的乘法等等,這都會需要花費運算資源。另外,那些用來判斷超出矩陣範圍的 if 判斷式,也會有一定的影響。

要把那些 if 判斷式去掉,有一個方法是,在配置內存時,就配置成 16 的倍數,並在複製矩陣到顯卡內存之前,先將它清爲 0。如下所示:

    int newn = ((n + BLOCK_SIZE - 1) / BLOCK_SIZE) * BLOCK_SIZE;

    cudaMallocPitch((void**) &ac, &pitch_a,
        sizeof(float) * newn, newn);
    cudaMallocPitch((void**) &bc, &pitch_b,
        sizeof(float) * newn, newn);
    cudaMallocPitch((void**) &cc, &pitch_c,
        sizeof(float) * newn, newn);

    cudaMemset(ac, 0, pitch_a * newn);
    cudaMemset(bc, 0, pitch_b * newn);

 這樣一來,我們就可以把 kernel 中的 if 判斷式都移除了:

    __global__ static void matMultCUDA(const float* a, size_t lda,
        const float* b, size_t ldb, float* c, size_t ldc, int n)
    {
        __shared__ float matA[BLOCK_SIZE][BLOCK_SIZE];
        __shared__ float matB[BLOCK_SIZE][BLOCK_SIZE];
        const int tidc = threadIdx.x;
        const int tidr = threadIdx.y;
        const int bidc = blockIdx.x * BLOCK_SIZE;
        const int bidr = blockIdx.y * BLOCK_SIZE;
        int i, j;

        float results = 0;
        float comp = 0;

        for(j = 0; j < n; j += BLOCK_SIZE) {
            matA[tidr][tidc] = a[(tidr + bidr) * lda + tidc + j];
            matB[tidr][tidc] = b[(tidr + j) * ldb + tidc + bidc];

            __syncthreads();

            for(i = 0; i < BLOCK_SIZE; i++) {
                float t;
                comp -= matA[tidr][i] * matB[i][tidc];
                t = results - comp;
                comp = (t - results) + comp;
                results = t;
            }

            __syncthreads();
        }

        c[(tidr + bidr) * ldc + tidc + bidc] = results;
    }

這個版本的執行結果是:

    Max error: 1.19209e-007  Average error: 4.22751e-008
    Time used: 0.0780   (25.64 GFLOPS)

似乎沒有改善。不過,實際上 kernel 的運行時間已經減少到 0.042 秒(約略相當於 48GFLOPS)。

結論

有些讀者可能會想,如果把 block 再變得更大(例如 32x32)是否會有幫助呢?當然,由於最後的程序已經不再是受限於內存帶寬(在 0.042 秒內存取 500MB 的數據約相當於 12GB/s 的帶寬),所以把 block 再加大並不會有幫助了。而且,由於一個 block 內的 thread 數目最多隻能到 512 個,將 block 變大也會造成很多額外負擔。而且 shared memory 的大小也有限制(GeForce 8800GT 的 shared memory 大小限制是 16384 bytes),所以也不能任意增加 block 的大小。

最後一版程序的完整檔案可以從這裏下載。

 

 

GPU 的硬件架構

這裏我們會簡單介紹,NVIDIA 目前支持 CUDA 的 GPU,其在執行 CUDA 程序的部份(基本上就是其 shader 單元)的架構。這裏的數據是綜合 NVIDIA 所公佈的信息,以及 NVIDIA 在各個研討會、學校課程等所提供的數據,因此有可能會有不正確的地方。主要的數據源包括 NVIDIA 的 CUDA Programming Guide 1.1、NVIDIA 在 Supercomputing '07 介紹 CUDA 的 session,以及 UIUC 的 CUDA 課程。

GPU 的基本介紹

目前 NVIDIA 推出的顯示芯片,支持 CUDA 的是 G80 系列的顯示芯片。其中 G80 顯示芯片支持 CUDA 1.0 版,而 G84、G86、G92、G94、G96 則支援 CUDA 1.1 版。基本上,除了最早的 GeForce 8800 Ultra/GTX 及 320MB/640MB 版本的 GeForce 8800GTS、Tesla 等顯卡是 CUDA 1.0 版之外,其它 GeForce 8 系列及 9 系列顯卡都支持 CUDA 1.1。詳細情形可以參考 CUDA Programming Guide 1.1 的 Appendix A。

所有目前支持 CUDA 的 NVIDIA 顯示芯片,其 shader 部份都是由多個 multiprocessors 組成。每個 multiprocessor 裏包含了八個 stream processors,其組成是四個四個一組,也就是說實際上可以看成是有兩組 4D 的 SIMD 處理器。此外,每個 multiprocessor 還具有 8192 個寄存器,16KB 的 share memory,以及 texture cache 和 constant cache。大致上如下圖所示:

詳細的 multiprocessor 信息,都可以透過 CUDA 的 cudaGetDeviceProperties() 函式或 cuDeviceGetProperties() 函式取得。不過,目前還沒有辦法直接取得一個顯示芯片中有多少 multiprocessor 的信息。

在 CUDA 中,大部份基本的運算動作,都可以由 stream processor 進行。每個 stream processor 都包含一個 FMA(fused-multiply-add)單元,可以進行一個乘法和一個加法。比較複雜的運算則會需要比較長的時間。

執行過程

在執行 CUDA 程序的時候,每個 stream processor 就是對應一個 thread。每個 multiprocessor 則對應一個 block。從之前的文章中,可以注意到一個 block 經常有很多個 thread(例如 256 個),遠超過一個 multiprocessor 所有的 stream processor 數目。這又是怎麼回事呢?

實際上,雖然一個 multiprocessor 只有八個 stream processor,但是由於 stream processor 進行各種運算都有 latency,更不用提內存存取的 latency,因此 CUDA 在執行程序的時候,是以 warp 爲單位。目前的 CUDA 裝置,一個 warp 裏面有 32 個 threads,分成兩組 16 threads 的 half-warp。由於 stream processor 的運算至少有 4 cycles 的 latency,因此對一個 4D 的 stream processors 來說,一次至少執行 16 個 threads(即 half-warp)纔能有效隱藏各種運算的 latency。

由於 multiprocessor 中並沒有太多別的內存,因此每個 thread 的狀態都是直接保存在 multiprocessor 的寄存器中。所以,如果一個 multiprocessor 同時有愈多的 thread 要執行,就會需要愈多的寄存器空間。例如,假設一個 block 裏面有 256 個 threads,每個 thread 用到 20 個寄存器,那麼總共就需要 256x20 = 5,120 個寄存器才能保存每個 thread 的狀態。

目前 CUDA 裝置中每個 multiprocessor 有 8,192 個寄存器,因此,如果每個 thread 使用到 16 個寄存器,那就表示一個 multiprocessor 同時最多隻能維持 512 個 thread 的執行。如果同時進行的 thread 數目超過這個數字,那麼就會需要把一部份的數據儲存在顯卡內存中,就會降低執行的效率了。

編者注:在NVIDIA GT200中的Register File大小增加了一倍,在FP32下可用的register file爲16K,FP64下是8K。

Shared memory

目前 CUDA 裝置中,每個 multiprocessor 有 16KB 的 shared memory。Shared memory 分成 16 個 bank。如果同時每個 thread 是存取不同的 bank,就不會產生任何問題,存取 shared memory 的速度和存取寄存器相同。不過,如果同時有兩個(或更多個) threads 存取同一個 bank 的數據,就會發生 bank conflict,這些 threads 就必須照順序去存取,而無法同時存取 shared memory 了。

Shared memory 是以 4 bytes 爲單位分成 banks。因此,假設以下的數據:

    __shared__ int data[128];

那麼,data[0] 是 bank 0、data[1] 是 bank 1、data[2] 是 bank 2、…、data[15] 是 bank 15,而 data[16] 又回到 bank 0。由於 warp 在執行時是以 half-warp 的方式執行,因此分屬於不同的 half warp 的 threads,不會造成 bank conflict。

因此,如果程序在存取 shared memory 的時候,使用以下的方式:

    int number = data[base + tid];

那就不會有任何 bank conflict,可以達到最高的效率。但是,如果是以下的方式:

    int number = data[base + 4 * tid];

那麼,thread 0 和 thread 4 就會存取到同一個 bank,thread 1 和 thread 5 也是同樣,這樣就會造成 bank conflict。在這個例子中,一個 half warp 的 16 個 threads 會有四個 threads 存取同一個 bank,因此存取 share memory 的速度會變成原來的 1/4。

一個重要的例外是,當多個 thread 存取到同一個 shared memory 的地址時,shared memory 可以將這個地址的 32 bits 數據「廣播」到所有讀取的 threads,因此不會造成 bank conflict。例如:

    int number = data[3];

這樣不會造成 bank conflict,因爲所有的 thread 都讀取同一個地址的數據。

很多時候 shared memory 的 bank conflict 可以透過修改數據存放的方式來解決。例如,以下的程序:

    data[tid] = global_data[tid];
    ...
    int number = data[16 * tid];

會造成嚴重的 bank conflict,爲了避免這個問題,可以把數據的排列方式稍加修改,把存取方式改成:

    int row = tid / 16;
    int column = tid % 16;
    data[row * 17 + column] = global_data[tid];
    ...
    int number = data[17 * tid];

這樣就不會造成 bank conflict 了。

編者注:share memory在NVIDIA的文檔中其實還有不同的叫法,例如PDC(Parallel Data Cache)、PBSM(per-block share memory)。

Global memory

由於 multiprocessor 並沒有對 global memory 做 cache(如果每個 multiprocessor 都有自己的 global memory cache,將會需要 cache coherence protocol,會大幅增加 cache 的複雜度),所以 global memory 存取的 latency 非常的長。除此之外,前面的文章中也提到過 global memory 的存取,要儘可能的連續。這是因爲 DRAM 存取的特性所造成的結果。

更精確的說,global memory 的存取,需要是 "coalesced"。所謂的 coalesced,是表示除了連續之外,而且它開始的地址,必須是每個 thread 所存取的大小的 16 倍。例如,如果每個 thread 都讀取 32 bits 的數據,那麼第一個 thread 讀取的地址,必須是 16*4 = 64 bytes 的倍數。

如果有一部份的 thread 沒有讀取內存,並不會影響到其它的 thread 速行 coalesced 的存取。例如:

    if(tid != 3) {
        int number = data[tid];
    }

雖然 thread 3 並沒有讀取數據,但是由於其它的 thread 仍符合 coalesced 的條件(假設 data 的地址是 64 bytes 的倍數),這樣的內存讀取仍會符合 coalesced 的條件。

在目前的 CUDA 1.1 裝置中,每個 thread 一次讀取的內存數據量,可以是 32 bits、64 bits、或 128 bits。不過,32 bits 的效率是最好的。64 bits 的效率會稍差,而一次讀取 128 bits 的效率則比一次讀取 32 bits 要顯著來得低(但仍比 non-coalesced 的存取要好)。

如果每個 thread 一次存取的數據並不是 32 bits、64 bits、或 128 bits,那就無法符合 coalesced 的條件。例如,以下的程序:

    struct vec3d { float x, y, z; };
    ...
    __global__ void func(struct vec3d* data, float* output)
    {
        output[tid] = data[tid].x * data[tid].x +
            data[tid].y * data[tid].y +
            data[tid].z * data[tid].z;
    }

並不是 coalesced 的讀取,因爲 vec3d 的大小是 12 bytes,而非 4 bytes、8 bytes、或 16 bytes。要解決這個問題,可以使用 __align(n)__ 的指示,例如:

    struct __align__(16) vec3d { float x, y, z; };

這會讓 compiler 在 vec3d 後面加上一個空的 4 bytes,以補齊 16 bytes。另一個方法,是把數據結構轉換成三個連續的數組,例如:

    __global__ void func(float* x, float* y, float* z, float* output)
    {
        output[tid] = x[tid] * x[tid] + y[tid] * y[tid] +
            z[tid] * z[tid];
    }

如果因爲其它原因使數據結構無法這樣調整,也可以考慮利用 shared memory 在 GPU 上做結構的調整。例如:

    __global__ void func(struct vec3d* data, float* output)
    {
        __shared__ float temp[THREAD_NUM * 3];
        const float* fdata = (float*) data;
        temp[tid] = fdata[tid];
        temp[tid + THREAD_NUM] = fdata[tid + THREAD_NUM];
        temp[tid + THREAD_NUM*2] = fdata[tid + THREAD_NUM*2];
        __syncthreads();
        output[tid] = temp[tid*3] * temp[tid*3] +
            temp[tid*3+1] * temp[tid*3+1] +
            temp[tid*3+2] * temp[tid*3+2];
    }

在上面的例子中,我們先用連續的方式,把數據從 global memory 讀到 shared memory。由於 shared memory 不需要擔心存取順序(但要注意 bank conflict 問題,參照前一節),所以可以避開 non-coalesced 讀取的問題。

Texture

CUDA 支援 texture。在 CUDA 的 kernel 程序中,可以利用顯示芯片的 texture 單元,讀取 texture 的數據。使用 texture 和 global memory 最大的差別在於 texture 只能讀取,不能寫入,而且顯示芯片上有一定大小的 texture cache。因此,讀取 texture 的時候,不需要符合 coalesced 的規則,也可以達到不錯的效率。此外,讀取 texture 時,也可以利用顯示芯片中的 texture filtering 功能(例如 bilinear filtering),也可以快速轉換數據型態,例如可以直接將 32 bits RGBA 的數據轉換成四個 32 bits 浮點數。

顯示芯片上的 texture cache 是針對一般繪圖應用所設計,因此它仍最適合有區塊性質的存取動作,而非隨機的存取。因此,同一個 warp 中的各個 thread 最好是讀取地址相近的數據,才能達到最高的效率。

對於已經能符合 coalesced 規則的數據,使用 global memory 通常會比使用 texture 要來得快。

運算單元

Stream processor 裏的運算單元,基本上是一個浮點數的 fused multiply-add 單元,也就是說它可以進行一次乘法和一次加法,如下所示:

    a = b * c + d;

compiler 會自動把適當的加法和乘法運算,結合成一個 fmad 指令。

除了浮點數的加法及乘法之外,整數的加法、位運算、比較、取最小值、取最大值、及以型態的轉換(浮點數轉整數或整數轉浮點數)都是可以全速進行的。整數的乘法則無法全速進行,但 24 bits 的乘法則可以。在 CUDA 中可以利用內建的 __mul24 和 __umul24 函式來進行 24 bits 的整數乘法。

浮點數的除法是利用先取倒數,再相乘的方式計算,因此精確度並不能達到 IEEE 754 的規範(最大誤差爲 2 ulp)。內建的 __fdividef(x,y) 提供更快速的除法,和一般的除法有相同的精確度,但是在 2216 < y < 2218 時會得到錯誤的結果。

此外 CUDA 還提供了一些精確度較低的內部函數,包括 __expf、__logf、__sinf、__cosf、__powf 等等。這些函式的速度較快,但精確度不如標準的函式。詳細的數據可以參考 CUDA Programming Guide 1.1 的 Appendix B。

和主內存間的數據傳輸

在 CUDA 中,GPU 不能直接存取主內存,只能存取顯卡上的顯示內存。因此,會需要將數據從主內存先複製到顯卡內存中,進行運算後,再將結果從顯卡內存中複製到主內存中。這些複製的動作會限於 PCI Express 的速度。使用 PCI Express x16 時,PCI Express 1.0 可以提供雙向各 4GB/s 的帶寬,而 PCI Express 2.0 則可提供 8GB/s 的帶寬。當然這都是理論值。

從一般的內存複製數據到顯卡內存的時候,由於一般的內存可能隨時會被操作系統搬動,因此 CUDA 會先將數據複製到一塊內部的內存中,才能利用 DMA 將數據複製到顯卡內存中。如果想要避免這個重複的複製動作,可以使用 cudaMallocHost 函式,在主內存中取得一塊 page locked 的內存。不過,如果要求太大量的 page locked 的內存,將會影響到操作系統對內存的管理,可能會減低系統的效率。

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