CUDA C++编程指南核心:编译、线程内存与性能优化
简介《CUDA C编程指南》是由NVIDIA官方发布的权威技术文档面向希望利用GPU并行计算能力的C开发人员系统覆盖从并行计算基础概念到高级编程技巧的完整知识体系。内容先介绍使用GPU的优势与可扩展编程模型再讲解内核、线程层次与线程块簇、内存层次、异构编程及异步SIMT编程模型帮助读者掌握在单GPU与多GPU环境下编写、部署和优化高性能计算程序的方法。资源包内含1个PDF文件大小约4.3MB便于下载后离线查阅与反复研读。已有166人学习浏览内容适合初学者系统入门也适合有经验的开发者补充进阶知识。文档对NVCC离线编译与即时编译机制、二进制与PTX兼容性、设备内存与L2缓存管理、64位系统兼容性等关键细节均有详细说明是开发CUDA加速应用时不可多得的常备参考资料。1. 把 CUDA C 当黑匣子用是你性能上不去的根本原因很多人拿到《CUDA C 编程指南》第一反应是“官方文档太厚翻不动”于是继续靠抄别人 kernel 过日子。但这份 Release 12.8 的官方指南恰恰是解决“为什么我的程序这么慢”“为什么显存报错”“为什么换了显卡结果就不一样”这类问题的最终答案。它讲透了内核怎么组织、线程层次怎么排、内存怎么选、nvcc 怎么编译、流和事件怎么用覆盖 CUDA C 编程的完整闭环。这篇文章会按“是什么 → 怎么用 → 坑在哪”的顺序把这份指南拆成能直接落地复现的操作路径适合刚写完第一个 kernel 的初学者也适合已经上线过 CUDA 应用但想系统排查性能问题的开发者。2. 编译流程与兼容性先把 nvcc 的工作方式搞清楚再谈优化2.1 离线编译与即时编译两种 PTX 生成路径的取舍CUDA 程序的编译和普通 C 有一个关键区别nvcc 不是一个简单的编译器前端它会把代码拆成主机端host和设备端device两部分分别走不同的编译路径。指南第 6.1.1 节详细解释了这一点。离线编译Offline Compilation是在开发阶段把.cu文件完整编译成 cubin 二进制运行时直接加载即时编译Just-in-Time Compilation则是把 PTX 中间代码在运行时交给驱动现场编译成设备相关的 SASS。这两个机制的取舍直接影响你的部署策略编译方式产物优点缺点离线编译cubinSASS启动快无首次运行编译延迟只兼容特定计算能力换卡需重新编译即时编译PTX cubin同一份 PTX 可在多代 GPU 上运行首次调用会卡顿几秒到几十秒我一般会在工程里同时保留两类编译产物。发布时在nvcc命令里指定-gencode archcompute_XY,codesm_XY和-gencode archcompute_XY,codecompute_XY这样既能拿到原生性能又能给未来新卡留一条后路。2.2 计算能力与编译参数为什么“能跑”和“跑得快”是两回事指南在 5.6 节和 6.1.2 节反复强调 Compute Capability计算能力的概念。设备端的sm_xy代表了硬件的具体架构版本比如sm_80对应 Ampere 架构sm_90对应 Hopper 架构。每条 SASS 指令和每个硬件单元的行为都跟这个版本绑定。编译时arch和code的写法很容易混archcompute_80指定的是 PTX 目标版本codesm_80指定的是 SASS 目标版本如果只写-archsm_80nvcc 会同时生成对应代次的 PTX 和 SASS 的 cubin。但如果你写的是-gencode archcompute_80,codecompute_80那就只有 PTX运行时靠 JIT 编译。给一个通用的交叉编译命令模板nvcc -O3 -gencode archcompute_80,codesm_80 \ -gencode archcompute_80,codecompute_80 \ -gencode archcompute_90,codesm_90 \ -o my_kernel my_kernel.cu这条命令的含义是为 Ampere 架构生成原生 SASS同时保留同代 PTX 兜底再为 Hopper 架构生成原生 SASS。逻辑说明每个-gencode组合生成一个 fatbin 段最终打包进可执行文件运行时由 CUDA 驱动从 fatbin 中挑选匹配的sm_xy版本。参数说明arch值越大PTX 新特性越多但兼容性越差如果拿不准用户机器分布建议从compute_70到compute_90都打进去代价是可执行文件体积变大。这里有个容易踩的坑不能对一个低于 PTX 版本的计算能力做 JIT。比如你只编译了compute_90的 PTX那它在计算能力 8.0 的显卡上会直接加载失败因为驱动没有能力做“降级”翻译。2.3 64 位兼容性与 C 标准支持指南 6.1.5 和 6.1.6 节专门讲了 C 兼容和 64 位兼容。CUDA C 本质上是对标准 C 的扩展nvcc 支持的语法特性跟着所选 C 标准走。实测中__host__ __device__函数里用模板、lambda、标准库算法都没问题但要注意std::vector这类依赖主机端实现的容器不能在设备端直接用。64 位兼容的重点是指针和size_t的长度。在设备端代码里unsigned int是 32 位unsigned long long是 64 位。如果你用unsigned int存数组总长度超过 4GB 的分配会溢出变成负数这在指南里被明确列为未定义行为。所有涉及显存大小、数据总长度的变量我习惯全部用size_t。3. 线程与内存层次结构从 grid 到共享内存一次把模型理顺3.1 线程层次的内外两层理解grid、block、warp 与 cluster指南 5.2 节给出了 CUDA 最核心的线程层次结构grid → block → thread。但光知道这三个层级还不够实际写代码时还得理解 warp线程束和 cluster线程块簇5.2.1 节新增概念的定位。warp 是硬件调度的最小单位一个 warp 固定 32 个线程。也就是说即使你一个 block 只开 16 线程硬件也会按 32 个线程的粒度去占用调度资源剩下的 16 个槽位就浪费了。因此 block 大小建议取 32 的倍数常见配置是 128、256、512。Thread Block Clusters 是较新计算能力引入的中间层。它允许一组 block 在运行时有共享内存的硬件连带关系能直接访问彼此的 shared memory。这对以前只能靠全局内存通信的跨 block 协作是一个很大改善。看一段简单的内核调用代码把层次对应关系具象化#include cuda_runtime.h #include stdio.h __global__ void inspect_threads() { // threadIdx.x 是块内偏移blockIdx.x 是块索引 // blockDim.x 是每块线程数gridDim.x 是全局块数 int global_id blockIdx.x * blockDim.x threadIdx.x; printf(global thread id: %d, block %d, thread in block %d\n, global_id, blockIdx.x, threadIdx.x); } int main() { // 启动 4 个 block每个 block 32 个线程 inspect_threads4, 32(); cudaDeviceSynchronize(); return 0; }逻辑说明4, 32是 CUDA 的启动配置语法第一个参数是 grid 维度第二个是 block 维度。blockIdx.x * blockDim.x threadIdx.x是所有 CUDA 教程都要写的全局线性索引公式含义是“先把当前 block 之前的所有线程数算出来再加上块内偏移”。参数说明gridDim, blockDim都可以是dim3类型用于二维和三维组织图像处理中常见gridDim, blockDim同时传(width, height)各自的尺寸。3.2 内存层次选型全局内存、共享内存、寄存器各自的定位指南 5.3 节把内存层次讲得很细。全局内存Global Memory容量大但延迟高共享内存Shared Memory是 block 内的高速缓存寄存器Register每个线程独占但数量有限。很多人一上手就把所有数据往全局内存里堆性能自然上不去。各存储空间的特征对比如下存储空间位置延迟量级容量作用域全局内存板载显存数百周期数 GB所有线程L2 缓存芯片内数十周期数 MB所有线程共享内存芯片内数个周期几十 KB单个 block寄存器芯片内1 个周期每线程数百字节单个线程一个典型的内存优化思路是把全局内存中将要被反复访问的 tile 数据分阶段搬进共享内存block 内所有线程从共享内存里读而不是反复打全局内存。这就是 tiling 技术的基本逻辑。3.3 共享内存的两种布局静态与动态分配共享内存既可以在编译期用__shared__静态声明也可以运行期用extern __shared__动态声明。静态方式简单直白动态方式能让同一个 kernel 适配不同大小的块。__global__ void shared_mem_demo(const float* in, float* out, int n) { // 静态共享内存编译期大小 __shared__ float tile[256]; int idx blockIdx.x * blockDim.x threadIdx.x; if (idx n) { tile[threadIdx.x] in[idx]; } __syncthreads(); // 每个线程把共享内存里的值翻倍后写回 if (idx n) { out[idx] tile[threadIdx.x] * 2.0f; } }逻辑说明__shared__ float tile[256]在编译期就把共享内存占住了大小必须是编译期常量。__syncthreads()是 block 内屏障保证所有线程都完成共享内存的写入之后任何线程才能读取。如果不加这个同步会出现部分线程还在写、部分线程已经开始读的竞争状态。参数说明这里 tile 大小和 block 大小强相关如果 block 开到 512 线程而 tile 只有 256后半部分线程就会访问越界。动态共享内存的写法是启动时在尖括号第三个参数里传大小__global__ void dynamic_shared_demo(const float* in, float* out) { extern __shared__ float dyn_tile[]; int idx blockIdx.x * blockDim.x threadIdx.x; dyn_tile[threadIdx.x] in[idx]; __syncthreads(); out[idx] dyn_tile[threadIdx.x] 1.0f; } int main() { float *d_in, *d_out; // 假设已经分配并拷贝数据 int block_size 256; int grid_size 1024; // 第三个参数是每个 block 的动态共享内存字节数 dynamic_shared_demogrid_size, block_size, block_size * sizeof(float)(d_in, d_out); cudaDeviceSynchronize(); return 0; }逻辑说明extern __shared__声明的是一个大小未知的共享内存数组实际大小由启动配置的第三个参数决定。这里的block_size * sizeof(float)就是为每个 block 预留的字节数。参数说明启动配置第三个参数的单位是字节不是元素个数写错的话轻则越界重则启动失败。一个 block 能声明的动态共享内存上限由设备的MaxDynamicSharedMemorySize属性决定超出会直接报错。4. 异步并发执行用流和事件把 GPU 的每一计算单元喂饱4.1 流Streams从默认流到多流并行默认流default stream是每个程序隐式拥有的特点是所有操作按顺序执行。指南 6.2.8.5 节指出只要不用默认流不同流里的操作就有并发执行的可能。流的使用包括三步创建流、把操作放入流、同步。#include cuda_runtime.h #include stdio.h int main() { cudaStream_t s1, s2; cudaStreamCreate(s1); cudaStreamCreate(s2); float *d_in1, *d_in2, *d_out1, *d_out2; float *h_in1, *h_in2, *h_out1, *h_out2; int n 1 20; size_t bytes n * sizeof(float); // 分配设备内存 cudaMalloc(d_in1, bytes); cudaMalloc(d_in2, bytes); cudaMalloc(d_out1, bytes); cudaMalloc(d_out2, bytes); // 分配页锁定主机内存用 pinned memory 才能异步拷贝 cudaMallocHost(h_in1, bytes); cudaMallocHost(h_in2, bytes); cudaMallocHost(h_out1, bytes); cudaMallocHost(h_out2, bytes); // 数据初始化省略 // 流1拷贝 - kernel - 拷贝回 cudaMemcpyAsync(d_in1, h_in1, bytes, cudaMemcpyHostToDevice, s1); my_kernelgrid_size, block_size, 0, s1(d_in1, d_out1, n); cudaMemcpyAsync(h_out1, d_out1, bytes, cudaMemcpyDeviceToHost, s1); // 流2同样三步但与流1并行 cudaMemcpyAsync(d_in2, h_in2, bytes, cudaMemcpyHostToDevice, s2); my_kernelgrid_size, block_size, 0, s2(d_in2, d_out2, n); cudaMemcpyAsync(h_out2, d_out2, bytes, cudaMemcpyDeviceToHost, s2); // 两个流都完成后再继续 cudaStreamSynchronize(s1); cudaStreamSynchronize(s2); cudaStreamDestroy(s1); cudaStreamDestroy(s2); cudaFree(d_in1); cudaFree(d_in2); cudaFree(d_out1); cudaFree(d_out2); cudaFreeHost(h_in1); cudaFreeHost(h_in2); cudaFreeHost(h_out1); cudaFreeHost(h_out2); return 0; }逻辑说明这里两个流各自包含“拷贝到设备 → 执行内核 → 拷回主机”三个操作。由于它们在不同流中如果设备资源允许H2D 拷贝、kernel 执行、D2H 拷贝三个阶段的空闲资源会被互相填充。参数说明cudaMemcpyAsync的最后一个参数是流 ID传cudaMemcpyDefault则优先使用统一虚拟地址自动判断方向。pinned memory 也就是cudaMallocHost分配的主机内存是异步拷贝的前提条件普通malloc内存走异步拷贝会退化为同步。4.2 事件Events比 cudaDeviceSynchronize 更精细的同步手段事件Events用于测时和同步。cudaDeviceSynchronize()会阻塞主机直到所有设备操作完成粒度太粗事件可以精确标记某个流中的某个时间点。cudaEvent_t start, stop; cudaEventCreate(start); cudaEventCreate(stop); // 记录事件到默认流 cudaEventRecord(start, 0); my_kernelgrid_size, block_size(d_out, d_in, n); cudaEventRecord(stop, 0); // 阻塞等待 stop 事件完成 cudaEventSynchronize(stop); float ms 0.0f; cudaEventElapsedTime(ms, start, stop); printf(kernel time: %f ms\n, ms); cudaEventDestroy(start); cudaEventDestroy(stop);逻辑说明cudaEventRecord将事件插入指定流的命令序列事件前所有操作完成后事件被标记完成。cudaEventElapsedTime计算两个事件之间的毫秒数。参数说明事件必须属于同一个上下文才能计算时间差跨设备的事件测时结果未定义。事件测时对 kernel 而言测的是 GPU 端时间不受主机端调度抖动影响所以比直接用clock()测出来的数据可信度高一截。4.3 Programmatic Dependent Launch 与 CUDA Graphs把启动开销压到最低指南 6.2.8.6 和 6.2.8.7 节分别讲了 Programmatic Dependent LaunchPDL和 CUDA Graphs。PDL 允许 kernel 在设备端直接触发下一个 kernel 的启动省去主机→设备之间的启动往返。CUDA Graphs 更进一步允许先建立依赖图一次性重放。cudaGraph_t graph; cudaGraphExec_t graphExec; // 用流捕获方式构建图 cudaStream_t stream; cudaStreamCreateWithFlags(stream, cudaStreamNonBlocking); cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal); my_kernelgrid_size, block_size, 0, stream(d_out, d_in, n); other_kernelgrid_size, block_size, 0, stream(d_out2, d_out, n); cudaStreamEndCapture(stream, graph); cudaGraphInstantiate(graphExec, graph, NULL, NULL, 0); // 多次启动只走一次验证开销 cudaGraphLaunch(graphExec, 0); cudaDeviceSynchronize(); cudaGraphExecDestroy(graphExec); cudaGraphDestroy(graph);逻辑说明cudaStreamBeginCapture之后在流里发起的所有操作都被记录为图节点cudaStreamEndCapture生成cudaGraph_t对象。实例化后的graphExec可以直接反复 launch省去每次 launch 的驱动解析和依赖检查开销。参数说明流捕获模式cudaStreamCaptureModeGlobal表示在捕获期间全局范围内的操作都会被纳入图中如果捕获期间调用cudaMemcpy这类同步操作捕获会直接报错这是个常见坑点。5. CUDA 常见问题排查与避坑五次翻车记录一次说清5.1 问题一cudaGetLastError()永远返回cudaSuccess但程序就是跑不出正确结果现象kernel 启动后没有报错数值结果错误或者程序崩溃。 原因kernel launch 是异步的主机端不会立刻收到错误。多数 kernel 错误发生在执行阶段而不是启动阶段。很多老手贴检查代码时只查 launch 返回值自然会漏掉。 解决每次 kernel 启动后加cudaDeviceSynchronize()再配合cudaGetLastError()查异步错误。my_kernelgrid, block(d_out, d_in, n); // 异步错误检查同步 取错误 cudaError_t err cudaDeviceSynchronize(); if (err ! cudaSuccess) { fprintf(stderr, Kernel failed: %s\n, cudaGetErrorString(err)); exit(EXIT_FAILURE); }逻辑说明cudaDeviceSynchronize会阻塞到所有此前发出的设备操作结束如果其中有任何错误返回码会在这里暴露。cudaGetErrorString把错误码转成可读字符串。参数说明在性能敏感的循环里不要每个 kernel 都调用cudaDeviceSynchronize正确的做法是在循环末尾做一次同步检查其他位置只查 launch 返回值。5.2 问题二动态共享内存明明传了大小kernel 启动还是报invalid argument现象grid, block, bytes传了共享内存大小但启动失败错误为cudaErrorInvalidValue。 原因超过设备的cudaDevAttrMaxDynamicSharedMemorySize限制。部分架构默认限制是 48KB需要手动通过cudaFuncSetAttribute提高上限。还有一种可能动态共享内存和静态共享内存的总和超出硬件容量。 解决先查询设备属性再对 kernel 设置属性。cudaDeviceProp prop; cudaGetDeviceProperties(prop, 0); printf(Max dynamic shared memory per block: %zu bytes\n, prop.sharedMemPerBlockOptin); // 对指定 kernel 放开动态共享内存上限 cudaFuncSetAttribute(my_kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, prop.sharedMemPerBlockOptin); // 之后再用大尺寸启动 my_kernelgrid, block, 96 * 1024(d_out, d_in, n);逻辑说明sharedMemPerBlockOptin是设备允许单个 block 申请的最大动态共享内存字节数需要先调用cudaFuncSetAttribute让 kernel 越过默认的 48KB 限制。参数说明这个设置是 per-kernel 的换了 kernel 就要重新设置静态共享内存和动态共享内存共用同一份硬件预算不能把两者分开算。5.3 问题三同一个 kernel 在旧显卡上性能正常新显卡上反而变慢现象升级硬件后 kernel 执行时间不降反升。 原因老卡上最优的 block 大小在新卡上可能不适合。不同架构对寄存器数量和共享内存容量的限制不同。如果 block 太大导致每个线程寄存器分配不足编译器会 spill 到本地内存变相把数据写回显存性能直接崩。 解决用cudaOccupancyMaxActiveBlocksPerMultiprocessor做占用率推导或者干脆对不同 block 大小做一轮 benchmark。int block_size 0; // 求给定块大小时每个 SM 能同时驻留的最大 block 数 cudaOccupancyMaxActiveBlocksPerMultiprocessor(num_blocks, my_kernel, 256, 0); // 通过 num_blocks 推算最优 block 大小逻辑说明占用率计算器指南 8.2.3.1 节提到会根据 kernel 的寄存器数、共享内存用量、block 大小和硬件限制算出硬件能同时驻留多少 block。如果num_blocks很低说明资源被某个维度卡住了。参数说明第三个参数是动态共享内存大小第四个可选参数可以传入 block 大小限制在性能分析阶段把这两个参数都试一遍比拍脑袋调强得多。5.4 问题四多流并发没效果profiler 里显示操作还是串行的现象分了两个流数据拷贝和 kernel 执行的时间完全没有重叠。 原因最常见是用了默认流。只要有任何操作在默认流里整个默认流和所有阻塞流会被强制串行化。另外两个流操作同一块内存也不允许并行。 解决所有流操作都避免使用默认流在创建流时显式加cudaStreamNonBlocking防止隐式同步。// 创建非阻塞流避免与默认流之间的隐式同步 cudaStreamCreateWithFlags(stream1, cudaStreamNonBlocking); cudaStreamCreateWithFlags(stream2, cudaStreamNonBlocking);参数说明cudaStreamNonBlocking表示该流不会与默认流同步。另一个创建选项cudaStreamDefault表示该流会和默认流隐式同步这种同步会破坏并发。5.5 问题五__restrict__没加导致 kernel 性能差 30%现象一个简单的拷贝 kernel读写指针分别指向不同数组性能比理论带宽低。 原因编译器不知道两个指针是否指向同一块内存只能做保守假设每次写入都可能影响后续读无法做重排和向量化。 解决在函数参数上加__restrict__。// 声明 in 和 out 不指向同一区域 __global__ void copy_kernel(const float* __restrict__ in, float* __restrict__ out, int n) { int i blockIdx.x * blockDim.x threadIdx.x; if (i n) out[i] in[i]; }逻辑说明__restrict__是向编译器承诺“这个指针是唯一访问该内存区域的入口”编译器得到这一信息后可以缓存和重排访问而不必担心in写入被out的读操作影响。参数说明滥用__restrict__有风险如果两个指针实际指向了同一块数据编译器会做出错误优化数值直接错乱。这也是为什么指南 10.2.6 节反复强调要确保别名关系确不存在。6. L2 持久化访问从三级 cache 压榨到最后 20% 的带宽前面几章覆盖了怎么把程序写对、怎么并发、怎么避坑这一章聊一个容易被忽略的进阶技巧——L2 Cache Set-Aside for Persisting Accesses指南 6.2.3 节。常规 CUDA 优化集中在共享内存和寄存器但当全局内存访问模式确实无法避免重复读取时L2 持久化窗口能直接为热点数据预留缓存空间避免被其他数据流冲掉。L2 持久化的核心思路很简单把访问属性分为“流式”访问一次不用缓存和“持久化”反复访问需要长期驻留然后告诉 CUDA 驱动在 L2 中划出固定比例给持久化数据用。// 1. 查询设备 L2 属性 int device_id 0; cudaDeviceProp prop; cudaGetDeviceProperties(prop, device_id); printf(L2 cache size: %d bytes\n, prop.l2CacheSize); // 2. 设置持久化访问窗口大小设为 L2 的 75% size_t set_aside (size_t)(prop.l2CacheSize * 0.75); cudaDeviceSetLimit(cudaLimitPersistingL2CacheSize, set_aside); // 3. 创建带持久化属性的流 cudaStream_t stream; cudaStreamAttribute attrs; int access_policy_window 0; cudaStreamCreateWithAttributes(stream, cudaStreamAttributeAccessPolicyWindow, attrs);再看内核里怎么标记持久化访问对象的命中区域关键在cudaStreamAttrValue的hitRatio和hitProp字段cudaStreamAttrValue attr; // 使用流式属性命中窗口的比例 attr.accessPolicyWindow.base_ptr (void*)d_data; attr.accessPolicyWindow.num_bytes n * sizeof(float); attr.accessPolicyWindow.hitRatio 1.0f; // 100% 命中窗口 attr.accessPolicyWindow.hitProp cudaAccessPropertyPersisting; attr.accessPolicyWindow.missProp cudaAccessPropertyStreaming; // 把属性绑定到流 cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, attr); // 在该流中启动 kerneld_data 的访问会被 L2 持久化缓存 compute_kernelgrid, block, 0, stream(d_data, result, n); // 用完把窗口重置为正常 attr.accessPolicyWindow.num_bytes 0; cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, attr);逻辑说明accessPolicyWindow里的base_ptr和num_bytes定义了一块 GPU 内存地址范围hitRatio表示访问落在持久化窗口内的概率。hitProp和missProp分别决定命中窗口和落空窗口时的缓存策略。参数说明hitRatio设 1.0 意味着所有对该地址范围的访问都按持久化处理一般用于真正的热点数据如果数据总量比 L2 大很多建议降低hitRatio让部分访问回落到流式属性避免把整个 L2 占死。cudaLimitPersistingL2CacheSize不能超过设备允许的最大值查询方法是cudaDeviceGetLimit设置超限会返回cudaErrorInvalidValue。实践中 L2 持久化适合的典型场景是每次 kernel 只读一小块全局数据比如权重矩阵、查找表但被所有 block 反复访问。这种模式共享内存放不下、全局内存访问又太多L2 持久化是投入产出比最高的解法之一。在数据集合不同、计算强度不同的多个 kernel 交替执行时我遇到过一个问题开了 L2 持久化窗口之后另一个 kernel 的流式数据访问性能反而下降了。原因是持久化窗口占掉的 L2 空间挤占了原本留给流式数据的缓存。后来我把持久化窗口从 75% 调到 50%并给那个 kernel 单独设置了流式属性两个 kernel 的性能才同时恢复。从那以后我每次使用 L2 持久化都会强制走一遍完整流程查设备 L2 大小 → 按实际热点数据量设置窗口 → 在流上绑定访问属性 → 跑完 benchmark 对比开与关的耗时。这套验证法也推荐给你试试做一次就清楚自己的数据模式适不适合这个机制了。希望帮到你。本文还有配套的精品资源点击获取