近年来,人们对优化神经网络模型的执行一直非常重视。算子融合是一种常见的提高神经网络模型执行效率的方法。这种融合的基本思想与优化编译器所做的传统循环融合相同,它们会带来:1)消除不必要的中间结果实例化,2)减少不必要的输入扫描;3)实现其他优化机会。下面让我们正式走进算子融合。
在讨论算子融合之前,我们首先要知道什么是计算图,计算图是对算子执行过程形象的表示,假设 C = \{ N, E, I, O\} 为一个计算的计算表达,那么可以有:
于是当我们遇到一个具体的算子实例时,我们可以在其对应的计算图上做等价的融合优化操作,这样的抽象使我们能够更加专心于逻辑上的处理而不用在意具体的细节。
为什么要算子融合呢?这样有什么好处呢? 融合算子出现主要解决模型训练过程中的读入数据量,同时,减少中间结果的写回操作,降低访存操作。它主要想解决我们遇到的内存墙和并行强墙问题:
算子的融合方式非常多,我们首先可以观察几个简单的例子,不同的算子融合有着不同的算子开销,也有着不同的内存访问效率提升。





通过示例代码模拟卷积核扩张与融合过程,将两个卷积算子融合成一个更大的卷积算子,具体如下所示:
import torch
import torch.nn.functional as F
# kernel fusion 示例
if __name__ == "__main__":
torch.manual_seed(0)
# 生成输入
input = torch.randn(1, 3, 32, 32)
# 生成 conv1 的权重和偏,out_channels=16, in_channels=3, kernel_height=3, kernel_width=3
conv1_weight = torch.randn(16, 3, 3, 3)
conv1_bias = torch.randn(16)
conv1_output = F.conv2d(input, conv1_weight, bias=conv1_bias, stride=1, padding=1)
print('conv1_output: ', conv1_output)
# 生成 conv2 的权重和偏置,out_channels=16, in_channels=3, kernel_height=1, kernel_width=1
conv2_weight = torch.randn(16, 3, 1, 1)
conv2_bias = torch.randn(16)
conv2_output = F.conv2d(input, conv2_weight, bias=conv2_bias, stride=1, padding=0)
print('conv2_output: ', conv2_output)
# kernel fusion
# 将 conv2 的卷积核权重由(1, 1)扩展到(3, 3)
weight_expanded = torch.zeros(16, 3, 3, 3)
weight_expanded[:, :, 1, 1] = conv2_weight[:, :, 0, 0]
# conv1 卷积核与 conv2 卷积核融合
weight_fusion = torch.concatenate([conv1_weight, weight_expanded], dim=0)
# conv1 偏置与 conv2 偏置融合
bias_fusion = torch.concatenate([conv1_bias, conv2_bias], dim=0)
# 1 次融合后的权重与偏置卷积,替代 conv1 与 conv2 两次卷积
kernel_fusion_output = F.conv2d(input, weight_fusion, bias=bias_fusion, stride=1, padding=1)
print(kernel_fusion_output)最后我们给一个更具体的算子融合,读者可自行验证。

如图所示,首先我们可以利用横向融合的思想,将一个 3×3×256 的卷积算子和一个 1×1×256 的卷积算子通过 Enlarge 方法将后者扩成 3×3×256 的卷积算子,然后融合成一个 3×3×512 的卷积算子;接着我们可以利用纵向融合的思想,将 Split,卷积,Add 融合成一个卷积算子,减少 Kernel 调度;最后,一般激活 ReLU 都可以和前一个计算步骤融合,于是最后融合得到一个 Cov2d_ReLU 算子。于是我们从一个比较复杂的计算图,最终得到了一个比较简洁的计算图,减少了 Kernel 调度,实现了“少就是多”。
总而言之,为了提高效率,我们可以通过消除不必要的中间结果实例化、 减少不必要的输入扫描、 发现其他优化机会等方法来指导我们实现算子融合。
Batch-Normalization (BN)是一种让神经网络训练更快、更稳定的方法(faster and more stable)。它计算每个 mini-batch 的均值和方差,并将其拉回到均值为 0 方差为 1 的标准正态分布。BN 层通常在 nonlinear function 的前面/后面使用。
下面我们以 Conv-BN-ReLU 的算子融合作为例子。
BN 的计算公式
计算均值:
计算方差:
归一化:
其中,\epsilon 是一个很小的正数,用于避免除以零。
线性变换:
其中,\gamma 和 \beta 是可学习的缩放参数和偏移参数。
在 BN 前向计算过程中,首先求输入数据的均值 \mu 与方差 \sigma^{2} ,然后使用 \mu 、\sigma^{2} 对每个输入数据进行归一化及缩放操作。其中,\mu 、\sigma^{2} 依赖于输入数据;归一化及缩放计算的输入则依赖于输入数据、均值、方差以及两个超参数。下图为前向计算过程中 BN 的数据依赖关系:

其中,\gamma 和 \beta 是一个可学习的参数,在训练过程中,和其他层的权重参数一样,通过梯度下降进行学习。在训练过程中,为保持稳定,一般使用滑动平均法更新 \mu 与 \sigma^{2} ,滑动平均就是在更新当前值时,保留一定比例上一时刻的值,以均值 \mu 为例,根据比例 \theta (如,\theta=0.99 )保存之前的均值,当前只更新 1-\theta 倍的本 Batch 的均值,计算方法如下:
BN 反向计算过程中,首先求参数误差;然后使用参数误差 \Delta\gamma 、\Delta\beta 计算输入误差 \Delta X 。参数误差导数依赖于输出结果误差 \Delta Y 以及输入 X ;输入误差 \Delta X 依赖于参数误差导数及输入 X 、输出误差 \Delta Y 。反向过程包括求参数误差以及输入误差两部分,BN 反向计算的关键访存特征是两次使用输入特征 X 及输出误差\Delta Y ,分别用于计算参数误差 \Delta\gamma 、\Delta\beta 及输入数据误差 \Delta X 。

网络模型训练时,需要保存每层前向计算时输出结果,用于反向计算过程参数误差、输入误差的计算。但是随着神经网络模型的加深,需要保存的中间参数逐渐增加,需要消耗较大的内存资源。由于加速器片上缓存容量十分有限,无法保存大量数据。因此需将中间结果及参数写出到加速器的主存中,并在反向计算时依次从主存读入使用。
前向计算过程中,每层的计算结果需写出主存,用于反向计算过程中计算输入误差;反向计算过程中,每层的结果误差也需写出到主存,原因是反向计算时 BN 层及卷积层都需要进行两次计算,分别求参数误差及输入数据误差,X 、\Delta Y 加载两次来计算参数误差 \Delta\gamma 、\Delta\beta 及输入误差 \Delta X 。ReLU 输入 Y 不需要保存,直接依据结果 Z 即可计算出其输入数据误差。

前向过程中,BN 重构为两个子层:BN_A 和 BN_B 。
其中 BN_A 计算均值与方差,BN_B 完成归一化与缩放,分别融合于相邻卷积层及激活层。首先从主存读取输入 X 、均值 \mu 、方差 \sigma^{2} 、参数 \gamma 、\beta ,计算 BN_B ,完成归一化及缩放计算,将结果 Y 用于激活计算,输出 Z 用于卷积计算,卷积结果 X^{'} 写出到主存之前,计算 BN_A ,即求均值 \mu^{'} 与方差 \sigma^{2} 。
最终完成“归一化缩放->激活层->卷积层->计算卷积结果均值与方差”结构模块的前向计算过程只需要读取一次,并写回卷积计算结果 X^{'} 及相关参数。

具体融合计算过程如下所示:
将卷积计算公式带入到 BN 计算公式中,可得到下式:
展开后可得到:
也即将卷积与 BN 融合后的新权重 $w'$ 与 $b'$,可表示为如下所示:
最后,将卷积、BN 与 ReLU 融合,可得到如下表达式:
TVM是一个端到端的机器学习编译框架,它的目标是优化机器学习模型让其高效运行在不同的硬件平台上。它前端支持 TensorFlow、Pytorch、MXNet、ONNX 等几乎所有的主流框架。它支持多种后端(CUDA、ROCm、Vulkan、Metal、OpenCL、LLVM、C、WASM)及不同的设备平台(GPU、CPU、FPGA 及各种自定义 NPU)。
TVM 主要用于推理场景。在架构上,主要包括 Relay 和 TIR 两层。其通过 Relay 导入推理模型,随后进行融合优化,最后通过 TIR 生成融合算子。TVM 整体的算子融合策略是基于支配树来实现的,下面将介绍支配树等相关概念。
支配树与支配点:
具体而言,对于一张有向图(可以有环)我们规定一个起点 r ,从 r 点到图上另一个点 w 可能存在很多条路径(下面将 r 到 w 简写为 r→w )。如果对于 r→w 的任意一条路径中都存在一个点 p ,那么我们称点 p 为 w 的支配点(也可以称作是 r→w 的必经点),注意 r 点不讨论支配点。
下面用 idom[u] 表示离点 u 最近的支配点。对于原图上除 r 外每一个点 u ,从 idom[u] 向 u 建一条边,最后我们可以得到一个以 r 为根的树。这个树我们就叫它"支配树"。
如下图所示,到达 Node8 的路径有 Node3->4->7->8,Node3->5->7->8,Node3->6->7->8,因此 Node4,Node5,Node6,Node7 为 Node8 的支配点。

TVM 的算子融合策略就是检查每个 Node 到其支配点的 Node 是否符合融合条件,如果符合就对其进行融合。如上图,检查 Node4->Node7->Node8 是否能融合,若可以融合,则用新的算子替代原来路径上的算子。因此支配树作用如下:
支配树生成方式:
TVM 算子融合流程如下:
TVM 提供了 4 种融合规则,具体如下:

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。