增量构建:脏检查、Build Log 与 Restat

Ninja 的卖点是增量构建快。快的前提是准确而廉价地判断”哪些边其实需要重建”——这就是脏检查(dirty checking)。本文讲三件事:脏检查算法、命令哈希(.ninja_log)、以及 restat 短路。前置阅读:构建图模型

再次澄清:Ninja 不用 mmap.ninja_log 是文本格式,启动时用一个 256KB 缓冲的 LineReader 顺序读完(build_log.cc:157)。

1. 脏检查算法

1.1 入口:RecomputeDirty

DependencyScan::RecomputeDirtygraph.cc:49)用一个工作队列驱动递归:std::deque<Node*> 从目标节点出发,对每个节点调 RecomputeNodeDirty,后者深度优先递归其输入。validation 节点(|@)单独收集、不内联递归,避免误报环。

1.2 核心:RecomputeNodeDirty

RecomputeNodeDirtygraph.cc:82)对一个节点判断脏与否:

  1. 叶子节点(无 in_edge):StatIfNecessary 后,存在即干净、缺失即脏(graph.cc:96)——源文件存在就不脏。
  2. 环检测VisitDone 标记短路,VerifyDAGVisitInStack 检测环(graph.cc:101)。
  3. 加载隐式依赖:首次访问时解析 dyndep 并 dep_loader_.LoadDeps(edge) 拉入 depfile/deps-log 里的隐式依赖(graph.cc:151)。加载失败会把边标脏(deps_missing_),强制重建以重新生成依赖。
  4. 先递归所有输入graph.cc:163)——必须先于就绪判断,因为 dyndep 可能新增输入。
  5. stat 所有输出graph.cc:169)。
  6. 算”最新输入”graph.cc:179):遍历输入,任一非 order-only 输入是脏的 → 本边脏;否则记下 mtime 最大的输入 most_recent_input。order-only 输入跳过 mtime 比较(graph.cc:188),这正是 构建图模型 §2.1 提到的语义差别。
  7. 输出脏判断graph.cc:204):调 RecomputeOutputsDirty
  8. 传播graph.cc:209):若边脏,每个输出 MarkDirty()

1.3 mtime 比较的核心一行

RecomputeOutputDirtygraph.cc:284)逐个输出做判断,中心比较是(graph.cc:334):

// 输出比最新输入旧 → 脏
if (!used_restat && most_recent_input &&
    output->mtime() < most_recent_input->mtime()) {
  // "output %s older than most recent input %s (%lld vs %lld)"
  return true;
}

加上几个前置情况:输出缺失 → 脏(graph.cc:312);phony 边特殊处理(见 §5)。mtime 的三态约定(-1/0/正数)见 构建图模型 §1StatIfNecessarygraph.h:53)保证每个节点只 stat 一次。

2. 命令哈希:改了命令行也要重建

只看 mtime 是不够的——如果你把 -O0 改成 -O2,源文件 mtime 没变,但产物必须重建。Ninja 的解法是给每条命令算个哈希存进 .ninja_log,下次比对。

哈希函数 BuildLog::LogEntry::HashCommandbuild_log.cc:60):

uint64_t BuildLog::LogEntry::HashCommand(StringPiece command) {
  return rapidhash(command.str_, command.len_);
}

比对在 RecomputeOutputDirtygraph.cc:351):

if (!generator &&
    BuildLog::LogEntry::HashCommand(command) != entry->command_hash) {
  // "command line changed for %s"
  return true;
}

注意 commandEvaluateCommand(incl_rsp_file=true) 的结果(graph.cc:273),含 rspfile 内容,所以连 response 文件里的参数变化也能侦测到。

2.1 generator 例外

generator = 1 的规则(典型如重跑 CMake 生成 build.ninja)被豁免:generator 标志取自 edge->GetBindingBool("generator")graph.cc:346),!generator 守卫让”命令变了”不触发重建,且”日志里没记录”也不强制重建(graph.cc:376)。否则每次升级 Ninja 改了内部命令格式,所有 generator 都会被无谓重跑。

3. Build Log(.ninja_log)

3.1 LogEntry 结构

struct LogEntrybuild_log.h:60):

struct LogEntry {
  std::string output;        // 输出路径
  uint64_t command_hash = 0; // 命令哈希
  int start_time = 0;        // 相对构建起点的毫秒
  int end_time = 0;
  TimeStamp mtime = 0;       // 记录时的输出 mtime
};

3.2 磁盘格式

.ninja_log制表符分隔的文本,版本头 # ninja log v7kCurrentVersion = 7build_log.cc:54)。每条记录一行,由 WriteEntry 写出(build_log.cc:334):

start_time \t end_time \t mtime \t output_path \t command_hash(hex)\n

注意 command_hash十六进制写(%PRIx64),Load 时用 strtoull(..., 16) 读回(build_log.cc:303)。

3.3 追加式 + 偶尔重整

这是”速度优先”的又一体现:

  • RecordCommandbuild_log.cc:90)每次只追加一行并 fflush,不重写整个文件。同一输出的后写记录覆盖先写的(Load 时后者生效,build_log.cc:288)。
  • 文件实际打开是惰性的:OpenForWritebuild_log.cc:77)只记路径,真正 fopen(..., "ab") 推迟到首次写(OpenForWriteIfNeededbuild_log.cc:132)。
  • 追加久了会膨胀,于是有重整Load 时若 条目数 > 100总数 > 唯一数 × 3,置 needs_recompaction_build_log.cc:312)。Recompactbuild_log.cc:340)把只剩活条目的内容写到 .recompact 临时文件再替换,顺便丢弃 user.IsPathDead(output) 为真的死条目。

