想象一下,你是一家快餐厅的主厨,新来的学徒抄了一份菜谱:先加一勺盐,尝一口太咸,又加半碗水;把西红柿切丁,装盘,再倒回锅里和鸡蛋一起炒……你会立刻划掉这些多此一举的步骤,把能提前称好的配料全部备齐,把“翻炒”和“装盘”合并成一次动作。AI 模型的计算过程,本质上也是一张“菜谱”——计算图。XLA 编译器里的“代数简化”,就是那位雷厉风行的主厨:它把“加 0”“乘 1”这种毫无意义的步骤直接划掉,把“3+2“这种写得死死的常量提前算好,把挨在一起的算子合并成一道工序,最终让模型跑得更快、更省显存。这篇文章就用”后厨做菜”的故事,带你读懂计算图优化的底层逻辑。

岗位关联

对 AI 从业者来说,理解代数简化能帮你读懂框架的编译日志、定位性能瓶颈,也能在部署模型时做出更聪明的“后厨决策”——这是系统优化岗位的核心基本功。

目录
  1. 可视化演示
  2. 计算图基础:读懂菜谱才能改菜谱
  3. 代数简化:划掉“加水又倒掉”的步骤
  4. 常量折叠:提前把配料称好
  5. 算子融合:一锅出的智慧
  6. 优化Pass调度:后厨动线设计
  7. 性能评估:出菜计时器说了算
  8. XLA与PyTorch:两位大厨的同款理念
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

三步交互演示XLA编译器代数简化核心机制:常量折叠编译时预计算、表达式化简消除冗余算子、优化Pass流水线协同执行。

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

常量折叠 编译时预计算常量表达式 消除运行时冗余计算节点 const(a) op const(b) = const(a op b) 减少计算图节点数量

计算图基础:读懂菜谱才能改菜谱

主厨拿到一份新菜谱,第一件事一定是通读全篇:总共有多少道工序?哪道工序必须在哪道之前?切西红柿必须在打蛋前面吗?不一定,但“先备料再开火”和“一边炒一边找盐”的出菜效率截然不同。读懂工序和依赖,是一切改动的起点。计算图之于 AI 编译器,正是这张菜谱。

深度学习模型的计算过程,就是一张巨大的“菜谱”:计算图(Computation Graph)。在 XLA 里,这张图有一个专门的名字——HLO(High Level Optimizer 的中间表示)。图上的节点是算子,比如 Add、Mul、MatMul、ReLU;边是张量的流动方向,数据从输入节点流入,经过一个个算子,最后到达输出节点。XLA 后面的所有优化,都在这张图上展开。

图优化的第一个前提是“读图”。编译器需要知道三件事:每个节点的输入来自哪里(数据依赖),哪些节点可以并行(节点间没有依赖链),每个中间张量的形状是什么(Shape)。有了这些信息,后续的代数简化、常量折叠、算子融合规则才有用武之地——就像主厨只有看清每道工序的先后关系,才知道哪些步骤可以删、哪些可以提前准备。

就像主厨只有看清每道工序的依赖关系,才能决定先做哪一步、哪些步骤可以提前准备,编译器也只有读懂计算图的结构,才知道哪些节点可以删、可以提前算、可以合并。读图,是后面所有“偷懒技巧”的前提。

y=W×x+b\displaystyle y = W \times x + b
这是深度学习里最常见的计算模式:线性层。它对应计算图中的三个节点:输入 x、矩阵乘 W×x、加偏差 b。图中还有“乘 1”“加 0”之类的冗余节点没画出来——它们正是后面要删掉的对象。
  1. 输入变量 x作为计算图的叶子节点,数据从这里流入,相当于菜谱里的基础食材。
    x\displaystyle x
  2. 矩阵乘 W×x权重 W 与输入相乘,生成中间张量,是图里计算量最大的算子之一,相当于“把食材下锅翻炒”。
    W×x\displaystyle W \times x
  3. 加偏差 b逐元素加上偏置,完成线性变换,得到最终输出 y,相当于出锅前最后调味。
    W×x+b\displaystyle W \times x + b

代数简化:划掉“加水又倒掉”的步骤

