实战案例7:机器学习推理插件(ONNX/TensorRT动态加载)

说实话,把机器学习模型塞进C语言程序里,这事儿我干过不少次。最早是在一个工业视觉检测项目里,甲方要求用TensorRT做推理加速,但整个系统是C写的嵌入式框架。你想想看,总不能为了跑个模型就把整个架构推倒重来吧?

所以动态加载就成了最自然的选择。模型推理作为一个插件,按需加载,按需卸载。今天我就拿ONNX Runtime和TensorRT这两个主流推理引擎,给你演示怎么把它们做成动态库插件。

为什么需要动态加载推理引擎

我遇到过不少团队,一开始把推理引擎静态链接进主程序。结果呢?每次升级模型框架都得重新编译整个项目。更麻烦的是,ONNX和TensorRT的依赖库经常打架——一个要libprotobuf 3.11,另一个非要3.20。

动态加载的好处很明显:

  • 解耦:主程序不需要链接任何推理库
  • 灵活:运行时切换推理后端,甚至热更新模型
  • 隔离:每个插件有自己的依赖环境,互不干扰

核心思路:定义统一的推理接口,ONNX和TensorRT各自实现。主程序通过dlopen/dlsym加载插件,完全不知道底层用的是哪个引擎。

统一的推理接口定义

先定义插件需要暴露的接口。我个人习惯用纯C的结构体函数指针表,这样ABI最稳定,C++的name mangling容易出问题。

// inference_plugin.h
#ifndef INFERENCE_PLUGIN_H
#define INFERENCE_PLUGIN_H

#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

// 推理引擎类型
typedef enum {
    INFERENCE_ENGINE_ONNX = 0,
    INFERENCE_ENGINE_TENSORRT,
    INFERENCE_ENGINE_UNKNOWN
} inference_engine_t;

// 张量数据结构
typedef struct {
    float* data;
    int32_t* shape;
    int32_t ndim;
    int32_t size;  // 元素总数
} tensor_t;

// 模型配置
typedef struct {
    const char* model_path;
    inference_engine_t engine_type;
    int32_t device_id;       // GPU设备ID
    int32_t batch_size;
    int32_t use_fp16;        // 是否使用半精度
} model_config_t;

// 插件接口结构体
typedef struct {
    // 插件信息
    const char* (*get_engine_name)(void);
    const char* (*get_version)(void);
    
    // 生命周期
    int32_t (*init)(const model_config_t* config);
    int32_t (*destroy)(void);
    
    // 推理
    int32_t (*infer)(tensor_t* input, tensor_t* output);
    
    // 查询
    int32_t (*get_input_shape)(int32_t* shape, int32_t* ndim);
    int32_t (*get_output_shape)(int32_t* shape, int32_t* ndim);
} inference_plugin_t;

// 插件必须导出的符号
extern inference_plugin_t plugin;

#ifdef __cplusplus
}
#endif

#endif

这里有个细节:plugin是一个全局变量,不是函数。为什么?因为dlsym获取符号地址时,全局变量比函数更直观,而且可以包含多个函数指针。我在项目里吃过亏——用函数返回结构体,结果不同编译器对返回值的处理方式不一样,踩了不少坑。

ONNX Runtime插件实现

ONNX Runtime的C API其实挺清爽的。不过要注意,它要求先创建环境(OrtEnv),再创建会话(OrtSession)。我习惯在init里一次性搞定。

// onnx_plugin.c
#include "inference_plugin.h"
#include <onnxruntime_c_api.h>
#include <stdlib.h>
#include <string.h>

static const OrtApi* g_ort = NULL;
static OrtEnv* g_env = NULL;
static OrtSession* g_session = NULL;
static OrtMemoryInfo* g_memory_info = NULL;

static const char* get_engine_name(void) {
    return "ONNX Runtime";
}

static const char* get_version(void) {
    return "1.15.0";
}

