异构计算 = CPU(控制者)+ GPU(加速者)协同工作
“为任务选择适合它的架构”。
- 费脑子的事(逻辑、分支预测、IO读取)给 CPU。
- 费体力的事(大规模矩阵运算、像素处理)给 GPU。
用GPU输出:Hello world!
准备工作
检查CUDA编译器是否正确安装,以及路径:
which nvcc通常结果是/usr/local/cuda/bin/nvcc。
检查GPU:
ls -l /dev/nv*我这里的输出结果是:
crw-rw-rw- 1 root root 195, 0 11月 24 15:55 /dev/nvidia0crw-rw-rw- 1 root root 195, 1 11月 24 15:55 /dev/nvidia1crw-rw-rw- 1 root root 195, 2 11月 24 15:55 /dev/nvidia2crw-rw-rw- 1 root root 195, 3 11月 24 15:55 /dev/nvidia3crw-rw-rw- 1 root root 195, 255 11月 24 15:55 /dev/nvidiactlcrw-rw-rw- 1 root root 235, 0 11月 24 16:10 /dev/nvidia-uvmcrw-rw-rw- 1 root root 235, 1 11月 24 16:10 /dev/nvidia-uvm-tools
/dev/nvidia-caps:total 0cr-------- 1 root root 238, 1 11月 24 15:55 nvidia-cap1cr--r--r-- 1 root root 238, 2 11月 24 15:55 nvidia-cap2/dev/nvidia0 到 /dev/nvidia3表示有4张独立显卡。
第一个代码
#include <stdio.h>
__global__ void hello(void){ printf("Hello world from GPU !\n");}
int main(){ printf("Hello world from CPU !\n"); hello<<<1,10>>> (); cudaDeviceReset(); return 0;}我保存到test.cu中,然后运行nvcc test.cu -o test编译,./test来运行。运行结果:
$ ./testHello world from CPU !Hello world from GPU !Hello world from GPU !Hello world from GPU !Hello world from GPU !Hello world from GPU !Hello world from GPU !Hello world from GPU !Hello world from GPU !Hello world from GPU !Hello world from GPU !可以看见,输出了一次CPU,十次GPU。
我的理解是,.cu文件,实际上是一个C程序,只是通过CUDA调用了GPU。而nvcc是一个编译器驱动,调用gcc编译CPU代码、以及NVDIA的自己的编译器来翻译GPU语言。
在CUDA编程里面,没有 __global__ 的函数,是普通C函数,比如main函数。
加上 __global__ 的函数(也叫 Kernel 核函数),由CPU来调用,通过GPU来执行。
因此前面CPU只输出一次。
int main(){ printf("Hello world from CPU !\n"); // CPU运行,只有一个主线程,所以只打印一次 // ...}后面的:
int main(){ printf("Hello world from CPU !\n"); hello<<<1,10>>> (); // 启动1个线程块,每个块10个线程同时跑,所以打印10次 // ...}三重尖括号意味着从主线程到设备端代码的调用,格式是函数名<<< 组的数量, 每组的人数 >>>(参数);
块和线程
前面这段代码,如果运行hello<<< 1, 10 >>>和hello<<< 2, 5 >>>,效果应该是一样的。
但是其实应该是不一样的。
用下面这段修改后的代码,打印出块号blockIdx.x和线程号threadIdx.x。
#include <stdio.h>
__global__ void hello(void){ printf("Block %d,\t Thread %d\n", blockIdx.x, threadIdx.x);}
int main(){ printf("Case : <<< 1, 10 >>>\n"); hello<<<1,10>>> (); cudaDeviceSynchronize();
printf("Case : <<< 2, 5 >>>\n"); hello<<<2, 5>>> (); cudaDeviceSynchronize(); return 0;}输出结果:
$ ./testCase : <<< 1, 10 >>>Block 0, Thread 0Block 0, Thread 1Block 0, Thread 2Block 0, Thread 3Block 0, Thread 4Block 0, Thread 5Block 0, Thread 6Block 0, Thread 7Block 0, Thread 8Block 0, Thread 9Case : <<< 2, 5 >>>Block 0, Thread 0Block 0, Thread 1Block 0, Thread 2Block 0, Thread 3Block 0, Thread 4Block 1, Thread 0Block 1, Thread 1Block 1, Thread 2Block 1, Thread 3Block 1, Thread 4为什么要分块?这看上去运行hello<<< 1, 10 >>>和hello<<< 2, 5 >>>结果并没有差很多,都是多个同时运行。
应该是应该硬件限制,每个块的线程有上限。
而且同一个block内部,有共享内存。
可以提升可扩展性。让程序可以在不同的CPU上都能跑。
同步与重置
cudaDeviceReset()、cudaDeviceSynchronize()的问题。
理论上,cudaDeviceReset是全部清空上下文,重新初始化整个环境。cudaDeviceSynchronize()是等前面的GPU干完了,不会清空上下文。
cudaDeviceSynchronize()意思是,等前面调用的GPU代码都干完了。如果没有调用这个,前面没有干完,CPU也会继续往下。因为CPU调用核函数是非阻塞的。
在前面运行这段代码的时候:
#include <stdio.h>
__global__ void hello(void){ printf("Block %d,\t Thread %d\n", blockIdx.x, threadIdx.x);}
int main(){ printf("Case : <<< 1, 10 >>>\n"); hello<<<1,10>>> (); cudaDeviceSynchronize();
printf("Case : <<< 2, 5 >>>\n"); hello<<<2, 5>>> (); cudaDeviceSynchronize();
return 0;}显然可以注意到,第一次打印出来Case : <<< 1, 10 >>>后,要等一会儿(很明显),才能看见后续的一堆东西打印出来,而且是立即全部打印出来,第二次的Case : <<< 2, 5 >>>不需要等待。
第一次等待,应该是因为有一个冷启动。
如果把cudaDeviceSynchronize()全部换成cudaDeviceReset(),理论上应该是有区别的!毕竟说过了后者会清空上下文,但是前面这段运行起来,体感上没有区别,但是我感觉应该是有区别的。
用下面的代码测试一下时间:
#include <stdio.h>#include <time.h>
__global__ void hello(void){ // printf("Block %d,\t Thread %d\n", blockIdx.x, threadIdx.x);}
int main(){ int N = 100;
clock_t start, end; double cost_time;
hello<<<1, 1>>>(); cudaDeviceSynchronize();
printf("Test Synchronize: \n");
start=clock(); for (int i=0;i<N;i++){ hello<<<1,1>>>(); cudaDeviceSynchronize(); } end=clock(); cost_time=((double)(end-start))/CLOCKS_PER_SEC; printf("Synchronize cost %.4f seconds\n", cost_time);
printf("Test Reset: \n");
start=clock(); for (int i=0;i<N;i++){ hello<<<1,1>>>(); cudaDeviceReset(); } end=clock(); cost_time=((double)(end-start))/CLOCKS_PER_SEC; printf("Reset cost %.4f seconds\n", cost_time);
return 0;}测试结果:
~$ ./testTest Synchronize:Synchronize cost 0.0006 secondsTest Reset:Reset cost 11.5860 seconds感觉还是很明显的。Reset慢很多。
CUDA编程结构
Host (主机) = CPU + 内存。负责逻辑控制、读取文件、指挥 GPU 。
Device (设备) = GPU + 显存。专门负责并行的繁重计算。
这两者通过 PCI-Express 总线连接,但它们的内存是物理隔离的。这意味着,CPU 里的变量,GPU 没法直接读;GPU 算好的结果,CPU 也没法直接拿。
NOTE可以把 CPU 上的变量名加前缀
h_(host),GPU 上的变量名加前缀d_(device)。
因为内存不互通,所以我们往往需要:
- H->D:把数据从 CPU 内存(Host)拷贝到 GPU 显存(Device)。
- 核函数执行:CPU 发令,GPU 开始并行计算。(这是异步的,CPU 发完令可以去干别的,也可以等 GPU 算完。)
- D->H:把算好的结果从 GPU 显存拷贝回 CPU 内存。
在GPU分配空间,用的是cudaMalloc函数:
cudaError_t cudaMalloc (void** devPtr, size_t size)这个是在GPU显存上,申请一个size字节的连续内存,返回的是是否分配成功,然后分配的GPU显存的指针,会被存在devPtr指向的地址中。
主机和设备之间的数据传输,用的是cudaMemcpy函数:
cudaError_t cudaMemcpy (void* dst, const void* src, size_t count, cudaMemcpyKind kind)从src这个地址开始,复制count字节到dst指向的地址。
最后的kind,用来表示复制方向,有四种:
cudaMemcpyHostToHost(疑似是为了对称的,主机搬到主机,和memcpy没啥区别)cudaMemcpyHostToDevicecudaMemcpyDeviceToHostcudaMemcpyDeviceToDevice
然后cudaError_t是一个枚举类型,成功就是cudaSuccess,失败就是cudaErrorMemoryAllocation。
可以用这个函数,把错误代码转成可以读的错误消息:
char* cudaGetErrorString (cudaError_t error)向量相加
下面是一个简单的向量相加代码。
#include <stdio.h>
__global__ void kernel_add(int *d_a, int *d_b, int *d_c, int n){ int idx = threadIdx.x + blockIdx.x * blockDim.x; if (idx < n) d_c[idx] = d_a[idx] + d_b[idx];}
int main(){ int n = 1000;
int h_a[n + 5], h_b[n + 5], h_c[n + 5]; for (int i = 0; i < n; i++) h_a[i] = i, h_b[i] = i;
size_t sz = n * (sizeof(int));
int *d_a, *d_b, *d_c; cudaMalloc(&d_a, sz); cudaMalloc(&d_b, sz); cudaMalloc(&d_c, sz);
cudaMemcpy(d_a, h_a, sz, cudaMemcpyHostToDevice); cudaMemcpy(d_b, h_b, sz, cudaMemcpyHostToDevice);
int block_size = 256; // 每个块256个线程 int block_cnt = (n + block_size - 1) / block_size; // up(n/block_size)
kernel_add<<<block_cnt, block_size>>>(d_a, d_b, d_c, n);
cudaMemcpy(h_c, d_c, sz, cudaMemcpyDeviceToHost);
printf("result: \n"); for (int i = 0; i < 4; i++) printf("%d\t+\t%d\t=\t%d\n", h_a[i], h_b[i], h_c[i]); printf("...\n"); for (int i = n - 4; i < n; i++) printf("%d\t+\t%d\t=\t%d\n", h_a[i], h_b[i], h_c[i]);
cudaFree(d_a); cudaFree(d_b); cudaFree(d_c);
return 0;}运行:
nvcc test.cu -o test./test结果:
result:0 + 0 = 01 + 1 = 22 + 2 = 43 + 3 = 6...996 + 996 = 1992997 + 997 = 1994998 + 998 = 1996999 + 999 = 1998这里,似乎加上(void **)强转比较好,但是我编译没有报错:
cudaMalloc(&d_a, sz); cudaMalloc(&d_b, sz); cudaMalloc(&d_c, sz);加上:
cudaMalloc((void**)&d_a, size); // ...错误检查宏
如果我们把n改成10000,然后后面增加进程数:
int block_size = 2048; // 每个块256个线程 ,改成2048 int block_cnt = (n + block_size - 1) / block_size;这样运行是直接错的:
result:0 + 0 = 01 + 1 = 02 + 2 = 03 + 3 = 0...9996 + 9996 = 09997 + 9997 = 09998 + 9998 = 09999 + 9999 = 0因为一个block最多就1024个线程。
但是在不知道这个问题的时候,可能不太好找到问题。
可以加一个这样的:
#define CHECK(call) \{ \ const cudaError_t error = call; \ if (error != cudaSuccess) \ { \ printf("Error: %s:%d, ", __FILE__, __LINE__); \ printf("code:%d, reason: %s\n", error, cudaGetErrorString(error)); \ exit(1); \ } \}然后代码改成下面这样:
#include <stdio.h>
#define CHECK(call) \{ \ const cudaError_t error = call; \ if (error != cudaSuccess) \ { \ printf("Error: %s:%d, ", __FILE__, __LINE__); \ printf("code:%d, reason: %s\n", error, cudaGetErrorString(error)); \ exit(1); \ } \}
__global__ void kernel_add(int *d_a, int *d_b, int *d_c, int n){ int idx = threadIdx.x + blockIdx.x * blockDim.x; if (idx < n) d_c[idx] = d_a[idx] + d_b[idx];}
int main(){ int n = 10000;
int h_a[n + 5], h_b[n + 5], h_c[n + 5]; for (int i = 0; i < n; i++) h_a[i] = i, h_b[i] = i;
size_t sz = n * (sizeof(int));
int *d_a, *d_b, *d_c; CHECK(cudaMalloc((void**)&d_a, sz)); CHECK(cudaMalloc((void**)&d_b, sz)); CHECK(cudaMalloc((void**)&d_c, sz));
CHECK(cudaMemcpy(d_a, h_a, sz, cudaMemcpyHostToDevice)); CHECK(cudaMemcpy(d_b, h_b, sz, cudaMemcpyHostToDevice));
int block_size = 2048; // 每个块256个线程 int block_cnt = (n + block_size - 1) / block_size; // up(n/block_size)
kernel_add<<<block_cnt, block_size>>>(d_a, d_b, d_c, n); CHECK(cudaGetLastError());
CHECK(cudaMemcpy(h_c, d_c, sz, cudaMemcpyDeviceToHost));
printf("result: \n"); for (int i = 0; i < 4; i++) printf("%d\t+\t%d\t=\t%d\n", h_a[i], h_b[i], h_c[i]); printf("...\n"); for (int i = n - 4; i < n; i++) printf("%d\t+\t%d\t=\t%d\n", h_a[i], h_b[i], h_c[i]);
CHECK(cudaFree(d_a)); CHECK(cudaFree(d_b)); CHECK(cudaFree(d_c));
return 0;}就可以看见报错了:
$ ./testError: test.cu:44, code:9, reason: invalid configuration argument同步问题
前面说了,核函数启动是异步的。
但是我并没有调用cudaDeviceSynchronize(),那么,会导致printf在GPU算完之前就输出吗?
不会。
因为后面有个:
cudaMemcpy(h_c, d_c, sz, cudaMemcpyDeviceToHost);这个函数以同步方式执行,因为在 cudaMemcpy 函数返回以及传输操作完成之前主机应用程序是阻塞的。
在运行这个函数的时候,CUDA会先检查GPU是否在忙碌,只有前面都忙完了,才会拷贝数据。