菜谱里最气人的步骤是“加一勺盐,觉得咸,再加水稀释”。这个操作不改变最终味道,却白白占了灶台和厨师的时间。后厨高手看到这种步骤会毫不犹豫划掉。计算图里也大量存在这种“做了等于没做”的算子:乘以 1、加 0、乘以 0。

代数简化(Algebraic Simplification)就是 XLA 里专门做这件事的优化 pass(可以理解为一个“专门挑冗余步骤的审核员”)。它的工作方式非常朴素:在图上寻找符合特定恒等模式的子图,然后替换成更简单的等价子图。单个规则看上去像是“小聪明”,但在动辄成千上万个节点的模型里,冗余算子往往是以“万”为单位出现的。

除了恒等消去,代数简化还包括两类常用技巧。一类是强度削减(strength reduction):把昂贵的算子换成便宜的等价算子,比如把 x 的平方改成 x×x,把除以常数改成乘以它的倒数;另一类是公共子表达式消除(CSE):如果图里两处计算的是同一个子表达式,只算一次,结果复用——就像熬一锅高汤,多道菜共用,而不是每道菜各熬一锅。

划掉“加水又倒掉”,菜品味道一字不差,出菜速度却明显变快。代数简化的精髓正是如此:语义严格保持不变,计算成本悄悄下降。对编译器而言,这种“零风险”的优化永远排在性能优化金字塔的最底层——它不依赖硬件,却能让后面的每一层优化受益。

x×1=xx+0=xx×0=0\displaystyle x \times 1 = x \qquad x + 0 = x \qquad x \times 0 = 0
这是代数简化最核心的三条恒等规则:乘 1 等于原数,加 0 等于原数,乘 0 等于 0。编译器一旦匹配到这类子图,就直接用更简单的节点替换。
  1. 乘一恒等(x×1=x)乘以 1 不改变任何信息,乘积节点可以整体替换为 x。
    x×1=x\displaystyle x \times 1 = x
  2. 加零恒等(x+0=x)加 0 同样不产生新信息,加法节点可以整体替换为 x。
    x+0=x\displaystyle x + 0 = x
  3. 乘零吸收(x×0=0)任何数乘 0 都得 0,与 x 的具体取值无关,可以替换为常量节点 0。
    x×0=0\displaystyle x \times 0 = 0

常量折叠:提前把配料称好

高峰期出餐,主厨一定会在开市前把盐、糖、淀粉按小份装好。顾客点单时直接倒进锅里,而不是现场称量——现场称量不仅慢,还容易忙中出错。计算图里也经常出现这种“现场称量”的浪费:3+2、0.5×2 这类子表达式,输入全是常量,与任何动态数据无关,却要在每次前向传播里重复计算。

常量折叠(Constant Folding)的做法非常直接:在编译期把常量子表达式算出来,替换成新的常量节点。比如 (3+2)×x,编译器在编译阶段算出 3+2=5,把整棵子图压成一个常量节点 5。要知道,模型训练一个 epoch 可能要跑几千步,推理服务每秒钟要跑几百次——把"3+2"从每次运行里抹掉,累积收益相当可观。

更有意思的是常量传播(Constant Propagation):把已知常量的值沿着计算图的边向前推,让下游原本依赖变量的算子也被“感染”成常量子表达式,从而连锁触发更多折叠。常量折叠还有一个隐藏收益——形状推断:当所有维度都在编译期确定后,编译器可以提前分配显存、规划 kernel 调度,而不是运行时手忙脚乱地分配。

提前称好的配料,让出餐高峰期从容不迫。常量折叠省下的,正是模型每一次前向传播里的“现场称量”。这也是为什么编译器工程师常说:能在编译期做完的事,绝不留到运行时。

