在一家连锁餐厅的后厨里,两位厨师各做一道菜:一个做鱼香肉丝,一个做宫保鸡丁。他们都先切葱花、切蒜末、再调一份糖醋汁。切菜声此起彼伏,调料台上摆了两份一模一样的备菜。路过的主管摇了摇头:同样的备菜,为什么要做两次?于是他在后厨中央设了一个“备菜中心”,切一次、调一次,谁需要谁来取。 AI 模型的运行流程有时也像这座后厨:两个分支明明依赖同一个中间结果,却各自重新算了一遍。公共子表达式消除(CSE)就是那位聪明的主管,它识别流程里重复出现的子表达式,把多次计算合并成一次,再让所有使用方共享。这篇内容会用“中央厨房备菜”这条主线,带你看懂 CSE 怎么工作、为什么不是万能药,以及它在 PyTorch 2.0 和 XLA 编译栈里如何落地。
掌握图优化思想,是 AI 编译器工程师看懂 torch.compile 与 XLA 内部机制的第一块敲门砖,也能帮助部署工程师快速定位推理性能瓶颈。
可视化演示
展示CSE合并计算图中重复子表达式的前后对比、值编号等价检测机制、局部CSE与全局CSE(GVN)的作用范围差异,交互切换核心环节。
本节配有交互动效(进入视口后自动加载,约 3KB)。
CSE合并重复子表达式 优化前计算图中A*B出现3次,编译器通过签名检测识别等价子表达式 优化后合并为单次计算共享输出,算子数3→2,减少33%冗余计算 sig(node) = hash(op_type, sig(operands)) CSE是图级优化的基础手段
1. 问题与动机
后厨主管看了一会儿就发现了问题:两位厨师都在切葱花、切蒜末、调糖醋汁,动作完全一样,但谁也没想到向对方要一份。鱼香肉丝和宫保鸡丁口味不同,可它们共享的备菜步骤是相同的。与其各做各的,不如让备菜中心统一完成一次,再分给两个灶台。这个直觉,正是公共子表达式消除的出发点。
到了 AI 模型的世界,类似场景每天都在发生。一个模型的前向流程里,可能有多个分支都需要计算某个中间张量,比如 a + b。如果编译器没有注意到这两个 a + b 其实是同一个计算,它就会在 GPU 上老老实实执行两次。浪费的不只是算力,还有中间结果的存储开销和访存带宽。用后厨的话说,这就是同一份糖醋汁被熬了两遍。
CSE 的做法很朴素:一旦发现某个子表达式已经计算过,并且后面的计算需要的输入来源完全相同,就不再重复计算,而是直接复用上一次的结果。假设这个子表达式被使用 \(k\) 次,消除前后的总成本可以写成:
回到后厨,主管并没有改变菜谱,只是把“切葱、切蒜、调汁”这份备菜从重复劳动变成了公共资源。CSE 同样不改算子的数学含义,它只是让相同的中间结果不再被重复制造。理解了这一点,后面的图视角和算法细节就都好懂了。
- 符号含义这里的 \(x\) 代表一个公共子表达式,比如两个分支都会用到的 \(a+b\)。
- 优化前成本如果这个子表达式被 \(k\) 个消费者分别计算,总成本就是使用次数乘以单次成本。
- 优化后成本CSE 让所有消费者共享第一次计算得到的中间结果,因此总成本只剩一次。
2. 计算图视角
后厨主管为了看清楚整个流程,把每道菜的做法画成了一张流程图:一个方框代表一个动作,一条箭头代表半成品从一个动作流向另一个动作。鱼香肉丝和宫保鸡丁的流程图虽然终点不同,但前面一段“爆香葱蒜、调糖醋汁”被画了两遍。他一眼就看出,这是同一张子图。
AI 编译器也用类似的方式理解模型。这种图叫计算图:节点是算子,比如加法、卷积、矩阵乘法;边是张量,也就是算子的输入和输出。模型前向时,数据沿着边流动,每经过一个节点就完成一次计算。如果两张流程图的局部出现完全相同的节点组合,编译器就会看到一个“重复的子图”。
识别重复子图,本质上是在比较结构是否等价。对任意一个节点 \(n\),它的子结构可以递归地写成:
用流程图来思考,CSE 的“公共”二字就很清楚了:它不只是两个节点长得像,而是从输入一路到输出,整棵子树的结构和算子类型都一致。只有这样的两份备菜,才真正可以放心共用。
- 节点 \(n\)图中的每一个计算节点,对应后厨流程图里的一个动作。
- 算子类型 \(\mathrm{op}(n)\)表示这个节点执行的操作,比如加法、乘法,相当于“切菜”还是“炒菜”。
- 输入子结构\(\mathrm{in}_1(n)\) 和 \(\mathrm{in}_2(n)\) 是节点的输入,它们的子结构也要加入比较,保证整个子树完全一致。
3. 核心识别思想
主管要给备菜中心定规则:怎样判断两份备菜是否相同?他每道工序都写成一个签名,比如“3 毫米葱花 + 5 克蒜末 + 200 毫升高汤”。只要签名一样,就认为这道备菜可以共享。这个签名并不关心是谁切的、用什么刀切的,只关心工序本身。
编译器里的 CSE 同样会为每个节点生成签名。它的做法是从计算图底部向上遍历:先处理输入节点,再处理它们的父算子。这样当某个算子需要比较时,它的输入签名已经准备好了。签名由算子类型和输入签名共同组成:
其实这就像后厨给“糖醋汁”定了一个标准编码:以后不需要每次现场拿鼻子闻、拿舌头尝,只要看到相同编码,就知道它是同一份备菜。CSE 的核心识别思想,就是给计算图中的每个子表达式生成这样的标准编码。
- 当前算子节点 \(v\)正在被检查的节点,可能是加法、乘法或其他任意算子。
- 操作数节点 \(u_1,u_2\)它是 \(v\) 的输入,相当于备菜流程中的上一道工序。
- 递归生成签名签名由算子类型和输入签名共同哈希得到,只要输入结构不同,最终签名就不同。
4. 哈希值编号
后厨的备菜越来越多,主管不能每次都把两份备菜的原料摊开对比。他开始给每类备菜分配编号:糖醋汁是 7 号,葱蒜末是 3 号。后厨墙上挂着一张表,从签名查到编号,再顺着编号找到备菜本身。这样一来,新来的备菜只需看一眼签名,就知道是不是重复的。
编译器里的 CSE 也维护了一张类似的大表,叫“全局值编号表”。它把哈希签名映射到一个唯一编号,再用编号代表对应的中间值。查表过程可以写成:
哈希的好处是快,但哈希也有代价:两个不同表达式可能撞出同一个签名。所以真正严谨的编译器在哈希命中后,还会做一次结构化等价确认,就像两个备菜编码相同时,再抽查一下原料有没有细微差别。哈希值编号让 CSE 在海量节点中依然能保持高效。
- 签名 \(s\)由算子类型和输入签名哈希得到,是子表达式的“身份证号”。
- 值编号表 \(\mathrm{table}\)后厨墙上的那张备菜对照表,编译器用它记录“签名 → 编号”的映射。
- 命中情况如果签名已经存在,说明表达式已经算过,直接复用旧编号即可。
- 未命中情况如果签名不存在,说明这是新备菜,需要分配新编号并登记。
5. 收益与代价
中央厨房并不是免费的。主管要维护备菜表、设置备菜中心、安排专人登记编号,这些都要成本。更重要的是,有些备菜做出来之后如果没人立刻取走,就要放在冰箱里占地方。共享一个中间结果,有时会让这个结果存活更长时间,反而增加内存压力。
CPU 和 GPU 上也有类似现象。CSE 减少了重复计算,但被共享的中间值可能被多个下游消费者引用,生命周期变长,寄存器或缓存压力随之上升。编译器必须估算收益,不能无脑合并。假设单次子表达式成本是 \(C_{\mathrm{sub}}\),哈希维护成本是 \(O_{\mathrm{hash}}\),那么优化净收益大约是:
所以 CSE 不是“越合并越赚”。当重复次数很多、单次计算又昂贵时,它是大赢家;当重复次数只有一次、哈希开销却很大时,主管就该按兵不动。真正优秀的编译器不是机械地套公式,而是像经验丰富的后厨主管一样,先估算再动手。
- 减少的重复次数原本要算 \(k\) 次,现在只算 1 次,减少 \(k-1\) 次。
- 单次子表达式成本每次计算需要消耗的算力和访存资源。
- 哈希维护开销维护值编号表、进行哈希比较和碰撞确认都不是免费的。
6. 编译栈落地
连锁餐厅规模扩大后,主管把“识别重复备菜”的规则写成了软件,装进每家分店的厨房系统里。AI 世界也一样,像 XLA 和 PyTorch 2.0 的编译栈,已经把 CSE 做成了标准优化流程中的一个固定环节。模型在前端描述出来后,编译器会把它翻译成中间计算图,再经过一系列优化 pass,CSE 就是其中一员。
在 XLA 中,CSE 经常作用于 HLO(High Level Optimizer)层的计算图;在 PyTorch 2.0 的 torch.compile 路径里,类似思想也出现在图优化和代码生成阶段。它常常和 DCE(死代码消除)配合使用:CSE 负责把重复计算合并成一次,DCE 负责清理合并后已经没人使用的旧节点。整个流程可以写成:
对做部署的工程师来说,看到推理时 GPU 利用率不高、显存占用偏高,除了看算子实现,也要怀疑是不是图里存在重复计算。CSE 就像后厨系统里的自动备菜模块:它不会改变菜的味道,但会让整个厨房少一些没必要的烟火气。
- 原始计算图编译器刚刚拿到模型时,图里可能有很多重复子表达式。
- CSE 优化先做公共子表达式消除,让所有重复计算共享同一份中间结果。
- DCE 清理再删除已经没有消费者可达的死节点,得到更紧凑的最终图。
AI 应用场景
推理部署:同一份中间结果不再算两遍
阶段:模型编译与推理部署
解决的问题:手写优化时,工程师很难在大型模型里逐处发现重复子图;编译器用 CSE 在编译阶段自动共享,减少延迟和显存占用。
具体案例:在 torch.compile 编译视觉模型时,多个分支可能对同一个张量做相同的 reshape + transpose。CSE 会让这些分支指向同一个结果,避免重复申请内存、重复启动 kernel。
训练图编译:多任务计算的公共底座
阶段:训练计算图编译
解决的问题:多任务训练中多个损失函数共用同一个特征提取器,如果图展开后重复计算,会让显存和算力白白浪费;CSE 保证公共骨干只算一次。
具体案例:一个多任务模型同时做分类和检测,两个 head 都对同一份特征做 L2 归一化。CSE 把归一化算子合并成一个公共节点,训练 step 的峰值显存明显下降。
Python 演示
def cse(program): value_table = {} var_to_num = {} num_to_name = {} result = [] def resolve(src): if src in var_to_num: return num_to_name[var_to_num[src]] return src for op, dst, src1, src2 in program: lhs = resolve(src1) rhs = resolve(src2) signature = (op, lhs, rhs) if signature in value_table: old_num = value_table[signature] old_name = num_to_name[old_num] result.append(("copy", dst, old_name)) new_num = old_num else: new_num = len(num_to_name) value_table[signature] = new_num num_to_name[new_num] = dst result.append((op, dst, src1, src2)) var_to_num[dst] = new_num return result if __name__ == "__main__": program = [ ("add", "t0", "a", "b"), ("mul", "t1", "t0", "c"), ("add", "t2", "a", "b"), ("mul", "t3", "t2", "d"), ] optimized = cse(program) for ins in optimized: print(ins)
a+b 合并成一次计算,并输出优化后的指令列表。def resolve(src):把历史赋值替换成最初的变量名,保证比较时用的是规范名字。signature = (op, lhs, rhs)用算子和两个操作数组成“备菜签名”。if signature in value_table:如果签名已经见过,说明这是重复的子表达式。result.append(("copy", dst, old_name))新变量只需复制之前的结果,不必重新计算。num_to_name[new_num] = dst给新表达式分配一个编号,并记住编号对应的变量名。var_to_num[dst] = new_num记录当前变量的编号,供后续指令继续追踪。
常见误区
误区很多初学者以为只有 `1+2` 这样的常量才能被合并,变量表达式不能用 CSE。
正解CSE 关心的是“结构相同、输入来源相同”的运行时子表达式。比如某个张量 x 和 y 计算 x+y 出现在两处,编译时就能合并,不需要值在编译期确定。中央厨房合并的是“同样菜谱”,而不是“同样的具体食材”。
为何常见:常量折叠也发生在编译期,容易和 CSE 混在一起,人们因此把编译优化和“静态常量”绑定。
误区不少同学认为减少计算就一定变快,所以 CSE 对任何模型都有正收益。
正解如果被共享的中间结果生命周期变长,寄存器或缓存压力可能上升,尤其在 GPU 上可能影响并行度。CSE 是“用共享换重复计算”,是否划算还要看哈希表开销和访存成本。后厨如果只做一道菜,额外建一套备菜系统反而浪费。
为何常见:直觉“少做事一定更快”忽略了现代硬件中数据搬移和并行度的重要性。
误区只要代码文本相同,比如两个 `x * x`,编译器都应该合并成一个。
正解有副作用的算子不能随便合并,尤其是随机数、状态更新、自定义可变算子。结构相同不等于语义相同,两个独立抽奖箱不能共用一个,即使外观完全一样。编译器必须保证语义一致。
为何常见:只看到语法树相同,忽略了算子背后可能存在的状态和随机性。
误区图优化里所有“消除”都是一回事,CSE 和 DCE 可以混着叫。
正解CSE 合并重复计算并共享结果,DCE 删除没有任何消费者可达的节点。CSE 把重复计算指向共享结果后,原来的重复节点可能没人引用,此时需要 DCE 清理。两者是前后配合的两道工序。
为何常见:二者名字里都有“消除”两个字,初学者容易把它们当成同一个优化 pass。
面试题
Q1. 公共子表达式消除的核心目标是?
- A.把常量直接替换成结果
- B.合并重复的子表达式计算← 正确答案
- C.删除没有任何使用的算子
- D.将一个大图切成若干子图
答案:B。CSE 像中央厨房把同一份备菜合并一次,目标是共享重复的中间计算。A 是常量折叠,C 是 DCE,D 是图切分。
Q2. 在中央厨房比喻里,CSE 最接近的动作是?
- A.换一口更大的炒锅
- B.招募更多备菜员工
- C.把重复备菜合并成一次← 正确答案
- D.把菜品全部炖得更烂
答案:C。CSE 不是换工具、加人手,而是发现两份一模一样的备菜后只做一次,让两个灶台共享同一份半成品。
Q3. 下面哪种情况最适合做 CSE?
- A.每个子表达式只执行一次
- B.两个算子输入相同且可共享← 正确答案
- C.所有算子都带有副作用
- D.计算图只有少量几个节点
答案:B。B 正是冗余出现的条件。A 没有重复,C 有副作用不能随便共享,D 节点少也不代表没有冗余。
Q4. 值编号表里通常需要记录哪些内容?
- A.算子的哈希签名← 正确答案
- B.签名对应的编号← 正确答案
- C.损失函数的梯度
- D.图的调度顺序
答案:AB。值编号表类似备菜编码本,存储签名与编号的映射。C 是训练相关概念,D 是执行期调度信息,都不是值编号表的职责。
Q5. 哈希签名发生碰撞时,正确的做法是?
- A.直接认为两个表达式等价
- B.放弃所有哈希优化手段
- C.用结构等价做二次确认← 正确答案
- D.随机更换一个哈希函数
答案:C。哈希是快速索引,碰撞时仍需回到原始结构比较。就像两个备菜编码一样时还得再尝一尝,不能直接当作同一道菜。
Q6. CSE 合并重复中间结果后,可能带来哪些收益?
- A.减少浮点运算次数← 正确答案
- B.减少重复内存访问← 正确答案
- C.增大模型参数总量
- D.让结果精度必然更高
答案:AB。共享一次计算可以同时少算、少访存。C 与参数量无关,D 数值结果通常不变,谈不上必然更高。
Q7. 哪些情况可能让 CSE 收益变小甚至变负?
- A.共享结果被长期占用← 正确答案
- B.哈希表维护成本过高← 正确答案
- C.表达式完全无副作用
- D.重复计算本身代价极低← 正确答案
答案:ABD。共享会让结果存活更久,哈希表有开销,重复计算代价极低时优化意义也有限。C 是适合 CSE 的条件,不是收益变负的原因。
Q8. 在 PyTorch 2.0 或 XLA 编译栈中,CSE 通常出现在哪个环节?
- A.数据加载与增强
- B.计算图编译与优化← 正确答案
- C.模型权重初始化
- D.损失函数反向传播
答案:B。CSE 是编译器后端的图优化 pass,在计算图被翻译成执行计划时运行,类似正式开火前统一备菜,而不是开火后的动作。
Q9. 为什么 CSE 经常要和 DCE 配套使用?
- A.CSE 会删除所有未使用节点
- B.CSE 合并后可能留下无用节点← 正确答案
- C.DCE 会关闭整个编译器
- D.两者是同一个优化 pass
答案:B。CSE 把重复计算指向共享结果后,原先的重复节点可能不再被引用,DCE 负责清理这些“没人来取”的备菜。A 夸大了 CSE,C 和 D 说法错误。
Q10. 对于两个结构相同但带有随机状态算子的子图,CSE 应如何处理?
- A.照常合并以保证执行速度
- B.标记为不可公共消除← 正确答案
- C.合并后让两边结果相同
- D.随机挑选其中一个合并
答案:B。随机算子每次执行的语义不同,合并会破坏随机性,编译器必须保守处理并标记为不可消除。就像两个独立抽奖箱不能共用一个,即使外观完全一样。