4、CUDA基础回顾:编程模型、内存层次、线程块配置与性能工具

好,咱们进入第四章。这一章我打算带大家快速过一遍CUDA的核心基础。别觉得基础就简单,我在边缘设备上踩过的坑,十有八九都是基础没打牢。

说白了,CUDA编程就是让GPU干活。但GPU怎么干活,你得先懂它的规矩。

4.1 CUDA编程模型:Host与Device的协作

CUDA的编程模型,核心就两个角色:Host(CPU)和Device(GPU)。Host负责逻辑控制、数据准备,Device负责大规模并行计算。

我习惯把Host比作项目经理,Device比作施工队。项目经理把图纸(数据)和任务(kernel)交代清楚,施工队就闷头干。干完了,项目经理去验收(数据回传)。

典型的CUDA程序流程是这样的:

  1. 分配Host内存,初始化数据
  2. 分配Device内存
  3. 把数据从Host拷贝到Device
  4. 调用kernel函数,在GPU上执行
  5. 把结果从Device拷回Host
  6. 释放内存

嗯,这里有个关键点:kernel是异步执行的。也就是说,你调用完kernel,CPU不会傻等着,它会继续往下走。我曾经在项目里犯过这个错——kernel还没算完,CPU就去读结果了,读回来的全是垃圾数据。后来加了cudaDeviceSynchronize()才搞定。

核心概念:CUDA编程本质上是异构编程。你要时刻清楚,哪段代码跑在CPU上,哪段跑在GPU上。数据在哪,计算就在哪。

4.2 内存层次结构:别让数据成为瓶颈

GPU的内存层次,是性能优化的重中之重。你想想看,计算单元再快,数据喂不进去也是白搭。

CUDA的内存模型大致分这几层:

内存类型 位置 访问速度 作用域 生命周期
全局内存 板载显存 慢(几百周期) 所有线程 程序全程
共享内存 芯片内 快(几个周期) 同一个Block Block生命周期
寄存器 SM内部 极快(1周期) 单个线程 线程生命周期
常量内存 板载显存(有缓存) 较快 所有线程(只读) 程序全程
纹理内存 板载显存(有缓存) 较快 所有线程(只读) 程序全程

我个人最常用的优化手段,就是把频繁访问的全局数据搬到共享内存里。举个例子,做矩阵乘法时,如果每个线程都去全局内存读数据,那带宽很快就被打满了。但如果你让一个Block的线程先把数据拉到共享内存里,再各自计算,速度能快好几倍。

避坑指南:我曾经在边缘设备上做图像处理,没注意共享内存的大小限制。一个Block开了太多共享内存,结果每个SM能同时跑的Block数量骤降,并行度反而下降了。记住:共享内存不是越大越好,要平衡。

4.3 线程块配置:找到那个黄金比例

线程块怎么配?这是每个CUDA新手都会问的问题。我的回答是:没有万能公式,但有经验法则。

先看几个关键参数:

  • 线程块大小(blockDim):通常是32的倍数,比如128、256、512。为什么?因为warp是32个线程一组,32的倍数能充分利用。
  • 网格大小(gridDim):根据数据量来定。比如你要处理100万个元素,每个线程处理1个,那网格大小就是100万 / 线程块大小。
  • 每个SM的最大线程数:不同架构不一样。Maxwell是2048,Pascal是2048,Volta是1024。你得查手册。

我一般这样配:先定线程块大小为256。为什么是256?因为256个线程需要的寄存器数量适中,共享内存也适中,不容易撞到资源上限。然后根据数据量算网格大小。

举个例子:

// 假设有N个元素,每个线程处理1个
int blockSize = 256;
int gridSize = (N + blockSize - 1) / blockSize;

myKernel<<<gridSize, blockSize>>>(d_data, N);

但注意,这只是起点。实际项目中,我会用性能分析工具跑一遍,看看occupancy(占用率)是多少。如果occupancy太低,就调小线程块或者减少每个线程的寄存器使用量。

警告:线程块大小不要小于32。否则一个warp里只有部分线程在工作,剩下的都在摸鱼,浪费计算资源。我见过有人设blockSize=8,结果性能惨不忍睹。

4.4 流与事件:让GPU流水线跑起来

流(Stream)是CUDA里实现并发的手段。说白了,就是让GPU同时干几件事。

默认情况下,所有操作都在默认流里,是串行的。但你可以创建多个流,让数据传输和计算重叠起来。

我常用的模式是这样的:

cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);

// 在stream1里做数据传输
cudaMemcpyAsync(d_data1, h_data1, size, cudaMemcpyHostToDevice, stream1);
// 在stream2里做计算
kernel1<<<grid, block, 0, stream2>>>(d_data2);

cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);

事件(Event)则用来做同步和计时。比如你想知道某个kernel跑了多久:

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

cudaEventRecord(start);
myKernel<<<grid, block>>>(d_data);
cudaEventRecord(stop);

cudaEventSynchronize(stop);
float milliseconds = 0;
cudaEventElapsedTime(&milliseconds, start, stop);
printf("Kernel用时: %f ms\n", milliseconds);

嗯,这里要注意:事件记录也是异步的。你得用cudaEventSynchronize等它完成,才能读时间。我刚开始用的时候忘了同步,读出来的时间全是0,还以为是GPU太快了...

4.5 性能分析工具:别靠猜,靠数据

优化性能最忌讳的就是「我觉得」。你得用工具说话。

我常用的工具有这几个:

  • nvidia-smi:快速查看GPU利用率、显存占用、温度。适合现场排查。
  • nvprof:老牌性能分析工具,能告诉你每个kernel的耗时、数据传输量、occupancy等。不过NVIDIA已经停止更新了,推荐用Nsight Systems。
  • Nsight Systems:新一代工具,界面更友好。能展示时间轴,让你看到CPU和GPU的活动是怎么重叠的。
  • Nsight Compute:专门分析kernel性能的。能告诉你瓶颈在计算还是访存,还能给出优化建议。

我个人的工作流是这样的:先用Nsight Systems看整体,找到最耗时的kernel。然后用Nsight Compute深入分析那个kernel,看看是计算密集还是访存密集,再针对性优化。

一个小技巧:在边缘设备上,nvidia-smi可能没有图形界面。你可以用watch -n 1 nvidia-smi每秒刷新一次,实时监控GPU状态。我在调试一个嵌入式设备时,就是靠这个命令发现显存泄漏的。

好了,这一章的内容就到这里。CUDA基础虽然看起来简单,但每个点都值得深挖。下一章我们会把这些知识用到边缘AI设备的实际部署中,到时候你会发现,今天讲的内存层次和线程块配置,都是决定模型能不能跑起来的关键。