(3+2)×x  ⇒  5×x\displaystyle (3 + 2) \times x \;\Rightarrow\; 5 \times x
这个表达式展示了常量折叠的完整过程:左侧的子树 (3+2) 与任何输入无关,在编译期就算出 5,右侧的表达式在运行时只剩一次乘法。
  1. 识别常量子表达式 (3+2)遍历图时发现某个算子节点的所有输入都是常量,与动态数据无关,相当于“这步操作不需要等顾客点单”。
    3+2\displaystyle 3 + 2
  2. 编译期求值在编译阶段直接算出 3+2=5,这个过程只发生一次,不占用运行时间。
    3+2=5\displaystyle 3 + 2 = 5
  3. 子图替换把整棵子图替换为常量节点 5,运行时不再重复计算,下游算子直接消费这个新常量。
    (3+2)×x  ⇒  5×x\displaystyle (3 + 2) \times x \;\Rightarrow\; 5 \times x

算子融合:一锅出的智慧

后厨最懂“一锅出”的妙处:西红柿炒蛋,先炒蛋盛出来、再炒西红柿、最后倒回去混合?高手直接一锅搞定。省掉的不仅是来回折腾的时间,还有洗盘子的水——对应到 GPU 上,就是中间张量写回显存再读出来的开销。这个开销往往比计算本身还要昂贵。

算子融合(Operator Fusion)就是把计算图中相邻的、逐元素计算的算子合并成一个自定义 kernel(GPU 上执行的一段程序)。比如 conv + bias + relu 三个算子,原本要启动三次 kernel、产生两个中间张量;融合之后变成一个“融合算子”,中间结果根本不离开片上缓存,直接在寄存器里传递。

这里要澄清一个常见误解:融合不会减少浮点运算次数(FLOPs 一点没少),它省的是两笔工程开销。第一笔是访存——中间张量不用写回显存再读出来,省掉了最贵的全局内存往返;第二笔是 kernel 启动次数——GPU 上每次启动 kernel 都有固定开销,融合把三次启动合并成一次。但融合不是无脑合并:涉及 shape 改变的算子(如 reshape、pad)必须谨慎,强行融合可能让访存模式变差,反而更慢。融合规则的精髓,正是判断哪些工序值得一锅出,哪些必须分锅。

一锅出不代表所有菜都该一锅出——炖汤和油炸必须分开,否则互相破坏口感。算子融合也是如此,它是一门“看菜下锅”的手艺:选对能融合的算子组合,性能起飞;选错组合,反而得不偿失。

g(f(x))=(g∘f)(x)\displaystyle g(f(x)) = (g \circ f)(x)
这是函数复合的标准写法,也是算子融合的数学本质:两个依次执行的函数 f 和 g,在数学上可以看作一个复合函数 g∘f,在工程上则可以编译成一个融合 kernel。
  1. 内层算子 f先执行的计算步骤,比如矩阵乘,对应“先下锅翻炒”。
    f(x)\displaystyle f(x)
  2. 外层算子 g依赖 f 的输出、紧接着执行的下一步,比如激活函数 ReLU,对应“出锅前调味”。
    g(f(x))\displaystyle g(f(x))
  3. 复合函数融合把两次计算合并为一次内核执行,中间结果不再物化到全局内存,对应“一锅出,省掉端盘子和洗盘子”。
    (g∘f)(x)\displaystyle (g \circ f)(x)

优化Pass调度:后厨动线设计

后厨动线是门学问:备料台放哪、灶台怎么排、出菜口在哪,都有讲究。更重要的是顺序——先切配还是先煲汤?先备料再开火,还是边炒边备料?顺序错了,再好的厨师也会手忙脚乱。编译器的优化过程同样面临这个问题,而它的解决方案是“优化 Pass 调度”。

编译器里的优化不是一口气完成的,而是按一个个“优化 pass”逐步执行的。每个 pass 是一个独立的小优化器,按顺序作用在计算图上:前一个 pass 的输出图,是后一个 pass 的输入图。XLA 的优化管线里,代数简化、常量折叠、算子融合、死代码消除等 pass 被编排成一个序列,各司其职、依次执行。

Pass 的顺序大有讲究。比如常量折叠通常要在恒等消去之前:折叠产生的新常量,可能正好触发下游的“乘 1 消去”;而代数简化把图精简之后,算子融合的模式匹配又变得更简单。更复杂的是“机会触发”——某些 pass 只在检测到特定图模式时才开工,需要前面的 pass 先把模式“造”出来。因此编译器通常不会只跑一遍,而是循环迭代整个 pass 集合,直到图不再变化——这个稳定状态叫“不动点”。

