TensorRT大模型推理优化实战:从ONNX到YOLOv12 C++部署
这两年做大模型推理TensorRT几乎是绕不开的名字。无论是开源社区里刷屏的benchmark还是工业界落地时拍板的推理方案NVIDIA这套深度学习推理优化器总会被拿出来对比一遍。很多人问我为什么同样的模型在PyTorch里跑得慢吞吞换成TensorRT之后就能快一倍甚至更多是玄学还是工程优化这篇文章我想把我实际部署过程中验证过的东西摊开讲一讲——TensorRT到底做了什么、它凭什么成为大模型推理的首选以及一个基于YOLOv12 ONNX转TensorRT的C实战案例。我在不少项目里给模型做加速从早期的分类网络到现在的目标检测和轻量大模型TensorRT一直是很顺手的工具。但我也遇到过很多朋友拿着TensorRT去套所有场景结果踩了一堆坑。所以这篇不只是讲优点还会把边界说清楚什么时候该用什么时候别硬上。1. 大模型推理的瓶颈与TensorRT的破局思路1.1 大模型推理到底卡在哪里很多人觉得GPU很强推理应该很快才对。但实际部署时你会发现GPU的算力利用率经常低得可怜。大模型推理不同于训练训练阶段是batch越大越好追求吞吐推理阶段往往是单条或小batch请求追求低延迟。推理慢的根源通常不在浮点运算量而在访存和调度开销。以Transformer类模型为例每个token生成都要完整走一遍计算图几十个层依次执行每层之间都要把中间结果写回显存、再读出来。哪怕算力再强带宽在那里卡着整体就上不去。这类场景专业说法是memory-bound算子本身的计算量不大瓶颈在数据搬运。另一个被忽视的问题是Python层的运行时开销。PyTorch的eager模式会为每个算子单独启动一个CUDA kernelkernel启动本身有延迟叠加大量的算子调用开销非常可观。你去看Nsight Systems的性能报告很多推理任务里kernel启动间隙的时间占了20%到30%这就是纯纯的浪费。大模型场景里还有显存容量的压力。像KV Cache这类结构长度越长占得越多如果推理框架不能精细管理显存很容易在并发上来之后OOM。很多优化方案的核心其实都是在跟这几件事死磕减少数据搬运、减少kernel启动次数、压满算力单元、省显存。1.2 TensorRT用编译思路解决运行时开销TensorRT的破局思路很直接既然PyTorch在运行时才解析图、调度算子那就在加载模型时把所有这些工作做完做成一个针对特定GPU和特定输入形状的“专属可执行文件”也就是engine。你可以把它理解成编译器。源程序是计算图编译器做解析、优化、生成目标代码。TensorRT的输入是ONNX、UFF这类中间表示输出是针对某块NVIDIA GPU、某个CUDA版本、甚至某组模型参数定制好的推理程序。整个过程在构建阶段完成推理时就按已经优化好的计划执行。这个思路跟JIT有点像但更彻底。TensorRT会在构建阶段遍历计算图做层融合、精度重排、kernel自动选择然后把结果序列化保存成engine文件。之后每次部署直接加载engine不用再解析模型文件也没有Python解释器的开销。这也是为什么同一个模型TensorRT和ONNX Runtime能跑出完全不同的性能。2. 扒开TensorRT的加速底牌2.1 层融合与图优化TensorRT最核心的优化手段是层融合也就是把多个算子合并成一个kernel。听上去简单实际上涉及大量规则。举例来说一个常见的卷积块包含Conv、Bias、ReLU三个操作。如果分别执行计算结果要先写到显存再读出来做下一个操作。融合之后一个kernel就能完成全部计算中间结果留在寄存器或共享内存里不经过DRAM。这个差距在数据量大的时候非常明显。Transformer结构里也一样。LayerNorm由均值、方差、归一化等多个步骤组成TensorRT可以把它融合成一个优化kernelMulti-Head Attention中的QKV投影、缩放、Softmax也可能被重构成更高效的执行计划。比如近年很火的FlashAttention思路本质上也是在融合Attention的计算减少中间矩阵的读写。层融合带来的收益不仅仅是减少kernel启动次数。更重要的是让数据留在速度快的存储层级里少走几趟显存和DRAM。对大模型这种访存密集的模型来说这个收益往往比降低FLOPs更实在。2.2 精度校准与量化TensorRT的另一张王牌是低精度推理。现代NVIDIA GPU上的Tensor Core对FP16、INT8、FP8等低精度计算有专门的硬件加速路径吞吐量比FP32高好几倍。这里有一个很多人误解的点FP16不是简单把权重转成半精度就行。TensorRT在构建engine时会把每一层的数据类型单独规划。哪些层用FP16、哪些层必须保留FP32甚至哪些层可以降成INT8都有一套策略。你开FP16模式后模型里的大部分算子会被转换成FP16 kernel但像某些对精度敏感的归一化算子可能会保留更高精度。INT8量化则更讲究。TensorRT会拿一批有代表性的数据也就是校准集跑一遍模型统计每层激活值的分布然后选择合适的缩放因子让INT8表示带来的精度损失最小。校准集选得好不好直接决定量化后的精度。我见过不少人随便拿几十张图去校准结果部署后mAP掉得一塌糊涂然后反过来骂TensorRT量化不行。实际上校准集要覆盖真实场景的分布数量不在多代表性才是关键。对于大模型FP8和INT8量化是当前的主流方向。TensorRT从8.x开始逐步支持FP8TensorRT-LLM里对大语言模型的FP8推理支持已经比较成熟。这类低精度推理能同时降低显存占用和延迟尤其适合要求高吞吐的服务端场景。2.3 Kernel自动调优与Tensor Core每个模型在每块GPU上TensorRT都会尝试多种kernel实现然后用基准测试挑出最快的那个。这个机制叫tactic选择。构建engine时你会看到日志里有一行行“Tactic”相关的输出就是在做这件事。为什么需要自动调优因为同一套算法在不同GPU微架构上表现完全不同。老一点的图灵架构和现在的Blackwell架构寄存器数量、共享内存大小、Tensor Core的指令集都不一样。手工调kernel是专家做的事TensorRT把这些选择自动化了代价就是构建engine的时间会变长。Tensor Core是这块加速的重要支撑。它本质上是一种专门为矩阵乘加设计的硬件单元可以一次完成多个精度下的矩阵运算。Transformer大模型里计算量最集中的部分就是矩阵乘Linear层、Attention里的QKV投影全部落在矩阵乘上。TensorRT会优先挑选能最大化利用Tensor Core的kernel这也解释了为什么低精度推理在TensorRT上表现格外突出。2.4 显存优化与动态形状显存优化这块很容易被忽略但它对部署体验的影响非常大。TensorRT会为engine分配一个workspace也就是工作区kernel执行时需要的临时显存都在这里复用。多个层之间如果生命周期不重叠TensorRT会让它们共享同一块显存而不是每层各开一块。这样显存占用能降下来不少部署时留给KV Cache或并发请求的空间就更充裕。动态形状也是实际部署中绕不开的点。一个检测服务不可能只接受固定尺寸的图片一个LLM服务也不可能每个请求的prompt长度都一样。TensorRT通过optimization profile支持动态shape给每个输入指定最小、最优、最大三个维度。构建engine的时候针对最优档做深度优化其他档位也能跑只是性能可能没那么极致。这里要提醒一点动态shape的范围别拍脑袋定。比如输入分辨率最小设成640最大设成5000TensorRT需要覆盖很大的优化空间构建时间会暴增甚至内存不足。合理的做法是统计线上实际数据的分布把范围尽量收窄。比如图片最常见的是1280那就把opt档设为1280min和max贴着真实场景来。3. 从ONNX到TensorRTYOLOv12 C部署实战3.1 环境准备5070显卡能跑吗先说结论能而且很顺利。我手上的测试卡是RTX 5070Blackwell架构对应CUDA compute capability是12.0。这个架构比较新所以TensorRT版本不能太老建议直接用TensorRT 10.9以上的版本它对Blackwell的支持才完整。安装这块我推荐用tar包或deb包。以Linux服务器为例下载TensorRT的tar包后解压把lib路径写进LD_LIBRARY_PATH再把Python wheel装一下。验证安装是否成功跑一下自带的trtexectrtexec --version能正常输出版本信息就说明环境没问题。另外要注意CUDA和cuDNN的版本匹配。TensorRT 10对CUDA 12.x支持比较好驱动版本也要跟上。如果驱动太老cudaGetDeviceProperties都会报警更别说跑TensorRT了。Windows平台也类似5070在Windows下用TensorRT 10.9完全没问题。C开发的话用Visual Studio 2019或2022配置好include和lib目录即可。3.2 准备YOLOv12 ONNX模型YOLOv12是Ultralytics生态里的模型导出ONNX很方便。终端里执行yolo export modelyolov12n.pt formatonnx dynamicTrue simplifyTrue这里开了dynamicTrue让ONNX支持动态batch和动态分辨率。simplifyTrue是让ONNXRuntime的optimizer帮忙做一遍简化导出后的模型更干净。导出后最好用onnx.checker.check_model检查一下确认输入输出的name和shape符合预期。我这边导出的模型输入name是images输出是output0。如果你用的是官方repo导出的模型输出格式通常是1x84x8400这种其中84是4个坐标加80个类别置信度8400是三个检测头的anchor总数。这一步有个小坑如果YOLOv12的onnx导出带了后处理算子比如NMS或者decode转TensorRT时容易遇到不支持的plugin。我的建议是导出纯检测模型不带后处理把decode和NMS放C端自己实现。这样TensorRT只负责最干净的卷积网络部分部署起来最省心。3.3 构建engine的C代码ONNX转engine有两种方式命令行用trtexec或者C里直接调API。实际项目里我都是在程序里边构建边加载所以这节直接给一套可复用的代码。先看构建engine的核心代码我用的是TensorRT 10.x的API#include NvInfer.h #include NvOnnxParser.h using namespace nvinfer1; class Logger : public ILogger { void log(Severity severity, const char* msg) noexcept override { if (severity Severity::kWARNING) { std::cout msg std::endl; } } }; Logger gLogger; std::vectorchar buildEngineFromOnnx(const std::string onnxPath, const std::string enginePath) { auto builder std::unique_ptrIBuilder(createInferBuilder(gLogger)); auto network std::unique_ptrINetworkDefinition( builder-createNetworkV2(1U static_castint(NetworkDefinitionCreationFlag::kEXPLICIT_BATCH))); auto parser std::unique_ptrnvonnxparser::IParser( nvonnxparser::createParser(*network, gLogger)); if (!parser-parseFromFile(onnxPath.c_str(), static_castint(ILogger::Severity::kWARNING))) { throw std::runtime_error(Failed to parse ONNX file); } auto config std::unique_ptrIBuilderConfig(builder-createBuilderConfig()); auto profile builder-createOptimizationProfile(); // 动态输入设置min/opt/max profile-setShape(images, Dims4{1, 3, 640, 640}, Dims4{4, 3, 640, 640}, Dims4{16, 3, 640, 640}); config-addOptimizationProfile(profile); // 开启FP16 config-setFlag(BuilderFlag::kPREFER_PRECISION_CONSTRAINTS); config-setFlag(BuilderFlag::kFP16); // 设置workspace大小 config-setMemoryPoolLimit(MemoryPoolType::kWORKSPACE, 1 30); // 构建并序列化 auto serialized builder-buildSerializedNetwork(*network, *config); std::vectorchar engineData(serialized-size()); memcpy(engineData.data(), serialized-data(), serialized-size()); // 保存到文件 std::ofstream out(enginePath, std::ios::binary); out.write(engineData.data(), engineData.size()); return engineData; }代码里有几个点值得解释。开FP16用了setFlag(BuilderFlag::kFP16)这是最常规的做法。如果模型里有些层对精度特别敏感你可以在network里显式指定某些层用FP32构建器会尊重这个约束。setMemoryPoolLimit控制workspace大小。设太大构建时占显存多设太小kernel选择余地少性能可能下降。我一般先从1GB起调性能不达标再加。动态shape这块setShape传入三个维度档。注意输入name要和ONNX里的一致我这里用的images。如果你的ONNX输入是input这里也要跟着改。推理阶段的核心代码class EngineInferencer { public: EngineInferencer(const std::string enginePath) { std::ifstream in(enginePath, std::ios::binary); std::vectorchar data((std::istreambuf_iteratorchar(in)), std::istreambuf_iteratorchar()); runtime std::unique_ptrIRuntime(createInferRuntime(gLogger)); engine std::unique_ptrICudaEngine(runtime-deserializeCudaEngine(data.data(), data.size())); context std::unique_ptrIExecutionContext(engine-createExecutionContext()); } void infer(const float* h_input, float* h_output, int batchSize) { // 设置输入实际shape context-setInputShape(images, Dims4{batchSize, 3, 640, 640}); // 获取输入输出binding的索引 int inputIdx engine-getBindingIndex(images); int outputIdx engine-getBindingIndex(output0); // 获取输出维度 auto outputDims context-getTensorShape(output0); int outputSize 1; for (int i 0; i outputDims.nbDims; i) { outputSize * outputDims.d[i]; } // 分配device内存 static std::vectorfloat deviceInput, deviceOutput; static int deviceSize 0; int inputSize batchSize * 3 * 640 * 640; if (deviceSize batchSize) { deviceSize batchSize; deviceInput.resize(inputSize); deviceOutput.resize(outputSize); // 这里只是示意实际应该用cudaMalloc分配显存 // CUDA_CHECK(cudaMalloc(d_input, inputSize * sizeof(float))); } // 拷贝输入 // CUDA_CHECK(cudaMemcpyAsync(d_input, h_input, inputSize * sizeof(float), // cudaMemcpyHostToDevice, stream)); // 设置binding地址 std::vectorvoid* bindings(engine-getNbBindings(), nullptr); bindings[inputIdx] d_input; bindings[outputIdx] d_output; // 执行推理 context-enqueueV3(stream, bindings.data(), nullptr); // 拷贝输出 // CUDA_CHECK(cudaMemcpyAsync(h_output, d_output, outputSize * sizeof(float), // cudaMemcpyDeviceToHost, stream)); // CUDA_CHECK(cudaStreamSynchronize(stream)); } private: std::unique_ptrIRuntime runtime; std::unique_ptrICudaEngine engine; std::unique_ptrIExecutionContext context; };这个类只是一个最小骨架真实工程里还需要管理CUDA stream、内存池、多线程并发。实际部署时我建议把推理放到独立的worker线程里避免阻塞主线程。后处理部分YOLOv12的输出需要decode出边界框。核心步骤是先处理坐标。ONNX输出的一般是cx, cy, w, h格式需要转成x1, y1, x2, y2不同检测头的stride要单独处理比如640分辨率下三个head的stride分别是8、16、32。然后做阈值过滤和NMS。这个模块用标准C写就行不需要TensorRT参与。3.4 性能实测与比较我在5070上跑YOLOv12n分辨率640实测数据列个表给各位参考方案精度平均延迟(ms)备注PyTorchFP329.5eager模式ONNX RuntimeFP164.2有框架调度开销TensorRTFP161.8融合Tensor CoreTensorRTINT81.1需要校准集mAP略降这组数据是在batch size为1的情况下测的延迟都是多次运行的平均值。TensorRT FP16比PyTorch fast了5倍多比ONNXRuntime快了一倍多这就是编译优化带来的差距。要注意的是TensorRT的构建时间和推理性能是矛盾的。如果模型结构比较复杂构建可能要吃好几GB显存跑很久。我建议用trtexec先快速验证性能别每次都走C的构建流程trtexec --onnxyolov12n.onnx --saveEngineyolov12n.engine --fp16 trtexec --loadEngineyolov12n.engine --shapesimages:1x3x640x640第二条命令会直接加载engine并做一次完整推理输出的GPU Compute Time就是纯推理耗时用来做性能基准很方便。4. 什么时候别迷信TensorRT选型与避坑4.1 TensorRT的适用边界TensorRT很强但它的强是有前提的。首先它只能在NVIDIA GPU上跑。如果你的部署环境可能是AMD卡、Intel卡或者根本没有独立GPU那TensorRT就不是一个可选方案这就是个硬边界。其次TensorRT对模型的动态性容忍度有限。虽然支持动态shape但变化范围太大会带来两个问题一是构建时间暴涨二是推理性能不稳定。如果你的输入shape变化极其剧烈比如每次请求的分辨率都不固定、跨度很大TensorRT可能不是最优选择。这时ONNX Runtime的DML、OpenVINO这些通用方案或者干脆自己写CUDA可能更灵活。第三个坑是模型更新频率。TensorRT的构建过程少则几十秒多则十几分钟。如果模型每周迭代一次每次上线都要重新构建和验证这个成本要算清楚。我在实际项目里的做法是模型还没完全冻结前用ONNX Runtime先顶着等结构彻底定稿再上TensorRT做最终优化。这样既保证了开发效率上线性能也不差。对比ONNX Runtime其实没有必要非此即彼。ONNX Runtime也支持TensorRT execution provider可以让模型的一部分算子跑TensorRT其余算子自动fallback。对于结构复杂、包含大量自定义算子的模型这是一个不错的折中方案。我试过一些检测项目用ORT的TRT EP能拿到接近原生TensorRT九成的性能但省去了很多手工处理的功夫。4.2 遇到报错怎么排查TensorRT的报错信息有时候很劝退。最常见的几种第一个是Unsupported ONNX operator。模型里有个算子TensorRT不认识通常是自定义算子或者非常新的算子。排查方法是先用Netron看一下ONNX图找出不支持的节点看能不能在导出时绕过。YOLOv12导出时如果有注意力相关的复合算子可以试试打开simplify或者把模型分块导出在C端手动拼接。第二个是Out of memory。构建或者推理时显存不足。构建时OOM就调小workspace推理时OOM就要考虑输入尺寸是不是拉太大了。我之前有次部署视频检测输入设成1920x1080显存一下爆了改成1280后就好很多。第三个是动态shape相关的Binding index mismatch或者input shape is incompatible。这通常是C代码里设置的shape和engine构建时的profile对不上。检查一下min/opt/max的范围是不是包含了实际输入。如果实在摸不着头脑就用Nsight Systems和Nsight Compute。前者看时间线哪个阶段耗时高后者看kernel的指令流水线能定位到具体的算子瓶颈。这两个工具我几乎是排查性能问题时必开的。4.3 大模型服务场景TensorRT-LLM才是那个正式答案最后提醒一句如果你在做的“大模型”指的是GPT这类大语言模型而且要做在线服务那应该关注TensorRT-LLM而不是单把通用TensorRT拿过来用。TensorRT-LLM在通用TensorRT的基础上针对Transformer结构做了大量定制优化比如KV Cache的page管理、连续batch、paged attention等这些是通用TensorRT不直接提供的。通用TensorRT在LLM场景里也能用但需要自己做很多东西。我自己试着用通用TensorRT部署过一个小规模的GPT类模型跑是能跑但每来一个请求就要重新算一次KV Cache显存利用和并发调度都达不到生产要求。后来切到TensorRT-LLM事情一下子就顺了。所以选型时要把场景看清楚目标检测、图像分类这类模型通用TensorRT完全够用真正的大语言模型在线服务请把TensorRT-LLM列入评估清单。回到最开始的问题TensorRT为什么能成为大模型推理的首选核心在于它把“优化”这件事前置到了编译阶段做到了运行时几乎没有额外开销同时把NVIDIA硬件的算力潜力挖掘到了极限。它不是万能的但在NVIDIA生态内、模型结构稳定、性能要求高的场景下它确实是当前性价比最高的答案。我个人的习惯是新模型先用ONNX Runtime快速验证功能再上TensorRT做性能优化最后用trtexec和Nsight工具逐步压榨。走完这一套推理部署这块基本就稳了。