在一家连锁餐厅的后厨里,两位厨师各做一道菜:一个做鱼香肉丝,一个做宫保鸡丁。他们都先切葱花、切蒜末、再调一份糖醋汁。切菜声此起彼伏,调料台上摆了两份一模一样的备菜。路过的主管摇了摇头:同样的备菜,为什么要做两次?于是他在后厨中央设了一个“备菜中心”,切一次、调一次,谁需要谁来取。 AI 模型的运行流程有时也像这座后厨:两个分支明明依赖同一个中间结果,却各自重新算了一遍。公共子表达式消除(CSE)就是那位聪明的主管,它识别流程里重复出现的子表达式,把多次计算合并成一次,再让所有使用方共享。这篇内容会用“中央厨房备菜”这条主线,带你看懂 CSE 怎么工作、为什么不是万能药,以及它在 PyTorch 2.0 和 XLA 编译栈里如何落地。

岗位关联

掌握图优化思想,是 AI 编译器工程师看懂 torch.compile 与 XLA 内部机制的第一块敲门砖,也能帮助部署工程师快速定位推理性能瓶颈。

目录
  1. 可视化演示
  2. 1. 问题与动机
  3. 2. 计算图视角
  4. 3. 核心识别思想
  5. 4. 哈希值编号
  6. 5. 收益与代价
  7. 6. 编译栈落地
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示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 同样不改算子的数学含义,它只是让相同的中间结果不再被重复制造。理解了这一点,后面的图视角和算法细节就都好懂了。

Costbefore=k×C(x),Costafter=C(x)\displaystyle \mathrm{Cost}_{\text{before}} = k \times C(x),\qquad \mathrm{Cost}_{\text{after}} = C(x)
\(k\) 是重复使用次数,\(C(x)\) 是计算一次子表达式的成本。优化后只需要一次计算,省下了 \((k-1)\) 次重复开销。
  1. 符号含义这里的 \(x\) 代表一个公共子表达式,比如两个分支都会用到的 \(a+b\)。
    x=a+bx = a + b
  2. 优化前成本如果这个子表达式被 \(k\) 个消费者分别计算,总成本就是使用次数乘以单次成本。
    Costbefore=k×C(x)\mathrm{Cost}_{\text{before}} = k \times C(x)
  3. 优化后成本CSE 让所有消费者共享第一次计算得到的中间结果,因此总成本只剩一次。
    Costafter=C(x)\mathrm{Cost}_{\text{after}} = C(x)

2. 计算图视角

后厨主管为了看清楚整个流程,把每道菜的做法画成了一张流程图:一个方框代表一个动作,一条箭头代表半成品从一个动作流向另一个动作。鱼香肉丝和宫保鸡丁的流程图虽然终点不同,但前面一段“爆香葱蒜、调糖醋汁”被画了两遍。他一眼就看出,这是同一张子图。

AI 编译器也用类似的方式理解模型。这种图叫计算图:节点是算子,比如加法、卷积、矩阵乘法;边是张量,也就是算子的输入和输出。模型前向时,数据沿着边流动,每经过一个节点就完成一次计算。如果两张流程图的局部出现完全相同的节点组合,编译器就会看到一个“重复的子图”。

识别重复子图,本质上是在比较结构是否等价。对任意一个节点 \(n\),它的子结构可以递归地写成:

用流程图来思考,CSE 的“公共”二字就很清楚了:它不只是两个节点长得像,而是从输入一路到输出,整棵子树的结构和算子类型都一致。只有这样的两份备菜,才真正可以放心共用。

Sub(n)=(op(n), Sub(in1(n)), Sub(in2(n)))\displaystyle \mathrm{Sub}(n)=\bigl(\mathrm{op}(n),\ \mathrm{Sub}(\mathrm{in}_1(n)),\ \mathrm{Sub}(\mathrm{in}_2(n))\bigr)
这个公式把“节点是什么算子”和“它的输入子结构长什么样”组合成一个整体。只要两个节点的这个组合相同,它们就是同一个公共子表达式。
  1. 节点 \(n\)图中的每一个计算节点,对应后厨流程图里的一个动作。
    nn
  2. 算子类型 \(\mathrm{op}(n)\)表示这个节点执行的操作,比如加法、乘法,相当于“切菜”还是“炒菜”。
    op(n)\mathrm{op}(n)
  3. 输入子结构\(\mathrm{in}_1(n)\) 和 \(\mathrm{in}_2(n)\) 是节点的输入,它们的子结构也要加入比较,保证整个子树完全一致。
    Sub(in1(n)), Sub(in2(n))\mathrm{Sub}(\mathrm{in}_1(n)),\ \mathrm{Sub}(\mathrm{in}_2(n))

