Skip to content
 
 

Repository files navigation

nixgg

A gg-style build accelerator built on Nix content-addressed derivations.

Intercept every -c compile, ar archive, and link with a shim. Turn each invocation into a content-addressed Nix derivation. Nix decides what's cached and what needs building — nixgg just constructs the expressions.

Two modes for producing derivations, same drv-hashes either way:

  • Native — shims write .nix thunk files on disk, one nix build at the end. Works with any recent Nix daemon.
  • Sandbox / dyn-drv — shims call nix derivation add inside a builder-rpc-v0 sandbox and submit the final drv as the outer derivation's output. nix build .#hello Just Works, and so does nix run .#hello — the flake exposes a real derivation, not just a resolvable string.

Try it

# 1. Enter a shim-enabled shell.
nix develop

# 2. Native: your normal build system, drvs materialised on demand.
cd example
make          # compiles + auto-force-links via NIXGG_AUTOFORCE=1
./hello

# 3. Sandbox: same source, whole graph as dynamic Nix derivations.
nix build .#hello
./result/bin/hello
# or just:
nix run .#hello

# 4. Real projects, sandbox mode, out-of-tree sources pinned in flake.lock.
nix build .#lua         # lua 5.4.7 — 32 TUs, 1 archive, 1 link
nix build .#fmt         # {fmt} 11.0.2 — cmake + ninja + libfmt.a
nix build .#mosh        # mosh unstable — autoconf + protobuf + openssl/ncurses/zlib

Both modes produce byte-identical .drv files. nix build .#lua gets an instant cache hit from an earlier native build in an extracted lua source tree, and vice versa.

That holds by construction rather than by discipline: the build command is rendered once, in Go, and sandbox mode bakes it into a JSON drv while native mode passes the same text through a thunk for nix/resolve-script.nix to fill in the few values only Nix knows at eval time. The two modes cannot disagree about flag quoting or argument order because there is only one place that decides either.

Two tests, covering different failure modes:

  • tests/drv-equivalence.sh — the invariant. 149 drvs across five fixtures: hello (3), lua (37), fmt (3), mosh (38), gcc (68), every one matching byte-for-byte between the two modes. ~25 min; ONLY=hello is a 35-second smoke of the same machinery.
  • tests/smoke.sh — every example builds, its artifact is at the FHS path it should be, and it runs. ~2 min; EXAMPLES=all adds redis, ffmpeg and llvm.

The second exists because the first structurally cannot catch a whole class of bug: it compares drv hashes and never realises an output, so it stayed green at 149/149 while a change to output placement left native mode unable to collect any artifact at all.

Invoking sandbox mode explicitly

The nix build .#hello above assumes you are inside nix develop, which supplies the patched Nix and the experimental features. To run it from an arbitrary shell, spell out all four:

nix build .#patched-nix -o .patched-nix     # one-time; substituted from cache

./.patched-nix/bin/nix build .#hello -Lv \
  --store 'local?root=/tmp/incremental' \
  --extra-experimental-features "ca-derivations dynamic-derivations" \
  --extra-system-features builder-rpc-v0

Every part of that is load-bearing:

  • ./.patched-nix/bin/nix — it must be the patched Nix, not whatever is on PATH. Your system Nix will get surprisingly far (it evaluates the flake and starts the outer derivation) and then fail inside the build with

    error: Submit outputs for a currently running derivation
           not supported by store 'local'
    

    because nix store submit-output does not exist in it.

  • --store 'local?root=…' — an alternative store. Sandbox mode registers derivations from inside a running build, which a normal daemon store refuses.

  • ca-derivations dynamic-derivations — content-addressed outputs and builtins.outputOf. Note the plural in ca-derivations; Nix treats an unknown feature name as a warning, not an error, so a typo here fails later and confusingly.

  • --extra-system-features builder-rpc-v0mkNixggBuild sets requiredSystemFeatures = [ "builder-rpc-v0" ], so without this the derivation is simply unbuildable on this machine.

  • -Lv is optional, but sandbox mode does its interesting work inside a build, so without it you see none of the [nixgg] lines.

One wrinkle worth knowing: result symlinks to a /nix/store/… path that does not exist on your real filesystem, because the artifact lives under the alt-store root. Read it there instead:

/tmp/incremental/nix/store/…-bin-hello/bin/hello

