首页 / 资讯中心 / 文章详情

ONNX Runtime异步推理实战:从同步瓶颈到高并发提速的完整路径

ONNX Runtime异步推理实战:从同步瓶颈到高并发提速的完整路径 ★ FEATURED ARTICLE
干过推理优化的同学都知道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_namesNone, input_feed{input: input_data}, callbackcallback ) 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_optionsort.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, dtypenp.float32) binding.bind_output(nameoutput_tensor_name, device_typecuda, device_id0, element_typenp.float32, shapeoutput_shape) # 输入绑定 input_tensor_name session.get_inputs()[0].name input_data np.random.randn(*output_shape).astype(np.float32) binding.bind_cpu_input(nameinput_tensor_name, tensorinput_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_optionsself._build_session_options(), providers[CUDAExecutionProvider, CPUExecutionProvider] ) self._request_queue: queue.Queue queue.Queue(maxsize256) self._loop asyncio.new_event_loop() self._worker_thread threading.Thread( targetself._loop.run_forever, daemonTrue ) 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_namesNone, input_feed{input: input_data}, callbackdone_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. 实测数据异步推理在不同场景下的真实提速幅度理论说了半天上点实测数据才有说服力。下面是几个我在不同模型上跑过的对比结果。环境是单张GPUCPU为8核虚拟化环境ONNX Runtime 1.16.3。4.1 大batch单路视频帧推理模型是分割类输入1x3x1080x1920batch固定1。单次输入下同步推理约52ms异步推理约55ms。单看单路延时异步反而略慢多了一次回调分发和事件循环转接的开销。但是看CPU占用和吞吐方案并发16路时P50延时并发16路时P99延时GPU利用率服务端CPU占用同步run48ms220ms45%~55%85%~95%异步run_async39ms110ms68%~75%30%~40%16路并发下P99延时从220ms降到110ms这个幅度对线上服务来说是质变。CPU占用骤降说明线程空转等GPU的问题被处理掉了。4.2 高并发小模型请求小模型反而更能体现异步的价值。人脸关键点模型单次推理只有3ms但单帧前处理人脸检测裁剪加resize要花8ms。同步模式下一次请求的线程占用时间约11msGPU真正干活才3ms。异步化之后同一条线程在等待GPU那3ms时能做掉另一帧的前处理吞吐量几乎是线性上去的。实际压测结果同步方案单worker吞吐约60QPS异步方案单worker约105QPS提升75%左右。并且异步方案的极限并发推上去之后延时曲线的线性段比同步方案长了将近一倍。4.3 模型执行时间本身在异步下不会变快必须把期望值摆正异步推理不改变模型内部计算路径单次推理耗时基本不变。提速的核心收益是单位时间内能用同样的硬件资源处理更多的请求也就是吞吐量和资源利用率的提升。如果你的业务就是单路请求、完全同步调用没有画中画管道也没有并发压力那异步化带来的收益可以忽略不计。5. 实战踩坑异步改造后性能不升反降的排查链路异步化改造理论上能提速但实际落地中遇到性能不升反降的情况不在少数。我这边有一个印象很深的排查经历完整链路拉出来比单给结论有价值得多。5.1 踩坑现场上了异步但线程数暴涨某次给一套OCR服务做异步化改造代码跑起来之后不对劲。服务本身逻辑看起来一切正常但吞吐量不升反降还出现了CPU使用率比同步方案更高的诡异情况。开top -H一看线程数量惊人光一个worker进程就开出了上百个线程。第一反应是怀疑ONNX Runtime的内部线程池没有设定好。但检查了SessionOptionsintra_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_workers4, thread_name_prefixpreprocess ) 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_size8, window_ms10): 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], axis0) # 提交一次异步推理完成后回填结果 ...动态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占用反而降了。如果你的服务现在正卡在高并发延时抖动的阶段建议从复制上面的调度器开始结合自身业务压测调整慢慢就能调出合适的参数组合了。
阅读完成 · 觉得有帮助?
咨询建站