将 VM 放入 TVM:Relay Virtual Machine#

Relay是一种新的程序表示方法,它实现了大量机器学习程序的表示和优化。不幸的是,通过支持更有表现力的程序集,引入了一些新的执行挑战。

Relay 的解释器可以执行完整的语言,但是有明显的限制,这使得它不适合生产部署。它被构造成执行 AST 遍历来执行程序的低效解释器。这种方法在概念上很简单,但效率很低,因为 AST 遍历严重依赖于其间接性。

在编译动态代码方面还有更多的挑战,比如动态调度(dynamic scheduling)和分配(allocation)、全动态张量形状(fully dynamic tensor shapes)和控制流(control flow)。解释器为这些提供了简单的解决方案,但没有足够引人注目或优化的。

第二种执行机制是现有的图执行程序。为了使 Relay 程序达到这个目标,将它们中的一小部分编译成旧的图格式,并在运行时执行它们。图执行器提供了快速执行体验,但只适用于非常有限的 Relay 程序子集。

另一种替代的但不是标准的方法是 Relay 的提前编译器(ahead-of-time compiler),它将 Relay 程序编译到包含提前实现的共享库中。提前编译器提供了令人信服的性能,但是很难扩展和使用,这只能通过修改代码生成和优化机制来实现。

Relay 虚拟机旨在成为平衡这些相互竞争的方法的框架,提供动态执行环境,可以通过灵活的扩展机制进行扩展、插装,并与其他方法(如提前编译)集成。

设计虚拟机是为了在部署和执行 Relay 程序时在性能和灵活性之间取得平衡,而不放弃 TVM 的好处。

在编程语言和系统中,虚拟机(VM)设计是得到充分研究的领域,已经有各种成熟的和嵌入式编程语言的虚拟机设计。

以前的语言 VM 设计都是针对传统程序的执行配置文件进行了大量的定制。传统程序操作小标量值,由大量低级指令组成。指令的数量要求指令的执行和调度非常高效。在机器学习的背景下,主要使用张量值,使用(相对)少量的高级指令。ML 程序的成本中心是在大输入上昂贵的 operator 调用,例如 GEMM 或卷积。由于 ML 程序所展示的执行概要,在标量 vm 中进行微优化的重要性大大降低。

TVM 为视觉模型提供了强有力的支持,但我们希望发展到支持更广泛的模型。图执行器能够利用输入图的完全静态特性来执行主动优化,例如完全静态分配和最佳内存重用。当引入使用控制流、递归、动态形状(dynamic shapes)和动态分配(dynamic allocation)的模型时,必须改变执行的工作方式。Relay 的虚拟机是自然的选择。

本文档的其余部分提供了 Relay 虚拟机设计及其指令集的高级概述。

设计#

VM 的设计注重简单性,而不牺牲性能。为了实现这一点,着重设计了张量 VM,而不是标量 VM。

在张量 VM 设置中,优化了对象的廉价“allocation”(通过尝试避免真正的 allocation)、静态片段(static fragments)的重用和动态形状(即 jagged tensors)的能力。

指令集#

指令集和指令(instruction)表示的选择是虚拟机最关键的设计决策。指令的当前表示形式是 tagged union,包含 op-code 和 data payload。

重要的设计决策是指令的抽象级别(RISC vs. CISC)以及它们如何获取数据(固定宽度指令编码(fixed-width instruction encoding) vs.可变长度指令编码(variable-length encoding))。当前版本更接近 CISC,使用了像 AllocTensor 这样的复杂指令,并且由于在指令中包含了形状,所以是可变长度的。当前的指令集是非常高级的,大致对应于 Relay 中的高级运算。

Ret#

参数

  • RegName dst

  • RegName result

result 寄存器(register)中的对象返回给调用者的 dst 寄存器。

InvokePacked#

参数

  • Index packed_index

  • Index arity

  • Index output_size

  • RegName* packed_args

调用 packed_index 所表示的打包函数。arityoutput_size 用来告诉虚拟机预期有多少输入和输出。packed_args 存储参数寄存器(register)列表。注意 Indexint64_t 的别名,它也会在其他指令中使用。

AllocTensor#

参数

  • RegName dst

  • RegName storage

  • uint32_t ndim

  • int64_t* shape

  • DLDataType dtype

从给定的存储块(storage block)storage 中分配使用常量 shape(存储在 shape 中)和 dtype 的张量值。结果保存在 register dst

AllocTensorReg#

参数

  • RegName dst

  • RegName storage

  • RegName shape_register

  • DLDataType dtype

从给定的存储块(存储在 storage)中分配适当形状的张量值(存储在 shape_register 中)和 dtype。结果保存在 register dst

AllocStorage#

参数

  • RegName dst

  • RegName size

  • RegName alignment

  • DLDataType dtype_hint

使用给定的 sizealignment,数据类型和 dtype_hint 分配存储块。分配的存储块存储在寄存器 dst 中。

AllocADT#

参数

  • RegName dst

  • Index tag

  • Index num_fields

  • RegName* datatype_fields

使用来自注册表(register)datatype_fieldsnum_fields 条目分配带有标记 tag 的数据类型。结果保存在 register dst

AllocClosure#

参数

  • RegName dst

  • Index clo_index

  • Index num_freevar

  • RegName* free_vars;

分配闭包,将 VMFunction 在 clo_index 处作为其代码,并从 free_vars 寄存器中分配 num_freevar 条目。结果保存在 register dst

GetField#

参数

  • RegName dst

  • RegName object

  • Index field_index

