29、C++与机器学习:ONNX Runtime集成、TensorRT C++ API、高性能推理引擎、模型部署实践

说实话,很多做C++的朋友一听到「机器学习部署」就觉得头大。觉得那是Python工程师的事,跟自己没关系。

但我得说,这种想法得改改了。真正的高性能推理,最终还是要落到C++头上。Python只是用来训练和实验的,生产环境里,C++才是主角。

今天我们就聊聊,怎么用C++把训练好的模型跑起来,跑得快,跑得稳。

为什么是C++来做推理?

你想想看,Python的GIL锁、动态类型、解释执行……这些特性在训练阶段无所谓,但在线上推理时,每一个微秒都很关键。我见过不少团队,模型在Python里跑得还行,一上生产就崩,延迟直接翻倍。

C++的优势很明显:

  • 零运行时开销:没有解释器,没有GC,代码就是机器指令
  • 内存控制精细:你可以手动管理每一块显存,避免碎片化
  • 多线程亲和:C++的线程模型和硬件绑定更紧密,延迟可控

说白了,如果你在乎性能,C++是绕不开的。

ONNX Runtime:跨平台的推理中间件

ONNX Runtime是微软开源的推理引擎。它最大的好处是——你不用关心模型是用什么框架训练的。PyTorch、TensorFlow、还是飞桨?只要导出成ONNX格式,ONNX Runtime都能吃进去。

集成方式

我习惯用CMake来管理ONNX Runtime的依赖。官方提供了预编译的库,直接下载就行。

# CMakeLists.txt 示例
find_package(onnxruntime REQUIRED)
target_link_libraries(my_app PRIVATE onnxruntime::onnxruntime)

一个完整的推理示例

#include <onnxruntime/core/session/onnxruntime_cxx_api.h>
#include <iostream>
#include <vector>

int main() {
    // 1. 创建环境
    Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "test");
    Ort::SessionOptions session_options;
    session_options.SetIntraOpNumThreads(4);

    // 2. 加载模型
    const char* model_path = "model.onnx";
    Ort::Session session(env, model_path, session_options);

    // 3. 获取输入输出信息
    Ort::AllocatorWithDefaultOptions allocator;
    auto input_name = session.GetInputNameAllocated(0, allocator).get();
    auto output_name = session.GetOutputNameAllocated(0, allocator).get();

    // 4. 准备输入数据
    std::vector<float> input_data(1 * 3 * 224 * 224, 1.0f);
    std::vector<int64_t> input_shape = {1, 3, 224, 224};

    Ort::MemoryInfo memory_info = Ort::MemoryInfo::CreateCpu(
        OrtArenaAllocator, OrtMemTypeDefault);

    Ort::Value input_tensor = Ort::Value::CreateTensor<float>(
        memory_info, input_data.data(), input_data.size(),
        input_shape.data(), input_shape.size());

    // 5. 执行推理
    const char* input_names[] = {input_name};
    const char* output_names[] = {output_name};
    auto output_tensors = session.Run(Ort::RunOptions{nullptr},
                                      input_names, &input_tensor, 1,
                                      output_names, 1);

    // 6. 获取结果
    float* output_data = output_tensors[0].GetTensorMutableData<float>();
    std::cout << "推理完成,第一个输出值: " << output_data[0] << std::endl;

    return 0;
}
我的经验:ONNX Runtime的线程数设置很关键。不是越多越好。我遇到过把线程数设成16,结果因为CPU超线程竞争,反而比4线程还慢。建议根据实际CPU核心数来调。

TensorRT C++ API:NVIDIA的加速利器

如果你用的是NVIDIA显卡,那TensorRT几乎是必选项。它能把模型做量化、层融合、内核自动调优……说白了,就是榨干GPU的每一分性能。

构建引擎

TensorRT的工作流程是:ONNX模型 → 构建引擎 → 序列化保存 → 推理时反序列化加载。

#include <NvInfer.h>
#include <NvOnnxParser.h>
#include <fstream>
#include <memory>

