想象你要准备一桌丰盛的年夜饭。菜单上有一道糖醋排骨,配料栏赫然写着“加 3 勺白糖,再额外加 2 勺白糖”。聪明的厨师绝不会等到下锅炒菜时才开始数糖,他会在备菜阶段直接改成“加 5 勺白糖”——反正都是固定要加的,提前算好天经地义。在 AI 的世界里,程序就像一份菜谱,编译器就是那个厨师。代码里经常写着 x = 3 * 4 + 2 这样的句子,如果每次运行都要重新算一遍 3×4,那实在太浪费了。编译器会在“开火”之前就把 3×4+2 算成 14,直接把 x 的位置摆好一个固定答案。这个“提前算好固定答案”的过程,就叫常量折叠(Constant Folding),它是图优化中最基础、也最常用的一招。看似简单,却贯穿了从 PyTorch 到 XLA 的整条 AI 编译链路,是理解所有编译器优化的第一块敲门砖。
掌握常量折叠,你就拿到了理解 AI 编译器优化日志的第一把钥匙。无论在 PyTorch 训练加速、XLA 编译调优还是推理部署瘦身中,都能迅速定位“哪些计算本该在编译期消失”。
可视化演示
展示常量折叠三步流程(识别→求值→替换)、不动点迭代收敛过程及标量/张量/Shape三类折叠,切换模式查看不同优化机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
常量折叠流程:识别→求值→替换 识别所有输入为编译期常量的算子节点(如ADD的输入2和3) 编译期求值后替换为常量节点(2+3→5),消除运行时计算 fold(ADD, 2, 3) → Const(5) 折叠减少算子数量,简化图结构。
概念入门
年夜饭备菜时,厨师会把“3 勺糖 + 2 勺糖”改成“5 勺糖”。这个动作看似不起眼,却暗含一个深刻的道理:只要操作对象全是固定的,结果就是固定的,提前做掉天经地义。AI 程序同样如此。模型代码会被编译成一张“计算图”——你可以把它想象成一张描述数据如何流动的菜谱,每个节点是一次运算(加法、乘法、卷积),每条边是数据的传递。当某个节点所有的输入都是固定值(常量)时,编译器就看到了一个“备菜机会”:直接把这道菜做出来,把结果放冰箱里等着用。
这个机会就是常量折叠。在编译优化领域,它专指在编译期计算那些输入全部为常量的表达式,并用计算结果替换原子图的操作。比如:
在图优化 pass 管线中,常量折叠几乎总是最早执行的优化之一。它先把图中的“死肉”剔掉,让图变小,后续的算子融合、内存规划、指令调度才能在更清爽的图上大展拳脚。回到年夜饭的比喻:提前把糖称好,不仅炒菜时少两道工序,也让灶台更整洁、大厨更从容。这就是常量折叠在 AI 编译里的定位——用编译期的一点时间,换运行期一路顺畅。
- 原始表达式这个式子包含一次乘法和一次加法,三个操作数 3、4、2 都是字面量常量
- 内层乘法按运算优先级,3×4 是最内层子表达式,编译器可先对它求值
- 折叠符号箭头上的 fold 表示“编译器执行折叠变换”这一动作
- 折叠结果整个表达式收缩为单个常量 14,运行期只需读取,无需运算
折叠算法
备了这么多年菜,厨师总结出一套固定检查流程:这道菜的原料是不是都来自“固定仓库”?如果是,当场做好放保鲜盒;如果不是,留在下锅时再处理。编译器对计算图节点的检查逻辑与此完全一致——这就是折叠算法的主干。
算法的输入是一张计算图,输出是折叠后的新图。核心是对每个节点 n 做一次决策:
把这套决策落到工程实现上,经典流程分四步。第一步,常量子图识别:扫描全图,标记常量节点以及“所有输入都是常量”的可折叠节点。第二步,表达式求值:用编译期内置的求值器(类似一个迷你解释器)把子图算出结果。第三步,节点替换:用新的常量节点替换整棵子图。第四步,死代码消除:被替换掉的旧节点没有任何引用,顺手从图中删掉。这套流程像厨师把备好的菜放进保鲜盒后,把用过的砧板清洗归位——整个后厨焕然一新。
- 折叠函数fold(n) 表示对节点 n 做折叠决策,返回值是替换后的节点
- 全称量词∀ 读作“对于每一个”,它遍历 n 的全部输入节点 v,一个不落
- 常量判定isConst(v) 检查输入节点 v 是否为编译期可知的常量
- 求值分支当所有输入都通过判定,就用求值器算出常数值并替换原子图
- 保留分支否则保持节点 n 不动,等待运行时传入真实数据再计算
实现机制
后厨不是想什么时候备菜就什么时候备。主厨会把“提前备菜”写进每日流程表,固定在下午三点执行,风雨不改。编译器也一样,常量折叠作为一个独立优化 pass,被安排在优化管线的固定位置,严格遵守执行顺序。
在编译器架构中,pass 是“对中间表示做一次遍历变换”的最小优化单元。前端(负责把 Python 代码变成中间 IR)和后端(负责 IR 到机器码)各有自己的中间表示,也都各自实现了一份常量折叠。以常见的执行顺序为例:shape 推断确认每个张量的形状 → 常量折叠消除常量子图 → 算子融合把相邻算子合并成一个 kernel。折叠放在 shape 推断之后是刻意的——shape 推断能算出哪些节点的输出形状也是常量,这给折叠提供了更多线索;而折叠把图变小后,算子融合处理的候选节点变少,融合效率更高。
这个 pass 化的设计带来一个好处:优化策略可以自由组合、按需插拔。比如调试时跳过折叠 pass,就能保留中间节点;生产环境下开启它,就能获得极致性能。就像主厨的流程表上,“备菜”这一栏是可以根据当天情况勾选或跳过的——但一旦勾上,就会在固定时间、按固定标准执行,绝不手忙脚乱。
- 优化器 PP 表示一次具体的编译优化过程,这里是常量折叠 pass
- 图映射箭头↦ 表示从输入图 G 得到输出图 G',强调“图到图的变换”
- 折叠算子fold(G) 把折叠规则作用到图 G 的每个节点上,产出新图
XLA 实战
如果说 PyTorch 默认模式是“边看菜谱边炒菜”的小饭馆,那 XLA 就是大型中央厨房:把整张菜单(整个模型的计算图)拿过来统一规划,所有菜品在开张前就被模块化备好。XLA 是 Google 推出的 AI 编译器,它的中间表示叫 HLO(High Level Operations),可以理解为“中央厨房的食材清单与工序表”。
在 XLA 中,常量折叠是 HLO 优化管线里最勤快的帮厨。它做的第一件事是 HLO 折叠:扫描整张 HLO 图,把输入全为常量的子图(如 constant(3) × constant(4))在编译期算完。第二件事是跨算子优化:不只折叠单个算子,像 reshape、transpose、slice 这类“搬运型”算子,只要输入是常量,也会被整段算掉。第三件事是设备端常量:折叠后的常量直接编入设备二进制,TPU/GPU 运行时不再为这些常量准备额外内存。XLA 还配有编译缓存,相同形状的模型编译一次后,后续直接命中缓存。
正是这套“整图备菜”的流程,让 XLA 在 TPU 上能把模型启动时间压到极低。对于大模型训练,每次迭代都要把输入数据灌进设备,HLO 图越小、常量越少,数据传输与设备初始化就越快。中央厨房的哲学在这里体现得淋漓尽致:能在开张前做完的,绝不占开张后的灶台。
- 输入 HLO 图折叠的对象是 XLA 内部的 HLO 中间表示,而非 Python 源码
- ConstFold 动作这是 XLA 优化管线中常量折叠 pass 的代号,作用在整张图上
- 输出新图HLO' 表示折叠后的图,图中所有可提前计算的常量子图已消失
PyTorch 实战
PyTorch 默认的 eager 模式就像“边看菜谱边炒菜”:每执行一行 Python 代码,就立刻调用底层算子算一次。这种方式灵活性极高,但也意味着同样的固定计算会被反复执行。TorchScript 和 Dynamo 的出现,就是为了给 PyTorch 补上“提前背菜谱”的能力:先把 Python 函数追踪成静态计算图,再对图做优化,其中就包括常量折叠。
TorchScript 是 PyTorch 的静态编译工具,通过 torch.jit.trace 或 torch.jit.script 把模型变成可序列化的图表示。JIT 追踪时,凡是直接由 Python 数值算出的中间量都会被标记为常量,进入折叠流程。Dynamo(torch.compile 的底层引擎)则更进一步,它拦截 Python 字节码,把子图抽出来编译,在编译 IR 上同样应用常量折叠。最经典的落地案例是推理期 BatchNorm 折叠:BatchNorm 在训练时均值、方差是统计量,但部署后这些参数全部冻结为常量,于是可以整体折叠进卷积层:
背过菜谱再去炒菜,虽然备菜阶段多花了一点时间,但真正上菜时一步到位。PyTorch 的开发者正是利用这种“动态转静态”的编译哲学,让 eager 模式的灵活性与编译优化兼顾,这也是 torch.compile 成为大模型训练标配的原因。
- BN 原始计算推理阶段均值 μ、方差 σ、缩放 γ、偏移 β 都已冻结为常量,下标 c 表示"constant"
- 权重合并常数除法与乘法结合成一个新权重 w_c,这是折叠中的“乘除项归并”
- 偏置合并所有与 x 无关的加减项合并成偏置 b_c,这是“加减项归并”
- 折叠结果整个 BN 变成一个线性变换,算子数减半,推理时少一次全张量遍历
性能影响
备菜的直接好处很直观:炒菜快、台面干净、食材不浪费。常量折叠在性能上的收益也可以逐一量化。
第一是启动提速。模型初始化阶段往往有大量只依赖超参数的固定计算,例如位置编码、attention mask、旋转位置编码的 cos/sin 表。eager 模式下每次启动都从头算一遍,折叠后这些计算在编译期完成,启动时间显著缩短。第二是内存节省。常量子图不再于运行时创建临时张量,设备内存的峰值占用随之下降。第三是模型瘦身。折叠后的常量可以序列化成紧凑的缓存文件,部署包变小、加载变快。第四是权衡代价:折叠本身消耗编译时间,但对单图而言复杂度近线性,且“一次编译、多次运行”的训练/推理场景下,这笔投入的回报极高。
这也解释了为什么大模型训练格外重视常量折叠:一个位置编码表在几万步训练中被反复使用,折叠后单步省下的几毫秒乘以几万步,就是几十分钟的净收益。主厨坚持下午三点备菜,多花半小时,换来的是饭点高峰期一小时的心不慌——这买卖太划算了。
- 单次省时T_const 表示某个常量子图每次运行时消耗的时间
- 调用次数N_calls 表示该子图在整个训练或推理过程中被执行的次数
- 总节省两者相乘就是折叠省下的总时间;调用次数越多,收益越可观
工程挑战
备菜不是没有风险:菜谱抄错、食材提前切好会氧化、客人临时改菜单。常量折叠在工程落地时同样横着四道坎。
第一道坎是精度风险。浮点运算不满足结合律,(a+b)+c 和 a+(b+c) 的结果可能有细微差异。编译期求值器与运行期 kernel 若采用不同的求值顺序或精度策略,折叠后的结果与折叠前会有毫厘之差。对训练影响尚可,对某些对数值敏感的推理场景则是致命伤。第二道坎是副作用问题。random()、print()、读取系统时间这类操作不能被折叠——提前算出结果与运行期实时调用在语义上完全不同。第三道坎是调试困境。折叠后中间节点从图中消失,开发者在排查数值问题时看不到过程值,只能看到最终常量。第四道坎是边界处理:动态 shape、控制流依赖的场景下,节点是否“真的是常量”需要格外谨慎,不能把运行时才能确定的值当成常量提前算掉。
好厨师会在备菜时留个心眼:能提前的提前,不能提前的绝不动手。常量折叠的工程分寸正是如此——每一种风险背后都对应一条设计红线:只折叠纯函数、对浮点运算做严格的 IEEE 兼容测试、为调试模式保留旁路开关。理解了这些边界,你才算真正握住了常量折叠这把工具,而不只是听过它的名字。
- 两者之差先计算折叠结果 v_fold 与运行期真实结果 v_runtime 的差
- 取绝对值竖线 |·| 去掉符号,只保留误差大小
- 相对误差除以运行值的绝对值做归一化,得到与量级无关的相对误差指标
AI 应用场景
场景:大模型训练启动加速
阶段:训练前编译与图优化
解决的问题:GPT 类模型初始化时要生成旋转位置编码(RoPE)的 cos/sin 查找表、attention mask、因果掩码等,这些张量只依赖序列长度和超参数,是天然的常量。eager 模式下每次启动训练都要重新算一遍,浪费 GPU 空转时间,且每次输出的字节都一样,纯属重复劳动。
具体案例:某团队训练 70B 参数模型时发现,光 RoPE 预计算表每次启动就要花 40 秒。改用 torch.compile 并让编译期完成常量折叠后,启动时间降到 8 秒,且折叠后的常量表直接缓存在编译产物中,后续多次启动共享。
场景:推理引擎部署瘦身
阶段:推理图优化与部署
解决的问题:ONNX 模型导出后,图里残留大量输入为常量的 Transpose、Reshape、Concat 节点,运行时要逐个启动 kernel 处理,GPU 利用率低,部署包也臃肿。
具体案例:某推荐系统把排序模型导出为 ONNX 后用 ONNX Runtime 推理。开启 ConstantFolding 优化后,模型参数文件减少 18%,单次推理延迟从 3.2ms 降到 2.1ms。因为常量子图在编译期被算掉,运行时少执行了几十个算子,GPU 的 kernel 启动开销大幅下降。
场景:异构设备代码生成
阶段:编译器后端代码生成
解决的问题:TPU、NPU 等专用芯片不支持通用的 Python 逻辑,也不希望在设备端处理常量运算。所有常量计算必须在 host 端编译期完成,设备只负责真正的张量算子。
具体案例:XLA 在 TPU 上编译 Stable Diffusion 的 UNet 时,ConstantFolding pass 把 attention mask、时间步嵌入等常量子图在 host 端算完,设备端 HLO 图更小、执行时间更短。编译缓存让同一结构不同权重的模型只会重新折叠一次,后续编译毫秒级完成。
Python 演示
class ConstantFolder: """极简常量折叠器: 把计算图中所有输入均为常量的节点提前求值。""" def __init__(self): self.constants = {} self.graph = None def is_const(self, node): return node['op'] == 'const' def eval_op(self, op, args): if op == 'add': return args[0] + args[1] if op == 'mul': return args[0] * args[1] if op == 'sub': return args[0] - args[1] raise ValueError("unsupported op: " + op) def fold(self, graph): self.graph = graph for node in graph['nodes']: if self.is_const(node): self.constants[node['id']] = node['value'] changed = True while changed: changed = False for node in graph['nodes']: if node['id'] in self.constants: continue if all(inp in self.constants for inp in node['inputs']): val = self.eval_op( node['op'], [self.constants[inp] for inp in node['inputs']] ) self.constants[node['id']] = val changed = True return self.constants def build_demo_graph(): # 计算图: n0=3, n1=4, n2=2, n3=n0*n1, n4=n3+n2, n5=n4*n1 return { 'nodes': [ {'id': 'n0', 'op': 'const', 'value': 3}, {'id': 'n1', 'op': 'const', 'value': 4}, {'id': 'n2', 'op': 'const', 'value': 2}, {'id': 'n3', 'op': 'mul', 'inputs': ['n0', 'n1']}, {'id': 'n4', 'op': 'add', 'inputs': ['n3', 'n2']}, {'id': 'n5', 'op': 'mul', 'inputs': ['n4', 'n1']} ] } if __name__ == '__main__': graph = build_demo_graph() folder = ConstantFolder() result = folder.fold(graph) for node_id in sorted(result): print(node_id + ' = ' + str(result[node_id]))
self.constants = {}用字典记录“节点 id → 折叠后的常量值”,相当于后厨的保鲜盒标签if self.is_const(node):第一遍扫描,登记所有天生就是常量的叶子节点,像先把固定食材清点入库all(inp in self.constants for inp in node['inputs'])折叠算法的核心判据:一个节点的所有输入都已进入保鲜盒,它自己也就可折叠了self.eval_op(node['op'], [...])用算子的语义当场做运算,这就是编译期求值器的最小实现changed = True只要这一轮有节点被折叠,上层节点的输入就变“干净”了,必须再扫一轮,直到图不再变化self.constants[node['id']] = val新算出的常量也放进保鲜盒,供上层节点继续使用
常见误区
误区初学编译器时,我总把“常量折叠”和“常量传播”当成同一件事,觉得反正都是把常量提前算好。
正解两者是不同层级的兄弟优化。常量折叠把“表达式”算成值,比如 3×4+2 变成 14;常量传播是把“变量的常量约束”沿着数据流传递,比如 a=5; b=a+1 时,编译器先知道 a 是 5,再算出 b 是 6。二者经常在同一 pass 里配合出现,但前者消灭计算,后者消灭变量引用。
为何常见:教材和源码里它们总是成对出现、共用遍历框架,初学者看到一个优化 pass 同时做这两件事,就误以为是一个概念。
误区既然输入都是常量,那不管什么操作,提前算掉不就行了吗?
正解只有“纯函数”才能安全折叠。random()、print()、读取系统时间、访问全局可变状态这类操作,即使没有输入,也不能提前求值——它们的结果每次运行都不同,或者有输出副作用。折叠会让程序语义被篡改,好比把“客人点餐时才决定放不放辣”提前改成“一律不放辣”。
为何常见:教科书示例永远用 3×4、a+b 这种纯算术,给人造成“只要输入是常量就能折叠”的错觉。
误区编译期算出来的 14,和运行期算出来的 14,一定一模一样。
正解对于整数通常是安全的,但浮点运算不满足结合律和分配律。编译期求值器用某种顺序把 (a+b)+c 算完,运行期 kernel 可能用 FMA 指令或不同精度把 a+(b+c) 算出来,结果在小数点后几位可能不同。相对误差可能小到 1e-7,但对某些数值敏感的算法会连锁放大。
为何常见:人脑默认数学运算是精确且可结合的,忽略了 IEEE 754 浮点表示的实际规则。
误区`2+3` 这种标量才叫常量折叠,大模型里都是 Tensor,用不上。
正解张量同样可以被折叠。常量张量之间的 add、matmul、reshape、transpose、slice 都能在编译期算完,XLA 甚至会折叠整段跨算子的常量子图。PyTorch 的 BatchNorm 折叠就是对整张特征图做的事,省下的内存和时间远大于标量场景。
为何常见:教学材料习惯用标量举例子,而真实编译器的折叠实现是面向任意形状的张量数据,两者形成了认知落差。
面试题
Q1. 常量折叠发生在哪个阶段?
- A.源码编写阶段
- B.编译优化阶段← 正确答案
- C.运行时计算阶段
- D.模型部署阶段
答案:B。常量折叠是编译器的优化动作,发生在编译优化阶段,而非源码编写或运行时。编译器拿到中间表示后,在优化管线的固定位置执行折叠,对应公式中 fold 变换作用在编译期图上。
Q2. 表达式 3×4+2 经常量折叠后结果是什么?
- A.原式保留给运行期
- B.变为 12 再加 2
- C.折叠成常量 14← 正确答案
- D.变为 3 乘以 6
答案:C。常量折叠会把整条只含常量的表达式在编译期算完,而不是只做一步。3×4 先算成 12,12+2 再算成 14,最终替换为单个常量节点。选项 B 只是中间态,不是折叠终点。
Q3. 以下哪个操作不能被安全折叠?
- A.x = 3 乘 4 的乘积
- B.y = 2 加 5 的和值
- C.z = random 乘 2 值← 正确答案
- D.w = 10 除 2 的商
答案:C。random() 是带随机副作用的非纯函数,即使乘以常量 2 也不能提前求值,否则每次运行结果都被固定,程序语义被篡改。其余选项都是纯算术,可以安全折叠。
Q4. 常量折叠能带来哪些收益?
- A.减少运行期计算量← 正确答案
- B.加速模型启动过程← 正确答案
- C.直接提升模型精度
- D.减少运行内存占用← 正确答案
答案:ABD。折叠把常量计算移到编译期,运行时少执行算子,计算量和内存占用都下降,启动也更快。但折叠不改变数值精度(反而可能引入微小误差),所以不能提升模型精度,C 错误。
Q5. XLA 中常量折叠直接作用的对象是什么?
- A.TensorFlow Python 源码
- B.XLA 的 HLO 计算图← 正确答案
- C.Keras 模型配置文件
- D.训练数据批处理流
答案:B。XLA 的优化都发生在 HLO 中间表示上。Python 源码、Keras 配置在进入 XLA 前已被前端翻译成 HLO 图,折叠 pass 遍历的是 HLO 节点,对应公式 HLO → HLO'。
Q6. 常量折叠后遗留的无用节点由谁清理?
- A.算子融合优化器
- B.死代码消除步骤← 正确答案
- C.内存规划器
- D.梯度裁剪器
答案:B。被替换的旧节点无人引用,属于死代码,由死代码消除(DCE)清理。算子融合做的是合并相邻算子,内存规划管的是张量复用,梯度裁剪是训练技术,都与清理无关。
Q7. PyTorch 中哪个模块会对图执行常量折叠?
- A.torch.nn 模块函数
- B.TorchScript 编译器← 正确答案
- C.torch.optim 优化器
- D.torch.utils 工具集
答案:B。TorchScript 的 JIT 编译器会把 Python 函数追踪成静态图,并在图优化阶段执行常量折叠。torch.nn 是网络层库,torch.optim 是训练优化器,torch.utils 是数据工具,都不做图优化。
Q8. 下列哪些情况应避免常量折叠?
- A.操作带有随机副作用← 正确答案
- B.浮点精度敏感场景← 正确答案
- C.调试想保留中间节点← 正确答案
- D.表达式全是整数算术
答案:ABC。有副作用的操作不能折叠;浮点精度敏感时折叠可能引入细微偏差;调试时保留中间节点有助于观察数值。纯整数算术恰恰是最适合折叠的场景,D 错误。
Q9. 常量折叠与常量传播的主要区别是什么?
- A.折叠提前求值表达式← 正确答案
- B.两技术本质完全相同
- C.传播只处理浮点数
- D.折叠只能在 GPU 执行
答案:A。折叠是把常量表达式算成值(3×4 → 12),传播是把变量的常量约束沿数据流传递(a=5 传给 b=a+1)。两者常配合使用但职责不同,B、C、D 的描述都不成立。
Q10. 关于常量折叠精度问题的说法,哪些正确?
- A.浮点加法满足结合律
- B.编译期与运行期结果可能有差异← 正确答案
- C.整数运算也有溢出风险← 正确答案
- D.折叠后必须做 IEEE 校验
答案:BC。IEEE 754 浮点加法不满足结合律,(a+b)+c 与 a+(b+c) 可能有细微差异,所以 B 对、A 错。整数运算可能溢出,极端大数相乘结果超范围,故 C 也对。“必须做 IEEE 校验”说法过于绝对,实际是策略选择,D 错。