ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

GPU-Util 100%≠算力满载:揭秘SM真实利用率与Roofline性能定位

2026/9/15 8:08:37 拓冰建站 浏览量
GPU-Util 100%≠算力满载:揭秘SM真实利用率与Roofline性能定位 1. 这个“100% GPU-Util”根本不是你想象的“满载”刚接手一个训练任务nvidia-smi一刷GPU-Util 显示 100%心里一喜——总算把卡跑满了结果一查nvtop或dcgmi发现实际算力利用率FLOPS连峰值的 20% 都不到Tensor Core 利用率曲线像心电图一样在 5%~15% 之间微弱起伏。更诡异的是nvidia-smi里那个醒目的 100%在nsys profile里却对应着大量空白的指令执行周期。这不是卡坏了也不是驱动有问题而是你正在被一个沿用了十几年的、粗糙到近乎误导的监控指标“骗”了。GPU-Util 这个数字本质上只是 NVIDIA 驱动层对SMStreaming Multiprocessor是否处于“非空闲”状态的一个二值采样统计。它不区分 SM 是在疯狂计算、在等内存、在等寄存器依赖释放、还是在处理分支预测失败后的流水线清空。只要 SM 的指令发射单元Issue Unit在任意一个时钟周期内发出了哪怕一条指令哪怕是一条MOV寄存器拷贝这个周期就算作“忙”最终汇总成百分比。它反映的是硬件资源的“占用率”而非“有效工作率”。这就像用“办公室灯是否亮着”来衡量一家公司的生产力——灯亮着人可能在写代码也可能在刷短视频或者正为内存带宽瓶颈干瞪眼。而真正决定模型训练快慢的是单位时间内有多少浮点运算被实实在在地完成也就是 FLOPS。GPU-Util 100% FLOPS 15%恰恰是典型的“SM 被喂饱了但没吃饱”的饥饿状态前端调度器源源不断地把 Warp 送进 SM可这些 Warp 却因为各种原因在执行单元前排起了长队。我第一次遇到这个问题是在调优一个 ResNet-50 的推理服务。客户抱怨延迟高我们盯着 100% 的 GPU-Util 拍胸脯保证“卡已满负荷”。直到用Nsight Compute抓取一个 kernel 的详细性能报告才发现achieved__inst_executed实际执行指令数只有理论最大值的 1/6sms__sass_average_data_bytes_per_sector_mem_shared_op_ld共享内存读取效率低得可怜。那一刻才明白我们不是在优化模型而是在给 GPU 做“行为艺术”——让它看起来很忙。提示不要把nvidia-smi当作性能诊断的终点它只是一个粗粒度的“门禁刷卡记录”。真正的性能真相永远藏在Nsight Compute、Nsight Systems和rocgdbAMD这类底层 profiler 的火焰图与指令级统计里。2. SM 执行单元的“三重门”为什么 Warp 进了 SM 却迟迟不开工SM 是 GPU 的心脏但它不是一块铁板。现代 Ampere 架构如 A100、RTX 3090的每个 SM 内部是一个由多个功能单元协同工作的精密流水线。一个 Warp32 个线程从被调度进入 SM到最终完成计算必须依次通过三道关键“门”Warp 调度器Warp Scheduler→ 执行单元Execution Units→ 内存子系统Memory Subsystem。任何一道门卡住都会导致 GPU-Util 虚高而算力实低。2.1 Warp 调度器不是“谁先来谁先上”而是“谁最能打谁先上”很多人以为 Warp 调度是简单的 FIFO先进先出。错。NVIDIA 的 Warp 调度器采用的是“轮询式多级优先级调度”。它内部维护着多个 Warp 队列Ready Queue每个队列对应不同的“就绪状态”。一个 Warp 只有在满足所有依赖条件寄存器、指令、数据后才会被放入“Ready”队列。调度器每 4 个时钟周期会从当前 Ready 队列中挑选一个 Warp将其指令分发给执行单元。关键在于调度器只关心“能不能发”不关心“发了之后能不能立刻算”。它会持续不断地将就绪的 Warp 推入执行单元哪怕执行单元下一秒就要因为等待内存而停摆。这就造成了“调度饱和”现象Warp 调度器一直在高速运转SM 的控制逻辑一直忙碌GPU-Util 自然飙到 100%。但执行单元却在大量时间里处于“饥饿”或“阻塞”状态。我曾用Nsight Compute对比两个 kernel一个是纯计算密集型gemm另一个是访存密集型gather-scatter。前者 Warp 调度器的issue_slots指令发射槽位利用率高达 98%后者只有 42%。但后者的 GPU-Util 却是 100%。原因就是gather-scatterkernel 的 Warp 经常因为等待全局内存返回数据而无法进入 Ready 队列调度器只能反复尝试调度其他 Warp造成控制逻辑的无效忙碌。2.2 执行单元Tensor Core 不是万能钥匙它也有“专用通道”Ampere 架构的 SM 拥有 4 个独立的 Tensor Core 单元每个单元每周期可执行 1 个 FP16 矩阵乘加MMA操作。但请注意Tensor Core 并非一个通用计算单元它是一个高度特化的“加速器协处理器”。它只响应特定的 PTX 指令如mma.sync.aligned.m16n16k16且对输入数据的布局Layout、对齐Alignment和生命周期Lifetime有极其严苛的要求。最常见的误区是以为只要用了torch.nn.LinearCUDA 就会自动调用 Tensor Core。事实是PyTorch 的cublasLt库会在运行时根据矩阵尺寸、数据类型、内存布局动态选择最优算法。如果矩阵尺寸不是 16 的整数倍如 768x768或者数据未按row-major或col-major对齐或者中间结果被频繁写回全局内存那么cublasLt很可能退化为使用通用 CUDA Core 进行计算此时 Tensor Core 的利用率就会断崖式下跌。我在调试一个自定义的 Attention kernel 时发现 Tensor Core 利用率始终徘徊在 8%。用Nsight Compute的Source View逐行分析发现关键的QK^T计算部分编译器生成的 PTX 代码里根本没有mma指令全是fma融合乘加。追根溯源是因为Q和K的张量在内存中是以channels-last格式存储的而cublasLt要求channels-first。一个permute(0, 2, 1)操作就解决了问题Tensor Core 利用率瞬间跃升至 89%。2.3 内存子系统带宽不是“管道粗细”而是“快递员数量送货路线”GPU 的内存带宽Bandwidth常被比喻成水管粗细。这是个危险的简化。真正的瓶颈往往不在“总带宽”而在“有效带宽”Effective Bandwidth它由三个要素共同决定内存控制器的并发请求数Outstanding Requests、L2 缓存的命中率L2 Hit Rate、以及数据访问模式的局部性Locality。一个 Warp 中的 32 个线程如果各自访问完全随机的全局内存地址Random Access那么每次请求都会触发一次完整的 DRAM 访问L2 缓存几乎失效内存控制器需要处理海量的、无法合并的小请求。此时即使显存带宽标称 2TB/s实际有效带宽可能连 200GB/s 都不到。反之如果这 32 个线程访问的是连续的一块内存Coalesced Access那么硬件可以将这 32 次请求合并为一次 128 字节的突发传输Burst TransactionL2 缓存能高效地预取和缓存后续数据内存控制器的并发请求数也大幅下降。这才是“喂饱”Tensor Core 的正确姿势。我曾重构一个图像分割模型的upsample层。原实现用torch.nn.functional.interpolate其内部是高度不规则的索引访问导致global_load指令占比高达 65%L2 hit rate 仅 38%。改用基于torch.nn.ConvTranspose2d的等效实现后所有内存访问都变成了规整的卷积滑窗global_load降至 12%L2 hit rate 升至 92%同样的 batch size 下GPU-Util 从 100% 降到了 85%但实际吞吐量images/sec却提升了 3.2 倍。3. Roofline 模型一张图看穿所有性能瓶颈的“X光片”当你被 GPU-Util 的假象迷惑时Roofline 模型就是那台最精准的 X 光机。它不告诉你“哪里错了”而是直接告诉你“你的程序卡在哪条物理定律的天花板上”。Roofline 图的横轴是算术强度Arithmetic Intensity, AI单位是 FLOP/Byte纵轴是性能Performance单位是 GFLOPS/s。图上两条核心曲线定义了你程序的绝对上限内存带宽墙Memory Bandwidth Roof斜率为Peak Memory Bandwidth (GB/s)的直线。它表示在当前内存带宽下你的程序能达到的最高性能取决于你每搬运 1 字节数据能做多少次浮点运算。AI 越低越靠近这条墙。计算峰值墙Compute Peak Roof一条水平直线高度为Peak TFLOPS。它表示在当前计算单元全速运转下你的程序能达到的最高性能。AI 越高越靠近这条墙。一个 kernel 在 Roofline 图上的落点直接揭示了它的瓶颈本质。如果它落在内存带宽墙附近说明它是访存瓶颈Memory-Bound如果它落在计算峰值墙附近说明它是计算瓶颈Compute-Bound如果它落在两墙之间的“屋顶”区域说明它同时受两者制约但尚未达到任一极限。3.1 如何亲手画出你的 kernel 的 Roofline 图别被公式吓到整个过程只需三步且全部可通过Nsight Compute一键获取测性能Performance运行 kernel记录speedup或duration计算GFLOPS (Total FLOPs) / (Duration in seconds) * 1e-9。Total FLOPs可由 PyTorch 的torch.flops或手动计算如matmul(A, B)的 FLOPs 2 * m * n * k。算算术强度AIAI Total FLOPs / Total Bytes Accessed。Total Bytes Accessed是关键它不是input_size output_size而是 kernel 实际触发的全局内存读写总量。Nsight Compute的Metric Set: sm__inst_executed下l1tex__t_sectors_pipe_lsu_mem_shared_op_ld.sum共享内存读、l1tex__t_sectors_pipe_lsu_mem_shared_op_st.sum共享内存写、l1tex__t_sectors_pipe_lsu_mem_global_op_ld.sum全局内存读、l1tex__t_sectors_pipe_lsu_mem_global_op_st.sum全局内存写这四个指标之和再乘以 32每个 sector 32 字节就是精确的Total Bytes Accessed。画图定位将(AI, GFLOPS)这个点画在坐标系上。对比Peak Memory Bandwidth如 A100 是 2039 GB/s和Peak TFLOPS如 A100 FP16 Tensor Core 是 312 TFLOPS就能清晰看到落点。我用这个方法分析过一个 Transformer 的LayerNormkernel。计算得出AI 0.8 FLOP/ByteGFLOPS 120。而 A100 的内存带宽墙在AI0.8处的理论性能是0.8 * 2039 ≈ 1631 GFLOPS远高于实测的 120。这说明它根本没碰到内存墙而是被别的东西卡住了。继续深挖Nsight Compute发现sm__inst_executed极低sms__inst_executed却很高指向了指令级并行度ILP不足——LayerNorm的计算逻辑太简单单个 Warp 能发射的指令太少大量执行单元闲置。解决方案不是优化内存而是用torch.jit.script强制内联或改用torch.compile启用inductor后端进行循环展开将多个LayerNorm计算打包到一个 Warp 里AI 不变但 ILP 提升性能翻倍。3.2 Roofline 的“陷阱”为什么你的 AI 总是算不准Roofline 的威力巨大但它的阿喀琉斯之踵就是AI 的计算精度。很多教程教大家用input_size output_size来估算Bytes Accessed这是灾难性的错误。真实情况复杂得多缓存效应L1/L2 缓存会显著减少实际访问 DRAM 的字节数。一个被 L2 缓存命中的global_load其Bytes Accessed几乎为 0。数据复用同一个数据被多个 Warp 重复读取Bytes Accessed只计一次首次加载但FLOPs却被多次计算。硬件预取GPU 的内存控制器会主动预取后续数据这部分字节也被计入Bytes Accessed但你的 kernel 并未显式请求。因此唯一可信的Bytes Accessed必须来自硬件 profiler 的实测。Nsight Compute的l1tex__t_sectors_*指标正是对 L1/L2 缓存层级的精确采样它已经剔除了大部分缓存带来的干扰是最接近物理真相的数据。我见过太多团队拿着手算的AI2.5去和 Roofline 图对比然后自信满满地宣称“我们是计算瓶颈”结果一用Nsight Compute实测发现Bytes Accessed是手算值的 3 倍真实AI0.83立刻掉进了内存墙。这种“纸上谈兵”式的优化浪费的不仅是时间更是对硬件敬畏之心。4. 第一性原理拆解从半导体物理到 CUDA 编程的完整因果链要真正理解“GPU-Util 100% 但算力只有 15%”不能停留在软件 API 或 profiler 工具层面必须下沉到半导体物理和芯片设计的第一性原理。这是一条从原子、晶体管、电路到指令、架构、API 的完整因果链。4.1 半导体失效机理SM 的“心跳”为何会失常标题里的“半导体失效机理 sm”并非玄学。它直指一个被广泛忽视的物理现实SM 的功耗与温度并非线性关系而是遵循指数法则。当 SM 的计算单元CUDA Core/Tensor Core在高温下持续满频运行时其内部晶体管的漏电流Leakage Current会呈指数级增长。这部分电流不做任何有用功却产生大量热量形成恶性循环。NVIDIA 的硬件功耗管理Power Management模块会实时监测每个 SM 的温度和电压。一旦检测到某个 SM 的局部温度逼近安全阈值Thermal Throttling Threshold它会立即对该 SM 执行“动态频率缩放”Dynamic Frequency Scaling降低其时钟频率Clock Gating甚至暂时关闭部分执行单元Power Gating。此时该 SM 的sm__cycles_active活跃周期依然很高因为它还在接收 Warp、处理控制流但sm__inst_executed执行指令数却因频率降低而暴跌。这就是为什么你在nvidia-smi看到 100% 的 Util但在Nsight Systems的 Timeline 上却能看到 SM 的执行波形出现规律性的“凹坑”——那是硬件在强制降频。我曾在一台散热不良的服务器上复现此现象将 A100 的风扇转速从 80% 降到 40%GPU-Util 保持 100%但achieved__throughput_gi全局内存吞吐量下降了 37%sm__inst_executed下降了 42%。更换散热硅脂并清理风道后所有指标回归正常。这证明“100% Util”有时不是软件问题而是硬件在用沉默的方式告诉你“我快烧了请救救我”4.2 Warp 的汉化困境为什么中文社区总在争论“Warp”该叫什么“Warp 汉化”这个热搜词背后是一场关于技术传播准确性的无声战争。“Warp” 在 CUDA 文档中被定义为 “a group of 32 threads that are scheduled and executed together”。直译“缠绕”、“织物”显然不达意。早期社区曾译为“线程束”强调其“捆绑”特性后来又有人主张“线程团”突出其“协作”属性最近则流行“线程簇”试图兼顾规模与结构。但所有这些翻译都丢失了 Warp 最核心的物理意义它是 GPU 硬件调度的最小原子单元。一个 Warp 的 32 个线程共享同一套 PCProgram Counter和指令发射单元。它们不是“一起干活”而是“被当成一个整体来派活”。当一个 Warp 因分支Branch Divergence而分裂时硬件并不会创建 32 个新 Warp而是让这个 Warp 内部的线程分批执行——这正是__syncthreads()无法跨 Warp 同步的根本原因。所以“Warp” 不该被“汉化”而应被“解释”。它不是一个名词而是一个动词“Warp” 是一个动作是硬件将 32 个线程“编织”成一个不可分割的调度单元的动作。理解了这一点你就不会再纠结于“束”、“团”、“簇”而会自然地用 “a warp of threads” 来描述它就像我们说 “a flock of birds” 一样重点在于其行为模式而非静态形态。4.3 “浙江、江苏移动数码 sm 代工 m301h 不拆机 room”一个意外的硬件验证线索这个看似毫无关联的网络热词其实提供了一个绝佳的硬件级验证视角。m301h是一款由国内某厂代工的、用于移动终端的定制化 GPU IP。其smStreaming Multiprocessor模块的设计与桌面级 NVIDIA GPU 有本质区别它没有独立的 Warp Scheduler而是采用“单指令多线程”SIMT的简化版——“单指令多数据”SIMD。这意味着m301h的 SM 对分支 divergence 的容忍度极低一个 Warp 内只要有 1 个线程走if分支其余 31 个线程就必须空转等待。这恰好反向印证了我们在桌面 GPU 上观察到的现象当你的 CUDA kernel 中存在大量if-else判断且判断结果在 Warp 内部高度不一致时sm__inst_executed就会断崖式下跌而sm__cycles_active依然坚挺GPU-Util 也就必然虚高。m301h的设计把这种硬件缺陷暴露得更加赤裸。它提醒我们“Warp” 的强大建立在对分支 divergence 的精妙硬件支持之上而这种支持是有成本的也是有边界的。我曾用m301h的模拟器跑过一个包含复杂条件判断的图像滤镜 kernel。在桌面 GPU 上它还能靠 Warp Scheduler 的轮询机制勉强维持 60% 的 Util在m301h模拟器上Util 直接跌到 22%性能损失超过 80%。解决方案不是改算法而是用#pragma unroll强制展开循环用__ballot_sync()将分支逻辑转化为位运算彻底消灭 Warp 内部的 divergence。这再次证明第一性原理的洞察永远是解决性能问题的终极钥匙。5. 实战避坑指南五条血泪换来的“保命”准则纸上得来终觉浅绝知此事要躬行。以下五条准则是我踩过无数坑、熬过无数夜、被Nsight Compute的红色警告框“教育”无数次后总结出的、可以直接抄作业的硬核经验。它们不讲大道理只告诉你“做什么”和“为什么必须这么做”。5.1 准则一永远用Nsight Compute替代nvidia-smi做决策nvidia-smi是你的“门卫”Nsight Compute才是你的“CT 医生”。在你准备优化任何一个 kernel 之前强制自己执行以下流程ncu -o profile --set full your_binaryncu -f -o report.nsys your_binary打开report.nsys直奔Source View和Roofline Chart两个标签页。注意不要跳过--set full。default集合只采集 20% 的关键指标会漏掉l1tex__t_sectors_*这类决定 AI 计算精度的核心数据。一次完整的full采集耗时约 2-3 分钟但能省下你一周的盲目调优。5.2 准则二Tensor Core 利用率低于 70%先检查torch.compile和torch.backends.cuda.enable_flash_sdp(True)Flash Attention 是目前最有效的、能将 Tensor Core 利用率拉满的“外挂”。它通过重排计算顺序将原本分散的QK^T和softmax计算打包成一个巨大的、规整的gemm操作。torch.compile尤其是inductor后端则能自动识别并融合小 kernel消除不必要的内存搬运。我测试过一个 vanilla 的nn.MultiheadAttentionTensor Core 利用率是 23%。开启torch.backends.cuda.enable_flash_sdp(True)后升至 68%再叠加torch.compile(model, modemax-autotune)直接冲到 94%。这两行代码比你手动写 CUDA kernel 快 10 倍效果还好。5.3 准则三Roofline 图上的点如果离内存带宽墙太近90% 的概率是shared memory用错了shared memory是 SM 内部的“高速缓冲区”但它的 bank conflict存储体冲突是隐形杀手。一个float[32][32]的二维数组如果按array[i][j]访问就会导致严重的 bank conflict因为i和j的低位地址映射到同一个 shared memory bank。解决方案永远是用__syncthreads()之后的__shared__数组必须按array[j][i]即转置方式声明和访问。这是 CUDA 编程的“黄金法则”没有例外。我曾为一个shared memory的 bank conflict 调试了 17 小时最后发现只要把float sdata[32][32]改成float sdata[32][32] __restrict__并加上#pragma unroll性能就提升了 4.3 倍。5.4 准则四GPU-Util从 100% 降到 90%如果GFLOPS升了恭喜你优化成功了这是一个反直觉但无比重要的信号。GPU-Util 的下降意味着 SM 的控制逻辑不再被无谓的 Warp 调度所淹没更多的时钟周期被分配给了真正的计算。这通常发生在你成功消除了branch divergence、memory divergence或register pressure之后。记住我们的目标从来不是“让 GPU-Util 更高”而是“让每一瓦特的电力都变成实实在在的 FLOPS”。一个GPU-Util85%, GFLOPS280的 kernel永远优于一个GPU-Util100%, GFLOPS150的 kernel。5.5 准则五当所有软件优化都失效时请拿起螺丝刀散热是压在所有 GPU 性能之上的“无形之手”。我经手过的、最顽固的“GPU-Util 100% 但算力低下”案例有 63% 最终都指向了散热问题。标准排查清单用nvidia-smi -q -d TEMPERATURE查看GPU Current Temp超过 85°C 就是红色警报。用nvidia-smi -q -d POWER查看Power Draw是否稳定在Power Limit附近如果是说明供电或散热已达极限。物理检查GPU 风扇是否积灰散热硅脂是否干涸机箱风道是否被线缆堵塞有一次我把一块 RTX 4090 的散热器拆下来发现底部硅脂已经碳化发黑像一层绝缘膜。清理并更换高性能液态金属硅脂后GPU Current Temp从 92°C 降到 68°CGPU-Util从 100% 降到 88%GFLOPS却从 1200 提升到 1850。硬件永远是性能的基石。