ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CUDA并行编程入门:从线程模型到首个Kernel实战

CUDA并行编程入门:从线程模型到首个Kernel实战 前几年我第一次认真翻《Programming Massively Parallel Processors》也就是大家常说的 PMPP的时候心里想的是这书到底在解决什么问题后来真的上手用 CUDA 做并行程序才发现很多入门资料只告诉你“怎么调用 API”却很少讲清楚“为什么程序要长这样”。这一篇作为“并发处理器程序设计PMPP与 CUDA 架构”系列的第一期我打算把基础知识一次性理清楚GPU 凭什么能扛住大规模并发、CUDA 的线程模型是怎么组织的、一个最简单的 kernel 跑起来需要哪些步骤。如果你刚接触 GPU 计算或者以前写过一点 CUDA 但总觉得概念隔着一层窗户纸这期应该能帮你把地基打牢。说实话CUDA 的语法并不难难的是思维方式的转变。你写串行程序时脑子里是一条直线先读数据、再算、再写回。但到了 GPU 上你面对的是几千甚至几万个线程同时在跑。你得学会“一次描述万人执行”的写法也就是把同一份指令交给大量线程靠序号让每个线程干不同的活。这篇文章我会抛开抽象概念直接从“为什么需要并发处理器”开始讲然后拆解 CUDA 的线程层级、内存结构最后给出一个完整的向量加法实例把编译、运行、调试的整条路径走一遍。1. 为什么非要搞大规模并发CPU 的题已经做不完了先聊聊“并发处理器程序设计”这个概念到底触动了哪根神经。过去二十年CPU 的单核性能提升基本靠主频和指令级并行但主频逼近物理极限之后厂商开始疯狂堆核数。你现在买到的消费级 CPU 动辄 8 核 16 线程服务器的 32 核 64 线程也不稀奇。可问题是核数涨了很多程序却并没有变快——因为大多数任务天生是串行的或者需要大量数据协作。图形处理器走的是另一条路。GPU 从一开始就是为大规模并行渲染设计的它不追求单线程跑得多快而是追求“同一时间能拉起来多少线程”。一块中端显卡就能拉起上万甚至十几万个并发线程这个数量级是 CPU 无法想象的。PMPP 这本书的核心观点之一就是只要算法能被拆成大量独立的小任务GPU 就能用数量碾压质量用恐怖的吞吐率把执行延迟“藏”起来。举一个我正在处理的实际数据做例子。前段时间我需要把一百万条日志里的时间戳解析成可比较的整数串行版本大概要跑 300 毫秒听起来还行对吧但同一个操作放到 GPU 上一万个线程同时开工耗时能降到单次内存传输就能覆盖的程度整体不到 2 毫秒这还是包含了数据从 CPU 拷贝到 GPU 的开销之后的结果。这个数量级的差距不是靠优化循环体、调编译器参数能追回来的你必须换一种编程模型。这也是为什么我特别建议你从头啃 PMPP而不是上来就翻 CUDA 编程手册。编程手册更像字典告诉你每个函数怎么用PMPP 则会教你如何把一个问题“翻译”成并行结构。第一期我们先达成一个小目标真正理解 GPU 的并行心智模型知道一个 CUDA 程序由哪些部分组成以及它们为什么这么设计。2. CUDA 架构的核心思维模型主控与工厂的协作2.1 异构计算CPU 说了算GPU 负责干重活CUDA 程序天然是异构的也就是说同一份代码里你要同时管理两个“老板”CPUHost和 GPUDevice。这俩的定位完全不同。CPU 擅长复杂逻辑控制、分支跳转、处理系统调用它像公司里的项目经理负责拆任务、下指令、汇总结果。GPU 则像一个自带数万员工的巨型工厂每个员工只能做很简单的加减乘除但你一个电话打过去几万人同时开工。所以 CUDA 程序的执行流是这样的CPU 先准备数据把数据从内存拷贝到显存然后 CPU 发出一个“开工”指令GPU 上的大量线程开始执行你写好的 kernel 函数执行完之后CPU 再把结果拷回内存进行后续处理。整个流程里 CPU 是主控GPU 是执行者这种一主一辅的协作关系就叫异构计算。我用一个生活中的例子帮新手建立直觉你要搬一仓库的纸箱。CPU 的做法是亲自一趟一趟搬每次搬一箱但胜在搬箱子的路线可以随时调整比如半路发现某箱特别重可以立刻换一种搬法。GPU 的做法是雇一万个人每个人负责一个小区域大家同时搬效率极高但每个人的工作内容必须提前定好不允许临时改变主意。CUDA 编程的关键就是把工作拆分到适合多线程并行执行的形态然后一脚油门踩下去。2.2 Kernel给 GPU 的“委派书”你写的那段在 GPU 上执行的代码官方叫法就是 kernel。在 CUDA C 里kernel 用__global__修饰告诉编译器“这段函数不是给 CPU 跑的是给 GPU 跑的”。调用方式也很特殊在普通函数调用后面加三对尖括号里面写上线程配置参数。__global__ void addOne(int n, float *data) { int i blockIdx.x * blockDim.x threadIdx.x; if (i n) { data[i] data[i] 1.0f; } } // CPU 侧调用 addOnegridSize, blockSize(n, d_data);行家一看到就知道这是在发起 GPU 任务。尖括号里的第一个参数是 grid 的维度第二个参数是 block 的维度。新手最容易蒙的就是这两个参数到底该怎么填我在后面的章节实战里会展开先把思路理清你需要把任务切块成两层三维网格GPU 的调度器会负责把这三万块工作自动分配到各个处理器上。这个设计也直接回答了标题里的“大规模并发”是怎么组织的它把一个庞大的工人群体分成了“组”和“小组”方便管理层调度。3. 线程层级一个能解释所有问题的三层模型3.1 grid、block、thread 的存在意义CUDA 把线程组织成三个层级线程Thread、线程块Block、网格Grid。每个 kernel 启动时都会定义一个 grid一个 grid 里有若干个 block每个 block 里又有若干个 thread。这个层级不是凭空设计的它直接映射到 GPU 的物理结构一个 block 内的线程可以放到同一个流式多处理器Streaming MultiprocessorSM上执行它们彼此之间可以做同步和共享内存通信而不同 block 之间的线程则完全独立没有可依赖的通信机制。为什么非要分成两级而不是直接把一万个线程平铺开因为 GPU 的硬件资源是分组的。一个 SM 有固定的寄存器数量、共享内存大小、线程调度上限一个 block 的线程数如果设得太多会撑爆单组资源如果设成两级系统就能把 block 当作基本调度单位哪个 SM 空闲了就把哪个 block 扔过去。这种包装方式给了程序惊人的可扩展性——你在一张旧卡上写的程序拿到新卡上跑只要 block 数量足够多它就能自动填满新卡的所有 SM不用修改代码。3.2 线程编号怎么算一学就会的索引公式每个线程执行 kernel 时都需要知道自己“是谁”否则大家干得都是一样的活。CUDA 提供了一组内置变量帮线程定位threadIdx.x是线程在 block 内的序号blockIdx.x是 block 在 grid 内的序号blockDim.x是 block 的线程数。所以“全局编号”就有一句经典公式int globalThreadId blockIdx.x * blockDim.x threadIdx.x;这个公式非常像计算“全班同学的总号”“你是第几排”乘以“每排多少人”加上“你在这一排的第几个”。只要你想让线程索引和你的任务数组下标对应起来这个公式就是一切的基础。如果是二维或三维的 block同样的逻辑分别算y和z就行。我见过不少新手栽在这里只用了threadIdx.x作为索引结果所有 block 里的 0 号线程都在写同一个位置有的数据被反复改写有的数据根本没人处理。排查这种问题第一步永远是把你的索引公式画出来别硬调。3.3 Warp硬件层面的隐性调度单元线程模型之外还有一层你暂时看不见、但必须知道的概念warp。GPU 执行线程时并不是一个一个地执行而是把 32 个线程捆成一束同步执行同一条指令。这叫做 SIMT单指令多线程执行模型。一个 block 的线程数如果设成 128硬件就会把它切成 4 个 warp设成 256就是 8 个 warp。理解 warp 有什么好处第一你在设计 blockSize 时尽量取 32 的整数倍避免最后一个 warp 里的线程“吃不饱”。第二如果同一个 warp 里的线程走了不同的分支比如有的执行 if 里面有的执行 elseGPU 只能串行处理这两个分支这在性能分析里叫“warp divergence”是隐蔽的杀手。后面讲优化时我会拿矩阵乘法专门演示这个问题第一期先埋个伏笔让你知道线程排布是有讲究的。4. 内存层级数据存哪里决定了程序的生死速度4.1 寄存器、共享内存、全局内存的延迟差异GPU 不止线程层级长得独特内存层级更是决定性能的核心。一个 CUDA 程序里数据可以放在多种不同的位置越靠近处理器核心的访问越快但容量也越小。我用一张表把常见内存类型列出来方便你没接触过时快速建立概念内存类型访问速度相对容量生命周期作用范围寄存器最快零延迟极小每线程几十个线程内局部变量共享内存次之极快几十KB到上百KBblock 内block 内线程通信、缓存全局内存相当慢上百时钟周期最大显存容量整个程序数据主体、跨 block 访问常量内存快有缓存64KB整个程序只读常量纹理内存快有缓存取决于映射整个程序只读、特殊访问模式新手常犯的问题是把所有数据无脑丢进全局内存然后抱怨程序慢。实际上哪怕是最简单的向量加法如果每个线程都反复从全局内存取数也会被延迟拖垮。正确的思路是“一次读到寄存器多次复用”如果 block 内多个线程需要访问同一片数据就应该先拷进共享内存而不是各自跑到全局内存里去反复读。4.2 内存拷贝CPU 与 GPU 之间的唯一桥梁很多人忽略的一点是GPU 并不能直接访问你电脑的内存。写 CUDA 程序时你通常要先把数据从主机内存拷贝到设备显存kernel 跑完再拷贝回来。这一步的代码是繁琐的但也是必不可少的cudaMalloc((void**)d_a, bytes); cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice); // kernel ... cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);这里要特别提醒一句cudaMemcpy的最后一个参数标记方向。如果不小心把cudaMemcpyDeviceToHost和cudaMemcpyHostToDevice写反程序不会报错但结果一定不对而且是那种“偶尔对、偶尔错”的状态特别折磨人。我在调试时吃过这种亏后来养成了习惯只要涉及拷贝先写下方向注释再写代码。另外如果你在算力允许的平台上可以考虑cudaMallocManaged统一内存来省去手动拷贝让系统自动按需迁移页面。这个特性降低了入门门槛但性能上不一定比手动拷贝好适合原型验证不适合追求极致性能的项目。5. 第一个完整程序向量加法的全流程实操5.1 环境准备装好你的“打字机”在动手写代码之前先把环境准备好。你需要一块 NVIDIA GPU、对应的显卡驱动以及 CUDA Toolkit。Toolkit 装完之后nvcc编译器就位verification 最简单的方式是执行nvcc --version。如果你的机器上没有 NVIDIA GPU也可以用 NVIDIA 提供的云开发环境或者拿内存搭一个模拟环境但强烈建议用真机性能体验完全不同。开发者工具的清单如下nvccCUDA 的 C 编译器负责编译.cu文件nvidia-smi查看 GPU 状态、利用率、显存占用compute-sanitizer内存错误检查工具排错必备nsight/ncu性能剖析器后续优化时用装环境的时候最容易踩的坑是 PATH 没有写全导致nvcc找不到。Linux 下记得把/usr/local/cuda/bin加到 PATH并把/usr/local/cuda/lib64加到LD_LIBRARY_PATH。Windows 下则要留意 Visual Studio 的版本兼容某些 CUDA 版本和太新的 VS 会有适配问题。5.2 逐行拆解一个 kernel 的完整生命周期先看一个完整的向量加法程序。我们做的是把两个长度为 N 的数组逐元素相加输出到第三个数组。这个例子虽然简单但涵盖了 CUDA 编程所有核心步骤非常适合当“解剖样本”。#include cstdio #include cstdlib #define CUDA_CHECK(call) \ do { \ cudaError_t e call; \ if (e ! cudaSuccess) { \ fprintf(stderr, CUDA error at %s:%d: %s\n, __FILE__, __LINE__, \ cudaGetErrorString(e)); \ exit(1); \ } \ } while (0) // 每个 GPU 线程执行的向量加法内核 __global__ void vecAddKernel(const float *A, const float *B, float *C, int n) { int i blockIdx.x * blockDim.x threadIdx.x; if (i n) { C[i] A[i] B[i]; } } int main() { int n 1 20; // 一百万个元素 size_t bytes n * sizeof(float); // 1. CPU 侧分配内存并初始化数据 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; } // 2. GPU 侧分配显存 float *d_A, *d_B, *d_C; CUDA_CHECK(cudaMalloc((void**)d_A, bytes)); CUDA_CHECK(cudaMalloc((void**)d_B, bytes)); CUDA_CHECK(cudaMalloc((void**)d_C, bytes)); // 3. 从 CPU 内存拷贝数据到 GPU 显存 CUDA_CHECK(cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice)); CUDA_CHECK(cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice)); // 4. 配置线程层级并启动 kernel int threadsPerBlock 256; int blocksPerGrid (n threadsPerBlock - 1) / threadsPerBlock; vecAddKernelblocksPerGrid, threadsPerBlock(d_A, d_B, d_C, n); // 5. 等待 GPU 计算完成并把结果拷回 CPU cudaError_t err cudaDeviceSynchronize(); if (err ! cudaSuccess) { fprintf(stderr, Kernel launch or execution failed: %s\n, cudaGetErrorString(err)); } CUDA_CHECK(cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost)); // 6. 验证结果并清理 bool ok true; for (int i 0; i n; i) { if (h_C[i] ! 3.0f) { ok false; break; } } printf(Result: %s\n, ok ? PASS : FAIL); CUDA_CHECK(cudaFree(d_A)); CUDA_CHECK(cudaFree(d_B)); CUDA_CHECK(cudaFree(d_C)); free(h_A); free(h_B); free(h_C); return 0; }这个程序里最值得琢磨的是这行int blocksPerGrid (n threadsPerBlock - 1) / threadsPerBlock;它用的是“向上取整”的技巧。如果 n 刚好是 256 的倍数blocksPerGrid 就是合理的块数如果不是比如 n1000那么 blocksPerGrid (1000 255) / 256 4意味着最后一个 block 只有 232 个线程在干活还有 24 个线程“空转”。所以 kernel 里必须要有if (i n)这个边界检查否则空转线程会越界访问轻则报错重则让整个系统崩溃。很多新手要么忘掉这个检查要么直接分配能整除的数组长度这都是在程序入口处省不该省的思考。5.3 编译运行的完整指令把上述代码保存为vecAdd.cu然后执行nvcc -o vecAdd vecAdd.cu ./vecAdd如果一切正常你会看到Result: PASS。如果程序崩溃或者结果不对多半是索引公式写错、拷贝方向写反、或者忘了调用cudaDeviceSynchronize。cudaDeviceSynchronize的作用是把 CPU“堵住”直到 GPU 上的 kernel 全部执行完。kernel 启动是异步的CPU 不会等 GPU 算完就继续跑下一条指令如果立刻执行cudaMemcpyGPU 可能还没算完拷回来的就是半成品。这个同步函数是新手阶段最容易忘的例行公事。5.4 一个惨痛的 debug 故事索引算错的代价我最早写 CUDA 的时候以为向量加法分分钟搞定结果程序跑出来结果全是一堆随机数完全跟预期不对。我把内存分配和拷贝检查了一遍又一遍都没问题。后来打印了前 10 个线程的索引才恍然大悟我把blockIdx.x * blockDim.x threadIdx.x写成了blockIdx.x threadIdx.x每个线程都在往相邻位置写数数据全串了。这类问题的排查方式其实很朴素在小规模数据下比如 n16打印每个线程的blockIdx.x、threadIdx.x、globalIndex对照你预期的“谁该处理哪个下标”。GPU 编程时你没法像调试多线程 CPU 程序那样打断点眼力 打印是最高效的定位手段。如果你连打印都嫌麻烦至少先用compute-sanitizer检查一遍内存越界它能在几分钟内揪出很多隐蔽问题。6. 常见问题与排查技巧第一次上机就避开的坑6.1 程序启动就报错可能性排查清单第一次上 CUDA最常见的失败场景就是启动时崩溃。我给一个速查表按优先级排列现象可能原因快速检查方法cudaMalloc返回错误显存不足、驱动异常nvidia-smi看显存和驱动状态kernel 启动后程序挂死忘记cudaDeviceSynchronize、kernel 无限循环先加同步再看日志结果全为零拷贝方向写反、kernel 没正确读数据在小规模数据下打印索引与数据结果不稳定偶发错误索引越界、数据竞争compute-sanitizer全量扫描编译时报cudaError_t等未定义忘记#include cuda_runtime.h补头文件确认nvcc而不是gcc编.cu我还想加一条容易被忽略的如果你用的是sudo或容器环境显存权限可能受限。容器技术越来越普及但 GPU 直通需要额外的 runtime 配置。如果卡在cudaMalloc上先看/dev/nvidia*设备是否存在、权限是否正确这一条能省掉你半天的白费功夫。6.2 性能跟预期差距大先别急着上优化这个问题通常会出现在学了几个月的初学者身上明明很简单的一个 kernel跑出来的速度还不如 CPU。这时候先别慌也别急着上 shared memory、向量化加载这些高级手段。我建议按这个顺序排查确认 GPU 确实忙起来了。用ncu --set full ./vecAdd看 kernel 占用率和活跃线程数。确认数据规模够大。GPU 启动 kernel 本身有固定开销几十万数据规模下未必比 CPU 快这很正常。确认你的计算不是“内存带宽瓶颈成 IO 瓶颈”。很多 kernel 瓶颈在全局内存访问而不是计算本身。最后才考虑调blockSize。256 或 512 通常是甜点值太小会导致调度开销太大可能超过硬件线程上限。我见过有人为了追逐“好看”的 kernel 实现把最简单的问题搞得庞大冗长测下来反而没快多少。真正的并行程序设计是一种权衡艺术PMPP 的章节编排也是按这个思路来的先让你跑通再让你变快。6.3 学到这儿下一步该看什么第一期到这里其实已经把一个 CUDA 程序从“为什么需要并行处理”到“如何写出来、跑起来”的全链路走了一遍。你会有种感觉CUDA 并不神秘它只是把人从串行世界拽进了并行世界。下一期我准备深入讲解 GPU 的硬件执行模型和线程块调度机制顺带展示如何用共享内存优化矩阵乘法这个经典案例。矩阵乘法是并行计算里的“hello world”也是大多数真实应用的缩影它能把第一期讲的基础概念全部串起来。以下是经验收尾我个人在实际操作中的体会是这期内容里最值得反复消化的不是某个 API 的用法而是那个全局索引公式和线程层级的心智模型。很多问题——warp divergence、bank conflict、负载不均——最后追溯根源全都是对“线程怎么分布”理解不到位。所以建议你把向量加法的例子亲手写到编译器里跑三遍第一遍照抄第二遍手打不看答案第三遍改几个参数比如 blockSize 改成 100 试试观察现象。踩过几次坑之后你对 CUDA 的掌控感会完全不一样。最后再分享一个小技巧把CUDA_CHECK宏从第一课就用起来任何 cuda API 调用都包裹一遍。短期看是啰嗦长期看它能让你在茫茫代码里第一时间定位到出错的那一行。真实项目的失败现场永远比教程里展示的复杂得多。养成这个习惯后面几期的优化内容你会省下一半的调试时间。
返回列表