假设你是一家餐厅新上任的后厨总管,桌上摆着七位大厨各自交来的菜谱:有的菜谱写“先洗菜、切菜、焯水、再炒”,食材在冷藏室和灶台之间来回搬运十几次;有的菜谱里某个步骤“雕一朵萝卜花摆盘”,但压根没客人吃它;还有一份菜谱写着“酱汁现场调制:糖2克、醋3克”,可这套配方明明每天都一样。你要做的事,就是把这些菜谱改造成一套高效的后厨流水线——这正是 TVM 编译器对深度学习模型做的事。 深度学习模型在数学上是一连串张量运算,把它画成一张图,节点是算子、边是数据流动,就叫“计算图”。TVM 编译器里的图优化,本质上就是这位后厨总管的三板斧:提前备菜(常量折叠)、一锅炖(算子融合)、划掉没用的步骤(死代码消除)。读完这篇文章,你会彻底搞懂这三项技术的原理、公式和实现,并能亲手写出一个迷你图优化器。

岗位关联

对 AI 从业者而言,理解计算图优化是读懂 TVM、加速模型推理的钥匙。无论你做部署、做推理引擎还是 AI 编译器,这三板斧都是最核心的入门功夫。

目录
  1. 可视化演示
  2. 一、计算图概念:一份可以改写的菜谱
  3. 二、常量折叠:提前把酱汁调好
  4. 三、算子融合:一锅炖的带宽革命
  5. 四、死代码消除:划掉没人吃的装饰菜
  6. 五、优化流水线:后厨的标准化改造流程
  7. 六、性能评估:改造效果怎么算账
  8. 七、工程实践:在 TVM 里跑起来
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态演示TVM计算图优化三大技术的核心机制,通过输入/处理/存储/输出四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

TVM计算图优化三大技术:TVM计 TVM计算图优化的整体框架与流 常量折叠优化原理与实现机制 核心机制可视化 TVM计算图优化三大技术整体架构与关键组件

一、计算图概念:一份可以改写的菜谱

想象你是后厨总管,手头有一份“结构化菜谱”:每个步骤画成一个方框,食材从上一站流向下一站,箭头标明顺序。只有菜谱写成这种规范格式,你才能分析哪一步可以提前、哪几步能合并、哪一步纯属多余。深度学习模型也一样——只有先把模型翻译成一张计算图,编译器才能对它动手脚。

计算图是模型运算的可视化结构:节点是算子(卷积、加法、ReLU),边是张量流动(数据从上游传到下游)。TVM 用 Relay 中间表示(IR)承载这张图,图优化 Pass 在 IR 上做分析和改写。训练框架负责“写出菜谱”,TVM 负责“改造菜谱”。

计算图是一张有向无环图(DAG),数学上记作:

G=(V,E),v∈V 表示算子,e=(u,v)∈E 表示张量从 u 流向 v\displaystyle G = (V, E), \quad v \in V \text{ 表示算子,} e = (u, v) \in E \text{ 表示张量从 } u \text{ 流向 } v

1. 节点集合:$G=(V,E)$ 中的 $V$ 是所有算子的集合,如卷积、加法、ReLU,相当于菜谱中一个个具体动作。

V={conv,add,relu,… }V = \{\text{conv}, \text{add}, \text{relu}, \dots\}

2. 有向边集合:$e=(u,v)$ 表示张量从算子 $u$ 输出后作为输入流入 $v$,相当于食材盆从上一站端到下一站。

E={(u,v)∣u 的输出是 v 的输入}E = \{(u,v) \mid u \text{ 的输出是 } v \text{ 的输入}\}

3. 无环约束:图是 DAG,不存在循环依赖,保证每道菜都能按序完成,不会出现“回锅炖自己”的死循环。

G 中不存在路径 v1→v2→⋯→v1G \text{ 中不存在路径 } v_1 \to v_2 \to \cdots \to v_1

菜谱结构化之后,后厨总管才能火眼金睛地找到改造点。同样的道理,Relay IR 就是那张“可以改写的菜谱”,接下来的三大技术都在这张图上施展。

二、常量折叠:提前把酱汁调好

