
在把一套语义分割模型从GPU侧迁到CANN平台的时候我一开始几乎把所有精力都放在了卷积算子的精度对齐上结果真正让我头疼的却是一个看似不起眼的算子Upsample。语义分割模型要求输出和原图同分辨率backbone又把特征图压得非常小所以最后几次上采样几乎都在大通道数、大空间尺寸的feature map上做访存开销一点不比卷积小。这篇文章就从CANN ops-nn里的Upsample算子入手把它的参数语义、实现思路、性能优化方法以及我在实际迁移中踩过的几个坑完整梳理一遍给同样在昇腾环境上做分割模型部署的开发者一个参考。1. 为什么语义分割模型里总有一个Upsample在等着你1.1 从Encoder-Decoder结构看Upsample的固定生态位语义分割要做的事情是给图像里的每个像素打类别标签这就要求输出张量的空间分辨率必须和输入图像一致。而主流分割网络几乎都脱胎于分类网络backbone里堆叠了大量stride2的下采样层输入512x512的图像经过五六个下采样之后特征图往往只剩16x16甚至更小。要把这么小的特征图恢复到512x512中间必然要经过空间维度的放大Upsample就是干这件事的。有人可能会问能不能不缩小那么多不行因为语义分割依赖足够大的感受野来理解上下文而池化和步进卷积是最便宜的扩大感受野的手段。所有成熟的分割方案都接受了“先下采样、再上采样”这个结构所以Upsample在这个生态位上是刚需不是可选项。在实际网络里Upsample不止出现在最后输出头。很多解码器会把多尺度特征做融合比如低分辨率的高层语义特征要放大两倍之后才能和上一层特征逐元素相加或者拼接。这种内部Upsample的输入输出通道数往往都很大比如256或512通道一次2倍上采样的数据搬移量就非常可观。我见过很多开发者只盯着最后输出层的那个Upsample调优结果发现真正耗时的是解码器中间那几次这个认知要修正。1.2 插值型Upsample与转置卷积的分工上采样并不是只有插值这一条路转置卷积同样可以放大特征图而且在早期分割网络里很流行。转置卷积本质上是有学习参数的卷积能把小图放大到指定尺寸但它的缺点是计算量大、训练不稳定、容易在输出中引入棋盘状伪影。后来的分割网络逐渐抛弃了大比例转置卷积改用“插值上采样轻量卷积”的组合或者只在最后几层用转置卷积做精细恢复。在CANN的算子库中这两个东西被明确区分开插值上采样是Upsample算子带学习权重的上采样卷积是ConvTranspose算子两者的接口、计算逻辑和性能特征完全不同。本文讨论的Upsample特指基于插值的上采样包括最近邻、双线性和双三次这几类。理解了这一点之后看问题就会更清晰Upsample是无参数的它的计算量几乎可以忽略真正影响性能的是它的访存模式和数据搬移量而ConvTranspose是重计算算子优化思路完全不在一个频道上。2. CANN ops-nn里Upsample的参数语义与框架对齐关系2.1 输出尺寸的两种指定方式size和scale先看接口层面。CANN ops-nn里的Upsample算子输入通常是一个四维张量布局可以是NCHW也可以是NHWC基本参数包括输入特征、输出高度、输出宽度以及插值模式。输出尺寸有两种指定方式一种是直接给绝对尺寸out_h和out_w另一种是给scale_h和scale_w表示高度和宽度的放大倍数。这两种方式在框架侧是对应于torch.nn.functional.interpolate里的size参数和scale_factor参数。我个人的建议是算子实现里尽量以最终输出尺寸为基准不要以scale为基准。原因是scale在非整数倍放大时会有精度隐患。比如scale1.5输入7x7理论输出尺寸应该是10.5到底取10还是取11不同框架的处理方式不完全一样。有些框架直接四舍五入有些框架先算坐标再取整。如果以size为基准坐标映射里的除法就有一个明确的除数行为完全可控而用scale就多了一层“输出尺寸如何由scale推导”的不确定环节。在实际工程里模型转换工具从PyTorch导出ONNX时一般都会把scale_factor直接展开成对应的输出尺寸写进图里所以在CANN侧看到的Upsample节点通常带的是绝对尺寸。如果你自己写算子调用也建议优先给size省掉后面很多对齐麻烦。2.2 插值模式与align_corners的坐标映射插值模式最常用的两个是nearest和bilinear。nearest直接取源图中最近像素的值没有权重计算bilinear取源图2x2邻域的四个像素做双线性加权。两者在坐标映射这个大框架下是统一的区别只是权重计算方式。这里必须重点提一下align_corners它决定坐标映射的几何语义是我见过最容易出问题的一个参数。当align_cornersTrue时输入和输出的首尾像素是对齐的坐标映射公式是src dst * (in_size - 1) / (out_size - 1)当align_cornersFalse时采用的是半像素对齐方式坐标映射公式是src (dst 0.5) * (in_size / out_size) - 0.5这两个公式看起来只差一点点实际差异非常大。以输入2x2放大到4x4为例align_cornersTrue时输出像素对应的源坐标分别是0、0.333、0.667、1align_cornersFalse时对应坐标是0、0.25、0.5、0.75。前者首尾固定对齐后者每个像素中心都偏了半个像素的距离。来源边界位置可以就近取输入像素值对于bilinear则用clamp之后的位置取四个相邻像素。如果clamp做错了或者只在某些分支里做了、某些分支里漏了放大倍数越大、输出越接近边界时错误就越明显。我习惯的做法是在算子内部把源坐标先统一clamp到[0, in_size-1]的合法范围再做后续的邻域计算。虽然理论上bilinear的邻域访问在严格模式下应该由权重保证合法但在浮点误差的影响下边界处的坐标可能会略微超出范围clamp一下不需要多少成本却能省掉一整个排查周期。5.3 极端shape下的越界和索引错位最后一个坑来自不常规的shape。如果一个来做测试的开发者把输入尺寸设成H1或者W1很多平时不会触发的问题就全冒出来了。align_cornersFalse的公式里有一个“-0.5”的操作当输出尺寸很小、比例又比较尴尬的时候源坐标可能是负数也可能是大于源尺寸的越界值。如果没有clamp越界直接读出来就是垃圾数据或者直接非法访问。还有scale不是整数倍、且输入输出尺寸互质的情况比如从7x7放大到8x8。这种时候不同位置的双线性权重分布极不均匀索引如果有一行错位输出图里会出现肉眼可见的条纹。我在单测里专门准备了一批这种“刁钻”shape比如1x1放大到2x2、3x5放大到4x7、7x7放大到8x8每个case都跑一遍能提前挡住很多只在特定尺寸下才出现的隐性bug。另外提醒一句如果模型推理时输入分辨率是固定的那问题不大但如果模型要支持动态分辨率Upsample算子必须保证任意合法尺寸下行为一致。这个要求看起来简单实际上对tiling逻辑和索引计算都是一个不小的考验。我建议在算子交付之前把动态shape的边界用例当成和正常用例同等重要的事情来对待不要只在固定尺寸上验证完就收工。最后说一点个人体会。我最初也以为Upsample不过是个数据搬运工不值得花时间去研究但真正做了性能分析和精度排查之后才意识到这种访存密集型的“小算子”在分割模型里的地位被严重低估了尤其是在高分辨率输出场景下几个Upsample合起来的耗时占比可以排进全模型的前几名。所以做CANN模型迁移时建议把Upsample列进重点排查清单至少用profiling工具看一眼它的实际耗时和带宽利用率。另一个经验是同一个Upsample在不同网络结构里的最优策略可能完全不同DeepLab那种单次大比例上采样和UNet那种逐级2倍上采样访问特征差异很大别指望一个配置走天下。如果后面有机会我打算再聊聊ConvTranspose和Upsample配合使用时的融合优化那又是一个全新的故事。