3. 核心识别思想

主管要给备菜中心定规则:怎样判断两份备菜是否相同?他每道工序都写成一个签名,比如“3 毫米葱花 + 5 克蒜末 + 200 毫升高汤”。只要签名一样,就认为这道备菜可以共享。这个签名并不关心是谁切的、用什么刀切的,只关心工序本身。

编译器里的 CSE 同样会为每个节点生成签名。它的做法是从计算图底部向上遍历:先处理输入节点,再处理它们的父算子。这样当某个算子需要比较时,它的输入签名已经准备好了。签名由算子类型和输入签名共同组成:

其实这就像后厨给“糖醋汁”定了一个标准编码:以后不需要每次现场拿鼻子闻、拿舌头尝,只要看到相同编码,就知道它是同一份备菜。CSE 的核心识别思想,就是给计算图中的每个子表达式生成这样的标准编码。

sig(v)=hash(op(v), sig(u1), sig(u2))\displaystyle \mathrm{sig}(v)=\mathrm{hash}\bigl(\mathrm{op}(v),\ \mathrm{sig}(u_1),\ \mathrm{sig}(u_2)\bigr)
其中 \(u_1,u_2\) 是算子 \(v\) 的两个输入节点。用哈希把整棵子树压成一个签名,编译器就能快速判断两个节点是否等价。
  1. 当前算子节点 \(v\)正在被检查的节点,可能是加法、乘法或其他任意算子。
    vv
  2. 操作数节点 \(u_1,u_2\)它是 \(v\) 的输入,相当于备菜流程中的上一道工序。
    u1, u2u_1,\ u_2
  3. 递归生成签名签名由算子类型和输入签名共同哈希得到,只要输入结构不同,最终签名就不同。
    sig(v)=hash(op(v), sig(u1), sig(u2))\mathrm{sig}(v)=\mathrm{hash}\bigl(\mathrm{op}(v),\ \mathrm{sig}(u_1),\ \mathrm{sig}(u_2)\bigr)

4. 哈希值编号

后厨的备菜越来越多,主管不能每次都把两份备菜的原料摊开对比。他开始给每类备菜分配编号:糖醋汁是 7 号,葱蒜末是 3 号。后厨墙上挂着一张表,从签名查到编号,再顺着编号找到备菜本身。这样一来,新来的备菜只需看一眼签名,就知道是不是重复的。

编译器里的 CSE 也维护了一张类似的大表,叫“全局值编号表”。它把哈希签名映射到一个唯一编号,再用编号代表对应的中间值。查表过程可以写成:

哈希的好处是快,但哈希也有代价:两个不同表达式可能撞出同一个签名。所以真正严谨的编译器在哈希命中后,还会做一次结构化等价确认,就像两个备菜编码相同时,再抽查一下原料有没有细微差别。哈希值编号让 CSE 在海量节点中依然能保持高效。

