24. GPU编程基础:CUDA入门、数据传输优化、核函数优化

说到GPU编程,很多人第一反应就是“难”。其实不然。我刚开始接触CUDA时,也觉得这东西玄乎——CPU跑得好好的,干嘛非要去折腾显卡?直到有一次,我接手了一个图像处理项目,单张图片要处理3秒钟,客户直接摔了鼠标。嗯,从那以后,我就老老实实学起了CUDA。

说白了,GPU就是一群“头脑简单、四肢发达”的工人。CPU是几个全能博士,什么都能干,但数量少。GPU是成千上万个只会做加减乘除的壮汉,干不了复杂活,但人多力量大。你想想看,处理一张图片的每个像素,不就是让每个壮汉算一个像素值吗?这活他们最擅长。

24.1 CUDA编程模型入门

先说说CUDA的基本概念。我个人习惯把CUDA程序分成两部分:Host端(CPU负责)和Device端(GPU负责)。Host端管逻辑控制、数据准备,Device端管大规模并行计算。

一个典型的CUDA程序长这样:

// 1. 分配GPU内存
float *d_data;
cudaMalloc(&d_data, N * sizeof(float));

// 2. 把数据从CPU拷贝到GPU
cudaMemcpy(d_data, h_data, N * sizeof(float), cudaMemcpyHostToDevice);

// 3. 调用核函数(在GPU上执行)
myKernel<<<gridDim, blockDim>>>(d_data, N);

// 4. 把结果拷回CPU
cudaMemcpy(h_result, d_data, N * sizeof(float), cudaMemcpyDeviceToHost);

// 5. 释放GPU内存
cudaFree(d_data);

这里有个关键点:<<<gridDim, blockDim>>> 这个尖括号里的东西,决定了有多少线程在干活。gridDim 是线程块网格的维度,blockDim 是每个线程块里的线程数。我刚开始写的时候,经常搞混这两个参数,结果要么线程不够用,要么线程太多把GPU撑爆了。

核心概念速查表:

概念说明我的经验
Thread最小的执行单元每个线程执行同一个函数,但处理不同数据
Block一组线程,共享共享内存block内线程可以协作,但别超过1024个
Grid一组Block,构成整个任务Grid大小决定了总线程数
Kernel在GPU上运行的函数用 __global__ 修饰,返回void

24.2 数据传输优化:别让带宽成为瓶颈

我在项目中遇到过最坑的事:辛辛苦苦写好了核函数,跑起来发现比CPU还慢。查了半天,发现90%的时间都花在数据传输上。你想想看,PCIe 3.0 x16的理论带宽也就16GB/s,而GPU显存带宽动辄几百GB/s。数据搬运的速度,根本喂不饱GPU这个“大胃王”。

怎么优化?我总结了几个实战技巧:

  • 减少传输次数:能一次传完的数据,别分十次传。我见过有人循环里每次传一个float,简直是在折磨PCIe总线。
  • 使用异步传输cudaMemcpyAsync 配合流(Stream),可以让数据传输和计算重叠。说白了就是一边拷数据,一边算数据,别闲着。
  • 固定内存(Pinned Memory):用 cudaHostAlloc 分配主机内存,能提高传输带宽。我实测过,能快2-3倍。
  • 零拷贝(Zero-Copy):对于只读数据,可以直接让GPU访问主机内存,省掉拷贝这一步。但要注意,这招只适合数据量小、访问频率低的情况。

避坑指南:我曾经在项目里用了 cudaMallocHost 分配了超大块固定内存,结果系统内存被锁死,其他进程直接卡死。固定内存虽然快,但别贪多,够用就行。

24.3 核函数优化:榨干GPU的每一分性能

核函数是GPU编程的灵魂。写得好,性能飞起;写得烂,GPU在摸鱼。我见过太多人把CPU的写法直接搬到GPU上,结果惨不忍睹。

先看一个简单的向量加法核函数:

__global__ void vecAdd(float *a, float *b, float *c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

这个写法没问题,但性能一般。怎么优化?

24.3.1 内存访问合并

GPU从显存读数据时,一次会读连续的一大块。如果线程访问的内存地址是连续的,就能合并成一次读取,效率极高。反之,如果跳着访问,每次只读一点点,带宽就浪费了。

我建议:让相邻线程访问相邻地址。比如线程0访问a[0],线程1访问a[1],这样就是合并访问。如果线程0访问a[0],线程1访问a[1000],那就是灾难。

24.3.2 共享内存的使用

共享内存是GPU上的一块“高速缓存”,比全局内存快几十倍。但它的容量很小,一个Block通常只有几十KB。怎么用?

  • 把频繁访问的全局数据先搬到共享内存里
  • 在Block内部做数据复用,减少全局内存访问
  • 注意Bank Conflict:多个线程同时访问共享内存的同一个Bank,会导致串行化

警告:共享内存用多了,每个Block能启动的线程数就会减少。我见过有人为了用共享内存,把Block线程数从512降到128,结果并行度下降,性能反而更差。这玩意是把双刃剑。

24.3.3 线程束发散

GPU执行指令时,是以32个线程为一组(称为Warp)来执行的。如果Warp里的线程走了不同的分支(比如if-else),那这些分支就得串行执行,性能直接腰斩。

我有个习惯:尽量让Warp内的线程走同一个分支。如果实在避免不了,就把分支条件设计成基于 threadIdx.x / 32 这种粒度,让整个Warp保持一致。

24.4 知识体系总览

下面这张图,是我自己总结的CUDA优化知识体系。每次做性能调优时,我都会对照着检查一遍:

CUDA性能优化知识体系 数据传输优化 核函数优化 架构适配 减少传输次数 / 批量传输 异步传输 + Stream重叠 固定内存 (Pinned Memory) 零拷贝 (Zero-Copy) 内存访问合并 (Coalescing) 共享内存 + Bank Conflict 线程束发散 (Warp Divergence) 循环展开 / 指令级并行 Grid/Block维度设计 Occupancy优化 寄存器压力控制 L1/L2缓存利用 性能调优 = 数据传输优化 × 核函数优化 × 架构适配

24.5 实战经验总结

最后,分享几个我踩过的坑,希望能帮你少走弯路:

  1. 别迷信“线程越多越好”:GPU的并行度受限于SM数量和寄存器资源。我曾经把线程数设到几百万,结果每个线程分到的寄存器太少,性能反而下降。
  2. 用好 cuda-gdbnvprof:我习惯先用 nvprof 看瓶颈在哪,是计算受限还是带宽受限,再针对性优化。别瞎猜,用数据说话。
  3. 注意浮点精度:GPU的float运算和CPU不完全一样。我在一个科学计算项目里,因为精度问题导致结果偏差,排查了整整两天。后来改用double,虽然慢了点,但结果对了。
  4. 别忽略错误检查:每个CUDA API调用后,记得检查返回值。我见过太多人写 cudaMalloc 不检查,结果内存不够直接崩了,还找不到原因。

一个小技巧:写CUDA代码时,我习惯在核函数开头加一句 if (threadIdx.x == 0 && blockIdx.x == 0) printf(...),用来打印调试信息。虽然慢,但关键时刻能救命。

GPU编程说难不难,说简单也不简单。核心就三点:数据搬得快、线程用得好、架构摸得清。把这三点吃透了,大部分性能问题都能迎刃而解。嗯,今天就聊到这,下次有机会再聊聊更高级的优化技巧。


公众号:蓝海资料掘金营,微信deep3321