干过推理优化的同学都知道,ONNX Runtime的同步推理接口一调用就卡住当前线程,等模型算完才放行。单请求、单batch的场景还好,一旦上了高并发服务或者流式应用,CPU线程等待GPU的时间就是白白的空转。今天这篇就围绕ONNX Runtime异步推理提速展开,把原理、实操、压测数据和一个真实的踩坑排查链路都拉出来聊聊,希望对正在搞推理服务优化的朋友有帮助。
1. 为什么同步推理会成为瓶颈:从一次压测说起
先说一个我自己比较有体感的场景。之前接手一个图像处理服务的优化,模型是分割类的,输入尺寸固定4032x3024。刚开始很顺利,用ONNX Runtime的默认InferenceSession加载模型,输入numpy数组,调用session.run拿输出,单路延时从原始的TensorFlow部署从180ms降到了大概90ms。表面看起来已经不错了,但那段时间服务端经常出现CPU被顶满、GPU利用率反而只有40%多的情况。我们一开始也没细想,觉得耗时数据已经好了,直到某次压测直接暴露了问题。
用压测工具打并发的时候,服务吞吐量上不去,从20路并发开始,RT(响应时间)出现线性增长。正常的推理服务在并发增长时延时略有抖动是合理的,但这里是近乎垂直的上涨,明显不是正常的排队消耗。排查的时候打开nvidia-smi的实时刷新看着GPU Wave,发现GPU利用率始终不高,但四个CPU核心全部吃满,而且线程栈里大量时间都卡在同步等待状态。
这就是同步推理的典型特征:每次run()调用其实是"提交任务到执行环境 -> 等待执行环境完成 -> 返回结果",中间这个等待窗口里,调用线程不能干任何别的事情。你开了多少个并发请求,就至少要垫进去多少个线程在那干等,每个线程还占着栈内存和调度资源。高并发一上来,GPU没吃饱,CPU因为大量线程空转反而先垮了。
这里的本质问题不在于ONNX Runtime推理本身慢,而是"同步等待"这种调用模型在并发场景下的资源利用率太差了。
异步推理解决的就是这个等待空转问题:调用线程把推理请求提交出去之后,立刻返回去做别的事(接下一个请求、做数据预处理、处理其他请求的结果),真正算完的时候通过回调或者事件机制再回来取结果。这样同样的并发量,不需要为每个请求都配一个专职干等的线程。
2. ONNX Runtime异步推理的底层机制:IO绑定与线程池
但ONNX Runtime的异步推理不是简单地开个后台线程池就能了事,它有两套不同的东西,混在一起容易绕晕。
2.1 run_async接口:从调用层做异步
ONNX Runtime官方提供了InferenceSession.run_async接口(C++对应RunAsync),它做的事情是把"提交推理任务"和"等待推理结果"这两个动作拆开。提交后立即返回,拿到一个future/回调对象,等执行环境内部完成推理后再触发回调,把结果交回到你想要的位置。
这个方式在Python里用起来比较顺手,比如可以配合asyncio事件循环:
import asyncio import numpy as np import onnxruntime as ort # 常规同步session session = ort.InferenceSession("model.onnx", providers=["CUDAExecutionProvider"]) async def infer_async(input_data: np.ndarray): loop = asyncio.get_running_loop() future = loop.create_future() def callback(result, err): if err: loop.call_soon_threadsafe( future.set_exception, RuntimeError(f"推理失败: {err}") ) else: loop.call_soon_threadsafe(future.set_result, result) # 提交异步推理任务 session.run_async( output_names=None, input_feed={"input": input_data}, callback=callback ) return await future这种写法在CPU执行提供器(CPUExecutionProvider)和CUDA执行提供器(CUDAExecutionProvider)下都能用,核心效果是让发起推理的线程不再阻塞等待。但要注意的是,run_async内部依然有线程池在干活,intra_op_num_threads和inter_op_num_threads这些选项依然会影响到内部任务的调度方式。
2.2 可绑定(bind)机制:从执行流做覆盖
另一套容易搞混的是IOBinding绑定机制。它做的事情是把输入输出先绑定到预先分配好的内存(或者GPU显存)上,然后调用run_with_iobinding或run_async_with_iobinding。这个方案的核心目的不是"异步"两个字本身,而是减少每次推理时反复做张量拷贝和内存分配的固定开销。
import numpy as np import onnxruntime as ort session = ort.InferenceSession( "model.onnx", providers=["CUDAExecutionProvider"], sess_options=ort.SessionOptions() ) # 预建binding对象 binding = session.io_binding() output_tensor_name = session.get_outputs()[0].name # 预分配输出内存 output_shape = (1, 128, 64, 64) output_tensor = np.empty(output_shape, dtype=np.float32) binding.bind_output(name=output_tensor_name, device_type="cuda", device_id=0, element_type=np.float32, shape=output_shape) # 输入绑定 input_tensor_name = session.get_inputs()[0].name input_data = np.random.randn(*output_shape).astype(np.float32) binding.bind_cpu_input(name=input_tensor_name, tensor=input_data) # 执行推理 session.run_with_iobinding(binding) # 取回结果 result = binding.copy_outputs_to_cpu()[0]IOBinding和run_async结合起来用,其实才是真正的异步推理完全体:一次请求全程不涉及临时内存的反复分配,提交到执行环境后线程立刻返回。但注意,它自身不带"完成通知"能力,需要搭配回调机制或自己轮询状态。
2.3 执行提供器层面:CUDA图与Stream
继续往下扒一层,ONNX Runtime在CUDA执行提供器内部实际上维护了自己的CUDA stream。图优化、算子融合、内存池这些动作都发生在执行环境初始化阶段,异步推理真正做的就是把这些已经编译好的kernel依序提交到CUDA stream上执行。
CUDA 10之后大量推理框架开始引入CUDA Graph,把一系列kernel启动封装成一张图,减少CPU端反复启动kernel的开销。ONNX Runtime在较新的版本里也支持了CUDA Graph的捕获和重放能力。在OnnxRuntime 1.15以上的版本,CUDAExecutionProvider有个enable_cuda_graph选项。这个对短kernel多的小模型提速尤其明显,因为kernel启动本身就是一笔不小的CPU开销,图重放直接把这个摊销掉了。
cuda_provider_options = { "device_id": 0, "cuda_graph_enable": True } session = ort.InferenceSession( "model.onnx", providers=[ ("CUDAExecutionProvider", cuda_provider_options), "CPUExecutionProvider" ] )这个选项值得单独试一下,但要求输入shape必须固定,且运行过程中不能频繁改动图捕获时的配置。
3. 从代码到平台:整个推理流水线怎么改成异步模型
光把session.run换成session.run_async是远远不够的。异步推理做的是"整个服务的执行模型重构",而不是"换一个API调用"。下面把我整理出来的完整改造链路放出来。
3.1 串行流水线拆解为三阶段队列
一个典型推理请求从进来到返回,大概经历三个阶段:
- 前置处理:读图、解码、resize、归一化(CPU密集型)
- 推理:ONNX Runtime执行模型计算(GPU密集型)
- 后置处理:softmax、NMS、结果格式化、封装返回(CPU密集型)
同步模型下,这三个阶段串在一起,一个线程从头跟到尾。异步化改造的第一步,就是把这三个阶段拆开,中间用队列解耦。CPU在前置处理干完活,把数据塞进任务队列,立刻回去处理下一个请求。计算线程从队列里拿任务,提交给ONNX Runtime的run_async,干活的线程继续去消化下一个任务。推理完成回调触发后,再把结果交给后置处理线程。
3.2 一个完整的异步推理调度器示例
下面是我实际在用的一个简化版异步调度器,核心思路是用一个常驻的事件循环线程配合future回调:
import asyncio import threading import queue from typing import Dict, Any, Callable import numpy as np import onnxruntime as ort class AsyncInferenceEngine: def __init__(self, model_path: str): self.session = ort.InferenceSession( model_path, sess_options=self._build_session_options(), providers=["CUDAExecutionProvider", "CPUExecutionProvider"] ) self._request_queue: queue.Queue = queue.Queue(maxsize=256) self._loop = asyncio.new_event_loop() self._worker_thread = threading.Thread( target=self._loop.run_forever, daemon=True ) self._worker_thread.start() self._futures: Dict[int, asyncio.Future] = {} self._sequence = 0 self._lock = threading.Lock() def _build_session_options(self) -> ort.SessionOptions: opts = ort.SessionOptions() opts.intra_op_num_threads = 4 opts.inter_op_num_threads = 4 opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL opts.enable_profiling = False return opts def schedule(self, input_data: np.ndarray) -> asyncio.Future: """将推理任务提交到事件循环,返回一个Future""" with self._lock: self._sequence += 1 seq = self._sequence fut = self._loop.create_future() self._futures[seq] = fut # 在线程池外安全地将任务投递到事件循环 self._loop.call_soon_threadsafe(self._dispatch, seq, input_data) return fut def _dispatch(self, seq: int, input_data: np.ndarray): """在事件循环线程中发起异步推理""" try: def done_callback(result, err): fut = self._futures.pop(seq, None) if fut is None or fut.done(): return if err: self._loop.call_soon_threadsafe( fut.set_exception, RuntimeError(str(err)) ) else: self._loop.call_soon_threadsafe(fut.set_result, result) self.session.run_async( output_names=None, input_feed={"input": input_data}, callback=done_callback ) except Exception as e: fut = self._futures.pop(seq, None) if fut and not fut.done(): self._loop.call_soon_threadsafe(fut.set_exception, e)调用侧就清爽了:
engine = AsyncInferenceEngine("model.onnx") fut = engine.schedule(input_data) result = await fut这套设计里有一个很关键的小细节:run_async的callback是在ONNX Runtime内部线程池触发的,不能在回调里直接操作asyncio的future(不是线程安全的),所以要用call_soon_threadsafe把结果"塞回"事件循环线程。这个细节不处理好,异步会变成更隐蔽的偶发崩溃。
3.3 多模型共存的异步路由策略
跑同一个模型时调度比较简单,麻烦的是服务里同时挂了两三个模型,比如检测模型加分类模型。这种情况下,最好为每个模型建独立的AsyncInferenceEngine实例,各自持有一条任务队列。一共也就那几个模型,队列不会多到失控。不要在同一个session里用多个线程去并发调用run_async,虽然官方支持一定程度的并发调用同一个session,但实测并发度一高还是会在部分kernel上出现资源竞争和延迟抖动。
我的建议:模型之间做队列隔离,跨模型路由统一交给服务层,别把多个模型的推理调度混在一个模块里写。
4. 实测数据:异步推理在不同场景下的真实提速幅度
理论说了半天,上点实测数据才有说服力。下面是几个我在不同模型上跑过的对比结果。环境是单张GPU,CPU为8核虚拟化环境,ONNX Runtime 1.16.3。
4.1 大batch单路视频帧推理
模型是分割类,输入1x3x1080x1920,batch固定1。单次输入下同步推理约52ms,异步推理约55ms。
单看单路延时,异步反而略慢(多了一次回调分发和事件循环转接的开销)。但是看CPU占用和吞吐:
| 方案 | 并发16路时P50延时 | 并发16路时P99延时 | GPU利用率 | 服务端CPU占用 |
|---|---|---|---|---|
| 同步run | 48ms | 220ms | 45%~55% | 85%~95% |
| 异步run_async | 39ms | 110ms | 68%~75% | 30%~40% |
16路并发下,P99延时从220ms降到110ms,这个幅度对线上服务来说是质变。CPU占用骤降,说明线程空转等GPU的问题被处理掉了。
4.2 高并发小模型请求
小模型反而更能体现异步的价值。人脸关键点模型,单次推理只有3ms,但单帧前处理(人脸检测裁剪加resize)要花8ms。同步模式下,一次请求的线程占用时间约11ms,GPU真正干活才3ms。异步化之后,同一条线程在等待GPU那3ms时能做掉另一帧的前处理,吞吐量几乎是线性上去的。
实际压测结果:同步方案单worker吞吐约60QPS,异步方案单worker约105QPS,提升75%左右。并且异步方案的极限并发推上去之后,延时曲线的线性段比同步方案长了将近一倍。
4.3 模型执行时间本身在异步下不会变快
必须把期望值摆正:异步推理不改变模型内部计算路径,单次推理耗时基本不变。提速的核心收益是"单位时间内能用同样的硬件资源处理更多的请求",也就是吞吐量和资源利用率的提升。如果你的业务就是单路请求、完全同步调用,没有画中画管道也没有并发压力,那异步化带来的收益可以忽略不计。
5. 实战踩坑:异步改造后性能不升反降的排查链路
异步化改造理论上能提速,但实际落地中遇到性能不升反降的情况不在少数。我这边有一个印象很深的排查经历,完整链路拉出来,比单给结论有价值得多。
5.1 踩坑现场:上了异步但线程数暴涨
某次给一套OCR服务做异步化改造,代码跑起来之后不对劲。服务本身逻辑看起来一切正常,但吞吐量不升反降,还出现了CPU使用率比同步方案更高的诡异情况。开top -H一看,线程数量惊人,光一个worker进程就开出了上百个线程。
第一反应是怀疑ONNX Runtime的内部线程池没有设定好。但检查了SessionOptions,intra_op_num_threads和inter_op_num_threads都设成了2。再往深里查,发现问题其实出在自己的调度器上。
5.2 根因:预处理线程独立模型导致线程数放大
我最初的调度器设计里,为每个并发请求都单独开了一个asyncio任务,每个任务内部独立做前处理,然后再提交推理。代码看起来合理,但实际上前处理用的是loop.run_in_executor,默认用的是线程池执行器,而asyncio默认线程池的上限是min(32, os.cpu_count() + 4)。
如果给每个请求都开一个默认线程池任务,再叠加ONNX Runtime内部的线程池,再叠加requests库自己的连接池线程,整体的线程数直接失控。线程一多,CPU上下文切换开销上来了,锁竞争也变多,异步化省下来的那点等待成本全被调度开销吞掉了。
5.3 解决:统一线程池与任务分级
修正方案是两件事:
- 把前处理的线程池从asyncio默认池换成独立命名、固定大小(一般为CPU核心数)的
ThreadPoolExecutor。 - 引入请求级排队限流,超过队列容量直接返回负载过高错误码,避免请求无限制堆叠导致线程和高内存双爆。
from concurrent.futures import ThreadPoolExecutor preprocess_pool = ThreadPoolExecutor( max_workers=4, thread_name_prefix="preprocess" ) def handle_request(image_bytes): # 提交到预处理线程池,不阻塞主循环 fut = loop.run_in_executor( preprocess_pool, preprocess_fn, image_bytes ) ...这次问题的核心教训在于:异步推理的性能收益跟整个服务的线程模型强相关。调用线程不再阻塞没问题,但那部分资源必须被有效利用起来,而不是又变成新的一批排队线程。
6. 再往深处走:CUDA Stream并发、动态batch与异步的组合拳
搞完基础的异步化之后,如果你想继续薅性能,有几个方向值得试试。这些是我在后续项目中陆续摸出来的,相互之间能叠加。
6.1 利用多个CUDA Stream做跨流并发
ONNX Runtime的CPU执行提供器和CUDA执行提供器在行为上有本质差别。CUDA执行提供器默认在一个stream上串行推理,多个session实例各自维护独立stream,可以实现一定程度上的跨流并发。
在有多个模型的情况下,与其把它们塞进一个session里顺序执行,不如拆成两个session,每个模型用独立实例,自然分配到不同stream上。两个模型的kernel启动可以在GPU上交替执行,显存和计算单元能得到更充分的利用。但要盯着显存占用看,别让两个模型叠加后爆显存。
6.2 动态batch的异步分组
服务场景下请求是分散到达的,每个请求单独进模型推理就等于把GPU算力切成了碎块。动态batch(也叫动态batching)把短时间窗口内的多个请求攒到一起,作为一次推理送出。配合异步推理,等待攒批的过程不会阻塞任何线程。
ONNX Runtime本身没有内置动态batch,需要自己在服务层实现一个攒批调度器。思路是:维护一个输入队列,在设定窗口期内(比如10ms)收集进来的请求,到点了把这一批拼成一个大tensor提交一次run_async,推理完成后按原顺序拆回结果,分发给各个请求的future。
class DynamicBatcher: def __init__(self, batch_size=8, window_ms=10): self.batch_size = batch_size self.window_ms = window_ms self.pending: List[Tuple[np.ndarray, asyncio.Future]] = [] self._timer = None async def submit(self, input_data: np.ndarray) -> np.ndarray: fut = asyncio.get_running_loop().create_future() self.pending.append((input_data, fut)) self._ensure_timer() return await fut def _flush(self): batch = self.pending[:] self.pending.clear() pooled_input = np.stack([item[0] for item in batch], axis=0) # 提交一次异步推理,完成后回填结果 ...动态batch与async的结合是我在多个项目里实测收益最高的组合拳,尤其对于batch维敏感的小模型,吞吐往往能再翻一倍。代价是处理延时上限(等待攒批的时间),所以窗口要按业务容忍度来做权衡。
6.3 固定shape与内存复用
ONNX Runtime在推理过程中存在一些图级别的内存规划。如果输入shape不固定,每个新shape都可能触发内存重分配和re-session初始化,这是异步化大忌。条件允许的情况下,尽量把输入统一resize到固定尺寸,或者做padding到统一shape,你会发现P99稳定性明显变好。
IOBinding也配合这个方向非常有效。把输出buffer预先分配好,绑定到session上,推理结果直接写进预留内存,绕开每次返回新tensor的拷贝开销。
7. 异步推理落地清单与最终建议
项目收尾,我把踩坑经验和比较有效的方法论总结成一个清单,方便还没动手的朋友直接参考。
第一,明确业务目标再决定要不要异步化。单路同步调用、没有并发压力的场景不需要异步,多此一举还会增加代码复杂度。高并发在线服务、实时视频流处理、多模型流水线,这些才是异步推理的主场。
第二,先跑基准,再动代码。用同步baseline把单路延时、并发P50/P99、吞吐量、GPU利用率和CPU占用全部记下来。异步改造完之后跑同一套压测脚本对比,不要凭感觉判断快慢。
第三,线程模型是异步化的命门。ONNX Runtime的intra_op_num_threads推荐设置为CPU核心数或核心数减一,inter_op_num_threads一般设置为核心数即可,实际需要跑压测微调。千万别让服务里的其他线程池失控,这是性能劣化最大的隐性来源。
第四,异步推理的收益核心是"资源复用"而不是"单次提速"。如果你观察到的效果只有单路延时下降,基本可以判断是测量方式出了问题,或者模型本身执行时间就不稳定。
坦白说我个人的体会:ONNX Runtime异步推理上手简单,但真正要拿它做出服务体系级别的大提升,核心考验在工程收敛能力——线程模型、队列长度、显存规划、延时容忍度,每一处都要拿压测数据说话。
这套方案目前在我维护的服务里已经稳定跑了不短的时间,吞吐量相比最初同步版本提升约一倍以上,CPU占用反而降了。如果你的服务现在正卡在高并发延时抖动的阶段,建议从复制上面的调度器开始,结合自身业务压测调整,慢慢就能调出合适的参数组合了。