ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CUDA性能优化实战:从GPU架构到核函数调优的完整指南

CUDA性能优化实战:从GPU架构到核函数调优的完整指南 1. 从一张显卡的“脾气”说起为什么你的CUDA程序跑不快很多人第一次接触CUDA编程心态是这样的CPU上跑个循环要好几秒听说GPU有几千个核心把循环往核函数里一搬性能不得起飞结果一跑发现比CPU还慢甚至慢十倍。这不是GPU不行是你没摸清它的脾气。GPU架构和CPU架构的设计哲学完全不同。CPU像是一个博士团队每个核心都很聪明擅长处理复杂的逻辑分支、乱序执行、大缓存命中GPU像是一支几千人的施工队每个人只会搬砖但胜在人多只要指令统一吞吐量惊人。CUDA编程优化的本质就是让这支施工队高效搬砖——减少沟通成本、避免窝工、让每个人手里都有活干。这篇文章适合谁看如果你写过CUDA核函数但性能不达预期如果你在做深度学习推理加速、图像处理、科学计算或者你只是好奇“GPU到底怎么工作的”这篇内容都能给你一套可落地的方法论。我不会只讲API怎么调而是把GPU架构的物理约束和CUDA代码的优化手段对应起来讲——你知道为什么这么优化比知道怎么优化重要一百倍。全文围绕四个核心问题展开GPU的硬件结构到底长什么样、CUDA编程模型如何映射到硬件、性能瓶颈怎么定位、以及具体的优化手段怎么用。每个部分都会配上我实际踩过的坑和验证过的参数你可以直接抄作业。2. GPU架构拆解从流处理器到内存层级2.1 一颗GPU里面到底有什么把GPU芯片放大看核心计算单元是SMStreaming Multiprocessor流多处理器。一颗现代GPU通常有几十到上百个SM每个SM里面又包含CUDA Core执行整数和浮点运算的基本单元一个SM里通常有64到128个Tensor Core专门做矩阵乘加运算的加速单元深度学习推理的主力RT Core光线追踪专用图形渲染场景才用得上Warp Scheduler指令调度器每个SM有4个左右Register File寄存器堆容量直接决定能同时跑多少线程Shared Memory / L1 Cache可配置的片上高速缓存这里有个关键数字一个SM同一时刻能驻留的线程数是有限的。比如某代架构一个SM最多驻留2048个线程寄存器总量65536个。这意味着如果你的核函数每个线程用32个寄存器那最多只能驻留2048个线程如果用64个寄存器就只能驻留1024个线程。寄存器用量直接决定了Occupancy占用率而Occupancy又直接影响延迟隐藏能力。我见过太多人写核函数时随手声明一堆局部变量编译器一编译每个线程用了80多个寄存器Occupancy掉到25%然后抱怨GPU跑得慢。这不是GPU的锅是你把施工队的人均装备配得太重导致工地站不下几个人。2.2 内存层级为什么你的数据搬运比计算还慢GPU的内存层级是一个金字塔结构越往上越快越小越往下越慢越大层级典型容量典型延迟谁可以访问寄存器每线程几十到255个1个周期仅本线程Shared Memory每SM 48KB-164KB20-30周期同Block内线程L1 Cache与Shared Memory共享30-40周期同SM内线程L2 Cache几MB到几十MB200周期左右全GPUGlobal Memory几GB到几十GB400-800周期全GPU这个表你要刻在脑子里。Global Memory的延迟是寄存器的几百倍如果你的核函数频繁读写全局内存那计算单元大部分时间都在等数据算力再强也白搭。一个生活化类比寄存器是你手边的工具伸手就能拿Shared Memory是工具箱走两步就能取Global Memory是仓库开车去拿一趟要半天。优化的核心思路就是尽量用手边的工具减少去仓库的次数。2.3 Warp与SIMTGPU执行指令的真实方式GPU不是按“线程”调度的而是按Warp调度的。一个Warp包含32个线程这32个线程必须执行相同的指令。如果它们走了不同的分支就会发生Warp Divergence分支发散——两条分支串行执行性能直接减半甚至更多。举个例子核函数里有这么一段if (threadIdx.x % 2 0) { // 分支A16个线程执行 } else { // 分支B16个线程执行 }一个Warp里32个线程16个走A16个走B。硬件会先让走A的16个线程执行走B的16个线程等着然后再反过来。本来一条指令能搞定的事变成了两条吞吐量直接砍半。所以写CUDA核函数时尽量避免Warp内线程走不同分支。如果实在避不开尽量让分支粒度大于32比如按Block分而不是按线程分。3. CUDA编程模型把任务映射到硬件上3.1 Grid、Block、Thread的三层结构CUDA的线程组织是三层Grid包含多个BlockBlock包含多个Thread。这个结构不是随便设计的它直接对应硬件一个Block会被分配到一个SM上执行Block内的线程可以通过Shared Memory通信一个Grid里的多个Block会分散到多个SM上Block之间不能直接通信一个Warp是32个连续Thread硬件调度的基本单位理解这个映射关系你就能明白为什么Block大小要设成32的倍数——如果Block大小是48那硬件会把它拆成两个Warp3216第二个Warp只有16个线程活跃另外16个空转浪费了一半的调度槽位。我通常建议Block大小设为128或256。太小了SM上驻留的Block数量受限Occupancy上不去太大了寄存器压力大而且Block内同步开销增加。256是一个比较稳妥的默认值具体还要看核函数的资源用量。3.2 线程索引计算别在这里翻车线程索引计算是CUDA编程的基本功但也是最容易出错的地方。一个典型的二维索引计算int row blockIdx.y * blockDim.y threadIdx.y; int col blockIdx.x * blockDim.x threadIdx.x;这里有个坑blockIdx和threadIdx的顺序容易搞反。x方向是列连续内存方向y方向是行。如果你把行列搞反了内存访问模式就从连续变成跨步性能直接崩盘。还有一个常见错误边界检查。当矩阵尺寸不是Block大小的整数倍时必须检查索引是否越界if (row height col width) { // 安全访问 }我见过有人忘了边界检查程序在小矩阵上跑得好好的一上大矩阵就段错误。这种bug调试起来很痛苦因为CUDA的报错信息往往不指向真正的问题行。3.3 内存访问模式合并访问是性能的生命线Global Memory的访问是以32字节或128字节为单位的。如果一个Warp内的32个线程访问连续的内存地址硬件可以把这些访问合并成一次大事务效率极高。这就是Coalesced Access合并访问。反过来如果32个线程访问的是跨步的地址比如每隔128字节取一个硬件就得发起32次独立事务带宽利用率降到1/32。这就是Uncoalesced Access性能杀手。看一个矩阵转置的例子。朴素实现__global__ void transposeNaive(float *in, float *out, int w, int h) { int x blockIdx.x * blockDim.x threadIdx.x; int y blockIdx.y * blockDim.y threadIdx.y; if (x w y h) { out[x * h y] in[y * w x]; } }读in的时候同一Warp内线程的y相同、x连续访问in[y*wx]是连续的合并访问没问题。但写out的时候地址是x*hy同一Warp内x连续变化地址跨步为h完全无法合并。结果就是读快写慢整体性能被写操作拖死。解决方案是用Shared Memory做中转先合并读入Shared Memory再从Shared Memory跨步读出写入Global Memory。Shared Memory没有合并访问的概念只要避免Bank Conflict就行。这个后面会详细讲。4. 性能瓶颈定位别猜用工具说话4.1 用nvprof和Nsight Compute找瓶颈优化最忌讳的就是“我觉得这里慢”。你觉得没用得让工具告诉你。NVIDIA提供了两个主力工具nvprof命令行工具快速看核函数耗时、内存吞吐、OccupancyNsight Compute图形化工具能看到每个SM的利用率、Warp Stall原因、内存事务数一个典型的nvprof输出会告诉你Kernel: myKernel Duration: 2.5ms Achieved Occupancy: 25% Memory Throughput: 450 GB/s (peak: 900 GB/s) Compute Throughput: 15%看到这个数据你立刻就知道Occupancy只有25%内存带宽用了一半计算单元基本闲着。瓶颈在延迟隐藏不足——线程太少没法掩盖内存访问的延迟。4.2 常见瓶颈类型速查表现象可能原因排查方向Occupancy低寄存器/Shared Memory用量大减少局部变量调整Block大小内存吞吐低非合并访问、Bank Conflict检查访问模式用Shared Memory中转计算吞吐低指令依赖链长、分支发散增加ILP减少分支核函数耗时波动大资源竞争、调度不均检查Grid/Block配置整体加速比低数据传输占比高用Pinned Memory、异步传输这张表是我自己排查问题时最常用的。拿到一个性能数据先对照这张表定位方向再用工具深入分析。4.3 一个真实的排查案例之前有个图像处理的核函数处理4K图像要80ms目标是要压到10ms以内。用nvprof一看Occupancy12.5%寄存器用量每线程128个内存吞吐200 GB/s问题很明显寄存器用量太高导致Occupancy极低内存延迟完全没法隐藏。怎么改第一步把核函数里的大数组从局部变量改成Shared Memory。局部数组在CUDA里默认放在本地内存其实是Global Memory访问极慢而且占用大量寄存器。第二步把一些常量参数用__constant__内存传递减少寄存器压力。第三步调整Block大小为128让更多Block能驻留。改完之后寄存器用量降到40个Occupancy升到50%耗时降到15ms。再进一步把内存访问改成向量化加载float4耗时降到8ms。这个案例说明一个道理优化是一个迭代过程先定位瓶颈再针对性修改然后重新测量。不要一次改一堆东西否则你根本不知道哪个改动起了作用。5. 核心优化手段从寄存器到全局内存5.1 寄存器优化省着用但别省过头寄存器是GPU上最快的存储但总量有限。每个SM的寄存器数量是固定的比如65536个分给所有驻留线程。所以每个线程用的寄存器越少能同时驻留的线程就越多。减少寄存器用量的几个手段避免在核函数里声明大数组改用Shared Memory用__launch_bounds__限定最大线程数让编译器知道寄存器预算把循环展开的因子调小展开太多会增加寄存器压力用-maxrregcount编译选项强制限制寄存器数量但可能导致Spill这里有个权衡寄存器太少会导致Spill溢出到本地内存反而更慢。所以不要盲目追求低寄存器用量要看Occupancy和Spill的平衡点。一般来说如果Spill的Load/Store指令占比超过5%就说明寄存器压得太狠了。5.2 Shared Memory优化片上缓存的艺术Shared Memory是Block内线程共享的高速缓存延迟只有Global Memory的几十分之一。用得好性能提升立竿见影用得不好Bank Conflict会让你怀疑人生。Shared Memory被分成32个Bank每个Bank宽度4字节。如果同一个Warp内的多个线程访问同一个Bank的不同地址就会发生Bank Conflict访问被串行化。避免Bank Conflict的经典技巧是Padding。比如你有一个32x32的数组存在Shared Memory里按行访问时第0列和第32列会落在同一个Bank。解决办法是声明成float smem[32][33]每行多一个元素这样列访问就错开了Bank。__shared__ float tile[32][33]; // 33而不是32避免Bank Conflict这个技巧在矩阵乘法、转置等场景中非常常用。多出来的那一列不存有效数据只是为了错开Bank。5.3 全局内存优化合并访问与向量化Global Memory的优化核心就两条合并访问和向量化加载。合并访问前面讲过了关键是让同一Warp内的线程访问连续地址。向量化加载则是用float4、int4这样的宽类型一次加载16字节减少内存事务数量。// 标量加载4次事务 float a in[i]; float b in[i1]; float c in[i2]; float d in[i3]; // 向量化加载1次事务 float4 v reinterpret_castfloat4*(in)[i/4];向量化加载要求地址16字节对齐而且数据总量是4的倍数。在图像处理、矩阵运算中这个优化往往能带来20%-30%的带宽提升。5.4 异步传输与流让数据传输和计算重叠CUDA程序的时间往往花在数据传输上而不是计算上。如果你的程序是“传输-计算-传输”的串行模式那GPU计算单元有一半时间在闲着。解决办法是用CUDA Stream和异步内存拷贝让数据传输和核函数执行重叠cudaStream_t stream; cudaStreamCreate(stream); cudaMemcpyAsync(d_in, h_in, size, cudaMemcpyHostToDevice, stream); kernelgrid, block, 0, stream(d_in, d_out); cudaMemcpyAsync(h_out, d_out, size, cudaMemcpyDeviceToHost, stream);配合Pinned Memory页锁定内存传输带宽能比普通内存高一倍以上。Pinned Memory的分配用cudaMallocHost释放用cudaFreeHost。注意Pinned Memory分配过多会拖慢系统整体性能因为它不能被操作系统换出。一般分配几十MB到几百MB就够了不要一次性分配几个GB。6. 实战案例矩阵乘法从朴素到优化6.1 朴素实现为什么它慢得离谱矩阵乘法是CUDA优化的经典案例。朴素实现如下__global__ void matmulNaive(float *A, float *B, float *C, int N) { int row blockIdx.y * blockDim.y threadIdx.y; int col blockIdx.x * blockDim.x threadIdx.x; if (row N col N) { float sum 0.0f; for (int k 0; k N; k) { sum A[row * N k] * B[k * N col]; } C[row * N col] sum; } }这个实现的问题每个线程要读A的一整行和B的一整列Global Memory访问次数是O(N^3)。而且B的访问是跨步的完全无法合并。N1024时这个核函数要跑几百毫秒。6.2 Shared Memory分块优化优化的核心思路是分块Tiling把矩阵分成小块每个Block负责计算一个输出块。Block先把对应的A和B的子块加载到Shared Memory然后从Shared Memory里读取数据进行计算。#define TILE 32 __global__ void matmulTiled(float *A, float *B, float *C, int N) { __shared__ float As[TILE][TILE1]; __shared__ float Bs[TILE][TILE1]; int row blockIdx.y * TILE threadIdx.y; int col blockIdx.x * TILE threadIdx.x; float sum 0.0f; for (int t 0; t N / TILE; t) { As[threadIdx.y][threadIdx.x] A[row * N t * TILE threadIdx.x]; Bs[threadIdx.y][threadIdx.x] B[(t * TILE threadIdx.y) * N col]; __syncthreads(); for (int k 0; k TILE; k) { sum As[threadIdx.y][k] * Bs[k][threadIdx.x]; } __syncthreads(); } if (row N col N) { C[row * N col] sum; } }这个版本把Global Memory访问次数从O(N^3)降到O(N^2/TILE)性能提升几十倍。__syncthreads()是必须的它保证所有线程都加载完Shared Memory后才开始计算以及计算完后再加载下一块。6.3 进一步优化向量化与寄存器分块在Shared Memory分块的基础上还可以做两层优化第一向量化加载。把A和B的加载改成float4减少内存事务数。第二寄存器分块。每个线程计算多个输出元素比如4x4这样能提高计算访存比减少Shared Memory访问次数。这两个优化叠加后矩阵乘法的性能可以接近GPU的理论峰值。具体的代码比较长核心思想就是让每个线程做更多的事减少同步和内存访问开销。6.4 优化效果对比版本N1024耗时相对加速比朴素实现280ms1xShared Memory分块12ms23x分块向量化8ms35x分块向量化寄存器分块5ms56x这个数据是我在某代中端GPU上实测的不同硬件会有差异但趋势是一致的。每一步优化都有明确的理论依据不是瞎调参数调出来的。7. 常见问题与排查技巧实录7.1 核函数启动失败但没报错CUDA的核函数启动是异步的错误不会立即返回。如果你不检查返回值程序可能静默失败。解决办法是在核函数启动后加cudaError_t err cudaGetLastError(); if (err ! cudaSuccess) { printf(Kernel launch failed: %s\n, cudaGetErrorString(err)); }或者在调试阶段用cudaDeviceSynchronize()强制同步让错误立即暴露。7.2 Occupancy上不去怎么办先查寄存器和Shared Memory用量。编译时加--ptxas-options-v编译器会输出每个核函数的资源用量ptxas info: Used 40 registers, 8192 bytes smem, 0 bytes cmem[0]如果寄存器用量超过64考虑用__launch_bounds__限制如果Shared Memory用量大考虑减小TILE大小。但要注意Occupancy不是越高越好有些计算密集型核函数在50% Occupancy下反而比100%快因为每个线程有更多寄存器可用减少了Spill。7.3 结果不对但找不到原因CUDA调试最痛苦的就是结果不对但不知道哪里错了。几个排查手段用cuda-memcheck检查内存越界和竞态条件把核函数改成单线程执行1, 1看结果是否正确在核函数里用printf打印中间结果注意不要打印太多会拖慢程序用__syncthreads()确保同步点正确我遇到过一个经典bugShared Memory加载后忘了__syncthreads()导致部分线程读到旧数据。这种bug在小矩阵上可能碰巧结果正确大矩阵就暴露了。同步点是CUDA编程中最容易出错的地方每写一个Shared Memory操作都要问自己这里需要同步吗7.4 性能优化速查清单问题检查项优化手段内存带宽利用率低访问是否合并调整索引计算用Shared Memory中转Occupancy低寄存器/Shared Memory用量减少局部变量调整Block大小分支发散严重Warp内是否有分支重构逻辑按Block分支数据传输占比高是否用异步传输CUDA Stream Pinned Memory核函数耗时波动是否有原子操作竞争减少原子操作用归约代替Shared Memory慢是否有Bank ConflictPadding调整访问模式这张表建议打印出来贴在显示器旁边每次优化前对照检查一遍。8. 一些个人体会CUDA优化这件事说到底就是理解硬件约束然后顺着硬件的脾气写代码。GPU不喜欢复杂分支你就别写复杂分支GPU喜欢连续访问你就把数据排布成连续的GPU寄存器有限你就省着用。我刚开始学CUDA的时候总想着一步到位写出最优版本结果改了半天性能反而下降。后来学乖了每次只改一个变量改完立刻测量。优化是一个实验科学不是理论推导。你的直觉往往不准工具告诉你的数据才准。还有一个体会是不要过早优化。先把功能跑通再用工具找瓶颈然后针对性优化。我见过有人花一周时间优化一个核函数最后发现它只占总耗时的5%优化到极致也就提升2%。真正的大头在数据传输或者另一个核函数上。最后分享一个实用技巧如果你在做深度学习相关的CUDA优化优先看cuBLAS、cuDNN这些库有没有现成的实现。这些库是NVIDIA工程师调了无数遍的性能通常比你自己写的核函数好。你的优化精力应该花在库覆盖不到的地方而不是重复造轮子。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进