Use it in your own project

nixgg is a flake input. Pull in mkNixggBuild and call it with your own source, target, and build command — same function every example in this repo uses.

Two things a consuming flake needs beyond the call itself: the experimental features that dynamic derivations require, and a Nix that can serve builder-rpc-v0. Both are shown below.

# flake.nix
{
  inputs.nixgg.url = "github:tomberek/nixgg";

  # mkNixggBuild's output is a `builtins.outputOf` node, and its outer
  # derivation asks for the builder-rpc-v0 system feature. Without
  # these, `nix build` fails at eval with "experimental Nix feature
  # 'dynamic-derivations' is disabled". Nix prompts you to trust these
  # the first time; after that it Just Works.
  nixConfig = {
    extra-experimental-features = [
      "ca-derivations"
      "dynamic-derivations"
      "configurable-impure-env"
    ];
    extra-system-features = [ "builder-rpc-v0" ];
  };

  outputs = { self, nixpkgs, nixgg }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
      mkNixggBuild = nixgg.packages.${system}.mkNixggBuild;
    in
    {
      packages.${system}.default = (mkNixggBuild {
        pname = "myproject";
        version = "0.1.0";
        src = ./.;
        target = "myproject";      # basename the link shim submits as `out`
        nativeBuildInputs = [ pkgs.pkg-config ];
        buildInputs = [ pkgs.zlib ];
        buildCommand = ''
          make -j"$NIX_BUILD_CORES"
        '';
      }).result;
    };
}

You also need a Nix that implements builder-rpc-v0 and nix store submit-output — that work lives on NixOS/nix master and is not in a released Nix yet. This flake exposes the build it pins:

# One-time: get a capable nix (substituted from cache.nixos.org).
nix build github:tomberek/nixgg#patched-nix -o ./.patched-nix

# Build your project with it.
./.patched-nix/bin/nix build .

Stock Nix is fine for nixgg's native mode (thunks on disk, one nix build at the end); the patched Nix is only needed to consume a mkNixggBuild result, which is sandbox mode.

