# CUDA 的半精度浮點數運算
半精度浮點數,又稱為 float16,由於半精度浮點數的理論計算量比單精度浮點數少,所以理論上可以減少運算時間,但半精度浮點數需要硬體支援,所以一般的硬體無法使用。而較新的 Nvidia 的 GPU 則支援半精度浮點。
自 CUDA 7.5 後開始支援半精度浮點計算,它要求 GPU 的計算力 (compute capability) 至少大於等於 5.3,可自行到 [wiki](https://en.wikipedia.org/wiki/CUDA) 查詢自己 GPU 對應的計算力,或是直接在 CUDA 中通過 cudaDeviceProp 查詢。
```cpp
int dev_id = 0;
cudaDeviceProp dev_prop;
cudaGetDeviceProperties(&dev_prop, dev_id);
printf("The compute capability is %d.%d\n", dev_prop.major, dev_prop.minor);
```
</br>
一般而言在 CUDA 設備上使用半精度浮點數有三個步驟
1. 在 host 端將 float32 轉成 float16 並將資料推入 GPU
2. 對 float16 做運算,有兩種狀況
* 存在可以使用的 float16 硬體加速指令,使用指令做運算
* 不存在可以使用或不想使用 float16 硬體加速指令,先轉成 float32 做運算,最後轉成 float16 輸出
3. 將 float16 資料推入 host,並在 host 端將 float16 轉成 float32
</br>
## 轉換浮點數型態
CUDA 的半精度浮點數使用 [IEEE 754 表準](https://en.wikipedia.org/wiki/Half-precision_floating-point_format),格式如下。
<div id="fp16-format" align="center">
<br/>
<img src="https://i.imgur.com/EupG9Zc.png" width="384"/>
</div>
</br>
CUDA 內相互轉換 float16 和 float32 有以下三種方法
1. 在 kernel 內轉換
在 CUDA kernel 內轉換,好處是簡單且沒有版本問題,缺點是要多宣告更多 CUDA 的記憶體,且轉換只能在 GPU 上執行。
```cpp!
__global__ void float_to_half(half *out, float *in) {
int idx = threadIdx.x;
half v0 = (half)(in[idx]); // 直接轉換
half v1 = __float2half(in[idx]); // 用指令轉換
out[idx] = v0;
}
```
2. 在 host 端轉換
可以直接在你的 C/C++ 程式碼中使用 ```__float2half``` 指令,但要求 CUDA 版本至少大於等於 9.2。
3. 使用第三方庫在 host 端轉換
這是最保守的方法,不需要考慮版本問題,也不用浪費記憶體。你可以使用我撰寫的庫 [simple half](https://github.com/CGLemon/simple_half) 完成轉換工作,它可以滿足基本需求。
</br>
## float16 精度與溢位的問題
由於 float16 表示範圍有限,它的確有較嚴重的精度和溢位問題,導致算出的數值有可感知的誤差,但大部分出來的數值差距小於 1% ,如果你的任務對於精度有要求,則應當放棄 float16 ,反之你的任務對於誤差較不敏感,例如神經網路的計算,則極力推薦 float16,因為在算力足夠的 GPU 上加速效果明顯。
## 設計 kernel
理論而言,我們可以用模板設計一個 float16 和 float32 共用的 kernel,如此不需要新增任何工作量,就能直接使用 float16 運算,如下
```cpp!
template <typename T>
__global__ void add(T *C, T *A, T *B) {
int idx = threadIdx.x;
T a = A[idx];
T b = B[idx];
C[idx] = a + b;
}
```
但實際上,由於 float16 屬於硬體加速指令,需要硬體、程式庫和編譯器的配合(指 ```gencode``` 選項),在當前大環境中還是需要支援較就的硬體,編譯器無法同時對所有的硬體優化,因此 kernel 本身不能設計成純粹的 float16 計算 (模板還是需要同時編譯 float16 和 float32,因此會有成純粹的 float16 kernel),需要 float16 和 float32 混合使用,例如
```cpp!
template <typename T>
__global__ void add(T *C, T *A, T *B) {
int idx = threadIdx.x;
float a = (float)(A[idx]); // 永遠都轉換成 float32
float b = (float)(B[idx]); // 永遠都轉換成 float32
float c = a + b; // 永遠用 float32 作用算
C[idx] = (T)c; // 轉換成對應的型態
}
```
但如果你只針對自己需要的平台,例如只設計給 40 系列的,那當然可以使用使用版本一的 kernel
</br>
而 blas 本身不受編譯器影響,可以根據當前需要切換
```cpp!
if (fp16) {
cublasHgemm(...); // float16 的矩陣乘法
} else {
cublasSgemm(...); // float32 的矩陣乘法
}
```
</br>
## 實例
假設有 A 、B 和 C 三個矩陣,執行的任務為 A = A + B 且 O = A x C ,假設加法為簡單運算,大部分時間都消耗在乘法上,那加法就不太需要對 float16 實做專門優化的版本,因此 ```add_kernel``` 是 float16 和 float32 共用的,而乘法則須使用專門的 float16 指令 ```cublasHgemm```。
```cpp!
// 檔案名稱 main.cu
// 編譯 nvcc main.cu -o demo -lcublas
// 執行 ./demo
#include <stdio.h>
#include "half.h" // https://github.com/CGLemon/simple_half
#include <cublas_v2.h>
#include <cuda_fp16.h>
int div_up(int a, int b) {
return (a + b - 1) / b;
}
// float16 和 float32 通用的 kernel
template <typename T>
__global__ void add_kernel(T *C, T *A, T *B, int total_elements) {
int idx = threadIdx.x + blockDim.x * blockIdx.x;
if (idx < total_elements) {
float a = (float)(A[idx]);
float b = (float)(B[idx]);
float c = a + b;
C[idx] = (T)c;
}
}
// float16 和 float32 通用的函數
template <typename T>
void add(T *C, T *A, T *B, int total_elements) {
int block_size = 32;
int blocks = div_up(total_elements, block_size);
add_kernel<<<blocks, block_size>>>(C, A, B, total_elements);
}
// 宣告矩陣的大小
#define N (10)
#define SIZE (N*N)
void test_fp16(cublasHandle_t handle) {
// host 端的矩陣
half_float_t A[SIZE];
half_float_t B[SIZE];
half_float_t C[SIZE];
half_float_t O[SIZE];
for (int i = 0; i < SIZE; ++i) {
A[i] = GetFp16(1);
B[i] = GetFp16(2);
C[i] = GetFp16(3);
}
// device (GPU) 端的矩陣
half *A_gpu;
half *B_gpu;
half *C_gpu;
half *O_gpu;
size_t bufsize = SIZE * sizeof(half);
cudaMalloc(&A_gpu, bufsize);
cudaMalloc(&B_gpu, bufsize);
cudaMalloc(&C_gpu, bufsize);
cudaMalloc(&O_gpu, bufsize);
// host -> device
cudaMemcpy(A_gpu, A, bufsize, cudaMemcpyHostToDevice);
cudaMemcpy(B_gpu, B, bufsize, cudaMemcpyHostToDevice);
cudaMemcpy(C_gpu, C, bufsize, cudaMemcpyHostToDevice);
// 執行 A = A + B 之矩陣加法
add<half>(A_gpu, A_gpu, B_gpu, SIZE);
// 執行 O = A x C 之矩陣乘法
half_float_t alpha = GetFp16(1);
half_float_t beta = GetFp16(0);
cublasHgemm(handle,
CUBLAS_OP_N,
CUBLAS_OP_T,
N, N, N,
(half*)&alpha,
C_gpu, N,
A_gpu, N,
(half*)&beta,
O_gpu, N);
// device -> host
cudaMemcpy(O, O_gpu, bufsize, cudaMemcpyDeviceToHost);
// 輸出答案
for (int i = 0; i < SIZE; ++i) {
printf("%.2f ", GetFp32(O[i]));
}
printf("\n");
cudaFree(A_gpu);
cudaFree(B_gpu);
cudaFree(C_gpu);
cudaFree(O_gpu);
}
int main(void) {
cublasHandle_t handle;
cublasCreate(&handle);
test_fp16(handle);
cublasDestroy(handle);
return 0;
}
```