「并行与增量编译」

讲解现代构建系统与编译器如何通过并行与增量策略加速开发反馈:模块依赖图与拓扑排序、并行任务调度、指纹缓存与失效传播、分析结果的 memoization,以及构建产物跨进程复用。

1. 编译模型与反馈时延

一句话总结: 全量编译随工程规模线性膨胀,增量与并行分别从「少编译」与「同时编译」两个维度压缩反馈时延,是现代编译器的必修课。

传统编译一次处理一个翻译单元:预处理、编译、汇编、链接,整个工程串行完成。工程变大的时候全量重编译的时间从秒级涨到分钟级,严重拖慢开发迭代。两条优化路线应运而生:并行编译用多核同时编译多个相互独立的单元;增量编译只在源码变化时重编译受影响的单元,把「每次都全量」变成「只重做差异」。Rustc、GCC、Clang、Go、Java 的 javac 以及所有主流构建系统都同时支持两者。

# 全量 vs 增量: 模拟重编译工作量
def full_build(units):
    return sum(u["cost"] for u in units)

def incremental_build(units, changed):
    return sum(u["cost"] for u in units if u["name"] in changed)

units = [{"name": f"m{i}", "cost": 3} for i in range(100)]
print("全量编译:", full_build(units), "单位工作量")
print("改动 2 个文件的增量编译:", incremental_build(units, {"m3", "m7"}), "单位工作量")
加速手段原理典型实现
并行独立单元同时编译-j、rayon、jobserver
增量只重编译受影响单元依赖图 + 指纹
分布跨机器并行distcc、sccache 远程
缓存复用历史产物ccache、sccache

理想增量编译的复杂度正比于「改动本身」而不是「工程规模」。要做到这一点,编译器必须维护一份精确的依赖关系与一份可快速判定的「是否变化」机制,这正是依赖图与指纹要解决的问题。

2. 模块依赖图

一句话总结: 模块依赖图记录「谁编译依赖谁」的有向边,合法的并行与增量都建立在这张无环图(DAG)上。

要回答「改了 A 之后哪些东西需要重编」,编译器需要知道 A 影响了谁。模块依赖图把每个翻译单元(文件/模块)当作节点,v 依赖 u 表示编译 u 之后才能编译 v(被依赖者指向依赖者),所以 ui 与 main 都依赖 core 时,core 必须先编译。合法编译顺序是图的一个拓扑序;依赖图必须无环——循环依赖会迫使编译器把多个模块捆成一个编译单位,牺牲并行与增量粒度。Rust 的 crate、Go 的 package、Java 的 module 都显式表达这一层依赖关系。

def topo_sort(adj):
    """Kahn 算法: 每次取出一个入度为 0 的节点."""
    from collections import deque
    indeg = {n: 0 for n in adj}
    for edges in adj.values():
        for e in edges:
            indeg[e] = indeg.get(e, 0) + 1
    q = deque([n for n, d in indeg.items() if d == 0])
    order = []
    while q:
        n = q.popleft()
        order.append(n)
        for e in adj.get(n, ()):
            indeg[e] -= 1
            if indeg[e] == 0:
                q.append(e)
    return order

# 边 core->ui / core->main 表示 ui、main 都依赖 core
graph = {"core": {"ui", "main"}, "ui": {"main"}, "main": set()}
print("编译顺序:", topo_sort(graph))
# 依赖图可视化
def render_dag(adj):
    lines = ["digraph deps {"]
    for u, vs in adj.items():
        for v in vs:
            lines.append(f'  "{u}" -> "{v}"')
    lines.append("}")
    return "\n".join(lines)

print(render_dag(graph))
依赖类型含义增量影响
接口依赖读取声明/签名改动导致下游重编
实现依赖使用内部实现通常不触发下游
工具链依赖编译器/标志变化全局失效
数据依赖读取生成文件按文件指纹判定

依赖图的精细程度直接决定增量质量:如果依赖粒度是「整个文件」,那么一个公有函数签名的小改动也会让整个下游模块重编;如果能细化到函数级(如 Rustc 的 incremental 以 query 为粒度),重编译范围可以大幅缩小。依赖图也是并行调度的输入——只有互相没有依赖关系的单元才能同时编译。

