序文
参考文献:
Gao Sheng のブログ
「CUDA C プログラミング権威ガイド」
および CUDA 公式ドキュメント
CUDA プログラミング: 基本と実践 Fan Zheyong
記事のすべてのコードは私の GitHub で入手でき、将来的にはゆっくりと更新される予定です
記事と解説動画を同時更新「AI知識物語」B局:ご飯三杯食べに行く
1: 共有メモリ
共有メモリはプログラマが直接操作できるキャッシュの一種で、
(1)カーネル関数におけるグローバルメモリへのアクセス回数を減らし、スレッドブロックの効率的な内部通信を実現する、
(2)という2つの主な機能を持っています。 1 つは、グローバル メモリ アクセスの結合を改善することです。
以下は、C++ で書かれたリダクション計算です
。配列内のすべての要素の合計を計算する必要がある場合、N 個の要素を持つ配列 x、
つまり sum = x[0] + x[1] + ... + x[ N-1]
#include<stdint.h>
#include<cuda.h>
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <stdio.h>
#include <math.h>
#include <stdio.h>
#define CHECK(call) \
do \
{
\
const cudaError_t error_code = call; \
if (error_code != cudaSuccess) \
{
\
printf("CUDA Error:\n"); \
printf(" File: %s\n", __FILE__); \
printf(" Line: %d\n", __LINE__); \
printf(" Error code: %d\n", error_code); \
printf(" Error text: %s\n", \
cudaGetErrorString(error_code)); \
exit(1); \
} \
} while (0)
#ifdef USE_DP
typedef double real;
#else
typedef float real;
#endif
const int NUM_REPEATS = 20;
void timing(const real* x, const int N);
real reduce(const real* x, const int N);
int main(void)
{
const int N = 100000000;
const int M = sizeof(real) * N;
real* x = (real*)malloc(M);
for (int n = 0; n < N; ++n)
{
x[n] = 1.23;
}
timing(x, N);
free(x);
return 0;
}
void timing(const real* x, const int N)
{
real sum = 0;
for (int repeat = 0; repeat < NUM_REPEATS; ++repeat)
{
cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start));
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));
cudaEventQuery(start);
sum = reduce(x, N);
CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
float elapsed_time;
CHECK(cudaEventElapsedTime(&elapsed_time, start, stop));
printf("Time = %g ms.\n", elapsed_time);
CHECK(cudaEventDestroy(start));
CHECK(cudaEventDestroy(stop));
}
printf("sum = %f.\n", sum);
}
real reduce(const real* x, const int N)
{
real sum = 0.0;
for (int n = 0; n < N; ++n)
{
sum += x[n];
}
return sum;
}
2: スレッド同期機構
マルチスレッド プログラムの場合、2 つの異なるスレッドでの命令の実行順序は、コードに示されている順序と異なる場合があります。
カーネル関数内のステートメントの実行順序が出現順序と一致していることを確認するには、何らかの同期メカニズムを使用する必要があります。CUDAでは__syncthreadsという同期関数が提供されています。この関数はカーネル関数でのみ使用でき、最も簡単な使用法はパラメータを指定しないことです:
__syncthreads();この関数は、前のステートメントに続くステートメントを実行する前に、スレッド ブロック内のすべてのスレッドが
ステートメントを完全に実行することを保証します。ただし、この機能は同一スレッドブロック内のスレッドのみを対象としたものであり、異なるスレッドブロック内のスレッドの実行順序はまだ不明です。
3: スレッド同期を使用して計算を削減する
配列要素の数が 2 の累乗であると仮定すると (この仮定は後で削除します)、配列の後半の各要素を前半の対応する配列要素に追加できます。このプロセスを繰り返すと、結果として得られる最初の配列要素は、元の配列内の要素の合計になります。これはいわゆるバイナリリダクション法である。
3.1グローバルメモリを条件としたリダクション計算
void __global__ reduce_global(real* d_x, real* d_y)
{
const int tid = threadIdx.x;
//定义指针X,右边表示 d_x 数组第 blockDim.x * blockIdx.x个元素的地址
//该情况下x 在不同线程块,指向全局内存不同的地址---》使用不同的线程块对dx数组不同部分分别进行处理
real* x = d_x + blockDim.x * blockIdx.x;
//blockDim.x >> 1 等价于 blockDim.x /2,核函数中,位操作比 对应的整数操作高效
for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
{
if (tid < offset)
{
x[tid] += x[tid + offset];
}
//同步语句,作用:同一个线程块内的线程按照代码先后执行指令(块内同步,块外不用同步)
__syncthreads();
}
if (tid == 0)
{
d_y[blockIdx.x] = x[0];
}
}
3.2静的共有メモリを条件とした削減計算
void __global__ reduce_shared(real* d_x, real* d_y)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int n = bid * blockDim.x + tid;
//定义了共享内存数组 s_y[128],注意关键词 __shared__
__shared__ real s_y[128];
s_y[tid] = (n < N) ? d_x[n] : 0.0;
__syncthreads();
//归约计算用共享内存变量替换了原来的全局内存变量。这里也要记住: 每个线程块都对其中的共享内存变量副本进行操作。在归约过程结束后,每一个线程
//块中的 s_y[0] 副本就保存了若干数组元素的和
for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
if (tid == 0)
{
d_y[bid] = s_y[0];
}
}
3.3動的共有メモリを条件とした削減計算
前のカーネル関数では、共有メモリ配列を定義するときに固定長 (128) を指定しました。このプログラムでは、この長さがカーネル関数の実行構成パラメーター block_size (つまり、カーネル関数の blockDim.x) と同じであると想定しています。共有メモリ変数を定義するときに誤って間違った長さの配列を書き込んだ場合、エラーが発生したり、カーネル関数のパフォーマンスが低下したりする可能性があります。
このエラーの可能性を減らす 1 つの方法は、動的共有メモリを使用することです。
- カーネル関数を呼び出す実行構成内の 3 番目のパラメーターを書き留めます。
<<<grid_size, block_size, sizeof(real) * block_size>>>
前面2个参数网格大小和线程块大小,
第三个参数就是核函数中每个线程块需要 定义的动态共享内存的字节数
- 動的共有メモリを使用するには、カーネル関数内の共有メモリ変数の宣言も変更する必要があります。
extern __shared__ real s_y[];这是动态声明
__shared__ real s_y[128]; 这是静态声明
削減計算プログラムコード
#include<stdint.h>
#include<cuda.h>
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <stdio.h>
#include <math.h>
#include <stdio.h>
#define CHECK(call) \
do \
{
\
const cudaError_t error_code = call; \
if (error_code != cudaSuccess) \
{
\
printf("CUDA Error:\n"); \
printf(" File: %s\n", __FILE__); \
printf(" Line: %d\n", __LINE__); \
printf(" Error code: %d\n", error_code); \
printf(" Error text: %s\n", \
cudaGetErrorString(error_code)); \
exit(1); \
} \
} while (0)
#ifdef USE_DP
typedef double real;
#else
typedef float real;
#endif
const int NUM_REPEATS = 100;
const int N = 100000000;
const int M = sizeof(real) * N;
const int BLOCK_SIZE = 128;
void timing(real* h_x, real* d_x, const int method);
int main(void)
{
real* h_x = (real*)malloc(M);
for (int n = 0; n < N; ++n)
{
h_x[n] = 1.23;
}
real* d_x;
CHECK(cudaMalloc(&d_x, M));
printf("\nUsing global memory only:\n");
timing(h_x, d_x, 0);
printf("\nUsing static shared memory:\n");
timing(h_x, d_x, 1);
printf("\nUsing dynamic shared memory:\n");
timing(h_x, d_x, 2);
free(h_x);
CHECK(cudaFree(d_x));
return 0;
}
void __global__ reduce_global(real* d_x, real* d_y)
{
const int tid = threadIdx.x;
//定义指针X,右边表示 d_x 数组第 blockDim.x * blockIdx.x个元素的地址
//该情况下x 在不同线程块,指向全局内存不同的地址---》使用不同的线程块对dx数组不同部分分别进行处理
real* x = d_x + blockDim.x * blockIdx.x;
//blockDim.x >> 1 等价于 blockDim.x /2,核函数中,位操作比 对应的整数操作高效
for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
{
if (tid < offset)
{
x[tid] += x[tid + offset];
}
//同步语句,作用:同一个线程块内的线程按照代码先后执行指令(块内同步,块外不用同步)
__syncthreads();
}
if (tid == 0)
{
d_y[blockIdx.x] = x[0];
}
}
void __global__ reduce_shared(real* d_x, real* d_y)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int n = bid * blockDim.x + tid;
//定义了共享内存数组 s_y[128],注意关键词 __shared__
__shared__ real s_y[128];
//将全局内存中的数据复制到共享内存中
//共享内存的特 征:每个线程块都有一个共享内存变量的副本
s_y[tid] = (n < N) ? d_x[n] : 0.0;
//调用函数 __syncthreads 进行线程块内的同步
__syncthreads();
//归约计算用共享内存变量替换了原来的全局内存变量。这里也要记住: 每个线程块都对其中的共享内存变量副本进行操作。在归约过程结束后,每一个线程
//块中的 s_y[0] 副本就保存了若干数组元素的和
for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
if (tid == 0)
{
d_y[bid] = s_y[0];
}
}
void __global__ reduce_dynamic(real* d_x, real* d_y)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int n = bid * blockDim.x + tid;
//声明 动态共享内存 s_y[] 限定词 extern,不能指定数组大小
extern __shared__ real s_y[];
s_y[tid] = (n < N) ? d_x[n] : 0.0;
__syncthreads();
for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
if (tid == 0)
{
//将每一个线程块中归约的结果从共享内存 s_y[0] 复制到全局内 存d_y[bid]
d_y[bid] = s_y[0];
}
}
real reduce(real* d_x, const int method)
{
int grid_size = (N + BLOCK_SIZE - 1) / BLOCK_SIZE;
const int ymem = sizeof(real) * grid_size;
const int smem = sizeof(real) * BLOCK_SIZE;
real* d_y;
CHECK(cudaMalloc(&d_y, ymem));
real* h_y = (real*)malloc(ymem);
switch (method)
{
case 0:
reduce_global << <grid_size, BLOCK_SIZE >> > (d_x, d_y);
break;
case 1:
reduce_shared << <grid_size, BLOCK_SIZE >> > (d_x, d_y);
break;
case 2:
reduce_dynamic << <grid_size, BLOCK_SIZE, smem >> > (d_x, d_y);
break;
default:
printf("Error: wrong method\n");
exit(1);
break;
}
CHECK(cudaMemcpy(h_y, d_y, ymem, cudaMemcpyDeviceToHost));
real result = 0.0;
for (int n = 0; n < grid_size; ++n)
{
result += h_y[n];
}
free(h_y);
CHECK(cudaFree(d_y));
return result;
}
void timing(real* h_x, real* d_x, const int method)
{
real sum = 0;
for (int repeat = 0; repeat < NUM_REPEATS; ++repeat)
{
CHECK(cudaMemcpy(d_x, h_x, M, cudaMemcpyHostToDevice));
cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start));
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));
cudaEventQuery(start);
sum = reduce(d_x, method);
CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
float elapsed_time;
CHECK(cudaEventElapsedTime(&elapsed_time, start, stop));
printf("Time = %g ms.\n", elapsed_time);
CHECK(cudaEventDestroy(start));
CHECK(cudaEventDestroy(stop));
}
printf("sum = %f.\n", sum);
}
結果の比較:
グローバル メモリには 25 ミリ秒かかり、計算結果は間違っています。1.23*10^8 であるはずです。小数点以下が多くあります。
静的共有メモリには 28 ミリ秒かかり、結果も間違っています。
動的共有メモリは
29 ミリ秒かかる
結論:
(1)グローバル メモリのアクセス速度はすべてのメモリの中で最も遅いため、その使用は最小限に抑える必要があります。すべてのデバイス メモリの中でレジスタが最も効率的ですが、スレッドの連携が必要な問題では、単一のスレッドにのみ表示されるレジスタを使用するだけでは十分ではありません。スレッド ブロック全体から見える共有メモリを使用する必要があります。
(2) 動的共有メモリを使用するカーネル関数と静的共有メモリを使用するカーネル関数の実行時間にはほとんど差がありません。したがって、動的共有メモリを使用してもプログラムのパフォーマンスには影響しませんが、場合によってはプログラムの保守性が向上することがあります。
(3) 共有メモリを使用してグローバル メモリへのアクセスを改善しても、必ずしもカーネル機能のパフォーマンスが向上するとは限りません。したがって、CUDA プログラムを最適化するときは、通常、さまざまな最適化スキームをテストして比較する必要があります
。
(4) 計算結果 SUM に誤差があります。これは、累積計算においていわゆる「大きな数が小さな数を食う」現象が発生するためです。単精度浮動小数点数には、正確な有効数字が 6 桁または 7 桁しかありません。上記の関数reduceでは、変数sumの値を3,000万以上に累積してから1.23に加算すると、その値は増加しなくなります(小さい数値は大きい数値によって「食べられ」ますが、大きい数値はバラエティではありません
)。
現在のソリューションの例: Kahan 総和アルゴリズム