Nix 求值与构建性能优化:从 eval 剖析到远程构建

Nix 的时间花在两个阶段:求值(eval)与构建(build)。前者常被忽视却是「改一行等一分钟」的元凶,后者则受调度与缓存策略支配。本文详解 eval 剖析与缓存、惰性求值反模式、并行构建与 remote builders、store 优化与 GC 策略。

1. 性能的两个阶段:求值与构建

很多人抱怨「Nix 慢」,但慢在哪儿往往说不清。事实上 Nix 的执行分成两个截然不同的阶段:

阶段做什么主要瓶颈优化手段
求值 eval把 .nix 表达式算成 derivation 图单核 CPU、内存、无缓存重复求值eval-cache、减少无谓求值、拆分
构建 build按 derivation 图执行 builder多核 CPU、磁盘 I/O、网络并行、remote builder、缓存

nix build 的总时间 ≈ eval 时间 + 构建时间 + 下载时间。一个小仓库里 eval 可能只要 0.3 秒;但在大型 monorepo 里,eval 动辄 30 秒甚至几分钟,而构建反而因为缓存命中几乎不花时间。

本文与 Nix 构建与 CI 互补:后者讲缓存如何接入流水线,本文讲如何度量并压缩这两个阶段本身。

2. 求值性能剖析

2.1 先学会测量

# 显示各阶段耗时
nix build --timing nixpkgs#hello

# 更细的求值统计(实验特性)
nix build --eval-profiler flamegraph --eval-profiler-output eval.svg nixpkgs#hello

# 只看 eval(不构建)
time nix eval nixpkgs#hello.drvPath

--timing 会打印形如:

evaluated 1234 attributes in 2.3s

若这行数字很大,说明瓶颈在 eval 而非构建。

2.2 用 builtins.trace 定位重灾区

求值开销集中在少数几个函数时,用 builtins.trace 打点最直接:

let
  heavy = builtins.trace "EVAL: building package set" (import ./packages.nix { });
in heavy

若日志里同一行出现多次,说明该表达式被重复求值——这是最常见的性能反模式(见第 4 节)。

2.3 nixpkgs 的求值开销

import <nixpkgs> {} 本身就要几秒。原因:

  • pkgs 是一个巨大 attrset,尽管惰性,但构造 all-packages.nix 的外层仍需遍历
  • overlay 越多,外层构造越慢
  • config 与 overlays 参数会强制求值更多分支
# 对比:裸 import 与带 overlay
time nix eval --expr 'import <nixpkgs> {}' --impure
time nix eval --expr '(import <nixpkgs> { overlays = [ ]; }).hello.drvPath' --impure

3. eval-cache 与求值缓存

3.1 flake 的求值缓存

Nix 会把顶层 flake 输出的求值结果缓存到 ~/.cache/nix/eval-cache-v5/(版本号随 Nix 版本变化)。这解释了为什么「第二次 nix flake show 快很多」。

缓存键包含 flake 的 lock 与源码哈希,所以任何文件改动都会让缓存失效。

# 查看缓存目录
du -sh ~/.cache/nix/eval-cache-* 2>/dev/null

# 清空求值缓存(排障用)
rm -rf ~/.cache/nix/eval-cache-*

3.2 显式开启 eval-cache

# nix.conf
eval-cache = true

注意:eval-cache 只对顶层 flake 输出有效,对 import ./foo.nix 这类中间表达式无效。因此「把热点抽成 flake 输出」也是提速手段之一。

3.3 减少求值的结构性手段

# 反面:每次调用都重新 import 整个 nixpkgs
let pkgsFor = system: import nixpkgs { inherit system; };
in pkgsFor "x86_64-linux"

# 正面:复用同一个 pkgs 实例
let pkgs = import nixpkgs { system = "x86_64-linux"; };
in pkgs

在 flake-parts 场景下,perSystem 已经保证每个系统只有一份 pkgs,不要自己再 import 一遍。

4. 惰性求值陷阱与反模式

4.1 反模式一:不必要的 rec 与 with

# rec 会让整个 attrset 变成自引用,阻碍惰性
rec { a = 1; b = a + 1; }

# with 会扩大搜索路径,求值时需遍历更多作用域
with pkgs; [ git curl ]