精妙的动线设计让整条出菜流水线顺滑流畅。Pass 调度也是同样的道理:顺序对了,各个优化规则互相成就;顺序乱了,某个 pass 可能把图改得让后面所有 pass“无从下手”——这就是为什么编译器工程师把 pass 管线的编排看得和 pass 本身一样重要。

G0→P1G1→P2G2→P3G3\displaystyle G_0 \xrightarrow{P_1} G_1 \xrightarrow{P_2} G_2 \xrightarrow{P_3} G_3
这条链展示了 pass 调度的本质:初始计算图 G0 依次经过优化 pass P1、P2、P3,每一步都产生一个新图。整个过程迭代运行,直到图不再变化。
  1. 初始图 G₀编译器从框架前端拿到的原始计算图,充满了冗余算子,相当于一份未经整理的菜谱。
    G0\displaystyle G_0
  2. Pass P₁ 作用第一个优化步骤(比如常量折叠)输出新图 G₁,图变得更简洁,同时可能产生新的优化机会。
    G0→P1G1\displaystyle G_0 \xrightarrow{P_1} G_1
  3. 迭代至不动点持续应用后续 pass,直到输出图与输入图完全一致,说明所有能做的优化都已完成。
    G2→P3G3\displaystyle G_2 \xrightarrow{P_3} G_3

性能评估:出菜计时器说了算

主厨改菜谱从来不是凭感觉,而是靠计时——改前一桌菜 3 分钟出齐,改后 2 分钟,数据说话。如果计时器显示更慢了,再“有道理”的改动也得回滚。图优化同样如此,任何优化规则都不能只停留在“看起来更快”,必须用性能数据证明自己。

性能评估常用的指标有四类。端到端延迟:处理一个样本的耗时,是最直观的用户体验指标;吞吐:每秒能处理的样本数,对推理服务至关重要;显存峰值:优化前后占用的显存,融合经常能带来惊喜;还有 kernel 启动次数和浮点运算量,用来定位瓶颈到底在调度层还是计算层。

XLA 提供了专业的剖析工具,最常用的是导出 pass 前后的 HLO 图(通过 XLA_FLAGS 里的 --xla_dump_to 参数),工程师可以逐 pass 对比图的变化,精确看到每个优化规则到底删了什么、合并了什么。最终的综合指标是加速比(Speedup):优化前耗时除以优化后耗时,大于 1 说明优化有效,越大越好。

计时器不会说谎。优化是“看起来更快”还是“真的更快”,最终都要看这个比值。这也是为什么成熟的编译器团队会把性能回退测试(perf regression test)写进 CI——每次改动一个 pass,都要用一组标准模型跑一遍加速比,防止优化 A 给优化 B“帮了倒忙”。

Speedup=TbeforeTafter\displaystyle \text{Speedup} = \frac{T_{\text{before}}}{T_{\text{after}}}
加速比是衡量图优化效果的核心公式:T_before 是优化前一次推理的耗时,T_after 是优化后的耗时,两者相除得到加速比。
  1. 优化前耗时 T_before在原始计算图上运行一次前向推理的时间,对应菜谱改版前的出菜时长。
    Tbefore\displaystyle T_{\text{before}}
  2. 优化后耗时 T_after在优化后的计算图上运行相同推理的时间,对应菜谱改版后的出菜时长。
    Tafter\displaystyle T_{\text{after}}
  3. 加速比两个耗时相除,大于 1 说明优化带来收益;等于或小于 1 说明优化无效甚至起了反作用,需要回滚。
    TbeforeTafter\displaystyle \frac{T_{\text{before}}}{T_{\text{after}}}

XLA与PyTorch:两位大厨的同款理念

不同餐厅的菜系天差地别,但主厨的简化理念是相通的:删冗余、预配料、并工序。AI 编译器世界也是如此——XLA 和 PyTorch 编译器就像两位风格不同的大厨,用的却是同一套“后厨心法”。理解了代数简化这套理念,你在任何编译器里都能快速找到对应物。

