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
.nixthunk files on disk, onenix buildat the end. Works with any recent Nix daemon. - Sandbox / dyn-drv — shims call
nix derivation addinside abuilder-rpc-v0sandbox and submit the final drv as the outer derivation's output.nix build .#helloJust Works, and so doesnix run .#hello— the flake exposes a real derivation, not just a resolvable string.
# 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/zlibBoth 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=hellois 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=alladds 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.
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-v0Every part of that is load-bearing:
-
./.patched-nix/bin/nix— it must be the patched Nix, not whatever is onPATH. Your system Nix will get surprisingly far (it evaluates the flake and starts the outer derivation) and then fail inside the build witherror: Submit outputs for a currently running derivation not supported by store 'local'because
nix store submit-outputdoes 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 andbuiltins.outputOf. Note the plural inca-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-v0—mkNixggBuildsetsrequiredSystemFeatures = [ "builder-rpc-v0" ], so without this the derivation is simply unbuildable on this machine. -
-Lvis 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/hellonixgg 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.
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.
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:
- Phase 1 (
unpackPhasethroughbuildPhase) runs as abuilder-rpc-v0sandboxed derivation with nixgg's shims live onPATH— realconfigurePhase, real setup hooks (autoreconfHook,cmake, ...), realmake/ninja, whatever the package actually does, with everycc/c++/arinvocation turned into its own dynamic derivation exactly likemkNixggBuild.nixgg assemblethen walks the resulting tree, resolves every shimmed output, and submits the whole tree as one dynamic derivation output. - Phase 2 (
checkPhasethroughdistPhase) is an ordinary derivation seeded from phase 1's fully-resolved tree, running the package's own unmodifiedcheckPhase/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).
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).
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.
- 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 developbootstraps it, ornix build .#patched-nixif you would rather invoke it directly — see Invoking sandbox mode explicitly for the full flag set and what each flag is for.
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.
MIT. See LICENSE.