static int32_t init(const model_config_t* config) {
    // 获取ONNX Runtime API
    g_ort = OrtGetApiBase()->GetApi(ORT_API_VERSION);
    if (!g_ort) return -1;
    
    // 创建环境
    OrtStatus* status = g_ort->CreateEnv(ORT_LOGGING_LEVEL_WARNING, 
                                          "onnx_plugin", &g_env);
    if (status) {
        g_ort->ReleaseStatus(status);
        return -1;
    }
    
    // 创建会话选项
    OrtSessionOptions* session_options;
    g_ort->CreateSessionOptions(&session_options);
    g_ort->SetSessionGraphOptimizationLevel(session_options, 
                                             ORT_ENABLE_ALL);
    
    // 加载模型
    status = g_ort->CreateSession(g_env, config->model_path,
                                  session_options, &g_session);
    g_ort->ReleaseSessionOptions(session_options);
    
    if (status) {
        g_ort->ReleaseStatus(status);
        return -1;
    }
    
    // 创建内存信息
    g_ort->CreateMemoryInfo("Cpu", OrtDeviceAllocator, 0,
                            OrtMemTypeDefault, &g_memory_info);
    
    return 0;
}

static int32_t infer(tensor_t* input, tensor_t* output) {
    if (!g_session || !input || !output) return -1;
    
    // 创建输入张量
    OrtValue* input_tensor = NULL;
    OrtStatus* status = g_ort->CreateTensorWithDataAsOrtValue(
        g_memory_info, input->data, input->size * sizeof(float),
        input->shape, input->ndim, ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT,
        &input_tensor);
    if (status) return -1;
    
    // 运行推理
    OrtValue* output_tensor = NULL;
    const char* input_names[] = {"input"};
    const char* output_names[] = {"output"};
    
    status = g_ort->Run(g_session, NULL, input_names,
                         &input_tensor, 1, output_names, 1,
                         &output_tensor);
    g_ort->ReleaseValue(input_tensor);
    if (status) return -1;
    
    // 获取输出数据
    float* output_data;
    g_ort->GetTensorMutableData(output_tensor, (void**)&output_data);
    
    // 获取输出形状
    OrtTensorTypeAndShapeInfo* shape_info;
    g_ort->GetTensorTypeAndShape(output_tensor, &shape_info);
    g_ort->GetDimensions(shape_info, output->shape, &output->ndim);
    
    // 计算元素总数
    output->size = 1;
    for (int i = 0; i < output->ndim; i++) {
        output->size *= output->shape[i];
    }
    
    // 拷贝数据
    memcpy(output->data, output_data, output->size * sizeof(float));
    
    g_ort->ReleaseTensorTypeAndShapeInfo(shape_info);
    g_ort->ReleaseValue(output_tensor);
    
    return 0;
}

// ... 其他函数实现省略

inference_plugin_t plugin = {
    .get_engine_name = get_engine_name,
    .get_version = get_version,
    .init = init,
    .destroy = destroy,
    .infer = infer,
    .get_input_shape = get_input_shape,
    .get_output_shape = get_output_shape
};

小技巧:ONNX Runtime的API版本很重要。我遇到过客户用1.12的库,但插件编译时链接的是1.14的头文件,结果运行时直接崩了。建议在get_version里返回编译时的版本号,主程序可以做个校验。

TensorRT插件实现

TensorRT的API比ONNX Runtime复杂一些,因为它需要先构建引擎(engine),再创建执行上下文(execution context)。构建引擎这一步很慢,我一般会在init里从序列化文件加载,避免每次启动都重新构建。

// tensorrt_plugin.c
#include "inference_plugin.h"
#include <NvInfer.h>
#include <cuda_runtime_api.h>
#include <stdlib.h>
#include <string.h>

using namespace nvinfer1;