XLA 的编译管线清晰而经典:前端把 TensorFlow 或 JAX 模型转换成 HLO 中间表示,然后在 HLO 上依次运行一串优化 pass——代数简化、常量折叠、算子融合都在其中;优化完的 HLO 再交给 LLVM,生成针对 GPU/TPU 的机器码。PyTorch 2.0 的 torch.compile 走的也是同样的路:Dynamo 负责捕获 Python 代码里的计算图,Inductor 在图上做算子融合,并生成高效的 Triton kernel。

两位大厨有一个共同的“头疼事”:动态 Shape。如果张量形状在运行时才确定,常量折叠和形状推断在编译期就难以发挥作用,内存预分配也无从谈起——相当于菜谱上写着“盐适量、水少许”,主厨只能临场发挥。这也是为什么实际生产中,工程师常常用 padding 把输入形状固定下来,牺牲一点显存换来编译优化的全面激活。

不管哪家大厨,拿手的都是“把菜谱打磨到极致”的功夫。XLA 和 PyTorch 编译器各有所长:XLA 适合静态图、追求极致性能,torch.compile 兼顾动态 Python 生态和编译优化。但它们的核心心法完全一致——代数简化,删冗余、预配料、并工序,让计算图更高效。

HLO→代数简化HLOopt→算子融合HLOfused\displaystyle \text{HLO} \xrightarrow{\text{代数简化}} \text{HLO}_{\text{opt}} \xrightarrow{\text{算子融合}} \text{HLO}_{\text{fused}}
这是 XLA 编译管线的核心环节:原始 HLO 先经过代数简化变成精简版,再经过算子融合变成可执行版本,每一步都在为最终的机器码铺路。
  1. 原始 HLO从框架前端得到的中间表示,包含大量冗余,相当于一份未经整理的菜谱。
    HLO\displaystyle \text{HLO}
  2. 代数简化 pass第一轮优化删除恒等算子、折叠常量,输出精简的 HLO_opt,相当于划掉多余步骤、备好配料。
    HLO→代数简化HLOopt\displaystyle \text{HLO} \xrightarrow{\text{代数简化}} \text{HLO}_{\text{opt}}
  3. 算子融合 pass第二轮优化把相邻算子合并,输出 HLO_fused,最终交给后端生成高效机器码,相当于把所有工序整合成流畅的流水线。
    HLOopt→算子融合HLOfused\displaystyle \text{HLO}_{\text{opt}} \xrightarrow{\text{算子融合}} \text{HLO}_{\text{fused}}

AI 应用场景

场景1:torch.compile 自动优化训练图

阶段:模型训练启动期的编译优化

解决的问题:PyTorch 默认的 eager 模式每遇到一个算子就启动一次 GPU kernel,训练循环里成百上千次 kernel 启动让 GPU 大部分时间在“排队”而非计算。传统做法是写代码时手动合并操作,既繁琐又容易出错。

具体案例:训练一个中型 ResNet-50 图像分类模型,开启 torch.compile(model) 后,前向与反向中的 ReLU、BatchNorm、卷积偏置加法被自动融合,训练吞吐提升 30%-80%(取决于模型和 GPU),显存占用同步下降。工程师不需要改一行模型代码,只换了一行调用。

场景2:推理引擎导出时的常量折叠

阶段:模型导出与推理部署

解决的问题:训练好的模型导出到 ONNX/TensorRT/XLA 时,原图里充满归一化参数、padding 常量等“静态配料”。传统推理逐层实时计算这些常量,浪费 GPU 算力。导出编译器自动做常量折叠,把 BatchNorm 的均值方差乘进卷积权重,推理时少一整层计算。

具体案例:部署 BERT 文本分类模型到 TensorRT 时,LayerNorm 的均值和方差在推理时是固定值,编译器把它们折叠进前面的线性层权重,推理延迟从 8ms 降到 5ms,同时显存峰值降低约 20%。

场景3:HLO dump 定位编译性能瓶颈

阶段:编译器开发与性能调优

解决的问题:模型变慢时,工程师需要判断是计算量大、访存频繁还是 kernel 启动太多。传统做法靠经验猜测,反复试错。XLA 提供 --xla_dump_to 导出 pass 前后的 HLO 图,逐 pass 对比能精确定位哪个优化没触发、哪个子图没被融合。

