Nix *could* be a great build system

Wouldn’t it be great if one could use nix as the only build system for large projects? That is, completely ditch make/cmake/bazel/etc and just use nix-build. The project can be divided into multiple small packages, each being built as a separate derivation, so that nix-build will perform granular rebuilds on changes.

That is, of course, not a new idea at all. For example, see this post and discussion: Nix that looks like Bazel

I tried it out and there is one major problem with it. Nix doesn’t allow us to have compiler caches which persist across multiple builds. For example, there’s no way to have a non-ephemeral and mutable GOCACHE directory. As a consequence, each time you build a go package, all its dependencies will be compiled from scratch. That might be extremely slow. It’s not only Go; many modern language toolchains will have the same problem (ZIG_GLOBAL_CACHE_DIR is another example).

Ironically, the problem is not about nix lacking some feature. It’s about nix having too many fences and not allowing the user to simply disable them when they are meaningless for the problem at hand.

To clarify: yes, I know about extra-sandbox-paths config. It doesn’t really help much, because we are then blocked by another fence: nix uses multiple build users; therefore, the first build can successfully create the cache in an extra path directory, but subsequent builds will fail because the compiler can’t work with cache due to lack of write permissions.

We really, really need a way to have persistent caches across builds (and it should be easy to do so: requiring to pass a flag to nix-build is ok; having to edit global nix.conf is not).

8 Likes

This stems from a mismatch of priorities: Nix cares more about perfect reproducibility of your stack than in-dev performance. In cases where you care about compiler caches (or simply object files in the C world) nix currently is simply the wrong tool for the job.

The path that people usually take is to use ecosystem-specific tooling (e.g. go) during development, with nix devshells to serve system library dependencies, and to then use nix exclusively as a system integration/CI build tool to then build the production artifacts. This gives you compiler artifacts when you need them (i.e. when you recompile and change little things until it stops throwing errors), and reproducibility when you need it (when you want to publish a binary and be certain you’re not throwing some random developer laptop’s malware at your customers).


IMO the underlying problem is that our industry keeps reinventing the build system wheel for every language under the sun. Each language has subtly different build mechanisms, and they all don’t integrate very well, but because the tooling exists and is standard for the respective languages it’s almost impossible to substitute other tooling, especially if you care about keeping up with the ecosystem standard (to make it easier for new devs who interact with your dev stack).

Without committing the gargantuan effort of perfectly reproducing each respective ecosystem’s build system in a transparent way that still gives you all the nix advantages (which is often impossible, since the ecosystem-specific build systems are usually not designed with reproducibility in mind), you can’t really have your cake and eat it too. Nix’ ecosystem-adapted build tooling lives in the twilight zone, where things are just convenient enough that you can build, but not so high-effort that keeping up with upstream changes becomes infeasible (especially given the relatively small number of nix ecosystem developers).

As such, the true solution to this problem is to work towards a real standard for this process cross-ecosystem. At the end of the day all we’re all doing is taking some input files and producing some output files, with some intermediate results which we’d like to keep track of for work avoidance; this shouldn’t be so complex. Begin by standardizing dependency handling so we don’t have two dozen different “crate”/“project”/etc. “registries”…

With that said, the idea of exposing artifact caches comes up every once in a while, but it’s heavily resisted since it’d break reproducibility guarantees. Doing that is quite bad even if you don’t care about reproducibility at all, because now the entire cache model becomes problematic; if a faulty compiler cache on some build machine can cause cache poisoning, you need way more tooling and UI/UX to handle those edge cases than nix currently has.

It’s also not as trivial as you’d hope; all the various languages have different expectations for how their build artifacts are persisted. You’d need lots of language-specific handling to make this work. Often atime/mtime matters, nix builds are quite allergic to that.


All that out of the way, BuildStream (a project I was part of what feels like half a lifetime ago) has an interesting feature that lets you pierce this exact veil during development; it has a concept of a “workspace” that is effectively a checkout of a project in your system tree. It allows developing/changing files in it, which it will then reuse during the build. This can give you incremental builds.

Give it a shot, it’ll show you all the downsides of doing this pretty quickly. Other than that it’s a perfectly cromulent build tool IMO, just falls short on performance (at least back when I still worked with it, projects written in python have their limitations) and lacks the ecosystem adoption nix has (as well as the turing-complete config language, while I agreed that that was a reasonable idea at the time, nix has convinced me that that was a mistake - all the other tools use imperative languages, a functional language definitely seems like the right call).

15 Likes

That feels like a bug we can address to make it work?
(CC @Ericson2314 for this thoughts)

2 Likes

I do indeed want Nix to be a great build system. I am open to eventually letting language-specific tooling do their own impure caching, but I see it as a last resort — I would much rather work with compilers so they can do caching the pure Nix way. (It’s a bit complicated to explain how this works, but you can imagine the compiler creates dynamic derivations which invoke the compiler on very small inputs like individual functions, etc.) My hunch is that is Bazel etc. simply haven’t tried hard enough to make things both fast and pure :), and people have fallen back on these unsafe caches too soon.

