ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LiteSeg实时语义分割:轻量ASPP与深度可分离卷积的工程实践

LiteSeg实时语义分割:轻量ASPP与深度可分离卷积的工程实践 简介LiteSeg 实时轻量级语义分割算法的 PyTorch 实现包面向计算机视觉开发者与边缘设备部署场景在保证较高分割精度的同时大幅降低计算开销适用于自动驾驶、无人机监控、医疗影像分析等对实时性敏感的任务。包内共 39 个文件约 21.09MB涵盖 21 个 Python 源码包括 MobileNet、ShuffleNet、DarkNet 等骨干网络定义、ASPP 模块、损失函数、训练评估与 ONNX 转换脚本、5 张 JPG 与 5 张 PNG 示例图片以及 yml/yaml 环境与训练配置、txt 标签/训练验证列表、运行说明文档和预训练权重 LiteSeg-mobilenet-card_epoch-99.pth目录模块划分清晰便于直接复现与二次开发。压缩包还附带 .gitignore 和数据集划分文件有助于工程化管理。目前已有 1358 人学习下载。通过这份资源读者可以快速理解 LiteSeg 的网络结构、训练策略与部署流程并获得一套开箱可用的基线代码适合作为实时语义分割项目起步或算法对比的参考。1. LiteSeg 解决的是实时语义分割里最现实的一个问题LiteSeg 解决的是实时语义分割里最现实的一个问题DeepLabV3 精度够看但推理太慢ENet 这类早期轻量网络跑得快小目标和边缘细节却掉得厉害结构规则的路面、建筑勉强能看一到工业缺陷检测、遥感影像这些场景就露馅。它的定位正好卡在中间用被裁过的 MobileNetV2 或 ResNet18 做编码器中间接一个轻量版 ASPP空洞空间金字塔池化解码器只做一次低层特征融合。整个模型参数数量级在三五百万消费级 GPU 上能跑满实时帧率精度比同体量老模型高出一截。LiteSeg 的思路不是发明新分割头而是把 DeepLab 系里收益最高的两部分——空洞卷积和 ASPP——搬到轻量编码器上再用输出步长output stride和通道数做容量取舍。同一套结构改一个切片索引就能在精度和帧率之间滑动这是它在工程上比固定结构的轻量模型更好用的原因。下面按我平常做分割复现的路线走先讲清设计原理再给一个能直接跑通的 PyTorch 实现然后是训练配置与调优最后落在一个经常被忽略的验证技巧上。适合两类人需要在边缘 GPU 或机器视觉项目里跑实时分割的工程师以及拿语义分割做 baseline 复现的研究者。2. LiteSeg 的轻量化设计深度可分离卷积、空洞卷积与轻量 ASPP 各自扮演什么角色2.1 深度可分离卷积是 LiteSeg 的参数量地基普通 3×3 卷积在语义分割里用得最多因为编码器要密集输出。以通道数从 96 扩到 128 为例普通 3×3 卷积的参数是 96×128×3×3110592 个深度可分离卷积把它拆成两步先对每个输入通道单独做 3×3 卷积groupsin_channels参数 96×3×3864再用 1×1 逐点卷积把 96 个通道投影到 128参数 96×12812288。两段加起来约 13152 个参数只有原来的八分之一左右计算量同样按这个比例缩。这是 LiteSeg 控住模型体量的第一层地基。但有个反直觉的地方在 GPU 上让深度可分离卷积真正跑快收益往往不在 FLOPs 下降而在算子融合。3×3 的 depthwise 卷积本身是通道间没有交互的算子访存密度低很容易变成带宽瓶颈真正占推理时间的是后面那个 1×1 pointwise因为它对特征图每个点做了通道全连接。所以优化时不要只看乘法次数还要看显存带宽和 kernel 启动开销。下面把三者的差异列出来卷积类型参数规模96→128感受野在 LiteSeg 里的位置标准 3×31105923×3解码器里少量高收益位置深度可分离 3×3131523×3编码器主体与 ASPP 分支空洞深度可分离 3×313152(3−1)×r1ASPP 多尺度分支参数保持不变空洞率 r 只改变感受野不增加参数量。这也是 LiteSeg 这类结构愿意大量使用空洞深度可分离卷积的原因多尺度能力加进来了成本几乎没有增加。2.2 空洞卷积不降分辨率地扩大感受野语义分割的编码器普遍用 stride 把特征图逐级缩小到原图的 1/16 甚至 1/32换取全局上下文。代价是 16×16 的小特征图在恢复分辨率时物体边界很容易被磨掉。空洞卷积解决的是这个矛盾卷积核里插 r−1 个 0让 3×3 核的实际作用范围变成 (3−1)×r1。r12 时实际上看到了 25×25 的区域参数还是 9 个。DeepLab 家族把 ASPP 里的空洞率定义为特征图坐标系下的值。LiteSeg 沿用这个约定当输出步长为 16 时特征图一个像素对应原图 16 个像素ASPP 分支里 r6/12/18实际覆盖的原图范围要乘 16分别是 96、192、288 像素。换算关系在调参时很关键——512×512 输入上r18 已经接近全图负责全局形状换到 1024×1024 的遥感影像时同样的 r18 覆盖范围占原图比例变小多尺度范围就不够了要么增大空洞率要么加一条 r24。deeplabv3 语义分割相关的复现踩坑很大一部分都出在这个比例换算上。2.3 轻量 ASPP把多尺度上下文压缩到一次融合LiteSeg 的轻量 ASPP 和 DeepLabV3 的 ASPP 结构同源区别在两处。第一3×3 普通卷积分支全部换成深度可分离卷积第二输出通道从常见的 256 压缩到 128。一个典型实现由四类分支组成一条 1×1 卷积分支处理基础语义三条空洞率分别为 6/12/18 的 3×3 深度可分离卷积分支抓取不同尺度外加一条全局平均池化分支兜底全局上下文。五个分支的输出在通道维拼起来再用 1×1 卷积投影回设定通道数。全局池化分支很多人会删我建议保留。语义分割的常见失败模式是把大目标内部预测成碎片这个分支用一个 1×1 的全局特征告诉解码器“这是一整块路面”对整体一致性帮助明显而它只增加一次 GAP 和一次 1×1 卷积的耗时几乎可以忽略。代价是全图池化会稍微拖慢 batch 内并行但在 512 分辨率下实测影响很小。2.4 编码器切分与输出步长LiteSeg 的可调容量来源LiteSeg 没用全新的编码器而是在预训练好的 MobileNetV2 或 ResNet18 上做“切分”。拿 torchvision 里的 MobileNetV2 说事它的 features 是一长串 Sequential前几层负责边缘纹理越往后语义越强、分辨率越低。切分决定两件事编码器最后一层停在哪个输出步长以及传给 ASPP 的通道数是多少。features 切片输出通道输出步长用途features[0:4]244低层细节进解码器features[4:14]9616默认高层语义进 ASPPfeatures[4:7]328小目标优先场景的高层输入默认配置是 stride 4 的低层特征加 stride 16 的高层特征。如果任务里小目标多把高层切到 stride 8特征图分辨率翻倍但代价是 ASPP 和整个解码器要处理两倍大的特征图延迟上升明显。我把这种“改一个切片索引就改变整个计算量”的设计理解为资源可调resource-aware思想模型容量不是固定的而是按延迟预算来选 slice。第 5 章我会专门讲怎么按帧率反过来定这些参数。3. 用 PyTorch 从零搭建 LiteSeg一个能直接跑通的最小模型3.1 基础组件DepthwiseSeparableConv 里 BN 放哪是有讲究的先把最常用的深度可分离卷积块写出来。我习惯把 BN 和 ReLU 放在 pointwise 之后和 MobileNet 原实现一致depthwise 那层不加 BN少一次显存读写推理时更省。import torch import torch.nn as nn import torch.nn.functional as F class DepthwiseSeparableConv(nn.Module): 空洞率可调的深度可分离卷积。 dw 是 3x3 分组卷积groupsin_channels逐通道做 pw 是 1x1 卷积负责通道间的信息交换 def __init__(self, in_ch, out_ch, kernel_size3, stride1, dilation1): super().__init__() padding ((kernel_size - 1) * dilation) // 2 self.dw nn.Conv2d(in_ch, in_ch, kernel_size, stridestride, paddingpadding, dilationdilation, groupsin_ch, biasFalse) self.pw nn.Conv2d(in_ch, out_ch, 1, biasFalse) self.bn nn.BatchNorm2d(out_ch) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.dw(x) x self.pw(x) return self.relu(self.bn(x))padding 的计算很关键。空洞卷积的 padding 不是简单的 kernel_size//2而是((kernel_size - 1) * dilation) // 2这样才能保证 r 变化时输出分辨率不变。groupsin_channels让 dw 成为真正的逐通道卷积这一步写错参数量和计算量会直接膨胀好几倍而且网络还能训起来是最隐蔽的错误之一。3.2 轻量 ASPP 的 PyTorch 实现结合上一章的结构下面是可直接用的 LightASPPclass LightASPP(nn.Module): 1x1 分支 3x3 空洞分支 全局池化共 5 个分支1x1 投影回 out_ch。 def __init__(self, in_ch, out_ch128, rates(6, 12, 18)): super().__init__() self.branches nn.ModuleList([ nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) ]) for r in rates: self.branches.append( DepthwiseSeparableConv(in_ch, out_ch, 3, dilationr) ) self.gap nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_ch, out_ch, 1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) # 分支数是 1 len(rates) 1全局分支 branch_n len(rates) 2 self.project nn.Sequential( nn.Conv2d(out_ch * branch_n, out_ch, 1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): size x.shape[2:] outs [branch(x) for branch in self.branches] g self.gap(x) g F.interpolate(g, sizesize, modebilinear, align_cornersFalse) outs.append(g) return self.project(torch.cat(outs, dim1))注意全局池化分支必须用双线性插值上采样回当前分辨率才能和其余分支拼接。拼接后通道数是 128×5640project 用一个 1×1 卷积压回 128。如果显存紧可以把 out_ch 降到 96或者去掉 r18 那条分支改成 4 分支拼接通道数会自动跟着 branch_n 变不用改代码。3.3 LiteSeg 主体MobileNetV2 编码器切分与解码器融合接下来搭完整模型。编码器直接复用 torchvision 的 mobilenet_v2只取 features 部分按第 2.4 章的切片规则切出低层和高层两组特征from torchvision.models import mobilenet_v2, MobileNet_V2_Weights class LiteSeg(nn.Module): 输入任意尺寸的 3 通道图输出 num_classes 通道的 logits。 def __init__(self, num_classes19, aspp_ch128, pretrainedTrue): super().__init__() if pretrained: m mobilenet_v2(weightsMobileNet_V2_Weights.IMAGENET1K_V1) else: m mobilenet_v2(weightsNone) features m.features # 低层stride 424 通道保存边缘细节 self.low_level features[0:4] # 高层stride 1696 通道作为 ASPP 的输入 self.high_level features[4:14] self.aspp LightASPP(in_ch96, out_chaspp_ch, rates(6, 12, 18)) # 解码器ASPP 输出与低层特征拼接后做两轮 3x3 卷积 self.decoder nn.Sequential( nn.Conv2d(aspp_ch 24, aspp_ch, 3, padding1, biasFalse), nn.BatchNorm2d(aspp_ch), nn.ReLU(inplaceTrue), nn.Conv2d(aspp_ch, aspp_ch, 3, padding1, biasFalse), nn.BatchNorm2d(aspp_ch), nn.ReLU(inplaceTrue), ) self.head nn.Conv2d(aspp_ch, num_classes, 1) def forward(self, x): low self.low_level(x) high self.high_level(low) high self.aspp(high) high F.interpolate( high, sizelow.shape[2:], modebilinear, align_cornersFalse ) fused torch.cat([high, low], dim1) out self.decoder(fused) return F.interpolate( out, sizex.shape[2:], modebilinear, align_cornersFalse )几个实现细节说明。第一编码器两次切分来自同一个 features低层段和高层段是相邻的low_level 的输出直接喂给 high_level这样从预训练权重迁移过来时通道和 stride 完全对得上。第二解码器拼接的维度必须写对ASPP 输出是 aspp_ch128低层是 24第一层卷积输入是 152修改 aspp_ch 后要同步改这个数字。第三最后两次上采样都用双线性、align_cornersFalse这是分割头里最稳妥的配置和检测里的 ROIAlign 保持一致。3.4 验证输出形状与参数量搭完后先别急着训跑一个前向检查输出形状和模型体积model LiteSeg(num_classes11, aspp_ch128) x torch.randn(1, 3, 512, 512) with torch.no_grad(): out model(x) # (1, 11, 512, 512) total_params sum(p.numel() for p in model.parameters()) trainable sum(p.numel() for p in model.parameters() if p.requires_grad) print(out.shape, ftotal{total_params/1e6:.2f}M, ftrainable{trainable/1e6:.2f}M)输入 512×512、11 类比如 CamVid时输出形状应为 (1, 11, 512, 512)参数量在 400 万到 500 万之间。如果机器访问不了 torchvision 的预训练权重下载地址把 pretrained 设为 False 先跑通流程网络通了再补权重。环境方面用 conda 建一个 python 3.10 的虚拟环境按机器 CUDA 版本装对应构建的 torch 和 torchvisionCUDA 12.x 就选 cu121 那套CPU 上只能验证逻辑测不了实时性。4. 训练 LiteSeg 模型数据管线、损失函数与学习率策略模型只是第一步语义分割训练里大多数效果差异来自数据管线和训练配置。这一章以 CamVid 为例城市道路场景 11 类图像 960×720标注是单通道 PNG每个像素值就是类别 id。4.1 数据管线从标注到单通道 PNG关于语义分割数据集如何制作我的做法是这样用 LabelMe 或 X-AnyLabeling 标出多边形导出 VOC 格式 JSON再用脚本把多边形栅格化生成“背景为 0、每个类别按 id 填充”的单通道 PNG。类别 id 映射表要单独存一份比如 road0、line1、sky4不能和可视化调色板混在一起。可视化用的 RGB 是另一张表有人把 id 直接当 RGB 用训练时 loss 看起来正常可视化一团黑。import random import numpy as np import torch from PIL import Image class SegmentationDataset(torch.utils.data.Dataset): def __init__(self, image_dir, mask_dir, crop512, augTrue): self.images sorted(image_dir.glob(*.jpg)) self.masks sorted(mask_dir.glob(*.png)) assert len(self.images) len(self.masks) self.crop, self.aug crop, aug def __len__(self): return len(self.images) def __getitem__(self, idx): img Image.open(self.images[idx]).convert(RGB) mask Image.open(self.masks[idx]) if self.aug: # 随机缩放 0.5~1.5 后中心裁剪是分割里最划算的增强 scale random.uniform(0.5, 1.5) new_size (int(img.width * scale), int(img.height * scale)) img img.resize(new_size, Image.BILINEAR) mask mask.resize(new_size, Image.NEAREST) # 裁到统一尺寸左对齐或中心对齐都行但要固定 left random.randint(0, img.width - self.crop) top random.randint(0, img.height - self.crop) img img.crop((left, top, left self.crop, top self.crop)) mask mask.crop((left, top, left self.crop, top self.crop)) img_t torch.from_numpy(np.array(img, np.float32) / 255.0) img_t img_t.permute(2, 0, 1) # HWC - CHW mask_t torch.from_numpy(np.array(mask, np.int64)) return img_t, mask_tmask 的缩放必须用 NEAREST因为类别 id 是离散的双线性插值会在类别边界插出非整数甚至不存在的 id。图像用 BILINEAR 是平滑退化mask 用 NEAREST 是硬边界这两者不对齐是分割训练边界噪声的第一来源。4.2 数据增强与类别不平衡处理除了随机缩放裁剪我常规会加水平和颜色抖动。遥感影像这类类别极不平衡的数据随机采样经常一整个 batch 都没有少数类不要急着改 loss先统计每个类别的像素占比对占比低于 1% 的类别做区域级采样——先有放回地选包含这些类别的图像再在图内裁剪通常比任何 fancy loss 都见效快。import torchvision.transforms as T color_jitter T.ColorJitter(brightness0.4, contrast0.4, saturation0.4) hflip T.RandomHorizontalFlip(p0.5)颜色抖动要克制幅度过大相当于换了光照分布在真实部署场景往往适得其反。增强的目的是提高对变化的鲁棒性不是把训练分布改掉。4.3 损失函数与训练循环分割的默认 loss 是带 ignore_index 的交叉熵。CamVid 这类数据里存在标注为 255 的边界抖动带计算 loss 时跳过否则网络会被往“边界都预测成固定类”的方向带偏。类别不平衡严重时我会加一个 Dice 项权重 0.2 左右给少数类一个稳定的梯度信号。criterion nn.CrossEntropyLoss(ignore_index255) class DiceLoss(nn.Module): def forward(self, logits, targets, eps1.0): prob F.softmax(logits, dim1) # (B, C, H, W) targets_onehot F.one_hot(targets, logits.shape[1]) targets_onehot targets_onehot.permute(0, 3, 1, 2).float() inter (prob * targets_onehot).sum(dim(2, 3)) union prob.sum(dim(2, 3)) targets_onehot.sum(dim(2, 3)) dice (2 * inter eps) / (union eps) return 1.0 - dice.mean()注意one_hot 对 ignore_index255 的位置生成全零向量Dice 分支天然把忽略区域排除掉。但送入 one_hot 之前要确保 targets 里没有其他越界值否则 one_hot 的深度会超界报错。训练循环用 poly 学习率调度和混合精度model LiteSeg(num_classes11).cuda() optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) max_iter len(train_loader) * 150 # 150 epochs base_lr 0.01 def poly_lr(step, max_step, power0.9): return base_lr * (1.0 - step / max_step) ** power dice_loss DiceLoss().cuda() scaler torch.cuda.amp.GradScaler() for epoch in range(150): for it, (img, mask) in enumerate(train_loader): img img.cuda(non_blockingTrue) mask mask.cuda(non_blockingTrue) cur_iter epoch * len(train_loader) it for g in optimizer.param_groups: g[lr] poly_lr(cur_iter, max_iter) optimizer.zero_grad() with torch.cuda.amp.autocast(): logits model(img) loss criterion(logits, mask) 0.2 * dice_loss(logits, mask) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()SGD 在分割里比 AdamW 稳定mIoU 通常高 1~3 个点代价是学习率调度要细心。poly 衰减 power0.9 是 DeepLab 系标准配置复现时先保持原配置不要上来就换 cosine。混合精度下 loss 计算要在 autocast 内执行backward 用 GradScaler 缩放这两行是固定模板少一个就会精度异常或 OOM。4.4 mIoU 的正确计算方式每个 epoch 结束要算 mIoU。最稳的做法是累积混淆矩阵最后统一算def update_confusion(logits, mask, num_classes): pred logits.argmax(dim1) mask mask.clone() valid mask num_classes # 过滤掉 255 和越界像素 pred, mask pred[valid], mask[valid] idx mask * num_classes pred # 展平成混淆矩阵索引 hist torch.bincount(idx, minlengthnum_classes ** 2) return hist.view(num_classes, num_classes)常见错误有两种一是把 ignore 像素直接丢进 argmaxmIoU 虚高二是训练和验证对“0 类”定义不一致有的数据集 0 是背景有的 0 是 void切换数据集时没核对类别表验证时整体错位mIoU 莫名其妙崩掉。建议把混淆矩阵写成一个独立函数训练和验证共用避免两处实现漂移。5. 实时性调试分辨率、输出步长、BN 折叠与推理加速训练完进推理优化。这里说的实时性不是 FLOPs而是端到端延迟和吞吐。我有几个固定流程照着走。5.1 先定分辨率再定通道输入分辨率对延迟的影响是二次方的宽和高都参与计算通道数是线性所以调实时性的第一步永远是看分辨率。以 512、640、768 三种输入为例输入分辨率相对 FLOPs相对延迟适用任务512×5121.0×1.0×实时监控、工业检测640×6401.56×约 1.5×中等质量数据768×7682.25×约 2×遥感影像、高点位质检延迟超标先降一档分辨率看 mIoU 掉多少掉得明显就回原分辨率把 aspp_ch 从 128 降到 96或者把解码器第二轮 3×3 卷积换成 1×1。对精度的伤害顺序一般是解码器通道 ASPP 通道 分辨率。5.2 输出步长 8 与 16 的取舍之前说过把高层切片改成 features[4:7] 就能得到 stride 8 的高层特征代价是 ASPP 输入分辨率翻倍。OS8 适合小目标密集、边界要求高的场景比如遥感影像复现里的道路和屋顶提取OS16 适合大目标为主的城市道路。切换 OS 后 ASPP 的空洞率要相应加大因为特征图坐标系下同样的 r12在 OS8 时覆盖的原图像素只有 OS16 的一半多尺度感受野整体变小。5.3 部署侧加速BN 折叠、ONNX 导出与 torch.compileBN 折叠的原理是把 eval 模式下y γ·(x−μ)/√σ²ε β合并进前一层的卷积权重。PyTorch 推理时 BN 是单独算子折叠后少一次 kernel 启动、少一次中间显存读写对小模型提升明显。model.eval() example torch.randn(1, 3, 512, 512).cuda() traced torch.jit.trace(model, example) # 静态图可折叠 BN traced.save(liteseg.pt) torch.onnx.export( model, example, liteseg.onnx, opset_version16, input_names[input], output_names[logits], )这个阶段我一般用 torch.jit.trace 先确认算子没问题再决定要不要走 ONNX 到 TensorRT。TensorRT 开 FP16 精度配合 channels_last 内存格式速度通常再涨 30%~50%。PyTorch 2.x 可以直接试torch.compile(model, modereduce-overhead)对 L-ASPP 这种分支多、算子轻的模型图融合收益最大。5.4 数据加载别成瓶颈实时管线里模型推理只是其中一段。CPU 上的图像解码、缩放、归一化以及 CPU→GPU 拷贝经常占掉 30% 以上的延迟预算。处理办法是DataLoader(num_workers4, pin_memoryTrue, persistent_workersTrue)并把缩放挪到 GPU 上做。还有一个多路视频场景的坑输入尺寸不对齐 8 的倍数时底层 kernel 会多调几轮部署前把输入固定成 32 的倍数比如 512×512 而不是 500×500延迟能稳不少。6. 一个常被忽略的验证技巧LiteSeg 的实时性要量 95 分位延迟而不是均值最后说一个我在验收环节反复纠正的问题。很多复现报告里的“推理 30ms”是拿 time.time() 前后各调一次或者测一批就除一下 batch得到的是平均延迟。GPU 上单帧推理是一个短到 5~20ms 的过程操作系统调度、显存带宽争用、温度频率都会带来 10% 以上的抖动。如果平均延迟 32msp95 是 42ms在 30 FPS预算 33ms的视觉系统里每 20 帧就有一帧超时放在质检线上就是肉眼可见的卡顿和丢帧。正确的测法是用 CUDA event做足 warmup 后统计分布def profile_latency(model, x, warmup50, num_iters500): model.eval() for _ in range(warmup): # 让 cuDNN 选核、CUDA context 就绪 model(x) torch.cuda.synchronize() latencies [] for _ in range(num_iters): s torch.cuda.Event(enable_timingTrue) e torch.cuda.Event(enable_timingTrue) s.record() with torch.no_grad(): model(x) e.record() torch.cuda.synchronize() # 必须同步否则计时叠到下一轮 latencies.append(s.elapsed_time(e)) latencies.sort() mean sum(latencies) / len(latencies) p95 latencies[int(len(latencies) * 0.95)] print(fmean{mean:.1f}ms p50{latencies[len(latencies)//2]:.1f}ms fp95{p95:.1f}ms)三个关键点。第一warmup 必须跑几十次让 CUDA context 和 cuDNN benchmark 选核完成这段启动时间不计入稳态延迟。第二每次迭代都要 synchronize否则 event 是异步的后一次推理的时间片会把前一次的尾部叠进去测出来偏大。第三结果排序后取分位数而不是求平均。验收标准我习惯这样定目标 30 FPS预算 33ms真正要满足的是 p95 ≤ 33ms均值只作参考。如果 p95 明显大于均值优先查两点。一是看有没有其他 CUDA stream 或显示进程在抢占 GPU多路视频推理时把各路推理放到同一个 stream 串行执行比多 stream 并行更可控二是看数据加载线程pin_memory 和 non_blockingTrue 没配齐时CPU 拷贝会产生周期性长尾表现为 p99 特别大。在消费级 GPU 上这类小模型 p95 与均值的差距一般能控制在 10% 以内差距超过 30% 时几乎可以断定管线里还有没关掉的竞争源先把抢占和拷贝问题排干净再谈优化模型本身。本文还有配套的精品资源点击获取
返回列表