想象你是一家星级酒店后厨的主厨。午市高峰期,八桌客人同时点了蒜蓉粉丝虾、蒜蓉蒸排骨、蒜蓉西兰花。三个炉头的帮厨各自抓起蒜头,开始剥皮、剁碎、炼蒜油——三份一模一样的工作,浪费了三份时间和人力。聪明的厨师长会规定:蒜蓉统一由备菜间一次性加工一大碗,三份菜单共享这一碗。这个看似简单的“备菜共享”决策,正是AI编译器里“公共子表达式消除”(CSE)的核心思想。 在AI的世界里,模型就是一个庞大的“后厨”,计算图(Graph)就是它的菜谱流程。训练和推理时,计算图中经常出现完全相同的“备菜动作”——比如某个特征矩阵乘以同一个权重矩阵两次,某两个张量相加的结果被后续多处使用。如果不加优化,GPU(图形处理器)这位“帮厨”就得重复执行这些相同运算,白白烧掉算力和电费。CSE要做的事,就是把重复的子表达式识别出来,提前计算一次,让后续消费者共享结果。本文将带你从后厨视角出发,一步步深入CSE的原理、算法、性能权衡,以及它在XLA、PyTorch编译等AI编译器中的真实应用,让你彻底看透这块“图优化”的基石。
对AI从业者而言,理解CSE是读懂编译器优化Pipeline、排查模型推理性能瓶颈、优化训练显存占用的基本功——它是AI编译器帮你“白捡”性能的隐形功臣。
可视化演示
三步展示XLA CSE优化:HloCSE哈希分桶+精确比较的等价检测、重复子表达式只算一次并共享、以及非确定性/副作用指令的排除约束。
本节配有交互动效(进入视口后自动加载,约 3KB)。
子表达式等价检测 A+B: 重复子表达式 同桶:哈希相同待精确比较 哈希分桶 + 精确比较 (O(n)近似) 避免O(n²)两两比较。
概念定义
回到后厨场景。厨师长翻看午市菜单,发现三台灶都在做“剁蒜泥”这同一个动作。每台灶对应计算图中的一个算子节点,而“剁蒜泥”这个动作就是“公共子表达式”——它在图的不同位置重复出现,且操作相同、输入相同。在AI编译器的术语里,计算图是一张有向无环图(DAG):节点是算子(如矩阵乘法、加法、激活函数),边是张量数据流。当某个子图结构在图中出现多次,且对应的输入张量来源一致时,它就是一个公共子表达式,编译器可以只保留一份计算。
CSE的优化目标很明确:消除冗余计算,减少算子执行次数。假设图中两处分别计算 a×b,第一次用到结果后,第二次完全可以复用同一份结果。正如备菜间一次性剁好一大碗蒜泥,两台灶随取随用,不再各自重复劳动。
这就是CSE的“备菜哲学”:一次准备,全店共享。理解了概念,接下来我们看后厨如何快速认出“哪两碗菜是同一份活儿”。
a×b 提取为中间变量 t,原来的 x 和 y 都改为从 t 取结果。- 提取公共子表达式将重复出现的
a×b计算一次,存入新变量t。 - 替换第一处使用原
x = a×b + c中,a×b替换为引用t。 - 替换第二处使用原
y = a×b + d中,同样引用t,避免第二次计算。
识别原理
帮厨每天要面对几十种备菜需求,如何一眼认出“蒜蓉”和“姜末”不是同一件事?靠的是标签。CSE识别公共子表达式的核心手段是哈希表与值编号(Value Numbering)。每个表达式根据“操作符 + 左右操作数的编号”计算出一个唯一的哈希值,这个哈希值就是表达式的“编号”。当两个表达式的哈希值相同,编译器就判定它们等价,可以合并。
具体做法是:遍历计算图时,为每个节点分配一个值编号。对于二元运算 e = op(l, r),其值编号 v(e) 由操作符 op 与左右操作数的值编号组合后哈希得到。由于 a×b 的两个操作数编号相同,两处 a×b 得到的哈希值必然相同——不管它们在图中相隔多远。为了识别更隐蔽的重复,编译器还会利用代数恒等式做“标准化”,比如 a×b 与 b×a 视为同一表达式(交换律), a+0 规范化为 a(单位元),从而让哈希判断更聪明。
哈希值匹配只是“认出重复”,但真正要安全地合并,还要回答一个问题:第二次使用时,第一次的结果是否一定还“活着”?这就引出了算法的全局视野——支配关系。
e 的编号是其算子 op、左操作数编号 v(l)、右操作数编号 v(r) 三者联合哈希的结果。- 取算子得到当前节点的运算类型,如乘法
\times或加法+。 - 取左右编号递归获取左操作数和右操作数已有的值编号,代表它们的“身份”。
- 联合哈希将三者拼成元组计算哈希值,同结构的表达式得到相同编号。
算法流程
备菜间准备的蒜蓉,必须确保在它被使用之前不会被打扫卫生的阿姨倒掉。CSE同样面临“生命周期”问题:合并两个相同表达式的前提是,第一个计算点必须能安全地“主宰”第二个使用点。这就是支配关系(Dominance)的由来。我们称节点 d 支配节点 n,如果从入口到 n 的所有路径都必经 d。只有第一个表达式所在节点支配第二个表达式所在节点时,第二个节点才能确定地复用第一个节点的结果——否则控制流可能绕开了第一个计算点,导致使用时变量未定义。
编译器按拓扑序遍历基本块,在每个基本块内维护一个局部哈希表,记录“当前可见的表达式编号”。遇到新表达式先查表:命中则用旧编号重写当前节点;未命中则插入表。对于跨越基本块的情况,借助支配树提前将表达式提升到支配者节点,从而让CSE从局部扩展到全局。整个流程是:遍历图 → 查哈希匹配 → 用支配关系验证安全性 → 重写计算图。
有了哈希匹配和支配关系双保险,CSE就能放心地“一份备菜,多处共享”。但这个优化并非没有代价。
n 的支配集合等于自身与所有前驱支配集合交集的并,表示所有路径必经的节点集合。- 自身加入每个节点都支配自己,这是支配关系的基点。
- 前驱支配集交集取所有前驱节点支配集合的交集,表示无论从哪个前驱进入都会经过的节点。
- 并集构成完备集自身与前驱交集合并,得到节点
n完整的支配集合。
性能影响
备菜共享节省了剁蒜的时间,但代价是那一大碗蒜蓉得一直放在台面上占地方。CSE同样存在权衡:它减少了重复计算(省算力、省延迟),但提取出的公共子表达式会产生中间张量,这些张量可能长时间驻留在寄存器或显存里,增加内存压力。尤其在现代AI芯片上,有时“重新算一遍”反而比“从远处读取中间结果”更快——因为访存开销可能大于浮点运算开销。
因此,CSE并非总是净收益,编译器需要启发式地评估:如果公共表达式出现的次数很多、单次计算代价很高,合并绝对划算;如果只出现两次且代价低廉(如一次加法),而中间结果又需要跨多个算子保持活跃,那么不如不合并。性能工程师常用的分析指标是“净节省操作数”:
理解了代价模型,再看AI编译器里的真实场景:现代深度学习模型动辄几百个算子,CSE在其中扮演着什么角色?
n 次的子表达式 e,合并后减少 n-1 次执行,每次节省 cost(e) 的计算代价。- 出现次数统计公共子表达式在计算图里被计算的次数
n。 - 单次代价估算该表达式单次执行的算子开销,如乘加运算的FLOPs。
- 合并节省合并后只需执行一次,总节省为
(n-1)倍的单次代价。
AI编译实践
如果把深度学习模型比作一场满汉全席,几十道菜共用高汤、酱汁这些“半成品”,那么XLA、PyTorch编译器等AI编译器就是统筹全局的总厨。XLA在将TensorFlow或JAX模型编译为HLO(High Level Optimizer)中间表示后,会执行多轮Pass优化,其中就包含HLO级别的CSE——把重复的卷积、矩阵乘、归一化子图合并成公共算子。在残差网络(ResNet)中,x + f(x) 的分支结构里,x 被反复引用,CSE确保这个激活值只计算一次,大幅减少冗余内核启动。
PyTorch 2.x中的TorchInductor编译器同样通过CSE优化算子融合后的计算图,减少中间张量的分配与拷贝。不过动态图(Eager模式)下,模型边运行边构建图,CSE难以执行——因为你永远不知道下一步会算什么。这也是编译器工程师推动“图模式”(torch.compile)的原因:把动态执行收敛为静态图,CSE等优化才有用武之地。在手机端侧推理引擎(如TFLite、NCNN)中,CSE还被用于减少内存带宽消耗,让低功耗设备跑起大模型。
从框架到端侧,CSE贯穿了AI编译的各个环节。那么在实际工程项目中,工程师如何落地一个CSE Pass?
x + f(x) 中,CSE确保 x 与 f(x) 各自只计算一次,避免因展开或重排产生重复激活。- 原始结构残差块的输出是输入
x与函数变换f(x)之和。 - 提取中间量让
f(x)的结果先存入变量y,明确公共的中间表达式。 - 复用求和
out由x与y相加得到,保证f(x)只执行一次。
工程实战
备菜流程不是写一次就完事,主厨会根据翻台率、菜品种类持续调整SOP。CSE在编译器工程中同样是一个可插拔的Pass,通常运行在目标无关的IR优化管线中。实现一个CSE Pass,基本步骤包括:遍历IR中的基本块 → 维护表达式哈希表 → 匹配成功后用第一个结果的SSA值替换后续使用 → 最后清理无效的冗余指令。但在真实工程里,CSE通常与死代码消除(DCE)配合:CSE合并后会产生一些“没人再用的旧计算”,DCE负责把这些死代码删除,两兄弟协同作战,才能最大化收益。
调试CSE是件细致活。工程师常用 print-ir-after-all 之类开关逐Pass dump IR,对比优化前后的计算图;出现浮点精度差异时,要检查是否错误合并了本不该合并的表达式(例如涉及非确定性算法的算子)。基准测试方面,通常用GPU内核启动次数、FLOPs、端到端延迟三个指标量化CSE的收益。整个优化管线可以形式化表示为:
至此,从后厨到编译器,CSE的全貌已经展开。它不只是“省一次乘法”的小技巧,更是AI编译器优化管线中承上启下的基石。
G 经CSE消除冗余后变为 G',再经DCE删除死代码后得到精简的 G''。- CSE消除冗余对图
G应用公共子表达式消除,合并重复计算。 - DCE删除死代码
G'中冗余计算留下的无用算子由死代码消除Pass清理。 - 输出精简图最终
G''是既无重复计算又无死代码的精简计算图。
AI 应用场景
大模型训练显存优化
阶段:模型编译与训练加速
解决的问题:训练大模型时,计算图中频繁出现的重复子表达式(如共享权重矩阵被多个分支使用)会导致中间张量重复分配,GPU显存告急。CSE在编译阶段合并重复计算,显著降低峰值显存占用。
具体案例:在GPT风格的Transformer训练中,注意力头之间的 QKV 线性变换共享输入 h。XLA编译器通过CSE识别出多个头共用同一份 h×W 计算,将显存占用降低约15%,训练吞吐提升8%,使得原本需4张A100的模型压缩至3张即可训练。
TorchInductor推理加速
阶段:推理引擎优化
解决的问题:PyTorch模型在Eager模式下重复执行相同算子,离散的内核启动导致推理延迟高。torch.compile 配合TorchInductor对计算图做CSE和算子融合,减少冗余内核调用。
具体案例:一个包含残差连接的ResNet-50推理模型,原始计算图中存在多处重复的卷积结果引用。启用TorchInductor后,CSE将重复的BatchNorm输出合并,推理延迟从12.3ms降至8.7ms,且无需改动任何模型代码。
编译器Pass性能回归分析
阶段:编译器开发与调试
解决的问题:新增Pass或修改IR生成规则后,CSE可能与后续优化产生交互效应,导致性能回归。开发者在调试时需要直观对比CSE前后的IR变化。
具体案例:编译器团队在重构布局优化Pass时发现某些模型反变慢。通过dump HLO并逐段核对CSE合并点,定位到新Pass破坏了CSE的支配关系检查,回滚后恢复正常。整个排查过程借助CSE调试工具链在2小时内完成。
Python 演示
class CSEPass: def __init__(self): self.expr_map = {} self.next_id = 0 def canonicalize(self, op, lhs, rhs): # 利用交换律标准化:a*b 与 b*a 视为同一个表达式 if op == 'mul' and lhs > rhs: lhs, rhs = rhs, lhs return (op, lhs, rhs) def run(self, instructions): rewrite = {} new_ins = [] for ins in instructions: op, lhs, rhs, dst = ins key = self.canonicalize(op, lhs, rhs) if key in self.expr_map: # 哈希命中:用已有的值编号重写此指令的目标 rewrite[dst] = self.expr_map[key] else: # 新表达式:分配值编号并保留指令 new_id = 'v' + str(self.next_id) self.next_id += 1 self.expr_map[key] = new_id new_ins.append((op, lhs, rhs, new_id)) rewrite[dst] = new_id return new_ins, rewrite def main(): prog = [ ('mul', 'a', 'b', 't1'), ('mul', 'a', 'b', 't2'), ('add', 't1', 'c', 'x'), ('add', 't2', 'd', 'y'), ] cse = CSEPass() new_prog, rewrite = cse.run(prog) for ins in new_prog: print(ins) print('rewrite:', rewrite) if __name__ == '__main__': main()
if op == 'mul' and lhs > rhs:利用交换律对乘法操作数排序,让a*b与b*a哈希到同一槽位。key in self.expr_map查询哈希表,判断当前表达式是否已出现过。rewrite[dst] = self.expr_map[key]将重复表达式的目标变量重写为首次计算的结果编号。new_ins.append((op, lhs, rhs, new_id))仅保留首次出现的表达式指令,其余重复指令被丢弃。('mul', 'a', 'b', 't1')输入程序用四元组表示:算子、左操作数、右操作数、目标变量。
常见误区
误区我以为编译器里的优化都是“免费的午餐”,CSE消除了重复计算,程序肯定会更快。所以在任何地方都希望编译器多做一些CSE。
正解CSE是一种权衡。它减少了FLOPs,但会引入中间张量,可能增加寄存器/显存压力和访存延迟。在GPU上,如果公共子表达式的计算代价很低(如一次标量加法),但合并后中间结果需要跨块保持活跃,可能反而降低性能。编译器通常用启发式规则评估“出现次数×单次代价”是否值得合并。
为何常见:初学者最容易只看到“计算量减少”这一面,忽略了现代处理器上访存开销往往比浮点运算更昂贵的事实。
误区`a*b + c` 和 `a*b + d` 里的 `a*b` 结构完全相同,编译器合并它们不会改变结果,所以浮点运算的CSE是安全的。
正解浮点乘法不满足结合律和分配律的严格等价。不同优化路径(如是否开启FMA融合、中间舍入次数不同)会导致微小数值差异。CSE只能用于语义严格等价的表达式;对于涉及 fast-math 标志或非确定性算法(如某些GPU归约操作)的算子,编译器必须保守处理,甚至禁止合并。
为何常见:习惯了数学上的整数恒等关系,容易忽略浮点运算在计算机中的舍入行为。很多人把数学等价与编程语义等价混为一谈。
误区我觉得CSE和常数折叠是一回事,都是把重复计算消掉,反正都是编译器在偷懒。
正解两者有本质区别。常数折叠(Constant Folding)是把编译期可确定的常量表达式直接算出结果,如 2*3 变成 6;CSE则是合并“运行时”重复出现的结构相同表达式,如两处 a*b 共享一次计算。CSE依赖值编号识别结构等价,常数折叠依赖常量传播确定值等价。一个面向“结构”,一个面向“常量值”。
为何常见:它们经常在同一个优化管线里相继执行,初学者看到IR输出类似就误以为是同一个Pass。
误区PyTorch的Eager模式是动态图,每次迭代都重新构建计算图,所以CSE对动态图完全无效。
正解Eager模式确实难以做跨迭代的CSE,因为图结构实时变化。但torch.compile 将模型先trace成静态图再交给Inductor优化,CSE在静态图上生效。此外,动态图内部的单个算子融合中也能做局部、浅层的冗余消除。不能说CSE与动态图无缘,只能说它需要“先把图定格”。
为何常见:人们把“动态图”与“无法优化”划等号,忽略了PyTorch等框架已经通过编译模式把动态图转化成静态图来享受CSE红利。
误区CSE只负责合并重复表达式,DCE只负责删除死代码,两者是独立的Pass,配合关系不大。
正解CSE合并后会产生大量“悬空”的旧指令——被替换的表达式虽然没人消费了,但仍残留在IR里。如果不紧跟DCE,这些死代码依然占据内存和指令cache。反过来,DCE删掉死代码后,某些表达式可能出现“假重复”,为CSE提供新的合并机会。实践中两者常被交替调度,形成迭代优化循环。
为何常见:学习时按单Pass逐个理解,缺少“管线整体效果”的视角,忽略了优化Pass之间的耦合放大效应。
面试题
Q1. CSE在编译器中的全称是什么?
- A.Common Subexpression Elimination← 正确答案
- B.Constant Substitution Encoding
- C.Control Structure Excision
- D.Cache State Evaluation
答案:A。CSE即Common Subexpression Elimination(公共子表达式消除)。它专门消除计算图中结构相同、输入相同的重复子表达式。其余选项均为干扰项,分别对应常量替换、控制结构剔除、缓存状态评估等臆造的概念。
Q2. CSE在AI编译器中的主要优化目标是什么?
- A.减少冗余的重复计算← 正确答案
- B.增加模型参数量
- C.降低学习率敏感性
- D.扩展网络层数
答案:A。CSE的核心目标是把计算图中重复出现的表达式合并为一次计算,从而减少冗余算子执行。它不改变模型精度或参数量,也不影响学习率等训练超参数,所以B、C、D都与CSE无关。
Q3. CSE识别公共子表达式最常用的数据结构是什么?
- A.栈
- B.哈希表← 正确答案
- C.红黑树
- D.链表
答案:B。CSE通过哈希表做值编号,用表达式“算子+操作数编号”的哈希值快速查找重复项。哈希表平均O(1)的查找性能非常适合编译Pass的高频查询场景,而栈、链表、红黑树在“按键查找”上效率低或实现复杂。
Q4. 关于浮点数的CSE,以下哪种说法正确?
- A.浮点表达式永远可以安全合并
- B.浮点合并可能产生舍入误差← 正确答案
- C.CSE只对整型表达式生效
- D.浮点运算无需舍入控制
答案:B。浮点运算的舍入行为使“结构相同”的表达式在不同路径下可能产生微小差异。编译器在未启用fast-math等宽松语义时,不能无条件合并浮点表达式。CSE对整型和浮点型都适用,但浮点需要额外的安全性验证。
Q5. 全局CSE要求第一个表达式节点必须满足什么条件?
- A.与第二个节点相邻
- B.支配第二个表达式节点← 正确答案
- C.拥有更大的哈希值
- D.位于循环体外部
答案:B。全局CSE的关键是安全性:使用点必须保证第一个计算点的结果可用。支配关系保证了从入口到使用点的所有路径都经过计算点。若不相邻或哈希更大都不能保证安全,位于循环外也无法覆盖所有共享场景。
Q6. XLA编译器在哪个IR级别执行CSE优化?
- A.Python源码级别
- B.HLO中间表示级别← 正确答案
- C.TorchScript字节码级别
- D.设备驱动级别
答案:B。XLA将模型编译成HLO(High Level Optimizer)中间表示,并在HLO上执行CSE、算子融合等核心Pass。Python源码和字节码级别由前端处理,设备驱动层面不涉及编译优化逻辑。
Q7. CSE与常数折叠的本质区别是什么?
- A.CSE处理结构等价,常数折叠处理常量值← 正确答案
- B.CSE永远比常数折叠更高效
- C.常数折叠只用于训练阶段
- D.CSE无法识别常量表达式
答案:A。CSE通过结构哈希识别重复子表达式,与操作数是否为常量无关;常数折叠则依赖常量传播,把可静态求值的表达式直接算成常数。两者关注点不同,常常在管线中先后配合执行。
Q8. CSE在哪些情况下可能带来负面影响?
- A.中间结果导致寄存器压力增大← 正确答案
- B.访存开销超过重复计算开销← 正确答案
- C.合并后代码可读性下降
- D.引入非确定性计算错误
答案:AB。CSE提取中间张量会占用寄存器/显存,若访存代价高于重新计算则得不偿失。虽然CSE也可能间接导致D这类精度问题,但作为“负面效果”的核心是A和B。C选项代码可读性不属于运行时性能影响。
Q9. 下列哪些策略能提升CSE的识别成功率?
- A.代数恒等式标准化← 正确答案
- B.交换律对操作数排序← 正确答案
- C.放宽支配性安全检查
- D.与DCE配合迭代执行← 正确答案
答案:ABD。代数恒等式标准化和交换律排序让结构不同但语义相同的表达式哈希到同一槽位;与DCE配合能清除死代码后暴露新的合并机会。而放宽安全支配检查是危险的,可能把不可复用的表达式错误合并,导致程序错误。
Q10. 在PyTorch中启用CSE相关的优化通常需要什么手段?
- A.使用torch.compile编译模型← 正确答案
- B.切换到Eager模式
- C.借助TorchInductor后端优化← 正确答案
- D.关闭梯度计算上下文
答案:AC。torch.compile 会把模型trace为静态图并交给TorchInductor执行CSE等优化。Eager模式是动态执行,难以做全局CSE;关闭梯度只是减少反向传播开销,不直接触发CSE。因此A和C是正确手段。