LiteSeg实时语义分割:轻量ASPP与深度可分离卷积的工程实践
2026/9/12 22:38:51 网站建设 项目流程

简介: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×3=110592 个;深度可分离卷积把它拆成两步,先对每个输入通道单独做 3×3 卷积(groups=in_channels,参数 96×3×3=864),再用 1×1 逐点卷积把 96 个通道投影到 128(参数 96×128=12288)。两段加起来约 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)×r+1ASPP 多尺度分支

参数保持不变,空洞率 r 只改变感受野,不增加参数量。这也是 LiteSeg 这类结构愿意大量使用空洞深度可分离卷积的原因:多尺度能力加进来了,成本几乎没有增加。

2.2 空洞卷积:不降分辨率地扩大感受野

语义分割的编码器普遍用 stride 把特征图逐级缩小到原图的 1/16 甚至 1/32,换取全局上下文。代价是 16×16 的小特征图在恢复分辨率时,物体边界很容易被磨掉。空洞卷积解决的是这个矛盾:卷积核里插 r−1 个 0,让 3×3 核的实际作用范围变成 (3−1)×r+1。r=12 时实际上看到了 25×25 的区域,参数还是 9 个。

DeepLab 家族把 ASPP 里的空洞率定义为特征图坐标系下的值。LiteSeg 沿用这个约定:当输出步长为 16 时,特征图一个像素对应原图 16 个像素,ASPP 分支里 r=6/12/18,实际覆盖的原图范围要乘 16,分别是 96、192、288 像素。换算关系在调参时很关键——512×512 输入上,r=18 已经接近全图,负责全局形状;换到 1024×1024 的遥感影像时,同样的 r=18 覆盖范围占原图比例变小,多尺度范围就不够了,要么增大空洞率,要么加一条 r=24。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默认高层语义,进 ASPP
features[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 分组卷积,groups=in_channels,逐通道做 pw 是 1x1 卷积,负责通道间的信息交换 """ def __init__(self, in_ch, out_ch, kernel_size=3, stride=1, dilation=1): super().__init__() padding = ((kernel_size - 1) * dilation) // 2 self.dw = nn.Conv2d(in_ch, in_ch, kernel_size, stride=stride, padding=padding, dilation=dilation, groups=in_ch, bias=False) self.pw = nn.Conv2d(in_ch, out_ch, 1, bias=False) self.bn = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) 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 变化时输出分辨率不变。groups=in_channels让 dw 成为真正的逐通道卷积,这一步写错,参数量和计算量会直接膨胀好几倍,而且网络还能训起来,是最隐蔽的错误之一。

3.2 轻量 ASPP 的 PyTorch 实现

结合上一章的结构,下面是可直接用的 LightASPP:

class LightASPP(nn.Module): """1x1 分支 + 3x3 空洞分支 + 全局池化,共 5 个分支,1x1 投影回 out_ch。""" def __init__(self, in_ch, out_ch=128, rates=(6, 12, 18)): super().__init__() self.branches = nn.ModuleList([ nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) ]) for r in rates: self.branches.append( DepthwiseSeparableConv(in_ch, out_ch, 3, dilation=r) ) self.gap = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_ch, out_ch, 1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) # 分支数是 1 + len(rates) + 1(全局分支) branch_n = len(rates) + 2 self.project = nn.Sequential( nn.Conv2d(out_ch * branch_n, out_ch, 1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): size = x.shape[2:] outs = [branch(x) for branch in self.branches] g = self.gap(x) g = F.interpolate(g, size=size, mode="bilinear", align_corners=False) outs.append(g) return self.project(torch.cat(outs, dim=1))

注意:全局池化分支必须用双线性插值上采样回当前分辨率,才能和其余分支拼接。拼接后通道数是 128×5=640,project 用一个 1×1 卷积压回 128。如果显存紧,可以把 out_ch 降到 96,或者去掉 r=18 那条分支改成 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_classes=19, aspp_ch=128, pretrained=True): super().__init__() if pretrained: m = mobilenet_v2(weights=MobileNet_V2_Weights.IMAGENET1K_V1) else: m = mobilenet_v2(weights=None) features = m.features # 低层:stride 4,24 通道,保存边缘细节 self.low_level = features[0:4] # 高层:stride 16,96 通道,作为 ASPP 的输入 self.high_level = features[4:14] self.aspp = LightASPP(in_ch=96, out_ch=aspp_ch, rates=(6, 12, 18)) # 解码器:ASPP 输出与低层特征拼接后做两轮 3x3 卷积 self.decoder = nn.Sequential( nn.Conv2d(aspp_ch + 24, aspp_ch, 3, padding=1, bias=False), nn.BatchNorm2d(aspp_ch), nn.ReLU(inplace=True), nn.Conv2d(aspp_ch, aspp_ch, 3, padding=1, bias=False), nn.BatchNorm2d(aspp_ch), nn.ReLU(inplace=True), ) 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, size=low.shape[2:], mode="bilinear", align_corners=False ) fused = torch.cat([high, low], dim=1) out = self.decoder(fused) return F.interpolate( out, size=x.shape[2:], mode="bilinear", align_corners=False )

几个实现细节说明。第一,编码器两次切分来自同一个 features,低层段和高层段是相邻的,low_level 的输出直接喂给 high_level,这样从预训练权重迁移过来时通道和 stride 完全对得上。第二,解码器拼接的维度必须写对,ASPP 输出是 aspp_ch=128,低层是 24,第一层卷积输入是 152,修改 aspp_ch 后要同步改这个数字。第三,最后两次上采样都用双线性、align_corners=False,这是分割头里最稳妥的配置,和检测里的 ROIAlign 保持一致。

3.4 验证输出形状与参数量

搭完后先别急着训,跑一个前向,检查输出形状和模型体积:

model = LiteSeg(num_classes=11, aspp_ch=128) 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, f"total={total_params/1e6:.2f}M", f"trainable={trainable/1e6:.2f}M")

输入 512×512、11 类(比如 CamVid)时,输出形状应为 (1, 11, 512, 512),参数量在 400 万到 500 万之间。如果机器访问不了 torchvision 的预训练权重下载地址,把 pretrained 设为 False 先跑通流程,网络通了再补权重。环境方面:用 conda 建一个 python 3.10 的虚拟环境,按机器 CUDA 版本装对应构建的 torch 和 torchvision(CUDA 12.x 就选 cu121 那套),CPU 上只能验证逻辑,测不了实时性。

4. 训练 LiteSeg 模型:数据管线、损失函数与学习率策略

模型只是第一步,语义分割训练里大多数效果差异来自数据管线和训练配置。这一章以 CamVid 为例,城市道路场景 11 类,图像 960×720,标注是单通道 PNG,每个像素值就是类别 id。

4.1 数据管线:从标注到单通道 PNG

关于语义分割数据集如何制作,我的做法是这样:用 LabelMe 或 X-AnyLabeling 标出多边形,导出 VOC 格式 JSON,再用脚本把多边形栅格化,生成“背景为 0、每个类别按 id 填充”的单通道 PNG。类别 id 映射表要单独存一份,比如 road=0、line=1、sky=4,不能和可视化调色板混在一起。可视化用的 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, crop=512, aug=True): 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_t

mask 的缩放必须用 NEAREST,因为类别 id 是离散的,双线性插值会在类别边界插出非整数甚至不存在的 id。图像用 BILINEAR 是平滑退化,mask 用 NEAREST 是硬边界,这两者不对齐是分割训练边界噪声的第一来源。

4.2 数据增强与类别不平衡处理

除了随机缩放裁剪,我常规会加水平和颜色抖动。遥感影像这类类别极不平衡的数据,随机采样经常一整个 batch 都没有少数类,不要急着改 loss:先统计每个类别的像素占比,对占比低于 1% 的类别做区域级采样——先有放回地选包含这些类别的图像,再在图内裁剪,通常比任何 fancy loss 都见效快。

import torchvision.transforms as T color_jitter = T.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.4) hflip = T.RandomHorizontalFlip(p=0.5)

颜色抖动要克制,幅度过大相当于换了光照分布,在真实部署场景往往适得其反。增强的目的是提高对变化的鲁棒性,不是把训练分布改掉。

4.3 损失函数与训练循环

分割的默认 loss 是带 ignore_index 的交叉熵。CamVid 这类数据里存在标注为 255 的边界抖动带,计算 loss 时跳过,否则网络会被往“边界都预测成固定类”的方向带偏。类别不平衡严重时我会加一个 Dice 项,权重 0.2 左右,给少数类一个稳定的梯度信号。

criterion = nn.CrossEntropyLoss(ignore_index=255) class DiceLoss(nn.Module): def forward(self, logits, targets, eps=1.0): prob = F.softmax(logits, dim=1) # (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_index=255 的位置生成全零向量,Dice 分支天然把忽略区域排除掉。但送入 one_hot 之前要确保 targets 里没有其他越界值,否则 one_hot 的深度会超界报错。训练循环用 poly 学习率调度和混合精度:

model = LiteSeg(num_classes=11).cuda() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) max_iter = len(train_loader) * 150 # 150 epochs base_lr = 0.01 def poly_lr(step, max_step, power=0.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_blocking=True) mask = mask.cuda(non_blocking=True) 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 衰减 power=0.9 是 DeepLab 系标准配置,复现时先保持原配置,不要上来就换 cosine。混合精度下 loss 计算要在 autocast 内执行,backward 用 GradScaler 缩放,这两行是固定模板,少一个就会精度异常或 OOM。

4.4 mIoU 的正确计算方式

每个 epoch 结束要算 mIoU。最稳的做法是累积混淆矩阵,最后统一算:

def update_confusion(logits, mask, num_classes): pred = logits.argmax(dim=1) mask = mask.clone() valid = mask < num_classes # 过滤掉 255 和越界像素 pred, mask = pred[valid], mask[valid] idx = mask * num_classes + pred # 展平成混淆矩阵索引 hist = torch.bincount(idx, minlength=num_classes ** 2) return hist.view(num_classes, num_classes)

常见错误有两种:一是把 ignore 像素直接丢进 argmax,mIoU 虚高;二是训练和验证对“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 输入分辨率翻倍。OS=8 适合小目标密集、边界要求高的场景,比如遥感影像复现里的道路和屋顶提取;OS=16 适合大目标为主的城市道路。切换 OS 后 ASPP 的空洞率要相应加大,因为特征图坐标系下同样的 r=12,在 OS=8 时覆盖的原图像素只有 OS=16 的一半,多尺度感受野整体变小。

5.3 部署侧加速:BN 折叠、ONNX 导出与 torch.compile

BN 折叠的原理是把 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_version=16, input_names=["input"], output_names=["logits"], )

这个阶段我一般用 torch.jit.trace 先确认算子没问题,再决定要不要走 ONNX 到 TensorRT。TensorRT 开 FP16 精度,配合 channels_last 内存格式,速度通常再涨 30%~50%。PyTorch 2.x 可以直接试torch.compile(model, mode="reduce-overhead"),对 L-ASPP 这种分支多、算子轻的模型,图融合收益最大。

5.4 数据加载别成瓶颈

实时管线里模型推理只是其中一段。CPU 上的图像解码、缩放、归一化,以及 CPU→GPU 拷贝,经常占掉 30% 以上的延迟预算。处理办法是DataLoader(num_workers=4, pin_memory=True, persistent_workers=True),并把缩放挪到 GPU 上做。还有一个多路视频场景的坑:输入尺寸不对齐 8 的倍数时底层 kernel 会多调几轮,部署前把输入固定成 32 的倍数,比如 512×512 而不是 500×500,延迟能稳不少。

6. 一个常被忽略的验证技巧:LiteSeg 的实时性要量 95 分位延迟而不是均值

最后说一个我在验收环节反复纠正的问题。很多复现报告里的“推理 30ms”是拿 time.time() 前后各调一次,或者测一批就除一下 batch,得到的是平均延迟。GPU 上单帧推理是一个短到 5~20ms 的过程,操作系统调度、显存带宽争用、温度频率都会带来 10% 以上的抖动。如果平均延迟 32ms,p95 是 42ms,在 30 FPS(预算 33ms)的视觉系统里每 20 帧就有一帧超时,放在质检线上就是肉眼可见的卡顿和丢帧。

正确的测法是用 CUDA event,做足 warmup 后统计分布:

def profile_latency(model, x, warmup=50, num_iters=500): 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_timing=True) e = torch.cuda.Event(enable_timing=True) 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(f"mean={mean:.1f}ms p50={latencies[len(latencies)//2]:.1f}ms " f"p95={p95:.1f}ms")

三个关键点。第一,warmup 必须跑几十次,让 CUDA context 和 cuDNN benchmark 选核完成,这段启动时间不计入稳态延迟。第二,每次迭代都要 synchronize,否则 event 是异步的,后一次推理的时间片会把前一次的尾部叠进去,测出来偏大。第三,结果排序后取分位数而不是求平均。验收标准我习惯这样定:目标 30 FPS,预算 33ms,真正要满足的是 p95 ≤ 33ms,均值只作参考。

如果 p95 明显大于均值,优先查两点。一是看有没有其他 CUDA stream 或显示进程在抢占 GPU,多路视频推理时把各路推理放到同一个 stream 串行执行,比多 stream 并行更可控;二是看数据加载线程,pin_memory 和 non_blocking=True 没配齐时,CPU 拷贝会产生周期性长尾,表现为 p99 特别大。在消费级 GPU 上,这类小模型 p95 与均值的差距一般能控制在 10% 以内;差距超过 30% 时,几乎可以断定管线里还有没关掉的竞争源,先把抢占和拷贝问题排干净,再谈优化模型本身。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询