GPU利用率详解
一、GPU利用率是什么?
GPU利用率(GPU-Util)是指GPU计算单元的使用率,反映了GPU的算力被利用了多少。
nvidia-smi 输出示例: +-----------------------------------------------------------------------------+ | GPU Name Utilization Memory-Usage | |=============================================================================| | 0 RTX 3080 95% 4500MB / 10240MB | +-----------------------------------------------------------------------------+ ↑ 这个就是GPU利用率GPU内部结构(简化)
GPU = CUDA Cores(计算单元) + 显存(存储) + 缓存(Cache) ↑ ↑ ↑ 负责计算 负责存数据 加速数据访问二、GPU利用率 vs 内存读取的关系
你的理解部分正确!让我详细解释:
场景1:计算密集型操作(高GPU利用率)✅
# 标准卷积:大量浮点运算 y = Conv2d(x) # GPU利用率: 90-95% # 执行过程: 1. 从显存读取数据 x (很快,~1ms) ↓ 2. CUDA Cores疯狂计算 (主要时间,~8ms) ↓ 3. 写回结果到显存 (很快,~1ms) 总时间: 10ms,其中8ms在计算 GPU利用率: 8/10 = 80%+ ✅特点:
- 计算量 >> 内存访问量
- CUDA Cores一直在忙
- GPU利用率高
场景2:内存密集型操作(低GPU利用率)❌
# torch.roll:纯内存拷贝 y = torch.roll(x, shifts=1, dims=2) # GPU利用率: 20-30% # 执行过程: 1. 从显存读取数据 x (慢,~5ms) ↓ 2. 重新排列内存布局 (几乎没计算,~0.1ms) ↓ 3. 写回结果到显存 (慢,~5ms) 总时间: 10ms,其中0.1ms在计算 GPU利用率: 0.1/10 = 1% ❌特点:
- 内存访问量 >> 计算量
- CUDA Cores大部分时间在等待数据
- GPU利用率极低
三、为什么内存读取会降低GPU利用率?
关键概念:计算/访存比(Compute to Memory Access Ratio)
计算/访存比 = 浮点运算次数 / 内存访问字节数示例1:矩阵乘法(高计算/访存比)
# C = A @ B,其中 A, B 都是 1024×1024 A = torch.randn(1024, 1024) # 4MB B = torch.randn(1024, 1024) # 4MB C = A @ B # 计算量: FLOPs = 2 × 1024³ ≈ 2.15 GFLOPs # 内存访问: 读取A: 4MB 读取B: 4MB 写入C: 4MB 总计: 12MB # 计算/访存比: 2.15 GFLOPs / 12MB = 179 FLOPs/Byte ✅ 很高! # 结果:GPU利用率 90%+示例2:torch.roll(低计算/访存比)
# 移位操作 x = torch.randn(8, 32, 224, 224) # 64MB y = torch.roll(x, shifts=1, dims=2) # 计算量: FLOPs = 0(没有浮点运算!) # 内存访问: 读取x: 64MB 写入y: 64MB 总计: 128MB # 计算/访存比: 0 FLOPs / 128MB = 0 FLOPs/Byte ❌ 太低! # 结果:GPU利用率 10-20%四、GPU内存层级与性能
GPU内存层级(从快到慢)
寄存器 Register (最快) ↓ 共享内存 Shared Memory (~100x 比显存快) ↓ L2 缓存 Cache (~10x 比显存快) ↓ 显存 Global Memory (最慢,但容量大)好的内存访问模式(高GPU利用率)
# 标准卷积:数据局部性好 for h in range(H): for w in range(W): # 访问 x[h-1:h+2, w-1:w+2] 的3×3邻域 # 相邻数据连续存储,L2 Cache命中率高 ✅ GPU利用率: 90%+坏的内存访问模式(低GPU利用率)
# torch.roll:随机访问 for i in range(H): y[i] = x[(i+shift) % H] # 跳跃访问 # Cache命中率低,频繁访问慢速显存 ❌ GPU利用率: 20-30%五、实际案例分析
UNet(高GPU利用率 90%)
时间线: ████████████ Conv1计算 (CUDA Cores忙碌) ████████████ Conv2计算 (CUDA Cores忙碌) ████████████ Conv3计算 (CUDA Cores忙碌) ... 特点: - 连续的大量计算操作 - 内存访问有规律,Cache命中率高 - CUDA Cores几乎一直在工作Rolling-UNet(低GPU利用率 25%)
时间线: ██░░░░░░░░░░ roll1 (内存拷贝,CUDA Cores空闲) ██░░░░░░░░░░ roll2 (内存拷贝,CUDA Cores空闲) ██░░░░░░░░░░ roll3 (内存拷贝,CUDA Cores空闲) ████████████ Conv (CUDA Cores忙碌,但只占20%时间) ██░░░░░░░░░░ roll4 (又开始空闲...) ... 特点: - 大量时间在等待内存拷贝 - 真正计算的时间很少 - CUDA Cores大部分时间空闲六、如何判断是计算瓶颈还是内存瓶颈?
方法1:看GPU利用率
nvidia-smi dmon -s u # 输出: # gpu pwr temp sm mem enc dec # 0 80 65 95 60 0 0 # ↑ ↑ # GPU利用率 显存带宽利用率 # 判断: sm高(>80%) → 计算瓶颈 ✅ 正常 sm低(<50%) → 可能是内存瓶颈 ❌方法2:PyTorch Profiler
import torch.profiler as profiler with profiler.profile( activities=[profiler.ProfilerActivity.CUDA], with_stack=True ) as prof: output = model(images) print(prof.key_averages().table(sort_by="cuda_time_total")) # 输出示例: # Name Self CUDA time # of Calls # --------------------------------------------------- # aten::roll 25.3ms 60 ← 罪魁祸首! # aten::cudnn::convolution 5.2ms 10方法3:计算理论利用率
# 假设 RTX 3080: 理论算力 = 29.77 TFLOPS (FP32) # 测量你的模型: 模型GFLOPs = 83.8G 实际延迟 = 8.86ms # 计算实际算力: 实际算力 = 83.8G / 0.00886s = 9.46 TFLOPS # GPU利用率: 利用率 = 9.46 / 29.77 = 31.8% # 判断: 如果模型都是标准卷积,31.8%已经不错(考虑其他操作开销) 如果模型用了很多roll/gather,说明被内存拖累了七、总结
你的问题:"是不是有内存读取,利用率就比较低?"
答案:不完全是!关键看计算/访存比:
| 操作类型 | 计算量 | 内存访问 | 计算/访存比 | GPU利用率 |
|---|---|---|---|---|
| 标准卷积 | 大 | 中 | 高 | 90%+ ✅ |
| 矩阵乘法 | 大 | 中 | 高 | 90%+ ✅ |
| BatchNorm | 小 | 中 | 中 | 50-70% |
| torch.roll | 极小 | 大 | 极低 | 10-30% ❌ |
| Pooling | 小 | 中 | 低 | 30-50% |
核心原则
好的设计:让CUDA Cores忙碌计算,而不是等待内存 坏的设计:CUDA Cores闲着,等待数据搬运实用建议
- 多用标准操作(Conv, Linear)→ 高GPU利用率
- 少用内存操作(roll, gather, scatter)→ 避免低利用率
- 监控GPU利用率:
>80%✅ 优秀50-80%⚠️ 还行<50%❌ 有问题,检查是否有大量内存操作
一句话总结:不是"有内存读取就低",而是"计算太少、纯搬数据"才低!
核心发现:参数量和GFLOPs都不能单独决定推理速度
一、三个模型的性能对比
| 模型 | 参数量 | GFLOPs | FPS | 延迟 | GPU利用率 |
|---|---|---|---|---|---|
| UNeXt-Stripe | 3.48M | 1.922G | 174.58 | 5.73ms | ~85% |
| UNet | 31.04M | 83.817G | 112.83 | 8.86ms | ~90% |
| Rolling-UNet | 1.78M | 3.219G | 31.10 | 32.16ms | ~25% |
关键矛盾:
- UNet的GFLOPs是Rolling-UNet的26倍,但反而快3.6倍
- Rolling-UNet参数量最少,但推理最慢
二、决定推理速度的真正因素(重要性排序)
1.GPU利用率(最关键)
实际性能 = 理论GFLOPs × GPU利用率 × 硬件优化系数- UNet: GPU利用率90% → 虽然GFLOPs高,但几乎满载运行
- Rolling-UNet: GPU利用率25% → 虽然GFLOPs低,但大量时间在等待
2.内存访问模式
- 高效:连续内存访问(标准卷积)→ L2 Cache命中率>90%
- 低效:随机内存访问(torch.roll)→ 大量cache miss
3.算子类型
- 硬件友好:Conv2d, BatchNorm, ReLU → cuDNN极致优化
- 硬件不友好:torch.roll, gather, scatter → 纯内存拷贝操作
4.GFLOPs(仅反映计算量,不包括内存操作)
5.参数量(只影响显存占用,几乎不影响速度)
三、为什么Rolling-UNet最慢?
问题1:torch.roll是内存密集型操作
# 每层需要6次移位操作 x1 = torch.roll(x[:, :c], shifts=-1, dims=2) # ~0.5ms x2 = torch.roll(x[:, c:2c], shifts=1, dims=2) # ~0.5ms # ... 共6次 # 10层 × 6次 × 0.5ms = 30ms(接近实测的32.16ms)本质问题:
- torch.roll = 0 FLOPs(不计入GFLOPs统计)
- 但需要重新排列整个特征图的内存布局
- 触发GPU内存带宽瓶颈
- 无法利用CUDA Cores/Tensor Cores
问题2:Kernel调度开销
UNet: Conv → BN → ReLU(融合后1次调用) Rolling-UNet: roll → roll → roll → roll → roll → roll → cat → Conv (10+次调用,每次~5-10μs启动延迟)问题3:GFLOPs统计的误导性
GFLOPs只计算浮点运算,完全忽略了:
- 内存拷贝操作(torch.roll, torch.cat)
- 数据重排操作
- Kernel启动开销
四、为什么UNet虽然GFLOPs高但仍然快?
优势1:cuDNN极致优化
# 标准卷积调用路径 nn.Conv2d(64, 128, 3×3) ↓ cudnnConvolutionForward() # NVIDIA官方优化 ↓ Tensor Core加速(FP16/TF32)效率对比:
- cuDNN卷积效率:80-90%
- RTX 3080理论算力:29.77 TFLOPS
- UNet实际达到:9.46 TFLOPS(~32%利用率,考虑其他操作已经很好)
优势2:算子融合
# PyTorch自动融合 Conv → BN → ReLU ↓ 编译后 FusedConvBNReLU # 单次kernel调用减少:
- GPU kernel启动次数
- 中间结果的内存读写
优势3:内存访问高度优化
输入 → im2col变换(硬件加速) → 矩阵乘法(Tensor Core) → 输出 所有操作都是coalesced memory access五、为什么UNeXt-Stripe最快?
优势1:低GFLOPs架构设计
# 在高分辨率时使用少通道 E1: [B, 3, H, W] → [B, 16, H/2, W/2] # 16通道 E2: [B, 16, H/2, W/2] → [B, 32, H/4, W/4] # 32通道 # 在低分辨率时才增加通道 E4: [B, 160, H/16, W/16] # 分辨率已降低96%优势2:标准卷积 + 高GPU利用率
- 全部使用cuDNN优化的标准操作
- GPU利用率达到85%
优势3:EdgeEnhancedStripeDecoder虽然复杂但高效
# 多分支并行计算,充分利用GPU并行性 main = self.main_conv(x) # 3×3 conv edge_x = self.edge_conv_x(x) # 3×3 conv (Sobel) edge_y = self.edge_conv_y(x) # 3×3 conv (Sobel) high_f = self.high_freq(x) # 1×1 conv # 这些可以在GPU上并行执行六、性能分析方法
方法1:PyTorch Profiler
import torch.profiler as profiler with profiler.profile( activities=[profiler.ProfilerActivity.CUDA], record_shapes=True ) as prof: output = model(images) print(prof.key_averages().table(sort_by="cuda_time_total"))方法2:GPU监控
nvidia-smi dmon # 实时监控GPU利用率七、核心经验教训
❌ 不可靠的指标
- 参数量:只影响显存,不影响速度
- GFLOPs单独看:忽略了内存操作和硬件利用率
✅ 可靠的指标
- 实际延迟(ms):端到端性能
- GPU利用率:反映硬件使用效率
- GFLOPs + GPU利用率的组合
设计原则
优先使用硬件友好的操作
- ✅ Conv2d, Linear, BatchNorm, ReLU
- ❌ roll, gather, scatter, index_select
避免大量自定义内存操作
- 用可变形卷积(DeformConv2d)替代显式移位
测量真实性能
- 不要假设,用Profiler实测
- 关注GPU利用率,不只是GFLOPs
八、优化建议
对于Rolling-UNet
# ❌ 慢:显式移位 x_shifted = torch.roll(x, shifts=-1, dims=2) # ✅ 快:可变形卷积 from torchvision.ops import DeformConv2d self.deform_conv = DeformConv2d(C, C, 3, padding=1)通用原则
性能因素重要性排序:
GPU利用率 > 内存访问模式 > GFLOPs > 参数量