4. TensorRT深度解析(下)

上一讲我们把TensorRT的静态图优化聊透了。今天咱们来啃几块硬骨头——动态形状、自定义插件,还有API实战。说实话,这三块才是真正区分“会用”和“用好”的分水岭。

我在生产环境里踩过的坑,多半都跟动态形状和插件有关。你想想看,模型部署到线上,输入尺寸不可能永远固定。客户传一张1920×1080的图,你总不能让人家先缩放到512×512吧?

4.1 动态形状支持:别再写死输入尺寸了

TensorRT从7.0开始支持动态形状。说白了,就是允许你在推理时改变输入的batch size、通道数、高和宽。但代价是——性能会打折扣,因为优化器没法做极致的常量折叠。

4.1.1 动态形状的三要素

要启用动态形状,你得在构建引擎时指定三个范围:

参数 含义 示例
min 最小输入尺寸 [1, 3, 224, 224]
opt 最优输入尺寸(性能调优目标) [8, 3, 224, 224]
max 最大输入尺寸 [32, 3, 224, 224]

我个人习惯把opt设成线上最常见的batch size。比如线上QPS要求高,batch=8最常用,那就把opt设成8。TensorRT会针对这个尺寸做最激进的优化。

注意:动态形状下,TensorRT不会为所有可能的尺寸生成最优kernel。它只在opt附近做深度优化,离opt越远,性能退化越明显。我曾经见过batch从8切到1时,延迟反而增加了30%——就是因为优化器没覆盖到那个区域。

4.1.2 C++ API配置动态形状

// 创建builder和network
nvinfer1::IBuilder* builder = nvinfer1::createInferBuilder(logger);
nvinfer1::INetworkDefinition* network = builder->createNetworkV2(1U);

// 定义输入张量,维度用-1表示动态
nvinfer1::ITensor* input = network->addInput(
    "input", nvinfer1::DataType::kFLOAT, 
    nvinfer1::Dims4{-1, 3, -1, -1});

// 创建优化配置
nvinfer1::IOptimizationProfile* profile = builder->createOptimizationProfile();
profile->setDimensions("input", nvinfer1::OptProfileSelector::kMIN, 
                       nvinfer1::Dims4{1, 3, 224, 224});
profile->setDimensions("input", nvinfer1::OptProfileSelector::kOPT, 
                       nvinfer1::Dims4{8, 3, 224, 224});
profile->setDimensions("input", nvinfer1::OptProfileSelector::kMAX, 
                       nvinfer1::Dims4{32, 3, 224, 224});

// 配置config
nvinfer1::IBuilderConfig* config = builder->createBuilderConfig();
config->addOptimizationProfile(profile);

// 构建引擎
nvinfer1::IHostMemory* serializedModel = builder->buildSerializedNetwork(*network, *config);

这段代码里,Dims4{-1, 3, -1, -1}表示batch和宽高都是动态的。注意通道数我写死了3——除非你处理的是多模态数据,否则通道数一般固定。

4.1.3 推理时设置输入尺寸

引擎构建好之后,每次推理前你得告诉它这次用多大尺寸:

// 创建执行上下文
nvinfer1::IExecutionContext* context = engine->createExecutionContext();

// 假设这次输入是 batch=4, 640x480
nvinfer1::Dims4 inputDims{4, 3, 480, 640};
context->setBindingDimensions(0, inputDims);

// 检查是否有效
if (!context->allInputDimensionsSpecified()) {
    // 处理错误
}

// 执行推理
context->enqueueV2(buffers, stream, nullptr);
小技巧:如果你频繁切换输入尺寸,可以考虑创建多个execution context,每个绑定一个固定的profile。这样省去了每次setDimensions的开销。我在视频流处理项目里就是这么干的——4路视频各绑一个context,互不干扰。

4.2 插件编写:当TensorRT不够用时

TensorRT内置了200多种算子,但总有些骚操作它不支持。比如我做过一个模型,里面有个自定义的“可变形卷积”,TensorRT压根不认识。这时候就得自己写插件。

插件说白了,就是告诉TensorRT:“这个算子的计算逻辑是什么,梯度怎么传,内存怎么分配”。

4.2.1 插件基类结构

TensorRT的插件体系分两层:

  • IPluginV2DynamicExt:支持动态形状的插件基类(推荐)
  • IPluginV2IOExt:旧版基类,不支持动态形状