with 本身不慢,但嵌套 with 会拖慢求值。nixpkgs 内部已尽量避免,用户代码里也应克制。

4.2 反模式二:builtins.readDir 全量扫描

# 每次求值都扫描目录,且会随文件增加而变慢
builtins.readDir ./packages

若该表达式出现在被频繁求值的位置(如每个 system 的 perSystem),开销会翻倍。缓解办法是把扫描结果放在 flake 顶层的 let 里,只算一次。

4.3 反模式三:lib.optional 与条件构造的深层嵌套

# 深层嵌套列表拼接会构造大量中间列表
lib.flatten (lib.mapAttrsToList (k: v: lib.optionals v.flag [ v ]) attrs)

用 lib.filterAttrs + lib.attrValues 通常更快:

lib.attrValues (lib.filterAttrs (_: v: v.flag) attrs)

4.4 反模式四:忘记 lib.genAttrs 的惰性

# 若某些系统其实不需要构建,可以用 genAttrs 保持惰性
lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system: pkgsFor system)

genAttrs 的每个属性只在被访问时才求值,而手写 { x86_64-linux = ...; aarch64-linux = ...; } 也是惰性的——真正的问题在于 withSystem 会强制求值。

4.5 度量反模式的通用方法

# 求值前后对比 drvPath 计算时间
for i in 1 2 3; do
  /usr/bin/time -p nix eval --raw nixpkgs#hello.drvPath
done

5. 并行构建与调度

5.1 两个关键旋钮

# nix.conf
max-jobs = auto      # 同时构建多少个 derivation(默认 1!)
cores = 0            # 每个构建用多少核(0 = 全部)
  • max-jobs 是「并行构建几个包」,默认是 1,这是新手最大的性能损失来源
  • cores 是「单个构建内部的 make -j 并行度」,通过 NIX_BUILD_CORES 传给 builder

经验法则:max-jobs × cores ≈ 物理核数。例如 16 核机器可以设 max-jobs = 4、cores = 4,兼顾小包并行与大包内部并行。

# 命令行临时覆盖
nix build -j 8 --cores 4 nixpkgs#firefox

5.2 常见错误配置

配置问题
max-jobs = 1(默认)大量小包串行,CPU 空闲
max-jobs = 64, cores = 0内存爆炸(OOM)、磁盘抖动
cores = 1单个大包编译慢(如 LLVM)

5.3 观察并行度

nix build --timing -j 8 nixpkgs#chromium 2>&1 | grep -E 'build|start'
# 或另开终端
nix-store --gc --print-live | wc -l

6. remote builders

6.1 为什么要远程构建

  • 本地是 macOS,但目标是 Linux(必须 Linux builder)
  • 本地机器弱,希望把重活丢给云上的大机器
  • 多架构产物(aarch64 交叉或原生)

6.2 配置 buildMachines

nix.buildMachines = [
  {
    hostName = "builder1";
    system = "x86_64-linux";
    maxJobs = 8;
    speedFactor = 2;
    supportedFeatures = [ "nixos-test" "big-parallel" "kvm" ];
    sshUser = "builder";
  }
  {
    hostName = "builder-arm";
    system = "aarch64-linux";
    maxJobs = 4;
    supportedFeatures = [ "big-parallel" ];
  }
];

nix.distributedBuilds = true;
nix.settings.builders-use-substitutes = true;

关键点:

  • speedFactor 影响调度器把任务派给谁(越大越优先)
  • supportedFeatures 必须匹配 derivation 要求的特性(如 big-parallel、kvm),否则不会被派发
  • builders-use-substitutes = true 让远程 builder 自己也能从缓存拉依赖,减少往返

6.3 验证远程构建生效

nix build -v nixpkgs#hello 2>&1 | grep -i 'building on'
# => building '/nix/store/...drv' on 'builder1'...

6.4 用 nix build 临时指定 builders

nix build --builders 'ssh://builder1 x86_64-linux /home/builder/.ssh/id_ed25519 8 2' nixpkgs#hello

6.5 交叉编译作为替代

若只是想要其他架构产物,交叉编译比远程原生构建更省事,参见 交叉编译与 overlay。两者取舍:交叉编译省机器但可能触发大量补丁与重编译;远程原生构建更可靠但需要目标架构机器。

