4、异步推理API详解:深入讲解OpenVINO异步推理的关键API(如start_async、wait、wait_for等),并演示基本用法
在OpenVINO的异步推理体系中,API的正确使用直接决定了流水线能否真正“跑起来”。很多开发者虽然理解了异步的概念,但在实际编码时仍然会混淆同步与异步的调用方式,或者不清楚如何优雅地管理多个推理请求。本章将深入剖析OpenVINO异步推理的核心API——start_async、wait、wait_for,以及它们背后的请求状态机与队列管理逻辑。我们会从API的语义出发,结合代码片段和工程案例,帮助你建立一套清晰的异步编程心智模型。
上图展示了OpenVINO异步推理的核心架构:应用层维护一个InferRequest池,通过异步API与调度核心交互,最终将计算任务分发到硬件设备。理解这张图的关键在于:异步API并不直接操作硬件,而是通过请求状态机来间接管理设备资源。下面我们逐一拆解这些API的语义与用法。
4.1 核心API语义与状态机
OpenVINO的异步推理API围绕ov::InferRequest对象展开。每个请求对象都维护一个内部状态机,状态迁移由API调用驱动。理解状态机是正确使用异步API的前提。
| API方法 | 函数签名 | 行为语义 | 阻塞性 |
|---|---|---|---|
start_async() |
void start_async() |
将请求提交到设备执行队列,立即返回。请求状态变为EXECUTING。 | 非阻塞 |
wait() |
void wait() |
阻塞当前线程,直到请求完成(DONE)或被取消(CANCELLED)。 | 阻塞 |
wait_for() |
bool wait_for(const std::chrono::duration<Rep, Period>& timeout) |
在指定超时时间内等待请求完成。超时返回false,完成返回true。 | 限时阻塞 |
set_callback() |
void set_callback(std::function<void(std::exception_ptr)> callback) |
注册回调函数,请求完成时在内部线程池中异步调用。 | 非阻塞 |
cancel() |
void cancel() |
尝试取消尚未开始的请求。已进入执行阶段的请求无法取消。 | 非阻塞 |
get_tensor() |
ov::Tensor get_tensor(const std::string& name) |
获取输入/输出张量的句柄,用于直接读写数据。 | 非阻塞 |
核心原理:异步API的本质是“提交-轮询-完成”模型。调用start_async()后,请求进入设备队列,CPU线程可以立即处理其他任务。当需要结果时,通过wait()或wait_for()同步等待,或者通过set_callback()实现完全的事件驱动。这种设计将计算延迟与数据传输延迟重叠,从而提升整体吞吐。
4.2 基本用法演示:从同步到异步的演进
我们先看一个最基础的同步推理代码,然后逐步改造为异步版本,以直观感受API的差异。
4.2.1 同步基线版本
// 同步推理:每次推理都阻塞线程
ov::InferRequest req = compiled_model.create_infer_request();
req.set_input_tensor(input_tensor);
req.infer(); // 阻塞直到推理完成
ov::Tensor output = req.get_output_tensor();
上述代码中,infer()是同步调用,CPU在推理期间完全空闲等待。如果处理视频流或批量请求,这种模式会浪费大量计算资源。
4.2.2 单请求异步改造
// 异步版本:提交后立即返回
ov::InferRequest req = compiled_model.create_infer_request();
req.set_input_tensor(input_tensor);
req.start_async(); // 非阻塞提交
// 此处可以执行其他CPU任务...
req.wait(); // 需要结果时再等待
ov::Tensor output = req.get_output_tensor();
这个改造虽然简单,但已经体现了异步的核心优势:提交与等待分离。在start_async()和wait()之间,你可以执行图像预处理、数据加载等操作。
4.2.3 多请求流水线(吞吐优化关键)
// 创建请求池
const int num_requests = 4;
std::vector<ov::InferRequest> requests;
for (int i = 0; i < num_requests; ++i) {
requests.push_back(compiled_model.create_infer_request());
}
// 流水线调度
size_t current = 0;
for (size_t frame_id = 0; frame_id < total_frames; ++frame_id) {
auto& req = requests[current % num_requests];
// 等待当前槽位空闲(超时1ms轮询)
if (!req.wait_for(std::chrono::milliseconds(1))) {
// 如果还没完成,先处理其他任务或稍后再试
continue;
}
// 填充输入数据
auto input = req.get_input_tensor();
memcpy(input.data(), frames[frame_id].data(), input.get_byte_size());
// 提交异步推理
req.start_async();
// 如果已经完成,可以立即获取结果
if (req.wait_for(std::chrono::milliseconds(0))) {
auto output = req.get_output_tensor();
process_result(output);
}
current++;
}
实战经验:请求池大小通常设置为设备并发能力的2-3倍。例如,GPU可以并行处理8个推理请求,那么池大小设为16-24较为合适。过小的池会导致流水线停顿,过大的池会增加内存开销。
4.3 回调机制与事件驱动
对于高吞吐场景,轮询wait_for()仍然会消耗CPU周期。更优雅的方式是使用set_callback()实现完全的事件驱动。
// 回调方式:请求完成时自动触发处理
ov::InferRequest req = compiled_model.create_infer_request();
req.set_callback([&](std::exception_ptr eptr) {
try {
if (eptr) std::rethrow_exception(eptr);
auto output = req.get_output_tensor();
process_result(output);
// 处理完成后,可以重新提交或回收请求
req.start_async(); // 实现持续流水线
} catch (const std::exception& e) {
std::cerr << "推理回调异常: " << e.what() << std::endl;
}
});
req.start_async(); // 首次启动
回调函数在OpenVINO内部线程池中执行,因此必须保证线程安全。不要在回调中执行耗时操作(如日志写入、网络传输),否则会阻塞内部线程池,影响其他请求的回调。
4.4 案例拆解:视频流实时推理优化
我们以一个实际的视频流处理场景为例,展示如何通过异步API将吞吐提升3倍以上。
场景背景:某安防系统需要实时分析4路1080p视频流,每路30FPS,使用YOLOv8模型在Intel i7-12700H CPU上推理。初始同步方案只能处理2路视频流,CPU利用率仅35%。
关键指标:
- 单帧推理耗时:约25ms(同步模式)
- 帧间预处理耗时:约8ms(缩放、归一化)
- 目标:4路视频流共120FPS处理能力
分析过程:同步模式下,每帧处理总耗时=25ms推理+8ms预处理=33ms,理论最大吞吐约30FPS。但CPU利用率仅35%,说明大量时间在等待I/O或线程切换。通过VTune分析发现,推理期间CPU空闲等待占比达60%。
优化方案:
- 创建8个InferRequest的请求池(4路视频×2级流水线深度)
- 为每路视频分配2个请求,实现“预处理-推理-后处理”三级流水线
- 使用
wait_for(0)非阻塞检查请求状态,避免线程挂起 - 将预处理和后处理分配到独立线程,与推理线程并行
实施步骤:
// 每路视频流独立线程,共享请求池
void video_stream_worker(int stream_id, ov::CompiledModel& model) {
auto& req_pool = request_pools[stream_id]; // 每路2个请求
size_t idx = 0;
while (running) {
auto& req = req_pool[idx % 2];
// 非阻塞检查请求是否可用
if (!req.wait_for(std::chrono::milliseconds(0))) {
std::this_thread::yield(); // 让出CPU
continue;
}
// 从视频流抓取帧(非阻塞)
cv::Mat frame;
if (!capture.read(frame)) break;
// 预处理(耗时8ms)
auto input = req.get_input_tensor();
preprocess(frame, input);
// 提交推理
req.start_async();
// 如果请求已完成,立即获取结果
if (req.wait_for(std::chrono::milliseconds(0))) {
auto output = req.get_output_tensor();
postprocess_and_draw(output, frame);
}
idx++;
}
}
结果对比:
| 方案 | 可处理视频路数 | CPU利用率 | 端到端延迟 | 吞吐(FPS) |
|---|---|---|---|---|
| 同步方案 | 2路 | 35% | 33ms | 60 |
| 异步流水线 | 4路 | 82% | 28ms | 120 |
| 异步+回调 | 4路 | 88% | 26ms | 128 |
风险与代价:异步方案增加了代码复杂度,需要处理线程同步和请求复用逻辑。内存占用从原来的200MB增加到450MB(请求池+帧缓冲)。此外,回调模式需要特别注意异常处理,避免未捕获异常导致线程池崩溃。
常见误区:
- 误区1:认为
start_async()后请求立即开始执行。实际上,请求只是进入设备队列,具体执行时间由设备调度决定。 - 误区2:在回调中调用
wait()。回调本身就在内部线程中执行,再次调用wait()会导致死锁。 - 误区3:忽略
wait_for()的返回值。超时返回false时,请求可能仍在执行,此时访问输出张量会导致未定义行为。 - 误区4:请求池大小设置过大。每个请求都会分配独立的输入输出缓冲区,过多请求会耗尽内存。
4.5 总结与最佳实践
异步推理API的核心价值在于将计算等待时间转化为有效工作时间。通过合理使用start_async()、wait()、wait_for()和set_callback(),可以显著提升硬件利用率。以下是经过验证的最佳实践:
- 请求池化:预先创建固定数量的InferRequest,避免运行时动态创建的开销。
- 非阻塞轮询:使用
wait_for(std::chrono::milliseconds(0))检查请求状态,配合std::this_thread::yield()实现低CPU占用轮询。 - 回调谨慎使用:回调适合高吞吐、低延迟场景,但必须保证回调函数轻量且线程安全。
- 超时策略:在需要保证实时性的场景中,使用
wait_for()设置合理超时(如5ms),超时后放弃当前帧或降低处理优先级。 - 性能监控:定期检查
wait_for(0)的返回值,统计请求完成率,及时发现设备瓶颈。
掌握这些API的细节,你就能构建出高效、稳定的推理流水线。下一层次的问题——如何根据硬件特性动态调整请求池大小、如何结合NUMA架构优化内存访问——将建立在这些基础API之上。