How in general are multiple builds using the same build cache synchronized? I would say the right choice of hacky duck tape :slight_smile: depends on that. Some possible are:

  • One cache per build user
  • (ab)use the post-build hook to chown after build finishes
7 Likes

I don’t think that pre- or post-build hooks would generally work. Consider a scenario with two concurrently running builds, A and B. A creates some directory in cache; then B will fail if it tries to write something to that directory (before A is finished).

That would be better than nothing, however:

  1. I can’t think up how to achieve this easily;
  2. If there are many build users, we’ll have many cache misses.

It’s perfectly normal to run several compiler processes concurrently operating on the same cache, as long as they are launched by the same user. It’s the compiler’s responsibility to synchronize cache accesses.

OK then it sounds like one should arrange for a group group write permissions to have files that any build user can modify? Maybe some umask stuff is needed, but should work?

Been a while since the last time I tested this but used to work just fine:

      {
        programs.ccache.enable = true;
        programs.nix-required-mounts.enable = true;
        programs.nix-required-mounts.allowedPatterns."ccache" = {
          onFeatures = [
            "ccache"
            "sccache"
          ];
          paths = [
            config.programs.ccache.cacheDir
            "/var/cache/sccache"
          ];
        };
        nix.settings.system-features = [
          "ccache"
          "sccache"
          "kvm"
        ];
        systemd.tmpfiles.settings."50-sccache" = {
          "/var/cache/ccache" = rec {
            "d" = {
              user = "root";
              group = "nixbld";
              mode = "0770";
            };
            "Z" = d;
          };
          "/var/cache/sccache" = rec {
            "d" = {
              user = "root";
              group = "nixbld";
              mode = "0770";
            };
            "Z" = d;
          };
        };
      }

3 Likes

I tried relaxing umask before compiler invocations. That indeed fixes the cache situation.

However, the binaries produced by the compiler are also, quite naturally, affected by this umask. Copying them into $out as-is will trigger yet another fence in nix, it fill complain about “suspicious” permissions.

This might be fixed by chmod-ing the binaries before installing them. I didn’t try that yet.

The amount of effort I need to spend trying to work around nix features I don’t need in the first place (like multiple build users) saddens me. I’d rather work on interesting projects rather than fight the tooling.

You can just disable that entirely if you want, you know?

I assumed you wanted sandboxing for most things, and just wanted this carefully-crafted sandboxing “hole”, but if you don’t care about sandboxing at all, you can just turn it off!

How can I tell nix not to use multiple build users? And, moreover, do so for one particular project builds, not for the whole nix installation?

You might be interested in nix-ninja: GitHub - pdtpartners/nix-ninja: Ninja-compatible incremental build system for Nix (lightning talk at NixCon: https://www.youtube.com/watch?v=KVVPkArpRWM).

It gives you a form of incremental builds by splitting builds into small derivations (ideally per file), the nix store then essentially acts as a cache.

But I agree that it would be great to more easily be able to reuse build tool specific caching/incremental build. For the permissions issue, would using the auto-allocate-uids feature avoid the problem? Otherwise it would be nice if nix would somehow map all build users to a single real ID on the filesystem, but that probably requires certain linux features.

3 Likes

I think that works but not inside nix-build if you want to wrap the whole thing in Nix

Nix → language tool → Nix dynamic derivations

MAYBE I was thinking if the derivations are small enough then something like ccache is actually just a /nix/store entry where each entry is a .o file for the given source.

Is that what you mean?

You can set __noChroot = true; on the derivation

1 Like

I was imagining dynamic derivations is one wants to go even finer-grained than one .c and .o regular compilation unit. This would generally require compiler support to e.g. parse the file and split it up into individual functions (definitions), but that’s OK.

I am not saying this stuff will be fast as an LSP server, with a single process, incremental parsing and everything else, etc. But it still should be pretty good!

3 Likes

In an imaginary future system that uses even impure caches for fast CI and sharing amongst developers, there should still be a slower hermetic build that runs less frequently but can mark earlier YOLO builds as false. They can then be purged from the cache.

1 Like

In a cyberpunk future, you can just symbolically execute makefiles into dynamic derivations and all other build systems become obsolete. Assimilate your compiler invocations to the (of)borg. :stuck_out_tongue:

1 Like

I tried it and it still uses nixbld* users, so that doesn’t fix the problem with permissions:(

I’m not sure about that. Wouldn’t we still get different users for different builds with that feature?

You need to have sandbox mode set to “relaxed” in your nix.conf for it to work.

I know. I did. It does work and it does disable the sandbox. It still uses nixbld* user pool.

Reading the documentation for build-users-group, it seems like if you removed all but one user from this group, then you would have a consistent UID for Nix builds?

Alternatively,

If the build users group is empty, builds will be performed under the uid of the Nix process