3.4 IsPathDead

BuildLogUser::IsPathDead(接口 build_log.h:32)由 NinjaMain 实现(ninja.cc:178):一个路径”死”的条件是——当前 manifest 里它没有 in_edge 磁盘上也不存在。还在磁盘上的文件即使没规则也保留(对 generator 有用)。

4. Restat:内容没变就剪掉下游

有些命令即使重新运行,输出内容其实和上次一样(典型:配置头、戳记文件、restat 风格的”只在内容变化时才更新”的工具)。如果机械地按 mtime 判断,这类输出每跑一次就让整条下游链全部重建。restat = 1 解决这个问题。

4.1 运行后短路:FinishCommand

命令跑完后,若 record_mtime == 0 || restat || generatorFinishCommand 会重新 stat 每个输出(build.cc:947),核心判断(build.cc:955):

if ((*o)->mtime() == new_mtime && restat) {
  // 命令没真正改动输出 —— 把"干净"沿构建图向下传播
  if (!plan_.CleanNode(&scan_, *o, err))
    return false;
  node_cleaned = true;
}

即”跑完之后的 mtime == 跑之前的 mtime”,说明这次运行对该文件是 no-op。若有节点被清理,record_mtime 会被重置为该边的构建起始时间 command_start_time_build.cc:965),这个记录的 mtime 正是后续脏检查会比对的值,让 restat 的判断能跨次构建保持。

4.2 脏检查侧:used_restat

回到脏检查,对一个有 build log 记录的 restat 边,used_restat 被置真(graph.cc:326),从而跳过 §1.3 那条磁盘 mtime 比较!used_restat 守卫),改为依赖”记录的 mtime vs 最新输入”的判断(graph.cc:360)。注释(graph.cc:320)解释了原因:restat 输出不该仅因自身被重写就算脏,只有当输入确实自上次运行后变化了才算。

4.3 传播:Plan::CleanNode

Plan::CleanNodebuild.cc:272)是把”干净”沿图向下扩散的递归:

set_dirty(false)
for 每条想建的下游边:
  若其所有非 order-only 输入现已干净:
    RecomputeOutputsDirty 重新判断
    若仍干净:
      递归 CleanNode 其输出
      把该边从计划移除 (kWantNothing, --wanted_edges_, --command_edges_)

这就是 restat 能”剪枝”的机制——本不必要的下游边被直接从 Plan 里摘掉(还会回调 EdgeRemovedFromPlan 更新进度分母)。

5. Phony 与 deps 的特殊情形

5.1 Phony 边

phony 规则不执行命令(is_phony()graph.cc:577),常用作别名或聚合目标。脏检查里它有特殊待遇(graph.cc:288):不写任何文件;只有”无输入无 validation 且输出缺失”时才算脏;否则把最新输入的 mtime 通过 UpdatePhonyMtimegraph.cc:43)转写到 phony 节点上,让下游看到一个有意义的 mtime,然后返回干净。无输入的 phony 边即使”脏”也保持 outputs_readygraph.cc:220)。

5.2 deps log 的 mtime 作为依赖时效水位

deps = gcc/msvc 的边,其隐式依赖从 .ninja_deps 读出(见 依赖发现)。LoadDepsFromLoggraph.cc:740)会先 stat 输出,若 output->mtime() > deps->mtime,说明记录的依赖信息已过期(输出比记录时还新,可能是手工动过),直接判定需要重建(graph.cc:756)。这个 deps->mtimeFinishCommand 里记录的、命令跑完后输出的 mtime(build.cc:991)。有效的依赖节点随后并入 edge->inputs_,照常参与 §1.3 的最新输入比较——于是某个头文件变新就能通过标准路径让产物变脏。

6. disk_interface:mtime 从哪来

所有 stat/read/write 都经 DiskInterfacedisk_interface.h:45)。TimeStamp 就是 int64_ttimestamp.h:31),只比较、不转人类时间。

RealDiskInterface::Statdisk_interface.cc:215):

  • POSIX 分支(disk_interface.cc:253):stat()ENOENT/ENOTDIR 返回 0(缺失),其他错误返回 -1。返回纳秒级 mtime,且把恰为 0 的 mtime 抬到 1,避免和”缺失”哨兵撞车(disk_interface.cc:269)。
  • Windows 分支(disk_interface.cc:217):用目录级 stat 缓存——StatAllFilesInDirdisk_interface.cc:126)一次列出整个目录的文件名→mtime 缓存起来。因为 Windows 文件系统单文件 stat 慢,批量列目录能大幅加速(手册里”Windows 上加载依赖文件慢”的痛点之一)。

其余:MakeDirdisk_interface.cc:315)、WriteFiledisk_interface.cc:284)、RemoveFiledisk_interface.cc:336,返回 0 删除/1 不存在/-1 错误)、ReadFiledisk_interface.cc:326)。

7. 小结:行号速查

主题 位置
RecomputeDirty 工作队列 graph.cc:49
RecomputeNodeDirty graph.cc:82
mtime 比较核心 graph.cc:334
命令哈希函数 / 比对 build_log.cc:60 / graph.cc:351
generator 例外 graph.cc:346
LogEntry / 磁盘格式 build_log.h:60 / build_log.cc:334
重整 Recompact build_log.cc:340
restat 短路 build.cc:955
Plan::CleanNode build.cc:272
used_restat graph.cc:326
phony / UpdatePhonyMtime graph.cc:288 / graph.cc:43
RealDiskInterface::Stat disk_interface.cc:215

下一篇 依赖发现 深入头文件依赖是怎么被采集、压缩、回填的。


本站访问量

This site uses Just the Docs, a documentation theme for Jekyll.