lookup(s)={v,s∈tablenew(s),s∉table\displaystyle \mathrm{lookup}(s)=\begin{cases}v, & s \in \mathrm{table}\\\mathrm{new}(s), & s \notin \mathrm{table}\end{cases}
\(s\) 是当前子表达式的哈希签名。如果签名已经在表里,就返回原来的编号 \(v\);如果不在,就为它分配一个新的编号。
  1. 签名 \(s\)由算子类型和输入签名哈希得到,是子表达式的“身份证号”。
    ss
  2. 值编号表 \(\mathrm{table}\)后厨墙上的那张备菜对照表,编译器用它记录“签名 → 编号”的映射。
    table\mathrm{table}
  3. 命中情况如果签名已经存在,说明表达式已经算过,直接复用旧编号即可。
    s∈table⇒vs \in \mathrm{table} \Rightarrow v
  4. 未命中情况如果签名不存在,说明这是新备菜,需要分配新编号并登记。
    s∉table⇒new(s)s \notin \mathrm{table} \Rightarrow \mathrm{new}(s)

5. 收益与代价

中央厨房并不是免费的。主管要维护备菜表、设置备菜中心、安排专人登记编号,这些都要成本。更重要的是,有些备菜做出来之后如果没人立刻取走,就要放在冰箱里占地方。共享一个中间结果,有时会让这个结果存活更长时间,反而增加内存压力。

CPU 和 GPU 上也有类似现象。CSE 减少了重复计算,但被共享的中间值可能被多个下游消费者引用,生命周期变长,寄存器或缓存压力随之上升。编译器必须估算收益,不能无脑合并。假设单次子表达式成本是 \(C_{\mathrm{sub}}\),哈希维护成本是 \(O_{\mathrm{hash}}\),那么优化净收益大约是:

所以 CSE 不是“越合并越赚”。当重复次数很多、单次计算又昂贵时,它是大赢家;当重复次数只有一次、哈希开销却很大时,主管就该按兵不动。真正优秀的编译器不是机械地套公式,而是像经验丰富的后厨主管一样,先估算再动手。

ΔCost=(k−1)×Csub−Ohash\displaystyle \Delta \mathrm{Cost}=(k-1)\times C_{\mathrm{sub}}-O_{\mathrm{hash}}
\((k-1)\) 是减少的重复次数,乘上单次成本就是省下的计算量,再减去哈希表的维护开销。只有 \(\Delta \mathrm{Cost} > 0\) 时,CSE 才值得做。
  1. 减少的重复次数原本要算 \(k\) 次,现在只算 1 次,减少 \(k-1\) 次。
    k−1k - 1
  2. 单次子表达式成本每次计算需要消耗的算力和访存资源。
    CsubC_{\mathrm{sub}}
  3. 哈希维护开销维护值编号表、进行哈希比较和碰撞确认都不是免费的。
    OhashO_{\mathrm{hash}}

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 就像后厨系统里的自动备菜模块:它不会改变菜的味道,但会让整个厨房少一些没必要的烟火气。

Pipeline:Graph→CSEOptimized→DCECompact\displaystyle \mathrm{Pipeline}:\quad \mathrm{Graph} \xrightarrow{\mathrm{CSE}} \mathrm{Optimized} \xrightarrow{\mathrm{DCE}} \mathrm{Compact}
  1. 原始计算图编译器刚刚拿到模型时,图里可能有很多重复子表达式。
    Graph\mathrm{Graph}
  2. CSE 优化先做公共子表达式消除,让所有重复计算共享同一份中间结果。
    Graph→CSEOptimized\mathrm{Graph} \xrightarrow{\mathrm{CSE}} \mathrm{Optimized}
  3. DCE 清理再删除已经没有消费者可达的死节点,得到更紧凑的最终图。
    Optimized→DCECompact\mathrm{Optimized} \xrightarrow{\mathrm{DCE}} \mathrm{Compact}

AI 应用场景

推理部署:同一份中间结果不再算两遍

阶段:模型编译与推理部署

解决的问题:手写优化时,工程师很难在大型模型里逐处发现重复子图;编译器用 CSE 在编译阶段自动共享,减少延迟和显存占用。

具体案例:在 torch.compile 编译视觉模型时,多个分支可能对同一个张量做相同的 reshape + transpose。CSE 会让这些分支指向同一个结果,避免重复申请内存、重复启动 kernel。

训练图编译:多任务计算的公共底座

阶段:训练计算图编译

解决的问题:多任务训练中多个损失函数共用同一个特征提取器,如果图展开后重复计算,会让显存和算力白白浪费;CSE 保证公共骨干只算一次。

具体案例:一个多任务模型同时做分类和检测,两个 head 都对同一份特征做 L2 归一化。CSE 把归一化算子合并成一个公共节点,训练 step 的峰值显存明显下降。

Python 演示

python42 行
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)
目的:在一段简单指令序列上演示局部值编号 CSE,把重复的 a+b 合并成一次计算,并输出优化后的指令列表。
  1. def resolve(src):把历史赋值替换成最初的变量名,保证比较时用的是规范名字。
  2. signature = (op, lhs, rhs)用算子和两个操作数组成“备菜签名”。
  3. if signature in value_table:如果签名已经见过,说明这是重复的子表达式。
  4. result.append(("copy", dst, old_name))新变量只需复制之前的结果,不必重新计算。
  5. num_to_name[new_num] = dst给新表达式分配一个编号,并记住编号对应的变量名。
  6. 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. 公共子表达式消除的核心目标是?