class Logger : public ILogger {
    void log(Severity severity, const char* msg) override {
        // 只打印警告和错误
        if (severity <= Severity::kWARNING) {
            fprintf(stderr, "[TensorRT] %s\n", msg);
        }
    }
};

static Logger g_logger;
static IRuntime* g_runtime = NULL;
static ICudaEngine* g_engine = NULL;
static IExecutionContext* g_context = NULL;
static cudaStream_t g_stream = NULL;
static void* g_buffers[2];  // 输入输出缓冲区

static int32_t init(const model_config_t* config) {
    // 创建runtime
    g_runtime = createInferRuntime(g_logger);
    if (!g_runtime) return -1;
    
    // 读取序列化引擎文件
    FILE* fp = fopen(config->model_path, "rb");
    if (!fp) return -1;
    
    fseek(fp, 0, SEEK_END);
    size_t size = ftell(fp);
    rewind(fp);
    
    char* engine_data = (char*)malloc(size);
    fread(engine_data, 1, size, fp);
    fclose(fp);
    
    // 反序列化引擎
    g_engine = g_runtime->deserializeCudaEngine(engine_data, size);
    free(engine_data);
    
    if (!g_engine) return -1;
    
    // 创建执行上下文
    g_context = g_engine->createExecutionContext();
    if (!g_context) return -1;
    
    // 创建CUDA流
    cudaStreamCreate(&g_stream);
    
    // 分配GPU内存
    int input_idx = g_engine->getBindingIndex("input");
    int output_idx = g_engine->getBindingIndex("output");
    
    Dims input_dims = g_engine->getBindingDimensions(input_idx);
    Dims output_dims = g_engine->getBindingDimensions(output_idx);
    
    size_t input_size = 1, output_size = 1;
    for (int i = 0; i < input_dims.nbDims; i++) input_size *= input_dims.d[i];
    for (int i = 0; i < output_dims.nbDims; i++) output_size *= output_dims.d[i];
    
    cudaMalloc(&g_buffers[0], input_size * sizeof(float));
    cudaMalloc(&g_buffers[1], output_size * sizeof(float));
    
    return 0;
}

static int32_t infer(tensor_t* input, tensor_t* output) {
    if (!g_context || !input || !output) return -1;
    
    // 拷贝输入数据到GPU
    cudaMemcpyAsync(g_buffers[0], input->data,
                    input->size * sizeof(float),
                    cudaMemcpyHostToDevice, g_stream);
    
    // 执行推理
    g_context->enqueueV2(g_buffers, g_stream, nullptr);
    
    // 拷贝输出数据回CPU
    cudaMemcpyAsync(output->data, g_buffers[1],
                    output->size * sizeof(float),
                    cudaMemcpyDeviceToHost, g_stream);
    
    cudaStreamSynchronize(g_stream);
    
    return 0;
}

// ... 其他函数实现省略

extern "C" {
    inference_plugin_t plugin = {
        .get_engine_name = get_engine_name,
        .get_version = get_version,
        .init = init,
        .destroy = destroy,
        .infer = infer,
        .get_input_shape = get_input_shape,
        .get_output_shape = get_output_shape
    };
}

注意:TensorRT的序列化引擎文件是跟GPU架构绑定的。我曾经把在A100上生成的引擎文件放到T4上跑,结果加载直接失败。所以生产环境里,我一般会在部署时根据实际GPU型号重新构建引擎,或者准备多份引擎文件。

主程序动态加载

主程序这边就简单了。用dlopen加载插件,dlsym获取plugin符号,然后调用接口就行。

// main.c
#include <dlfcn.h>
#include <stdio.h>
#include "inference_plugin.h"

