「数据流分析框架」

数据流分析是优化器的公共底座。本文从格与偏序出发,讲解不动点迭代的理论基础,再用到达定值、活跃变量、可用表达式与常量传播四个经典问题贯穿始终,最后落到支配树、SSA 稀疏分析与 worklist 算法的工程实现。

1. 数据流分析在做什么

一句话总结: 数据流分析沿控制流图传播「程序点上成立的事实」,直到所有事实不再变化,为优化提供依据。

优化器需要回答一系列全局问题:某个变量在这一点上是否还被用到?某个表达式的值在这里是不是常量?这条赋值能不能被消除?这些问题的共同点是——答案取决于控制流可达的整片区域,而不是单条指令。数据流分析就是回答这类问题的统一框架:把「事实」建模成集合,把指令的语义建模成集合的传递函数,沿控制流图反复传播直到不动点。

# 数据流分析的统一骨架 (正向, 并集合并)
def solve_forward(cfg, transfer, gen, kill, init, all_facts):
    IN  = {n: set() for n in cfg}          # 块入口的事实
    OUT = {n: set() for n in cfg}          # 块出口的事实
    for n in cfg:
        OUT[n] = set(init)
    changed = True
    while changed:
        changed = False
        for n in cfg:
            merged = set()
            for p in preds(cfg, n):        # 汇合前驱
                merged |= OUT[p]
            IN[n] = merged
            new_out = transfer(gen[n], kill[n], IN[n], all_facts)
            if new_out != OUT[n]:
                OUT[n] = new_out
                changed = True
    return IN, OUT
def preds(cfg, n):
    return [p for p, succs in cfg.items() if n in succs]

四个要素决定了一个数据流问题:方向(前向/后向)、合并方式(并集/交集)、传递函数(gen/kill 或更复杂的映射)、初始值(空集或全集)。把这四个旋钮调好,剩下的就是套模板。理解了这套模板,再看寄存器分配里的活跃性分析、循环优化里的归纳变量识别、SSA 构造中的支配边界,会发现它们全是同一个框架的不同实例。

问题方向合并事实含义
到达定值前向并集哪些赋值可能到达此处
活跃变量后向并集此处之后还会被读的变量
可用表达式前向交集此处之前必已计算且未被破坏
常量传播前向交集此处该变量必为某常量

2. 格、偏序与单调性

一句话总结: 数据流分析的收敛性由格的有限高度与传递函数的单调性共同保证,二者缺一不可。

把「事实」组织成格(lattice)是数据流分析的理论基础。一个格由偏序集 (L, ⊑) 构成,任意两个元素都有上确界(join,记 ⊔)与下确界(meet,记 ⊓)。到达定值用幂集格:元素是「定值集合」,偏序是子集关系,join 是并集;常量传播用平坦格:每个变量取值来自 {⊤, c1, c2, ..., ⊥},⊤ 表示「还不确定」,⊥ 表示「已确认不是常量」,从 ⊤ 向下走代表信息变精确。

# 常量传播的平坦格 (flat lattice)
TOP, BOTTOM = "TOP", "BOTTOM"

def join(a, b):
    if a == b:            return a          # 相同: 保持
    if a == TOP:          return b          # TOP 与任何值 join 得该值
    if b == TOP:          return a
    return BOTTOM                           # 两个不同常量: 退化为未知

print(join(TOP, 5), join(5, 5), join(5, 7), join(BOTTOM, 5))
# TOP与5 join -> 5; 5与5 -> 5; 5与7 -> BOTTOM; BOTTOM与5 -> BOTTOM
def transfer_const(state, stmt):
    """state: 变量 -> 常量格元素; 返回执行 stmt 后的新状态."""
    out = dict(state)
    if stmt[0] == "const":                  # x = 常量
        out[stmt[1]] = stmt[2]
    elif stmt[0] == "assign":               # x = y op z
        _, x, op, y, z = stmt
        a = state.get(y, BOTTOM) if not isinstance(y, int) else y
        b = state.get(z, BOTTOM) if not isinstance(z, int) else z
        out[x] = eval_binop(op, a, b)
    else:                                   # 有副作用: 全部降级
        out = {k: BOTTOM for k in state}
    return out

