4. 基础推理流程:创建runtime、反序列化引擎、创建context、执行推理
好,咱们今天来聊聊TensorRT推理最核心的那几步。说白了,就是怎么把训练好的模型跑起来。
我记得刚接触TensorRT那会儿,看着文档里一堆API,心里直犯嘀咕:这玩意儿到底怎么用?后来做多了才发现,核心流程就那么四步。嗯,今天我就把这四步掰开揉碎了讲给你听。
4.1 创建runtime:一切推理的起点
第一步,创建runtime。你可以把它理解成TensorRT的“运行环境”。没有它,后面的事儿都干不了。
代码很简单,就一行:
nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(gLogger);
这里有个细节——gLogger。我刚开始做项目时,随便传了个空指针进去,结果程序跑着跑着就崩了,查了半天才发现是日志没处理好。你想想看,TensorRT内部很多错误信息都是通过这个logger输出的,你要是把它忽略了,出问题了都不知道错在哪儿。
4.2 反序列化引擎:把“编译好的模型”加载进来
第二步,反序列化引擎。这一步说白了,就是把我们之前用TensorRT编译好的.engine文件读进来,加载到内存里。
为什么叫“反序列化”?因为.engine文件是二进制序列化后的模型。你想想看,训练好的模型经过TensorRT优化后,变成了一个高效的执行计划,这个计划被保存成了文件。我们要用的时候,就得把它从文件里“解出来”。
// 读取engine文件
std::ifstream file("model.engine", std::ios::binary);
file.seekg(0, std::ios::end);
size_t size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<char> engineData(size);
file.read(engineData.data(), size);
file.close();
// 反序列化
nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(engineData.data(), size);
我在项目中遇到过一个问题:engine文件明明存在,但反序列化就是失败。后来发现是文件读取不完整——文件大小获取错了。所以,我建议你读取文件后,检查一下实际读取的字节数是否和文件大小一致。
4.3 创建context:推理的“执行上下文”
第三步,创建context。这个context,你可以把它想象成一个“工作台”。引擎是固定的,但每次推理时,我们需要一个独立的工作空间来存放中间结果。
nvinfer1::IExecutionContext* context = engine->createExecutionContext();
这里有个关键点:一个引擎可以创建多个context。为什么?因为多流并发推理时,每个流都需要自己的context。我刚开始做多流优化时,就犯过这个错——多个线程共用一个context,结果数据全乱了。
4.4 执行推理:把数据喂进去,把结果拿出来
第四步,执行推理。这一步最复杂,也最容易出错。我把它拆成三个小步骤:
4.4.1 准备输入输出缓冲区
首先,你得在GPU上分配内存,用来放输入数据和输出结果。
// 获取输入输出binding的索引
int inputIndex = engine->getBindingIndex("input");
int outputIndex = engine->getBindingIndex("output");
// 获取binding的维度
nvinfer1::Dims inputDims = engine->getBindingDimensions(inputIndex);
nvinfer1::Dims outputDims = engine->getBindingDimensions(outputIndex);
// 计算缓冲区大小
size_t inputSize = 1;
for (int i = 0; i < inputDims.nbDims; ++i) {
inputSize *= inputDims.d[i];
}
inputSize *= sizeof(float); // 假设数据类型是float
size_t outputSize = 1;
for (int i = 0; i < outputDims.nbDims; ++i) {
outputSize *= outputDims.d[i];
}
outputSize *= sizeof(float);
// 在GPU上分配内存
void* inputBuffer;
void* outputBuffer;
cudaMalloc(&inputBuffer, inputSize);
cudaMalloc(&outputBuffer, outputSize);
这里有个坑:binding的索引不一定按顺序来。我见过有人硬编码索引0是输入、1是输出,结果换了个模型就全乱了。所以,我建议你始终用getBindingIndex来获取索引,别偷懒。
4.4.2 把数据拷贝到GPU
准备好了缓冲区,接下来就是把CPU上的数据拷贝到GPU上。
// 假设cpuInputData是CPU上的输入数据
cudaMemcpy(inputBuffer, cpuInputData, inputSize, cudaMemcpyHostToDevice);
这一步看起来简单,但性能影响很大。为什么?因为CPU到GPU的拷贝是PCIe传输,速度远不如GPU内部的计算。我曾经在一个项目中,推理时间只有2ms,但数据拷贝花了5ms——你说这优化了个寂寞?
4.4.3 执行推理并取回结果
最后,调用enqueueV2执行推理,然后把结果从GPU拷回CPU。
// 创建CUDA流
cudaStream_t stream;
cudaStreamCreate(&stream);
// 准备binding数组
void* bindings[] = {inputBuffer, outputBuffer};
// 执行推理(异步)
context->enqueueV2(bindings, stream, nullptr);
// 同步等待推理完成
cudaStreamSynchronize(stream);
// 把结果拷回CPU
cudaMemcpy(cpuOutputData, outputBuffer, outputSize, cudaMemcpyDeviceToHost);
// 清理
cudaStreamDestroy(stream);
这里要注意:enqueueV2是异步的,它把任务提交到CUDA流后就立即返回了。你必须调用cudaStreamSynchronize或者cudaDeviceSynchronize来等待推理完成,否则拿到的结果是错的。
4.5 完整的推理流程示例
好了,把上面这些串起来,就是一个完整的推理流程。我贴个完整的代码示例:
// 1. 创建runtime
nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(gLogger);
// 2. 反序列化引擎
std::vector<char> engineData = loadEngineFile("model.engine");
nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(engineData.data(), engineData.size());
// 3. 创建context
nvinfer1::IExecutionContext* context = engine->createExecutionContext();
// 4. 准备缓冲区
int inputIdx = engine->getBindingIndex("input");
int outputIdx = engine->getBindingIndex("output");
// ... 计算大小,分配GPU内存 ...
// 5. 执行推理
cudaStream_t stream;
cudaStreamCreate(&stream);
cudaMemcpyAsync(inputBuffer, cpuInput, inputSize, cudaMemcpyHostToDevice, stream);
context->enqueueV2(bindings, stream, nullptr);
cudaMemcpyAsync(cpuOutput, outputBuffer, outputSize, cudaMemcpyDeviceToHost, stream);
cudaStreamSynchronize(stream);
// 6. 清理
cudaStreamDestroy(stream);
context->destroy();
engine->destroy();
runtime->destroy();
嗯,这就是最基础的推理流程。你可能会问:就这么简单?对,核心流程就这么四步。但实际项目中,每一步都有很多优化空间。比如多流并发、动态形状、异步流水线等等,这些我们后面会慢慢讲。
记住一句话:基础不牢,地动山摇。把这四步吃透了,后面的优化才能得心应手。