作者:Lorenzo Porzi,Mapillary

在像美国这样的发达国家,道路每年变化高达 15%,Mapillary 通过将任何相机的图像组合成世界的 3D 可视化来满足对保持地图更新日益增长的需求。Mapillary 独立协作的方式使任何人都可以收集、分享和使用街景图像来改进地图、发展城市和推动汽车行业进步。

如今,世界各地的人们和组织已贡献了超过 6 亿张图像,共同支持 Mapillary 的使命,即通过图像帮助人们了解世界各地,并提供这些数据,其客户和合作伙伴包括世界银行、HERE 和丰田研究院。

Mapillary 的计算机视觉技术以前所未有的方式为地图注入智能,从而提升我们对世界的整体理解。Mapillary 对其所有图像进行大规模最先进的语义图像分析和基于图像的 3D 建模。在这篇文章中,我们讨论了 Mapillary Research 的两项最新工作及其在 PyTorch 中的实现——无缝场景分割 [1] 和原地激活 BatchNorm [2]——它们分别生成全景分割结果并在训练期间节省高达 50% 的 GPU 内存。

无缝场景分割

Github 项目页面:https://github.com/mapillary/seamseg/

无缝场景分割的目标是从图像中预测“全景”分割 [3],即对每个像素进行完整标注,分配类别 ID,并在可能的情况下分配实例 ID。像许多处理实例检测和分割的现代 CNN 一样,我们采用 Mask R-CNN 框架 [4],使用 ResNet50 + FPN [5] 作为骨干网络。这种架构分两个阶段工作:首先,“Proposal head”选择图像上可能包含对象的候选边界框集(即 proposals);然后,“Mask head”关注每个 proposal,预测其类别和分割掩码。这个过程的输出是“稀疏”的实例分割,仅覆盖图像中包含可数对象(例如汽车和行人)的部分。

为了完善我们称为无缝场景分割的全景方法,我们在 Mask R-CNN 中添加了第三个阶段。从相同的骨干网络分支出来,“Semantic head”预测整个图像的密集语义分割,同时也考虑了不可数或无定形的类别(例如道路和天空)。最后,使用简单的非极大值抑制算法融合 Mask head 和 Semantic head 的输出,生成最终的全景预测结果。有关实际网络架构、所用损失和底层数学的所有详细信息,请参阅我们 CVPR 2019 论文 [1] 的项目网站

虽然 Mask R-CNN 的几个版本已经公开可用,包括用 Caffe2 编写的官方实现,但在 Mapillary,我们决定使用 PyTorch 从头开始构建无缝场景分割,以便完全控制和理解整个流程。在此过程中,我们遇到了一些主要的绊脚石,不得不提出一些创造性的解决方案,接下来我们将进行描述。

处理变长张量

将全景分割网络与传统 CNN 区分开来的一点是变长数据的普遍性。实际上,我们处理的许多量不能轻易地用固定大小的张量表示:每张图像包含不同数量的对象,Proposal head 可以为每张图像生成不同数量的 proposals,并且图像本身的大小也可能不同。虽然这本身不是问题——可以一次处理一张图像——但我们仍然希望尽可能地利用批处理级别的并行性。此外,在使用多个 GPU 进行分布式训练时,DistributedDataParallel 要求其输入是批处理的、大小一致的张量。

解决这些问题的方案是将每批变长张量封装在 PackedSequence 中。PackedSequence 基本上是一个经过包装的张量列表类,它将其内容标记为“相关”,确保它们都具有相同的类型,并提供了一些实用的方法,例如将所有张量移动到特定设备等。在执行批处理并行性提升不大的轻量级操作时,我们只需在 for 循环中遍历 PackedSequence 的内容。在性能至关重要时,例如在网络主体中,我们只需将 PackedSequence 的内容进行连接,根据需要添加零填充(就像处理变长输入的 RNN 那样),并记录下每个张量的原始维度。

PackedSequence 也帮助我们处理上面提到的第二个问题。我们稍微修改了 DistributedDataParallel,使其能够识别 PackedSequence 输入,将它们分割成大小相等的块,并将内容分发到各个 GPU 上。

使用分布式数据并行时的非对称计算图

我们的网络的另一个可能更微妙的特殊之处在于,它可以在不同的 GPU 上生成非对称的计算图。实际上,构成网络的一些模块是“可选的”,也就是说它们并不总是对所有图像都进行计算。例如,当 Proposal head 没有输出任何 proposals 时,Mask head 就完全不会被遍历。如果我们在多个 GPU 上使用 DistributedDataParallel 进行训练,这会导致其中一个副本不计算 Mask head 参数的梯度。

在 PyTorch 1.1 之前,这会导致崩溃,因此我们不得不开发一个解决方案。我们简单但有效的解决方案是在不需要实际前向传播时计算一个“假的前向传播”,例如这样:

def fake_forward():
    fake_input = get_correctly_shaped_fake_input()
    fake_output = mask_head(fake_input)
    fake_loss = fake_output.sum() * 0
    return fake_loss

这里,我们生成一批伪造数据,将其通过 Mask head,并返回一个始终向所有参数反向传播零梯度的损失。

从 PyTorch 1.1 开始,不再需要此解决方案:通过在构造函数中设置 find_unused_parameters=TrueDistributedDataParallel 会被告知识别那些并非所有副本都计算了梯度的参数,并正确处理它们。这极大地简化了我们的代码库!