3. 并行任务调度

一句话总结: 调度器按拓扑序把无依赖的单元分发给工作线程,负载均衡决定并行加速比,饱和依赖与共享缓存是实际瓶颈。

有了依赖图,并行编译就是「尽可能同时调度互不依赖的任务」。经典实现是 make 的 -j 与 Ninja 的并行调度:维护「就绪队列」,每当一个单元完成就把依赖它的单元加入就绪集,工作线程从就绪集取活。负载均衡上,CPU 密集型编译任务适合「任务队列 + 工作窃取」,避免某个线程空转。实际加速比受 Amdahl 定律约束:依赖链上不可并行的部分是天花板。

import threading
from collections import deque

def parallel_build(adj, workers=4):
    """adj[u] = 依赖 u 的模块集合; 边指向依赖者."""
    indeg = {n: 0 for n in adj}         # indeg[v] = v 的未完成前置依赖数
    for u, vs in adj.items():
        for v in vs:
            indeg[v] = indeg.get(v, 0) + 1
    ready = deque(n for n, d in indeg.items() if d == 0)
    done = []
    lock = threading.Lock()

    def worker():
        while True:
            with lock:
                if not ready:
                    return
                n = ready.popleft()
            import time; time.sleep(0.05)       # 模拟编译耗时
            with lock:
                done.append(n)
                for u in adj.get(n, ()):        # n 完成后解锁依赖它的模块
                    indeg[u] -= 1
                    if indeg[u] == 0:
                        ready.append(u)

    threads = [threading.Thread(target=worker) for _ in range(workers)]
    for t in threads: t.start()
    for t in threads: t.join()
    return done

# 与上节同一依赖图: core 先编译, 再 ui, 最后 main
print("并行编译完成顺序:", parallel_build(
    {"core": {"ui", "main"}, "ui": {"main"}, "main": set()}
))
调度策略思想优点/缺点
就绪队列无依赖先做简单、公平
工作窃取忙线程偷闲线程任务负载均衡好
优先级调度关键路径优先缩短总时延

编译任务不是纯 CPU 计算:预处理读头文件、写产物落盘、偶尔链接大二进制,I/O 与锁竞争都可能把并行度拉低。工程上常用「CPU 数 + 1」作为默认并行度,并把链接这类重 I/O 任务与编译分离。另一个隐蔽瓶颈是共享内存缓存——所有线程同时读写的增量缓存会成为热点,缓存分区与锁粒度都要专门设计。

4. 增量缓存与指纹

一句话总结: 增量编译的核心是缓存上次的结果,并用内容指纹判断「这次是否还要重做」,指纹既包括源码也包括依赖的产物。

增量编译要回答「我重编译这个单元会不会得到与上次相同的结果」。答案是算指纹(fingerprint):把源码内容、依赖模块的接口、编译标志、工具链版本拼成一个哈希。指纹相同 → 复用上次产物;指纹不同 → 重编译。指纹必须反映所有影响输出的输入,漏掉任何一项都会产生「过期缓存未失效」的正确性 bug。

import hashlib, json