See examples/*/default.nix for real-world call sites (lua, {fmt}, mosh, redis, ffmpeg, and a 3-phase LLVM build). If your build execs one of its own binaries mid-build — codegen and bootstrap tools do this — read examples/llvm/default.nix: that needs two or more chained mkNixggBuild calls, since a not-yet-realised output can't be run.

mkNixggBuild's parameters:

param required meaning
pname yes naming only
version no (default "0") naming only
src yes the source tree
target yes path of the final artifact; its basename is matched against the link/archive shim's -o to decide what gets submitted as the derivation's output
buildCommand yes shell run inside the sandbox once shims are on PATH — typically make/cmake --build/ninja
nativeBuildInputs no build-time tools (compilers, generators, pkg-config)
buildInputs no libraries the build links against
propagatedBuildInputs no passed through to the underlying stdenv.mkDerivation

Every mkNixggBuild call also returns a .shell — a plain mkShell mirroring the sandbox's exact stdenv environment. nix develop into it when you need to reproduce a sandbox build by hand; it's what tests/drv-equivalence.sh uses to run the native side under the same tool env.

Upgrade an existing nixpkgs package

mkNixggBuild is for builds you write yourself. dynDrvStdenv is for builds nixpkgs already wrote — any ordinary stdenv.mkDerivation package gets the builder-rpc-v0 treatment with a one-line override, no rewriting its package.nix. Same prerequisites as mkNixggBuild (see Use it in your own project above for the nixConfig block and patched-nix):

{ pkgs, nixgg }:

let
  dynDrvStdenv = nixgg.packages.${pkgs.system}.dynDrvStdenv { stdenv = pkgs.stdenv; };
in
pkgs.hello.override { stdenv = dynDrvStdenv; }

Tested directly against real nixpkgs packages spanning the common build-system shapes — hello (autotools), mosh (autotools + autoreconfHook), zstd (cmake, 4 outputs, custom checkPhase running ctest, plus a package that execs one of its own binaries mid-build — see below). All build, install to the right outputs, run real per-translation-unit shim acceleration (every cc/c++/ar call becomes its own content-addressed derivation, same as mkNixggBuild), and pass their own installCheckPhase/checkPhase unmodified.

How it works

dynDrvStdenv overrides mkDerivationFromStdenv — the same seam nixpkgs' own pkgsMusl/pkgsStatic/ccache use, just with a much smaller radius: it changes how a package's derivation gets built, not the toolchain or the whole package set. Every override is scoped to the one package you apply it to via .override { stdenv = ...; }; nothing else in your pkgs set changes.

Under the hood it splits stdenv.mkDerivation into two real derivations:

  1. Phase 1 (unpackPhase through buildPhase) runs as a builder-rpc-v0 sandboxed derivation with nixgg's shims live on PATH — real configurePhase, real setup hooks (autoreconfHook, cmake, ...), real make/ninja, whatever the package actually does, with every cc/c++/ar invocation turned into its own dynamic derivation exactly like mkNixggBuild. nixgg assemble then walks the resulting tree, resolves every shimmed output, and submits the whole tree as one dynamic derivation output.
  2. Phase 2 (checkPhase through distPhase) is an ordinary derivation seeded from phase 1's fully-resolved tree, running the package's own unmodified checkPhase/installPhase/fixupPhase/ installCheckPhase/meta — so multi-output splitting, RPATH shrinking, ctest/test-suite execution, and install-time checks all still work exactly as nixpkgs wrote them, against real binaries (not unresolved stubs).

Packages that exec their own binaries mid-build

A handful of build systems (cmake's add_custom_target(... DEPENDS some-tool) being the common case) compile a helper tool and immediately exec it as part of the same build — zstd's contrib/gen_html renders zstd_manual.html this way. Inside a builder-rpc-v0 sandbox that fails: the shim's link step for the helper leaves an unresolved placeholder in place of a real executable (nothing inside the sandbox resolves dynamic-derivation outputs synchronously), so ./gen_html errors with "Permission denied".

Fix it with the same phase-chaining pattern mkNixggBuild's own examples/two-phase and examples/llvm already use: build the helper standalone via mkNixggBuild first, then patch the wrapped package's build graph to call that already-resolved binary instead of building its own. The patch has to go through dynDrvStdenv's extraPhase1Attrs parameter, not a plain .overrideAttrs — nixpkgs' own .override/.overrideAttrs reapplication contract always re-invokes the package function with its original, unpatched attrs first, so an attrs-level patch applied via .overrideAttrs can never reach phase 1 (confirmed directly: doing it that way produced a byte-identical phase-1 derivation to the fully unpatched build). extraPhase1Attrs/extraPhase2Attrs are spliced in before phase 1 is computed, at the dynDrvStdenv { ...; } call site itself:

{ pkgs, mkNixggBuild, dynDrvStdenv }:

let
  genHtml = mkNixggBuild {
    pname = "zstd-gen-html";
    version = "0";
    src = pkgs.zstd.src;
    target = "gen_html";
    buildCommand = ''
      cd contrib/gen_html
      g++ -O2 -c gen_html.cpp -o gen_html.o
      g++ gen_html.o -o gen_html
    '';
  };
in
pkgs.zstd.override {
  stdenv = dynDrvStdenv {
    stdenv = pkgs.stdenv;
    extraPhase1Attrs = finalAttrs: old: old // {
      postPatch = old.postPatch + ''
        substituteInPlace build/cmake/contrib/gen_html/CMakeLists.txt \
          --replace-fail \
            'add_executable(gen_html ''${GENHTML_DIR}/gen_html.cpp)' \
            "" \
          --replace-fail \
            'DEPENDS gen_html COMMENT "Update zstd manual")' \
            'COMMENT "Update zstd manual")' \
          --replace-fail \
            'set(GENHTML_BINARY ''${PROJECT_BINARY_DIR}/gen_html''${CMAKE_EXECUTABLE_SUFFIX})' \
            'set(GENHTML_BINARY ${genHtml.package}/bin/gen_html)'
      '';
    };
  };
}

See examples/zstd-dyndrv/default.nix for the full, tested version. extraPhase1Attrs's old is phase 1's own attrset as dynDrvStdenv built it (already carrying the real package's postPatch, plus dynDrvStdenv's own shim-activation postPatch/preBuild) — not the raw, unmodified package.nix attrs — so appending to old.postPatch, as above, preserves everything already there. extraPhase2Attrs is the same shape for phase 2, mostly for symmetry: phase 2 is reachable via an ordinary .overrideAttrs on the returned package (confirmed directly — only phase 1 has the reapplication problem).

Cache an existing package's configure step

dynDrvStdenv accelerates the whole build/install split. Sometimes you don't need that much — you just want configure to stop rerunning every time you touch something downstream, like installFlags or a postInstall. configureCacheStdenv splits stdenv.mkDerivation at the configure/build boundary instead. It doesn't need the builder-rpc-v0 sandbox at all, because configure isn't compiling anything unknown — there's nothing to shim:

{ pkgs, nixgg }:

let
  configureCacheStdenv = nixgg.packages.${pkgs.system}.configureCacheStdenv;
in
pkgs.hello.override { stdenv = configureCacheStdenv { stdenv = pkgs.stdenv; }; }

Changing anything after configure — installFlags, postInstall, whatever — no longer reruns or rehashes configure. Configure's own output is also content-addressed, so if a rerun happens to produce identical output, the build after it doesn't rerun either.

For the stronger case — an unrelated source-file edit shouldn't rerun configure at all — pass configureSrcFilter to shrink configure's own src down to just the files it reads, via a small content-addressed filter derivation. (Not lib.fileset: that needs a real path at eval time, and most packages' src is a fetcher derivation that hasn't been built yet.)

let
  configureSrcFilterPresets = nixgg.packages.${pkgs.system}.configureSrcFilterPresets;
in
pkgs.hello.override {
  stdenv = configureCacheStdenv {
    stdenv = pkgs.stdenv;
    configureSrcFilter = {
      includePatterns = configureSrcFilterPresets.autotools;
      existenceStubs = [ "src/hello.c" ]; # hello's own AC_CONFIG_SRCDIR arg
    };
  };
}

configureSrcFilterPresets ships starting points for autotools and cmake, and they're just that — starting points, not guarantees. An under-inclusive pattern list doesn't fail loudly; it silently caches a stale configure output. Test by building the package, not by reading the pattern list. hello alone needed more than the obvious Makefile.am/configure.ac — also *.in/*.mk templates, and its whole po/ directory, which is easier to include wholesale than to enumerate. existenceStubs covers the opposite case: a file that's only ever checked for existence, never read, like autoconf's AC_CONFIG_SRCDIR — safe to stub out as empty.

Not every cmake package can use configureSrcFilter at all: if a package's own CMakeLists.txt collects its sources with file(GLOB ...) over a whole directory (zstd does this), configure needs that entire directory present regardless of any filter — there's no smaller include list that helps. fmt lists its sources explicitly, so filtering it actually does something; see .#fmt-cache-filtered for the working example, and tests/configure-cache-cutoff.sh for an automated check that the caching itself holds (not just that the build succeeds): it edits a file the filter excludes and one it includes, and asserts configure's own output only changes for the latter.

Architecture

Every shim writes a derivation. Nix does the rest. See ARCHITECTURE.md for the design and the shim mechanics. See dyn-drv/NOTES.md for the sandbox / dynamic-derivation exploration notes.

Requirements

  • Nix ≥ 2.36 for sandbox mode (needs builder-rpc-v0 + nix store submit-output, both merged into NixOS/nix master via #15793). Native mode works with older Nix.
  • The flake pins its own Nix build; nix develop bootstraps it, or nix build .#patched-nix if you would rather invoke it directly — see Invoking sandbox mode explicitly for the full flag set and what each flag is for.

Prior art

Inspired by gg (Stanford SNR, ATC '19: From Laptop to Lambda). gg models every build step as a content-addressed thunk that a scheduler can dispatch to a cluster of workers. nixgg keeps the model but drops the scheduler in favor of the one nixOS already ships — the Nix store, its evaluator, and its remote-build machinery. Every gg thunk becomes a Nix derivation; every gg fingerprint becomes a Nix output path.

Related work in the same shape:

  • nix-ninja — emits dynamic derivations from Ninja build graphs. Similar sandbox mechanism (builder-rpc-v0), rust implementation, targets the meson/cmake→ninja pipeline.
  • sandstone — Haskell-module-per-derivation via recursive-nix.
  • NixOS/nix#15793 — the upstream PR that added builder-rpc-v0 + nix store submit-output. Now merged into master.

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages