王师傅的装修队接了一单老房子改造。业主是位程序员,他提了个奇怪要求:房子里很多部分完全是多余的——比如客厅里那堵二米宽的隔断墙,砌死了,人根本绕不过去;还有几根水管,接进来以后没接任何水龙头,一开闸只会白流。业主心疼水电费,想把这些“没用的部分”拆掉,让房子又轻又快。王师傅没急着抡大锤。他先画户型图,再拿图纸从大门出发,顺着走廊一间一间验证:哪几个房间根本走不进去?然后检查管线:哪根水管通了水也没人用?最后才动手拆。这套流程,跟编译器里的“死代码消除”(Dead Code Elimination,DCE)一模一样。程序里那些永远执行不到的代码,就像走不进去的房间;计算结果从来没人读取的赋值,就像没接水龙头的水管。编译器会在生成机器码之前,把它们精准识别、安全拆除,只付出“分析”的时间,换来更小的程序和更快的运行。在这篇文章里,王师傅就是编译器,户型图叫控制流图,验房间叫可达性分析,查水管叫活跃变量分析。

岗位关联

掌握死代码消除原理,你就掌握了编译器后端优化和静态分析工具的核心思维——无论将来写编译器、做代码质量平台,还是参与 AI 模型推理优化,都能理解“让程序更小更快”的底层逻辑,并亲手实现一套可运行的版本。

目录
  1. 可视化演示
  2. 死代码概述
  3. 控制流图构建
  4. 可达性分析
  5. 活跃变量分析
  6. DCE算法流程
  7. LLVM实现
  8. 边界与局限
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态演示编译器死代码消除优化原理的核心机制,通过基础/核心/应用/扩展四种模式展示关键概念与交互流程。

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

编译器死代码消除优化原理:死代码的 死代码的定义与分类体系 控制流图 CFG 的构建与基本 核心机制可视化 编译器死代码消除优化原理整体架构与关键组件

死代码概述

装修队进场第一件事,是搞清楚“哪些东西才算废料”。王师傅带着工人转了一圈,把废料分成两类:一类是砌死了的隔断墙——里面是实心,前后左右都过不去人,属于“永远不会被走到”的空间;另一类是废弃的旧水管——水确实能流进去,但出口没接任何水龙头,属于“流了也白流”的管线。死代码正好也分这两类。

编译器把“永远执行不到的代码”叫作不可达代码(unreachable code),把“执行了但结果从未被使用”的代码叫作死代码(dead code)。前者像走不进去的房间,后者像不接水龙头的管子,两者统称死代码(Dead Code)。它们多半来自历史遗留:产品迭代后旧的逻辑分支被 if (false) 或恒定的开关变量“焊死”,但代码块还留在原地;宏与模板展开会生成大量冗余指令;自动代码生成器按模板输出时,也常多打出几条永远不会执行的指令。

消除死代码的收益是实打实的:程序体积变小、加载更快、指令缓存命中率提高,后续优化面对的中间代码也更干净。但这一切的前提是语义不变——拆废料绝不能碰承重结构。用数学语言表达,就是下面这个等价关系:

所以王师傅在开工前给业主写保证书:我拆的每一面墙,都不会改变你生活的动线——你开门、开窗、开灯,感受到的一切和拆之前一模一样。只是房子更轻、跑得更快。

