
简介一份面向边缘计算与嵌入式部署开发者的实战文档聚焦YOLOv11模型量化压缩与TensorRT加速推理直面端侧算力不足、存储有限、实时性要求高的部署难题。内容从边缘计算背景与YOLOv11工作原理讲起系统梳理线性量化、非线性量化、训练感知量化等压缩手段并深入TensorRT的层融合、精度校准、内核自动调优机制。在此基础上完整呈现模型转换、ONNX导出与检查、TensorRT引擎构建、推理执行及性能调试的落地流程还给出内存复用、多线程并行、硬件加速等优化策略最后落到智能安防、自动驾驶、工业质检三大实战案例。文档共30页压缩包为1个pdf文件大小约2.02MB支持目录章节跳转适合有深度学习基础、正在突破边缘推理瓶颈的开发者参考。目前已有87人学习下载既可系统阅读也可按需检索作为YOLOv11边缘部署的速查手册。1. 边缘计算跑YOLOv11模型不是“能跑”就行而是“能长期跑”边缘计算场景里部署YOLOv11最先被忽略的问题不是精度而是每一帧的算力预算。工控机、Jetson盒子这类边缘设备显存和算力都被卡得很死你可能在台式机上用FP32跑了几天都没事一放到现场面对多路视频流延迟和显存立刻爆掉。模型量化压缩和TensorRT部署就是把这个模型从“实验室能跑”压成“现场能长期跑”的两板斧——先用INT8/FP16把参数位宽降下来再用TensorRT把算子融合成更少的内存搬运。这篇文章按完整落地链路讲从你训练的.pt怎么导出成ONNX量化怎么做TensorRT引擎怎么构建到边缘盒子上最容易翻车的几个坑。适合正在做工业检测、视频结构化、边缘盒子上实时识别的人看完能直接复刻一套最小可用的部署流程。2. 从YOLOv11的.pt到ONNX先解决“格式不对后面全白搭”2.1 训练产出与权重文件.pt不是部署格式部署只需要“前向图”很多新手拿到训练好的best.pt第一反应是直接拿去问TensorRT能不能转。实际best.pt里除了权重还带着优化器状态、EMA参数、训练配置和各类缓存这些对推理全是垃圾。TensorRT要的东西是一张干净的推理图输入是RGB顺序的NCHW张量输出是三个尺度的检测头中间没有数据增强、没有loss分支。所以第一步永远是先用ultralytics把模型重新export成ONNX而不是直接拿.pt硬转。这里顺手提一句环境配置。网上关于“yolov11(ultralytics)环境配置”的教程特别多但0基础最稳的还是新开一个conda环境只装ultralytics和onnx相关依赖。Python版本选3.10或3.11显存不够就别装torch的CUDA12.x装CUDA11.8对应的版本也能用。conda create -n yolo11 python3.10 -y conda activate yolo11 pip install ultralytics onnx onnxruntime-gpu这条命令把ultralytics、ONNX导出器和onnxruntime装在一起。onnxruntime-gpu主要是为了导出后做一次精度体检用后面TensorRT阶段不依赖它。如果你已经训练好了自己的模型直接把next步骤里的权重路径换成runs/detect/train/weights/best.pt。2.2 导出ONNX动态shape与固定shape怎么选三个参数决定结局导出命令本身极小但几个参数的选择会直接影响后续TensorRT的灵活度。用YOLO对象导出的同时也要注意输出头的shapeYOLOv11默认有三个输出对应80x80、40x40、20x20的特征图类别数加54个边框坐标加1个objectness。如果你的数据及类别数不是COCO的80类输出通道会自动变化。from ultralytics import YOLO # 自己训练的权重或者下载官方yolo11n.pt model YOLO(runs/detect/train/weights/best.pt) # 导出ONNX开动态batch和动态分辨率 model.export( formatonnx, dynamicTrue, opset12, simplifyTrue, imgsz640, )参数说明dynamicTrue会同时把batch维和h/w维标为动态轴这样同一个engine能适配不同分辨率也能在部署时通过设置batch4做多路并发。opset12对TensorRT 8.x到10.x都兼容太高某些老版本TensorRT会报不支持simplifyTrue会用onnx-simplifier把常量折叠、把ToColorSpace这类冗余节点清掉能减少后续转换失败的概率。imgsz640这里指定的是训练/导出的基准分辨率后面做推理时如果输入分辨率不同动态shape会接管。如果你确定现场只跑固定640x640、batch1那可以把dynamicFalse换掉engine构建更简单也许还能少一点显存开销。但大多数边缘场景需要多路并发我建议第一次就开dynamic后面路数要变时不用重新导模型。2.3 导出后先做一次“精度毁伤体检”同一张图ONNX和PyTorch对比ONNX导出成功不代表模型没被偷偷改坏。经常有这种情况PyTorch推理正常导出后的ONNX直接多了一个Transpose或者某一层被简化器合并成了精度略不同的实现。所以务必在导出后马上用onnxruntime跑同一张图对比输出差异。这个步骤能帮你区分“是后续TensorRT的问题”还是“从ONNX开始就坏了”。import onnxruntime as ort import numpy as np import cv2 from ultralytics.data.augment import LetterBox session ort.InferenceSession(best.onnx, providers[CUDAExecutionProvider]) input_name session.get_inputs()[0].name print(input:, session.get_inputs()[0].shape, session.get_inputs()[0].type) # 读取图片并做letterbox img cv2.imread(test.jpg) boxed LetterBox((640, 640), autoFalse, stride32)(img) # 保持长宽比补灰边 blob np.transpose(boxed[..., ::-1], (2, 0, 1))[None].astype(np.float32) / 255.0 # onnx推理 out session.run(None, {input_name: blob}) print(outputs:, [o.shape for o in out])这段代码里LetterBox的autoFalse强制缩放到指定sizestride32保证补边后能被模型下采样整除。输入的第三次[..., ::-1]是把BGR转成RGB因为YOLOv11训练时用的是RGB通道顺序。输出结果是三个(1, nc5, H, W)张量shape中的H/W就是20/40/80。如果输出shape不对比如通道数不是nc5优先怀疑自己的类别数写错了再检查导出前模型是否被重新加载过。这一步最容易被跳过但它是整个链路中成本最低的“后悔药”——等量化完之后再回来查ONNX就太晚了。3. 量化压缩不是玄学FP16与INT8的原理、校准集和TensorRT实操3.1 为什么FP16不够INT8要校准位宽降下来分布也得跟上模型量化压缩的本质是压低数值精度去换吞吐和显存。FP32是4字节表示一个数FP16是2字节INT8是1字节。从FP32到FP16参数和激活值都变成半精度显存直接减半带宽压力也减半而绝大多数层的精度损失几乎可以忽略。所以FP16是TensorRT部署里最基础的“白嫖”优化。到了INT8就是另一个故事把连续浮点范围映射到[-127,127]的256个离散整数点。如果只是简单除一个缩放因子相当于对每一层都用固定比例粗暴截断那些数值区间很宽、但大多数激活集中在很小区域的层比如shortcut之后的feature map会被大量截断成0特征信息直接丢。所以INT8必须先做校准从真实数据里抽样统计每层激活值的分布然后找一个能最大程度保留信息的分割点。TensorRT的校准本质是在“把大数值往中间合拢”和“保留小数值精度”之间做权衡。这也是很多人在边缘场景翻车的地方直接用训练集里的几百张图去校准结果模型只见过一种光照现场出现逆光、雨雾就集体失效。校准集的质量比数量重要一定要覆盖你要部署现场的真实分布。至少包含不同亮度、不同距离、不同类别的样本数量200到500张足够。3.2 用Python API做INT8校准一个能直接落地的CalibratorTensorRT的INT8转换需要提供一个校准器。常见做法是先写一个继承IInt8EntropyCalibrator2的Python类把校准图片从磁盘或npy数组喂进去生成calib.cache缓存文件。之后再用trtexec构建engine时可以复用缓存避免重复校准。import tensorrt as trt import numpy as np import os class MyCalibrator(trt.IInt8EntropyCalibrator2): def __init__(self, npy_dir, batch_size8, input_nameimages): super().__init__() self.batch_size batch_size self.input_name input_name self.files [os.path.join(npy_dir, f) for f in os.listdir(npy_dir) if f.endswith(.npy)] self.cache_file calib.cache self.batch_idx 0 self.batch_data np.zeros((batch_size, 3, 640, 640), dtypenp.float32) def get_batch_size(self): return self.batch_size def get_batch(self, names): if self.batch_idx self.batch_size len(self.files): return None for i in range(self.batch_size): self.batch_data[i] np.load(self.files[self.batch_idx i]) self.batch_idx self.batch_size return [self.batch_data, self.batch_data.shape[0]] def read_calibration_cache(self): if os.path.exists(self.cache_file): with open(self.cache_file, rb) as f: return f.read() return None def write_calibration_cache(self, cache): with open(self.cache_file, wb) as f: f.write(cache)使用这个校准器构建engine的片段如下builder trt.Builder(logger) network builder.create_network(1 int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) parser trt.OnnxParser(network, logger) with open(best.onnx, rb) as f: parser.parse(f.read()) config builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 2 30) # 2GB config.set_flag(trt.BuilderFlag.INT8) calibrator MyCalibrator(./calib_npy, batch_size8) config.int8_calibrator calibrator engine builder.build_serialized_network(network, config) with open(best_int8.engine, wb) as f: f.write(engine)代码里的MyCalibrator.get_batch()每次返回一个(batch, 3, 640, 640)的float32数组这个数组应该是由你的训练/验证图片经过letterbox和归一化之后转成npy存盘的。可以提前用脚本批量生成也可以直接改成在get_batch()里读图片实时处理。注意1 EXPLICIT_BATCH这个写法是TensorRT 8.x的标准语法新版本里EXPLICIT_BATCH依然可用。3.3 量化参数怎么调全局INT8掉点检测头回退FP16INT8校准跑完别急着欢呼。直接用INT8 engine推理会发现小目标漏检、低置信度框抖动。这时候先确认两件事一是校准集是否覆盖了目标大小分布二是是否需要对敏感层做精度回退。回退的做法是按层设置精度检测头的最后几层也就是输出三个尺度的Conv层是坐标回归和类别概率的直接输出它们对量化误差的敏感度远高于backbone里的卷积。一般我会在构建engine时对检测头所在的层单独设置FP16或FP32其余层保持INT8。粗粒度做法是直接把输出头整体设为FP16代价是显存和延迟略增但精度几乎不丢。细粒度可以用layer.precision trt.float16和layer.set_output_type(0, trt.float16)逐层指定需要先打印network的所有layer名定位到检测头。这一节没有万能参数只有排查顺序先看校准集分布再看哪些层掉点严重最后决定回退力度。如果你用的是老式边缘卡INT8可能因为硬件对INT8卷积优化不足而并不比FP16快多少这时直接放弃INT8用FP16反而更稳。4. 从ONNX到TensorRT engine构建、推理、多路并发一整套4.1 用trtexec构建engine动态shape和workspace是必调参数ONNX导出和校准都做完后最省事的构建方式是直接调用trtexec。它会自动完成网络解析、层融合、精度分配和内核选择并把结果写进.engine文件。trtexec在TensorRT安装包的bin目录下也可以从pip安装的tensorrt包中直接调用系统命令。trtexec \ --onnxbest.onnx \ --minShapesimages:1x3x640x640 \ --optShapesimages:4x3x640x640 \ --maxShapesimages:8x3x640x640 \ --fp16 \ --memPoolSizeworkspace:2048 \ --saveEnginebest.engine参数说明这里假设ONNX输入名是images前面的静态导出时如果不指定输入名通常就是images可以用onnxruntime打印确认。三个*Shapes分别指定动态轴的profile范围optShapes是构建时各层融合的基准尺寸通常选实际运行最多的batch。如果只需要单路固定分辨率可以把min/opt/max都设为1x3x640x640这样engine更小、启动更快。--fp16表示构建时允许使用FP16精度如果是INT8把--fp16换成--int8并追加--calibcalib.cache复用之前生成的校准缓存。--memPoolSizeworkspace:2048控制TensorRT单个engine可用的显存上限边缘卡显存本来就紧张建议设置成实际可用显存的一半避免构建时因为workspace过大直接OOM。构建成功会输出类似“Engine created”的日志。构建过程中出现“unsupported op”时不要急着换TensorRT版本先回到2.2节用simplify重新导一次ONNX八成是opset或冗余节点问题。4.2 用Python API写一个最小推理脚本从engine到检测框trtexec能构建engine但真正的业务代码还得自己写Python推理。下面这个脚本是一个最小闭环它加载engine分配输入输出buffer执行推理并解析YOLOv11的输出。不依赖ultralytics的检测头NMS部分用简单的按置信度过滤加非极大值抑制。import tensorrt as trt import numpy as np import cv2 def letterbox(img, size(640,640), pad114): h, w img.shape[:2] r min(size[0]/h, size[1]/w) resized cv2.resize(img, (int(w*r), int(h*r))) canvas np.full((size[0], size[1], 3), pad, dtypenp.uint8) canvas[:resized.shape[0], :resized.shape[1]] resized return canvas, r # 加载engine logger trt.Logger(trt.Logger.WARNING) with open(best.engine, rb) as f: runtime trt.Runtime(logger) engine runtime.deserialize_cuda_engine(f.read()) context engine.create_execution_context() # 输入输出 input_name engine.get_tensor_name(0) input_shape engine.get_tensor_shape(input_name) output_names [engine.get_tensor_name(i) for i in range(engine.num_io_tensors) if engine.get_tensor_mode(engine.get_tensor_name(i)) trt.TensorIOKind.OUTPUT] output_shapes [engine.get_tensor_shape(name) for name in output_names] # 分配buffer buffers [] for name in engine.get_tensor_names(): shape engine.get_tensor_shape(name) size trt.volume(shape) dtype trt.nptype(engine.get_tensor_dtype(name)) buf np.empty(size, dtypedtype) buffers.append(buf) # 处理一帧 img cv2.imread(test.jpg) boxed, scale letterbox(img) blob np.transpose(boxed[..., ::-1], (2,0,1))[None].astype(np.float32) / 255.0 buffers[0].flat blob.flatten() # 拷贝到CPU buffer # 推理 context.execute_v2(buffers[buf.ctypes.data for buf in buffers]) # 解析三个尺度的输出并简单过滤 results [] for out_name, buf, shape in zip(output_names, buffers[1:], output_shapes): out buf.reshape(shape) # (1, nc5, grid_h, grid_w) out out[0].transpose(1,2,0) # (grid_h, grid_w, nc5) conf_mask out[..., 4] 0.5 # 只取objectness高的位置 if not conf_mask.any(): continue # 此处省略坐标解码实际要乘以stride和grid索引 results.append(out) cv2.imwrite(result.jpg, boxed)这段脚本只展示了骨架。实际解析YOLOv11的检测头时需要把每个grid cell的偏移乘以对应stride还原到原图坐标还要做NMS。建议直接用ultralytics库的model(..., imgsz640)做推理来验证engine正确性但生产代码里为了去掉对训练库的依赖手写解码是值得的。至少你要确认engine的输出shape和导出ONNX时的shape一致不一致说明动态shape配错了。4.3 多路并发T4上1080p25帧每秒用TensorRT跑640检测到底能撑多少路这个问题被问得最多。先说结论不能只问“多少路”要问“什么模型、什么精度、什么解码方式”。以NVIDIA T4为例它的FP16算力约65 TFLOPSINT8算力是130 TOPS。跑yolo11n在640x640输入下FP16的纯推理延迟大约在5到8毫秒INT8能到3到4毫秒。而1080p25fps意味着每路每秒25帧每帧预算40毫秒。用最粗糙的计算单路推理延迟8ms理论上一个GPU可以串行处理约125帧每秒除以25就是5路。如果换INT8延迟降到4ms约250帧每秒能到10路。但这里有两个大坑一是视频解码CPU用OpenCV解码4路1080p就已经开始冒烟必须用NVDEC硬件解码并且让解码和推理通过异步队列并行二是预处理把1080p的帧缩放到640x640再转成NCHW这个操作在CPU上每次可能吃掉3到5毫秒如果串行你的“纯推理延迟”会被直接打回原形。正确做法是把预处理放到GPU上做或者至少用多线程把帧采集、缩放、推理、后处理流水线化。所以T4到底能支持多少路我的经验值yolo11s FP16固定640分辨率配合NVDEC和GPU预处理保守能撑4到6路1080p25fpsINT8能到6到10路。网上很多说能跑20路的基本是只算推理不算解码和IO或者用了batch1却没有考虑单流的延迟上限。你要做的不是信一个数而是用下面这个公式自己压测可支撑路数 GPU推理吞吐fps/ 25其中吞吐fps用batch4或batch8的方式测因为高batch能利用T4的tensor core比单路重复跑更接近峰值。5. 部署避坑量化掉点、动态尺寸、显存泄漏排查与解决5.1 现象INT8后小目标几乎全丢大目标没问题原因校准集里全是中近距离的样本小目标在特征图上的响应本来就很弱INT8量化把那些微弱但关键的激活值截断成了0检测头对小目标的“注意力”被抹掉了。另外如果校准图片数量太少比如几十张TensorRT的熵校准会对分布估计过粗糙。解决重新扩容校准集强制加入小目标占比高的图片最好从验证集里按类别和尺寸分层抽样确保每一类在多个尺度上都有样本。如果还是掉点按3.3节对检测头回退FP16。实在不行就接受FP16方案小目标场景INT8收益不明显。5.2 现象trtexec构建的engine能跑但Python API加载后报“Buffer size is too small”原因动态shape的engine在创建context后必须用context.set_input_shape()设置一次输入的实际shape再根据该shape动态计算输出尺寸。很多人直接用engine里的静态shape分配buffer结果输出shape变了buffer装不下。解决推理前调用context.set_input_shape(input_name, (1,3,640,640))然后用context.get_tensor_shape(output_name)获取当前输出shape再分配buffer。不要把4.2节里的“静态shape假设”写死要多路并发变batch时动态获取。5.3 现象长时间推理后显存持续上涨最终OOM原因context.execute_v2()本身不缓存显存但如果你用了torch.cuda或onnxruntime的独立上下文并且没有调用torch.cuda.empty_cache()显存会被其他框架占住。另一个常见原因是每次推理都新建一个context而不销毁边缘卡显存本来就小几个context叠加立刻爆。解决全局只创建一个engine和一个context推理循环里重复用同一个context。如果同步接口用了execute_v2它内部的输出buffer是CPU侧的不占GPU显存不要每帧都调create_execution_context()。另外在后处理完之后把GPU输入buffer用cudaFree释放或者复用同一个numpy数组。5.4 现象engine构建成功但实际推理延迟比onnxruntime还慢原因构建时没有开FP16或INT8或者没有指定动态shape的optShapes导致TensorRT以为会运行在batch1的保守模式。另一种情况是模型里有大量不支持融合的自定义算子融合失败导致每个op单独launch kernel比普通推理更耗时间。解决在trtexec里显式加--fp16和--optShapesimages:4x3x640x640让层融合和内核选择基于真实batch。确认日志里“Fused ConvBN”这类信息明显增多如果自定义算子太多考虑用onnx-simplify去掉部分节点或改用官方支持的op。5.5 现象多路视频推流时CPU占用率100%GPU却间歇性空闲原因FPS上不去不是GPU不行而是预处理和后处理全在CPU上串行执行。OpenCV的resize、BGR2RGB、normalize都是CPU操作4路视频时CPU忙不过来GPU只能干等数据。解决把预处理挪到GPU上用CUDA kernel或cuda-python实现或者至少对每路视频单独开一个采集线程用queue.Queue把原始帧缓冲起来推理线程只负责排空队列。后处理NMS也可以换成带TensorRT插件或者cupy实现让GPU做decode之后的所有事。这一步做到位整套系统才算真正边缘化。6. 进阶动态shape、小目标优化和把结果落盘的三个技巧最后一章不讲大框架只讲三个我每次部署都会用的小技巧都是踩过坑换来的。第一个技巧动态shape不要只开batch也要开宽高。固定640x640在多路1080p视频时浪费了很大的缩放计算量——实际上很多画面目标很小完全可以用960x960输入提升小目标召回但每帧计算量会翻倍。我一般维持模型动态shape在推理时根据目标场景切换640x640和960x960。比如白天用640保证8路并发傍晚用960跑4路并提高小目标检出。这个切换只需要重新set_input_shape()不用重新构建engine代价是engine构建时要把min/max shape范围留足。第二个技巧小目标优化不止靠提升分辨率。很多边缘场景下输入分辨率受硬件限制目标只有十几个像素。这时我会人为把图像分成重叠的两个tile每个tile分别推理再合并结果——这种“tiling”做法比单纯换大模型更可控。必要的时候在训练阶段引入CopyPaste增强把小目标在图像里重复粘贴让模型对“小”这件事更敏感。这些调整配合INT8量化时一定要重新做校准集否则量化会吞掉你用增强换来的那点提升。第三个技巧结果落盘别直接写图片。yolo predict saveTrue是训练时代的好帮手但部署时要按流式输出保存每帧的检测框、置信度、类别和时间戳写JSON或Parquet视频流只保存原始码流。我会在推理线程里维护一个固定大小的“结果队列”由独立线程批量刷盘避免每一帧磁盘同步把推理线程卡死。如果要把检测框画在视频上用GPU端的画框插件别等在CPU上画完再编码否则又回到5.5节的老路。这些技巧没有一个是银弹但它们能让你在换设备、调精度、加并发时少走弯路。边缘部署本来就是一个反复权衡的过程——有时候为了稳宁可放弃INT8;有时候为了小目标宁可少一路并发。我自己的习惯是每次改动都先记下“精度、延迟、显存、路数”四个数字再动手这样排查问题时能快速定位是哪一步优化拖了后腿。希望帮到你。本文还有配套的精品资源点击获取