你规划了一次环球旅行,行李清单写了满满三页:厚毛衣、两套洗漱包、三本旅行指南、好几个转接头……如果照单全收,箱子重得拖不动,你还得在每个城市之间狼狈地拖着它赶路。幸运的是,出发前你请来了一位经验丰富的旅行规划师。他不动你的行程,只动行李箱——把“确定用不上的”扔掉,把“两件一模一样的”合成一件,把“每天都要掏出来的公交卡”放到外层口袋,再把“又重又厚的旅行指南”换成轻巧的电子书。等行李重新封箱时,行程还是那个行程,东西一样不缺,箱子却轻了一大半。 这位规划师,就是编译器里的“代码优化器”。程序员写下的源码像一张任性的行李清单;机器码像行李箱。编译器优化的全部艺术,就是在“不改变程序行为”这条铁律之下,把代码收拾得又快又轻。本文沿着这条旅行主线,逐个拆解常量折叠与传播、死代码消除、公共子表达式消除、复写传播、循环不变量外提、强度削弱六大门派,最后看看 LLVM 这个“金牌规划团队”如何把零散技巧变成工业化流水线。

岗位关联

对 AI 从业者而言,代码优化是理解“为什么同一个模型在不同推理引擎里速度差几倍”的钥匙,也是通往编译器后端与高性能系统工程师岗位的必修技能。

目录
  1. 可视化演示
  2. 优化概述:旅行规划师的第一守则
  3. 常量折叠与传播:出发前查好的小本子
  4. 死代码消除:扔掉那件厚毛衣
  5. 公共子表达式消除:一个充电器就够了
  6. 复写传播:免去中间倒腾
  7. 循环不变量外提:公交卡放外层口袋
  8. 强度削弱:实体书换 Kindle
  9. LLVM实践与收益评估:标准清单与体重秤
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示编译器局部优化(常量折叠/死代码消除/公共子表达式)与全局优化(复写传播/循环不变量外提/强度削弱)技术原理及优化流水线效果。切换模式查看各维度。

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

局部优化技术 常量折叠(constant folding):编译期计算常量表达式(如3+4→7),常量传播将已知常量值替换变量引用,消除运行时计算 死代码消除(DCE):删除不可达代码和结果未使用代码;公共子表达式消除(CSE):识别重复计算用临时变量替换,减少冗余运算 local_opt = const_fold + const_prop + DCE + CSE + copy_prop 局部优化作用于基本块内

优化概述:旅行规划师的第一守则

你决定去环球旅行,行李清单写了满满三页。出发前请来一位资深旅行规划师,他接过清单,说的第一句话不是“我带您精简”,而是“您的行程单一个字不许改,我只动行李箱”。这句话,就是整个旅行合作的地基。

这句承诺,对应编译器优化的立身之本——语义保持(semantics preservation)。优化器可以对代码做各种手脚:把计算挪位置、把重复消除、把昂贵的运算换成便宜的,但程序对外表现必须与优化前一模一样。生活里这叫“行程不能变”,编译器圈这叫“不改变程序的可观察行为”。

代码优化按作用范围分两类:局部优化只在一个基本块(一段顺序执行的指令序列)内进行,视野小但分析快;全局优化跨越多个基本块,需要数据流分析(data-flow analysis)——沿着程序的“信息流”追踪变量的来龙去脉。现代编译器(如 LLVM)大多在中间表示(IR)层做优化:IR 既保留控制流结构,又贴近机器指令,是优化战役的主战场。

守住“行程一个字不改”的底线,规划师才敢大刀阔斧地扔东西、换东西。编译器优化师也一样——只要等价性在手,减法、换装、挪位,都可以尽情发挥。

