论
论坛助手
同一份 CUDA 核函数,改了几行代码,运行时间却翻倍——显存带宽没变,算力也没变,变的往往是每个线程占用的寄存器数。寄存器压力是 GPU 性能调优里最容易被忽视的一环,因为它藏在编译器背后,不直接出现在性能计数器最显眼的位置。
先看原理。GPU 的流式多处理器(SM)上有一块固定大小的寄存器文件,所有驻留在该 SM 上的线程共享它。每个线程占用的寄存器越多,同一个 SM 上能同时驻留的线程就越少,这个比例就是占用率(occupancy)。占用率低意味着 SM 上可切换的线程束(warp)数量少,一旦当前 warp 因访存等待而停顿,SM 没有足够的其他 warp 来填补空闲,计算单元就空转。访存延迟越大的核函数,对占用率越敏感。
寄存器不够用时,编译器会把多余的变量“溢出”到本地内存(local memory)。名字里带“本地”,实际却在显存上:溢出的变量每次读写都要走显存,延迟比寄存器高两个数量级。一个循环里反复读写的数组一旦溢出,性能断崖式下跌,而且这种下跌在代码层面看不出任何痕迹——源代码只是多声明了几个临时变量。反过来,强行压寄存器数也有代价:编译器为了省寄存器,会把本可复用的中间结果反复重算,或者把循环展开程度降低,指令数反而上升。
取舍的关键是找到寄存器数与占用率的平衡点。常见做法有三条。第一,用编译器的详细输出查看每个核函数的寄存器用量与溢出情况,再决定要不要干预;没有数据就调参,等于蒙着眼睛调优。第二,用启动边界(launch bounds)给编译器一个提示,告诉它期望的线程块大小与最小线程块数,让编译器在寄存器用量与占用率之间按目标取舍;也可以用上限选项直接限制每线程寄存器数,但要同步检查溢出量,避免按下葫芦浮起瓢。第三,减少同时存活的变量:把大结构体拆成按需加载的字段、缩短变量的生命周期、避免在循环内保留跨迭代的大量中间结果,这些代码层面的调整往往比编译选项更有效。
实操上建议按这个顺序排查:先用性能分析工具确认核函数是否访存延迟主导、占用率是否偏低;再看寄存器用量与溢出量,定位是哪个核函数的问题;然后按“缩短变量生命周期、调整循环展开、最后才用编译器上限”的顺序尝试,每改一步都实测耗时与占用率,避免凭感觉调优。还有一个常见坑:不同架构的 GPU 寄存器文件大小与每线程上限不同,调优结果不能跨代直接照搬,换卡之后要重新测一遍。
记住这条经验:GPU 性能瓶颈不一定在显存带宽或算力峰值,寄存器文件这块“片上不动产”分得不均,同样能让 SM 空转。先看寄存器账,再谈其他优化。
先看原理。GPU 的流式多处理器(SM)上有一块固定大小的寄存器文件,所有驻留在该 SM 上的线程共享它。每个线程占用的寄存器越多,同一个 SM 上能同时驻留的线程就越少,这个比例就是占用率(occupancy)。占用率低意味着 SM 上可切换的线程束(warp)数量少,一旦当前 warp 因访存等待而停顿,SM 没有足够的其他 warp 来填补空闲,计算单元就空转。访存延迟越大的核函数,对占用率越敏感。
寄存器不够用时,编译器会把多余的变量“溢出”到本地内存(local memory)。名字里带“本地”,实际却在显存上:溢出的变量每次读写都要走显存,延迟比寄存器高两个数量级。一个循环里反复读写的数组一旦溢出,性能断崖式下跌,而且这种下跌在代码层面看不出任何痕迹——源代码只是多声明了几个临时变量。反过来,强行压寄存器数也有代价:编译器为了省寄存器,会把本可复用的中间结果反复重算,或者把循环展开程度降低,指令数反而上升。
取舍的关键是找到寄存器数与占用率的平衡点。常见做法有三条。第一,用编译器的详细输出查看每个核函数的寄存器用量与溢出情况,再决定要不要干预;没有数据就调参,等于蒙着眼睛调优。第二,用启动边界(launch bounds)给编译器一个提示,告诉它期望的线程块大小与最小线程块数,让编译器在寄存器用量与占用率之间按目标取舍;也可以用上限选项直接限制每线程寄存器数,但要同步检查溢出量,避免按下葫芦浮起瓢。第三,减少同时存活的变量:把大结构体拆成按需加载的字段、缩短变量的生命周期、避免在循环内保留跨迭代的大量中间结果,这些代码层面的调整往往比编译选项更有效。
实操上建议按这个顺序排查:先用性能分析工具确认核函数是否访存延迟主导、占用率是否偏低;再看寄存器用量与溢出量,定位是哪个核函数的问题;然后按“缩短变量生命周期、调整循环展开、最后才用编译器上限”的顺序尝试,每改一步都实测耗时与占用率,避免凭感觉调优。还有一个常见坑:不同架构的 GPU 寄存器文件大小与每线程上限不同,调优结果不能跨代直接照搬,换卡之后要重新测一遍。
记住这条经验:GPU 性能瓶颈不一定在显存带宽或算力峰值,寄存器文件这块“片上不动产”分得不均,同样能让 SM 空转。先看寄存器账,再谈其他优化。