具体案例:某 LLM 推理服务延迟超标,工程师 dump HLO 后发现动态 Shape 导致常量折叠基本失效;把输入 padding 到固定长度后,多个 pass 被连锁激活,端到端延迟下降 35%。这就是“读图”能力直接转化为性能收益的真实写照。

Python 演示

python64 行
class Expr:    """计算图表达式节点: 仅支持常量, 变量, 加法和乘法"""    def __init__(self, op, *args, value=None):        self.op = op          # const / var / add / mul        self.args = args      # 子节点        self.value = value    # 常量节点的数值     def __repr__(self):        if self.op == 'const':            return str(self.value)        if self.op == 'var':            return self.args[0]        sym = {'add': '+', 'mul': '*'}        return '(' + str(self.args[0]) + ' ' + sym[self.op] + ' ' + str(self.args[1]) + ')'     def count_nodes(self):        """统计子图节点总数, 用于观察优化效果"""        if self.op in ('const', 'var'):            return 1        return 1 + self.args[0].count_nodes() + self.args[1].count_nodes()  def simplify(expr):    """代数简化主函数: 自底向上折叠常量, 消去恒等操作"""    if expr.op not in ('add', 'mul'):        return expr    left = simplify(expr.args[0])    right = simplify(expr.args[1])    # 常量折叠: 两个子节点都是常量, 直接算出结果    if left.op == 'const' and right.op == 'const':        if expr.op == 'add':            return Expr('const', value=left.value + right.value)        return Expr('const', value=left.value * right.value)    # 恒等消去: x + 0 = x, x * 1 = x, x * 0 = 0    if expr.op == 'add' and right.op == 'const' and right.value == 0:        return left    if expr.op == 'mul' and right.op == 'const':        if right.value == 1:            return left        if right.value == 0:            return Expr('const', value=0)    # 构造一个新的, 左右子树均已简化的节点    return Expr(expr.op, left, right)  def main():    # 构造表达式: (x * 1) + (3 + 2)    x = Expr('var', 'x')    raw1 = Expr('add', Expr('mul', x, Expr('const', value=1)),                Expr('add', Expr('const', value=3), Expr('const', value=2)))    print('原始表达式:', raw1, '节点数:', raw1.count_nodes())    opt1 = simplify(raw1)    print('简化后表达式:', opt1, '节点数:', opt1.count_nodes())     # 再试: (x + 0) * (5 * 0), 期望直接折叠成常量 0    raw2 = Expr('mul', Expr('add', x, Expr('const', value=0)),                Expr('mul', Expr('const', value=5), Expr('const', value=0)))    print('原始表达式2:', raw2, '节点数:', raw2.count_nodes())    opt2 = simplify(raw2)    print('简化后表达式2:', opt2, '节点数:', opt2.count_nodes())  if __name__ == '__main__':    main()
目的:用约 60 行代码实现一个微型“代数简化编译器”,演示常量折叠、加零消去、乘一消去、乘零吸收四条优化规则,并输出优化前后的表达式与节点数。
  1. def simplify(expr):简化入口,自底向上递归处理每个节点,对应 XLA 中“先化简子树再处理父节点”的遍历方式。
  2. left = simplify(expr.args[0])先简化左子树,保证子问题解决后再处理当前节点,这是“自底向上”的关键。
  3. if left.op == 'const' and right.op == 'const':两个子节点都是常量,触发常量折叠,对应 XLA 的 Constant Folding pass。
  4. if expr.op == 'add' and right.op == 'const' and right.value == 0:加零恒等消去,x+0 直接返回 x,对应“划掉加水又倒掉的步骤”。
  5. if expr.op == 'mul' and right.op == 'const':乘一消去和乘零吸收两条规则汇聚在这里:乘以 1 返回原节点,乘以 0 返回常量 0。
  6. print('简化后表达式:', opt1, '节点数:', opt1.count_nodes())打印优化前后的节点数,用量化数据验证“图变简洁了”,对应性能评估中的指标对比。

常见误区

误区我听说 XLA 优化主要靠算子融合,代数简化就是把 x×1 变成 x,这种小打小闹对性能影响不大,不值得花时间研究。