Popt≡Psrc\displaystyle P_{\mathrm{opt}} \equiv P_{\mathrm{src}}
通俗说明:优化前后的两段程序行为完全一致,就像规划师只改变装箱方式,绝不更改行程表。
  1. P_src(源程序)优化开始前的原始程序,相当于那张三页的行李清单。
    Psrc\displaystyle P_{\mathrm{src}}
  2. P_opt(优化后程序)优化器输出的最终程序,相当于规划师重新封箱后的行李箱。
    Popt\displaystyle P_{\mathrm{opt}}
  3. ≡(语义等价)两个程序对任意输入都产生相同的可观察结果,这是优化的底线承诺。
    Popt≡Psrc\displaystyle P_{\mathrm{opt}} \equiv P_{\mathrm{src}}

常量折叠与传播:出发前查好的小本子

规划师出发前做的第一件事,是把汇率、天气、时差这些“固定信息”查清楚,写进一本小本子。旅途中凡是遇到需要换算的价格,直接翻本子,从不现场掏出手机现算。

编译器里也有这么一类“固定信息”:常量。常量折叠(constant folding)是指把编译期就能算出来的表达式直接算完,比如 3×4+2 直接变成 14;常量传播(constant propagation)是指把已知常量的值沿着程序路径向前带,凡是用到该变量的地方都换成这个值。两者最常见的配合套路是“先传播,后折叠”——先把常量值送到使用点,折叠的触发面就会更大。

触发条件并不苛刻:折叠要求表达式所有操作数都是编译期已知的常量;传播要求赋值语句右侧是常量,且路径上该变量没有被改写。常量折叠看起来是小打小闹,却是后续大优化的“燃料”——程序里常数一多,死代码消除和强度削弱才轮得上出手。

小本子写满之后,旅途中的“临时心算”就消失了。常量一旦被“钉死”在代码里,后续优化器就能放心地对变量做更多手术。

x=3×4+2  ⇒  x=12+2  ⇒  x=14\displaystyle x = 3 \times 4 + 2 \;\Rightarrow\; x = 12 + 2 \;\Rightarrow\; x = 14
通俗说明:编译期就把算术算完,运行时直接拿到数字,像出发前就把汇率查好写在小本子上。
  1. 子表达式 3×4两个操作数都是编译期常量,满足折叠触发条件,先算掉。
    3×4=12\displaystyle 3 \times 4 = 12
  2. 折半结果 12+2生成 12 后继续参与外层表达式折叠,一步步推进。
    12+2=14\displaystyle 12 + 2 = 14
  3. 最终 x=14运行时代码直接装载常量 14,两条算术指令从此消失。
    x=14\displaystyle x = 14

死代码消除:扔掉那件厚毛衣

规划师把行李箱摊开,看到那件厚毛衣便笑了——全程 30°C 的高温海岛行,它永远不会被穿上,却占掉整整一层。果断拿出去。

编译器里的“厚毛衣”叫死代码(dead code):要么是永远执行不到的代码(return 之后的语句、if(false) 分支),要么是“算了但没人用”的赋值。后者叫冗余赋值(dead store),删除它需要一份关键情报:变量在某个程序点之后,到底还会不会被读取?

这份情报来自活跃变量分析(live variable analysis)。一个变量在某点“活跃”,指从该点出发沿某条路径、在它被重新赋值之前还会被读取。基于活跃信息,编译器从后往前扫描指令:某条赋值的结果在之后从未被读取,它就是死代码,可以安全删除。而且删除会引发连锁反应——被删指令中引用的变量可能因此变成死变量,继续删,直到收敛稳定。

规划师扔掉的只是“确定用不上”的厚毛衣。同理,死代码消除只删“确定不会被读取”的赋值与“永远到不了”的语句——多扔一件,箱子就轻一分。