我建议你直接用IPluginV2DynamicExt。虽然接口多一点,但兼容性更好。下面是一个简单的ReLU插件骨架:

class MyReLUPlugin : public nvinfer1::IPluginV2DynamicExt {
public:
    // 返回插件名称
    const char* getPluginType() const noexcept override {
        return "MyReLU";
    }
    
    // 返回插件版本
    const char* getPluginVersion() const noexcept override {
        return "1";
    }
    
    // 获取输出维度(动态形状下需要根据输入计算)
    nvinfer1::DimsExprs getOutputDimensions(
        int outputIndex, const nvinfer1::DimsExprs* inputs, 
        int nbInputs, nvinfer1::IExprBuilder& exprBuilder) noexcept override {
        // ReLU不改变形状,直接返回输入维度
        return inputs[0];
    }
    
    // 检查输入输出格式是否支持
    bool supportsFormatCombination(
        int pos, const nvinfer1::PluginTensorDesc* inOut, 
        int nbInputs, int nbOutputs) noexcept override {
        // 只支持float32
        return inOut[pos].type == nvinfer1::DataType::kFLOAT;
    }
    
    // 配置插件(分配workspace等)
    int enqueue(const nvinfer1::PluginTensorDesc* inputDesc,
                const nvinfer1::PluginTensorDesc* outputDesc,
                const void* const* inputs, void* const* outputs,
                void* workspace, cudaStream_t stream) noexcept override {
        // 这里写CUDA kernel调用
        const float* input = static_cast<const float*>(inputs[0]);
        float* output = static_cast<float*>(outputs[0]);
        int64_t volume = 1;
        for (int i = 0; i < inputDesc[0].dims.nbDims; ++i) {
            volume *= inputDesc[0].dims.d[i];
        }
        myReluKernel<<<(volume + 255) / 256, 256, 0, stream>>>(input, output, volume);
        return 0;
    }
    
    // 克隆插件
    nvinfer1::IPluginV2DynamicExt* clone() const noexcept override {
        return new MyReLUPlugin(*this);
    }
    
    // 序列化
    void serialize(void* buffer) const noexcept override {
        // 如果有成员变量,需要序列化
    }
    
    // 反序列化构造
    MyReLUPlugin(const void* data, size_t length) {
        // 从buffer恢复成员变量
    }
};
核心要点:插件最难写的其实是getOutputDimensions和supportsFormatCombination。前者决定了TensorRT能否正确构建计算图,后者决定了你的插件能跟哪些算子搭配。我见过有人把输出维度算错了,结果引擎构建成功但推理结果全错——debug了一整天。

4.2.2 插件注册与使用

写好了插件类,还得注册到TensorRT的插件工厂里:

// 注册插件
class MyPluginFactory : public nvinfer1::IPluginFactory {
public:
    nvinfer1::IPluginV2* createPlugin(
        const char* layerName, const void* serialData, 
        size_t serialLength) noexcept override {
        return new MyReLUPlugin(serialData, serialLength);
    }
};

// 反序列化引擎时传入工厂
MyPluginFactory factory;
nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(logger);
nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(
    modelData, modelSize, &factory);

在ONNX模型中,你可以通过自定义节点来触发插件。比如在PyTorch中导出ONNX时,用torch.onnx.register_custom_op把自定义操作映射到插件名称上。

踩坑记录:我曾经写过一个插件,在enqueue里忘了同步CUDA stream,结果推理结果时对时错。排查了两天才发现是异步执行导致的race condition。记住:enqueue是异步的,如果你在kernel之后有host端的操作,记得加cudaStreamSynchronize。

4.3 C++/Python API实战:两种语言,一个引擎

TensorRT提供了C++和Python两套API。C++性能更好,Python开发更快。我的建议是:原型验证用Python,生产部署用C++。

4.3.1 Python API快速上手

Python API最大的优势是跟PyTorch/TensorFlow无缝衔接。下面是一个完整的推理流程:

import tensorrt as trt
import numpy as np
import pycuda.driver as cuda
import pycuda.autoinit

# 创建logger
logger = trt.Logger(trt.Logger.WARNING)

# 加载引擎
with open("model.engine", "rb") as f:
    engine_data = f.read()