正解代数简化虽然单个规则简单,但真实模型里冗余算子大量存在:动态图转静态图会产生大量无意义算子,模板化代码会带来重复子表达式。这些冗余一旦消除,不仅直接减少计算量,更重要的是让计算图“变干净”——干净的图是常量传播、算子融合、内存分配等所有后续优化的前提。就像主厨划掉十个多余步骤,省下的累计时间相当可观,更别说每个步骤还占用灶台。

为何常见:单看一条恒等式觉得微不足道,忽略了它在大规模计算图上的累积效应,以及“干净图”对下游 pass 的连锁带动作用。

误区融合把两个算子合成一个,那浮点运算次数肯定变少了,模型整体更轻、算得更快。

正解融合不改变 FLOPs,乘法和加法一个都不少。它的收益来自工程层面:中间张量不再写回显存再读出来(省访存)、kernel 启动次数减少(省调度开销)、数据局部性更好(缓存命中率提升)。形象地说,一锅出省的是端盘子和洗盘子的时间,食材照旧要下锅炒。如果以“减少计算量”为目标,那是强度削减和剪枝的职责,不是融合的。

为何常见:直觉上“合并等于减少”,忽略了算子融合改的是执行方式和数据搬运,而不是算法本身。

误区常量折叠听起来很简单,就是编译器把字面量运算算一下。这种基础功能编译器几十年前就做完了,没什么好学的。

正解常量折叠只是起点,真正的价值在于常量传播——把已知常量的值沿着计算图向前推,让下游原本依赖变量的算子变成常量子表达式,从而连锁折叠。配合形状推断,编译器还能在编译期确定张量形状、预分配内存。在动态 Shape 模型里,这一整套根本无法施展,性能大打折扣——这正是它难的地方,也是编译器工程师反复 tuning 的关键点。

为何常见:只看到“折叠”这一个动作,没看到“传播+形状推断”构成的完整闭环,低估了常量信息在图上“流动”的威力。

误区编译优化就是按顺序把 pass 列表执行一遍,每个 pass 处理一次,图就优化好了,不用再管。

正解一个 pass 的输出可能开启另一个 pass 的机会。常见的做法是设置一个 pass 集合,反复迭代直到达到不动点(图不再变化)。例如常量折叠产生新常量,触发了新的恒等消去,消去后图更小,融合匹配更容易。如果只跑一遍,大量跨 pass 的优化机会会被白白漏掉。这也是为什么 XLA 的优化是一个循环而非线性流程。

为何常见:把 pass 当作互不相关的独立工具,忽略了它们通过图结构相互触发的依赖关系,就像只把后厨动线画在纸上,却从不在实际出餐中验证迭代。

面试题

Q1. XLA 中“代数简化”的核心目标是什么?

单选题 · easy

  • A.把模型换成更小的网络结构
  • B.保持语义不变同时减少计算开销← 正确答案
  • C.降低模型精度以提升运行速度
  • D.自动增加模型表达能力

答案:B。代数简化的信条是“口味不变,工序更少”——保持计算语义完全相同,同时削减冗余算子和运算开销。A 是架构改动,C 是量化剪枝的思路,D 更是南辕北辙。就像主厨划掉多余步骤,但绝不动菜的味道。

Q2. 下列哪个恒等变换是代数简化常用的规则?

单选题 · easy

  • A.x 加 1 等于 1
  • B.x 乘 0 等于 x
  • C.x 乘 1 等于 x← 正确答案
  • D.x 加 x 等于 0

答案:C。x 乘 1 等于 x,是典型的“乘一恒等”。A 中 x+1 不等于 1,B 中 x×0 等于 0 而非 x,D 中 x+x 等于 2x 而非 0。记住口诀:加零乘一,原样回去;乘零吸收,全部归零。

Q3. 常量折叠发生在哪个阶段?

单选题 · mid

  • A.运行时每一次前向传播
  • B.编译阶段的图优化过程← 正确答案
  • C.数据预处理阶段进行
  • D.模型训练微调的过程中

答案:B。常量折叠发生在编译阶段,属于图优化 pass 的一部分。它把 3+2 在编译期算成 5,运行时不再重复计算。A 是运行时逐次计算,C 与图优化无关,D 属于训练阶段。就像开店前称好配料,而不是顾客点单时再现场称量。

