3. 性能分析基础
在开始优化 ONNX 模型之前,必须先建立一套统一的度量体系。没有量化就没有优化——如果无法准确回答“当前模型延迟是多少”“吞吐量是否达标”“内存占用是否合理”,后续的一切调优动作都将失去参照基准。本章聚焦三个核心性能指标(延迟、吞吐量、内存占用)与两类关键分析工具(Profiler、Benchmark),为后续章节的案例调优打下基础。
3.1 三大核心性能指标
ONNX 模型性能分析中,最常被提及的指标是延迟(Latency)、吞吐量(Throughput)和内存占用(Memory Footprint)。三者从不同维度刻画模型运行效率,且往往相互制约。
| 指标 | 定义 | 典型单位 | 关注场景 |
|---|---|---|---|
| 延迟 | 单个推理请求从输入到输出所花费的时间 | ms / 次 | 实时交互、边缘端部署 |
| 吞吐量 | 单位时间内完成的推理请求数量 | QPS、FPS | 批量离线推理、云端服务 |
| 内存占用 | 模型加载及推理过程中占用的 RAM/VRAM | MB / GB | 移动端、嵌入式设备 |
延迟通常关注P50、P95、P99分位数,而非平均值——因为平均值容易被极端值掩盖。吞吐量则需区分单流吞吐与多流并发吞吐,后者更贴近真实服务场景。内存占用需同时关注静态内存(模型权重、图结构)与动态内存(推理过程中的中间张量、临时缓冲区)。
3.2 分析工具全景:Profiler 与 Benchmark
ONNX Runtime 生态提供了两类互补的工具:Profiler 用于细粒度定位耗时瓶颈,Benchmark 用于宏观测量端到端性能。两者配合使用,才能完成“发现瓶颈 → 定位算子 → 验证优化”的闭环。
3.3 核心工具实操:Profiler 与 Benchmark 使用
3.3.1 Profiler:定位算子级瓶颈
ONNX Runtime 的 Profiler 通过 SessionOptions 启用,可记录每个算子的执行时间、张量形状及内存事件。以下代码展示了如何开启 Profiler 并输出 JSON 格式的 trace 文件:
import onnxruntime as ort
import json
sess_options = ort.SessionOptions()
sess_options.enable_profiling = True # 开启 Profiler
sess_options.profile_file_prefix = "onnx_profile" # 输出文件前缀
session = ort.InferenceSession("model.onnx", sess_options)
session.run(None, {"input": input_data})
# 获取 Profiler 输出文件路径
prof_file = session.end_profiling()
print(f"Profiler 输出: {prof_file}")
# 解析 trace 文件,提取耗时 Top 算子
with open(prof_file, "r") as f:
trace_data = json.load(f)
# 按耗时排序,找出瓶颈算子
events = [e for e in trace_data if e.get("cat") == "Node"]
events.sort(key=lambda x: x.get("dur", 0), reverse=True)
for e in events[:5]:
print(f"算子: {e['name']}, 耗时: {e['dur']} us")
Profiler 输出的 trace 文件遵循 Chrome Tracing 格式,可直接在 chrome://tracing 或 Perfetto UI 中可视化。重点关注:
- 耗时占比最高的算子——通常是 Conv、MatMul、Attention 等计算密集型算子。
- 张量形状异常——例如某个中间张量维度远大于预期,导致不必要的内存拷贝。
- 内存分配频繁——大量小张量的反复分配/释放会拖慢整体速度。
3.3.2 Benchmark:宏观性能测量
ONNX Runtime 提供了 onnxruntime_benchmark 命令行工具,也支持通过 Python 脚本自定义测试。以下是一个典型的 Benchmark 脚本:
import onnxruntime as ort
import numpy as np
import time
import statistics
session = ort.InferenceSession("model.onnx")
input_name = session.get_inputs()[0].name
input_shape = session.get_inputs()[0].shape
input_data = np.random.randn(*input_shape).astype(np.float32)
# Warm-up:预热 10 次,排除首次加载影响
for _ in range(10):
session.run(None, {input_name: input_data})
# 正式测试:100 次推理
latencies = []
for _ in range(100):
start = time.perf_counter()
session.run(None, {input_name: input_data})
latencies.append((time.perf_counter() - start) * 1000) # ms
latencies.sort()
p50 = statistics.median(latencies)
p95 = latencies[int(len(latencies) * 0.95)]
p99 = latencies[int(len(latencies) * 0.99)]
avg = statistics.mean(latencies)
print(f"平均延迟: {avg:.2f} ms")
print(f"P50: {p50:.2f} ms | P95: {p95:.2f} ms | P99: {p99:.2f} ms")
print(f"吞吐量: {1000 / avg:.1f} FPS")
- Warm-up 次数:建议至少 10 次,以触发图优化、内存池预分配等初始化逻辑。
- 测试次数:建议 100 次以上,以获得稳定的统计分布。
- 线程数设置:通过
sess_options.intra_op_num_threads控制,默认使用所有物理核心。
3.4 案例:ResNet50 性能基线测量
以 ResNet50 为例,演示如何建立性能基线。假设模型已转换为 ONNX 格式,输入为 [1, 3, 224, 224] 的 RGB 图像。
第一步:Benchmark 初测,获取整体性能基线:
# 命令行方式(使用官方工具)
python -m onnxruntime.tools.benchmark \
--model resnet50.onnx \
--input_size 1x3x224x224 \
--iterations 100 \
--warmup 10
# 输出示例
# Average latency: 12.34 ms
# P95 latency: 14.56 ms
# Throughput: 81.0 FPS
第二步:Profiler 定位瓶颈,找出耗时最高的算子:
# 开启 Profiler 后,解析 trace 文件得到 Top 算子
# 典型输出:
# Conv_0: 2.1 ms (17.0%)
# Conv_1: 1.8 ms (14.6%)
# BatchNormalization_0: 0.9 ms (7.3%)
# MaxPool_0: 0.6 ms (4.9%)
# ...
第三步:分析结论。若 Conv 算子总耗时占比超过 60%,说明计算密度高,可考虑算子融合或低精度推理;若数据搬运(Transpose、Concat)耗时占比高,则需检查图结构是否合理。
3.5 常见误区与复盘
平均延迟 10ms 的模型,P99 可能高达 50ms。对于实时推理服务,尾延迟直接影响用户体验。必须同时报告 P50/P95/P99。
CPU 频率波动、GPU 温度、其他进程干扰都会影响测量结果。建议:
- 关闭 CPU 动态调频(设置 performance governor)
- GPU 测试前先预热至稳定温度
- 重复测试 3 次取最优/中位数
ONNX Runtime 默认使用内存池复用中间张量,但某些算子(如动态形状的 Resize)仍可能触发额外分配。使用 psutil 或 nvidia-smi 监控推理过程中的内存峰值:
import psutil
import os
# 在推理循环中采样内存
process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024 # MB
session.run(None, {input_name: input_data})
mem_after = process.memory_info().rss / 1024 / 1024
print(f"推理内存增量: {mem_after - mem_before:.1f} MB")
复盘总结:性能分析不是一次性的工作,而应贯穿整个优化周期。每次优化操作后,都应重新运行 Benchmark 验证收益,并用 Profiler 确认瓶颈是否转移。建议建立标准化的性能测试脚本,纳入 CI/CD 流程,防止模型更新后性能回退。