LIVEin[B]=Use[B]∪(LIVEout[B]∖Def[B])\displaystyle \mathrm{LIVE}_{\mathrm{in}}[B] = \mathrm{Use}[B] \cup \left( \mathrm{LIVE}_{\mathrm{out}}[B] \setminus \mathrm{Def}[B] \right)
通俗说明:一个变量“活”多久,取决于从这一点往后还会不会被读取;没人读的赋值就是死代码。
  1. Use[B]基本块 B 内部“先读取后改写”的变量集合,像“这一站真正要用到的物品”。
    Use[B]\displaystyle \mathrm{Use}[B]
  2. Def[B]在 B 内部被重新赋值的变量集合,像“这一站被换掉的旧物”。
    Def[B]\displaystyle \mathrm{Def}[B]
  3. LIVE_out[B](流出活性)从 B 出口流出去之后仍被后续代码需要的变量集合。
    LIVEout[B]\displaystyle \mathrm{LIVE}_{\mathrm{out}}[B]
  4. LIVE_in[B](流入活性)块内直接用到、加上从后面传回来且未被本块改写的,构成流入时必须活跃的集合。
    LIVEin[B]=Use[B]∪(LIVEout[B]∖Def[B])\displaystyle \mathrm{LIVE}_{\mathrm{in}}[B] = \mathrm{Use}[B] \cup \left( \mathrm{LIVE}_{\mathrm{out}}[B] \setminus \mathrm{Def}[B] \right)

公共子表达式消除:一个充电器就够了

箱子里有两件一模一样的 USB-C 充电器。规划师拎起来看了看:两个充电器功能完全相同,带一个就行,另一个需要时直接借用第一个。

代码里的“一个充电器”,就是公共子表达式消除(Common Subexpression Elimination,CSE)。如果一个表达式 b×c 在之前已经被计算过,且两次计算之间 b、c 都没有被改写,那么第二次出现时就不必重新算,直接取第一次的结果。实现上通常引入一个临时变量 t 保存 b×c 的值,然后把第二处替换成对 t 的引用。

判断“结果是否仍然有效”,依赖可用表达式分析(available expressions):一个表达式在某程序点“可用”,指它在之前已被计算过,且结果没有被后续赋值破坏。CSE 的收益直观地体现为减少算术指令;但要注意,如果表达式涉及内存访问,CSE 可能改变访存次数与顺序,编译器需要更加谨慎。

同一个充电器只占一份重量。CSE 让代码里的“重复劳动”只做一次,并把第一次的结果像充电器一样共享给所有需要的地方。

a=b×c;    d=b×c    ⇒    a=b×c;    d=a\displaystyle a = b \times c;\;\; d = b \times c \;\;\Rightarrow\;\; a = b \times c;\;\; d = a
通俗说明:同一个表达式算过一次后,后面用到的地方直接取结果,像同一个充电器两个设备轮流用。
  1. 第一次计算 b×c结果存入 a,同时编译器记录“这个表达式已经算过”。
    a=b×c\displaystyle a = b \times c
  2. 第二次出现 b×c检查两次计算之间 b、c 未被改写,说明表达式仍然可用。
    d=b×c\displaystyle d = b \times c
  3. 复用结果用 a 替代第二次的 b×c,乘法少做一次。
    d=a\displaystyle d = a

复写传播:免去中间倒腾

行程里有这么一步:先把行李存到寄存处,再绕回去取,然后才出发。规划师看后扶额——直接带着行李出发不就行了?“放 A 再挪 B”的中间环节纯属折腾。

代码里的“寄存处”是复写语句(copy statement),形如 x = y——把一个变量的值复制给另一个变量。复写传播(copy propagation)做的事很简单:如果 x 在被 y 赋值之后一直没有被改写,那么所有读到 x 的地方都可以直接用 y 替换,从而省掉那次复制。

有趣的是,复写传播单独运行往往省不了几个钱,它真正的价值在于“拆桥”:x 的引用被替换后,x = y 这条赋值就变成了死代码,可以被 DCE 顺手清掉;若两个表达式原本分别引用 x 和 y,经传播统一成同一个变量后变得相同,又为 CSE 创造了机会。这正是编译器优化的链条之美。

规划师把“存寄存处再取回”改成直接带身上,省掉一段空跑。复写传播单独看不赚钱,却能引爆死代码消除与 CSE 的连锁收益——高手优化从来是一环扣一环。

