Despite how different they look on the surface, Nix, Bazel and buck2 are remarkably similar under the hood. Don’t believe me? I built a prototype in one store, two frontends to prove it. Turns out though that the similarities are even deeper than I realized.
Upon further reflection, I realized that the intensional model is an even more substantial common denominator. What is the intensional model?
TIP I’ve written before about Nix’s intensional model, better known as content-addressed (CA) derivations. It has been “experimental” in Nix for years. 🧪
In the intensional model, outputs are addressed by their content, and actions refer to their inputs by content as well. The extensional model instead addresses an output by the recipe and inputs that produced it. Nix also calls this “content-addressed derivations” (CA derivations). Bazel and buck2 don’t name their model, but they use content-addressing as well. Consider this coincidence: Eelco’s PhD thesis was published in 2006, the same year that Google started development on Blaze, the predecessor of Bazel [cite].
Let’s take a closer look at the three systems with a simple C example and see how all three implement the intensional model. 🥸
// greet.h
void greet(const char *name);
// greet.c
#include <stdio.h>
#include "greet.h"
/* say hi */
void greet(const char *name) {
printf("hello, %s\n", name);
}
// main.c
#include "greet.h"
int main(void) {
greet("world");
return 0;
}
In the traditional extensional model (input-addressed), a change anywhere upstream of a build action invalidates the action’s output. This occurs even if the change is irrelevant to the output, such as a comment change in a source file. In contrast, the intensional model (content-addressed) only invalidates an action’s output if the content of the output itself changes.
In our C example, if we were to change the comment in greet.c from say hi to say hello, the compiled object file greet.o would still be byte-for-byte identical, as the comment does not affect the compiled output. In the ideal world, the linking of hello would not need to be redone, as its inputs have not changed in content.
Nix example
{ pkgs ? import <nixpkgs> {}, ca ? false }:
let
extra = pkgs.lib.optionalAttrs ca { __contentAddressed = true; };
in rec {
# The `cp` is needed otherwise the source file's store path ends up
# in the object file, which is a leak of the extensional model.
greet = pkgs.runCommandCC "greet" extra ''
cp ${./greet.h} greet.h; cp ${./greet.c} greet.c
mkdir -p $out
cc -c greet.c -o $out/greet.o
'';
hello = pkgs.runCommandCC "hello" extra ''
cp ${./greet.h} greet.h; cp ${./main.c} main.c
mkdir -p $out/bin
cc main.c ${greet}/greet.o -o $out/bin/hello
'';
}
By default, Nix uses the extensional (input-addressed) model. If we edit the comment and rebuild:
$ nix-build -A hello
building '/nix/store/3hwrlpnji5bh5bajdg52s7zbg7ihs6j6-greet.drv'...
building '/nix/store/2k5is7yim1j8pb3qjz89bw45cqzcy2y5-hello.drv'.../nix/store/122jc3iz6qkrgm907kdz7rrk3lp4qrma-hello
Everything rebuilt. 😩
The store path in the extensional model is a hash of the recipe, and the recipe for hello includes the recipe for greet. Recursion is fun!
Change one byte anywhere upstream and every path downstream changes with it. 🌀
Warning The two
hellobinaries are not even byte-identical: the cc-wrapper puts$out/libin theRUNPATH, so the binary contains its own store path. The extensional model leaks into the bytes. 🙃
If we enable the build to use content-addressed derivations we observe greet rebuilt, but hello did not. 🎉
$ nix-build --extra-experimental-features ca-derivations -A hello --arg ca true
building '/nix/store/qpa1q7g5yjvhdbnfap4y3r2glzyflyv4-greet.drv'...
resolved derivation: '/nix/store/1xnbxrrprs50879ivzmjpv7cbk44w258-hello.drv' -> '/nix/store/rs2j6r2gvz2vi7nfvqj1s3yxx4lqpa3m-hello.drv'.../nix/store/l2nrycgcg97vrz4nb59icwz9i04ys8jg-hello
You can read demystifying Nix’s intensional model for a more detailed explanation of how CA derivations work, but the short version is that Nix rewrites hello.drv to point at greet’s content-addressed output instead of greet.drv. The resolved rs2j6r…-hello.drv is the same one we built before the edit.
Since no “input” changed, Nix avoids the rebuild.
This is the same behavior as Bazel and buck2, which are intensional by default.
BUILD example
cc_library(
name = "greet",
srcs = ["greet.c"],
hdrs = ["greet.h"],
)
cc_binary(
name = "hello",
srcs = ["main.c"],
deps = [":greet"],
)
If we edit the comment and rebuild with Bazel or buck2, we see that the compile ran, but the link was an action cache hit.
$ bazel build //:hello --explain=explain.log
INFO: 2 processes: 1 action cache hit, 1 internal, 1 linux-sandbox.
$ cat explain.log
Executing action 'Compiling greet.c': action changed since cached execution.
$ buck2 build //:hello
Commands: 1 (cached: 0, remote: 0, local: 1)BUILD SUCCEEDED
$ buck2 log what-ran
build root//:greet (c_compile greet.c (pic)) local ... clang -c ./greet.c ...
Bazel and buck2 do not have an input-addressed mode. They always use the intensional model. An action’s key is a hash of its command line (to force rebuilds) and the content digests of its inputs.
A change only travels as far as the bytes actually change. This optimization is called early cutoff, and it is the whole selling point of the intensional model.
There is a clear mapping between Nix’s CA derivations and Bazel’s action cache.
| Nix (intensional) | Bazel / buck2 (REAPI) |
|---|---|
| resolved derivation | Action: command + input digests |
realisation: drv output → store path |
action cache: action digest → output digests |
| content-addressed store path | blob in the CAS |
A resolved derivation is a Bazel/buck2 action. 🤯
You may see the output paths in Bazel and buck2 and think they are extensional (i.e. bazel-out/k8-fastbuild/bin/hello), but they are just a convenient place on disk. The real addresses are the digests. buck2 can even make this explicit with declare_output(has_content_based_path=True).
Until dynamic derivations stabilize, people will continue to reach for Bazel and buck2 for the granularity on large builds. The intensional model is a big part of why they are so fast, and Nix has the same model. The two implementations are nearly identical in concept.
Nix described the intensional model in 2006. Google started building on it the same year. Twenty years later, maybe it’s time they compared notes. 🥸