原地激活 BatchNorm

Github 项目页面:https://github.com/mapillary/inplace_abn/

大多数研究人员可能会同意,无论他们的研究实验室拥有少量还是数千个 GPU,GPU 资源总是有限的。在 Mapillary,当时我们使用的 GPU 相当少,且大多是 12GB Titan X 级别的专业消费级 GPU,我们一直在寻找一种能虚拟增加训练期间可用内存的解决方案,以便能够在语义分割等密集标注任务上获得并推动最先进的结果。原地激活 BatchNorm 使我们能够使用高达 50% 的额外内存(计算开销很小),因此它被深度集成到我们目前的所有项目中(包括上面描述的无缝场景分割)。

在前向传播中处理 BN-激活函数-卷积序列时,大多数深度学习框架(包括 PyTorch)需要存储两个较大的缓冲区,即 BN 的输入 x 和 Conv 的输入 z。这是必需的,因为 BN 和 Conv 的标准反向传播实现依赖于它们的输入来计算梯度。使用 InPlace-ABN 替代 BN-激活函数序列,我们可以安全地丢弃 x,从而在训练时节省高达 50% 的 GPU 内存。为了实现这一点,我们用 BN 的输出 y 来重写其反向传播,而 y 又可以通过反转激活函数从 z 重建。

InPlace-ABN 的唯一限制是它需要使用可逆的激活函数,例如 leaky relu 或 elu。除此之外,它可以在任何网络中直接替代 BN+激活函数模块。我们的原生 CUDA 实现与 PyTorch 的标准 BN 相比计算开销极小,并且可供任何人从这里使用:https://github.com/mapillary/inplace_abn/

带有非对称图和不平衡批次的同步 BN

在使用同步 SGD 跨多个 GPU 和/或多个节点训练网络时,通常的做法是在每个设备上单独计算 BatchNorm 统计量。然而,根据我们处理语义和全景分割网络的经验,我们发现跨所有 worker 累积均值和方差可以显著提高精度。在使用小批量数据时尤其如此,例如在无缝场景分割中,我们在每个 GPU 上仅使用一张超高分辨率图像进行训练。

InPlace-ABN 支持跨多个 GPU 和多个节点的同步操作,并且从版本 1.1 开始,也可以在标准 PyTorch 库中使用 SyncBatchNorm 实现这一点。然而,与 SyncBatchNorm 相比,我们支持一些对无缝场景分割尤为重要的额外功能:不平衡批次和非对称图。

如前所述,类似 Mask R-CNN 的网络自然会产生变长张量。因此,在 InPlace-ABN 中,我们使用这里描述的并行算法的变体来计算同步统计量,该算法正确地考虑了每个 GPU 可以容纳不同数量样本的事实。PyTorch 的 SyncBatchNorm 目前正在修订以支持这一点,改进后的功能将在未来的版本中提供。

非对称图(如上所述)是在创建同步 BatchNorm 实现时需要处理的另一个复杂因素。幸运的是,PyTorch 的分布式组功能允许我们将分布式通信限制在部分 worker 范围内,轻松排除当前不活动的 worker。唯一缺少的部分是,为了创建分布式组,每个进程需要知道将参与组的所有进程的 ID,甚至不属于该组的进程也需要调用 new_group() 函数。在 InPlace-ABN 中,我们使用这样的函数来处理它:

import torch
import torch.distributed as distributed

def active_group(active):
    """Initialize a distributed group where each process can independently decide whether to participate or not"""
    world_size = distributed.get_world_size()
    rank = distributed.get_rank()

    # Gather active status from all workers
    active = torch.tensor(rank if active else -1, dtype=torch.long, device=torch.cuda.current_device())
    active_workers = torch.empty(world_size, dtype=torch.long, device=torch.cuda.current_device())
    distributed.all_gather(list(active_workers.unbind(0)), active)

    # Create group
    active_workers = [int(i) for i in active_workers.tolist() if i != -1]
    group = distributed.new_group(active_workers)
    return group

首先,包括不活动进程在内的每个进程通过 all_gather 调用将其状态通知给所有其他进程,然后利用共享的信息创建分布式组。在实际实现中,我们还包含了一个组的缓存机制,因为 new_group() 函数通常在每个批次调用会开销过大。

参考文献

[1] 无缝场景分割;Lorenzo Porzi, Samuel Rota Bulò, Aleksander Colovic, Peter Kontschieder;计算机视觉与模式识别 (CVPR),2019

[2] 原地激活 BatchNorm 用于 DNN 的内存优化训练;Samuel Rota Bulò, Lorenzo Porzi, Peter Kontschieder;计算机视觉与模式识别 (CVPR),2018

[3] 全景分割;Alexander Kirillov, Kaiming He, Ross Girshick, Carsten Rother, Piotr Dollar;计算机视觉与模式识别 (CVPR),2019

[4] Mask R-CNN;Kaiming He, Georgia Gkioxari, Piotr Dollar, Ross Girshick;国际计算机视觉大会 (ICCV),2017

[5] 用于目标检测的特征金字塔网络;Tsung-Yi Lin, Piotr Dollar, Ross Girshick, Kaiming He, Bharath Hariharan, Serge Belongie;计算机视觉与模式识别 (CVPR),2017