Q4. 算子融合可以带来哪些收益?

多选题 · mid

  • A.减少内核启动的次数← 正确答案
  • B.减少中间张量访存开销← 正确答案
  • C.减少模型的参数量
  • D.提升计算的数据局部性← 正确答案

答案:ABD。算子融合把相邻算子合并成一个 kernel:减少内核启动次数(A)、减少中间张量写回主存的访存开销(B)、提升数据局部性(D)。但参数量由模型结构决定,融合不会改变权重数量,C 错误。一锅出省的是洗盘子和端盘子的时间,不会减少食材本身。

Q5. 表达式 x×0 经过代数简化后等价于?

单选题 · easy

  • A.常量节点 1
  • B.常量节点 0← 正确答案
  • C.变量节点 x
  • D.无法确定结果

答案:B。任何数乘 0 都得 0,x×0 可以替换为常量节点 0,这就是“乘零吸收”。A 是乘 1 的结果,C 保留了变量依赖,D 说无法确定也是错的——结果与 x 无关,确定是 0。这也是“做了等于白做的步骤直接划掉”的原则体现。

Q6. 关于优化 Pass 的调度,下列哪些说法正确?

多选题 · mid

  • A.Pass间存在依赖关系← 正确答案
  • B.需要迭代执行直到收敛← 正确答案
  • C.Pass顺序对结果无影响
  • D.一个Pass开新优化机会← 正确答案

答案:ABD。优化 Pass 之间有依赖关系(A),比如常量折叠产生的常量能触发新的恒等消去;编译器需要反复迭代直到图不再变化(B);一个 Pass 的输出可能开启另一个 Pass 的机会(D)。而 C 说顺序没影响是错误的——后厨动线的顺序错了会手忙脚乱。

Q7. 为什么两个相邻逐元素算子融合后能加速?

单选题 · mid

  • A.减少了浮点运算的总次数
  • B.减少了中间结果写回内存← 正确答案
  • C.降低了模型训练的精度
  • D.增大了模型的参数量

答案:B。两个逐元素算子融合后,中间张量不再写回显存再读出来,直接在寄存器里传递,这是性能提升的主要来源。A 错在融合不减少浮点运算次数;C 与融合无关;D 更是错误。省的是“端盘子和洗盘子”,不是“做菜”本身。

Q8. 优化 Pass 迭代到“不动点”意味着什么?

单选题 · extreme

  • A.Pass集收敛于稳定图← 正确答案
  • B.Pass运行终止并报错
  • C.图结构不再产生变化
  • D.训练损失已经最小化

答案:A。Pass 迭代到不动点,意味着整套 pass 反复作用后,图结构不再产生任何变化,所有能做的优化都已完成。B 把收敛误解为报错,C 表述过于绝对(图仍可改,只是没有优化机会),D 把编译期概念误扣到训练损失上。就像主厨把菜谱改到“再改反而更差”为止。

Q9. 动态 Shape 给编译期图优化带来哪些挑战?

多选题 · hard

  • A.常量折叠无法发挥作用
  • B.形状推断难以在编译期完成← 正确答案
  • C.优化后算子无法并行执行
  • D.内存预分配方案变得困难← 正确答案

答案:BD。动态 Shape 让编译期无法确定张量形状:形状推断难以完成(B)、内存预分配方案失效(D),这是真实挑战。但 A 说常量折叠完全失效过于绝对——标量常量如 3+2 依然可以折叠,只是依赖具体形状的优化受限。C 与 Shape 动态性无必然关系,属于干扰项。

Q10. 公共子表达式消除的主要作用是?

单选题 · easy

  • A.减少重复计算的中间结果← 正确答案
  • B.增加模型的训练数据量
  • C.降低模型输出的数据维度
  • D.改变模型的损失函数

答案:A。公共子表达式消除发现图里两处计算同一个子表达式时,只算一次并复用结果,减少重复计算。B、C、D 分别说的是数据量、输出维度和损失函数,与该优化无关。就像一锅高汤多道菜共用,而不是每道菜各熬一锅。