def fingerprint(source, dep_interfaces, flags, compiler_version):
    payload = json.dumps({
        "source": source, "deps": dep_interfaces,
        "flags": flags, "compiler": compiler_version,
    }, sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()[:12]

f1 = fingerprint("fn main() {}", {"core": "v3"}, ["-O2"], "rustc 1.85")
f2 = fingerprint("fn main() {}", {"core": "v4"}, ["-O2"], "rustc 1.85")
print("未变指纹:", f1)
print("依赖变化指纹:", f2)
print("缓存是否命中:", f1 == f2)
指纹输入举例漏掉后果
源码本文件内容改代码不重编
依赖接口依赖的签名哈希依赖变化不传播
编译标志-O2 / 特性开关换标志不重编
工具链编译器版本升级后用旧产物

指纹的粒度要与依赖图一致:文件级指纹简单,但「只改注释也重编」的代价常被批评;更细的指纹(按 AST 或按 query 输出)能识别「改注释不改变语义」,把重编译范围压得更小。指纹计算本身也要快——哈希整棵 AST 若比编译还慢就得不偿失,多数实现先哈希源码文本、必要时再深入。

5. 失效传播

一句话总结: 一个模块的指纹变化会沿依赖图向下游传播,传播范围决定增量收益,切断传播要靠精确的接口隔离。

增量编译的难点不在发现「我变了」,而在发现「因为依赖变了所以我也要变」。当模块 v 的接口变化时,所有 依赖 v 的模块 u 都需要重编——但 u 的重编又可能改变 u 的输出指纹,从而继续传染 u 的下游。这个传播要沿依赖图广度或深度优先地展开,直到不再有模块的指纹变化。传播范围越大,增量越像全量;因此好的模块边界(小而稳定的接口)是增量编译的放大器。

def propagate_dirty(adj, initial_dirty):
    """初始脏集合沿依赖图向下游传播."""
    dirty = set(initial_dirty)
    worklist = list(initial_dirty)
    while worklist:
        v = worklist.pop()
        for u in adj.get(v, []):        # adj: v -> 依赖 v 的模块
            if u not in dirty:
                dirty.add(u)
                worklist.append(u)
    return dirty

# adj: 谁依赖谁 (被依赖 -> 依赖者)
adj = {"core": ["ui", "main"], "ui": ["main"], "main": []}
print("core 变化后需要重编:", sorted(propagate_dirty(adj, {"core"})))
print("ui 变化后需要重编:", sorted(propagate_dirty(adj, {"ui"})))
# 接口 vs 实现的失效差异
def should_recompile(iface_hash_old, iface_hash_new, impl_only_changed):
    if impl_only_changed:
        return "下游无需重编 (实现隔离)"
    return "需要重编" if iface_hash_old != iface_hash_new else "无需重编"

print(should_recompile("a1", "a1", impl_only_changed=True))
print(should_recompile("a1", "b9", impl_only_changed=False))
变化类型传播范围控制手段
公有接口变化全部下游少改接口
实现变化无(若隔离)接口稳定
编译标志变化全部全局失效

失效传播的正确实现依赖「指纹在传播过程中逐步更新」:u 重编后会得到新指纹,新指纹再与上次的对比决定是否继续传染 u 的下游。编译器在增量会话里把这份「上次指纹 → 本次指纹」的映射持久化,跨进程也能恢复。传播算法本身要在内存与磁盘之间平衡——整张依赖图驻留内存太快,但对大工程占空间;按需从磁盘加载又慢。多数构建系统用内存缓存 + 磁盘持久化的两级设计。

6. Memoization:分析结果复用

一句话总结: 编译器内部把重复的分析计算做成「以查询为键的备忘录」,同一输入只算一次,既省时间又为增量提供细粒度复用。

增量不止发生在模块之间,也发生在编译器内部。Rustc 的整个编译过程由 query 组成:每个 query(类型检查、借用检查、优化)以「输入」为键缓存结果。同一份依赖只算一次;本次运行里重复请求直接命中缓存;跨运行则靠持久化把缓存带到下次。这就是函数式编译器设计——把编译器看成「输入 → 输出」的纯函数组合,把中间结果 memoize,增量就自然涌现。

class Memoizer:
    """以 (query, 输入) 为键的备忘录, 跨请求复用结果."""
    def __init__(self):
        self.cache = {}
        self.calls = 0
    def run(self, key, compute):
        if key not in self.cache:
            self.cache[key] = compute()
            self.calls += 1
        return self.cache[key]

m = Memoizer()
type_of = lambda k: f"type:{k}"
for _ in range(10):
    m.run(("typeck", "foo"), lambda: type_of("foo"))
print("实际计算次数:", m.calls, "(10 次请求只算了 1 次)")
# 跨运行持久化: 磁盘上的 query 缓存
class PersistentMemo:
    def __init__(self):
        self.cache = {}
    def get(self, key, deps_fingerprint):
        entry = self.cache.get(key)
        if entry and entry["fingerprint"] == deps_fingerprint:
            return entry["value"], True    # 缓存命中
        return None, False

pm = PersistentMemo()
pm.cache["typeck:foo"] = {"fingerprint": "abc", "value": "int"}
print(pm.get("typeck:foo", deps_fingerprint="abc"))   # 命中
print(pm.get("typeck:foo", deps_fingerprint="xyz"))   # 失效
复用层级粒度持久化例子
函数内单次调用否局部 memo
进程内一次编译否query 缓存
跨进程多次编译是Rustc incremental
跨机器构建集群是sccache 远程

Memoization 的关键是输入的稳定性:query 的输入必须是可哈希、可比较的「指纹化的东西」,否则缓存无法判定命中。Rustc 把输入(依赖模块的 HIR、类型结果)也指纹化,任何一环变化都会让整条 query 链失效重算。代价是内存占用:缓存所有中间结果会让单次编译的内存峰值上升,因此编译器要在「缓存多少层」与「重算多快」之间调参。

7. 构建产物复用与分布式构建

一句话总结: 把「编译结果」按指纹缓存到共享存储,跨进程、跨机器甚至跨 CI 复用,ccache/sccache 是这一思想的工业实现。

增量缓存在本地磁盘,换台机器、换个 CI 容器就全没了。构建产物复用把缓存放到共享层:以「源码+依赖+标志+工具链」的指纹为键,把编译产物(.o、.rlib、缓存的 query)存进共享存储。命中就直接取回,未命中才真实编译并回填。ccache 对 C/C++ 的预处理后源码做指纹;sccache 更进一步支持多语言与远程执行;Rustc 的 incremental 目录则按 crate 持久化。

import hashlib

class ArtifactCache:
    def __init__(self, store=None):
        self.store = store or {}
    def key(self, source, deps, flags):
        return hashlib.sha256((source + deps + flags).encode()).hexdigest()[:16]
    def get(self, source, deps, flags):
        k = self.key(source, deps, flags)
        if k in self.store:
            return self.store[k], "cache hit"
        artifact = f"obj:{k}"
        self.store[k] = artifact
        return artifact, "cache miss (已回填)"

ac = ArtifactCache()
for _ in range(3):
    obj, status = ac.get("src(main)", "core@v3", "-O2")
    print(f"  {status} -> {obj}")
缓存层键粒度共享范围典型工具
本地文件源文件哈希本机ccache
远程缓存完整指纹组织内 CIsccache 远程
分布式执行指纹 + 远程编译集群distcc、sccache

分布式构建把「缓存未命中」也外包:把编译单元发给远端机器执行,回传产物。这要求编译器输出可复现(reproducible)——同样的输入必须产出逐字节一致的产物,否则共享缓存会张冠李戴。因此现代构建体系普遍引入「路径无关」「时间戳无关」「构建 ID 独立」等可复现性约束。产物复用与增量的边界也在此处交汇:增量缓存重放「上次的编译决策」,产物缓存重放「上次的编译结果」,两者共享同一套指纹体系。

8. 总结

主题核心结论
加速模型并行「同时做」+ 增量「只做差异」
依赖图DAG 定义编译顺序与并行机会
并行调度就绪队列/工作窃取,受 Amdahl 约束
指纹缓存内容哈希判定是否重做
失效传播沿依赖图传染,接口隔离切断
Memoization编译器内查询级结果复用
产物复用共享存储跨进程/跨机器复用产物

并行与增量编译把「编译」从一次性的重型工序变成可持续累积的工程资产:依赖图告诉你顺序,指纹告诉你变化,缓存帮你复用,调度器帮你榨干多核。三者协同,让「改一行代码」从「重编整个工程」变成「重编受影响的最小集合」。现代编译器(Rustc、Clang、Go)把增量与并行写进了语言与构建系统的 DNA,理解这些机制,不仅是理解构建工具,更是理解大规模软件工程反馈循环的提速之道。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「compiler」更多文章

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