Dependency analysis and ninja file generation¶
After BUILD files are loaded and Target objects are registered, blade
turns the in-memory graph into a set of ninja files: one root
build.ninja plus per-target <pkg>/<name>.build.ninja files included
from it. Most of the implementation effort is in not having to do all
that work every time — both transitive-dep expansion and per-target
ninja generation are cached aggressively across the analysis phase and
across incremental rebuilds.
| File | Role |
|---|---|
src/blade/dependency_analyzer.py |
expanded_deps, topological sort, cycle detection |
src/blade/build_manager.py |
Orchestration; per-target fingerprint and ninja-file caching |
src/blade/backend.py |
Root-file header, global rules, helper templates |
src/blade/target.py |
generate_build / fingerprint computation |
src/blade/util.py |
write_if_changed |
1. Dependency graph¶
Target.deps is the declared dep list from the BUILD. Analysis fills
in Target.expanded_deps: the transitive, deduplicated, topologically
sound list a target actually needs.
_expand_target_deps()recurses throughdeps, with a per-walkroot_targetspath set; revisiting an in-progress target raises aLoop dependencyfatal that names the offending edge._unique_deps()deduplicates while preserving order (keeping the last occurrence), so the relative position relevant to linking is not perturbed.- The expanded list is memoized on the target: every consumer that
asks for
target.expanded_depsafterwards gets the cached value. The reverse direction (expanded_dependents) is filled in the same pass, for the few queries that need it. _topological_sort()runs Kahn's algorithm over the whole build graph. The output order is significant: when generating ninja code for a target, blade may need information already produced for its dependencies (for example, aproto_library's declared generated headers must be visible before acc_librarythat depends on it emits its include lines).
2. Per-target ninja generation¶
A target's generate() method (implemented per subclass) emits a sequence
of ninja build and rule statements via two helpers:
Target.generate_build(rule, outputs, inputs, ...)constructs abuildstatement withinputs,implicit_deps(after|),order_only_deps(after||),implicit_outputs, and per-edgevariables. The result is appended to the target's internal text buffer._NinjaFileHeaderGenerator.generate_rule(name, command, ...)inbackend.pydeclares a global rule (cc, cxx, ar, link, proto, cxxhdrs, ccincchk, ...) once, with all the conventional fields (depfile,deps,restat,pool,rspfile).
The text accumulated in the target buffer is written out to
<build_dir>/<path>/<name>.build.ninja. The root build.ninja then
plain-includes that file (no subninja — the per-target files are
fragments meant to share the global rules).
3. Root build.ninja structure¶
_NinjaFileHeaderGenerator.generate() emits the root file in a fixed order:
- The minimum
ninja_required_versionline andbuilddir = .... - Global pools (e.g. a
heavy_poolwith depth 1 to throttle a few rules that don't parallelize well). - All global ninja
ruledeclarations — every compile/link/codegen rule the workspace might use, regardless of whether any specific target ends up needing them. - One-off build statements such as the SCM stamp.
- An
include <path>.build.ninjaline per generated per-target file, in topological order (so deps come before dependents).
The two wrapper scripts referenced by compile rules (cc_wrapper.sh for
POSIX, cc_wrapper.py for MSVC) and the pickled
inclusion_declaration.data consumed by ccincchk are also written into
the build dir as a side effect of analysis, the first time anything
references them.
4. Incrementality¶
Three caching layers cooperate to avoid redoing work on each run:
(a) Per-target fingerprint —Target.fingerprint() is an MD5 over an
entropy dict that includes the blade revision, the config digest, srcs,
direct deps' fingerprints, the rule type, and the rule's cmd. Each
per-target ninja file starts with #Fingerprint=<hash> on line 1. Before
regenerating, build_manager reads the old fingerprint; if unchanged,
the whole per-target file is reused as-is and nothing else runs for
that target.
(b) write_if_changed in util.py — used for the cc wrappers, the
inclusion-declaration pickle, the per-target ninja files, and (inside the
cc wrappers themselves) for the .incstk files. It compares the new bytes
to the on-disk file and only writes when they differ, preserving mtime
otherwise. Ninja's restat = 1 rule flag then uses that preserved mtime
to prune downstream edges whose only input was a now-unchanged
implicit output (see hdrs check for the full chain
applied to .incstk).
(c) The expanded_deps memoization described above ensures the
transitive dependency walk runs once per target per analysis phase, not
once per consumer.
5. Implementation details and design notes¶
- Topological order is for generation, not execution. Ninja itself
schedules executions purely from the graph. Blade sorts targets so it
can compute fields that one target needs from another (e.g.
declared_genhdrspropagated fromproto_librarytocc_library) before the consuming target emits its rules. - Order-only deps (
||) for generated headers are pervasive. They guarantee the generator runs before any compile that might#includethe generated file, but they do not force a recompile if only the generator's outputs' timestamps moved. Combined withrestaton the generator's rule, this gives "regenerated but unchanged → no rebuild" semantics. The compiler depfile (-MMD) handles actual content-level re-triggering. - Per-target fingerprint covers exactly the load-phase inputs. Targets
with the same fingerprint produce the same
.build.ninjatext, by construction, so reusing the file is safe. Things that don't affect load-phase output (such ascc_testexecution flags) deliberately aren't in the fingerprint. - Targets outside the requested set still get ninja rules. Blade
needs them because they may be transitive deps of requested targets;
--no-testand--generate-packageare the explicit knobs that filter out test/package targets that would otherwise be pulled in. pool = heavy_poolwithdepth = 1is the simplest answer to a few rules whose tools (link-with-LTO, big proto codegen, ...) don't tolerate high parallelism well — they are routed through a 1-wide pool so ninja serializes them automatically.- The inclusion check uses the same caching plumbing. The
ccincchkrule's only inputs are.incstkimplicit outputs of compile rules. Becausewrite_if_changedpreserves the.incstkmtime when the inclusion stack didn't actually change, ninja'srestatskips the check on recompiles that didn't alter#includes — a substantial perf win on incremental builds.
6. UX optimizations¶
- Memoize once, query many.
expanded_depsandexpanded_dependentsare computed in a single pass and reused by every downstream consumer. The cost of "what does this depend on?" goes from O(graph) per query to O(1). write_if_changedeverywhere. Per-target ninja files, inclusion-stack files, wrapper scripts, the inclusion-declaration pickle — all only touch disk when the bytes change. Combined with ninja'srestat, this pushes the granularity of "did anything change?" from "the file was rewritten" down to "the file's contents differ".- Per-target ninja files survive identical regenerations. A no-op edit (whitespace, comment) to a BUILD file that doesn't move the fingerprint costs nothing past the load phase.
- Failures point at the cause.
Loop dependencyreports the offending edge and walks the cycle; missing-dep failures in the hdrs check show the inclusion stack; topological-sort failures (rare) name the unresolved input. The investment in legible diagnostics here is deliberate, because a bad message on a many-thousand-target graph is hard to debug.