// 构建引擎
nvinfer1::ICudaEngine* buildEngine(
    nvinfer1::IBuilder* builder,
    nvinfer1::IBuilderConfig* config,
    const std::string& onnx_path) {

    auto network = builder->createNetworkV2(
        1U << static_cast<int>(
            nvinfer1::NetworkDefinitionCreationFlag::kEXPLICIT_BATCH));

    auto parser = nvonnxparser::createParser(*network, gLogger);
    parser->parseFromFile(onnx_path.c_str(),
                          static_cast<int>(nvinfer1::ILogger::Severity::kWARNING));

    // 设置工作空间大小和精度
    config->setMemoryPoolLimit(
        nvinfer1::MemoryPoolType::kWORKSPACE, 1 << 30); // 1GB
    config->setFlag(nvinfer1::BuilderFlag::kFP16); // 启用FP16

    return builder->buildSerializedNetwork(*network, *config);
}

推理执行

void infer(nvinfer1::IExecutionContext* context,
           float* input, float* output,
           int batch_size) {
    // 绑定输入输出缓冲区
    void* buffers[2];
    cudaMalloc(&buffers[0], batch_size * 3 * 224 * 224 * sizeof(float));
    cudaMalloc(&buffers[1], batch_size * 1000 * sizeof(float));

    cudaMemcpy(buffers[0], input,
               batch_size * 3 * 224 * 224 * sizeof(float),
               cudaMemcpyHostToDevice);

    context->executeV2(buffers);

    cudaMemcpy(output, buffers[1],
               batch_size * 1000 * sizeof(float),
               cudaMemcpyDeviceToHost);

    cudaFree(buffers[0]);
    cudaFree(buffers[1]);
}
注意:TensorRT的引擎是跟显卡型号绑定的。你在A100上构建的引擎,拿到T4上跑不了。我当初踩过这个坑,部署到客户机器上直接崩溃。解决方案是:在目标机器上重新构建,或者用ONNX Runtime做fallback。

高性能推理引擎的设计要点

光会调API还不够,真正的工程落地,还得考虑架构设计。我总结了几条核心原则:

  1. 批处理(Batching):单条推理效率低,攒一批一起算,吞吐量能翻好几倍。我习惯用动态批处理,设置一个最大延迟阈值,到了就触发推理。
  2. 异步流水线:预处理、推理、后处理用三个线程流水线并行。不要让GPU闲着等CPU。
  3. 内存池复用:每次推理都new/delete内存,性能损耗很大。用内存池预分配,重复使用。
  4. 模型预热:刚加载的模型第一次推理很慢,因为缓存没建立。启动时先跑几次假数据,把状态预热好。

模型部署实践:从开发到上线

部署不是把模型文件扔到服务器上就完事了。我经历过几次线上事故后,总结了一套流程:

阶段 关键动作 常见坑
模型导出 ONNX导出,检查算子兼容性 动态shape没处理好,导出失败
精度验证 对比原始模型和推理引擎的输出 FP16量化后精度下降,需要校准
性能压测 用真实数据跑压力测试 显存泄漏,跑几天就OOM
灰度上线 先切1%流量观察 模型版本管理混乱,回滚困难

核心思路:推理引擎只是工具,真正的难点在于工程化。把模型部署当成一个独立的服务来设计,做好监控、日志、版本管理、优雅降级。这样即使模型出了问题,服务也不会崩。

知识体系总览

下面这张图,是我梳理的C++机器学习推理的核心知识结构。你可以把它当作一个checklist,看看自己哪些地方还没覆盖到。

C++ 推理引擎 ONNX Runtime 跨平台 · 多框架兼容 CPU/GPU 双后端 TensorRT FP16/INT8 量化 层融合 · 内核调优 模型部署实践 批处理 · 异步流水线 内存池 · 模型预热 核心目标:低延迟 · 高吞吐 · 稳定可靠

这张图把今天讲的内容串起来了。左边是ONNX Runtime,主打通用和兼容;右边是TensorRT,主打极致性能;底部是部署实践,把两者落地到生产环境。

我个人建议,刚开始做推理部署的同学,先从ONNX Runtime入手。它简单、稳定、坑少。等你对整个流程熟悉了,再上TensorRT做极致优化。别一上来就搞量化、搞层融合,容易把自己搞懵。

一个小技巧:如果你不确定用哪个引擎,可以两个都集成。用ONNX Runtime做fallback,TensorRT做主力。一旦TensorRT出问题(比如算子不支持),自动降级到ONNX Runtime。我现在的项目就是这么干的,稳得很。

好了,关于C++与机器学习推理引擎的内容,今天就聊到这里。代码示例都在上面了,你可以直接拿去跑。遇到问题别慌,多看看日志,多打几个断点,总能找到原因。


公众号:蓝海资料掘金营,微信deep3321