4、ONNX Runtime高级用法:自定义算子、性能调优、内存管理
说实话,ONNX Runtime 的基本用法,跑个模型推理,大家上手都很快。但真正到了生产环境,你会发现事情没那么简单。模型里有个奇怪的算子跑不了?推理速度上不去?内存动不动就爆?
嗯,这些才是真正考验功底的地方。今天我就把这几块硬骨头啃下来,跟你聊聊我的实战经验。
4.1 自定义算子:当ONNX标准算子不够用时
我遇到过太多次这种情况:模型里用了一个很新的算子,或者自己写了个特殊操作,ONNX 标准算子列表里压根没有。这时候怎么办?自己动手,丰衣足食。
4.1.1 为什么需要自定义算子?
说白了,ONNX 的算子集虽然丰富,但永远追不上研究前沿。你想想看,Transformer 刚出来那会儿,ONNX 哪有 MultiHeadAttention?
我个人习惯是,只要标准算子能拼凑出来的,绝不自己写。但遇到以下情况,就得考虑自定义了:
- 性能关键路径:用多个标准算子组合,效率太低。比如某些激活函数的变体。
- 硬件特殊指令:你的 NPU 或 GPU 有特殊指令,标准算子映射不过去。
- 模型结构特殊:比如用了自定义的池化方式,ONNX 不支持。
4.1.2 实现一个自定义算子(C++示例)
我们以添加一个「带阈值的ReLU」为例。这个算子很简单:输入小于阈值时输出0,否则输出输入值。
// 1. 定义算子内核
struct ThresholdedReluKernel {
float threshold_;
ThresholdedReluKernel(const OrtApi& api, const OrtKernelInfo* info) {
// 从属性中读取阈值
Ort::ThrowOnError(api.KernelInfoGetAttribute_float(
info, "threshold", &threshold_));
}
void Compute(OrtKernelContext* context) {
// 获取输入输出
Ort::KernelContext ctx(context);
auto input = ctx.GetInput(0);
auto output = ctx.GetOutput(0, input.GetTensorTypeAndShapeInfo().GetShape());
const float* src = input.GetTensorData<float>();
float* dst = output.GetTensorData<float>();
size_t count = input.GetTensorTypeAndShapeInfo().GetElementCount();
// 核心计算
for (size_t i = 0; i < count; i++) {
dst[i] = (src[i] > threshold_) ? src[i] : 0.0f;
}
}
};
// 2. 注册算子
struct CustomOp : Ort::CustomOpBase<CustomOp, ThresholdedReluKernel> {
void* CreateKernel(const OrtApi& api, const OrtKernelInfo* info) const {
return new ThresholdedReluKernel(api, info);
}
const char* GetName() const { return "ThresholdedRelu"; }
const char* GetExecutionProviderType() const { return "CPUExecutionProvider"; }
};
// 3. 注册到全局
extern "C" OrtStatus* OrtSessionOptionsAppendExecutionProvider_Custom(
_In_ OrtSessionOptions* options) {
static CustomOp op;
return Ort::ThrowOnError(OrtSessionOptionsAppendCustomOpDomain(
options, "my.custom.domain", &op));
}
4.2 性能调优:让推理飞起来
模型能跑了,但速度不够?这是最常见的场景。ONNX Runtime 提供了不少调优手段,但很多人不知道从哪下手。
4.2.1 Session选项优化
我刚开始用 ONNX Runtime 时,直接默认设置就跑。后来发现,光 Session 选项就能拉开 30% 的性能差距。
| 选项 | 说明 | 推荐值 |
|---|---|---|
| intra_op_num_threads | 算子内部并行线程数 | CPU核心数(或减1) |
| inter_op_num_threads | 算子间并行线程数 | 2-4(不宜过多) |
| execution_mode | 执行模式:顺序或并行 | ORT_SEQUENTIAL(稳定) |
| graph_optimization_level | 图优化等级 | ORT_ENABLE_ALL |
// Python示例
import onnxruntime as ort
sess_options = ort.SessionOptions()
sess_options.intra_op_num_threads = 4
sess_options.inter_op_num_threads = 2
sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
session = ort.InferenceSession("model.onnx", sess_options)
关键点: 线程数不是越多越好。我踩过坑,设了16个线程,结果上下文切换开销比计算还大。建议从核心数的一半开始试,逐步增加。
4.2.2 执行提供者(Execution Provider)选择
说白了,就是让 ONNX Runtime 知道用哪个硬件来跑。不同的硬件,性能天差地别。
- CPUExecutionProvider:默认,兼容性最好,但性能一般。
- CUDAExecutionProvider:NVIDIA GPU,适合大模型。
- TensorrtExecutionProvider:NVIDIA TensorRT,极致优化。
- OpenVINOExecutionProvider:Intel 硬件,CPU/GPU/VPU都行。
我记得有一次,客户在 Intel 服务器上跑模型,用默认 CPU 提供者,延迟 50ms。换成 OpenVINO 后,直接降到 8ms。嗯,选对提供者,比调代码管用多了。
4.3 内存管理:别让OOM找上门
模型推理时内存暴涨,甚至 OOM(Out of Memory),这是生产环境最常见的问题之一。尤其是服务端,多个请求并发时,内存管理不好,服务直接挂掉。
4.3.1 内存分配策略
ONNX Runtime 默认会为每个 Session 分配独立的内存。如果你同时加载多个模型,内存会线性增长。
我建议的做法是:
- 共享内存分配器:多个 Session 共用同一个内存池。
- 设置内存上限:通过 Arena 分配器控制最大内存。
- 及时释放:推理完成后,手动调用释放接口。
// C++示例:设置Arena内存上限
Ort::SessionOptions session_options;
Ort::ArenaConfig arena_config;
arena_config.SetMaxMemory(1024 * 1024 * 1024); // 1GB上限
session_options.SetArenaConfig(arena_config);
4.3.2 输入输出内存管理
很多人不注意输入输出的内存管理。每次推理都重新分配内存,这是巨大的浪费。
我的做法是:
- 预分配输入输出缓冲区:如果输入尺寸固定,一次性分配好,重复使用。
- 使用 Ort::Value 的 Clone 方法:避免深拷贝。
- 注意张量生命周期:不要持有 Ort::Value 引用太久,用完即释放。
# Python示例:预分配缓冲区
import numpy as np
import onnxruntime as ort
session = ort.InferenceSession("model.onnx")
input_name = session.get_inputs()[0].name
output_name = session.get_outputs()[0].name
# 预分配固定大小的输入
fixed_input = np.zeros((1, 3, 224, 224), dtype=np.float32)
# 多次推理复用同一缓冲区
for i in range(1000):
# 更新输入数据(不重新分配内存)
fixed_input[:] = get_next_batch()
# 推理
outputs = session.run([output_name], {input_name: fixed_input})
4.3.3 内存碎片问题
长时间运行的服务,内存碎片会越来越严重。尤其是频繁分配释放小张量时。
我建议:
- 使用 jemalloc 或 tcmalloc:替换 glibc 的 malloc,减少碎片。
- 批量处理请求:把多个小请求合并成一个大 batch,减少内存操作次数。
- 定期重启:对于长期运行的服务,设置内存阈值,超过后自动重启。
MALLOC_ARENA_MAX=1 环境变量限制 glibc 的 arena 数量,能有效减少内存碎片。我有个服务,加上这个参数后,内存使用量降低了 40%。
4.4 实战:综合调优案例
最后,我分享一个实际案例。一个图像分类服务,要求单张图片推理延迟 < 10ms,QPS > 1000。
初始状态:延迟 25ms,QPS 200。经过以下优化:
- 启用 TensorRT Execution Provider,延迟降到 12ms。
- 设置 intra_op_num_threads=4,延迟降到 8ms。
- 预分配输入输出缓冲区,减少内存分配开销。
- 使用共享 Arena,内存从 2GB 降到 500MB。
- 批量处理请求,QPS 提升到 1500。
最终结果:延迟 8ms,QPS 1500,内存占用 500MB。嗯,这才是生产级的表现。
记住,调优不是一蹴而就的。每个环节都可能有瓶颈,需要你一步步排查。我习惯用 perf 和 ONNX Runtime 自带的 profiling 工具,先定位热点,再针对性优化。