单选题 · easy

  • A.把常量直接替换成结果
  • B.合并重复的子表达式计算← 正确答案
  • C.删除没有任何使用的算子
  • D.将一个大图切成若干子图

答案:B。CSE 像中央厨房把同一份备菜合并一次,目标是共享重复的中间计算。A 是常量折叠,C 是 DCE,D 是图切分。

Q2. 在中央厨房比喻里,CSE 最接近的动作是?

单选题 · easy

  • A.换一口更大的炒锅
  • B.招募更多备菜员工
  • C.把重复备菜合并成一次← 正确答案
  • D.把菜品全部炖得更烂

答案:C。CSE 不是换工具、加人手,而是发现两份一模一样的备菜后只做一次,让两个灶台共享同一份半成品。

Q3. 下面哪种情况最适合做 CSE?

单选题 · mid

  • A.每个子表达式只执行一次
  • B.两个算子输入相同且可共享← 正确答案
  • C.所有算子都带有副作用
  • D.计算图只有少量几个节点

答案:B。B 正是冗余出现的条件。A 没有重复,C 有副作用不能随便共享,D 节点少也不代表没有冗余。

Q4. 值编号表里通常需要记录哪些内容?

多选题 · mid

  • A.算子的哈希签名← 正确答案
  • B.签名对应的编号← 正确答案
  • C.损失函数的梯度
  • D.图的调度顺序

答案:AB。值编号表类似备菜编码本,存储签名与编号的映射。C 是训练相关概念,D 是执行期调度信息,都不是值编号表的职责。

Q5. 哈希签名发生碰撞时,正确的做法是?

单选题 · hard

  • A.直接认为两个表达式等价
  • B.放弃所有哈希优化手段
  • C.用结构等价做二次确认← 正确答案
  • D.随机更换一个哈希函数

答案:C。哈希是快速索引,碰撞时仍需回到原始结构比较。就像两个备菜编码一样时还得再尝一尝,不能直接当作同一道菜。

Q6. CSE 合并重复中间结果后,可能带来哪些收益?

多选题 · mid

  • A.减少浮点运算次数← 正确答案
  • B.减少重复内存访问← 正确答案
  • C.增大模型参数总量
  • D.让结果精度必然更高

答案:AB。共享一次计算可以同时少算、少访存。C 与参数量无关,D 数值结果通常不变,谈不上必然更高。

Q7. 哪些情况可能让 CSE 收益变小甚至变负?

多选题 · hard

  • A.共享结果被长期占用← 正确答案
  • B.哈希表维护成本过高← 正确答案
  • C.表达式完全无副作用
  • D.重复计算本身代价极低← 正确答案

答案:ABD。共享会让结果存活更久,哈希表有开销,重复计算代价极低时优化意义也有限。C 是适合 CSE 的条件,不是收益变负的原因。

Q8. 在 PyTorch 2.0 或 XLA 编译栈中,CSE 通常出现在哪个环节?

单选题 · easy

  • A.数据加载与增强
  • B.计算图编译与优化← 正确答案
  • C.模型权重初始化
  • D.损失函数反向传播

答案:B。CSE 是编译器后端的图优化 pass,在计算图被翻译成执行计划时运行,类似正式开火前统一备菜,而不是开火后的动作。

Q9. 为什么 CSE 经常要和 DCE 配套使用?

单选题 · mid

  • A.CSE 会删除所有未使用节点
  • B.CSE 合并后可能留下无用节点← 正确答案
  • C.DCE 会关闭整个编译器
  • D.两者是同一个优化 pass

答案:B。CSE 把重复计算指向共享结果后,原先的重复节点可能不再被引用,DCE 负责清理这些“没人来取”的备菜。A 夸大了 CSE,C 和 D 说法错误。

Q10. 对于两个结构相同但带有随机状态算子的子图,CSE 应如何处理?

单选题 · extreme

  • A.照常合并以保证执行速度
  • B.标记为不可公共消除← 正确答案
  • C.合并后让两边结果相同
  • D.随机挑选其中一个合并

答案:B。随机算子每次执行的语义不同,合并会破坏随机性,编译器必须保守处理并标记为不可消除。就像两个独立抽奖箱不能共用一个,即使外观完全一样。