4、插件基类IPluginV2详解:核心虚函数、生命周期、序列化与反序列化
好,咱们今天来啃一块硬骨头——IPluginV2。说实话,这个基类是整个自定义插件开发的基石。你写的每一个插件,本质上都是在跟这个类打交道。我刚开始接触TensorRT时,看着那一堆纯虚函数,说实话有点懵。但后来做多了就发现,其实它的设计思路非常清晰,就是一套「生命周期管理」加上「序列化契约」。
4.1 核心虚函数:你必须实现的那些接口
IPluginV2 里定义了十几个纯虚函数。但别怕,真正需要你手写的,其实就那么几个。我按功能把它们分成了三组,这样好记。
4.1.1 元信息查询类
这类函数负责告诉TensorRT:「我这个插件长什么样」。说白了,就是汇报你的输入输出维度、数据类型、以及名字。
| 函数签名 | 作用 | 我的经验 |
|---|---|---|
getNbOutputs() const |
返回输出张量的数量 | 大部分插件输出1个,但像一些检测头会输出多个 |
getOutputDimensions() |
根据输入维度计算输出维度 | 这里最容易翻车,我后面会讲 |
getOutputDataType() |
返回输出张量的数据类型 | 通常跟输入保持一致,除非你做量化 |
getPluginType() |
返回插件的唯一标识字符串 | 这个字符串在序列化时会被用到 |
getOutputDimensions 这个函数,TensorRT在构建网络时会多次调用。我曾经在这里犯过一个低级错误——在里面做了动态内存分配,结果导致构建阶段内存泄漏。记住,这个函数应该是纯计算的,不要有任何副作用。
4.1.2 配置与初始化类
这类函数负责插件的「启动」和「关闭」。嗯,你可以理解为构造函数和析构函数的补充。
// 配置阶段:TensorRT会调用这个函数,告诉你一些全局信息
int initialize() noexcept;
// 销毁阶段:释放你申请的资源
void terminate() noexcept;
// 设置插件所在的命名空间(可选)
void setPluginNamespace(const char* pluginNamespace) noexcept;
// 获取插件所在的命名空间
const char* getPluginNamespace() const noexcept;
我个人习惯在 initialize() 里做两件事:一是分配CUDA内存,二是初始化一些常量。比如你要实现一个自定义的LayerNorm,可以在 initialize() 里把 gamma 和 beta 拷贝到设备端。
那 terminate() 呢?就是做相反的事——释放内存、销毁CUDA流、关闭句柄。记住,TensorRT不会自动帮你释放你在插件里申请的资源,你得自己擦屁股。
4.1.3 执行与序列化类
这部分是插件的「灵魂」——真正干活的地方。
// 前向推理:核心计算逻辑
int enqueue(int batchSize,
void const* const* inputs,
void* const* outputs,
void* workspace,
cudaStream_t stream) noexcept;
// 返回工作空间大小
size_t getWorkspaceSize() const noexcept;
// 返回序列化后的大小
size_t getSerializationSize() const noexcept;
// 序列化:把你的插件参数打包成字节流
void serialize(void* buffer) const noexcept;
enqueue 这个函数,说白了就是你的CUDA kernel入口。你在这里启动你的自定义kernel,所有输入输出都是设备指针。我建议你在 enqueue 里只做两件事:参数检查 + 启动kernel。不要做任何同步操作,否则会阻塞CUDA流,影响性能。
4.2 生命周期:从创建到销毁的完整旅程
一个插件在TensorRT里是怎么活过一生的?我画个时间线给你看:
- 创建阶段:你调用
createPlugin()或者反序列化构造函数 - 配置阶段:TensorRT调用
initialize(),分配资源 - 推理阶段:多次调用
enqueue(),执行计算 - 销毁阶段:TensorRT调用
terminate(),释放资源 - 析构阶段:插件对象被销毁
关键点:TensorRT可能会多次调用 initialize() 和 terminate()。比如在动态形状场景下,每次输入形状变化时,TensorRT可能会重新初始化插件。所以你的 initialize() 必须是幂等的——多次调用不会出问题。
我曾经遇到过一个坑:在 initialize() 里分配了CUDA内存,但在 terminate() 里忘记释放。结果跑多batch时,显存越占越多,最后OOM了。嗯,从那以后我养成了一个习惯——在 terminate() 里加一个断言,检查资源是否真的释放了。
4.3 序列化与反序列化:让你的插件「活」起来
序列化是TensorRT的一个核心特性。你想想看,你辛辛苦苦写了一个插件,编译成引擎文件。如果引擎文件不能保存插件的参数,那每次加载引擎时都得重新配置,多麻烦?
序列化的本质就是:把你的插件参数打包成一段连续的字节流。反序列化就是反过来,从字节流里恢复出插件对象。
4.3.1 序列化怎么写?
void serialize(void* buffer) const noexcept {
// 把参数按顺序写入buffer
char* d = static_cast<char*>(buffer);
// 写入一个int
std::memcpy(d, &m_param1, sizeof(m_param1));
d += sizeof(m_param1);
// 写入一个float
std::memcpy(d, &m_param2, sizeof(m_param2));
d += sizeof(m_param2);
// 写入一个字符串(长度+内容)
int len = m_name.size();
std::memcpy(d, &len, sizeof(len));
d += sizeof(len);
std::memcpy(d, m_name.c_str(), len);
}
这里有个小技巧:我习惯在序列化时先写一个「魔数」或者版本号。这样反序列化时可以校验数据格式是否正确。比如:
// 序列化开头写一个魔数
uint32_t magic = 0xDEADBEEF;
std::memcpy(d, &magic, sizeof(magic));
d += sizeof(magic);
4.3.2 反序列化怎么写?
反序列化通常放在插件的构造函数里。TensorRT会给你一个 PluginFieldCollection 或者直接给你一个 void* buffer,你得自己解析。
MyPlugin(const void* data, size_t length) {
const char* d = static_cast<const char*>(data);
// 读取魔数并校验
uint32_t magic;
std::memcpy(&magic, d, sizeof(magic));
d += sizeof(magic);
if (magic != 0xDEADBEEF) {
// 数据损坏,处理错误
return;
}
// 读取参数
std::memcpy(&m_param1, d, sizeof(m_param1));
d += sizeof(m_param1);
// ... 以此类推
}
4.4 避坑指南:我踩过的那些坑
- 序列化大小不一致:
getSerializationSize()返回的大小必须和serialize()实际写入的大小完全一致。多一个字节或少一个字节都不行。我建议在serialize()结束时加一个断言,检查写入的字节数是否等于getSerializationSize()的返回值。 - 多线程安全问题:TensorRT可能会在多线程环境下调用你的插件。如果你的插件里有全局变量或者静态变量,记得加锁。不过说实话,我一般避免在插件里用全局变量,太容易出问题了。
- 动态形状下的维度计算:在动态形状场景下,
getOutputDimensions()可能会被多次调用,而且每次的输入维度都可能不同。你的实现必须能处理任意合法的输入维度。
好了,关于 IPluginV2 的核心内容就这些。说白了,你只要把元信息、初始化、执行、序列化这四块搞明白,写插件就成功了一大半。下一章我会带你手写一个完整的插件,到时候这些概念都会用上。