Notes · Dissecting Real Systems

growing

There Are No Phases

Buck2's DICE engine collapses load, analysis, and execution into a single graph — and the decision to rebuild rather than adopt.

· · 7 min read

build-systems, buck2, dice, incremental-computation, dissecting-systems

Simplicity is prerequisite for reliability.

— Edsger W. Dijkstra, EWD498 (1975)

Cite this
APA
Mangalapilly, Y. J. (2026, June). There Are No Phases. Saṃhitā Notes. https://yesudeep.com/blog/there-are-no-phases/
BibTeX
@online{mangalapilly2026there,
          author  = {Yesudeep Jose Mangalapilly},
          title   = {There Are No Phases},
          journal = {Sa\d{m}hit\=a Notes},
          year    = {2026},
          month   = {June},
          url     = {https://yesudeep.com/blog/there-are-no-phases/},
          urldate = {2026-07-02},
        }
Plain
Yesudeep Jose Mangalapilly. “There Are No Phases.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/there-are-no-phases/.
RIS
TY  - ELEC
        AU  - Mangalapilly, Yesudeep Jose
        TI  - There Are No Phases
        T2  - Saṃhitā Notes
        PY  - 2026
        UR  - https://yesudeep.com/blog/there-are-no-phases/
        Y2  - 2026-07-02
        ER  - 

The third of five pieces on build systems. By the end you'll understand why Buck2 folds loading, analysis, and execution into a single graph on its DICE engine, how "early cutoff" reuses unchanged work, and the case for rebuilding a tool from scratch rather than retrofitting an existing one.

Last time we saw how Bazel's Skyframe discovers a build graph by letting computations ask for their dependencies. Bazel still keeps a conceptual seam, though: a loading/analysis world that produces the action graph, and an execution world that runs it. Buck2, Meta's from-scratch rewrite, made a sharper bet — that the seam itself is the problem. Its position, stated flatly in the repo's own docs/about/why.md:

Buck2 is not phased — there are no target graph/action graph phases, just a series of dependencies in a single graph on DICE that result in whatever the user requested.

No phases. One graph. Everything — parsing a target, configuring it, analyzing it, running the action — is a node in the same incremental structure. This piece is about what that buys, and why a company that already had a build system — and could have adopted Bazel — decided it was worth building a new engine to get it.

A phase is a schedule imposed on a graph that already knows its own order.

Phases are a convenience that costs you

Why do build systems have phases at all? Because it's the obvious way to think: first figure out what to build (the graph), then build it. Load, analyze, execute. Each phase consumes the previous one's output.

The trouble is that a phase boundary is a barrier. In a strictly phased build, nothing in execution can start until analysis is completely done, even if the first action's inputs were ready long ago. And the two halves keep separate bookkeeping — separate caches, separate notions of "what changed" — that must be kept consistent. The phase split is easy to reason about and quietly expensive to run.

A phased build is a factory with three stations: every part must clear inspection before any part gets painted, and every part must be painted before any gets assembled — so the slowest item holds the whole batch at each gate. Buck2 is a craftsman who takes one order and does whatever that order needs next — inspecting, painting, assembling on demand. No gates, and nobody waits on a stranger's part.

The strongest witness for that cost is Bazel itself, which has spent years dismantling its own barrier: since Bazel 7 (2023), "Skymeld" interleaves the analysis and execution phases by default, so a target's actions can start as soon as its analysis finishes rather than waiting for everyone's. When the incumbent removes the wall between two of its three phases, the wall was a cost, not a principle.

Buck2's claim is that the split was never essential. If loading, analysis, and execution are all just computations that depend on other computations, then a single engine that tracks dependencies precisely can interleave them — and, in the words of the same doc, "sometimes parallelise different phases and track changes very precisely." An action whose inputs are ready can run while a sibling target is still being analyzed. There is no barrier because there is nothing to bar.

Phased vs. unified. A phase boundary forces a barrier — execution waits for all of analysis. One graph lets ready work start immediately.

DICE: one trait, the whole engine

The engine is called DICE — Dynamic Incremental Computation Engine, per its own README — and you'll find it under dice/ in the source. Like Skyframe, almost everything hangs from a single abstraction. Here it's the Key trait, from dice/dice/src/api/key.rs:

#[async_trait]
pub trait Key: ... {
    type Value: ...;
    async fn compute(&self, ctx: &mut DiceComputations, ...) -> Self::Value;
    fn equality(x: &Self::Value, y: &Self::Value) -> bool;
}

A Key computes a Value, and reaches its dependencies through the DiceComputations context passed into computectx.compute(other_key). If that reads like Skyframe's Environment#getValue, it should: both are demand-driven engines where asking for a dependency records the edge. The difference is uniformity. In Buck2 a "target node," a "configured target node," and an "action node" are all just Key implementations in the same graph, so a dependency from an action back to the analysis that produced it is an ordinary edge, not a cross-phase handoff.

Early cutoff, by a different name

The second half of any incremental engine is deciding what to not recompute, and DICE's answer is a small method on that same trait — equality. Its doc comment says exactly what it's for:

If computed value is equal to previously cached value, DICE won't invalidate graph nodes depending on this node.

This is Skyframe's dirty-versus-changed distinction wearing Rust. When an input changes, DICE recomputes the nodes that depended on it — but if a node recomputes to a value equality deems equal to the cached one, the invalidation stops there. Nodes above it are never touched. Reformat a BUILD file without changing the targets it declares, and the parse node recomputes, returns an equal value, and not a single downstream action rebuilds.

Early cutoff: a recomputed node whose value is unchanged halts invalidation; the work above it is reused.

The pattern is now familiar enough to name as a principle: a good incremental engine doesn't propagate change events, it propagates value differences, and it stops the instant a value stops differing. Skyframe and DICE arrived at the same rule independently, because it's the rule the problem demands.

What the single graph unlocks

Collapsing the phases isn't just tidiness; it changes what's possible.

Because everything is one graph, Buck2 can support dynamic dependencies — a rule can inspect the output of an action and decide, at that point, what further actions to run. In a phased system that's awkward: you'd have to know the full action graph before execution begins, but here the dependency is just another edge discovered the same way as any other. Analysis and execution interleaving is the default, not a special case.

It also means rules are not special. Even Buck2's core C++ and Rust build rules are ordinary Starlark, computed by ordinary =Key=s, not privileged paths baked into the binary. The engine doesn't know or care whether a node is "a phase" — it only knows keys, values, and edges.

Why rebuild instead of adopt

Which brings us to the real question: Meta already used Buck, and Bazel existed and had already solved most of this. Why write a new engine?

Meta's own announcement gives the practical reasons: Buck2 had to stay compatible with existing Buck targets and macros so an enormous monorepo could migrate in place; it was designed around remote execution and Meta's virtual filesystems from day one; it ships as one binary; and internally Meta measured it "around 2x as fast as Buck1." Adopting Bazel would have meant migrating the repo and still not getting those.

Under the practical reasons sits the architectural one this piece cares about: the unification couldn't be retrofitted. Bazel's phase split has historically been load-bearing in its architecture — Skymeld is the multi-year effort of removing one seam of it. Getting to "one graph, no phases, dynamic dependencies, rules-are-data" wasn't a feature you could add; it was a different center of gravity. When the thing you want is a property of the core, a rewrite can be cheaper than a retrofit — not always, but here Meta judged it so, and the result is an engine where the phase barrier simply doesn't exist to work around.

Buck2 is also honest about what it gave up to get there. Per its own README, local-only builds aren't yet hermetic — the guarantee holds under remote execution, which the engine treats as the primary path. Both engines, meanwhile, read their build files in the same deliberately restricted language, and the next piece asks why that language was designed unable to loop — and where the two implementations quietly disagree. The content-addressed, Merkle-tree machinery that lets a whole team share one cache — where "proportional to your change" becomes "proportional to anyone's" — is the last piece in the arc.

Lessons

  • A phase boundary (load \to analyze \to execute) is a barrier: nothing in a later phase can start until the previous one fully finishes. Collapsing the phases into one graph lets ready work run immediately.
  • Buck2's DICE engine treats target, configured-target, and action nodes as ordinary =Key=s in a single graph — so a dependency across what used to be a phase boundary is just an edge.
  • Early cutoff (DICE's equality) is Skyframe's dirty-vs-changed by another name: a recomputed node whose value is unchanged halts invalidation. Good engines propagate value differences, not change events.
  • One graph unlocks dynamic dependencies and makes rules ordinary data, not privileged binary paths — which is why the unification was worth a rewrite rather than a retrofit.

References

  1. Buck2: why?.” — · the DICE design doc · the DICE source
  2. Dynamic dependencies in Buck2.”
  3. salsa.” — and incremental computing for the shared lineage with Skyframe

How to cite

APA
Mangalapilly, Y. J. (2026, June). There Are No Phases. Saṃhitā Notes. https://yesudeep.com/blog/there-are-no-phases/
BibTeX
@online{mangalapilly2026there,
          author  = {Yesudeep Jose Mangalapilly},
          title   = {There Are No Phases},
          journal = {Sa\d{m}hit\=a Notes},
          year    = {2026},
          month   = {June},
          url     = {https://yesudeep.com/blog/there-are-no-phases/},
          urldate = {2026-07-02},
        }
Plain
Yesudeep Jose Mangalapilly. “There Are No Phases.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/there-are-no-phases/.
RIS
TY  - ELEC
        AU  - Mangalapilly, Yesudeep Jose
        TI  - There Are No Phases
        T2  - Saṃhitā Notes
        PY  - 2026
        UR  - https://yesudeep.com/blog/there-are-no-phases/
        Y2  - 2026-07-02
        ER  - 

Annotations

Thank you — your note is held for review and will appear once approved.

Thank you — your note is published.

Please sign in below to leave a note.

Type to search · ↑↓ to move · ↵ to open · Esc to close