P≡P′  ⟺  ∀I:  Obs(P,I)=Obs(P′,I)P \equiv P' \iff \forall I:\; \mathrm{Obs}(P, I) = \mathrm{Obs}(P', I)
公式通俗说明:原程序 P 和消除死代码后的 P' 等价,当且仅当对任何输入 I,两者表现出的可观察行为(输出、文件读写、网络请求等)完全一致。
  1. 符号 P 与 P'P 是优化前的原始程序,P' 是消除死代码后的程序,它们就像同一栋楼的装修前与装修后。
    P  ≡  P′P \;\equiv\; P'
  2. 全称量词 ∀I对所有合法输入都要检验,一个都不能放过,如同抽查任何一个房间都不能塌。
    ∀I\forall I
  3. 可观察行为 Obs程序对外部世界的全部影响,包括标准输出、文件写入、系统调用等,这些是“外部世界能感知的动静”。
    Obs(P,I)=Obs(P′,I)\mathrm{Obs}(P, I) = \mathrm{Obs}(P', I)

控制流图构建

要拆墙,先得有一张准确的户型图。王师傅把每一条走廊、每一个房间都画出来,门与门之间的通路标清楚,这张图就是整栋房子的“结构骨架”。

编译器在分析一个函数前,会先把中间代码(IR)组织成一张控制流图(Control Flow Graph,CFG)。节点是一个个基本块(basic block),边表示控制权从一个块流向另一个块。基本块是“一条指令进、一条指令出”的直线代码片段:块内不能有跳转指令,也不能有跳转指令从半路进入。划分基本块分两步:第一步找领导者(leader)——函数第一条指令、任何跳转目标、紧跟在跳转指令后面的那条指令;第二步从每个领导者开始向后延伸,直到下一个领导者之前,就是一个基本块。

块与块之间由跳转边连接:条件跳转的两个分支、无条件跳转的直接目标。整个函数还有一个唯一的入口块(entry),它在 CFG 上地位特殊,是所有可达性分析的起点。

户型图一画好,王师傅就能按图索骥,不用再满屋子乱窜。接下来要做的,就是拿着这张图从大门出发,看看每一间房到底能不能走进去——这正是可达性分析。

CFG=(V,E,entry),V={Block1,…,Blockk},E⊆V×V\mathrm{CFG} = (V, E, \mathit{entry}), \quad V = \{ \mathrm{Block}_1, \dots, \mathrm{Block}_k \}, \quad E \subseteq V \times V
公式通俗说明:控制流图就是一个三元组——房间集合 V、门集合 E 和唯一大门 entry。存在一条边 (u, v) 表示执行完块 u 的末尾指令后,控制流可以直接进入块 v。
  1. 节点集合 V每个基本块是一个节点,也就是户型图上的一间房间,内部是指令的直线序列。
    V={Block1,…,Blockk}V = \{ \mathrm{Block}_1, \dots, \mathrm{Block}_k \}
  2. 边集合 EE 是有序对的集合,一条边 (u, v) 就是一扇“门”:从块 u 的末尾能直接走到块 v 的开头。
    E⊆V×VE \subseteq V \times V
  3. 入口 entry函数唯一的入口块,等于户型图上的大门,一切分析从这里出发。
    entry∈V\mathit{entry} \in V

可达性分析

王师傅拿着户型图,从大门出发,沿着走廊一间一间推门验证:能推开门的房间记一个“可达”的勾;推不开、绕不过、图纸上标着却根本接不通的房间,就画上一个叉。这些画叉的房间,就是“建筑死区”。

编译器里的可达性分析就是这套动作:从 entry 出发,沿着 CFG 的边进行图遍历(BFS 或 DFS),凡是能到达的基本块都打上“可达”标记。分析结束时仍未被标记的块,就是不可达代码——它们在任何输入下都不可能执行。删除阶段十分干脆:不可达的块整个移除,连同块内所有指令和连向它的边。注意,不可达块里的指令哪怕“副作用很大”,也不必担心——它们根本不会执行,删掉它们不改变任何可观察行为。

用数据流方程描述,令 Reach(n) 表示块 n 是否可达,那么入口块天然可达,其他块只要存在一个可达的前驱,它就可达。这个方程是单调的,从“只有入口可达”的初始状态开始逐轮扩散,有限轮后必然收敛。

画叉的房间直接封门拆除。可王师傅没急着走,他还要再查一次水管——有些房间能进,里面的水管却早已成了摆设。

Reach(n)=(n=entry)∨(⋁p∈Pred(n)Reach(p))\mathrm{Reach}(n) = (n = \mathit{entry}) \lor \left( \bigvee_{p \in \mathrm{Pred}(n)} \mathrm{Reach}(p) \right)
公式通俗说明:一个房间可达,要么它本身就是大门,要么它的前驱房间(能走到它的那间房)至少有一间可达。这个判断从大门开始一层层往外推,直到整张图都标记完。
  1. 入口条件入口块恒为可达,它是整张图的源头,就像大门永远能进。
    n=entryn = \mathit{entry}
  2. 前驱集合 Pred(n)所有能通过一条边直接到达 n 的块,相当于“通向这间房的全部走廊入口”。
    p∈Pred(n)p \in \mathrm{Pred}(n)
  3. 存在量词 ∨只要有一个前驱可达,n 就可达;所有前驱都不可达时,n 就是死路。
    ⋁p∈Pred(n)Reach(p)\bigvee_{p \in \mathrm{Pred}(n)} \mathrm{Reach}(p)
  4. 迭代收敛每轮扩散只会让可达集合变大,且节点数有限,所以若干轮后标记不再变化,达到不动点。
    Reachk+1=Reachk\mathrm{Reach}^{k+1} = \mathrm{Reach}^{k}

活跃变量分析

有些房间能走进去,但里面藏着一根废弃水管:水闸一开,水确实流进管子,可管子另一端没有接任何水龙头或马桶,流了也是白流,还平白增加水压负担。王师傅检查的方法,是反向沿着管道找“水龙头”:从每个出水口回溯,哪根管子在某个地方被接上了,就算“活的”;始终接不上任何出水口的,就是死管。

编译器把“给变量赋值”看作向管道注水,把“读取变量”看作接水龙头。一条赋值指令 x = ... 是“死”的,当且仅当 x 的新值在之后的任意执行路径上都没有被读取,或者在被读取之前又被重新赋值。为了判断这一点,编译器要做活跃变量分析(live variable analysis):从函数的出口出发,反向遍历 CFG,计算每个程序点上的活跃变量集合。用 Use[n] 表示基本块 n 内“先读后写”的变量(真使用),Def[n] 表示块内被赋值的变量,数据流方程如下:

死管被拆掉后,水压变小、水费变少。编译器同理:删掉那些“注了水但没人用”的赋值,运行时就不必再做无用功。

LiveIn[n]=Use[n]∪(LiveOut[n]∖Def[n])\mathrm{LiveIn}[n] = \mathrm{Use}[n] \cup (\mathrm{LiveOut}[n] \setminus \mathrm{Def}[n])
LiveOut[n]=⋃s∈Succ(n)LiveIn[s]\mathrm{LiveOut}[n] = \bigcup_{s \in \mathrm{Succ}(n)} \mathrm{LiveIn}[s]
公式通俗说明:进入一个房间时的“活水管” = 房间里直接接上的水龙头 + 流出房间还活着、且没在这个房间里被截断重接的水管。而流出房间的活水管,是所有下游房间“进入时活水管”的总和。
  1. 真使用 Use[n]块内先读取后赋值的变量,相当于本房间里已接好的水龙头。
    Use[n]\mathrm{Use}[n]
  2. 定值 Def[n]块内被赋值的变量,相当于房间里被截断改接的管道。
    Def[n]\mathrm{Def}[n]
  3. 差集 LiveOut[n] ∖ Def[n]从上游流来的活变量,如果在本块被重新赋值,旧值作废,不再向上游传播。
    LiveOut[n]∖Def[n]\mathrm{LiveOut}[n] \setminus \mathrm{Def}[n]
  4. 并集 LiveIn[n]本块需求与下游需求的叠加,两者任一需要,该变量在入口处就是活的。
    LiveIn[n]=Use[n]∪(… )\mathrm{LiveIn}[n] = \mathrm{Use}[n] \cup (\dots)
  5. 后继并集 LiveOut[n]多出口时,只要有一条路径需要这个变量,它就算活;所有后继都不需要,它才是死的。
    LiveOut[n]=⋃s∈Succ(n)LiveIn[s]\mathrm{LiveOut}[n] = \bigcup_{s \in \mathrm{Succ}(n)} \mathrm{LiveIn}[s]

DCE算法流程

王师傅和工人开会定了一套标准流程:第一步出图,第二步探路,第三步查管道,最后统一拆除、绝不边看边敲。装修队的这套流程,对应编译器里 DCE 的完整算法管线。

一个标准的死代码消除算法分三个阶段:第一,构建 CFG(出图);第二,标记——用可达性分析找出不可达块,用活跃变量分析找出死赋值;第三,扫描删除——遍历所有指令,凡是“不可达”或“结果没人用且无副作用”的,一律从 IR 中摘除。删除是有连锁反应的:删掉一条死赋值指令后,生成它操作数的指令可能失去唯一的读者,也变成死代码。所以 DCE 需要把“标记—删除”迭代进行,直到某一轮没有任何一条指令被删除,即达到不动点。

每条指令是否可删,可以浓缩成一个判据:指令不可达,或指令的结果集合为空集(没有人读取它的结果)并且指令本身没有副作用。其中副作用包括写内存、调用外部函数、读取 volatile 变量等——这些行为可能被外部世界观察,即使结果没人用也不能删。由于指令总数有限、每轮至少删除一条,算法一定终止。

王师傅的规矩是“今天拆完明天复查,直到一整轮没拆下任何一块砖,才宣告验收”。DCE 也一样——删除会引发新的删除,直到图里再也找不到一根废管、一面死墙。

Dead(i)=¬Reach(i)  ∨  (UseEmpty(res(i))∧¬SideEffect(i))\mathrm{Dead}(i) = \lnot \mathrm{Reach}(i) \;\lor\; \big( \mathrm{UseEmpty}(\mathrm{res}(i)) \land \lnot \mathrm{SideEffect}(i) \big)
Deadk+1=Deadk  ⇒  stop\mathrm{Dead}^{k+1} = \mathrm{Dead}^k \;\Rightarrow\; \text{stop}
公式通俗说明:一条指令该不该拆,看两条:一是它的“房间”根本没人能走进来;二是它产生的东西没人接(结果无人使用),而且它本身不制造任何可感知的动静(无副作用)。满足其一就拆。拆到一轮内什么都没拆,收工。
  1. 不可达判据指令所在基本块不可达,整块删除,指令本身无需再分析。
    ¬Reach(i)\lnot \mathrm{Reach}(i)
  2. 结果无人使用指令有返回值,但 use-def 链上没有任何读者,即 UseEmpty。
    UseEmpty(res(i))\mathrm{UseEmpty}(\mathrm{res}(i))
  3. 无副作用指令不写内存、不调用函数、不读 volatile,删除它对外部世界不可见。
    ¬SideEffect(i)\lnot \mathrm{SideEffect}(i)
  4. 删除闭包删除一条指令可能使其他指令失去读者,需要迭代;达到不动点就终止。
    Deadk+1=Deadk\mathrm{Dead}^{k+1} = \mathrm{Dead}^k

LLVM实现

王师傅背后是一家大装修公司,公司把多年经验沉淀成《标准作业手册》。手册里写着“隔断墙拆除作业程序”的每一步:先确认非承重,再断水断电,最后切割清运。LLVM 就是这样一家“装修公司”,DCE Pass 就是手册里的拆墙程序。

LLVM 以 SSA(静态单赋值)形式的中间表示为基础,死代码判断被简化成一句口诀:一条指令是否是“平凡死的”(trivially dead),看三点——它有返回值(非 void)、它的结果没有任何 use、它没有副作用。三者同时成立,就立即删除,对应 isInstructionTriviallyDead 检查函数。LLVM 提供了两层 DCE 实现:DCEPass 是指令级死代码消除,逐条检查上面三个条件;ADCEPass(Aggressive DCE)更激进,额外利用控制流信息做“控制与数据流混合”分析,能删除“看起来可达但纯粹用于计算无用值”的更大区域。

在优化管线中,DCE 常以小 Pass 的身份反复出现:-O2 级别下,它与其他优化(如 GVN、SROA、InstCombine)交替执行——每当别的优化腾出“没人用的结果”,DCE 就立刻清场。这就是工业界与教科书做法的差异:不是最后做一次大扫除,而是全程随手收拾,配合 IR 的 eraseFromParent() 完成物理移除。

手册写好了,工人们照章办事,每家分店(每个编译任务)都能稳定复现同样的拆除质量。这就是工业级优化器与教学玩具的最大区别——把聪明算法变成可重复、可维护、不闯祸的工程流程。

isTriviallyDead(I):=  type(I)≠void  ∧  use_empty(I)  ∧  ¬hasSideEffects(I)\mathrm{isTriviallyDead}(I) :=\; \mathrm{type}(I) \neq \mathit{void} \;\land\; \mathrm{use\_empty}(I) \;\land\; \lnot \mathrm{hasSideEffects}(I)
公式通俗说明:LLVM 判断一条指令“平凡地死”:它产出了一个结果、但从没人用这个结果、而且它自己不产生副作用。三条同时满足,就直接从 IR 里移除。
  1. 返回类型非 void没有返回值的指令像“只管把动静搞出来”的施工,无法从“结果没人用”的角度判定,先排除。
    type(I)≠void\mathrm{type}(I) \neq \mathit{void}
  2. use_empty(I)SSA 形式下每个结果的使用者都被显式记录,检查该值的 use 链表是否为空,是 O(1) 操作。
    use_empty(I)\mathrm{use\_empty}(I)
  3. 无副作用排除函数调用、内存写、volatile 读写等外部可见行为,保证删除安全。
    ¬hasSideEffects(I)\lnot \mathrm{hasSideEffects}(I)

边界与局限

老王经验再丰富,也有不敢拆的地方:承重墙砸了会塌楼,公共管井动了整栋楼停水。装修师傅干的活,始终是在“安全”和“彻底”之间取平衡。死代码消除也一样,有它不可逾越的红线。

第一个红线是副作用。一条指令哪怕计算结果没人用,只要它会写全局变量、调用 printf、读取 volatile,就不能删——外部世界可能观察到这些动静。第二个红线是“精确判断死代码”本身不可判定:如果能在有限时间内精确判断任意一段代码是不是死代码,就可以用它解决停机问题。这由 Rice 定理保证——任何关于程序语义的非平凡性质都不可判定。因此,编译器只做保守的、可判定的近似:只删除那些“铁证如山”是死的代码。

保守近似的意义在于正确性优先:宁可漏删,不可错删。用符号表示,保守 DCE 删掉的是一个集合,它必须是精确死代码集合的子集——我们砍掉的每块砖,都确认不是承重墙。实际工程中还涉及优化时机:DCE 太早,后续优化又会生成新的死代码;太晚,中间代码膨胀会拖慢编译本身。所以工业编译器选择“小步快跑”,把它分散在优化管线中反复执行。

王师傅收工前,在验收单上写了一句:“拆除的部分,保证都不是承重结构。”编译器工程师在优化器里写下同样的承诺:我们删掉的每一行,都是铁证如山的多余;而真正的完美删除,留给理论家们继续仰望。

DCEcons(P)  ⊆  DCEexact(P)  ⊂  P,DCEexact  ≡  HALT⇒不可判定\mathrm{DCE}_{cons}(P) \;\subseteq\; \mathrm{DCE}_{exact}(P) \;\subset\; P, \qquad \mathrm{DCE}_{exact} \;\equiv\; \mathrm{HALT} \Rightarrow \text{不可判定}
公式通俗说明:我们删除的每一个“死代码”,都必须是真正彻底死了的(保守子集);而想在数学上精确判定“全部”死代码,等于解决停机问题,做不到。所以聪明的装修队从不追求“拆掉所有可能的废料”,只保证“拆掉的每一块都安全”。
  1. 保守子集实际删除集合是精确死代码集合的子集,宁漏勿错。
    DCEcons(P)⊆DCEexact(P)\mathrm{DCE}_{cons}(P) \subseteq \mathrm{DCE}_{exact}(P)
  2. 精确集合理论上“所有可删代码”的完全集合,包含所有语义上等价但体积更小的可能性。
    DCEexact(P)\mathrm{DCE}_{exact}(P)
  3. 不可判定归约若精确 DCE 可判定,就能构造算法解决停机问题;由 Rice 定理,矛盾。
    DCEexact≡HALT\mathrm{DCE}_{exact} \equiv \mathrm{HALT}
  4. 工程取舍在正确性红线内,用可判定的保守算法反复清扫,换取稳定的收益。
    DCEcons  可判定且安全\mathrm{DCE}_{cons} \; \text{可判定且安全}

AI 应用场景

编译器后端优化工程师

阶段:AI 训练与推理框架中的内核代码生成与编译优化

解决的问题:AI 编译器(如 TVM、XLA、MLIR)在自动生成内核代码时,模板展开会产生大量不被任何算子使用的中间计算和永远不会被执行的分支。手写代码靠人肉排查不现实,DCE 配合可达性分析,将自动生成的 IR 中无用计算全部清除,显著降低内核编译时间和运行开销。

具体案例:某推理引擎导出 ONNX 模型后,发现部分子图因条件恒假从未被激活,但权重和算子仍留在模型里。使用 MLIR 的 DCE Pass 结合常量折叠,删除 30% 的死算子,推理延迟下降约 12%,模型体积从 480MB 减到 340MB。

静态代码分析与代码质量平台

阶段:数据处理与特征工程之前的基础代码库健康分析

解决的问题:大型软件仓库中,历史遗留的死代码每年带来数万行无效维护成本。静态分析工具用类似 DCE 的算法——先构建调用图和控制流图,再做可达性分析——自动报告“不可达分支”和“结果未使用”的代码,辅助代码评审。

具体案例:某电商团队用自研静态分析工具对 2000 万行 Java 代码扫描,基于 CFG 可达性分析自动标记不可达分支与死赋值,两周内定位 1.2 万处疑似死代码,人工抽查后确认率为 83%,为后续重构节省约 150 人日。

AI 模型压缩与计算图优化

阶段:模型优化与部署压缩

解决的问题:模型剪枝后,被剪掉的通道对应算子往往仍留在计算图中。把 DCE 思想用于计算图优化:从输入到输出的数据流分析中,删除输出永远不会被消费的算子;从输入可达性角度删除不可能被触发控制流分支。

具体案例:某自动驾驶团队的检测模型经过通道剪枝后,计算图中残留约 18% 的“死算子”(输出无人消费或分支恒不满足)。利用 TensorRT 的图优化(内部含 DCE 逻辑)清理后,端到端推理耗时降低 8%,显存占用减少 15%。

Python 演示

python84 行
def live_variable_analysis(blocks):    """活跃变量分析:从出口反向迭代到入口,返回每个块的 LiveIn / LiveOut。"""    n = len(blocks)    live_in = [set() for _ in range(n)]    live_out = [set() for _ in range(n)]    changed = True    while changed:        changed = False        for i in range(n - 1, -1, -1):            new_out = set()            for s in blocks[i]['succ']:                new_out |= live_in[s]            new_in = blocks[i]['use'] | (new_out - blocks[i]['def'])            if new_in != live_in[i] or new_out != live_out[i]:                live_in[i] = new_in                live_out[i] = new_out                changed = True    return live_in, live_out  def remove_dead_assignments(blocks, live_in, live_out):    """反向扫描各块,删除结果不在活跃集合中的赋值指令。"""    for i, block in enumerate(blocks):        alive = live_out[i]        kept = []        for instr in reversed(block['instrs']):            dest, srcs, kind = instr            if kind == 'assign':                if dest not in alive:                    continue                alive = (alive - {dest}) | set(srcs)            kept.append(instr)        block['instrs'] = list(reversed(kept))  def eliminate_unreachable(blocks):    """可达性分析:只保留从入口可到达的基本块。"""    reachable = {0}    worklist = [0]    while worklist:        i = worklist.pop()        for s in blocks[i]['succ']:            if s not in reachable:                reachable.add(s)                worklist.append(s)    return [b for i, b in enumerate(blocks) if i in reachable]  def run_dce(blocks):    blocks = eliminate_unreachable(blocks)    live_in, live_out = live_variable_analysis(blocks)    remove_dead_assignments(blocks, live_in, live_out)    return blocks  if __name__ == '__main__':    # 块0:b = a 结果没人用,是死赋值;x = a 被 y = x + 1 使用,保留    blocks = [        {            'instrs': [                ('a', [], 'arg'),                ('b', ['a'], 'assign'),                ('x', ['a'], 'assign'),                ('y', ['x'], 'add'),            ],            'succ': [1],            'use': {'a'},            'def': {'b', 'x', 'y'},        },        {            'instrs': [                ('z', ['y'], 'assign'),            ],            'succ': [],            'use': {'y'},            'def': {'z'},        },    ]    cleaned = run_dce(blocks)    print('DCE 之后剩余指令:')    for i, b in enumerate(cleaned):        for dest, srcs, kind in b['instrs']:            text = ' '.join(srcs) if srcs else '<none>'            print(f'  block{i}: {dest} = {text} [{kind}]')
目的:用纯 Python 实现了一个迷你死代码消除器——先做可达性分析删除不可达块,再做活跃变量分析反向删除死赋值,演示 DCE 的核心三阶段。
  1. new_out |= live_in[s]将每个后继块的入口活跃集合做并集,得到流出当前块时的活跃变量,对应“下游任一房间要用水,这根管子就是活的”。
  2. new_in = blocks[i]['use'] | (new_out - blocks[i]['def'])完整实现活跃变量方程:本块真使用 + 下游需要但未被本块重定义的部分。
  3. if dest not in alive: continue活变量集合里没有这个目标变量,说明赋值结果无人读取,这就是死赋值,直接跳过不保留。
  4. alive = (alive - {dest}) | set(srcs)反向传播:保留这条赋值后,把它的目标从需求中移除,并把它的操作数加入需求,继续向更早的指令传播。

常见误区

误区我以为只要一个变量赋值后没被读取,编译器就一定会把它删掉。可有时候编译器却没删,是不是编译器太笨?比如我觉得某个中间变量根本没人用,结果编译出来的二进制里还在,气死我了。

正解编译器要“证明”这条赋值在所有路径上都没人读才敢删。如果存在一条路径后来读取了该变量,或者指令带副作用,编译器会保守保留。就像水管的出口可能在你看不见的隔墙后面,拆之前必须确认。只有活跃变量分析证明“出口集合为空”时才会删。

为何常见:人脑做的是“印象流”判断,编译器做的是全路径形式化证明,初学者容易拿直觉替代数据流分析。

误区反正那段代码永远不会执行,里面的 printf 啊、写文件啊,全都删掉不就完了!我上次把一段“永远不会运行”的函数体整个删了,结果程序行为变了,很奇怪。

正解不可达块确实可以整体删除,因为不可达意味着它在任何输入下都不会执行——这是可达性分析给出的强结论,副作用无关紧要。真正需要小心的是“可达但结果没人用”的指令:如果它有副作用(volatile 读取、函数调用),即使结果没用也不能删。两类死代码的删除依据完全不同,删之前先搞清楚它是“走不到”还是“没结果”。

为何常见:把“不可达”和“死赋值”两个概念混为一谈,误以为它们共享同一条删除判据。

误区我写完 DCE,从头扫一遍把所有死代码删掉,就完成任务了。但后来测试时发现生成的代码里还是有死代码,是不是我的算法有 bug?

正解删除一条指令会让它的操作数来源指令失去读者,变成新的死代码。所以 DCE 必须迭代到不动点:每一轮删除后重新分析,直到某轮没有任何删除。同时,后续其他优化(如常量传播、内联)也会产生新死代码,所以工业编译器把 DCE 散布在整个优化管线中反复执行,而不是只做一次大扫除。

为何常见:忽略了删除的连锁反应,把静态图分析误当成一次性操作。

误区DCE 不是号称死代码消除吗?为什么运行库里有些我觉得没用的函数还在?我明明看到它没有被调用,编译器为什么不删?

正解精确的死代码判定等价于解决停机问题,不可判定;编译器只做保守近似——只删能高效证明的死代码。函数入口、导出符号、可能有反射调用的代码,编译器无法证明其无用,都会保留。这不是 DCE 不行,而是“精确删完”在数学上不可能,任何编译器都要在正确性和彻底性之间做取舍。

为何常见:把工程上可判定的近似算法,当成理论上全知全能的完美删除。

面试题

Q1. 死代码的两种典型类型是什么?

单选题 · easy

  • A.语法错误和类型错误
  • B.不可达代码和结果未被使用的赋值← 正确答案
  • C.注释和空行
  • D.全局变量和局部变量

答案:B。死代码分为两类:永远执行不到的代码(不可达代码)和执行了但结果从未被使用的赋值(死赋值),分别对应文章里的“走不进的房间”和“没接水龙头的水管”。语法错误、注释空行都不是编译器优化语境下的死代码。局部变量本身不是死代码,只有它的某个赋值结果没人用才是。

Q2. 划分基本块时,下列哪项不是“领导者”(leader)?

单选题 · easy

  • A.函数的第一条指令
  • B.任何跳转指令的目标指令
  • C.紧跟在跳转指令之后的指令
  • D.循环体内的最后一条指令← 正确答案

答案:D。领导者的定义有三条:函数第一条指令、跳转目标、跳转指令的下一条(fall-through)。它们都是控制流“走进房间的门口”。循环体内的最后一条指令不一定是控制流入口,它可能只是普通指令,不能作为切分基本块的依据。

Q3. 可达性分析的起点是什么?

单选题 · easy

  • A.任意选择一个基本块
  • B.入口基本块 entry← 正确答案
  • C.出口基本块
  • D.循环最深处的块

答案:B。可达性分析从唯一入口 entry 出发,沿 CFG 的边向外扩散,就像王师傅从大门出发一间一间验证。只有入口块是“天然可达”的,从中间任意块出发会漏标甚至误判。所有不可达分析都必须以 entry 为源头。

Q4. 活跃变量分析的数据流传播方向是?

单选题 · mid

  • A.从入口到出口(前向)
  • B.从出口到入口(后向)← 正确答案
  • C.任意方向都可以
  • D.只沿循环边传播

答案:B。活跃性问的是“某个变量的值在之后还会不会被读”,属于未来信息,所以要从函数出口反向算回入口,正如王师傅从出水口回溯找水龙头。而可达性分析问的是“之前能不能走到”,才是前向的。两者方向正好相反。

Q5. 删除一条“结果没人用”的指令时,最需要顾虑的是?

单选题 · mid

  • A.指令名太长影响可读性
  • B.指令可能存在副作用← 正确答案
  • C.变量名与全局变量冲突
  • D.算术运算可能溢出

答案:B。副作用是死代码消除的红线:写内存、函数调用、volatile 读取等行为可能被外部世界观察,即使结果没人用也不能删。这就像一根水管虽没接水龙头,但管壁上有个裂缝会漏水到楼下——拆不拆要慎重。算术溢出在安全语言语义下有明确定义,不属于删除障碍。

Q6. 以下哪些情况属于死代码?

多选题 · mid

  • A.从入口出发永远无法到达的基本块← 正确答案
  • B.赋值后结果在任意路径上都未被读取的指令← 正确答案
  • C.被 if (false) 恒假条件包裹的分支代码← 正确答案
  • D.从未被调用的虚函数(运行时多态)

答案:ABC。A 是不可达块,B 是死赋值,C 是常量折叠后暴露的不可达分支,三者都是 DCE 的标准目标。D 不行:虚函数可能通过运行时多态被间接调用,编译器在多数情况下无法证明它永远不会被调到,属于保守保留范围——像不能证明是废料就不敢拆的墙。

Q7. 为什么完全精确的死代码消除在数学上不可判定?

单选题 · hard

  • A.因为程序规模太大,算不完
  • B.因为它等价于停机问题,由 Rice 定理保证← 正确答案
  • C.因为 CPU 的运行速度有限
  • D.因为编译器厂商没把算法写好

答案:B。判断“任意一段代码在任意输入下是否永远不会执行”等价于判断停机问题,而 Rice 定理说明任何关于程序语义的非平凡性质都不可判定。这就像“精确找出房子里所有永远没人走的房间”——除非你能预测未来每一次搬家。编译器只能做保守近似,删掉铁证如山是死的部分。

Q8. 关于迭代式 DCE,下列说法正确的是?

多选题 · hard

  • A.删除一条死指令后,其操作数来源可能变成新的死代码← 正确答案
  • B.每轮必须从头重算所有数据流,无法做增量更新
  • C.迭代到某一轮没有删除任何指令时达到不动点← 正确答案
  • D.由于删除会触发新删除,迭代必然无限进行

答案:AC。删除有连锁反应,这是必须迭代的原因,A 对。实际实现常用 worklist 增量更新而不是每轮全量重算,B 错。指令总数有限、每轮至少删一条,最终必然在某轮没有删除,C 对、D 错。这就像王师傅“拆完复查,直到一整轮没拆下任何一块砖才收工”。

Q9. LLVM 中判断一条指令“平凡死”(trivially dead)的条件包括?

多选题 · hard

  • A.指令的返回类型不是 void← 正确答案
  • B.指令结果的 use 链表为空← 正确答案
  • C.指令没有副作用← 正确答案
  • D.指令位于循环体内

答案:ABC。平凡死判据是“非 void + use_empty + 无副作用”三项与逻辑。它假定指令可以产出一个“没人要的结果”,SSA 形式下 use 链表为空直接说明没有读者。是否在循环内与死代码无关,D 是干扰项。

Q10. DCE 优化要达到的最终目标是?

单选题 · easy

  • A.改变程序功能,让它跑得更快
  • B.保持可观察行为不变,同时减小体积、提升性能← 正确答案
  • C.让源码更容易阅读
  • D.减少编译器自身的编译时间

答案:B。DCE 是语义保持变换,铁律是所有可观察行为完全一致——正如王师傅的保证书“你感受到的一切和拆之前一模一样”。它只删除证明是死的代码,因此不能主动改变功能;源码可读性和编译时间不是 DCE 的优化目标。