Nix evaluation is often quite slow. In this blog post, we’ll have a look at a nice advantage of the hermetic evaluation model enforced by flakes: the ability to cache evaluation results reliably. For a short introduction to flakes, see our previous blog post.

Why Nix evaluation is slow

Nix uses a simple, interpreted, purely functional language to describe package dependency graphs and NixOS system configurations. So to get any information about those things, Nix first needs to evaluate a substantial Nix program. This involves parsing potentially thousands of .nix files and running a Turing-complete language.

For example, the command nix-env -qa shows you which packages are available in Nixpkgs. But this is quite slow and takes a lot of memory:

$ command time nix-env -qa | wc -l
5.09user 0.49system 0:05.59elapsed 99%CPU (0avgtext+0avgdata 1522792maxresident)k
28012

Evaluating individual packages or configurations can also be slow. For example, using nix-shell to enter a development environment for Hydra, we have to wait a bit, even if all dependencies are present in the Nix store:

$ command time nix-shell --command 'exit 0'
1.34user 0.18system 0:01.69elapsed 89%CPU (0avgtext+0avgdata 434808maxresident)k

That might be okay for occasional use but a wait of one or more seconds may well be unacceptably slow in scripts.

Note that the evaluation overhead is completely independent from the time it takes to actually build or download a package or configuration. If something is already present in the Nix store, Nix won’t build or download it again. But it still needs to re-evaluate the Nix files to determine which Nix store paths are needed.

Caching evaluation results

So can’t we speed things up by caching evaluation results? After all, the Nix language is purely functional, so it seems that re-evaluation should produce the same result, every time. Naively, maybe we can keep a cache that records that attribute A of file X evaluates to derivation D (or whatever metadata we want to cache). Unfortunately, it’s not that simple; cache invalidation is, after all, one of the only two hard problems in computer science.

The reason this didn’t work is that in the past Nix evaluation was not hermetic. For example, a .nix file can import other Nix files through relative or absolute paths (such as ~/.config/nixpkgs/config.nix for Nixpkgs) or by looking them up in the Nix search path ($NIX_PATH). So unless we perfectly keep track of all the files used during evaluation, a cached result might be inconsistent with the current input.

(As an aside: for a while, Nix has had an experimental replacement for nix-env -qa called nix search, which used an ad hoc cache for package metadata. It had exactly this cache invalidation problem: it wasn’t smart enough to figure out whether its cache was up to date with whatever revision of Nixpkgs you were using. So it had a manual flag --update-cache to allow the user to force cache invalidation.)

Flakes to the rescue

Flakes solve this problem by ensuring fully hermetic evaluation. When you evaluate an output attribute of a particular flake (e.g. the attribute defaultPackage.x86_64-linux of the dwarffs flake), Nix disallows access to any files outside that flake or its dependencies. It also disallows impure or platform-dependent features such as access to environment variables or the current system type.

This allows the nix command to aggressively cache evaluation results without fear of cache invalidation problems. Let’s see this in action by running Firefox from the nixpkgs flake. If we do this with an empty evaluation cache, Nix needs to evaluate the entire dependency graph of Firefox, which takes a quarter of a second:

$ command time nix shell nixpkgs#firefox -c firefox --version
Mozilla Firefox 75.0
0.26user 0.05system 0:00.39elapsed 82%CPU (0avgtext+0avgdata 115224maxresident)k

But if we do it again, it’s almost instantaneous (and takes less memory):

$ command time nix shell nixpkgs#firefox -c firefox --version
Mozilla Firefox 75.0
0.01user 0.01system 0:00.03elapsed 93%CPU (0avgtext+0avgdata 25840maxresident)k

The cache is implemented using a simple SQLite database that stores the values of flake output attributes. After the first command above, the cache looks like this:

$ sqlite3 ~/.cache/nix/eval-cache-v1/302043eedfbce13ecd8169612849f6ce789c26365c9aa0e6cfd3a772d746e3ba.sqlite .dump
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE Attributes (
    parent      integer not null,
    name        text,
    type        integer not null,
    value       text,
    primary key (parent, name)
);
INSERT INTO Attributes VALUES(0,'',0,NULL);
INSERT INTO Attributes VALUES(1,'packages',3,NULL);
INSERT INTO Attributes VALUES(1,'legacyPackages',0,NULL);
INSERT INTO Attributes VALUES(3,'x86_64-linux',0,NULL);
INSERT INTO Attributes VALUES(4,'firefox',0,NULL);
INSERT INTO Attributes VALUES(5,'type',2,'derivation');
INSERT INTO Attributes VALUES(5,'drvPath',2,'/nix/store/7mz8pkgpl24wyab8nny0zclvca7ki2m8-firefox-75.0.drv');
INSERT INTO Attributes VALUES(5,'outPath',2,'/nix/store/5x1i2gp8k95f2mihd6aj61b5lydpz5dy-firefox-75.0');
INSERT INTO Attributes VALUES(5,'outputName',2,'out');
COMMIT;

In other words, the cache stores all the attributes that nix shell had to evaluate, in particular legacyPackages.x86_64-linux.firefox.{type,drvPath,outPath,outputName}. It also stores negative lookups, that is, attributes that don’t exist (such as packages).

The name of the SQLite database, 302043eedf….sqlite in this example, is derived from the contents of the top-level flake. Since the flake’s lock file contains content hashes of all dependencies, this is enough to efficiently and completely capture all files that might influence the evaluation result. (In the future, we’ll optimise this a bit more: for example, if the flake is a Git repository, we can simply use the Git revision as the cache name.)

The nix search command has been updated to use the new evaluation cache instead of its previous ad hoc cache. For example, searching for Blender is slow the first time:

$ command time nix search nixpkgs blender
* legacyPackages.x86_64-linux.blender (2.82a)
  3D Creation/Animation/Publishing System
5.55user 0.63system 0:06.17elapsed 100%CPU (0avgtext+0avgdata 1491912maxresident)k

but the second time it is pretty fast and uses much less memory:

$ command time nix search nixpkgs blender
* legacyPackages.x86_64-linux.blender (2.82a)
  3D Creation/Animation/Publishing System
0.41user 0.00system 0:00.42elapsed 99%CPU (0avgtext+0avgdata 21100maxresident)k

The evaluation cache at this point is about 10.9 MiB in size. The overhead for creating the cache is fairly modest: with the flag --no-eval-cache, nix search nixpkgs blender takes 4.9 seconds.

Caching and store derivations

There is only one way in which cached results can become “stale”, in a way. Nix evaluation produces store derivations such as /nix/store/7mz8pkgpl24wyab8nny0zclvca7ki2m8-firefox-75.0.drv as a side effect. (.drv files are essentially a serialization of the dependency graph of a package.) These store derivations may be garbage-collected. In that case, the evaluation cache points to a path that no longer exists. Thus, Nix checks whether the .drv file still exist, and if not, falls back to evaluating normally.

Future improvements

Currently, the evaluation cache is only created and used locally. However, Nix could automatically download precomputed caches, similar to how it has a binary cache for the contents of store paths. That is, if we need a cache like 302043eedf….sqlite, we could first check if it’s available on cache.nixos.org and if so fetch it from there. In this way, when we run a command such as nix shell nixpkgs#firefox, we could even avoid the need to fetch the actual source of the flake!

Another future improvement is to populate and use the cache in the evaluator itself. Currently the cache is populated and cached in the user interface (that is, the nix command). The command nix shell nixpkgs#firefox will create a cache entry for firefox, but not for the dependencies of firefox; thus a subsequent nix shell nixpkgs#thunderbird won’t see a speed improvement even though it shares most of its dependencies. So it would be nice if the evaluator had knowledge of the evaluation cache. For example, the evaluation of thunks that represent attributes like nixpkgs.legacyPackages.x86_64-linux.<package name> could check and update the cache.