CUDA编程

CUDA 学习笔记1

入门

CUDA编程入门极简教程

  内容来自知乎文章,CUDA编程入门极简教程,本文是学习笔记。

线程唯一标识

image-20240916163123447

  一个线程需要两个内置的坐标变量(blockIdx,threadIdx)来唯一标识,它们都是dim3类型变量,其中blockIdx指明线程块在grid中的位置,而threaIdx指明线程所在block中的位置。

  这是没问题的,因为核函数只会调一个grid,所以不需要gridIdx,CUDA中也没有gridIdx。

CUDA内存模型

  每个线程有自己的私有本地内存(Local Memory),而每个线程块有包含共享内存(Shared Memory),可以被线程块中所有线程共享,其生命周期与线程块一致。此外,所有的线程都可以访问全局内存(Global Memory)。还可以访问一些只读内存块:常量内存(Constant Memory)和纹理内存(Texture Memory)。

image-20240916163918864

CUDA编程的逻辑层和物理层

image-20240916194401750

  GPU存在很多CUDA核心,充分利用CUDA核心可以充分发挥GPU的并行计算能力。GPU硬件的一个核心组件是SM,英文名是 Streaming Multiprocessor,翻译过来就是流式多处理器。SM的核心组件包括CUDA核心,共享内存,寄存器等,SM可以并发地执行数百个线程,并发能力就取决于SM所拥有的资源数。当一个kernel被执行时,它的gird中的线程块被分配到SM上,一个线程块只能在一个SM上被调度。SM一般可以调度多个线程块,这要看SM本身的能力。那么有可能一个kernel的各个线程块被分配多个SM,所以grid只是逻辑层,而SM才是执行的物理层。

  SM采用的是SIMT (Single-Instruction, Multiple-Thread,单指令多线程)架构,基本的执行单元是线程束(warps),线程束包含32个线程,这些线程同时执行相同的指令,但是每个线程都包含自己的指令地址计数器和寄存器状态,也有自己独立的执行路径。所以尽管线程束中的线程同时从同一程序地址执行,但是可能具有不同的行为,比如遇到了分支结构,一些线程可能进入这个分支,但是另外一些有可能不执行,它们只能死等,因为GPU规定线程束中所有线程在同一周期执行相同的指令,线程束分化会导致性能下降。

  当线程块被划分到某个SM上时,它将进一步划分为多个线程束,因为这才是SM的基本执行单元,但是一个SM同时并发的线程束数是有限的。这是因为资源限制,SM要为每个线程块分配共享内存,而也要为每个线程束中的线程分配独立的寄存器。所以SM的配置会影响其所支持的线程块和线程束并发数量。总之,就是网格和线程块只是逻辑划分,一个kernel的所有线程其实在物理层是不一定同时并发的。所以kernel的grid和block的配置不同,性能会出现差异,这点是要特别注意的。还有,由于SM的基本执行单元是包含32个线程的线程束,所以block大小一般要设置为32的倍数。

检查自己的GPU硬件配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int dev = 0;
cudaDeviceProp devProp;
cudaGetDeviceProperties(&devProp, dev);
std::cout << "使用GPU device " << dev << ": " << devProp.name << std::endl;
std::cout << "SM的数量:" << devProp.multiProcessorCount << std::endl;
std::cout << "每个线程块的共享内存大小:" << devProp.sharedMemPerBlock / 1024.0 << " KB" << std::endl;
std::cout << "每个线程块的最大线程数:" << devProp.maxThreadsPerBlock << std::endl;
std::cout << "每个SM的最大线程数:" << devProp.maxThreadsPerMultiProcessor << std::endl;
std::cout << "每个SM的最大线程束数:" << devProp.maxThreadsPerMultiProcessor / 32 << std::endl;

/*
使用GPU device 0: NVIDIA GeForce MX350
SM的数量:5
每个线程块的共享内存大小:48 KB
每个线程块的最大线程数:1024
每个SM的最大线程数:2048
每个SM的最大线程束数:64
*/

