一个图像处理的 CUDA 内核,循环体只有六行,加了一行 #pragma unroll 之后,跑分反而慢了四成。加的时候没人看寄存器用量,加完也没人回头看,直到换了显卡重新测才露出来。
它让编译器做什么
#pragma unroll 是给编译器的建议,让循环体复制若干份,减少计数和跳转的开销。不带数字表示尽量全展开,带数字就展开指定次数,比如 #pragma unroll 4,写在 for 循环的上方一行。反过来,#pragma unroll 1 表示禁止展开,有些循环禁掉之后更快,因为指令缓存压力小了。
什么情况下值得加
循环次数固定且不大、循环体里没有分支、每次迭代访问连续内存,这几种展开确实能提高指令级并行。nvcc 对次数已知的小循环本来就会自动展开,手动加往往只是把默认行为写明而已。
什么情况下帮倒忙
展开后寄存器用量上去,占用率掉下来,延迟就藏不住了。编译时加 -Xptxas -v,能看到每个线程用多少寄存器和有没有本地内存溢出,数字明显涨了一截就该收手。全展开一个大循环还会让指令缓存频繁换页,取指变慢。
C 和 C++ 里,GCC 从 8 开始支持 GCC unroll 写法,Clang 也有对应指令,MSVC 走的是 loop 那套,不能直接照搬。跨编译器时把这行包进宏里,别硬编码在业务代码中。同一份源码在两种编译器上表现可能完全相反,各测一遍再定。
先量后改,别凭感觉
同一段代码在不同显卡上的最优展开次数不一样,这张卡上跑得好的参数,换一张可能就退步。用 Nsight Compute 看占用率和每周期指令数,再对比不同展开次数下的耗时,把结果记在代码注释旁边。
一次只改一个变量,展开次数和块大小同时动,出了问题分不清是谁的原因。循环里带 if 判断或者边界检查的,展开后分支变多,收益常常抵不过代码膨胀。老代码里手写过八次展开的,可以试着交回编译器,现代版本的自动展开已经不差。定下来的参数写进构建脚本,靠口头传递迟早丢。
判断办法很土但有效:改完跑基准,同时看寄存器和耗时两个数。只快了一点点,寄存器却涨了一截,就别留。