def eval_binop(op, a, b):
    if a == BOTTOM or b == BOTTOM: return BOTTOM
    if a == TOP or b == TOP:       return TOP
    return {"+": a + b, "-": a - b, "*": a * b}[op]

单调性是收敛的关键。传递函数 f 必须满足 x ⊑ y ⟹ f(x) ⊑ f(y):更精确的输入只能得到不更差的结果。结合格的高度有限(幂集格高度等于变量数,平坦格高度为 3),迭代序列 ⊥ ⊑ f(⊥) ⊑ f²(⊥) ⊑ ... 必然在有限步内稳定。若传递函数非单调,或格有无限升链,迭代就可能永不收敛——这是写自定义分析时最容易犯的错。

概念含义在本框架中的角色
偏序 ⊑信息精确度的比较定义「更精确」
上确界 ⊔两个事实的最小公共上界控制流汇合处的合并
最小元 ⊥信息最少(乐观起点)迭代初值
单调函数保序映射保证收敛
有限高度无无限升链保证有限步收敛

3. 到达定值与活跃变量

一句话总结: 到达定值向前传播「哪些赋值可能到达」,活跃变量向后传播「哪些变量还将被使用」,二者是数据流框架最经典的两个实例。

到达定值(reaching definitions)问的是:在程序点 p,变量 x 的当前值可能来自哪些赋值语句?它把每条赋值语句编号作为事实元素,正向传播,在控制流汇合处取并集,遇到对 x 的新赋值就把所有旧的 x 定值「杀死」。它是常量传播与定值-使用链(def-use chain)的基础。

def reaching_defs(cfg, defs_of, uses_of, all_defs):
    """defs_of[n]: 块 n 中定义的 (变量, 定值编号); uses_of[n]: 块 n 中使用的变量."""
    IN  = {n: set() for n in cfg}
    OUT = {n: set() for n in cfg}
    changed = True
    while changed:
        changed = False
        for n in cfg:
            merged = set()
            for p in preds(cfg, n):
                merged |= OUT[p]
            IN[n] = merged
            gen = {d for _, d in defs_of[n]}
            killed_vars = {v for v, _ in defs_of[n]}
            kill = {d for d in all_defs
                    if any(all_defs[d] == v for v in killed_vars)}
            new_out = gen | (IN[n] - kill)
            if new_out != OUT[n]:
                OUT[n], changed = new_out, True
    return IN, OUT

all_defs = {1: "x", 2: "y", 3: "x", 4: "z"}
cfg = {0: [1], 1: [2], 2: [3, 4], 3: [4], 4: []}
defs_of = {0: [], 1: [("x", 1)], 2: [("y", 2)], 3: [("x", 3)], 4: [("z", 4)]}
uses_of = {0: [], 1: [], 2: ["x"], 3: [], 4: ["x", "y", "z"]}
for n, s in zip(*reaching_defs(cfg, defs_of, uses_of, all_defs)):
    print(n, sorted(s))

活跃变量(liveness)方向相反:变量 x 在点 p 活跃,当且仅当存在一条从 p 出发的路径,在 x 被重新定义之前读到了 x。它后向传播,方程是 live_in = use ∪ (live_out − def)。活跃性分析直接服务于寄存器分配(冲突图)与死代码消除(定值后不活跃即为死代码),是优化后端出现频率最高的分析。

对比项到达定值活跃变量
方向前向后向
事实单位赋值语句编号变量名
合并并集并集
传递gen ∪ (in − kill)use ∪ (out − def)
主要用途常量传播、def-use 链寄存器分配、死代码消除

两者看似对称,实则语义不同:到达定值关心「值从哪来」,活跃变量关心「值还要到哪去」。一个常见的误解是「变量被定义了但从未使用」等价于「变量不活跃」——不活跃的定义更弱,它只要求「从这里出发的每条路径上,读取都发生在重定义之后」,一个死变量在它被定义的那条指令之后确实不活跃,但在定义点之前可能仍是活跃的(因为别处有读取)。

4. 可用表达式与常量传播

一句话总结: 可用表达式用交集合并表达「必经」语义,常量传播在格上做前向求值,两者共同支撑公共子表达式消除与常量折叠。