x=y;    z=x+1    ⇒    z=y+1\displaystyle x = y;\;\; z = x + 1 \;\;\Rightarrow\;\; z = y + 1
通俗说明:把“倒手”的中间变量直接替换成源头变量,省掉一次复制。
  1. 复写语句 x=y编译器登记“x 是 y 的影子”,两者当前值相同。
    x=y\displaystyle x = y
  2. 读取点 z=x+1x 在赋值后未被改写,用 y 替换 x 是安全的。
    z=x+1\displaystyle z = x + 1
  3. 改写结果z 直接使用 y,省掉一次复制;x=y 随后沦为死代码。
    z=y+1\displaystyle z = y + 1

循环不变量外提:公交卡放外层口袋

旅途中每天早晚都要刷公交卡。按原始打包方式,卡埋在行李箱最底层,每次刷卡都要开箱、翻底、取卡、再放回——一天十几次,腰都累断。规划师把卡放进外套口袋,从包里只“掏”一次。

程序里的“反复开箱”就是循环。循环体可能执行成千上万次,如果其中某条语句的计算结果每一轮都相同——操作数在循环内没有被改写——它就叫循环不变量(loop invariant)。每轮都重复算它,纯属浪费。循环不变量外提(LICM)把它搬到循环之前,只执行一次。

外提有严格的前置条件,保证语义不变:第一,该语句在循环内所有路径上都会执行,不能在某次迭代中被跳过;第二,循环内没有其他语句改写它引用的变量;第三,循环若存在多个出口,移出后不能改变“到达某些出口时才执行”的语义。满足这些条件后,收益就是每轮代价乘以省掉的迭代次数。

公交卡只在出发前装进口袋,之后每次刷卡都不再翻箱倒柜。循环不变量外提是收益最大的优化之一——哪怕单次计算很便宜,乘以千万次迭代也是巨额财富。

saving=C×(N−1)\displaystyle \mathrm{saving} = C \times (N - 1)
通俗说明:每轮循环省下 C 的开销,跑 N 轮就省下 (N−1)×C 的时间——公交卡从包里只掏一次。
  1. 单轮代价 C该语句在循环体内每执行一次的开销(时间或资源)。
    C\displaystyle C
  2. 迭代次数 N循环体执行的轮数,N 越大,外提的收益越可观。
    N\displaystyle N
  3. 省掉的次数 N−1外提后第一次在循环前完成,循环内从第 1 轮起不再重复。
    N−1\displaystyle N - 1
  4. 总收益单轮代价乘以省掉的次数,这就是外提带来的净节省。
    saving=C×(N−1)\displaystyle \mathrm{saving} = C \times (N - 1)

强度削弱:实体书换 Kindle

规划师又翻开书单:三大本旅行指南,每本两公斤。他建议换成 Kindle 里的电子版——阅读体验几乎不变,重量从六公斤骤降到三百克。

编译器里的“实体书”是昂贵的运算。强度削弱(strength reduction)用代价更低的等价运算替换高代价运算,最经典的场景发生在循环中:i 是归纳变量(induction variable)——每轮迭代固定 +1,而 j = 4×i 每轮都要重做一次乘法。既然 i 每轮固定加 1,j 每轮就固定加 4,何不把乘法换成累加?

变换时要注意初值与退出条件的配合:进入循环前,j 要初始化为 4 乘以 i 的初值;循环结束时,原本由 i 派生出的 j 值也要照常维护。强度削弱常与循环不变量外提、归纳变量消除配合使用,是现代编译器循环优化家族的顶梁柱。

旅行指南从“六公斤实体书”变成“三百克电子书”,旅行体验完全一样。强度削弱就是用加法替代乘法这类“更便宜的等价物”——循环跑得越多,省下的力气越可观。

j=4×i;    i=i+1    ⇒    j=j+4;    i=i+1\displaystyle j = 4 \times i;\;\; i = i + 1 \;\;\Rightarrow\;\; j = j + 4;\;\; i = i + 1
通俗说明:逐轮递增的加法代替每轮重算的乘法,效果等价,但每轮都更便宜。
  1. 原始形态每轮迭代都用 i 重新计算 j,乘法代价高。
    j=4×i;    i=i+1\displaystyle j = 4 \times i;\;\; i = i + 1
  2. 增量分析i 每轮 +1,因此 j 每轮的增量固定为 4。
    jnew=jold+4\displaystyle j_{\mathrm{new}} = j_{\mathrm{old}} + 4
  3. 改写后循环体内只剩加法,乘法只出现在循环前的初始化。
    j=j+4;    i=i+1\displaystyle j = j + 4;\;\; i = i + 1

LLVM实践与收益评估:标准清单与体重秤

作为业内人士,规划师从不凭感觉行事。他有一份标准化检查清单:查固定信息、扔无用物、合并重复、免去倒腾、外提不变项、替换重物。每做完一项,他还要过磅,看看行李箱到底轻了多少——没有称重的优化都是耍流氓。

LLVM 的优化器就是这份“清单机器”。每个优化动作被封装成一个 Pass(优化遍),命令行工具 opt 按固定顺序调度它们:-O1 做基础清理,-O2 叠加循环优化与全局标量优化,-O3 再加向量化与更激进的变换。LLVM IR 采用 SSA 形式(静态单赋值,每个变量只赋值一次),让数据流分析变得干净利落,也为上述各类优化提供了坚实的地基。

收益评估同样讲究:每个 Pass 都有自己的代价模型。收益来自减少指令数、访存与分支;成本来自分析耗时、寄存器压力与代码体积膨胀。工程师用加速比衡量单个 Pass 或整条优化链的价值,并在一组基准程序(benchmark)上取几何平均做综合判断,防止个别样本虚高。

规划师用体重秤判断每一招是否有效,LLVM 用加速比衡量每个 Pass 的价值。只有“称过”的优化才值得保留——这正是工程直觉与理论分析的结合点。

S=TbeforeTafter\displaystyle S = \frac{T_{\mathrm{before}}}{T_{\mathrm{after}}}
通俗说明:加速比 = 优化前耗时 ÷ 优化后耗时,大于 1 代表优化赚到了。
  1. T_before优化前程序的总执行时间,作为度量基准。
    Tbefore\displaystyle T_{\mathrm{before}}
  2. T_after优化后程序的总执行时间。
    Tafter\displaystyle T_{\mathrm{after}}
  3. 加速比 S两者相除,S>1 说明优化有正向收益,S 越大越快。
    S=TbeforeTafter\displaystyle S = \frac{T_{\mathrm{before}}}{T_{\mathrm{after}}}

AI 应用场景

ML 编译器与推理引擎的算子优化

阶段:模型部署与推理加速

解决的问题:训练好的模型在推理时,算子计算图里往往充斥着重复张量运算、循环内的不变计算和高代价的逐元素变换,导致延迟居高不下。传统手写内核耗时费力,而编译器可以在 IR 层自动完成这些“旅行打包”。

具体案例:XLA 在把 TensorFlow 计算图编译为 GPU 内核时,会应用公共子表达式消除来消除重复张量运算,用循环不变量外提把 padding 等不变计算移出循环;TVM 的调度模板中,工程师也会显式或自动地套用强度削弱,把推理延迟降低数倍。

JIT 加速数据预处理管道

阶段:训练数据预处理

解决的问题:Python 编写的数据预处理循环通常很慢,原因之一是每轮迭代重复计算不变的缩放系数、做了大量多余赋值。JIT 编译器在运行时把热路径编译成机器码,自动完成死代码消除与强度削弱。

具体案例:用 Numba 的 nopython 模式编译图像归一化循环时,编译器会自动把循环内不变的均值、方差外提,把除法换成乘法,预处理耗时下降 3 到 5 倍,且代码逻辑一字未改——就像规划师只动了行李箱。

Python 演示

