2D 卷积优化
单击 此处 下载完整的示例代码
本教程概述了如何用 TVM 在 VTA 设计上有效地映射 2D 卷积工作负载。推荐先学习 矩阵乘法分块 教程。
2D 卷积在大多数计算机视觉深度神经网络中占主导地位。本教程将演示 TVM schedule 优化,将 NCHW 布局中的 2D 卷积算子映射到 VTA。还引入了延迟隐藏的概念,使得最大限度地利用 VTA 的计算和内存资源。
RPC 设置
首先对 Pynq 的 FPGA 进行编程,并构建其 RPC runtime。
from __future__ import absolute_import, print_function
import os
import tvm
import tvm.testing
from tvm import te
import vta
import numpy as np
from tvm import rpc
from tvm.contrib import utils
from vta.testing import simulator
# 从 3rdparty/vta-hw/config/vta_config.json 文件加载 VTA 参数
env = vta.get_env()
# 从 OS 环境中读取 Pynq RPC 主机 IP 地址和端口号
host = os.environ.get("VTA_RPC_HOST", "192.168.2.99")
port = int(os.environ.get("VTA_RPC_PORT", "9091"))
# 在 Pynq 上配置比特流和 runtime 系统,匹配 vta_config.json 文件指定的 VTA 配置。
if env.TARGET == "pynq":
# 确保 TVM 是用 RPC=1 编译的
assert tvm.runtime.enabled("rpc")
remote = rpc.connect(host, port)
# 重新配置 JIT runtime
vta.reconfig_runtime(remote)
# 用预编译的 VTA 比特流对 FPGA 进行编程。
# 可以通过传递比特流文件的路径而非 None,用 自定义比特流对 FPGA 进行编程。
vta.program_fpga(remote, bitstream=None)
# 在模拟模式下,本地托管 RPC 服务器。
elif env.TARGET in ["sim", "tsim"]:
remote = rpc.LocalSession()
计算声明
第一步,用 NCHW 格式描述 2D 卷积计算。
通过 batch size、空间维度、输入通道、输出通道、内核维度、填充维度和步长维度来定义 2D 卷积 shape。
将 ResNet-18 架构的第 9 个卷积层的 shape 作为卷积工作负载参数。
在 2D 卷积中添加了额外的算子,这些算子对输出进行移位和裁剪,从而模拟定点卷积,然后进行校正线性激活。下面描述 2D 卷积层的 TVM 数据流图:
由于这种计算太大,无法一次全部放入 VTA 的芯片缓冲区。因此,在调度阶段,我们将依靠计算分块策略,将计算分解为易于管理的块。
空间填充
注意,要导入 TOPI 库,在输入特征图张量上应用空间填充。空间填充有助于在 2D 卷积的上下文中进行分块,因为若卷积核窗口大小大于 1,则对于任何给定层的输入特征图,相同的 (x, y) 空间位置会被多次读取。
在 CPU 和 GPU 上,并行化工作时提高内存访问效率的一种方法是空间打包,这种方法要对数据进行重新布局。VTA 加载 DMA 引擎可以自动插入填充,因此不必将原始输入特征图重新打包到内存中。
我们展示了数据从 DRAM 加载到 VTA 的 SRAM 中时,VTA 的动态空间填充效果。这个过程发生在 2D 跨步和填充内存(strided and padded memory)读取后。
from tvm import topi
# 2D 卷积层尺寸取自 ResNet-18 架构(第 9 个卷积层)
batch_size = 1
height = 14
width = 14
in_channels = 256
out_channels = 256
kernel_h = 3
kernel_w = 3
pad_h = 1
pad_w = 1
stride_h = 1
stride_w = 1
assert batch_size % env.BATCH == 0
assert in_channels % env.BLOCK_IN == 0
assert out_channels % env.BLOCK_OUT == 0
# 输入特征图:(N, IC, H, W, n, ic)
data_shape = (
batch_size // env.BATCH,
in_channels // env.BLOCK_IN,
height,
width,
env.BATCH,
env.BLOCK_IN,
)
# 内核:(OC,IC,H,W,oc,ic)
kernel_shape = (
out_channels // env.BLOCK_OUT,
in_channels // env.BLOCK_IN,
kernel_h,
kernel_w,
env.BLOCK_OUT,
env.BLOCK_IN,
)
# 导出输出特征图维度
fout_height = (height + 2 * pad_h - kernel_h) // stride_h + 1
fout_width = (width + 2 * pad_w - kernel_w) // stride_w + 1
# 输出特征图:(N, OC, H, W, n, oc)
output_shape = (
batch_size // env.BATCH,
out_channels // env.BLOCK_OUT,
fout_height,
fout_width,
env.BATCH,
env.BLOCK_OUT,
)
# 卷积 reduction 轴
dy = te.reduce_axis((0, kernel_h), name="dy")
dx = te.reduce_axis((0, kernel_w), name="dx")
ic = te.reduce_axis((0, in_channels // env.BLOCK_IN), name="ic")
ic_tns = te.reduce_axis((0, env.BLOCK_IN), name="ic_tns")
# 输入占位符张量
data = te.placeholder(data_shape, name="data", dtype=env.inp_dtype)
kernel = te.placeholder(kernel_shape, name="kernel", dtype=env.wgt_dtype)
# 复制缓冲区:
# 对输入特征图应用空间填充
data_buf = topi.nn.pad(data, [0, 0, pad_h, pad_w, 0, 0], name="data_buf")
kernel_buf = te.compute(kernel_shape, lambda *i: kernel(*i), "kernel_buf")
# 声明二维卷积
res_conv = te.compute(
output_shape,
lambda bo, co, i, j, bi, ci: te.sum(
data_buf[bo, ic, i * stride_h + dy, j * stride_w + dx, bi, ic_tns].astype(env.acc_dtype)
* kernel_buf[co, ic, dy, dx, ci, ic_tns].astype(env.acc_dtype),
axis=[ic, dy, dx, ic_tns],
),
name="res_conv",
)
# 为定点归一化添加移位阶段
res_shr = te.compute(output_shape, lambda *i: res_conv(*i) >> 8, name="res_shr")
# 在(0,输入最大值)之间应用 clip 函数
inp_max = (1 << (env.INP_WIDTH - 1)) - 1
res_max = te.compute(output_shape, lambda *i: tvm.te.max(res_shr(*i), 0), "res_max")
res_min = te.compute(output_shape, lambda *i: tvm.te.min(res_max(*i), inp_max), "res_min")
# 结果张量
res = te.compute(output_shape, lambda *i: res_min(*i).astype(env.inp_dtype), name="res")
调度计算
下面将研究用有效方式将 2D 卷积映射到 VTA 所需的一组调度转换。包括:
- 计算分块
- 增加计算利用率的虚拟线程
- 降级到 VTA 硬件内联函数
# 创建 TVM schedule
s = te.create_schedule(res.op)
# 查看默认的 TVM schedule
print(tvm.lower(s, [data, kernel, res], simple_mode=True))