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 > 参数量