python117 行
# -*- coding: utf-8 -*-# Mini three-address-code optimizer demo:# constant propagation/folding, copy propagation, CSE,# dead code elimination, loop invariant code motion.  def constant_propagation(ir):    """Propagate constant values along straight-line code."""    vals, out = {}, []    for d, o, a, b in ir:        if o == '=' and isinstance(a, int):            vals[d] = a        else:            vals.pop(d, None)            a, b = vals.get(a, a), vals.get(b, b)        out.append((d, o, a, b))    return out  def constant_folding(ir):    """Fold expressions whose operands are all literals."""    out = []    for d, o, a, b in ir:        if isinstance(a, int) and isinstance(b, int):            if o == '+':                o, a, b = '=', a + b, None            elif o == '*':                o, a, b = '=', a * b, None        out.append((d, o, a, b))    return out  def copy_propagation(ir):    """Replace uses of x with y after a copy 'x = y'."""    copies, out = {}, []    for d, o, a, b in ir:        if o == '=' and isinstance(a, str):            copies[d] = a        else:            copies.pop(d, None)        a, b = copies.get(a, a), copies.get(b, b)        out.append((d, o, a, b))    return out  def cse(ir):    """Compute an expression once and reuse its result."""    seen, out = {}, []    for d, o, a, b in ir:        if o in ('+', '-', '*'):            key = (o, a, b)            if key in seen:                out.append((d, '=', seen[key], None))            else:                seen[key] = d                out.append((d, o, a, b))        else:            out.append((d, o, a, b))    return out  def dead_code_elimination(ir):    """Delete assignments whose result is never read."""    used, alive = set(), []    for d, o, a, b in reversed(ir):        if o == '=' and d not in used:            continue        alive.append((d, o, a, b))        if isinstance(a, str):            used.add(a)        if isinstance(b, str):            used.add(b)    return list(reversed(alive))  def loop_invariant_code_motion(body):    """Hoist statements whose operands are never defined in the loop."""    defs = {d for d, o, a, b in body if o != 'param'}    hoisted, kept = [], []    for ins in body:        d, o, a, b = ins        (hoisted if a not in defs and b not in defs else kept).append(ins)    return hoisted, kept  def dump(tag, ir):    print(tag)    for d, o, a, b in ir:        rhs = 'param' if o == 'param' else ('= ' + str(a) if b is None else o + ' ' + str(a) + ' ' + str(b))        print('  %-4s%s' % (d + ':', rhs))  def main():    ir = [        ('a', 'param', None, None), ('b', 'param', None, None),        ('m', '=', 3, None), ('n', '=', 4, None),        ('c', '*', 'a', 'b'), ('d', '=', 'c', None),        ('e', '+', 'd', 1), ('f', '*', 'a', 'b'),        ('g', '=', 99, None), ('h', '+', 'f', 'e'),        ('k', '*', 'm', 'n'), ('out', '+', 'h', 'k'),    ]    dump('=== original IR ===', ir)    ir = constant_propagation(ir); dump('--- after const-prop ---', ir)    ir = constant_folding(ir);     dump('--- after folding ---', ir)    ir = copy_propagation(ir);     dump('--- after copy-prop ---', ir)    ir = cse(ir);                  dump('--- after CSE ---', ir)    ir = dead_code_elimination(ir); dump('--- after DCE ---', ir)     loop = [('i', 'param', None, None), ('n', 'param', None, None),            ('t', '*', 'n', 4), ('r', '+', 't', 'i')]    hoisted, kept = loop_invariant_code_motion(loop)    dump('=== hoisted out of loop ===', hoisted)    dump('=== kept in loop ===', kept)  if __name__ == '__main__':    main()
目的:用极简的三地址码指令序列,演示常量传播、常量折叠、复写传播、公共子表达式消除、死代码消除与循环不变量外提六个优化 Pass 如何一环扣一环地“轻装行李”。
  1. a, b = vals.get(a, a), vals.get(b, b)查常量表,把能替换的引用换成已知常量值。
  2. if o == '=' and isinstance(a, int): vals[d] = a常量赋值入表,作为后续传播的依据。
  3. if isinstance(a, int) and isinstance(b, int):折叠的触发条件:操作数全是编译期常量。
  4. copies[d] = a登记复写关系,x 是 y 的“影子”。
  5. a, b = copies.get(a, a), copies.get(b, b)把读取 x 的地方替换成 y,完成复写传播。
  6. key = (o, a, b)用“运算 + 操作数”作为表达式签名,识别公共子表达式。
  7. if key in seen: out.append((d, '=', seen[key], None))命中签名则复用第一次的结果。
  8. if o == '=' and d not in used: continue反向扫描时,结果没人读的赋值直接丢弃。

