做TensorRT C++部署这三年,我踩过最贵的一个坑,就是在显存上。记得第一次把公司的检测模型从PyTorch切到TensorRT时,模型倒是跑起来了,准确率也没掉,结果压测跑了不到两小时,程序直接报cudaErrorMemoryAllocation,整卡显存被打满,连宿主机都差点卡死。排查到最后,根本原因就是我在推理循环里重复创建了执行上下文,旧上下文一直没销毁,显存只涨不收。
所以当你问“TensorRT C++如何申请、使用、释放显存”时,我想先说一句:TensorRT不是内存管家,它只是帮你把模型执行计划编排好,真正跟CUDA打交道、向显存要空间的,还是你自己的代码。这篇东西我尽量不写废话,把我在实际项目里对显存的管理经验、代码框架和排坑记录都拿出来,适合正在做C++推理服务、想从onnx或者PyTorch迁移到TensorRT,以及被显存问题折磨的同学们参考。
1. 先弄清楚TensorRT C++里到底有哪些显存开销
1.1 TensorRT不等于显存管理员
很多人一开始会有一个错觉:只要调用TensorRT的API,显存分配和释放就自动搞定了。实际上,TensorRT的C++ API设计初衷是“给你控制权”,而不是“替你管资源”。engine反序列化阶段、创建执行上下文阶段、每次推理阶段,都会因为你的操作产生显存申请。比如你调用runtime->deserializeCudaEngine()时,TensorRT会把模型权重加载进显存;调用engine->createExecutionContext()时,又会为当前上下文准备一份独立的中间张量工作空间。这些空间如果不由你显式管理,只靠析构函数去回收,很容易在长生命周期服务里出现“看起来没泄漏但显存居高不下”的情况。
本质上,TensorRT底层还是依赖CUDA runtime API,也就是说,最终所有显存分配都是调用cudaMalloc。区别在于:TensorRT会根据引擎定义和上下文配置,在内部帮你申请一部分设备内存。而这些内部内存的释放时机,和context、engine的析构逻辑强相关。你不知道这层逻辑,就很难控制峰值显存。
1.2 一份典型TensorRT C++程序里的四类显存开销
以一个实际部署案例为例。假设我要部署一个YOLOv8分割模型,输入是1x3x640x640,输出包含检测框和掩码。我把显存开销拆成四块,这个分类方式也适用于绝大多数用TensorRT C++写的模型:
- 模型权重显存:engine反序列化后,权重常驻显存。这部分大小跟模型参数量、精度直接相关,FP16比FP32少一半,INT8又能再少一些。
- 执行上下文工作空间:TensorRT跑层时需要的中间buffer,由builder配置的workspace size决定。这里特别容易混淆,很多人以为这是模型权重,其实不是,它是Conv、MatMul、LayerNorm等算子执行时的临时存放区。
- 输入输出绑定显存:通过
cudaMalloc为input、output分配的buffer。这部分通常在C++侧手动申请,在代码里最容易被发现,也最容易被误用。 - CUDA context和CUDA graph相关显存:C++程序首次调用CUDA时,驱动会为当前进程创建CUDA context,同样占用几十到几百MB显存。如果你用了CUDA Graph捕获推理流程,还会额外保存一份graph executable显存。
我把这四类开销用下面的表做一个总览,方便对照:
| 显存类型 | 产生时机 | 生命周期 | C++侧是否可控 |
|---|---|---|---|
| 模型权重显存 | deserializeCudaEngine | 随engine销毁 | 间接可控 |
| 工作空间 | createExecutionContext | 随context销毁 | builder阶段可配置 |
| 输入输出buffer | cudaMalloc | 手动管理 | 完全可控 |
| CUDA context | 首次调用CUDA | 随进程结束 | 基本可控 |
从这里你能看出一个规律:你的显存控制力,集中在输入输出buffer和工作空间上限上。真正要精细优化,就得同时抓好这两个点。这也是后面几节要展开的内容。
2. 申请显存:三种常见方案与选型思路
2.1 最直接的方案:cudaMalloc手动申请输入输出buffer
几乎每个TensorRT C++程序都会用到这种方式。流程基本固定:先根据engine的binding信息拿到每个输入输出的张量维度,计算出字节数,再调用cudaMalloc。
初始化Engine之后,我一般这样获取binding信息:
auto engine = runtime->deserializeCudaEngine(engineData.data(), engineData.size()); auto context = engine->createExecutionContext(); int inputIndex = engine->getBindingIndex("input"); int outputIndex = engine->getBindingIndex("output"); auto inputDims = engine->getBindingDimensions(inputIndex); auto outputDims = engine->getBindingDimensions(outputIndex); // 这里假设输入输出都是float类型 size_t inputSize = 1; for (int i = 0; i < inputDims.nbDims; i++) { inputSize *= inputDims.d[i]; } size_t outputSize = 1; for (int i = 0; i < outputDims.nbDims; i++) { outputSize *= outputDims.d[i]; } size_t inputBytes = inputSize * sizeof(float); size_t outputBytes = outputSize * sizeof(float); void* inputBuffer = nullptr; void* outputBuffer = nullptr; cudaMalloc(&inputBuffer, inputBytes); cudaMalloc(&outputBuffer, outputBytes);为什么要自己算字节数,而不是直接拿engine->getMaxBatchSize()或者自己写死一个尺寸?因为TensorRT 8以上已经放弃了implicit batch模式,所有维度都以binding维度为准,而且动态shape下输出维度甚至要到推理完后才知道。你写死的尺寸,很可能不够用,或者反过来,浪费一大块显存。正确做法是先通过getBindingDimensions拿到静态shape下的维度,或者在动态shape场景下,先根据profile的最大shape来分配buffer。
2.2 动态shape下的显存申请:按profile最大值申请
动态shape是TensorRT C++里最常见的显存杀手。模型定义输入是[-1, 3, -1, -1],如果你只按实际输入尺寸申请显存,那么当一次请求突然变成1920x1080分辨率时,buffer就会溢出,表现可能是输出数据全错,或者直接报非法地址访问。
我在做动态shape部署时,通常按优化profile里的最大尺寸来申请buffer。比如配置了三个profile,每个profile都设置了min、opt、max三个维度,其中max维度就直接决定了当前profile下输入输出buffer需要分配多大。代码可以这样取最大尺寸:
int profileNum = engine->getNbOptimizationProfiles(); assert(profileNum == context->getOptProfile()); auto dimsMax = engine->getProfileDimensions(inputIndex, profileId, OptProfileSelector::kMAX);如果模型每个profile的输入形状不一样,或者上下文需要切换profile,那申请显存时就必须给每个profile下的最大输入输出尺寸都留足余量。最稳妥的做法是:分别遍历所有profile的kMAX维度和所有输出binding,取并集最大值来分配buffer。虽然这样会让单次峰值显存变高,但避免了后续动态shape请求过来时出现隐性越界。
有一个实测经验想分享:动态shape模型遇到“batch_size=1时显存正常,batch_size=4时调用enqueueV2直接报错”的案例,十有八九不是模型炸了,而是你buffer申请时只按batch=1算的。我在代码里加了按max profile分配之后,这种问题就再也没有出现过。
2.3 TensorRT内部显存申请:workspace与context隐式分配
除了上面的输入输出buffer,C++侧还需要关注TensorRT内部的工作空间。这部分虽然不需要手动cudaMalloc,但如果不控制上限,它可能占掉远超预期的显存。
builder阶段可以通过config设置workspace大小。TensorRT 8.x用的是:
auto config = builder->createBuilderConfig(); config->setMemoryPoolLimit(MemoryPoolType::kWORKSPACE, 1 << 30); // 1GBTensorRT 7以前习惯叫setMaxWorkspaceSize,新版本已经改名成setMemoryPoolLimit。我这里给你一个建议:workspace size并不是越大越好。如果你的模型主要跑在嵌入式设备或老显卡上,显存本身就不充裕,给1GB甚至2GB workspace会造成严重的显存压力。反过来,如果模型结构里有大规模矩阵乘或FFN,workspace太小可能导致层融合失效或执行时频繁申请额外显存。
推理时的执行上下文,也会默认申请一块显存作为activation memory。这块显存的峰值一般取决于网络张量flow的最大跨层中间张量总和。你可以在创建context后立即查询一次显存占用,如果发现空闲显存少了,而这块减少刚好对应你的预期model memory,说明中间张量已常驻。
3. 完整实操:从engine加载到推理循环的显存管理代码框架
3.1 先看一个典型的反序列化与上下文创建流程
下面这段代码是我在项目中使用的简化版本,可以直接套用。完整代码需要在编译时链接TensorRT和CUDA,实测在TensorRT 8.5和10.0下都能正常工作。
#include <cuda_runtime_api.h> #include <NvInfer.h> #include <fstream> #include <vector> #include <iostream> using namespace nvinfer1; class TrtInfer { public: bool loadEngine(const std::string& enginePath) { std::ifstream file(enginePath, std::ios::binary); if (!file.good()) { std::cerr << "failed to open engine file" << std::endl; return false; } file.seekg(0, std::ios::end); size_t size = file.tellg(); file.seekg(0, std::ios::beg); std::vector<char> data(size); file.read(data.data(), size); file.close(); m_runtime = createInferRuntime(gLogger); m_engine = m_runtime->deserializeCudaEngine(data.data(), size); m_context = m_engine->createExecutionContext(); // 这里只申请输入输出buffer // 如果存在动态shape,尽量先用profile max维度申请 int inputIdx = m_engine->getBindingIndex("input"); int outputIdx = m_engine->getBindingIndex("output"); auto inDims = m_engine->getBindingDimensions(inputIdx); auto outDims = m_engine->getBindingDimensions(outputIdx); size_t inBytes = volume(inDims) * sizeof(float); size_t outBytes = volume(outDims) * sizeof(float); cudaMalloc(&m_inputBuffer, inBytes); cudaMalloc(&m_outputBuffer, outBytes); return true; } bool infer(float* inputData, float* outputData, const int batch) { // 如果batch和engine dims不匹配,需要重新setBindingDimensions cudaMemcpy(m_inputBuffer, inputData, batch * 3 * 640 * 640 * sizeof(float), cudaMemcpyHostToDevice); m_context->enqueueV2(m_bindings, m_stream, nullptr); cudaStreamSynchronize(m_stream); cudaMemcpy(outputData, m_outputBuffer, batch * 3 * 640 * 640 * sizeof(float), cudaMemcpyDeviceToHost); return true; } void cleanup() { cudaFree(m_inputBuffer); cudaFree(m_outputBuffer); m_context->destroy(); m_engine->destroy(); m_runtime->destroy(); cudaStreamDestroy(m_stream); } private: IRuntime* m_runtime = nullptr; ICudaEngine* m_engine = nullptr; IExecutionContext* m_context = nullptr; cudaStream_t m_stream; void* m_bindings[2] = {nullptr, nullptr}; void* m_inputBuffer = nullptr; void* m_outputBuffer = nullptr; };这里有几个细节需要特别注意:
- 在
loadEngine阶段,我直接为输入输出都申请了显存,不要再等到infer阶段才申请,因为推理循环里的申请和释放非常容易成为性能瓶颈。 m_bindings数组里放的是指向输入输出device buffer的指针,enqueueV2调用时TensorRT要求它必须是一个包含所有binding地址的数组。如果你只填了第一个元素,后面为空,程序会表现成随机崩溃。- 所有cuda调用最好都检查返回值,我在正式项目里封装了
CudaCheck宏,一旦返回非cudaSuccess就直接打日志退出,避免脏数据继续跑。
3.2 CUDA Stream与多线程请求下的显存管理
如果你的C++服务需要并发处理多个请求,最常见的做法是每个线程持有一个独立的IExecutionContext,但engine和输入输出显存可以共享或按需分配。注意,同一context不能同时被多个线程执行推理,这会造成显存数据竞争。
我在一个QPS要求很高的项目里用了固定线程池,每个线程初始化时都创建自己的context,并且预先申请好该线程最大并发batch数对应的输入输出buffer。全局只有一个engine实例和一份模型权重显存。这样可以保证大部分显存开销是共享的,每个线程的额外开销主要来自一份独立工作空间和输入输出buffer。
关于多线程流,建议一个线程固定绑定一条stream,不要把并发请求的queue都发到同一条stream上。虽然stream本身支持排队,但如果你在多个线程里同时调用enqueueV2到同一个context,TensorRT文档里明确说了这是不安全的,实际跑起来会随机出现CUDA error: invalid argument。
3.3 遇到输出维度不确定怎么办
有些模型输出尺寸是动态的,比如目标检测的NMS结果,真实输出数量受输入内容影响。TensorRT会为输出binding设置最大可能的size,但你在C++侧很难提前知道最终有效元素个数。
我的处理方式是:先给输出分配一个足够大的buffer,例如按单张图片最大候选框数量maxOutputSize=300,每个输出元素按float计数,按这个最大值分配显存。然后通过context查询实际输出维度,再把需要的数据拷贝回host。
// 动态shape模型推理前,需要为每个输出设置实际维度 auto outDims = m_context->getBindingDimensions(outputIdx); int outputCount = 1; for (int i = 0; i < outDims.nbDims; ++i) { outputCount *= outDims.d[i]; }这里有一个很隐蔽的坑:动态输出shape如果没有在推理前通过setBindingDimensions设定具体尺寸,即使你已经给输出分配了最大buffer,enqueue时也可能失败或输出维度不对。所以我的建议是,在每次推理前,都对所有动态维度执行一次context->setBindingDimensions,再调用context->inferShapes()来确认所有输出binding维度是否合法。这样做会多一次CPU计算,但换来的是稳健性,尤其是在服务端长期运行场景下,很值得。
4. 释放显存:顺序错了等于白释放
4.1 严格按逆序销毁TensorRT对象
显存释放是C++无数头痛问题的来源。TensorRT对象之间存在依赖关系:runtime创建engine,engine创建context,所以销毁顺序应该是:context、engine、runtime。我在代码里见过有人先destroy掉runtime,然后才去destroy engine,程序通常不会立刻报错,但在第二次加载engine时会崩溃,或出现随机性cudaError。这是因为engine内部还保留着指向runtime资源句柄的引用,runtime先没了,engine里的资源回收逻辑就断了。
推荐的cleanup顺序如下:
void cleanup() { // 先释放所有cudaMalloc出来的buffer if (m_inputBuffer) cudaFree(m_inputBuffer); if (m_outputBuffer) cudaFree(m_outputBuffer); // 销毁推理上下文 if (m_context) m_context->destroy(); // 销毁engine if (m_engine) m_engine->destroy(); // 最后销毁runtime if (m_runtime) m_runtime->destroy(); // stream和相关事件 if (m_stream) cudaStreamDestroy(m_stream); }先释放cudaMalloc出的buffer也有讲究。想象一下,如果你先销毁context,再释放输出buffer,在某些极端驱动版本下,CUDA context和buffer之间的异步拷贝命令还没有完全执行完,cudaFree可能会隐式同步,反而可能阻塞。更安全的是在退出推理阶段前先做一次cudaDeviceSynchronize(),确保所有流上命令执行完毕,再依次释放。
4.2 常见的隐性显存泄漏场景
说到泄漏,最典型的不是忘了调用cudaFree,而是因为对象生命周期管理不当,导致cudaMalloc申请的buffer变成悬空指针,或者一个推理线程反复创建context而不销毁。
我遇到过这么几个案例:
- 推理循环里每次都调用
engine->createExecutionContext(),却没有在请求结束后调用context->destroy()。显存峰值稳步上升,直到OOM。 - 用
std::shared_ptr管理engine和context,但析构函数里没有调用对应的destroy()方法。TensorRT很多版本里,IRuntime、ICudaEngine、IExecutionContext都继承自INoCopy,如果只依赖智能指针删除operator而不用destroy(),底层显存不会完全释放。 - 使用CUDA Graph时,每次重新捕获graph前没有调用
cudaGraphExecDestroy销毁旧graph executable,导致显存累积。 - 在callback或异步线程里
cudaMalloc,但存储buffer的局部变量随函数退出释放,忘了cudaFree。
这里我强烈建议在开发阶段给C++程序挂一个显存监控线程,每隔几秒打印一次当前进程的显存占用。如果每压测1000次推理显存上涨超过几十MB,那基本可以断定存在泄漏了。不要等到服务OOM再排查,到那时现场已经被冲掉了。
4.3 谨慎使用cudaDeviceReset
有些同学图省事,在做退出清理时直接调cudaDeviceReset(),以为这样可以一键清空所有显存。这个函数确实会销毁当前进程的CUDA context并释放显存,但它同时会让所有已经存在的device pointer、stream、event全部失效。
如果后续代码还想再调用TensorRT,就必须重新初始化整个runtime和engine,成本非常高。更麻烦的是,如果程序里还有另一个线程正在执行CUDA操作,调用cudaDeviceReset会导致那个线程拿到非法context,直接崩溃。
所以我的建议是,只把cudaDeviceReset()用在一个明确“进程要退出、不再做任何推理”的场景,并且退出前先通知所有推理线程结束。正规做法还是把上面cleanup列表中的每一项都精确执行,不要依赖一键重置。
5. 显存占用调优:从“峰值爆炸”到“低显存运行模型”
5.1 定位显存大头:nvidia-smi不够,得会用CUDA API
调优之前,先要知道显存都去哪儿了。很多人只会敲nvidia-smi看一个总占用,但服务端在跑多模型时,你根本分不清哪个进程占了哪块。建议在C++代码里直接调用cudaMemGetInfo拿到当前可用显存,再结合上下文数量做分段排查。
size_t freeMem = 0; size_t totalMem = 0; cudaError_t err = cudaMemGetInfo(&freeMem, &totalMem); if (err != cudaSuccess) { std::cerr << "cudaMemGetInfo failed" << std::endl; } std::cout << "Free memory: " << freeMem / 1024.0 / 1024.0 << " MB" << std::endl;在控制台跑程序时,可以用下面的方法细粒度观察每个阶段的增长:
- 程序初始化完成后打一次free memory。
- 反序列化engine完成后打一次free memory。
- 创建context完成后打一次free memory。
- cudaMalloc buffer完成后打一次free memory。
这四次读数之间的差值,就能定位你的显存大头是在engine权重、context工作空间还是input/output buffer上。
5.2 用workspace限制降低本地显存峰值
有一次我压测一个LLM结构的模型,一个2B参数FP16模型,权重占大约4GB,但运行时显存峰值却到11GB。用上面四段诊断一打,发现创建context之后空闲显存瞬间少了6GB多,这明显是workspace和中间激活占了大头。
当时模型结构里有超大矩阵乘,TensorRT默认尝试把一切能融合的都融合起来,导致activation memory需求偏高。后来我在builder config里把workspace limit从默认的device total memory改成2GB,重新生成engine,显存峰值从11GB降到了7GB左右,吞吐只下降了4%。这个调优效果非常明显。
当然,workspace也不是越小越好。你把workspace压到很小,TensorRT没法为一些算子选到高效kernel,会退回保守策略,性能下降可能超过20%。正确方法是做一次workspace扫描:分别设置1GB、2GB、4GB、8GB,生成多个engine,然后同时观察延迟和显存峰值,最后选择一个性价比最高的点。
5.3 低显存运行模型的几个实用手段
从热搜词里看到不少人在问“低显存运行模型”“8G底显存跑多模态”。这个方向跟TensorRT C++的显存管理关系非常大。如果你的目标卡只有8GB或16GB显存,除了常规的FP16、INT8量化之外,下面几个手段在C++侧很实用。
第一个手段是模型分块/层流式加载。TensorRT引擎不一定要求把所有层都常驻显存,但默认engine是常驻的。如果你想让一个超大模型在低显存卡上运行,可以使用TensorRT的streaming engine特性,把部分层做成按需加载。不过这要求模型转换阶段就使用对应配置和插件,复杂度明显偏高,不是所有模型都支持。
第二个手段是复用输入输出buffer,避免在推理循环里反复申请释放。这个跟你业务形态有关。如果每次请求的输入shape变化不大,那就干脆在初始化阶段一次性申请一个最大尺寸的buffer池,循环使用。我在做视频分析时经常这样做,一个GPU同时跑三个模型,每个模型有自己固定的输入输出buffer,显存开销稳定可控。
第三个手段是把pinned memory和device memory分开管理。C++侧如果大规模用cudaMemcpyAsync,建议配合pinned host memory使用。但要注意,pinned memory虽然不属于显存,却也占用系统内存并且影响统一内存的分配效率,没必要为每个小请求都单独分配cudaHostAlloc。
5.4 配合TensorRT Docker镜像和容器显存限制
在服务端部署中,很多人会直接用TensorRT官方Docker镜像来跑C++程序。镜像的好处是NVIDIA驱动、CUDA、cuDNN、TensorRT版本都已经对齐,本地编译出来的可执行文件部署到镜像里,出问题的概率小很多。
容器层面限制显存时,一般通过NVIDIA_DRIVER_CAPABILITIES和NVIDIA_VISIBLE_DEVICES环境变量来控制能访问哪张GPU。不过要注意,如果你只限制了进程的可见GPU,却没有在容器层做显存配额,那进程仍然可能把整张卡的显存打满。可以考虑利用CUDA的cudaDeviceSetLimit或使用MPS配置来限制每个进程的显存上限。但是最简单可靠的做法还是回到代码层面:把每块buffer的大小算清楚,按最大可能输入尺寸申请,并在测试环境长时间压测,验证显存曲线稳定。
6. 常见问题与显存排查技巧实录
6.1 一张排查表,覆盖大部分显存类问题
下面这张表是我每次去帮同事排查TensorRT C++显存问题时基本都会对照的清单。
| 现象 | 可能原因 | 排查思路与解决 |
|---|---|---|
| 推理几次后显存持续上涨 | context或buffer在循环内重复创建没有释放 | 检查循环内是否有createExecutionContext、cudaMalloc;配合cudaMemGetInfo打点定位 |
| 启动时直接报cudaErrorMemoryAllocation | 显存被其他进程占满,或buffer分配过大 | nvidia-smi查看占用;检查是否按max profile分配;降低workspace limit |
| 换大输入尺寸后输出数据错误 | buffer按小尺寸申请导致越界覆盖 | 按engine profile最大维度重新分配输入输出buffer |
| enqueueV2报invalid argument | binding数组不完整或未setBindingDimensions | 打印全部binding索引,确认每个元素都有对应设备指针 |
| 销毁engine时程序崩溃 | 释放顺序错误或对象重复释放 | 严格按context、engine、runtime顺序销毁;避免cudaDeviceReset后继续访问引擎 |
| 显存没问题但推理延迟忽高忽低 | cudaMalloc和cudaFree发生在每次推理中 | 复用buffer,不要在热路径申请释放显存 |
6.2 我踩过的两个冷门坑
第一个坑和CUDA Graph有关。为了压延迟,我在某个模型上启用了CUDA Graph,首次推理时把enqueue过程捕获成graph。从第二次推理开始,直接执行graph。显存问题在于,graph捕获时TensorRT内部可能分配了额外的显存来保存graph节点信息,这个显存不会因为context析构而完全释放,必须在graph对象上调用cudaGraphExecDestroy才能回收。我最初没有显式做这一步,导致压测5万次后显存泄漏了接近1GB。
第二个坑是不同TensorRT版本对C++对象析构的要求并不完全一致。在TensorRT 8.x某些小版本里,IExecutionContext的析构函数并不自动释放所有device workspace,需要手动调用destroy。我在升级版本时发现,某次发布后服务的空闲显存比之前低了200MB,一查才知道是新版本自动回收了一部分。这种事情并不罕见,所以我的建议是:每次更换TensorRT版本后,都要重新做一次显存基线测试,不要想当然认为行为一致。
6.3 给团队定的“显存纪律”
最后分享几条我们在代码评审阶段就强制要求的规矩,你也可以直接用:
- 所有cudaMalloc/cudaHostAlloc出来的指针,初始化必须置空,并在释放后置空。避免重复释放。
- 推理函数内禁止直接cudaMalloc。统一走缓冲区管理器申请,缓冲区管理器在初始化阶段分配好所有可能用到的device内存。
- engine文件加载和context创建只在进程启动阶段执行一次。如果业务需要多路推理,每路使用独立context,但复用engine。
- 在压测环境跑一个20000次的稳定性测试,同时记录显存变化曲线,只有曲线保持水平才能进入上线评审。
- 如果显存不足,优先调整workspace大小和batch,不要先怀疑量化。量化虽然降低权重显存,但中间激活不会跟着线性下降。
显存问题在TensorRT C++部署里,不是一个“调一调API就行”的事,它关系到程序架构和资源生命周期设计。我的经验是,把申请、绑定、释放的流程提前规划清楚,比什么问题都出现了再去想办法救火,要省心得多。真到了现场排查,用上面这套打点定位的方式一步步切分,一般半小时内都能把问题范围锁定,剩下的就是改代码然后重新压测。