7. store 优化与 GC 策略

7.1 硬链接去重

# nix.conf
auto-optimise-store = true

开启后每次构建完成会自动尝试硬链接去重,代价是构建后多一步 I/O。已有 store 可以手动优化:

nix store optimise

7.2 自动 GC 水位

nix.gc = {
  automatic = true;
  dates = "weekly";
  options = "--delete-older-than 30d";
};

nix.settings = {
  min-free = 5 * 1024 * 1024 * 1024;   # 剩余低于 5G 时触发
  max-free = 20 * 1024 * 1024 * 1024;  # 回收到剩余 20G
};

min-free / max-free 是构建期保护:当磁盘紧张时,Nix 会自动 GC 而不是让构建失败。

7.3 分析 store 占用

# 按闭包大小排序
nix path-info --all --size | sort -k2 -n | tail -20

# 查看哪些根占着空间
nix-store --gc --print-roots | head -20

7.4 避免「GC 把缓存删了」

GC 只看可达性,不看「这个包以后可能还要用」。频繁 nix-collect-garbage 会导致下次构建重新下载。合理做法是:保留足够的世代与 gc root,用 --delete-older-than 而不是全删。

8. 缓存复用策略

8.1 三层缓存

层位置命中条件
本地 store/nix/store路径已存在
求值缓存~/.cache/nix顶层 flake 输出未变
二进制缓存远程 substituternarinfo 可用且签名受信

三层任一层命中都能省时间。调优顺序建议:先保命中(缓存),再提速度(并行/远程),最后压 eval。

8.2 CI 与本地共享

# CI 构建后推送
nix build .#packages.x86_64-linux.default
nix copy --to https://cache.example.com --secret-key-files ./key ./result

开发者本地配置同一个缓存后,nix build 直接下载。这是把「构建一次」的价值最大化的核心手段。

8.3 保持路径稳定的技巧

  • 避免把时间戳、随机数、当前目录写进 derivation 的输入
  • 用 SOURCE_DATE_EPOCH 固定时间(见 可复现与封闭构建)
  • 固定 src 的过滤规则(lib.cleanSource),避免 target/ 等构建产物进入哈希

9. 度量与调优实战

9.1 一个完整的诊断流程

# 1. 先看总时间构成
nix build --timing .#default 2>&1 | tail -20

# 2. 若 eval 占比高
nix eval --raw .#default.drvPath   # 反复跑,观察是否稳定变慢

# 3. 若构建占比高
nix build -j 8 --cores 4 .#default  # 调并行度
nix build --builders 'ssh://big 8' .#default  # 调远程

# 4. 若下载占比高
nix path-info --store https://cache.example.com -S .#default

9.2 典型优化前后对比

场景优化前手段优化后
20 包 monorepoeval 45s拆 flake-parts 模块 + 减少 withSystemeval 8s
本地编译 LLVM90 分钟远程 32 核 builder12 分钟
小包串行40 分钟max-jobs 1 → 89 分钟
重复下载每次 3G自建缓存 + substituter首次 3G,后续 0

9.3 不要过早优化

eval 30 秒在每天只跑一次的 CI 里无关紧要;但在「保存即构建」的开发循环里就是灾难。先量化,再动手。用 --timing 拿到数字,再决定投在哪一层。

10. 总结

Nix 性能优化的核心是分清 eval 与 build 两阶段,并各自对症下药:

  • eval 靠 eval-cache、减少重复求值与 withSystem、拆分模块来控制
  • build 靠 max-jobs / cores 的合理配比与 remote builders 来提速
  • store 靠 auto-optimise-store、min-free / max-free 与分级 GC 策略来维持健康
  • 缓存是最省力的优化:命中一次胜过调参十次

把 --timing 纳入日常,把缓存接入 CI(见 Nix 构建与 CI),把 GC 交给自动水位,你就能让 Nix 在大型仓库里依然「快得不像话」。排障细节可继续参考 Nix 构建调试与错误排查。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nix」更多文章

  1. NixOS 代际管理与回滚:从 generation 机制到引导项治理
  2. Nix 语言生态打包:Python、Node 与 Rust 的依赖治理
  3. Nix 派生与 Store 内幕:derivation、输入寻址与引用图