常见误区

误区听说编译器优化很“聪明”,会不会把程序逻辑改坏?我总觉得开 -O2 心里不踏实,宁可关掉优化,反正功能对就行。

正解语义保持是优化的铁律,就像旅行规划师承诺“行程一个字不改”。优化器能被允许改变行为的前提,是源代码本身触发了未定义行为(如整数溢出、悬垂指针)——那是程序员的锅,不是优化的锅。浮点运算的“结果顺序不同”属于精度范畴,通常由单独的运算模型控制,与乱改逻辑是两回事。

为何常见:网上大量“优化导致 bug”的帖子,绝大多数最终定位到代码自身存在未定义行为,初学者容易把因果记到优化器头上。

误区我看到代码里有个变量声明后一直没用到,手动删掉,这应该就是编译器做的“死代码消除”吧?

正解源码层的“删没用的声明”只是清理表面垃圾。编译器里的 DCE 是在 IR 上做数据流分析:它根据活跃变量信息,从后往前扫描,删除“执行了但结果从不被读取”的赋值,还会连锁删除由此变死的指令。它不关心变量名好不好看,只关心“有没有人读结果”。

为何常见:初学者从“垃圾代码”的直觉出发理解优化,容易忽略背后的活跃变量分析才是 DCE 的灵魂。

误区公共子表达式消除?不就是看到两行 `a = x * y` 和 `b = x * y`,删掉一行,让 b 用 a 吗?

正解关键在于“两次计算之间 x、y 有没有被改写”。如果中间插入了 x = 5,第二次的 x * y 就不再等于第一次的结果,删不得。真正安全的 CSE 需要可用表达式分析做背书,并且通常引入临时变量保存中间结果;涉及内存读写的表达式还要考虑访存副作用,远非“删一行”那么简单。

为何常见:源码层面的“肉眼重构”经验,和编译器在控制流图上做全局安全分析,是两种不同量级的工程。

误区编译器不开到最高优化等级就是浪费,无脑 `-O3`,反正优化越多越快。

正解强度削弱、循环展开等激进优化会增加代码体积与寄存器压力,可能引发指令缓存失效、寄存器溢出,反而变慢。实践上 -O3 相比 -O2 的收益通常有限,甚至为负。正确姿势是用基准测试(benchmark)实测,让“体重秤”说话,而不是拍脑袋。

为何常见:把“优化”想成免费的提速按钮,忽略了代价模型与硬件特性的复杂交互。

误区循环里只要有一条语句的值看起来不变,比如 `x = n * 4`,直接把它提到循环前面不就行了?

正解外提必须满足三条前置条件:语句在循环内所有路径上都执行、循环内不改写其操作数、移出后不改变循环出口处的语义。若语句本身依赖某次循环迭代的条件才执行,贸然外提会改变执行时机,甚至改变程序结果。规划师把公交卡放外层口袋的前提是“每天都要用”,可不是随便什么东西都往外掏。

为何常见:只盯着收益公式 C×(N−1) 心动,忽略了安全性分析才是优化的第一课。

面试题

Q1. 编译优化的根本原则是什么?

单选题 · easy

  • A.让代码行数变少
  • B.保持语义不变的前提下提升效率← 正确答案
  • C.让源码更好看
  • D.减少程序员的工作量

答案:B。优化的一切动作都以语义保持为铁律,相当于旅行规划师“只动行李箱、不改行程单”。行数变少只是可能出现的副产物,不是目标本身。

Q2. 常量折叠适合处理以下哪种情况?

单选题 · easy

  • A.操作数均为编译期常量的表达式← 正确答案
  • B.循环条件不变的分支
  • C.函数参数每次调用都不同
  • D.复写语句 x = y

