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 输出未变 |
| 二进制缓存 | 远程 substituter | narinfo 可用且签名受信 |
三层任一层命中都能省时间。调优顺序建议:先保命中(缓存),再提速度(并行/远程),最后压 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 包 monorepo | eval 45s | 拆 flake-parts 模块 + 减少 withSystem | eval 8s |
| 本地编译 LLVM | 90 分钟 | 远程 32 核 builder | 12 分钟 |
| 小包串行 | 40 分钟 | max-jobs 1 → 8 | 9 分钟 |
| 重复下载 | 每次 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 构建调试与错误排查。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。