+++
date = "2024-01-27"
draft = false
path = "/blog/build-systems-ca-tracing"
tags = ["build-systems", "nix"]
title = "Build systems: content addressed tracing"
+++
An idea I have lying around is something I am going to call "ca-tracing" for
the purposes of this post. The concept is to instrument builds and observe what
they actually did, and record that for future iterations such that excess
dependencies can be ignored if, *even if inputs changed*, the instructions are
the same and the files actually observed by the build are the same.
# Implementation
## Assumptions
This idea assumes a hermetic build system, since we need to know if anything
might have differed from build to build, so we need a complete accounting of
the inputs to the build. It is not necessarily the case that such a hermetic
build system would be Nix-like, however, it is easiest to describe on top of a
Nix-like; first one with build identity, then one that lacks build identity
like Nix.
This also assumes a content-addressed build system with early cut-off like Nix
with [ca-derivations]. In Nix's case, input-addressed builds are executed, then
renamed to a content-addressed path: if a build with different inputs is
executed once more with the same output, it is recorded as resolving to that
output, and further builds are cut off.
[ca-derivations]: https://www.tweag.io/blog/2021-12-02-nix-cas-4/
## Conceptual implementation
Conceptually, a build is a function:
> (*inputs*, *instructions*) -> *outputs*
We wish to narrow *inputs* to *inputsactual*, and save this
information alongside *outputs*. In a following build, we can then verify if
*instructions'* matches a previous build (*instructions*) and if so, extract
the values of the same dynamically observed *inputs'actual*, but
relative to *inputs'* and compare them to the values of
*inputsactual* from the previous build.
Since our build system is hermetic, if this hits cache, it can be assumed to have
identical results, modulo any nondeterminism (which we assume to be
unfortunate but unproblematic, and is there regardless of this technique).
## Making it concrete
A build ("derivation" in Nix) in a Nix-like system is a specification of:
* Inputs (files, other derivations)
* Environment variables
* Command to execute
The point of ca-tracing is to remove excess inputs, so let's contemplate how to
do that.
### File names
The inputs are files named based on `hash(contents)` in Nix, but we don't
know which contents we will actually access. This is a problem, since the file
paths of *inputs* need to remain constant across multiple executions of the
build (the paths for *inputs* must equal the paths for *inputs'*), since the
part of *inputs* that changed may be irrelevant to this build.
In a system that doesn't look like Nix, the input file paths might be the same
across two builds on account of not containing hashes, so this would not be a
problem.
We can solve the file names problem by replacing the hash parts in the input
filenames with random values per-run. These hashes should never appear, even in
part, in the output, if the builder is not doing things with them that would
render the build non-deterministic.
Unfortunately the file names may appear in the output through the ordering of
deterministic hash tables, for instance, which could be a problem; this exists
in practice in ELF hash tables for instance. Realistically we would need
file-type-specific rewriters to fixup execution output to a deterministic
result following multiple runs.
We would also have to rewrite those hashes within blocks of data read from
within the builder, but that's *possibly* just a few FUSE crimes away to be
able to do live, on-demand.
Following the build, the temporary hashes of the inputs can be substituted for
their concrete values pointing to the larger inputs †.
### Tracing, filesystem
To trace a build, one would have to pull the filesystem activity. This is
possible with some BPF tracing constrained to some cgroup on Linux, so that is
not the hard part.
The data that would have to be known is:
* Observed directory listings with hashes
* Read file names matching *inputs*, with associated hashes
* Extremely annoyingly: `fstat(2)` results for all queried files in inputs
(this is extremely annoying because everything calls `fstat` all the time
pointlessly or to check for files being present, and it includes things like
the length of a file, which could *in principle* cause unsoundness if not
recorded).
This would then all be compared to the equivalent paths in *inputs'* and if the
hashes match, the previous build could be immediately used.
## Avoiding build identity; how would this work in Nix?
Nix is built on top of an on-disk key-value store (namely, the directory
`/nix/store`), which is a mapping:
> Hash -> Value
Thus, we just need to construct a hash in such a way that both Build and Build'
get the same hash value.
We could achieve this by modifying the derivation in a deterministic manner
such that two modified-derivations share a hash if they could plausibly have
ca-tracing applied. Specifically, rewrite the input hashes to something like
the following:
> hash("ca-tracing" + name + position-in-inputs) + "-" + name
When a build is invoked, modify the derivation, hash it, and check for the
presence of a record of a modified-derivation of the same hash, and then check
if the actually-used filesystem objects when applied to *inputs'* remain the
same.
# Use cases
This idea is almost certainly best suited for builds using the smallest
possible unit of work, both in terms of usefulness and likelihood of bugs in
the rewriting. To use the terminology from [Build Systems à la Carte][bsalc],
it is likely most useful for systems that are closer to constructive traces
than deep constructive traces.
[bsalc]: https://www.microsoft.com/en-us/research/uploads/prod/2018/03/build-systems.pdf
For example, if this is applied to individual compiler jobs in a C++ project,
it can eliminate rebuilds from imprecise build system dependency tracking,
whereas if the derivation/unit of work is larger, the rebuild might be
necessary anyway.
# Problems
* There could exist multiple instances of a modified-derivation with different
filesystem activity, due to, say, a bunch of rebuilds against very
differently patched inputs. This system would have to be able to either
represent that or just discard old ones.
* Real programs abuse `fstat(2)` way too much and it's very likely that this
whole thing might not actually get any cache hits in practice if `fstat`
calls are considered. Without visibility into processes we cannot know if
`fstat` calls' results are actually used for anything more than checking if a
file exists.
This might benefit from some limited dynamic tracing inside processes to
determine whether the fstat result is actually read.
* The whole enterprise is predicated on generalized sound rewriting, which is
likely very hard; see below.
## Naive rewriting is a bad idea
The implementation of ca-derivations itself, where it just rewrites hashes
appearing in random binaries with the moral equivalent of `sed`, is extremely
unsound with respect to compression, ordered structures (even NAR files would
fall victim to this), and any other kind of non-literal storage of store paths,
and this approach just adds yet more naive rewriting that is likely to explode
spectacularly at runtime.
Naively rewriting store paths is an extension of the original idea of Nix doing
runtime dependencies by naively scanning for reference paths. However,
crucially, the latter does not *modify* random binaries without any knowledge
of their contents, and the worst case scenario for that reference scanning is a
runtime error when someone downloads a binary package.
Realistically, this would have to be done with a "[diffoscope] of rewriters",
which can parse any format and rewrite references in it. We can check soundness of a
build under rewriting by simply running it more times. The rewriter need
not be a trusted component, since its impact is only as far as breaking your
binaries (reproducibly so), which Nix is great at already!
In an actual implementation, I would even go so far as saying the rewriter
*must not* be part of Nix since it is generally useful, and it is fundamentally
something that would have to move pretty fast and perhaps even have per-project
modifications such that it cannot possibly be in a Nix stability guarantee.
[diffoscope]: https://diffoscope.org/
# Related work
This is essentially the idea of edef's incomplete project [Ripple], an
arbitrary-program memoizer, among other work, but significantly scaled down to
be less general and possibly more feasible. Compared to her project, this idea
doesn't look into processes at all, and simply involves tracing filesystem
accesses to read-only resources in an already-hermetic build system.
Thanks to edef for significant feedback and discussion about this post. You can
[sponsor her on GitHub here][edef-gh] if you want to support her work on making
computers more sound such as the Nix content addressed cache project, tvix, and
also her giving these ideas to Arch Linux developers.
[edef-gh]: https://github.com/sponsors/edef1c
[Ripple]: https://nlnet.nl/project/Ripple/