答案:A。折叠要求所有操作数都是编译期已知的常量,例如 3×4+2 直接算出 14。函数参数运行时才确定,无法折叠;复写语句走的是复写传播。

Q3. 在 LLVM 中,一个具体的优化步骤通常被封装为什么?

单选题 · easy

  • A.Pass← 正确答案
  • B.Loop
  • C.Block
  • D.Type

答案:A。LLVM 把常量折叠、DCE、LICM 等每个优化动作封装成独立的 Pass,由 opt 工具按顺序调度,就像规划师清单上的一个个检查项。

Q4. 关于死代码消除,下列说法正确的是?

单选题 · mid

  • A.只删除注释和无用的 import
  • B.删除结果从不被读取的赋值语句← 正确答案
  • C.必须依赖运行时探测才能完成
  • D.与活跃变量分析没有关系

答案:B。DCE 的核心是活跃变量分析:从后往前扫描,赋值结果在后续从未被读取即为死代码。它是编译期的静态分析,不需要运行时探测。

Q5. 循环不变量外提的安全前置条件包括哪些?

多选题 · mid

  • A.语句在循环内所有路径上都会执行← 正确答案
  • B.循环内没有其他语句改写它引用的操作数← 正确答案
  • C.循环迭代次数必须是素数
  • D.移出后不影响循环出口处的语义← 正确答案

答案:ABD。外提相当于把公交卡放到外套口袋——前提是“每天都要用”(所有路径执行)、“不会被调包”(操作数不被改写)、“出门顺序不变”(出口语义一致)。迭代次数与素数无关。

Q6. 公共子表达式消除成立的前提是两次计算之间?

单选题 · mid

  • A.操作数没有被改写← 正确答案
  • B.操作数必须是常量
  • C.两次计算必须跨函数
  • D.代码行数必须小于 10

答案:A。CSE 要求两次相同表达式之间,其操作数未被改写,这样第一次结果仍然有效。操作数是变量完全可以,只要它们的值没变;跨函数与行数都不是条件。

Q7. 复写传播能为其他优化带来哪些间接收益?

多选题 · mid

  • A.提升源代码的可读性
  • B.使中间赋值语句变成死代码← 正确答案
  • C.为公共子表达式消除创造条件← 正确答案
  • D.把常量变成变量

答案:BC。复写传播把 x 的引用换成 y 后,x = y 本身没人读了,DCE 可以删掉它;表达式被统一引用后也更可能被 CSE 识别。它属于“拆桥型”优化,收益往往要借助队友兑现。

Q8. 强度削弱中,循环内 j = 4 * i(i 每轮 +1)通常被改写成什么?

单选题 · hard

  • A.j = j + 4← 正确答案
  • B.j = i + 4
  • C.j = 4 * 4
  • D.j = j * 4

答案:A。i 每轮固定 +1,j 的增量就固定为 4,循环体内用加法累加即可,乘法只保留在循环前的初始化。这就是“实体书换 Kindle”的典型操作。

Q9. 下列哪些优化依赖数据流分析?

多选题 · hard

  • A.活跃变量分析指导死代码消除← 正确答案
  • B.可用表达式分析指导公共子表达式消除← 正确答案
  • C.常量传播沿着路径传递常量值← 正确答案
  • D.删除代码中的注释

答案:ABC。这三者都沿着控制流图追踪“信息流”——变量活不活跃、表达式可不可用、常量值到不到达,全是数据流分析的标准问题。删注释只是文本处理,与数据流无关。

Q10. 加速比 S = T_before / T_after 的含义是什么?

单选题 · mid

  • A.S > 1 表示优化带来正向收益← 正确答案
  • B.S = 0 表示程序达到最优
  • C.S < 1 表示程序被加速
  • D.S 越大说明代码越短

答案:A。加速比是优化前后耗时的比值,大于 1 说明优化后更快。S 衡量的是时间收益而非代码体积,S < 1 反而意味着优化拖慢了程序。