nvprof工具分析kernel运行情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ nvprof cuda2.exe
▒▒▒▒▒▒: 0
==23472== NVPROF is profiling process 23472, command: cuda2.exe
==23472== Profiling application: cuda2.exe
==23472== Profiling result:
Type Time(%) Time Calls Avg Min Max Name
GPU activities: 61.00% 3.1966ms 2 1.5983ms 1.5409ms 1.6557ms [CUDA memcpy HtoD]
27.48% 1.4400ms 1 1.4400ms 1.4400ms 1.4400ms [CUDA memcpy DtoH]
11.53% 604.06us 1 604.06us 604.06us 604.06us add(float*, float*, float*, int)
API calls: 80.95% 115.10ms 3 38.368ms 158.60us 114.77ms cudaMalloc
13.99% 19.900ms 1 19.900ms 19.900ms 19.900ms cuDevicePrimaryCtxRelease
4.24% 6.0248ms 3 2.0083ms 1.3885ms 2.9045ms cudaMemcpy
0.74% 1.0526ms 3 350.87us 312.10us 377.80us cudaFree
0.03% 48.900us 1 48.900us 48.900us 48.900us cudaLaunchKernel
0.03% 43.000us 1 43.000us 43.000us 43.000us cuModuleUnload
0.01% 16.500us 101 163ns 100ns 1.1000us cuDeviceGetAttribute
0.00% 4.7000us 3 1.5660us 200ns 3.6000us cuDeviceGetCount
0.00% 1.3000us 2 650ns 300ns 1.0000us cuDeviceGet
0.00% 800ns 1 800ns 800ns 800ns cuDeviceGetName
0.00% 600ns 1 600ns 600ns 600ns cuDeviceGetLuid
0.00% 400ns 1 400ns 400ns 400ns cuDeviceTotalMem
0.00% 200ns 1 200ns 200ns 200ns cuDeviceGetUuid

统一内存(Unified Memory)

  在CUDA 6.0引入统一内存(Unified Memory)来避免单独在host和device上进行内存分配并进行数据拷贝,简单来说就是统一内存使用一个托管内存来共同管理host和device中的内存,并且自动在host和device中进行数据传输。CUDA中使用cudaMallocManaged函数分配托管内存:

1
cudaError_t cudaMallocManaged(void **devPtr, size_t size, unsigned int flag=0);

  使用统一内存的代码更简洁了,值得注意的是kernel执行是与host异步的,由于托管内存自动进行数据传输,这里要用cudaDeviceSynchronize()函数保证device和host同步,这样后面才可以正确访问kernel计算的结果。

矩阵乘法

image-20240919103404820

image-20240919103917695