可用表达式(available expressions)问的是:在点 p,表达式 x + y 是否在之前的所有路径上都被计算过,且其操作数此后未被改写?注意「所有路径」——这要求合并用交集而非并集。这是数据流框架里「must analysis」(必然成立)与「may analysis」(可能成立)的分水岭:may 分析初值取空集、合并取并集;must 分析初值取全集、合并取交集。

def available_exprs(cfg, gen_of, kill_of, all_exprs):
    IN  = {n: set(all_exprs) for n in cfg}   # must 分析: 初值取全集
    OUT = {n: set(all_exprs) for n in cfg}
    changed = True
    while changed:
        changed = False
        for n in cfg:
            if not preds(cfg, n):
                merged = set()               # 入口块没有前驱, 无可用表达式
            else:
                merged = set(all_exprs)
                for p in preds(cfg, n):
                    merged &= OUT[p]         # must: 交集
            IN[n] = merged
            new_out = gen_of[n] | (IN[n] - kill_of[n])
            if new_out != OUT[n]:
                OUT[n], changed = new_out, True
    return IN, OUT
# 一个表达式在点 p 可用, 说明可以复用它已算出的值
exprs = {"t1", "t2", "t3"}
cfg = {0: [1], 1: [2, 3], 2: [4], 3: [4], 4: []}
gen_of = {0: set(), 1: {"t1"}, 2: {"t2"}, 3: set(), 4: set()}
kill_of = {0: set(), 1: set(), 2: set(), 3: {"t1"}, 4: set()}
IN, OUT = available_exprs(cfg, gen_of, kill_of, exprs)
print("块4 入口可用:", sorted(IN[4]))     # t1 在分支 3 被 kill, 故不可用

常量传播(constant propagation)是另一种形态:它不使用集合,而是用变量到格元素的映射,逐条指令做前向求值。若某变量的格元素收敛为具体常量,就可以在后续使用处直接替换,再触发常量折叠与不可达分支消除。把常量传播与分支可达性结合,就是经典的稀疏条件常量传播(SCCP):它维护「某条边是否可达」,不可达的边不参与合并,从而比朴素常量传播更精确。

def reachable_edges(cfg, cond_const, entry):
    """cond_const[block] 为分支条件已知的常量, None 表示未知."""
    seen, work = set(), [entry]
    while work:
        n = work.pop()
        if n in seen: continue
        seen.add(n)
        c, succs = cond_const.get(n), cfg[n]
        if c is True:    work.append(succs[0])     # 只走真分支
        elif c is False: work.append(succs[-1])    # 只走假分支
        else:            work.extend(succs)        # 两条都走
    return seen

print(reachable_edges({0: [1, 2], 1: [3], 2: [3], 3: []}, {0: True}, 0))
# {0, 1, 3}: 分支 2 因条件恒真而不可达
分析类型合并算子初值语义
may(可能)并集空集存在一条路径成立
must(必然)交集全集所有路径都成立
常量传播格 join每变量 ⊤所有路径都取同一常量

may 与 must 的选择直接决定优化的正确性方向:用 may 分析的结果做「必然成立」的假设,会生成错误的代码;反之用 must 分析做「可能成立」的假设,只会错过优化机会,是安全的。工程上宁可保守也不要激进,因为漏优化只是性能损失,错优化是正确性事故。

5. 支配树与 SSA 稀疏分析

一句话总结: 支配树刻画「哪个块必经哪个块」,SSA 形式让每个变量只有一个定义点,使数据流事实可以挂在定义点上做稀疏传播。

支配(dominance)是控制流图的骨架概念:若从入口到块 n 的每条路径都经过块 d,则称 d 支配 n。把每个块连到它的直接支配者(idom),得到一棵支配树。支配信息用途极广:识别循环(回边的头必然支配尾)、计算支配边界(放置 phi 节点的位置)、做代码移动的安全性判定。

