* ralph(eval): baseline + benchmark harness pureintent cpuTime 23.17s, zest cpuTime 14.93s (median of 5). Methodology: NIX_SHOW_STATS cpuTime, eval-cache off, warm fetcher cache. * ralph(eval) cycle 1: disable NixOS options manual on pureintent documentation.nixos.enable = false. Generating the NixOS manual evaluates every option's doc string. nrThunks 20,400,114 -> 19,201,996 (-5.9%), nrFunctionCalls -6.2%, nrPrimOpCalls -7.5%. Package man pages unaffected. Metric pivoted to deterministic NIX_SHOW_STATS counters: cpuTime is unusable on this thermally-throttling machine (same eval = 11s..21s). * ralph(eval): switch home target zest -> sincereintent (per request) sincereintent home config baseline: nrThunks 5,323,428 / calls 3,227,404 / primops 1,565,120 (deterministic). * ralph(eval) cycle 2: disable home-manager options manpage manual.manpages.enable = false in shared modules/home/default.nix. HM builds home-configuration.nix(5) by default, evaluating every HM option's doc string. sincereintent nrThunks 5,323,428 -> 3,551,679 (-33.3%), calls -35%, primops -39%. * ralph(eval): record cycle-2 pureintent gain (shared HM module) pureintent nrThunks 19,201,996 -> 17,431,437 (-9.2%); cycle-2's manual.manpages.enable=false also feeds pureintent's embedded home-manager via modules/nixos/default.nix. Total pureintent -14.6% from baseline. * ralph(eval) cycle 3: profiling + dead-ends (no behaviour-preserving win) Confirmed: nixpkgs single-instantiation (no 2x trap), unused inputs are lazy (no counter impact), escapeShellArg hotspot is HM file-linking + /etc (not patchable here). Remaining eval cost is feature-bound (embedded HM, vira/kolu, google-cloud-sdk/pandoc/omnix). * ralph(eval) cycle 4: drop heavy home.packages (yt-dlp/lima/omnix/pandoc/gcloud) User-approved behaviour change. Removed from shared modules/home/cli/terminal.nix (kept hledger). pureintent nrThunks 17,431,437 -> 16,989,720 (-2.5%), sincereintent 3,551,679 -> 2,757,201 (-22.4%). Totals from baseline: pureintent -16.7%, sincereintent -48.2%. * ralph(eval): finalize report (final measurement, key findings, cost breakdown)
8.1 KiB
Nix Eval-Time Optimization (Ralph)
Iterative measurement-driven reduction of Nix evaluation time for the two configs that get evaluated most often on this machine:
pureintent— the NixOS system (nixosConfigurations.pureintent)sincereintent— the home-manager config (homeConfigurations."srid@sincereintent")
Methodology
Primary metric: deterministic eval-work counters from NIX_SHOW_STATS=1 —
nrThunks, nrFunctionCalls, nrPrimOpCalls. These count the actual evaluation
work and are byte-identical across runs (independent of CPU clock, load, GC).
Why not
cpuTime/wall? Initially planned, but rejected after measuring: this is a live, thermally-throttling laptop. The same eval (identical nrThunks every run) reported cpuTime climbing 11s → 21s monotonically as the CPU clock dropped under sustained load. Time is meaningless here; counters are exact. A 1% drop in nrThunks is a real 1% less work the evaluator must do, on any machine. Single eval per target — no median needed.
# docs/eval-bench.sh [target] target = pureintent | zest | (both)
# - persistent $HOME=/tmp/ralph-evalhome keeps the fetcher/git cache warm
# - --option eval-cache false forces a full re-eval every run
# - reports nrThunks / nrFunctionCalls / nrPrimOpCalls per target (deterministic)
nix eval --raw .#nixosConfigurations.pureintent.config.system.build.toplevel.drvPath --option eval-cache false
nix eval --raw '.#homeConfigurations."srid@sincereintent".activationPackage.drvPath' --option eval-cache false
No nix store gc between runs (per request). The eval cache is disabled rather
than cleared, so the warm fetcher cache removes input-fetch noise.
Commit rule: commit only if nrThunks improves >3% for a target with no
regression on the other. (Counters have ~zero noise, so any real reduction
counts; >3% is the bar for a "meaningful" cycle.) Behaviour may change only by
dropping inputs/features first confirmed unused.
Profiling tool: nix eval … --eval-profiler flamegraph --eval-profile-file f
then aggregate self/inclusive frames (collapsed-stack format).
Baseline (nrThunks / nrFunctionCalls / nrPrimOpCalls)
| Target | nrThunks | nrFunctionCalls | nrPrimOpCalls |
|---|---|---|---|
| pureintent | 20,400,114 | 12,975,268 | 6,507,025 |
| sincereintent | 5,323,428 | 3,227,404 | 1,565,120 |
Env: nix 2.34.7, x86_64-linux.
Optimization log
(Δ% is on nrThunks vs baseline for the affected target.)
| Cycle | Change | pureintent thunks | sincereintent thunks | Verdict |
|---|---|---|---|---|
| 0 | baseline | 20,400,114 | 5,323,428 | — |
| 1 | pureintent: documentation.nixos.enable = false (drop NixOS options manual; option doc-strings no longer evaluated) |
19,201,996 (−5.9%) | 5,323,428 | ✅ commit |
| 2 | home: manual.manpages.enable = false in shared modules/home/default.nix (drop home-manager options manpage; evaluated every HM option's doc) |
17,431,437 (−9.2%) | 3,551,679 (−33.3%) | ✅ commit |
Note:
modules/home/default.nixis also fed to every Linux system's embedded home-manager viamodules/nixos/default.nix, so cycle 2 reduced pureintent too (its HM user's manpages were previously on).
| 3 | (profiling cycle — no change) investigated nixpkgs double-instantiation, unused inputs, escapeShellArg hotspot | 17,431,437 | 3,551,679 | — no behaviour-preserving win found |
| 4 | home: drop heavy home.packages from shared cli/terminal.nix — yt-dlp, lima, omnix, pandoc, google-cloud-sdk (kept hledger). Behaviour change, user-approved. | 16,989,720 (−2.5%) | 2,757,201 (−22.4%) | ✅ commit |
Dead ends
(investigated, no improvement — recorded so we don't retry)
- nixpkgs is NOT double-instantiated.
nixos-unified.lib.mkLinuxSystemcallsnixpkgs.lib.nixosSystemwithout pinningnixpkgs.pkgs, but the NixOSnixpkgsmodule imports the package set exactly once; thepkgs/top-level/impure.nixframe in the profile is that single import.home-manager.useGlobalPkgs = true(set by nixos-unified) already shares it with the embedded HM. No win available. - Dropping unused flake inputs does not reduce eval counters. Nix input
evaluation is lazy: an input not referenced during a config's eval (e.g.
nixos-hardware,git-hooks— 0 refs) contributes ~0 thunks to that config. Pruning them shrinksflake.lock/nix flakeoverhead only, notnrThunks. escapeShellArg(~72% inclusive on pureintent) is not a fixable hotspot. It is driven byhome-managerfile-linking (files.nix:442, ~43% — one escaped command per managed dotfile) and NixOS/etcgeneration (etc.nix:58). Both scale with how much is managed; nixpkgs/HM own the implementation. Not patchable from this repo.programs.command-not-foundalready disabled by thenix-index-databasemodule; nothing to gain.
Remaining cost is feature-bound
After the two documentation wins, the dominant derivationStrict targets in the
pureintent eval profile are all wanted features (samples ≈ inclusive share):
home-manager-generation/home-manager-files (~43%, embedded HM), /etc
(~29%), vira.service+vira-wrapped+vira-0.1.0.0 (Haskell app), kolu.service,
pipewire, plus heavy packages in home.packages (google-cloud-sdk, pandoc,
omnix — Haskell). Further reductions require trading a feature, not a free
structural change.
Final measurement (median irrelevant — counters are exact)
| Target | nrThunks (base → final) | Δ | nrFunctionCalls | nrPrimOpCalls |
|---|---|---|---|---|
| pureintent | 20,400,114 → 16,989,720 | −16.7% | 12,975,268 → 10,794,555 (−16.8%) | 6,507,025 → 5,327,976 (−18.1%) |
| sincereintent | 5,323,428 → 2,757,201 | −48.2% | 3,227,404 → 1,631,213 (−49.5%) | 1,565,120 → 771,171 (−50.7%) |
Both configs still instantiate cleanly (nix build --dry-run on the system
toplevel / home activationPackage succeeds).
Key findings
- On a thermally-throttling machine, time is a lie — count work instead.
The same eval reported cpuTime from 11s to 21s as the CPU clock sagged, while
nrThunkswas byte-identical every run. Eval-work counters (nrThunks,nrFunctionCalls,nrPrimOpCalls) are deterministic, zero-noise, and machine-independent — a far better optimization metric than wall/cpu time. - Generated option documentation is the biggest free win. The NixOS options
manual (
documentation.nixos.enable) and the home-manager options manpage (manual.manpages.enable) each evaluate the doc string of every option. Disabling both: pureintent −14.6%, sincereintent −33% — with zero behaviour cost (options are searched online). - A shared module multiplies.
manual.manpages.enable = falseinmodules/home/default.nixhelped both the standalone home config and every Linux system's embedded home-manager (viamodules/nixos/default.nix). - Eval is lazy — unused inputs are already free. Pruning unreferenced flake
inputs does not move the counters; it only trims
flake.lock. - The remainder is feature-bound. After the doc wins, eval cost is dominated
by embedded home-manager file-linking,
/etcgeneration, and the closures of wanted services/packages. Trimming 5 heavyhome.packages(user-approved) took sincereintent to −48% total; the rest are features worth their eval cost.
Cost breakdown (per-package eval cost, standalone pkgs.<p>.drvPath thunks)
Shared base floor ≈ 1.09M thunks; the excess is each package's own closure-eval:
| Package | thunks | excess | removed? |
|---|---|---|---|
| yt-dlp | 2.15M | +1.05M | ✅ |
| lima | 1.78M | +0.69M | ✅ |
| omnix | 1.76M | +0.67M | ✅ |
| pandoc | 1.46M | +0.37M | ✅ |
| google-cloud-sdk | 1.39M | +0.30M | ✅ |
| hledger | 1.35M | +0.26M | kept (wanted) |
| ripgrep / fd / just / television | ~1.09M | ~0 | kept (at floor) |