1
2
3
4
5
6
7
// 矩阵类型,行优先,M(row, col) = *(M.elements + row * M.width + col)
struct Matrix
{
int width;
int height;
float *elements;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 获取矩阵A的(row, col)元素
__device__ float getElement(Matrix *A, int row, int col)
{
return A->elements[row * A->width + col];
}

// 为矩阵A的(row, col)元素赋值
__device__ void setElement(Matrix *A, int row, int col, float value)
{
A->elements[row * A->width + col] = value;
}

// 矩阵相乘kernel,2-D,每个线程计算一个元素
__global__ void matMulKernel(Matrix *A, Matrix *B, Matrix *C)
{
float Cvalue = 0.0;
int row = threadIdx.y + blockIdx.y * blockDim.y;
int col = threadIdx.x + blockIdx.x * blockDim.x;
for (int i = 0; i < A->width; ++i)
{
Cvalue += getElement(A, row, i) * getElement(B, i, col);
}
setElement(C, row, col, Cvalue);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
int main()
{
int width = 1 << 10;
int height = 1 << 10;
Matrix *A, *B, *C;
// 申请托管内存
cudaMallocManaged((void**)&A, sizeof(Matrix));
cudaMallocManaged((void**)&B, sizeof(Matrix));
cudaMallocManaged((void**)&C, sizeof(Matrix));
int nBytes = width * height * sizeof(float);
cudaMallocManaged((void**)&A->elements, nBytes);
cudaMallocManaged((void**)&B->elements, nBytes);
cudaMallocManaged((void**)&C->elements, nBytes);

// 初始化数据
A->height = height;
A->width = width;
B->height = height;
B->width = width;
C->height = height;
C->width = width;
for (int i = 0; i < width * height; ++i)
{
A->elements[i] = 1.0;
B->elements[i] = 2.0;
}

// 定义kernel的执行配置
dim3 blockSize(32, 32);
dim3 gridSize((width + blockSize.x - 1) / blockSize.x,
(height + blockSize.y - 1) / blockSize.y);
// 执行kernel
matMulKernel << < gridSize, blockSize >> >(A, B, C);


// 同步device 保证结果能正确访问
cudaDeviceSynchronize();
// 检查执行结果
float maxError = 0.0;
for (int i = 0; i < width * height; ++i)
maxError = fmax(maxError, fabs(C->elements[i] - 2 * width));
std::cout << "最大误差: " << maxError << std::endl;

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ nvprof cuda2.exe
▒▒▒▒▒▒: 0
==10896== NVPROF is profiling process 10896, command: cuda2.exe
==10896== Profiling application: cuda2.exe
==10896== Profiling result:
Type Time(%) Time Calls Avg Min Max Name
GPU activities: 100.00% 564.00ms 1 564.00ms 564.00ms 564.00ms matMulKernel(Matrix*, Matrix*, Matrix*)
API calls: 72.06% 564.27ms 1 564.27ms 564.27ms 564.27ms cudaDeviceSynchronize
22.78% 178.37ms 6 29.729ms 29.200us 172.85ms cudaMallocManaged
3.72% 29.140ms 1 29.140ms 29.140ms 29.140ms cuDevicePrimaryCtxRelease
1.42% 11.137ms 1 11.137ms 11.137ms 11.137ms cudaLaunchKernel
0.01% 65.500us 1 65.500us 65.500us 65.500us cuModuleUnload
0.00% 26.000us 101 257ns 100ns 1.6000us cuDeviceGetAttribute
0.00% 3.9000us 3 1.3000us 300ns 3.1000us cuDeviceGetCount
0.00% 2.4000us 2 1.2000us 200ns 2.2000us cuDeviceGet
0.00% 700ns 1 700ns 700ns 700ns cuDeviceGetName
0.00% 500ns 1 500ns 500ns 500ns cuDeviceTotalMem
0.00% 400ns 1 400ns 400ns 400ns cuDeviceGetLuid
0.00% 300ns 1 300ns 300ns 300ns cuDeviceGetUuid

==10896== Unified Memory profiling result:
Device "NVIDIA GeForce MX350 (0)"
Count Avg Size Min Size Max Size Total Size Total Time Name
261 31.433KB 4.0000KB 32.000KB 8.011719MB 8.255700ms Host To Device
269 45.739KB 4.0000KB 1.0000MB 12.01563MB 78.50340ms Device To Host

从啥也不会到CUDA GEMM优化

  知乎文章:从啥也不会到CUDA GEMM优化

Naive GEMM

  a(M * K) * b(K * N) = c(M * N),用M*N个线程,每个线程算一个。

image-20241017232309470

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// row, col: 二维数组实际的行,列索引
// ld: 数组的列数
// a(M * K) * b(K * N) = c(M * N)
#define OFFSET(row, col, ld) ((row) * (ld) + (col))

__global__ void naiveSgemm(
float * __restrict__ a, float * __restrict__ b, float * __restrict__ c,
const int M, const int N, const int K){

// m = row 行号
int m = blockIdx.y * blockDim.y + threadIdx.y;
// n = col 列号
int n = blockIdx.x * blockDim.x + threadIdx.x;

if(m < M && n < N)
{
float psum = 0.0;
// 告知编译器自动展开循环体,这样可以减少循环控制的开销(循环次数小的时候可以这么做)
#pragma unroll

for(int k = 0; k < K; ++k)
{
// a[m][k], b[k][n]
// a[M][K], b[K][N]
psum += a[OFFSET(m, k, K)] * b[OFFSET(k, n, N)];
}

c[OFFSET(m, n, N)] = psum;
}
}

const int BM = 32, BN = 32;
const int M = 512, N = 512, K = 512;
dim3 blockDim(BN, BM);
dim3 gridDim((N + BN - 1) / BN, (M + BM - 1) / BM);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
#include <stdio.h>
#include <stdlib.h>
#include <float.h>
#include <cuda_runtime.h>

#define OFFSET(row, col, ld) ((row) * (ld) + (col))

// a(M * K) * b(K * N) = c(M * N)
// CPU 端的矩阵乘法
void cpuSgemm(float *a, float *b, float *c, const int M, const int N, const int K);

// GPU 端的
__global__ void naiveSgemm(
float * __restrict__ a, float * __restrict__ b, float * __restrict__ c,
const int M, const int N, const int K);

// 计算CPU和GPU端的误差
float testError(void);

// 用于测试不同规模的矩阵乘法时,GPU的性能
float testPerformance(
void (*gpuSgemm) (float *, float *, float *, const int, const int, const int),
dim3 gridDim, dim3 blockDim, const int M, const int N, const int K, const int repeat);

int main(void)
{
float max_error = testError();
printf("Max Error = %f\n", max_error);

printf("\nKernal = naiveSgemm\n");
const int TESTNUM = 15;
const int M_list[TESTNUM] = {128, 192, 256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096, 6144, 8192, 12288, 16384};
const int N_list[TESTNUM] = {128, 192, 256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096, 6144, 8192, 12288, 16384};
const int K_list[TESTNUM] = {1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024};

const int outer_repeat = 10, inner_repeat = 1;
const int BM = 32, BN = 32;
void (*gpuSgemm) (float *, float *, float *, const int, const int, const int) = naiveSgemm;

for(int i = 0; i < TESTNUM; ++i)
{
const int M = M_list[i], N = N_list[i], K = K_list[i];

dim3 blockDim(BN, BM);
dim3 gridDim((N + BN - 1) / BN, (M + BM - 1) / BM);

double max_sec = 0.0;
double min_sec = DBL_MAX;
double total_sec = 0.0;

for(int j = 0; j < outer_repeat; ++j)
{
double this_sec = testPerformance(gpuSgemm, gridDim, blockDim, M, N, K, inner_repeat);
max_sec = max(max_sec, this_sec);
min_sec = min(min_sec, this_sec);
total_sec += this_sec;
}

double avg_sec = total_sec / outer_repeat / inner_repeat;
double avg_Gflops = ((double)M) * N * K * 2.0 / 1024.0 / 1024.0 / 1024.0 / avg_sec;

printf("M N K = %6d %6d %6d, Time = %12.8lf %12.8lf %12.8lf s, AVG Performance = %10.4lf Glops\n", M, N, K, min_sec, avg_sec, max_sec, avg_Gflops);
}

return 0;

}

void cpuSgemm(float *a, float *b, float *c, const int M, const int N, const int K)
{
for(int i = 0; i < M; ++i)
{
for(int j = 0; j < N; ++j)
{
float psum = 0.0;
for(int k = 0; k < K; ++k) psum += a[OFFSET(i, k, K)] * b[OFFSET(k, j, N)];
c[OFFSET(i, j, N)] = psum;
}
}
}

__global__ void naiveSgemm(
float * __restrict__ a, float * __restrict__ b, float * __restrict__ c,
const int M, const int N, const int K){

int i = blockIdx.y * blockDim.y + threadIdx.y;
int j = blockIdx.x * blockDim.x + threadIdx.x;

if(i < M && j < N)
{
float psum = 0.0;
#pragma unroll
for(int k = 0; k < K; ++k) psum += a[OFFSET(i, k, K)] * b[OFFSET(k, j, N)];
c[OFFSET(i, j, N)] = psum;
}
}

float testError(void)
{
const int BM = 32, BN = 32;
const int M = 512, N = 512, K = 512;
dim3 blockDim(BN, BM);
dim3 gridDim((N + BN - 1) / BN, (M + BM - 1) / BM);

size_t size_a = M * K * sizeof(float);
size_t size_b = K * N * sizeof(float);
size_t size_c = M * N * sizeof(float);

float *h_a, *h_b, *h_c, *d_a, *d_b, *d_c, *h_d_c;
h_a = (float *)malloc(size_a);
h_b = (float *)malloc(size_b);
h_c = (float *)malloc(size_c);
cudaMalloc(&d_a, size_a);
cudaMalloc(&d_b, size_b);
cudaMalloc(&d_c, size_c);
h_d_c = (float *)malloc(size_c);

srand(time(0));
for(int i = 0; i < M * K; ++i) h_a[i] = rand() / float(RAND_MAX);
for(int i = 0; i < K * N; ++i) h_b[i] = rand() / float(RAND_MAX);
//cudaMemset(d_c, 15, size_c);

cpuSgemm(h_a, h_b, h_c, M, N, K);

cudaMemcpy(d_a, h_a, size_a, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, h_b, size_b, cudaMemcpyHostToDevice);
naiveSgemm<<<gridDim, blockDim>>>(d_a, d_b, d_c, M, N, K);
cudaMemcpy(h_d_c, d_c, size_c, cudaMemcpyDeviceToHost);

// 计算误差,注意判断非法值
float max_error = 0.0;
for(int i = 0; i < M * N; ++i)
{
float this_error = fabs(h_d_c[i] - h_c[i]);
if(max_error != max_error || this_error != this_error) max_error = -NAN;
else max_error = max(max_error, this_error);
}

free(h_a);
free(h_b);
free(h_c);
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);
free(h_d_c);

return max_error;
}

float testPerformance(
void (*gpuSgemm) (float *, float *, float *, const int, const int, const int),
dim3 gridDim, dim3 blockDim, const int M, const int N, const int K, const int repeat){

size_t size_a = M * K * sizeof(float);
size_t size_b = K * N * sizeof(float);
size_t size_c = M * N * sizeof(float);

float *d_a, *d_b, *d_c;
cudaMalloc(&d_a, size_a);
cudaMalloc(&d_b, size_b);
cudaMalloc(&d_c, size_c);

cudaEvent_t start, end;
cudaEventCreate(&start);
cudaEventCreate(&end);

cudaEventRecord(start);
for(int i = 0; i < repeat; ++i) gpuSgemm<<<gridDim, blockDim>>>(d_a, d_b, d_c, M, N, K);
cudaEventRecord(end);
cudaEventSynchronize(end);

// 计算strat到end之间的时间,默认是毫秒
float msec, sec;
cudaEventElapsedTime(&msec, start, end);
sec = msec / 1000.0 / repeat;

cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);

return sec;
}

image-20241016225304717

GEMM优化:矩阵分块,从global memory到SMEM

  SMEM:共享内存

  核心思路是利用on-chip(片上内存,eg 寄存器、Cache、SMEM)比off-chip内存的带宽大,降低数据读取时间。

image-20241026222601630

  访存次数:

image-20241026223944497

  还有一个问题,就是为什么沿k维度切:

image-20241026224114794

  是为了避免重复读数据。因为SMEM是跟线程块绑定的。对于C中那个绿色块,求当前神绿色的小块,需要加载深红色和深黄色的数据,而当求当前深绿色右边的同等大小的小块中的元素时,需要把当前深黄色的数据从SMEM移除,但是当求到当前绿色下面的那一小块的时候,发现又需要把当前深黄色的数据加载到SMEM中。