想象一家热闹的餐厅后厨:中央厨房提前把食材切好、调料配齐,按固定配方流水线批量出餐,速度快、成本低——可一旦客人临时说“不要香菜”“米饭换糙米”,整条流水线就卡住了。RELAY VM虚拟机,就是这家餐厅特意请来的“现场执厨”:他手里攥着一沓便签菜谱,把 RELAY IR 这整本菜谱抄成一张张标准步骤卡,逐张读、逐张做。客人改单时,他瞟一眼锅里,判断该继续照卡做,还是跳到另一张卡。虽然每道菜都要现场操作、慢一些,却能在动态模型、变长输入、复杂分支这些“临时改单”场景里从容应对。 这篇文章,我们就用这位现场厨师的视角,拆开 RELAY VM 的便签纸(字节码设计)、调料碗(虚拟寄存器)、出菜节奏(执行循环)、备料顺序(数据依赖)和他接临时改单的本事(控制流支持),再看看他与中央厨房(图执行器)的较量,以及餐厅什么时候才需要请他出山(工程配置)。
掌握 RELAY VM 的核心机制,是 AI 编译器工程师在动态模型推理引擎中完成选型、定位性能问题、设计调度策略的必备内功。
可视化演示
展示RELAY VM虚拟机三维度:编译执行流程(Relay AST经类型检查和变换Pass后生成字节码由VM解释器逐条分派执行指令包括Alloc Load Call If Ret等)、VM与图执行器对比(VM支持控制流动态形状ADT递归表达力强但解释开销大图执行器静态拓扑序执行内存复用优调度效率高但不支持运行时分支)、控制流与ADT支持(If指令实现条件分支cond为假跳转else配合Goto实现互斥执行AllocADT分配实例含tag和字段GetTag GetField实现模式匹配),三模式切换理解RELAY VM核心机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+C1+'
字节码设计
中央厨房拿到一本新菜谱,第一步不是直接下锅,而是把菜谱拆成一沓标准化便签:每张便签固定写“热油”“下葱”“放盐”,用最简短的记号承载一个动作。RELAY VM 做的正是这件事——它把高层 RELAY IR 这本“菜谱”翻译成一串紧凑的字节码(Bytecode)。字节码是 VM 的“便签纸语言”,每条指令由操作码(opcode)和操作数(operand)两部分构成:操作码是动作记号,操作数指明动作的对象。
为什么要绕这么一道弯,而不是直接执行 IR?因为 IR 是嵌套的树形结构,像一本带图表和批注的菜谱,翻起来费劲;而字节码是拉直的、线性的指令列表,VM 像只认记号的厨师,逐张便签照做即可。RELAY VM 会在编译阶段把 IR 的每个节点映射为一条或若干条指令,最终交付一段紧凑的指令序列。运行时,表驱动解码器把操作码当作索引,从一张函数表里找到对应的处理逻辑。
这套设计的关键收益是统一:不管前端把模型表达成什么样的 IR,到了 VM 这里都被收拢成同一种线性格式,便于检查、便于序列化,也让执行器的实现与具体模型结构解耦。
就这样,整本复杂的菜谱被摊成一张张统一格式的便签,现场厨师不识字也能照卡出菜。字节码的“标准化”正是 RELAY VM 一切灵活性的地基。
- 取指从字节码数组中取出程序计数器当前指向的那条指令,
instr就是手头这张便签。 - 前进根据操作码的编码宽度
\delta更新程序计数器,指向下一条便签的位置。
虚拟寄存器
厨师手边有一排编号小碗:3 号碗放葱花,5 号碗放酱汁。指令里写“把 3 号碗的葱花倒进 5 号锅”,全靠编号定位,手一伸就够到。RELAY VM 的虚拟寄存器(Virtual Register)就是这些编号碗——一块连续的数组,用来存放模型推理过程中的中间结果,通常是张量。每条字节码指令通过寄存器编号读取数据、计算结果,再写回另一个编号的寄存器。
有了这排编号碗,指令就变得非常扁平。比如一条乘法指令,只需写明“从几号碗取左操作数、从几号碗取右操作数、结果放进几号碗”,不需要像 IR 那样嵌套地表达计算关系,存取都是 O(1) 的数组读写。虚拟寄存器没有真实硬件的数量限制,编译器在生成字节码时,会为每个 IR 中间值分配一个寄存器编号,编号可复用——同一只碗用完倒空,下一道菜还能继续用。
虚拟寄存器把复杂的计算图“压扁”成一系列对固定槽位的读写,这既是字节码紧凑性的来源,也是执行循环能快速推进的原因。
碗按编号摆得整整齐齐,厨师随手抓取、倒进倒出,出菜节奏自然流畅。虚拟寄存器在字节码和真实数据之间搭起了一座“随手可取”的工作台。
- 取操作数从源寄存器
a和b中读取当前值,相当于从碗里舀出调料。 - 运算写回执行乘法后,把结果存进目标寄存器
d,覆盖掉旧内容。
执行循环
现场执厨的节奏是固定的:从一沓便签的第一张开始,读一张、做一张、划掉一张,直到整沓做完。RELAY VM 的核心同样是一个往复循环——执行循环(Dispatch Loop),三个动作周而复始:取指(Fetch)从内存中读出当前指令,解码(Decode)识别操作码含义,执行(Execute)驱动寄存器完成运算。程序计数器(PC)就像厨师的手指,随时指着正在读的那张便签。
执行循环的调度顺序完全由 PC 决定。取完一张便签,手指自然滑到下一张;遇到“跳到第 7 张”这种便签,手指就被强制拨到指定位置。这意味着 VM 的执行路径在运行时是可变的——同一段字节码,面对不同的输入数据,可能走过完全不同的指令序列。这与图执行器把指令顺序在编译期锁死形成鲜明对比。
PC 的更新规则是 VM 调度的基石。顺序执行只需加一,跳转执行则直接覆写目标地址,两种模式的切换由指令类型决定。
节奏固定而内容自由,便签换一张,菜就换一种。执行循环就是 VM 永不停歇的“出菜节拍器”,每一步都踩在 PC 这个节拍点上。
- 顺序前进普通指令执行完毕后,程序计数器加一,指向下一条相邻便签。
- 跳转覆写遇到跳转类指令时,程序计数器被直接设置为目标地址
addr。
数据依赖
先淘米才能蒸饭,先切菜才能下锅。便签的纸张顺序可以打乱,但备料的前后次序绝不能乱。RELAY VM 怎么知道哪道工序必须等哪道工序?答案藏在每条指令的“原料清单”里。数据依赖(Data Dependency)描述的是:一条指令的计算结果,被另一条指令当作输入使用。在 VM 中,每条指令有读集(Read Set)和写集(Write Set):读集列出它要取数的寄存器,写集列出它要写入的寄存器。
如果第一条指令的写集与第二条指令的读集有交集,第二条就依赖第一条。比如“把 5 号碗倒进 3 号锅”之后,“从 3 号锅舀一勺试味”就必须排在那之后。VM 通过这个规则保证:无论执行路径怎么跳转,每条指令读取到的都是最新、正确的计算结果。想调整执行顺序,前提是不破坏这些依赖关系。
依赖约束看似抽象,实则是正确性的保险绳。它决定了指令调度合法性的边界——哪些指令可以交换位置,哪些必须死死钉在原有顺序上。
数据依赖是便签背面看不见的线,把零散的工序串成一条逻辑严谨的备料链。VM 虽然灵活,也终究要顺着这条链行走,才能保证端上桌的菜是熟的。
- 刻画读写集分别收集指令 i 写入的寄存器集合和指令 j 读取的寄存器集合。
- 交集判依赖若两个集合交集非空,说明存在数据流动,i 必须排在 j 前面。
控制流支持
客人突然喊一句“辣就少放辣”,现场厨师瞟一眼锅里,立刻决定是继续照原卡做,还是跳过两页便签直接执行“改小火”的那张。RELAY VM 对动态模型的看家本领,就是这种“瞟一眼再决定”的能力——控制流支持。VM 提供条件跳转指令,它先检查某个寄存器的取值,条件成立就把 PC 拨到目标地址,条件不成立就继续顺序执行。if/else 分支、while 循环、动态 shape 的路径选择,统统能被编译成这种“看条件跳卡”的指令。
图执行器在编译期把整张图展开成固定的执行序列,遇到分支就提前算好要不要走、走哪条;而 VM 把决定权留给运行时。比如循环次数取决于输入序列长度时,VM 每跑一圈都检查一次“是否还有下一个 token”,有就回跳到循环体开头,没有就落到循环出口。这样同一份字节码就能适配任意长度的输入。
控制流让程序不再是一条笔直的路,而是一棵在运行时才长出枝叶的树。VM 的灵活性正是建立在这些跳转指令的调度之上。
现场厨师能接住临时改单,靠的就是锅边那一眼判断和手中娴熟的跳卡功夫。控制流支持,就是 RELAY VM 递给动态模型的那张“临时改单接受证”。
- 条件检查读取条件寄存器,计算当前是否满足跳转条件
cond。 - 双路选择条件为真则 PC 设为
tgt,为假则 PC 加一继续顺序执行。
图执行器对比
中央厨房按标准配方预配食材,一条流水线批量出餐:省人力、速度快、品质稳定——代价是每份菜都长一个样,改不了。图执行器(Graph Executor)就是这座中央厨房:编译阶段把整张计算图静态展开成调度序列,执行时按部就班,零分支判断、极低调度开销。而 RELAY VM 是明档前的现场执厨:每道菜单独起锅、逐条指令解码分发,灵活但多一份逐便签操作的“人工成本”。
两者的性能差异可以用一个简单的开销模型来理解:静态图把大部分成本消耗在编译期的一次性准备上,运行期几乎纯线性推进;VM 则每一轮循环都要付出取指、解码、分发的单位开销。但图执行器的“省”建立在一切提前确定的前提上——一旦 shape 动态变化、分支依赖数据、循环次数运行时才知道,静态图要么无法编译,要么被迫退化成笨重的回退策略,反而更慢。
选哪种执行方式,本质是看你的菜单够不够固定。固定 shape、高吞吐的推理服务,中央厨房更合适;变长输入、控制流密集、动态 shape 的模型,现场执厨才能接得住。
菜单固定时,没人会嫌弃中央厨房的效率和稳定;可宴席上常有临时改单的贵客,这时候就得把厨师请到明档。理解这两套执行模式的开销结构,才能做出不亏本的选型决策。
- 准备开销
C_setup涵盖编译、布局和设备准备,静态图这一项较重但只付一次。 - 逐条成本指令总数为
N_instr,每条指令的平均分发时间为t_dispatch,相乘即运行期总开销。
工程配置与适用场景
用不用现场执厨,取决于餐厅定位:快餐店永远用中央厨房,私房菜馆却把明档当招牌。TVM 也给了开发者一个“请厨师到明档”的选项——RELAY VM 后端。工程上的切换并不复杂:在构建阶段把编译目标切到 VM,生成字节码文件与执行库,运行时加载两者即可。RELAY VM 与图执行器共用前端与算子库,切换后无需改动模型定义,只是执行语义从“静态展开”变为“动态解释”。
配置 VM 时,开发者通常需要指定目标设备(CPU 或 GPU)、生成可执行代码对象,然后在运行时创建 VM 实例并把编译产物喂给它。VM 会基于指令中的张量类型信息在运行时规划内存:动态 shape 的张量按实际大小现场分配,固定 shape 的张量则可以复用预分配空间。这也解释了为什么 VM 特别适合 NLP 中变长序列批处理、强化学习中的 if/else 决策网络、以及需要反复修改图结构的模型调试场景。
内存在 VM 中是“用多少备多少”,动态 shape 越大,所需内存规划空间就越大。这个简单的估算是工程规划的第一把尺子。
配置得当的现场厨师就是镇店之宝:平时按需上场,关键时刻接得住任何临时改单。RELAY VM 不是要取代图执行器,而是在模型动态性面前,给工程师多一把趁手的刀。
- 单张量体积把第 k 个张量的每个维度相乘,再乘以它每个元素占用的字节数。
- 汇总求和把模型所有张量的体积加总,得到 VM 运行时需要的内存总量。
AI 应用场景
变长序列 NLP 推理引擎
阶段:推理执行引擎选型
解决的问题:传统图执行器要求输入 shape 在编译期固定,遇到批量大小不一、句子长短不齐的 NLP 任务,要么 padding 到最大长度浪费算力,要么反复重建图导致延迟飙升。RELAY VM 在运行时按真实输入动态规划张量形状,边推理边调度,省去 padding 开销。
具体案例:一家做智能客服的公司把 BERT 模型部署到 GPU 服务上,线上请求的句子长度从 3 个词到 100 个词波动。改用 RELAY VM 执行后,动态 shape 的每批次计算量贴合真实输入,吞吐提升约 30%,长尾请求的延迟也稳定下来。
强化学习模型的分支动作执行
阶段:训练与推理联动
解决的问题:强化学习策略网络内部常含 if/else 决策分支,动作选择依赖上一帧的观测值,同一份模型对不同状态走不同路径。图执行器会尝试把分支全部展开,分支一多就爆炸;VM 的条件跳转天然适配这种运行时决策。
具体案例:机器人控制团队在仿真环境里训练决策网络,策略里有一段“接近目标则转向,否则直行”的逻辑。使用 RELAY VM 后,模型编译一次即可应对所有状态分支,控制频率从 20Hz 提到 50Hz,训练效率显著提升。
模型调试中的单步执行
阶段:开发调试
解决的问题:静态图执行器像黑箱,开发者在排查数值异常时难以观察中间过程。VM 的解释执行天然支持指令级单步,配合寄存器内容检查,可以像调试普通程序一样排查模型逻辑。
具体案例:研究员在开发一个新的注意力变体时,发现 loss 异常。通过 RELAY VM 逐步执行字节码、查看每个寄存器的张量值,快速定位到某一层梯度被错误覆盖的问题,将排查时间从两天缩短到半天。
Python 演示
class MiniVM: """极简寄存器虚拟机,思想与 RELAY VM 一致""" def __init__(self, program): self.program = program self.pc = 0 self.regs = [0, 0, 0] def step(self): if self.pc >= len(self.program): return None ins = self.program[self.pc] op = ins[0] if op == "LD": self.regs[ins[1]] = ins[2] elif op == "ADD": self.regs[ins[3]] = self.regs[ins[1]] + self.regs[ins[2]] elif op == "SUB": self.regs[ins[3]] = self.regs[ins[1]] - self.regs[ins[2]] elif op == "CH" and self.regs[ins[1]] >= 0: self.pc = ins[2] return ins elif op == "JM": self.pc = ins[1] return ins if op != "HL": self.pc += 1 return ins def run(self): while self.pc < len(self.program): ins = self.step() print("PC=%d 执行 %s 寄存器=%s" % (self.pc, ins, self.regs)) def main(): # 程序语义: 若 x>=0 则 r0=x+y, 否则 r0=x-y program = [ ("LD", 1, 3), ("LD", 2, 2), ("CH", 1, 6), ("SUB", 1, 2, 0), ("HL",), ("HL",), ("ADD", 1, 2, 0), ("HL",) ] vm = MiniVM(program) vm.run() print("结果寄存器 r0 =", vm.regs[0]) if __name__ == "__main__": main()
self.regs[ins[1]] = ins[2]LD 指令把立即数写入目标寄存器,相当于往编号碗里放料。elif op == "CH" and self.regs[ins[1]] >= 0:条件跳转,检查寄存器值后决定是否修改 PC,对应 VM 的分支调度。if op != "HL": self.pc += 1非停机指令默认顺序前进,HL 让循环自然终止。vm.run()` 中的 `while self.pc < len(self.program)这就是执行循环本体,不断取指、执行,直到走出便签堆。("CH", 1, 6)` 之后紧跟 `("SUB", ...)` 和 `("HL",)被跳过的分支用占位指令隔开,真实 VM 中同样靠编译期地址布局实现跳转。
常见误区
误区RELAY VM 是解释器,逐条执行指令,所以不管什么模型,用它都一定比图执行器慢。既然图执行器更快,那 VM 就是鸡肋。
正解解释执行确实有逐条分发的开销,但图执行器在动态 shape 和分支模型面前需要重新编译、padding 或回退到通用路径,这些额外成本往往远超 VM 的调度开销。就像中央厨房在客人频繁改单时会堆积大量废料,现场执厨看似动作多,实际出菜反而更快。快慢取决于场景,而不是执行模式本身。
为何常见:人们习惯用“解释器 vs 编译器”的刻板印象做判断,忽略了动态模型给静态图带来的结构性成本。
误区RELAY VM 执行的就是 RELAY IR,字节码只是给 IR 换了个压缩格式,两者内容完全一样。
正解IR 是树形结构的高层中间表示,包含类型信息、算子节点、嵌套调用;字节码是经过编译器翻译的线性指令序列,把嵌套的计算压平成寄存器读写和跳转。这好比菜谱原著与后厨便签的区别——主厨不是复印菜谱,而是重新提炼成可执行的步骤卡。字节码丢掉了便于人阅读的树形结构,换来了运行时的高效解码与跳转能力。
为何常见:初学者容易把“中间表示”和“执行格式”混为一谈,忽略了编译这道关键工序。
误区VM 按字节码顺序一条条执行,程序员只要把指令排好就行,数据依赖是编译器才要操心的事。
正解VM 的执行器虽然按序推进,但保证顺序正确的前提,恰恰是严格遵守数据依赖约束。调度器在指令重排、寄存器复用、内存规划时都要检查读写集冲突,否则会算出错误结果。便签的顺序可以调整,但“先淘米再蒸饭”的依赖线不能断。依赖是藏在字节码背后的骨架。
为何常见:执行循环太像一个“按顺序执行”,让人误以为顺序天然合法,忽略了顺序本身是依赖约束的产物。
误区VM 是动态执行的,JIT 也是运行时编译的,所以 RELAY VM 就是一种 JIT 技术,两者没有区别。
正解VM 描述的是“用解释循环逐条执行字节码”的执行模式,JIT 则是在运行时把热点代码编译成机器码再执行。RELAY VM 可以配合 JIT——比如把热点指令序列批量翻译成机器码——也可以纯解释执行。就像现场厨师既能照便签一步步做,也能把常做的菜默背下来闭眼快炒,这是两种不同的工作方式,不该混为一谈。
为何常见:“动态”一词把两个不同层次的概念罩在一起,初学者容易在术语边界上踩坑。
面试题
Q1. 关于 RELAY VM 的核心作用,以下说法正确的是
- A.将 Python 源码编译为可执行文件
- B.为 RELAY IR 提供解释执行能力← 正确答案
- C.替代 GPU 驱动执行底层算子
- D.只做模型参数的序列化存储
答案:B。RELAY VM 是的执行层组件,其核心是把 RELAY IR 翻译成字节码并提供逐条解释执行的能力,对应便签菜谱的现场厨师。选项 A 是编译器的职责,C 是算子库和硬件驱动的事,D 是模型导入导出模块的工作。
Q2. 字节码指令的最小构成是
- A.操作码加操作数← 正确答案
- B.运算符加括号
- C.节点加依赖边
- D.类名加方法名
答案:A。字节码每条指令由操作码(做什么动作)和操作数(对谁做)组成,像便签上写“热油”和“葱花”两个要素。运算符加括号是表达式语法,节点加边是图的描述方式,类名加方法名是面向对象编程的概念,都不属于字节码的构成。
Q3. RELAY VM 中虚拟寄存器的主要作用是
- A.存放模型权重文件的路径
- B.保存推理过程的时间戳信息
- C.作为临时计算结果的固定槽位← 正确答案
- D.记录 GPU 显存的占用情况
答案:C。虚拟寄存器是字节码指令读写中间结果的数组槽位,相当于编号调料碗。路径、时间戳、显存占用都是宿主环境的元信息,不归 VM 的寄存器管理,它们是执行器周边工具的责任范围。
Q4. 程序计数器在 VM 执行循环中的职责是
- A.统计已执行指令的总数量
- B.记录当前正执行的指令位置← 正确答案
- C.存储神经网络的层数信息
- D.保存训练数据的标签集合
答案:B。程序计数器像厨师的手指,指着正在读的便签,VM 根据它的值取指、在执行后更新它。统计数量、记录层数、保存标签都不是 PC 的职责,PC 只回答“我读到哪张卡了”。
Q5. 判断两条指令存在数据依赖的正确写法是
- A.操作码完全相同就存在依赖
- B.前条写集与后条读集相交即依赖← 正确答案
- C.寄存器数量相同就存在依赖
- D.前条读集包含后条写集即依赖
答案:B。数据依赖的本质是数据流动:前一条指令写入的寄存器被后一条指令读取,即写集与读集相交,所以必须先算前一条再算后一条。操作码相同、寄存器数量相同都不能说明有值在传递,选项 D 描述的是反依赖方向,不是前写后读的标准依赖。
Q6. 关于 VM 对控制流的支持,以下说法正确的有
- A.条件跳转实现 if 分支选路← 正确答案
- B.循环回跳支撑动态轮次← 正确答案
- C.动态 shape 可在运行时选路径← 正确答案
- D.控制流完全由编译器静态展开
答案:ABC。VM 的看家本领就是把分支判断留给运行时:条件跳转检查寄存器决定 PC 去向,循环通过回跳指令反复执行体,动态 shape 根据实际输入选路径。选项 D 说的是图执行器的静态展开方式,恰与 VM 的动态特性相反。
Q7. 关于图执行器与 RELAY VM 的对比,以下说法正确的有
- A.图执行器静态展开图适合固定 shape← 正确答案
- B.VM 逐条解释字节码存在调度开销← 正确答案
- C.VM 天然支持动态分支与递归调用← 正确答案
- D.图执行器能直接执行任意 Python 控制流
答案:ABC。图执行器提前把计算图展开成固定序列,适合 shape 不变的高吞吐场景;VM 每条指令都要取指解码分发,开销来自逐条调度;VM 的跳转指令让动态分支和递归成为可能。图执行器只认静态展开的算子图,无法执行 Python 语言层面的任意控制流,D 错误。
Q8. 在 TVM 中启用 RELAY VM 的常见做法是
- A.构建时切换 VM 后端并链接执行库← 正确答案
- B.修改 GPU 驱动程序的配置文件
- C.用 Python 递归解析 AST 结构
- D.直接重写整个 TVM 编译器源码
答案:A。启用 VM 是工程配置动作:构建阶段把后端目标切到 VM,生成字节码和执行库,运行时加载两者即可,像把厨师请到明档。改驱动、解析 AST 或重写编译器都不是正确路径,前者越界,后者多余。
Q9. VM 执行循环的基本顺序是
- A.取指-解码-执行-更新 PC← 正确答案
- B.解码-取指-更新 PC-执行
- C.更新 PC-执行-取指-解码
- D.执行-更新 PC-取指-解码
答案:A。执行循环像厨师的读卡节奏:先取出一张便签(取指),看懂记号(解码),动手做(执行),最后手指滑到下一张(更新 PC)。其余选项颠倒了工序顺序,会导致指令还没读就执行或还没执行就翻页的逻辑错误。
Q10. 字节码与 RELAY IR 相比,优势包括
- A.线性序列便于跳转← 正确答案
- B.形式上紧凑且解码统一← 正确答案
- C.天然保存图的结构语义
- D.便于执行器批量读取← 正确答案
答案:ABD。字节码把嵌套 IR 压平成线性指令,跳转只需改变 PC,解码统一走表驱动函数,执行器按序批量读取效率高。选项 C 恰恰相反:字节码为了执行效率牺牲了图的树形结构语义,图的嵌套关系需要额外元数据才能还原。