runtime = trt.Runtime(logger)
engine = runtime.deserialize_cuda_engine(engine_data)

# 创建执行上下文
context = engine.create_execution_context()

# 分配GPU内存
inputs = []
outputs = []
bindings = []
for i in range(engine.num_bindings):
    shape = engine.get_binding_shape(i)
    size = trt.volume(shape)
    dtype = trt.nptype(engine.get_binding_dtype(i))
    
    # 分配host和device内存
    host_mem = cuda.pagelocked_empty(size, dtype)
    device_mem = cuda.mem_alloc(host_mem.nbytes)
    
    bindings.append(int(device_mem))
    if engine.binding_is_input(i):
        inputs.append({"host": host_mem, "device": device_mem})
    else:
        outputs.append({"host": host_mem, "device": device_mem})

# 准备输入数据
input_data = np.random.randn(1, 3, 224, 224).astype(np.float32)
np.copyto(inputs[0]["host"], input_data.ravel())

# 推理
stream = cuda.Stream()
cuda.memcpy_htod_async(inputs[0]["device"], inputs[0]["host"], stream)
context.execute_async_v2(bindings, stream.handle)
cuda.memcpy_dtoh_async(outputs[0]["host"], outputs[0]["device"], stream)
stream.synchronize()

# 获取结果
result = outputs[0]["host"].reshape(engine.get_binding_shape(1))
print(result)

这段代码里,cuda.pagelocked_empty分配的是页锁定内存,能加速CPU到GPU的传输。我刚开始用普通numpy数组,结果传输速度慢了一倍。

4.3.2 C++ API性能调优

C++ API在性能调优上更灵活。比如你可以用enqueueV2配合CUDA stream实现异步推理:

// 创建多个stream实现并发
cudaStream_t streams[4];
for (int i = 0; i < 4; ++i) {
    cudaStreamCreate(&streams[i]);
}

// 每个stream绑定一个context
nvinfer1::IExecutionContext* contexts[4];
for (int i = 0; i < 4; ++i) {
    contexts[i] = engine->createExecutionContext();
}

// 并发推理
for (int i = 0; i < 4; ++i) {
    contexts[i]->enqueueV2(buffers[i], streams[i], nullptr);
}

// 同步所有stream
for (int i = 0; i < 4; ++i) {
    cudaStreamSynchronize(streams[i]);
}

这种多stream并发的方式,能把GPU利用率从60%拉到95%以上。我在一个OCR服务里就是这么干的——4个stream处理4张图片,吞吐量直接翻倍。

4.3.3 性能对比:Python vs C++

维度 Python API C++ API
开发效率 高(原型验证快) 低(需要编译)
推理延迟 略高(GIL + Python调用开销) 低(直接CUDA调用)
内存控制 受限(依赖pycuda) 精细(可自定义allocator)
多线程支持 差(GIL限制) 好(原生线程安全)
调试便利性 好(可交互式调试) 差(需要gdb)

我个人的经验是:延迟敏感场景用C++,吞吐量敏感场景用Python。为什么?因为Python的GIL在多batch推理时影响不大,但单batch延迟会多出1-2ms。

一个实用技巧:如果你非要用Python部署,可以考虑用torch.cuda.Stream配合TensorRT的Python API,实现类似C++的异步效果。我在一个实时视频处理项目里就是这么做的——Python写业务逻辑,底层推理用异步stream,延迟只比C++版本高0.5ms。

4.4 本章知识体系

下面这张图总结了动态形状、插件编写和API实战的核心脉络:

TensorRT深度解析(下)知识体系 动态形状支持 三要素:min / opt / max 构建时配置profile 推理时setDimensions 多context绑定不同profile 性能随尺寸偏移而退化 插件编写 IPluginV2DynamicExt getOutputDimensions supportsFormatCombination enqueue写CUDA kernel 注册到插件工厂 C++/Python API实战 Python:快速原型验证 C++:生产部署调优 多stream并发推理 页锁定内存加速传输 延迟敏感用C++ 核心原则:动态形状定范围,插件编写补缺失,API选择看场景 常见避坑指南 • 动态形状下opt尺寸选错,性能可能下降30%以上 • 插件enqueue里忘记同步stream,推理结果随机错误 • Python API用普通numpy数组传输,速度比页锁定内存慢一倍