int main(int argc, char* argv[]) {
    if (argc < 3) {
        fprintf(stderr, "Usage: %s <plugin.so> <model_path>\n", argv[0]);
        return 1;
    }
    
    // 加载插件
    void* handle = dlopen(argv[1], RTLD_LAZY | RTLD_LOCAL);
    if (!handle) {
        fprintf(stderr, "Failed to load plugin: %s\n", dlerror());
        return 1;
    }
    
    // 获取插件接口
    inference_plugin_t* plugin = dlsym(handle, "plugin");
    if (!plugin) {
        fprintf(stderr, "Failed to find plugin symbol\n");
        dlclose(handle);
        return 1;
    }
    
    printf("Loaded engine: %s v%s\n",
           plugin->get_engine_name(),
           plugin->get_version());
    
    // 配置模型
    model_config_t config = {
        .model_path = argv[2],
        .engine_type = INFERENCE_ENGINE_UNKNOWN,
        .device_id = 0,
        .batch_size = 1,
        .use_fp16 = 0
    };
    
    // 初始化
    if (plugin->init(&config) != 0) {
        fprintf(stderr, "Failed to init plugin\n");
        dlclose(handle);
        return 1;
    }
    
    // 准备输入数据
    tensor_t input = {
        .data = (float*)malloc(3 * 224 * 224 * sizeof(float)),
        .shape = (int32_t[]){1, 3, 224, 224},
        .ndim = 4,
        .size = 3 * 224 * 224
    };
    
    tensor_t output = {
        .data = (float*)malloc(1000 * sizeof(float)),
        .shape = (int32_t[]){1, 1000},
        .ndim = 2,
        .size = 1000
    };
    
    // 执行推理
    if (plugin->infer(&input, &output) == 0) {
        printf("Inference succeeded!\n");
        // 处理输出...
    }
    
    // 清理
    plugin->destroy();
    free(input.data);
    free(output.data);
    dlclose(handle);
    
    return 0;
}

编译与测试

编译插件时,记得用-fPIC-shared。主程序链接-ldl

# 编译ONNX插件
gcc -fPIC -shared -o onnx_plugin.so onnx_plugin.c \
    -I/path/to/onnxruntime/include \
    -L/path/to/onnxruntime/lib -lonnxruntime

# 编译TensorRT插件
g++ -fPIC -shared -o tensorrt_plugin.so tensorrt_plugin.c \
    -I/path/to/tensorrt/include \
    -L/path/to/tensorrt/lib -lnvinfer -lcudart

# 编译主程序
gcc -o inference_app main.c -ldl

# 测试
./inference_app ./onnx_plugin.so ./model.onnx
./inference_app ./tensorrt_plugin.so ./model.engine

调试技巧:如果dlopen失败,先检查LD_LIBRARY_PATH有没有包含推理库的路径。我经常用ldd plugin.so查看依赖是否都找到了。另外,dlerror()返回的错误信息很详细,一定要打出来看。

插件加载架构图

下面这张图展示了整个动态加载推理插件的流程。主程序通过统一的接口与插件交互,底层可以是ONNX Runtime、TensorRT,或者将来任何新的推理引擎。

机器学习推理插件动态加载架构 主程序 dlopen / dlsym 统一推理接口 inference_plugin_t 结构体 ONNX Runtime 插件 onnx_plugin.so libonnxruntime.so TensorRT 插件 tensorrt_plugin.so libnvinfer.so model.onnx ONNX格式模型 model.engine TensorRT序列化引擎 主程序通过统一接口调用,底层推理引擎可动态切换

这张图其实反映了我做插件化架构的一个核心理念:接口稳定,实现灵活。主程序只跟inference_plugin_t打交道,至于背后是ONNX还是TensorRT,它根本不关心。哪天想换成OpenVINO或者TFLite,再写个新插件就行,主程序一行代码都不用改。

嗯,最后说一句。动态加载推理引擎这件事,看起来技术含量不高,但实际落地时坑不少。比如CUDA上下文的管理、多线程下的线程安全、GPU内存泄漏等等。我建议你在做生产环境时,至少加上插件健康检查、异常重启、性能监控这几个机制。别问我怎么知道的——都是血泪教训。

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