增量构建:脏检查、Build Log 与 Restat
Ninja 的卖点是增量构建快。快的前提是准确而廉价地判断”哪些边其实需要重建”——这就是脏检查(dirty checking)。本文讲三件事:脏检查算法、命令哈希(.ninja_log)、以及 restat 短路。前置阅读:构建图模型。
再次澄清:Ninja 不用 mmap。
.ninja_log是文本格式,启动时用一个 256KB 缓冲的LineReader顺序读完(build_log.cc:157)。
1. 脏检查算法
1.1 入口:RecomputeDirty
DependencyScan::RecomputeDirty(graph.cc:49)用一个工作队列驱动递归:std::deque<Node*> 从目标节点出发,对每个节点调 RecomputeNodeDirty,后者深度优先递归其输入。validation 节点(|@)单独收集、不内联递归,避免误报环。
1.2 核心:RecomputeNodeDirty
RecomputeNodeDirty(graph.cc:82)对一个节点判断脏与否:
- 叶子节点(无 in_edge):
StatIfNecessary后,存在即干净、缺失即脏(graph.cc:96)——源文件存在就不脏。 - 环检测:
VisitDone标记短路,VerifyDAG用VisitInStack检测环(graph.cc:101)。 - 加载隐式依赖:首次访问时解析 dyndep 并
dep_loader_.LoadDeps(edge)拉入 depfile/deps-log 里的隐式依赖(graph.cc:151)。加载失败会把边标脏(deps_missing_),强制重建以重新生成依赖。 - 先递归所有输入(graph.cc:163)——必须先于就绪判断,因为 dyndep 可能新增输入。
- stat 所有输出(graph.cc:169)。
- 算”最新输入”(graph.cc:179):遍历输入,任一非 order-only 输入是脏的 → 本边脏;否则记下 mtime 最大的输入
most_recent_input。order-only 输入跳过 mtime 比较(graph.cc:188),这正是 构建图模型 §2.1 提到的语义差别。 - 输出脏判断(graph.cc:204):调
RecomputeOutputsDirty。 - 传播(graph.cc:209):若边脏,每个输出
MarkDirty()。
1.3 mtime 比较的核心一行
RecomputeOutputDirty(graph.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/正数)见 构建图模型 §1,StatIfNecessary(graph.h:53)保证每个节点只 stat 一次。
2. 命令哈希:改了命令行也要重建
只看 mtime 是不够的——如果你把 -O0 改成 -O2,源文件 mtime 没变,但产物必须重建。Ninja 的解法是给每条命令算个哈希存进 .ninja_log,下次比对。
哈希函数 BuildLog::LogEntry::HashCommand(build_log.cc:60):
uint64_t BuildLog::LogEntry::HashCommand(StringPiece command) {
return rapidhash(command.str_, command.len_);
}
比对在 RecomputeOutputDirty(graph.cc:351):
if (!generator &&
BuildLog::LogEntry::HashCommand(command) != entry->command_hash) {
// "command line changed for %s"
return true;
}
注意 command 是 EvaluateCommand(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 LogEntry(build_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 v7(kCurrentVersion = 7,build_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 追加式 + 偶尔重整
这是”速度优先”的又一体现:
RecordCommand(build_log.cc:90)每次只追加一行并fflush,不重写整个文件。同一输出的后写记录覆盖先写的(Load时后者生效,build_log.cc:288)。- 文件实际打开是惰性的:
OpenForWrite(build_log.cc:77)只记路径,真正fopen(..., "ab")推迟到首次写(OpenForWriteIfNeeded,build_log.cc:132)。 - 追加久了会膨胀,于是有重整:
Load时若条目数 > 100且总数 > 唯一数 × 3,置needs_recompaction_(build_log.cc:312)。Recompact(build_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 || generator,FinishCommand 会重新 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::CleanNode(build.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 通过 UpdatePhonyMtime(graph.cc:43)转写到 phony 节点上,让下游看到一个有意义的 mtime,然后返回干净。无输入的 phony 边即使”脏”也保持 outputs_ready(graph.cc:220)。
5.2 deps log 的 mtime 作为依赖时效水位
deps = gcc/msvc 的边,其隐式依赖从 .ninja_deps 读出(见 依赖发现)。LoadDepsFromLog(graph.cc:740)会先 stat 输出,若 output->mtime() > deps->mtime,说明记录的依赖信息已过期(输出比记录时还新,可能是手工动过),直接判定需要重建(graph.cc:756)。这个 deps->mtime 是 FinishCommand 里记录的、命令跑完后输出的 mtime(build.cc:991)。有效的依赖节点随后并入 edge->inputs_,照常参与 §1.3 的最新输入比较——于是某个头文件变新就能通过标准路径让产物变脏。
6. disk_interface:mtime 从哪来
所有 stat/read/write 都经 DiskInterface(disk_interface.h:45)。TimeStamp 就是 int64_t(timestamp.h:31),只比较、不转人类时间。
RealDiskInterface::Stat(disk_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 缓存——
StatAllFilesInDir(disk_interface.cc:126)一次列出整个目录的文件名→mtime 缓存起来。因为 Windows 文件系统单文件 stat 慢,批量列目录能大幅加速(手册里”Windows 上加载依赖文件慢”的痛点之一)。
其余:MakeDir(disk_interface.cc:315)、WriteFile(disk_interface.cc:284)、RemoveFile(disk_interface.cc:336,返回 0 删除/1 不存在/-1 错误)、ReadFile(disk_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 |
下一篇 依赖发现 深入头文件依赖是怎么被采集、压缩、回填的。