def compute_idom(cfg, entry):
    """迭代求直接支配者: 用交集算法, 朴素但直观."""
    dom = {n: set(cfg) for n in cfg}
    dom[entry] = {entry}
    changed = True
    while changed:
        changed = False
        for n in cfg:
            if n == entry: continue
            ps = preds(cfg, n)
            new = {n} | set.intersection(*(dom[p] for p in ps)) if ps else {n}
            if new != dom[n]:
                dom[n], changed = new, True
    idom = {}
    for n in cfg:
        if n == entry: continue
        strict = dom[n] - {n}
        # 直接支配者: 严格支配者中不被其他严格支配者支配的那个
        idom[n] = next(d for d in strict
                       if all(d == s or d in dom[s] for s in strict))
    return idom

cfg = {0: [1], 1: [2, 3], 2: [4], 3: [4], 4: [1, 5], 5: []}
print(compute_idom(cfg, 0))
def dominance_frontier(cfg, idom, entry):
    """支配边界: 若 d 支配 n 的某个前驱但不严格支配 n, 则 n 在 d 的边界上."""
    df = {n: set() for n in cfg}
    for n in cfg:
        ps = preds(cfg, n)
        if len(ps) < 2: continue                  # 只有多前驱块才产生边界
        for p in ps:
            runner = p
            while runner is not None and runner != idom.get(n):
                df[runner].add(n)
                runner = idom.get(runner)
    return df

idom = compute_idom(cfg, 0)
print(dominance_frontier(cfg, idom, 0))

支配边界是 SSA 构造的钥匙:某个变量若在块 d 中被定义,且 d 的支配边界中存在块 n,那么 n 的入口就需要为这个变量插入 phi 节点。这解释了为什么 SSA 构造可以在近似线性时间内完成——不需要对每条边做数据流传播,只需要在支配边界这个稀疏的点集上插 phi。

稀疏分析是 SSA 带来的最大红利。传统数据流分析在每个基本块的入口出口都维护事实集合,事实数量与「块数 × 变量数」成正比;而在 SSA 形式下,每个变量只有唯一一个定义点,事实可以只挂在定义点上,沿着 def-use 链直接传播,复杂度降到与「定义数 + 使用数」成正比。基于 SSA 的稀疏分析把活跃性、常量传播、值编号等问题从「块级」提升到「定义级」,是现代编译器(LLVM、Go 编译器、HotSpot C2)的标准做法。

概念定义用途
支配所有路径都经过循环识别、安全性判定
直接支配者最近的严格支配者构造支配树
支配边界支配关系的「失效边界」确定 phi 插入点
SSA 稀疏化事实挂在定义点降低分析复杂度

6. Worklist 算法

一句话总结: worklist 算法只把「结果发生变化」的节点重新入队,避免整图反复扫描,是数据流分析的工程标准实现。

教科书式的迭代算法每轮扫描所有基本块,绝大多数块的结果并不会变化,白白浪费。worklist 算法用一个待处理队列替代全量扫描:初始把所有块入队,每次取出一个块计算其输出,只有输出发生变化时才把它的后继(前向分析)入队。实践表明这能把迭代次数降低一个数量级,在大型函数上尤其明显。

from collections import deque

def worklist_forward(cfg, entry, transfer, init):
    IN  = {n: set() for n in cfg}
    OUT = {n: dict(init) for n in cfg}
    wl = deque(cfg.keys())
    in_queue = set(cfg.keys())
    while wl:
        n = wl.popleft()
        in_queue.discard(n)
        merged = set()
        for p in preds(cfg, n):
            merged |= OUT[p]
        IN[n] = merged
        new_out = transfer(n, IN[n])
        if new_out != OUT[n]:
            OUT[n] = new_out
            for s in cfg[n]:                # 只把后继入队
                if s not in in_queue:
                    wl.append(s)
                    in_queue.add(s)
    return IN, OUT

def transfer(n, in_set):
    # 示例: 到达定值风格的 gen/kill
    return in_set | {n}
# 优先队列版本: 按逆后序处理能进一步减少迭代
def rpo(cfg, entry):
    order, seen = [], set()
    def dfs(n):
        seen.add(n)
        for s in cfg[n]:
            if s not in seen: dfs(s)
        order.append(n)
    dfs(entry)
    return order[::-1]

cfg = {0: [1], 1: [2, 3], 2: [4], 3: [4], 4: []}
print("逆后序:", rpo(cfg, 0))

