VTA 入门
单击 此处 下载完整的示例代码
本文介绍如何用 TVM 对 VTA 设计进行编程。
本教程演示在 VTA 设计的向量 ALU 上,实现向量加法的基本 TVM 工作流程。此过程包括,将计算降级到底层加速器操作所需的特定 schedule 转换。
首先导入 TVM(深度学习优化编译器)。进行 VTA 设计,还需要导入 VTA Python 包,这个包里包含针对 TVM 的 VTA 特定扩展。
from __future__ import absolute_import, print_function
import os
import tvm
from tvm import te
import vta
import numpy as np
加载 VTA 参数
VTA 的设计遵循模块化和可定制的原则。因此,用户可以自由修改影响硬件设计布局的高级硬件参数。这些参数在 vta_config.json
文件中由它们的 log2
值指定。这些 VTA 参数可以通过 vta.get_env
函数加载。
最后,在 vta_config.json
文件中指定 TVM target。当设置为 sim 时,会在行为 VTA 模拟器内执行。若要在 Pynq FPGA 开发平台上运行本教程,请遵循 VTA 基于 Pynq 的测试设置指南。
env = vta.get_env()
FPGA 编程
以 Pynq FPGA 开发板为 target 时,要为开发板配置 VTA 比特流。
# 要 TVM RPC 模块和 VTA 模拟器模块
from tvm import rpc
from tvm.contrib import utils
from vta.testing import simulator
# 从 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" or env.TARGET == "de10nano":
# 确保 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", "intelfocl"):
remote = rpc.LocalSession()
if env.TARGET in ["intelfocl"]:
# 对 intelfocl aocx 编程
vta.program_fpga(remote, bitstream="vta.bitstream")
计算声明
第一步,描述计算。 TVM 采用张量语义,每个中间结果表示为多维数组。用户需要描述生成输出张量的计算规则。
此示例描述了一个向量加法,分为多个计算阶段,如下面 的数据流图所示。首先,描述主存储器中的输入张量 A
和 B
。然后,声明 VTA 芯片缓冲区里的中间张量 A_buf
和 B_buf
。这个额外的计算阶段使得可以显式地暂存缓存的读和写。第三,描述将 A_buf
添加到 B_buf
,产生 C_buf
的向量加法计算。最后一个操作是,强制转换并复制回 DRAM,产生结果张量 C
。
输入占位符
以平铺数据格式描述占位符张量 A
和 B
,匹配 VTA 向量 ALU 规范的数据布局要求。
对于 VTA 的通用操作(例如向量相加),图块大小为 (env.BATCH, env.BLOCK_OUT)
。维度在 vta_config.json
配置文件中指定,默认设置为 (1, 16) 向量。
此外,A 和 B 的数据类型还要和 vta_config.json
文件中设置的 env.acc_dtype
匹配,即为 32 位整数。
# 输出通道因子 m - 总共 64 x 16 = 1024 输出通道
m = 64
# Batch 因子 o - 总共 1 x 1 = 1
o = 1
# A 平铺数据格式的占位符张量
A = te.placeholder((o, m, env.BATCH, env.BLOCK_OUT), name="A", dtype=env.acc_dtype)
# B 平铺数据格式的占位符张量
B = te.placeholder((o, m, env.BATCH, env.BLOCK_OUT), name="B", dtype=env.acc_dtype)
拷贝缓冲区
硬件加速器的特点之一是必须显式管理芯片存储器。这意味着要描述中间张量 A_buf
和 B_buf
,它们可以具有不同于原始占位符张量 A
和 B
的内存范围。
然后在调度阶段,告诉编译器 A_buf
和 B_buf
位于 VTA 的芯片缓冲区(SRAM)中,而 A
和 B
在主存储器(DRAM)中。将 A_buf
和 B_buf
描述为计算操作(恒等函数)的结果。编译器之后可将其解释为缓存的读操作。
# A 复制缓冲区
A_buf = te.compute((o, m, env.BATCH, env.BLOCK_OUT), lambda *i: A(*i), "A_buf")
# B 复制缓冲区
B_buf = te.compute((o, m, env.BATCH, env.BLOCK_OUT), lambda *i: B(*i), "B_buf")
向量加法
接下来用另一个计算操作来描述向量加法结果张量 C
。计算函数接收张量的 shape,以及描述张量每个位置计算规则的 lambda 函数。
这个阶段只声明如何计算,不会发生任何计算。
# 描述 in-VTA 向量加法
C_buf = te.compute(
(o, m, env.BATCH, env.BLOCK_OUT),
lambda *i: A_buf(*i).astype(env.acc_dtype) + B_buf(*i).astype(env.acc_dtype),
name="C_buf",
)
转换结果
计算完成后,将 VTA 计算的结果返回主存。
内存存储限制
VTA 的特点之一是它只支持窄的 env.inp_dtype
数据类型格式的 DRAM 存储。这减少了内存传输的数据占用时间(在基本矩阵乘法示例中,对此进行了更多说明)。
对窄的输入激活数据格式执行最后一个类型转换操作。
# 转换为输出类型,并发送到主存
C = te.compute(
(o, m, env.BATCH, env.BLOCK_OUT), lambda *i: C_buf(*i).astype(env.inp_dtype), name="C"
)
本教程的计算声明部分到此结束。
调度计算
虽然上面描述了计算规则,但可以通过多种方式获得 C
。TVM 要求用户提供名为 schedule 的计算实现。
schedule 是对原始计算的一组转换,它在不影响正确性的情况下,转换计算的实现。这个简单的 VTA 编程教程旨在演示将原始 schedule 映射到 VTA 硬件原语的基本 schedule 转换。
默认 Schedule
构建 schedule 后,schedule 默认以下面的方式计算 C
:
# 查看生成的 schedule
s = te.create_schedule(C.op)
print(tvm.lower(s, [A, B, C], simple_mode=True))
输出结果:
@main = primfn(A_1: handle, B_1: handle, C_1: handle) -> ()
attr = {"from_legacy_te_schedule": True, "global_symbol": "main", "tir.noalias": True}
buffers = {A: Buffer(A_2: Pointer(int32), int32, [1024], []),
B: Buffer(B_2: Pointer(int32), int32, [1024], []),
C: Buffer(C_2: Pointer(int8), int8, [1024], [])}
buffer_map = {A_1: A, B_1: B, C_1: C}
preflattened_buffer_map = {A_1: A_3: Buffer(A_2, int32, [1, 64, 1, 16], []), B_1: B_3: Buffer(B_2, int32, [1, 64, 1, 16], []), C_1: C_3: Buffer(C_2, int8, [1, 64, 1, 16], [])} {
allocate(A_buf: Pointer(global int32), int32, [1024]), storage_scope = global;
allocate(B_buf: Pointer(global int32), int32, [1024]), storage_scope = global {
for (i1: int32, 0, 64) {
for (i3: int32, 0, 16) {
let cse_var_1: int32 = ((i1*16) + i3)
A_buf_1: Buffer(A_buf, int32, [1024], [])[cse_var_1] = A[cse_var_1]
}
}
for (i1_1: int32, 0, 64) {
for (i3_1: int32, 0, 16) {
let cse_var_2: int32 = ((i1_1*16) + i3_1)
B_buf_1: Buffer(B_buf, int32, [1024], [])[cse_var_2] = B[cse_var_2]
}
}
for (i1_2: int32, 0, 64) {
for (i3_2: int32, 0, 16) {
let cse_var_3: int32 = ((i1_2*16) + i3_2)
A_buf_2: Buffer(A_buf, int32, [1024], [])[cse_var_3] = (A_buf_1[cse_var_3] + B_buf_1[cse_var_3])
}
}
for (i1_3: int32, 0, 64) {
for (i3_3: int32, 0, 16) {
let cse_var_4: int32 = ((i1_3*16) + i3_3)
C[cse_var_4] = cast(int8, A_buf_2[cse_var_4])
}
}
}
}