
做GPU编程的同行尤其是刚接触CUDA架构的朋友大多有过这种体验代码能跑但心里没底。为什么数据要先拷到显存为什么线程是这么编排的为什么稍微改个block尺寸性能差出好几倍这些问题如果不从根上解决后面看再多优化技巧也落不了地。我第一篇写的就是把这些基础概念掰开揉碎把“大规模并发处理器程序设计”这门经典课程里最核心的地基打牢适合正在学CUDA并行编程的在校学生也适合刚想用GPU做加速的工程师。这一期先不做花哨的优化只解决三个问题GPU凭什么快、CUDA的线程到底怎么组织、数据是怎么在显存里流动的。把这三个问题想清楚再看kernel代码基本就能做到“看到启动配置就猜到大概性能”。1. CPU受限困境与GPU并行革命1.1 性能瓶颈从哪来先说一个反直觉的现象现代CPU的主频已经很多年没有大幅提高了。十年前旗舰CPU能跑到3.5GHz今天还是3.5GHz上下甚至有往回缩的趋势。为什么功耗墙。频率再往上拉芯片发热量和供电压力都受不了。于是厂商换了一条路加核。从双核到四核再到十六核、三十二核但核心数的增长也遇到了天花板——内存带宽、缓存一致性、互连带宽都开始拖后腿。你现在拿一颗32核的服务器CPU去跑一个简单的大循环会发现核心全开也未必能线性加速。更麻烦的是功耗经济性。CPU为了兼顾延迟敏感型的任务比如操作系统调度、数据库事务、网络栈处理内部有大量复杂的控制逻辑、分支预测器、大容量缓存。这些硬件对单线程性能有帮助但对于“纯粹的数据批量处理”来说它们都在“空转”。打个比方CPU像一个独立工作室装修精美、工具齐全、什么活都能干但单价高GPU则更像一条流水线车间每个工位都极简但数量巨大专攻那些能被拆成无数小工的重复劳动。1.2 GPU的并行本色GPU最初为图形渲染而生图形渲染的本质就是对屏幕上几百万个像素做相同的计算坐标变换、光照计算、颜色输出。这些像素之间互不依赖天然适合并行。显卡厂商把大量晶体管投入到计算单元ALU而不是控制逻辑上所以一块消费级显卡能有数千个计算核心而一颗CPU通常只有十几二十个大核。关键点是吞吐量throughput思维。CPU追求低延迟一个请求进来希望尽快算完。GPU追求高吞吐一万个请求进来希望在单位时间内处理掉最多。单个线程可能比CPU慢但三万个线程并发执行整体速度就反超了。这就是“大规模并发处理器”这个名字的含义——不是把单个任务跑得更快而是把千万个任务的组织成本压下去。举一个实际的例子对两个长度为100万的数组做逐元素相加。单核CPU大概耗时几毫秒32核CPU用OpenMP可能很快但是同样的数据量在GPU上跑一个简单的kernel往往只要几十微秒而且数据规模越往上差距越明显。当然GPU也有短板单个任务的依赖链长的话或者频繁需要同步的话优势就不明显。这也是为什么要先理解任务类型再选方案。1.3 为什么选CUDA作为学习入口市面上有OpenCL、DirectCompute、SYCL、HIP等多种异构编程框架但选CUDA入门有几个很实际的理由。第一是生态成熟。CUDA的官方文档、示例代码、工具链nsight、nvcc、cuda-gdb都是完整的遇到问题搜到的答案也最多。第二是硬件占有率。不管你在学校里用的是Quadro还是GeForce在公司用的是A100还是H100只要跑深度学习或者科学计算N卡占有率在各个榜单上都很靠前。第三是学习迁移性。CUDA学透了再看HIP、SYCL基本是换个名字的事因为它们很多概念同源。还有一个隐性原因CUDA的编程模型非常接近GPU硬件的真实行为。它不像某些框架那样给你一个“统一数组”的抽象让你完全感觉不到内存拷贝。CUDA要求你显式地管理host端和设备端的数据这种“麻烦”反而能逼着你理解硬件原理。基础阶段多点这种麻烦是好事。2. 把GPU组织起来CUDA的硬件组成与线程模型2.1 硬件视角流处理器与流多处理器在软件里写代码之前先把GPU里的硬件单元认清楚。最底层的计算单元叫流处理器Streaming ProcessorSP——俗称CUDA core。每个SP负责执行一个线程的算术运算。一个显卡有几千个SP但它们不是独立的而是以组为单位封装在流多处理器Streaming MultiprocessorSM里。每个SM里有若干个SP、一块共享内存Shared Memory、一组寄存器文件Register File以及调度单元。整颗GPU由多个SM组成具体数量取决于芯片规模。拿一辆卡车做类比SP就是轮子SM就是车桥GPU就是整车。轮子不单独转动要跟着车桥走车桥的数量决定了整车能装多少货。编程时你接触的是“车桥”的概念——CUDA的线程块最终会被分配到SM上运行你不需要也不应该指定具体去哪个SM那是硬件调度器的事。2.2 软件视角线程的网格与线程块CUDA的线程组织是两级的一个kernel内核启动时创建一个网格grid网格由多个线程块block组成每个线程块包含多个线程thread。这种三级结构是理解CUDA的核心也是最容易被新手忽略的地方。对应到索引上gridDim.x网格在x维度上有多少个线程块。blockIdx.x当前线程块在网格中的编号。blockDim.x每个线程块里的线程数量。threadIdx.x当前线程在块内的编号。一个经典的用法是把全局索引算出来int globalIdx blockIdx.x * blockDim.x threadIdx.x;这就是线程与数据之间的映射公式。你可以把网格当成一本作业本线程块当成某一页线程当成这个页面里的一行。你拿到第几页第几行就能定位到作业本中的具体位置。真实场景里数据可能是二维的、三维的比如图像所以CUDA允许grid和block都定义到dim3类型最多三个维度。初学阶段先吃透一维二维三维只是索引公式多几项而已。2.3 Warp调度与SIMT执行机制每个名叫“线程”的执行单元硬件上是按固定大小分组调度的一组32个线程叫做一个warp。这是GPU执行的最小硬件单位。也就是说虽然你可以把block定义成128线程但硬件实际运行时是4个warp逐个被调度到SM上的。这背后叫SIMTSingle Instruction, Multiple Thread单指令多线程。同一时刻一个warp里的32个线程在各自的SP上执行同一条指令但操作的数据各不相同。这和CPU的SIMD单指令多数据不一样。CPU的SIMD需要你手动把数据打包到向量寄存器里而SIMT完全不用管你写的是普通标量指令硬件自动按32个线程并行执行。这个特性带来一个很现实的问题warp divergence线程分支发散。如果代码里有if (condition) { ... } else { ... }并且一个warp内32个线程里有的走if、有的走else那么硬件得把两段代码都执行一遍不满足条件的线程会被掩蔽掉。这等于浪费了一半执行资源。所以说面向GPU编程要尽量避免warp内的分支不均。不要觉得这句话是理论书上的教条实际写规约、排序、直方图的时候它直接决定性能。再补充一个容易被忽略的点SM上可以同时驻留多个block每个block又由多个warp组成。只要还有资源寄存器、共享内存、线程槽位SM就会尽量多塞block这样当一个warp在等待内存数据时调度器可以立刻切换到另一个warp去执行。这种切换几乎是零成本的和CPU线程切换保存现场完全不同。这也是GPU能靠“人海战术”吃掉访存延迟的核心原因。3. 第一个CUDA程序从环境搭建到代码跑通3.1 开发环境准备与nvcc学习CUDA第一步是装工具链。去Nvidia官网下载对应显卡驱动版本的CUDA Toolkit即可。装完之后命令行里会有nvcc -V这是CUDA的编译器驱动程序。注意nvcc和其他编译器最大的不同在于它要处理两种代码host端代码由NVCC传给宿主编译器比如GCC/Clang/MSVC编译最终在CPU上运行。device端代码也就是kernel由NVCC编译成GPU能执行的二进制最终在GPU上运行。你的.cu文件里可以同时混着C代码和CUDA kernelnvcc会根据语法区分它们。建议开发环境用Visual Studio Code加CUDA插件或者直接用Visual Studio也行。写代码的时候把硬件架构参数搞清楚编译的时候指定架构可以避免运行时“不支持的二进制格式”这类问题。如果你拿不准自己显卡的计算能力Compute Capability简称CC运行nvidia-smi看到显卡型号再去查对应CC版本。常见的比如RTX 3060是8.6A100是8.0RTX 4090是8.9。编译时用-arch参数来指定比如nvcc -archsm_80 hello.cu -o hellosm_80表示生成面向Ampere架构CC 8.0的代码。为了兼容性也可以直接用-archcompute_80,codesm_80这种更明确的写法。新手阶段可以直接用-archnative让编译器自动探测但要注意它在跨机器部署时会有兼容性问题。3.2 完整代码示例与逐行解读下面是一个最简单的CUDA程序它帮我们验证开发环境是否正常顺便跑通“CPU端调用GPU端”的完整链路#include cstdio __global__ void helloKernel() { printf(Hello CUDA from thread %d in block %d\n, threadIdx.x, blockIdx.x); } int main() { helloKernel2, 3(); cudaDeviceSynchronize(); return 0; }先说__global__关键字它表示这个函数是一个kernel运行在GPU上但由CPU端发起调用。kernel的调用语法是functiongridSize, blockSize这里2, 3表示启动一个包含2个block的grid每个block里有3个线程合计6个线程并行执行。打印语句里的threadIdx.x和blockIdx.x分别代表当前线程在block内的编号和block在grid内的编号。运行时你会看到六行输出编号依次是block 0的0、1、2以及block 1的0、1、2。输出顺序不一定因为GPU执行是并行的。为什么要有cudaDeviceSynchronize()kernel的启动在CPU看来是异步的也就是CPU把任务交给GPU后立刻返回不等GPU执行完。如果没有这一行程序可能直接return 0退出GPU端还没开始做事printf的结果自然也就看不到。这一行强制CPU端阻塞等待GPU端所有任务完成。后面写CUDA错误检查时会发现这也是一个关键的调试点。3.3 编译运行实操与常见坑把上面的代码保存为hello.cu打开命令行进入文件目录编译nvcc -archsm_80 hello.cu -o hello没有报错的话运行./hello屏幕上会打印6行hello信息。如果这里踩坑了最常见的有三种一是nvcc不是系统命令。原因通常是CUDA Toolkit的bin目录没加到系统PATH。装完后手动把/usr/local/cuda/bin加进去或者直接使用绝对路径调用。二是编译报错unsupported gpu architecture。这个通常是因为-arch参数填的架构比你的显卡新或者填的数字格式写错。先查自己显卡的CC版本填对再编。三是运行时报runtime API error : no kernel image is available。这个问题经常出现在用过于老的编译选项跑新卡上面。解决方式很简单指定匹配当前显卡的架构重新编译。例如显卡CC 8.6那就用-archsm_86。跑通hello之后可以试试把grid和block的尺寸调成二维三维观察索引变化。比如dim3(2,2), dim3(2,2)这种写法你会看到blockIdx.y和threadIdx.y都会参与索引计算。亲手调一次比看十遍文档都管用。4. 内存模型与数据搬移CUDA里的高速公路系统4.1 六种内存空间速览CUDA的内存模型是整个编程模型里最需要花工夫的地方。很多初学者一上来就写kernel结果数据没拷对算出来的全是垃圾值。先看一张总表内存类型位置访问权限生命周期速度特点寄存器每个SP内部线程私有kernel执行期间最快局部内存显存L1/L2缓存后线程私有kernel执行期间慢寄存器溢出时用共享内存SM内部block内所有线程共享block生命周期内快接近寄存器全局内存显存所有线程可读写程序运行期间慢带宽受限于PCIe/显存常量内存显存带缓存只读程序运行期间缓存命中时快纹理内存显存带专用缓存只读程序运行期间适合空间局部性访问你可以把全局内存理解为硬盘——容量大、慢、人人都能访问共享内存反而有点像CPU的L1/L2缓存但它是由开发者手动控制的而且极快。每个SM里的共享内存通常只有几十KB到一百多KB所以它是稀缺资源。寄存器则是每个线程独占的一小摞空间存线程的局部变量数量有限用多了会溢出到局部内存性能直接下降。4.2 数据从哪来、到哪去既然GPU不能直接访问CPU的内存那么一段通行的数据流程就是在设备端分配显存空间cudaMalloc。把CPU端的数据拷贝到设备端cudaMemcpy。调用kernel处理数据。把结果拷回CPU端cudaMemcpy。释放显存cudaFree。看一个向量加法的完整例子体会整个过程#include cstdio #include cstdlib __global__ void vectorAdd(const float *a, const float *b, float *c, int n) { int idx blockIdx.x * blockDim.x threadIdx.x; if (idx n) { c[idx] a[idx] b[idx]; } } int main() { const int n 1000000; size_t bytes n * sizeof(float); float *h_a (float*)malloc(bytes); float *h_b (float*)malloc(bytes); float *h_c (float*)malloc(bytes); for (int i 0; i n; i) { h_a[i] 1.0f; h_b[i] 2.0f; } float *d_a, *d_b, *d_c; cudaMalloc(d_a, bytes); cudaMalloc(d_b, bytes); cudaMalloc(d_c, bytes); cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice); cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice); int blockSize 256; int gridSize (n blockSize - 1) / blockSize; vectorAddgridSize, blockSize(d_a, d_b, d_c, n); cudaDeviceSynchronize(); cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost); cudaFree(d_a); cudaFree(d_b); cudaFree(d_c); free(h_a); free(h_b); free(h_c); return 0; }这段代码里有几个细节值得展开。int gridSize (n blockSize - 1) / blockSize;是一个取整技巧数据量n不一定能被blockSize整除所以算上整数块之外还要多出一个小块。因为kernel里我们写了if (idx n)做边界判断所以多出来的线程不会越界访存。这个技巧在几乎所有真实kernel里都会用到。blockSize 256是我随机选的。事实上它在很多场景下都是不错的起点但不是“最优解”。最优值取决于kernel的寄存器用量、共享内存使用和GPU架构一般用256到1024之间的整数。这块属于性能调优内容后续会细讲。基础阶段先记住block的线程数最好是warp大小32的倍数因为一个warp是32个线程block不是32的倍数时会产生不满的warp浪费调度槽位。cudaMemcpy的最后一个参数指定拷贝方向常见的有cudaMemcpyHostToDevice和cudaMemcpyDeviceToHost。方向写反了程序会报invalid argument错误这是新手高频错误之一。还要注意PCIe总线的拷贝速度通常远低于显存内部带宽如果频繁地在host和device之前来回搬运小块数据性能会非常差。这就是“数据搬移比计算贵”的来源。4.3 内存带宽思维一次拷贝抵几次计算我们说GPU快指的是它计算数据的速度快但数据进不了GPU一切都白搭。数据从CPU一路跋涉到GPU寄存器途经PCIe总线、显存、L2缓存、L1缓存每一级都是时间成本。Nvidia官方后来提供了统一内存Unified Memory机制目的就是藏起一部分拷贝细节但它底层仍然在搬数据。给一个直观数量级假设你要做100万次浮点加法这个计算量在GPU上可能只需要几微秒的“纯算力时间”但如果你用cudaMemcpy把两个数组从CPU拷到GPU再从GPU拷回去即使按PCIe 4.0的16GB/s带宽算也要几毫秒。这中间的差距有上百倍。可以这么说一个kernel里如果没有足够的计算量去“摊薄”拷贝开销那么整个程序基本输在了搬数据上。动手验证这个道理的方法很简单试着把vectorAdd里的数组长度从100万改成1000万、1个亿你会发现计算时间增长很小的同时拷贝时间却按比例涨而当你优化算法时最先看到收益的地方可能不是kernel本身而是减少了不必要的内存拷贝。等讲到进阶调优时你会频繁撞到这句话先减少数据搬运次数再谈怎么用共享内存。5. 错误处理与调试工具链程序跑飞是常态5.1 把CUDA错误检查当成习惯CUDA API调用返回的是一个cudaError_t类型值表示这次调用是否成功。很多初学者忽略这个返回值结果程序输出一堆NaN或者直接崩溃都不知道错在哪。一个标准的做法是把每个返回都包起来检查#include cstdio #define CUDA_CHECK(call) \ do { \ cudaError_t err (call); \ if (err ! cudaSuccess) { \ fprintf(stderr, CUDA error %s at %s:%d\n, \ cudaGetErrorString(err), \ __FILE__, __LINE__); \ exit(EXIT_FAILURE); \ } \ } while (0)之后把cudaMemcpy、cudaMalloc、cudaFree等所有调用全部包上CUDA_CHECK()。唯一不能包的是kernel启动本身因为kernel是异步的不会立刻暴露错误。所以在kernel调用完后再加上一行CUDA_CHECK(cudaGetLastError()); CUDA_CHECK(cudaDeviceSynchronize());cudaGetLastError()会拿到最近一次kernel启动或异步API调用丢弃的错误。这样配合起来你就能快速定位是哪个kernel出错。我在实际项目里几乎每个kernel后面都会写这两行效率提升明显。5.2 用nvidia-smi和nsight快速定位问题nvidia-smi是硬件状态工具它能显示当前GPU利用率、显存占用、运行中的进程。当你怀疑“GPU到底在忙什么”的时候先跑它看一眼利用率。如果利用率一直很低说明程序要么在拷贝数据要么在同步等待或者是kernel启动间隔太长这本身就是一种性能信号。深入一点的性能分析用NVIDIA Nsight Computensight compute它是profiling工具可以告诉你每个kernel的瓶颈是访存还是计算。用法很简单ncu --kernel-name regex ./your_executable它会报告每个kernel的occupancy、memory throughput、compute throughput。这些指标看起来可能懵但基础阶段只需看一个概念occupancy占用率。占用率是指SM上活跃warp数与理论最大warp数的比值。占用率高不代表性能最好但占用率过低通常说明资源限制导致调度空间不足比如共享内存用太多或者寄存器用太多。nsight输出的详细报告里会有“Warp State”部分能看到stall原因。比如long scoreboard表示线程在等待全局内存数据wait表示等待固定延时。这些细分项就是定位瓶颈的钥匙。基础阶段不知道怎么看就截图背下来后面调优时会自然而然地用到。5.3 编译错误与运行时错误速查表整理一份编译和运行过程中高频出现的错误方便你对着排查错误信息含义解决方案error: identifier threadIdx is undefinedkernel内使用了名字不对的变量检查threadIdx.x是否写成了threadIdx.x的大小写错误error: attribute global does not take arguments__global__的写法有误检查是不是写成了__global__没有双下划线kernel launch returned invalid configurationgrid或block配置非法检查blockSize是否为0block线程数是否超过1024runtime API error: invalid argument通常是cudaMemcpy方向或指针类型错误检查拷贝方向参数和指针是否是设备端指针device-side assert triggeredkernel内部越界或非法操作检查index计算特别是边界保护条件是否遗漏out of memory显存不足检查未释放的内存拷贝或改用更小的blockSize还有一个容易踩的坑在kernel里使用C标准库容器std::vector等。它们依赖CPU运行时的内存管理普通kernel里是不能直接用的。kernel里做数据存储应该用显存指针加手动管理的方式或者使用CUDA自带的向量类型。调试cuda-gdb也需要知道一点cuda-gdb可以设置设备端断点查看每个线程的值。但它比较依赖经验新手阶段建议先靠打印和错误检查把逻辑调对再考虑用调试器。打印是温和的cuda-gdb是严厉的。6. 写在最后的个人体会我自己最初学CUDA的时候最深的感悟是这门技术难的不是API而是思维方式的转变——从“单核串行”变成“海量线程并行”。前期看不到立刻的收益因为环境配置、错误处理、数据搬运这些“杂活”看起来都和性能无关。但把这些基本功打牢后面每走一步都会快很多。根据我踩过的坑建议你按这个顺序走先把今天讲的线程索引公式写熟能够不看文档就写出正确的全局索引再亲手跑三个小实验——向量加法、批量求和、矩阵转置第三个实验特别值得做因为非连续访问模式和连续访问模式的性能差距会让你对“内存布局”这四个字有深刻印象。如果你读到这里已经能独立编译运行一个带cudaMemcpy的完整程序说明你的CUDA基础框架已经建立起