worklist 的另一个常见优化是按逆后序(reverse postorder)初始化队列。逆后序保证一个块在被处理时,它在控制流上的大多数前驱都已经处理过,因此第一次处理往往就能得到接近最终的结果。对后向分析(活跃性)则使用后序或逆逆后序,原理相同。

实现方式复杂度特点
朴素全扫描O(块数 × 高度 × 块大小)实现最简单,常数大
worklist同上但常数小得多工程标准
逆后序初始化减少首次迭代的无效重算与 worklist 叠加
SSA 稀疏与定义数成正比最快,需 SSA

需要说明的是,数据流分析在最坏情况下的复杂度上界并不好看:以到达定值做位向量实现为例,复杂度是 O(块数² × 位数)。但实际程序的控制流图很少退化成最坏形态,配合 worklist 与逆后序,绝大多数真实函数的分析时间都在微秒级。真正拖慢编译的是超大型函数(自动生成的解析器、机器学习模型导出的代码),工程上靠函数大小阈值、分析预算与缓存来兜底。

7. 从分析到优化

一句话总结: 分析结果本身不是终点,把「事实」翻译成「变换」才是优化,而这中间隔着正确性与收益的权衡。

数据流分析的产物是一张「程序性质表」,把它变成优化需要三步:确认变换的前提条件、执行变换、验证变换未破坏后续分析依赖的事实。以公共子表达式消除为例,前提是「该表达式在此处可用」,变换是用前一次计算的结果替换本次计算,验证是要保证替换后该表达式的定值仍然可用。

def cse(block, available):
    """在块内做公共子表达式消除: 复用已计算过的表达式."""
    table, out = {}, []
    for stmt in block:
        if stmt[0] == "assign" and stmt[2] in ("+", "-", "*"):
            key = (stmt[2], stmt[3], stmt[4])
            if key in table and key in available:
                out.append(("copy", stmt[1], table[key]))   # 复用
                continue
            table[key] = stmt[1]
        out.append(stmt)
    return out

print(cse([("assign", "a", "+", "x", "y"),
           ("assign", "b", "+", "x", "y")], {"+xy"}))
优化依赖的分析前提条件
常量折叠常量传播操作数均为已知常量
公共子表达式消除可用表达式表达式已计算且未被 kill
死代码消除活跃变量定值后变量不活跃且无副作用
代码移动支配树目标位置支配所有使用点
循环不变量外提循环识别 + 支配计算与循环无关且安全
分支消除常量传播 + 可达性条件恒真/恒假

实践中最容易出问题的是分析结果的时效性:变换改变了控制流图或定值集合,此前算出的 IN/OUT 立刻作废。成熟的编译器把每个分析封装成独立对象,附上「失效通知」机制——任何 pass 修改了函数,依赖该函数的分析自动被标记为 stale,下次访问时重算。LLVM 的 AnalysisManager 与 PreservedAnalyses 就是这个机制的工业化实现:pass 声明自己保留了哪些分析,其余自动失效。这套设计避免了「改完还在用旧结果」这一类极其隐蔽的错误。

8. 总结

环节要点
框架要素方向、合并算子、传递函数、初值四要素
格与偏序有限高度 + 单调函数保证收敛
may 分析初值空集、合并并集,语义是「可能成立」
must 分析初值全集、合并交集,语义是「必然成立」
到达定值前向,回答「值从哪来」,支撑 def-use 链
活跃变量后向,回答「值到哪去」,支撑寄存器分配
常量传播平坦格上的前向求值,配合可达性做 SCCP
支配树循环识别与 phi 插入点计算的基础
SSA 稀疏分析事实挂在定义点,复杂度与定义数成正比
worklist只重算变化的节点,逆后序初始化进一步提速
结果时效性变换后分析失效,需显式失效通知机制

数据流分析是编译器优化的「公共基础设施」:它把「程序在某个点满足什么性质」变成可以机械计算的问题,让后续的常量传播、死代码消除、公共子表达式消除、寄存器分配都能建立在一个统一的抽象之上。掌握了框架的四要素与收敛性论证,再看任何一个陌生的分析,都能迅速判断它属于 may 还是 must、前向还是后向、格是什么、传递函数是否单调。下一篇进入一个更具体的优化战场——向量化与 SIMD,看看编译器如何把标量循环改造成一次处理多个数据的向量代码。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类