用索引 field_indexobject 获取字段值。结果保存在 register dst

If#

参数

  • RegName test

  • RegName target

  • Index true_offset

  • Index false_offset

检查寄存器 test 处的对象是否等于 target。如果相等,则相对跳转为 true_offset,否则相对跳转为 false_offset

GetTag#

参数

  • RegName object

  • RegName dst

在寄存器 object 中获取 ADT 对象的对象 tag。结果保存在 register dst

Fatal#

虚拟机执行失败。

Goto#

参数

  • Index pc_offset

通过 pc_offset 进行相对无条件跳转。

Invoke#

参数

  • Index func_index

func_index 调用函数,消耗 VMFunction 的 arity 字段中包含的参数数量。

InvokeClosure#

参数

  • RegName closure

  • Index num_closure_args

  • RegName* closure_args

调用 closure,消耗闭包的 VMFunction 中声明的参数数量。

LoadConst#

参数

  • RegName dst

  • Index const_index

从常量池中加载常量 const_index。结果保存在 register dst

LoadConsti#

参数

  • Index val

  • RegName dst

加载常量整数 val 来注册 dst。结果是 0-rank 阶张量。

Object 表示#

利用对象协议来表示 VM 使用的对象。

目前,有三种类型的对象 NDArrayADTClosure 对象,分别用来表示张量、元组/列表和闭包数据。它们的详细信息可以分别在 include/tvm/runtime/ndarray.hinclude/tvm/runtime/vm/vm.hinclude/tvm/runtime/container.h 中找到。

Stack and State#

Relay VM 维护了 stack frame,其中包含关于如何恢复前一个调用的信息。在连续空间(虚拟 register 文件)中为每个函数分配 register。

跟踪已调用的一组 Relay 函数,一个指向其字节码的指针,一个指向字节码的偏移量(称为程序计数器(program counter))。

struct VirtualMachine {
  ...
  std::vector<VMFrame> frames;
  ...
  // Current function.
  size_t func_index;
  // Pointer into the current function's instructions.
  const Instruction* code;
  // Current program counter relative to the code pointer.
  size_t pc;
  ...
};

Dispatch Loop#

VM 的关键部分是 dispatch loop。dispatch loop 通常支配虚拟机的执行时间,但通过实验发现,对于 Relay 来说并非如此。刚刚实现了简单的 switch/goto dispatch loop,它基于 instruction op code 进行 dispatch。

此循环由 VirtualMachine::Run() 实现。

VM Compiler#

这个基础结构的一个重要部分是编译器,从 Relay 的完整 IR 到 bytecode 序列。VM 编译器会将 tvm::relay::Module 变换为 tvm::relay::vm::Executable。可执行文件包含一组编译函数,编译函数包含在 tvm::relay::vm::Function 中。函数包含关于函数的元数据以及已编译的字节码。然后,发出的可执行对象可以通过 tvm::relay::vm::VirtualMachine 对象加载和运行。有关数据结构的完整定义,请参见 include/tvm/runtime/vm/executable.hinclude/tvm/runtime/vm/vm.h

优化#

VM 编译器需要相当多的优化。它们中的每一个都被实现为由 Relay pass manager 管理的 pass。

标记为 TODO 的优化还没有实现。

序列化#

序列化和反序列化由 Relay VM 编译器生成的可执行文件是必须的,因为可能想要将模型保存到磁盘上,然后执行推断。之前,Relay 已经在 json 文件中为图执行器生成了序列化的形式。但是,同样的格式并不直接适用于 VM,因为它会发出字节码,而不是图风格的程序。可执行文件的序列化本质上需要处理模型特定的(如 weights 和 kernels)和 VM 相关的(如字节码和全局函数名)数据。

对于 kernels,可以方便地利用现有的 TVM infra 来保存和加载已编译的库模块。这里只关注以二进制格式序列化其他几个组件,这些二进制格式按照以下部分的顺序组织。

  • 全局部分:包含虚拟机使用的全局变量(函数名)。

  • Constant 部分:用于存储虚拟机的 constant pool(即模型的 weights)。

  • 原语名称部分:为了适应将由虚拟机调用的原语算子名称列表(即以 fused_ 开头的名称)引入。原语名称被用作在编译后的 kernel 库中查找函数指针的符号。

  • Code 部分:VM 函数,包括字节码,都在这个部分。dispatching 循环遍历本部分以获取执行指令。

因此,与包含权重(.params)、graph json (.json)和编译的 kernel 库(.so)的图执行器工件不同,序列化的可执行工件是由 Relay 对象文件(.ro)和编译的 kernel 库(.so)组成的。

实现了 save 函数来将可执行文件存储到磁盘并将其序列化为上述格式。同时,使用 load_exec 函数加载序列化的 kernel 二进制和可执行的相关二进制代码,这些代码将再次用于实例化 VM 对象。更多示例请参考 test_vm_serialization.py

未解决的问题#

如何处理动态形状?#

动态形状支持是 TVM 升级 Relay,TVM 编译器正在进行的工作。关于动态形状支持的最新更新,建议在 TVM 的讨论论坛中进行追踪更新。

如何修改 VM 以支持 JIT 编译某些代码路径?#

在代码生成空间中,仍然有许多需要分析的权衡,VM 被设计得非常灵活,所以可以为未来的实验修改它。

如何支持异构执行?#

异构执行应该是开箱即用的,假设已经注解了适当的设备副本。为了正确地做到这一点,需要运行设备 annotation 和 copying passes。