
做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阶段可配置输入输出buffercudaMalloc手动管理完全可控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_size1时显存正常batch_size4时调用enqueueV2直接报错”的案例十有八九不是模型炸了而是你buffer申请时只按batch1算的。我在代码里加了按max profile分配之后这种问题就再也没有出现过。2.3 TensorRT内部显存申请workspace与context隐式分配除了上面的输入输出bufferC侧还需要关注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会造成严重的显存压力。反过来如果模型结构里有大规模矩阵乘或FFNworkspace太小可能导致层融合失效或执行时频繁申请额外显存。推理时的执行上下文也会默认申请一块显存作为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::vectorchar 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到同一个contextTensorRT文档里明确说了这是不安全的实际跑起来会随机出现CUDA error: invalid argument。3.3 遇到输出维度不确定怎么办有些模型输出尺寸是动态的比如目标检测的NMS结果真实输出数量受输入内容影响。TensorRT会为输出binding设置最大可能的size但你在C侧很难提前知道最终有效元素个数。我的处理方式是先给输出分配一个足够大的buffer例如按单张图片最大候选框数量maxOutputSize300每个输出元素按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设定具体尺寸即使你已经给输出分配了最大bufferenqueue时也可能失败或输出维度不对。所以我的建议是在每次推理前都对所有动态维度执行一次context-setBindingDimensions再调用context-inferShapes()来确认所有输出binding维度是否合法。这样做会多一次CPU计算但换来的是稳健性尤其是在服务端长期运行场景下很值得。4. 释放显存顺序错了等于白释放4.1 严格按逆序销毁TensorRT对象显存释放是C无数头痛问题的来源。TensorRT对象之间存在依赖关系runtime创建engineengine创建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最大维度重新分配输入输出bufferenqueueV2报invalid argumentbinding数组不完整或未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就行”的事它关系到程序架构和资源生命周期设计。我的经验是把申请、绑定、释放的流程提前规划清楚比什么问题都出现了再去想办法救火要省心得多。真到了现场排查用上面这套打点定位的方式一步步切分一般半小时内都能把问题范围锁定剩下的就是改代码然后重新压测。