宫保鸡丁的酱汁是糖、醋、酱油按固定比例调的。如果每来一桌客人,厨师都现场称量、现场搅拌,高峰期必然手忙脚乱。聪明的后厨会提前把酱汁批量调好、装瓶备用,客人点菜时直接淋上去。常量折叠就是这瓶提前备好的酱汁。

在计算图中,如果某个节点的所有输入都是常量——编译期就能确定数值的节点——那么它的输出也一定可以在编译期算出来。TVM 的做法是:遍历计算图,找到这样的节点后,直接在编译阶段执行它,把整个子图替换成一个常量节点。比如 2 × 3 在编译时直接变成 6,推理时少执行一个乘法算子。

这个策略可以严格写成:

vfold={eval(v),∀u∈inputs(v): u∈Constv,otherwise\displaystyle v_{\text{fold}} = \begin{cases} \text{eval}(v), & \forall u \in \text{inputs}(v):\ u \in \text{Const} \\ v, & \text{otherwise} \end{cases}

1. 输入全为常量:$\forall u \in \text{inputs}(v): u \in \text{Const}$ 表示节点 $v$ 的每个输入 $u$ 都是常量节点,就像酱汁配方里每样原料都是已知的。

∀u∈inputs(v): u∈Const\forall u \in \text{inputs}(v):\ u \in \text{Const}

2. 编译期求值:$\text{eval}(v)$ 表示在编译阶段真的执行一遍这个算子,像后厨提前把酱汁熬好、装瓶。

result=eval(v)\text{result} = \text{eval}(v)

3. 替换或保留:条件满足就替换成常量节点,不满足就保留原样,绝不动任何没把握的步骤。

vfold=eval(v) 或 vv_{\text{fold}} = \text{eval}(v) \ \text{或} \ v

别忘了边界情况:依赖运行时数据的算子(如 dropout、受 batch 影响的 BN)不能折叠,含随机性的算子也不该折叠,而且折叠本身要花编译时间——备菜也得算人力成本。提前备好酱汁,高峰期上菜自然快;常量折叠让推理时的算子更少、启动更快。

三、算子融合:一锅炖的带宽革命

后厨真正的瓶颈往往不是切菜,而是搬运:食材从冰箱取、洗完放回台面、切完再端到灶台,每一步都让食材在冷藏室和工作台之间来回折腾。推理引擎也一样——现代硬件算力增长飞快,真正的瓶颈是内存带宽:中间张量写回全局内存、再读出来,每一趟都是真金白银的时间。

以 conv → bn → relu 为例:卷积的输出明明马上要被 BN 消费,却必须先完整写回内存,BN 再从头读一遍,ReLU 又重复一遍。算子融合把这三个算子合并成单个内核,让中间结果留在寄存器或缓存里,根本不出芯片。一次融合就省掉两次中间张量的全局内存访问:

Saving=2×∑k=1m∣Tk∣ 字节\displaystyle \text{Saving} = 2 \times \sum_{k=1}^{m} |T_k| \ \text{字节}

1. 中间张量求和:$\sum_{k=1}^{m} |T_k|$ 是把 $m$ 个中间结果的大小累加,每个被融合的中间张量都不再落地。

∑k=1m∣Tk∣\sum_{k=1}^{m} |T_k|

2. 写读双省:乘以 $2$ 是因为每个中间张量原本要“写回内存一次、读回一次”,共计两次访存。

2×∑k=1m∣Tk∣2 \times \sum_{k=1}^{m} |T_k|

3. 带宽即瓶颈:省内存访问比省运算更划算,因为内存带宽的增长远远慢于算力增长。

Time≈访存字节数内存带宽\text{Time} \approx \frac{\text{访存字节数}}{\text{内存带宽}}

融合不是想融就融,TVM 通过模式匹配找到 conv + bn + relu 这种固定套路,再检查融合合法性:中间节点不能被其他节点旁路消费、数据类型要一致。一锅炖省下的是搬食材的功夫,这也是图优化里收益最直观的一招。

四、死代码消除:划掉没人吃的装饰菜

菜谱上写着“雕一朵萝卜花放在盘边”,可这道装饰菜既没人吃、也不影响任何主菜的味道,纯粹浪费雕花师傅的时间。死代码消除就是那个划掉萝卜花的狠角色。

计算图里也会长出“萝卜花”:某些节点的输出没有任何下游节点消费,或者根本不影响最终输出。它们从哪来?多半是前两步优化的“残留物”——常量折叠替换了旧子图、算子融合合并了旧节点,旧的节点没人引用了,就成了死代码。死代码消除把这些节点从图里永久删掉,让图更干净、启动更快。

具体算法是经典的标记-清除(mark-and-sweep):从输出节点出发,沿反向边遍历,标记所有“被真正需要”的节点,未标记的就是死代码:

Reach={u∣u 能到达某个输出节点},Vdead=V∖Reach\displaystyle \text{Reach} = \{ u \mid u \text{ 能到达某个输出节点} \}, \quad V_{\text{dead}} = V \setminus \text{Reach}

1. 反向可达集:$\text{Reach}$ 是从输出节点沿边反向能走到的所有节点,它们是“被需要”的步骤。

Reach={u∣u⇒output}\text{Reach} = \{ u \mid u \Rightarrow \text{output} \}

2. 补集即死节点:$V \setminus \text{Reach}$ 是可达集的补集,即所有不被输出需要的节点。

Vdead=V∖ReachV_{\text{dead}} = V \setminus \text{Reach}

3. 安全删除:删掉死节点不会改变任何输出结果,就像划掉装饰步骤不影响菜的味道。

output(G)=output(G∖Vdead)\text{output}(G) = \text{output}(G \setminus V_{\text{dead}})

死代码消除是“保洁员”:它自己很少制造收益,但能把前两步优化留下的垃圾扫干净。后厨定期清理闲置设备和多余步骤,空间大了、人也不堵了。

五、优化流水线:后厨的标准化改造流程

三种优化不是随随便便乱做,后厨改造必须按流程:先备菜,再并锅,最后打扫。如果先打扫卫生再备菜,融合之后又会产生新垃圾,等于白扫。TVM 里每个优化都是一个 Pass(一遍图变换),Pass 管理器按顺序调度它们。

TVM Relay 中,每个 Pass 接收一张计算图、产出一张新图。三大 Pass 的标准顺序是:先 FoldConstant 做常量折叠,再 FuseOps 做算子融合,最后 EliminateDeadCode 清理死代码。写成数学形式:

PassSeq=EliminateDead∘FuseOps∘FoldConst\displaystyle \text{PassSeq} = \text{EliminateDead} \circ \text{FuseOps} \circ \text{FoldConst}

1. 复合顺序:$\circ$ 表示函数复合,右边的 $\text{FoldConst}$ 最先执行,符合“先备菜”的直觉。

f∘g(x)=f(g(x))f \circ g (x) = f(g(x))

2. 先折叠:$\text{FoldConst}$ 把常量子图压成数值,让图变小,为后续匹配提供更干净的输入。

G1=FoldConst(G0)G_1 = \text{FoldConst}(G_0)

3. 再融合:$\text{FuseOps}$ 在折叠后的图上做模式匹配并合并且,产生新的中间结构。

G2=FuseOps(G1)G_2 = \text{FuseOps}(G_1)

4. 最后清扫:$\text{EliminateDead}$ 删除 $G_2$ 中残留的旧节点,输出最终优化图。

G3=EliminateDead(G2)G_3 = \text{EliminateDead}(G_2)

为什么顺序如此重要?如果先做死代码消除,融合后新产生的废弃节点就没人管了。标准化流程保证每个模型都经历同样的严格改造,这正是 TVM Pass 管理器的价值。

六、性能评估:改造效果怎么算账

改造完的后厨到底行不行?不能靠感觉,要算账:翻台率(推理延迟)、食材损耗(内存占用)、开档准备时间(启动开销)。图优化的效果同样要量化,用优化前后对比说话。

最常用的两个指标是推理延迟加速比和内存节省率。前者看速度提升了多少倍,后者看内存省了多少百分比:

Speedup=LbaseLopt,MemSave=Mbase−MoptMbase×100%\displaystyle \text{Speedup} = \frac{L_{\text{base}}}{L_{\text{opt}}}, \quad \text{MemSave} = \frac{M_{\text{base}} - M_{\text{opt}}}{M_{\text{base}}} \times 100\%

1. 延迟加速比:$L_{\text{base}}$ 是优化前单张图的推理延迟,$L_{\text{opt}}$ 是优化后的延迟,比值衡量提速倍数。

Speedup=LbaseLopt\text{Speedup} = \frac{L_{\text{base}}}{L_{\text{opt}}}

2. 内存节省率:$\frac{M_{\text{base}} - M_{\text{opt}}}{M_{\text{base}}}$ 是内存下降比例,乘以 $100\%$ 转成百分比。

Mbase−MoptMbase×100%\frac{M_{\text{base}} - M_{\text{opt}}}{M_{\text{base}}} \times 100\%

3. 端到端权衡:融合可能让单个内核变复杂、编译变慢,所以要综合看“编译一次 + 推理 N 次”的总账。

costtotal=tcompile+N×Lopt\text{cost}_{\text{total}} = t_{\text{compile}} + N \times L_{\text{opt}}

后厨用数据说话:不看单个动作快不快,只看翻台率和损耗。评测时务必和基线(优化前的模型)做 A/B 对比,排除硬件波动的影响。

七、工程实践:在 TVM 里跑起来

理论学完,该回后厨实操了。打开 TVM 的“后厨手册”,把三大 Pass 装进编译流程。TVM 的 Relay 前端提供了开箱即用的接口:relay.transform.FoldConstant()、relay.transform.FuseOps() 和 relay.transform.EliminateDeadCode(),它们都挂在 Pass 管理器下,一行代码就能调度。

实际部署中,这三个 Pass 几乎总是一起出现:移动端 App 内存带宽小,融合收益立竿见影;云端推理集群显存昂贵,常量折叠和死代码消除能压低峰值显存;自动驾驶对延迟极度敏感,任何多余算子都是安全隐患。调试技巧很简单:每个 Pass 跑完后打印一次 mod 里的计算图,肉眼对比图的变化,再配合 profiler 验证收益。

工程上的取舍可以写成这样:

costend2end=tcompile+∑i=1Ntinfer(i)\displaystyle \text{cost}_{\text{end2end}} = t_{\text{compile}} + \sum_{i=1}^{N} t_{\text{infer}}^{(i)}

1. 端到端成本:$t_{\text{compile}}$ 是编译阶段的耗时,只在部署前发生一次。

tcompilet_{\text{compile}}

2. 推理总时长:$\sum_{i=1}^{N} t_{\text{infer}}^{(i)}$ 是线上服务 $N$ 次推理的总时长,这是优化的核心目标。

∑i=1Ntinfer(i)\sum_{i=1}^{N} t_{\text{infer}}^{(i)}

3. 工程取舍:用一次性编译开销换取长期推理收益,opt_level 调得越高,Pass 越多、编译越久,但线上推理越快。

min⁡Pass 配置(tcompile+∑itinfer(i))\min_{\text{Pass 配置}} \left( t_{\text{compile}} + \sum_{i} t_{\text{infer}}^{(i)} \right)

从菜谱分析到后厨落地,三大技术就这样变成了生产环境中的真实提速。记住了这三板斧,你再看 TVM 的编译日志,就能读出每一行优化背后的用意。

AI 应用场景

场景一:移动端 App 内人脸检测

阶段:模型部署优化

解决的问题:手机内存带宽小,中间张量每写回一次 DDR 内存就既耗电又增延迟。不做优化的模型在低端机上发热、掉帧;通过算子融合把 conv+bn+relu 合并成单内核,中间结果留在缓存,再叠加常量折叠缩短启动时间。

具体案例:某美颜 App 用 TVM 优化 RetinaFace 人脸检测模型,推理延迟从 35ms 降到 21ms,功耗下降约 30%,低端机也能流畅运行。

场景二:云端推荐系统推理集群

阶段:推理服务性能调优

解决的问题:高并发下 GPU 显存逼近上限,每多一张图都烧钱。常量折叠把 mask、padding 等静态子图编译期算掉,死代码消除清掉冗余分支,降低了单卡的显存峰值和计算量,从而提升吞吐。

具体案例:某推荐模型的 Embedding + MLP 结构部署到 GPU 集群,三类 Pass 叠加后显存占用降低 12%,单卡 QPS 提升 18%,延迟 P99 稳在 SLA 以内。

场景三:自动驾驶实时感知

阶段:端侧模型编译优化

解决的问题:车道级检测要求毫秒级时延,任何多余的算子都是安全隐患。TVM 图优化让每次前向传播更快,同时死代码消除压缩模型图,减少车载计算平台的启动时间。

具体案例:目标检测模型部署到车内计算平台,常量折叠 + 算子融合 + 死代码消除全部开启后,端到端感知延迟下降 22%,满足安全预算。

Python 演示

python109 行
class Node:    def __init__(self, name, op, inputs, const=None):        self.name = name        self.op = op        self.inputs = list(inputs)        self.const = const        self.users = []        self.alive = True        self.fused = None def build_links(nodes):    for n in nodes:        for u in n.inputs:            u.users.append(n) def topo_sort(nodes):    seen = set()    order = []     def visit(n):        if id(n) in seen:            return        seen.add(id(n))        for u in n.inputs:            visit(u)        order.append(n)     for n in nodes:        visit(n)    return order def fold(op, vals):    if op == 'mul':        return vals[0] * vals[1]    if op == 'add':        return vals[0] + vals[1]    raise ValueError('unknown op: ' + op) def constant_folding(nodes):    for n in topo_sort(nodes):        if n.const is not None:            continue        if all(u.const is not None for u in n.inputs):            n.const = fold(n.op, [u.const for u in n.inputs]) def operator_fusion(nodes):    found = []    for n in nodes:        if n.op == 'relu' and n.inputs and n.inputs[0].op == 'bn':            bn = n.inputs[0]            if bn.inputs and bn.inputs[0].op == 'conv':                conv = bn.inputs[0]                fused = Node(n.name + '_fused', 'fused', conv.inputs[:])                fused.fused = [conv, bn, n]                found.append(fused)    return found def dead_code_elimination(nodes):    marked = set()    stack = [n for n in nodes if n.name == 'out']    while stack:        n = stack.pop()        if id(n) in marked:            continue        marked.add(id(n))        stack.extend(n.inputs)    for n in nodes:        if id(n) not in marked:            n.alive = False def dump(nodes):    for n in nodes:        if not n.alive:            continue        ins = []        for u in n.inputs:            tag = u.name            if u.const is not None:                tag += '=' + str(u.const)            ins.append(tag)        print(n.name, '(' + n.op + ')', '<-', ', '.join(ins) if ins else '-') def main():    c1 = Node('c1', 'const', [], const=2)    c2 = Node('c2', 'const', [], const=3)    mul = Node('mul', 'mul', [c1, c2])    data = Node('data', 'input', [])    conv = Node('conv', 'conv', [data])    bn = Node('bn', 'bn', [conv])    relu = Node('relu', 'relu', [bn])    add = Node('add', 'add', [relu, mul])    out = Node('out', 'out', [add])    dead = Node('dead', 'id', [data])    nodes = [data, conv, bn, relu, mul, add, out, c1, c2, dead]    build_links(nodes)    print('== 优化前 ==')    dump(nodes)    constant_folding(nodes)    print('== 常量折叠后 (mul 变成常量 6) ==')    dump(nodes)    fused_list = operator_fusion(nodes)    for f in fused_list:        print('融合:', '+'.join(x.name for x in f.fused), '->', f.name)    dead_code_elimination(nodes)    print('== 死代码消除后 (dead 被移除) ==')    dump(nodes) if __name__ == '__main__':    main()
目的:用约 100 行 Python 实现一个迷你计算图优化器,完整演示常量折叠、算子融合、死代码消除三个 Pass 的先后效果。
  1. if all(u.const is not None for u in n.inputs)检查一个节点的所有输入是否都是常量,这是触发折叠的开关。
  2. n.const = fold(n.op, [u.const for u in n.inputs])在编译期执行算子,把结果写回节点,子图悄悄变成了常量值。
  3. if n.op == 'relu' and n.inputs and n.inputs[0].op == 'bn'从链尾往前做模式匹配,识别 conv→bn→relu 融合套路。
  4. fused = Node(n.name + '_fused', 'fused', conv.inputs[:])构造融合节点,它的输入直接是 conv 的输入,中间算子被“吞并”。
  5. stack.extend(n.inputs)从输出节点出发,把上游节点不断压栈,这就是反向可达标记的 BFS/DFS。
  6. if id(n) not in marked: n.alive = False遍历结束后,没有被标记的节点就是死代码,直接标记为不可用。
  7. mul = Node('mul', 'mul', [c1, c2])构造 c1×c2 常量子图,专门用来演示折叠。
  8. dead = Node('dead', 'id', [data])构造一个只被“喂”却没人“吃”的节点,专门等着被 DCE 清除。

常见误区

误区初学者以为常量折叠只能处理 `2×3` 这种标量字面量,一看到 weight、mask 之类的大张量就觉得这事与己无关。

正解常量折叠的对象是“编译期值可确定的任何张量节点”。权重矩阵、padding、mask 的固定形状运算都可以折叠,关键不是“看起来像数字”,而是“值在编译期是否已知”。后厨里不只是盐和糖能提前备好,整罐高汤也可以提前熬好。

为何常见:教材里的例子几乎全用标量,读者很容易把示例当边界。

误区以为融合只是“代码合并”,把 conv 和 relu 塞进同一个 for 循环就万事大吉。

正解融合的本质是内存访问优化——消除中间张量的全局内存写读。如果代码结构并在一起但数据仍然来回落内存,收益几乎为零。TVM 的 FuseOps 做的是数据依赖分析、生成单一内核、让中间结果留在寄存器或共享缓存。

为何常见:“融合”这个字面意思太有迷惑性,让人以为只是文件层面的合并。

误区觉得 DCE 和 Java 的 GC 一样,是运行时自动清理内存的东西,改天不用了还能再回收。

正解DCE 是编译期的静态图分析:从输出反向遍历,把不可达节点从计算图中永久删除,删除后连算子都不会执行。GC 是运行时管理堆内存对象。一个是“改图纸”,一个是“打扫房间”。

为何常见:两者都带“清理”属性,名字里也都有 dead / garbage,天然容易混淆。

误区以为把 `opt_level` 拉到最高、所有 Pass 一股脑全上,收益一定最大。

正解Pass 之间有顺序依赖,也有编译期开销。某些 Pass 组合可能互相冲突,而常量折叠、图改写都会花编译时间。部署线上要算“编译一次 + 推理 N 次”的总账,opt_level 高不等于总收益最大。

为何常见:“优化”两个字天然让人觉得多多益善,忽略了成本一侧。

误区以为内存带宽瓶颈只在 GPU 上,CPU 部署根本不需要算子融合。

正解CPU 的 cache 层级同样对中间张量的写读敏感,融合在移动端 CPU、嵌入式芯片上的收益往往更明显——因为这些平台的内存带宽更小、更金贵。

为何常见:大多数优化案例的截图都是 GPU 指标,容易给人造成“只有 GPU 才需要优化”的错觉。

面试题

Q1. 常量折叠最适合处理哪类节点?

单选题 · easy

  • A.输入来自实时推理数据
  • B.输入均为编译期常量← 正确答案
  • C.输入包含随机数生成器
  • D.输入来自模型权重文件

答案:B。常量折叠的触发条件是“所有输入在编译期值已知”。实时推理数据运行时才产生,随机数生成器结果不确定,都不能折叠;模型权重虽然是静态文件,但本题最佳答案是输入均为编译期常量。就像酱汁配方里所有原料确定,才能提前兑好。

Q2. TVM 中计算图的基本数据结构是什么?

单选题 · easy

  • A.有向无环图 DAG← 正确答案
  • B.有向带环图结构
  • C.无向连通图结构
  • D.线性链表式结构

答案:A。计算图是节点(算子)和边(张量流动)构成的有向无环图,不允许出现循环,否则编译无法确定执行顺序。它既不是无向图,也不是链表。就像菜谱的步骤必须按序流动,不能“回锅炖自己”。

Q3. 算子融合最核心的收益是什么?

单选题 · mid

  • A.减少卷积的算法计算量
  • B.把浮点运算变成定点运算
  • C.减少中间张量的内存读写← 正确答案
  • D.让模型支持更多硬件平台

答案:C。融合把 conv+bn+relu 合并成单内核,中间结果不再写回全局内存,核心收益是省掉“写一次、读一次”的两次访存。它不改变算子的浮点计算量,也不做精度转换。好比一锅炖省的是搬食材的时间,不是食材本身的切法。

Q4. conv+bn+relu 融合时,BN 缺少什么信息会导致融合失败?

单选题 · mid

  • A.运行期统计均值方差← 正确答案
  • B.编译期常量缩放因子
  • C.推理时传入的 batch 大小
  • D.输入张量的内存首地址

答案:A。BN 在训练模式下使用 batch 统计量(均值、方差),这些值依赖运行时数据,编译期无法确定,融合就做不了;推理模式下 BN 的参数是固定的常量,可以顺利融合。这就像酱汁配方里若有一味“客人现点的口味”,就没法提前兑好。

Q5. 死代码消除使用什么图遍历策略?

单选题 · easy

  • A.从输出节点反向标记可达← 正确答案
  • B.从输入节点正向计算扇出
  • C.随机采样并检查输出差异
  • D.对每条边做最小生成树

答案:A。DCE 用标记-清除法:从输出节点出发沿反向边做可达性遍历,被标记的是“有用的步骤”,未标记的就是死代码。正向扇出只能看“谁喂谁”,看不出“谁被最终需要”。就像后厨从“上桌的菜”倒推哪些步骤才算数。

Q6. 下列哪种情况最容易产生死代码?

单选题 · mid

  • A.输入数据的维度发生改变
  • B.学习率被设置成一个固定值
  • C.模型权重做完量化以后
  • D.算子融合后旧节点被替换← 正确答案

答案:D。算子融合后,被合并的 conv、bn 旧节点没有消费者了;常量折叠后,被替换的旧子图也成了孤儿。这些残留物就是死代码的主要来源。数据维度变化、学习率固定、权重量化都不会自发创造无消费节点的图结构。

Q7. TVM 计算图优化 Pass 的推荐执行顺序是什么?

单选题 · hard

  • A.先清除死代码再折叠再融合
  • B.先折叠再融合最后清死代码← 正确答案
  • C.死代码消除必须排在折叠前面
  • D.三个 Pass 执行顺序无关紧要

答案:B。正确顺序是折叠 → 融合 → 清死代码。折叠让图变小变干净,融合才好做模式匹配;融合之后必然留下废弃旧节点,最后清理最合理。如果先清死代码,融合新产生的垃圾就没人管了。后厨先备菜、再并锅、最后打扫,顺序不能乱。

Q8. 下列哪些属于 TVM 计算图优化 Pass?(多选)

多选题 · mid

  • A.常量折叠 FoldConstant← 正确答案
  • B.算子融合 FuseOps← 正确答案
  • C.数据增强 DataAugment
  • D.死代码 EliminateDeadCode← 正确答案

答案:ABD。FoldConstant、FuseOps、EliminateDeadCode 是图优化三大 Pass,都属于 Relay 的编译期变换。数据增强是训练阶段的工作,不属于部署期的计算图优化,就像备菜和“给客人表演雕花”是两个工种。

Q9. 关于算子融合,下列说法正确的有哪些?(多选)

多选题 · extreme

  • A.要求中间节点没有旁路消费者← 正确答案
  • B.融合后中间张量不复存在← 正确答案
  • C.任何两个算子都可以任意融合
  • D.融合能有效降低带宽开销← 正确答案

答案:ABD。中间节点若有旁路消费者,融合后别的节点就拿不到它的输出了,所以必须检查合法性;融合的实质就是让中间张量不落地;省掉的正是内存带宽开销。但“任何算子都能融合”是错的,数据依赖、算子特性都限制融合。一锅炖的前提是这几道菜真的走一条线。

Q10. 常量折叠的边界情况包括哪些?(多选)

多选题 · hard

  • A.依赖运行期数据的算子不能折叠← 正确答案
  • B.含随机性的算子不应折叠← 正确答案
  • C.折叠会带来一些编译期开销← 正确答案
  • D.所有浮点乘法都必须折叠

答案:ABC。只要输入里有运行时才知道的值,折叠就无法进行;随机性算子每轮结果不同,折叠会改变语义;折叠本身要花编译时间,有成本。但“所有浮点乘法都必须折叠”是错的——只有输入全为常量的乘法才能折叠。酱汁只有配方完全固定才能提前兑,缺一味都不行。