GPU 监控不只是利用率显存碎片和 PCIe 带宽也在拖你的推理性能一、一张利用率 98% 的卡推理延迟却翻了 3 倍线上出现过这样一个场景NVIDIA-smi 显示 GPU 利用率稳定在 95% 以上Prometheus 告警面板一片绿色但业务侧反馈推理延迟从 80ms 飙到了 260ms。运维第一反应是模型服务出了问题排查三小时无果最后发现是显存碎片率超过 60% 且 PCIe 带宽被打满——这两个指标在常规监控面板上根本不存在。基础设施不需要漂亮话GPU 利用率这个指标在训练场景还算靠谱到了推理场景就是个安慰剂。推理请求的特点是短、碎、并发高CUDA Kernel 的 launch 开销占比远高于训练。利用率高只能说明 GPU 在干活不代表干得高效。真正的 GPU 性能瓶颈往往藏在三个维度计算利用率SM 占用率、显存健康度碎片率与带宽利用率、IO 通路PCIe 带宽与 NVLink 吞吐。只盯一个 GPU Utilization约等于只用 CPU 使用率判断服务器健康——太粗糙了。二、GPU 性能观测的三层模型GPU 内部的数据流动可以抽象为三层管道每一层都有自己的饱和度指标graph TB subgraph 观测层 A[计算层br/SM Occupancy / Tensor Core Utilization] B[显存层br/Memory Bandwidth / Fragmentation] C[IO层br/PCIe Bandwidth / NVLink Throughput] end subgraph 瓶颈检测 D{延迟抖动 阈值?} E{显存碎片率 50%?} F{PCIe 带宽利用率 80%?} end subgraph 根因定位 G[Kernel Launch 开销过大] H[显存分配/释放碎片化] I[模型加载/数据传输瓶颈] end A -- D B -- E C -- F D --|是| G E --|是| H F --|是| I D --|否| J[计算层正常] E --|否| K[显存层正常] F --|否| L[IO层正常]计算层SM OccupancySM 占用率反映每个 Streaming Multiprocessor 上活跃 warp 的比例。推理场景下的典型问题是 Kernel 太小太碎SM 根本塞不满。dcgm-exporter 暴露的DCGM_FI_PROF_SM_OCCUPANCY可以采集这个值。经验阈值推理负载下 SM Occupancy 低于 30% 就值得关注说明 GPU 的计算单元大量空转。显存层Fragmentation显存碎片不像利用率那样直观。当模型服务频繁创建和销毁 CUDA Context比如动态 batching 场景显存分配器会产生大量不连续的空闲块。PyTorch 的torch.cuda.memory_stats()可以拿到allocated_bytes和reserved_bytes的差值这个差值除以reserved_bytes就是碎片率的近似值。碎片率超过 50% 时即使nvidia-smi显示还有 20% 空闲显存大块连续分配也会失败。IO 层PCIe Bandwidth模型从 CPU 内存搬到 GPU 显存走的就是 PCIe 总线。多模型加载、大 batch 数据传输会轻易打满 PCIe 带宽。DCGM_FI_PROF_PCIE_TX_BYTES和DCGM_FI_PROF_PCIE_RX_BYTES配合理论带宽上限PCIe 4.0 x16 ≈ 32GB/s就能算出利用率。三、生产级监控采集代码以下代码基于 Go dcgm-exporter 实现 GPU 三维度指标采集核心是 Prometheus Gauge 的注册与更新package gpu import ( context fmt sync time github.com/NVIDIA/go-dcgm/pkg/dcgm github.com/prometheus/client_golang/prometheus github.com/prometheus/client_golang/prometheus/promauto ) // GPUMetrics 三维度 GPU 指标采集器 type GPUMetrics struct { mu sync.RWMutex // 计算层 smOccupancy *prometheus.GaugeVec // SM 占用率 tensorCoreUtil *prometheus.GaugeVec // Tensor Core 利用率 // 显存层 memBandwidthUtil *prometheus.GaugeVec // 显存带宽利用率 memFragRatio *prometheus.GaugeVec // 显存碎片率 // IO 层 pcieTxUtil *prometheus.GaugeVec // PCIe 发送带宽利用率 pcieRxUtil *prometheus.GaugeVec // PCIe 接收带宽利用率 // DCGM 客户端只采集不负责生命周期由外部注入 collector *dcgm.Collector // PCIe 理论带宽上限 (byte/s)PCIe 4.0 x16 pcieMaxBandwidth float64 } // NewGPUMetrics 创建 GPU 指标采集器 // pcieGen: PCIe 代数 (3, 4, 5), lanes: PCIe 通道数 (x8, x16) func NewGPUMetrics(pcieGen int, lanes int) *GPUMetrics { // PCIe 各代单 lane 带宽 (GB/s)含 128b/130b 编码损耗 laneSpeeds : map[int]float64{3: 0.985, 4: 1.969, 5: 3.938} maxBW : laneSpeeds[pcieGen] * float64(lanes) * 1e9 // 转为 byte/s return GPUMetrics{ smOccupancy: promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: gpu_sm_occupancy_ratio, Help: GPU SM occupancy ratio per device, }, []string{gpu_uuid, gpu_name}, ), tensorCoreUtil: promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: gpu_tensor_core_util_ratio, Help: GPU Tensor Core utilization ratio, }, []string{gpu_uuid, gpu_name}, ), memBandwidthUtil: promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: gpu_mem_bandwidth_util_ratio, Help: GPU memory bandwidth utilization ratio, }, []string{gpu_uuid, gpu_name}, ), memFragRatio: promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: gpu_memory_fragmentation_ratio, Help: GPU memory fragmentation ratio (0-1), }, []string{gpu_uuid, gpu_name}, ), pcieTxUtil: promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: gpu_pcie_tx_bandwidth_util_ratio, Help: PCIe TX bandwidth utilization, }, []string{gpu_uuid, gpu_name}, ), pcieRxUtil: promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: gpu_pcie_rx_bandwidth_util_ratio, Help: PCIe RX bandwidth utilization, }, []string{gpu_uuid, gpu_name}, ), pcieMaxBandwidth: maxBW, } } // Collect 执行一轮指标采集建议每 15s 调用一次 func (g *GPUMetrics) Collect(ctx context.Context) error { g.mu.Lock() defer g.mu.Unlock() // 获取所有 GPU 设备信息 devices, err : dcgm.GetAllSupportedDevices() if err ! nil { return fmt.Errorf(dcgm get devices: %w, err) } for _, device : range devices { gpuUUID : device.UUID gpuName : device.Identifiers.Model // 使用 select 在采集时保持 context 感知避免阻塞过久 select { case -ctx.Done(): return ctx.Err() default: } // 1. 计算层指标采集 smVal, err : dcgm.GetLatestValueForField(gpuUUID, dcgm.DCGM_FI_PROF_SM_OCCUPANCY) if err ! nil { // 单卡采集失败不中断整体流程只记录并跳过 // 防止一块卡的问题导致全集群指标缺失 continue } g.smOccupancy.WithLabelValues(gpuUUID, gpuName).Set(smVal.Float64() / 100.0) tcVal, err : dcgm.GetLatestValueForField(gpuUUID, dcgm.DCGM_FI_PROF_PIPE_TENSOR_ACTIVE) if err nil { g.tensorCoreUtil.WithLabelValues(gpuUUID, gpuName).Set(tcVal.Float64() / 100.0) } // 2. 显存层指标采集 memBWUtil, err : dcgm.GetLatestValueForField(gpuUUID, dcgm.DCGM_FI_PROF_DRAM_UTIL) if err nil { g.memBandwidthUtil.WithLabelValues(gpuUUID, gpuName).Set(memBWUtil.Float64() / 100.0) } // 显存碎片率需要从 PyTorch/CUDA API 侧采集这里用 DCGM 的显存分配/保留量近似 // 注意这是近似值精确碎片率需要进程内采集 allocated, _ : dcgm.GetLatestValueForField(gpuUUID, dcgm.DCGM_FI_DEV_FB_USED) total, _ : dcgm.GetLatestValueForField(gpuUUID, dcgm.DCGM_FI_DEV_FB_TOTAL) if allocated.Float64() 0 total.Float64() 0 { // 碎片率近似已用显存中非活跃部分占比 // 生产上建议配合 PyTorch memory_stats 做二次校验 fragRatio : (allocated.Float64() / total.Float64()) g.memFragRatio.WithLabelValues(gpuUUID, gpuName).Set(fragRatio) } // 3. IO 层指标采集 pcieTx, err : dcgm.GetLatestValueForField(gpuUUID, dcgm.DCGM_FI_PROF_PCIE_TX_BYTES) if err nil g.pcieMaxBandwidth 0 { g.pcieTxUtil.WithLabelValues(gpuUUID, gpuName).Set( pcieTx.Float64() / g.pcieMaxBandwidth, ) } pcieRx, err : dcgm.GetLatestValueForField(gpuUUID, dcgm.DCGM_FI_PROF_PCIE_RX_BYTES) if err nil g.pcieMaxBandwidth 0 { g.pcieRxUtil.WithLabelValues(gpuUUID, gpuName).Set( pcieRx.Float64() / g.pcieMaxBandwidth, ) } } return nil } // Run 启动周期性采集循环由调用方管理 goroutine 生命周期 // stopCh 关闭时优雅退出 func (g *GPUMetrics) Run(ctx context.Context, interval time.Duration, stopCh -chan struct{}) error { ticker : time.NewTicker(interval) defer ticker.Stop() for { select { case -stopCh: return nil case -ctx.Done(): return ctx.Err() case -ticker.C: if err : g.Collect(ctx); err ! nil { // 采集异常不退出循环防止瞬时故障导致监控中断 // 生产上建议在此处接入告警通道 } } } }对应的 Prometheus 告警规则groups: - name: gpu_health rules: # 显存碎片率告警超过 50% 意味大块分配可能失败 - alert: GPUMemoryFragmentationHigh expr: gpu_memory_fragmentation_ratio 0.5 for: 5m labels: severity: warning annotations: summary: GPU {{ $labels.gpu_uuid }} 显存碎片率超过 50% description: 当前碎片率 {{ $value | humanizePercentage }}建议触发显存整理或重启服务 # PCIe 带宽利用率告警超过 80% 可能拖着推理延迟 - alert: GPUPCIeBandwidthSaturation expr: gpu_pcie_tx_bandwidth_util_ratio 0.8 or gpu_pcie_rx_bandwidth_util_ratio 0.8 for: 3m labels: severity: warning annotations: summary: GPU {{ $labels.gpu_uuid }} PCIe 带宽利用率超过 80% # SM 占用率过低推理 Kernel 太碎太小 - alert: GPULowSMOccupancy expr: gpu_sm_occupancy_ratio 0.3 for: 10m labels: severity: info annotations: summary: GPU {{ $labels.gpu_uuid }} SM 占用率低于 30%计算单元利用率不足四、三维度监控的成本与边界采集成本dcgm-exporter 每 15 秒采集一次 GPU 指标是业界常见频率。但三维度全部打开后每张卡新增约 8 个 Gauge 指标100 张卡集群就是 800 个时间序列。按 Prometheus 单样本 1.2 byte 计算14 天保留需要约 1.3GB 存储。对于千卡集群需要考虑 Remote Write 到 VictoriaMetrics 等长期存储方案。SM Occupancy 的误报陷阱SM 占用率低不代表一定有问题。某些模型如小 batch 的 embedding 查询天然就是 Kernel 碎片化的。这种情况下SM Occupancy 低是预期行为不应触发告警。正确的做法是结合延迟分位数来判断SM 占用率低 P99 延迟高 有问题SM 占用率低 延迟正常 接受。碎片率的近似误差上述代码用已用显存占比近似碎片率真实碎片率需要进程内通过 PyTorch 的torch.cuda.memory_stats()采集allocated_bytes和reserved_bytes的差值。近似值和真实值之间可能有 20%-30% 的偏差。如果需要精确碎片监控必须在推理服务内埋点通过 Pushgateway 或 OTLP 上报。PCIe 带宽阈值需要按机型校准A100 的 PCIe 4.0 x16 理论带宽 32GB/s但实际可达上限受 NUMA 拓扑、CPU 型号影响。建议先跑一轮dcgmi diag拿到实际峰值再将告警阈值设为实际峰值的 80%。NVLink 的补充价值多卡推理场景下NVLink 带宽是比 PCIe 更关键的瓶颈。如果模型做了张量并行Tensor ParallelismNVLink 的吞吐直接决定跨卡通信延迟。DCGM_FI_PROF_NVLINK_TX_BYTES和DCGM_FI_PROF_NVLINK_RX_BYTES应该一并采集。五、总结GPU 监控从单一利用率升级到三维度观测核心要做的就三件事补上 SM Occupancy通过DCGM_FI_PROF_SM_OCCUPANCY判断计算单元是否被充分利用。阈值设 30%低于阈值且延迟劣化时触发排查。盯紧显存碎片率用allocated / reserved的差值衡量碎片化程度。超过 50% 时优先考虑触发显存整理或重启推理服务而非盲目加卡。监控 PCIe/NVLink 带宽采集收发两个方向的带宽利用率。超过实际峰值 80% 时告警落地方向是优化模型加载策略或升级互联拓扑。三个维度缺一不可只盯利用率就是在用上世纪的方式管今天的 GPU 集群。