Matlab是学术界和业界公认的高级编程语言,其优点在于编程复杂度低,缺点在于for循环的效率极低。为了利用Matlab已有的算法,降低编程复杂度,同时解决for循环效率低的问题,Matlab引入了Mex机制,使得Matlab可以调用C++编写的函数。CUDA是Nvidia推出的并行计算框架,基本遵循了C++语法规则。于是乎出现了Matlab调用CUDA的方法,称之为“MexCuda”。本文就Mex CUDA程序的调试方法简单介绍下经验。
1. CUDA入门——vectorAdd
最简单的CUDA程序莫过于“vectorAdd.cu",即向量的加法。下面给出了一个向量加法的简单例子:
#include <cstdio>
#include <cuda_runtime.h>
#define N 5
// Kernel 函数
__global__ void vectorAdd(const float* a, const float* b, float* c) {
int idx = threadIdx.x;
if (idx < N) {
c[idx] = a[idx] + b[idx];
}
}
int main() {
// 初始化数据
float a[N], b[N], c[N];
for(int i = 0; i < N; ++i) {
a[i] = i;
b[i] = i + 1;
}
// 将数据复制到 GPU
float* d_a, *d_b, *d_c;
cudaMalloc(&d_a, sizeof(float) * N);
cudaMalloc(&d_b, sizeof(float) * N);
cudaMalloc(&d_c, sizeof(float) * N);
cudaMemcpy(d_a, a, sizeof(float) * N, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, b, sizeof(float) * N, cudaMemcpyHostToDevice);
// 执行Kernel
int blocksPerGrid = 1;
int threadsPerBlock = 64;
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_a, d_b, d_c);
// 将数据从GPU取回
cudaMemcpy(c, d_c, sizeof(float) * N, cudaMemcpyDeviceToHost);
for(int i = 0; i < N; ++i) {
printf("c[%d] = %f\n", i, c[i]);
}
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);
return 0;
}
将上面文件保存为vectorAdd.cu,在终端中执行
nvcc -o vectorAdd vectorAdd.cu
./vectorAdd
可以得到
TomHeavendeMBP:CUDA tomheaven$ ./vectorAdd
c[0] = 1.000000
c[1] = 3.000000
c[2] = 5.000000
c[3] = 7.000000
c[4] = 9.000000
2. 检查运行时错误
在上面的例子中,我们只用了1个block,64个thread,而且是最简单的线性排列。那么我们不禁要问,如果程序复杂起来,变量多起来,应该如何查错呢?
首先,引进一段查错代码:
#define gpuErrchk(ans) { gpuAssert((ans), __FILE__, __LINE__); }
inline void gpuAssert(cudaError_t code, const char *file, int line, bool abort=false)
{
if (code != cudaSuccess)
{
printf("GPUassert: %s %s %d\n", cudaGetErrorString(code), file, line);
if (abort) exit(code);
} else {
printf("cuda returned code == cudaSuccess\n");
}
}
在调用内核语句的后面加上两行查错指令:
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_a, d_b, d_c);
gpuErrchk( cudaPeekAtLastError() );
gpuErrchk( cudaDeviceSynchronize() );
编译之后执行可得
TomHeavendeMBP:CUDA tomheaven$ ./vectorAdd
cuda returned code == cudaSuccess
cuda returned code == cudaSuccess
c[0] = 1.000000
c[1] = 3.000000
c[2] = 5.000000
c[3] = 7.000000
c[4] = 9.000000
说明内核执行过程没有错误。注意PeekLastError检查的是执行过程有没有直接错误返回,而deviceSynchronize检查的是设备同步状态,包括“共享内存溢”出这样的问题。这个两个检查缺一不可。良好的编程习惯是其他每一个cuda打头的函数都用gpuErrchk包裹起来,这样可以及时发现运行时的错误。
3. 必须注意的问题
下面说说新手最容易犯的错误。用CUDA编程的时候,必须时刻牢记一些硬件限制:
- 单卡的最大线程数:每个维度不超过 65535。当block一维排布时,就是65535。
-
每个Block的最大线程数:1024 (老N卡512)
-
每个Block的最大共享内存:大约48KB。这是非常小的存储空间。
当以上任意一个限制被突破,内核执行都会出现错误。最常见的错误是“CUDA_ILLEGAL_ADDRESS”,即访问了非法显存。但是原因并不是代码逻辑出错——访问了非法地址,而是设备资源不足。
针对以上问题,有如下常用解决方法:
- 如果是单卡最大线程数不够,可以考虑将block设计成二维甚至三维,这样又获得了很多线程资源。
-
如果是单个Block线程过多,缩小“threadsPerBlock”这个参数。
-
第三个问题是比较隐蔽的,新手往往容易忽略。可以自己估算一下每个Block使用的共享内存大小,48KB是很容易超过的。缩小“threadsPerBlock”这个参数来使得每个Block中的线程数减少,从而满足共享内存限制。再不济,不使用“shared”关键字,直接使用内存,当然,这样会牺牲一些效率。功能实现后,优化代码时,想尽办法少用共享内存,比如能用float就尽量不用double。
4. 逻辑调试
前面的方法可以确保程序能编译通过并执行起来。但是程序逻辑到底对不对呢?我们需要一个好的调试方法。我爬了一些帖子和教程,有说用VS的,有说用Nsight的。这些方法是有用,但是有很大局限性,比如不跨平台,不能用于调试Matlab的mexCuda,最大的局限性就是一个字“慢”。
本人在写C++程序的时候就有一个观点:输出调试是最好的调试方法。为什么呢?一是调试很快,不用断点单步。二是对于环境没有要求,只要程序执行了就有输出。这也就意味着无论使用Matlab调用的,还是Python调用的,输出调试都起作用。
那么在CUDA内核中如何用输出调试呢?在内核函数中加“printf”吗?读者可以试试。当然,结果肯定是不奏效。正确的方法就是加一个数组作为调试输出。比如这样:
#define DEBUG
// Kernel 函数
__global__ void vectorAdd(const float* a, const float* b, float* c, float* info) {
int idx = threadIdx.x;
if (idx < N) {
c[idx] = a[idx] + b[idx];
}
#ifdef DEBUG
if (idx == 0) {
info[0] = a[idx];
info[1] = b[idx];
info[2] = c[idx];
}
#endif
}
在main中添加
float info[N];
float * d_info;
cudaMalloc(&d_info, sizeof(float) * N);
// 执行Kernel...
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_a, d_b, d_c, d_info);
// ...
#ifdef DEBUG
cudaMemcpy(info, d_info, sizeof(float) * N, cudaMemcpyDeviceToHost);
for(int i = 0; i < N; ++i) {
printf("info[%d] = %f\n", i, info[i]);
}
#endif
这样我们就获取到第0个线程执行过程中的变量了,通过定义DEBUG宏可以方便的开启或关闭调试信息。这就是Kernel函数输出调试的基本思路。