让devenv启动变快,以及整个nixpkgs也随之加速 - devenv

Lobsters Hottest 工具

摘要

这篇博文解释了Nix中动态链接器搜索共享库导致devenv和其他Nix工具启动性能问题的原因,并探讨了包括静态链接在内的潜在解决方案。

<p><a href="https://lobste.rs/s/atsrpy/making_devenv_start_fast_whole_nixpkgs">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/26 20:12

# 让 devenv 快速启动——并让整个 nixpkgs 也快起来 - devenv 来源:https://devenv.sh/blog/2026/06/26/making-devenv-start-fast-and-the-whole-nixpkgs-with-it/ https://github.com/cachix/devenv/edit/main/docs/src/blog/posts/making-devenv-start-fast-and-the-whole-nixpkgs-with-it.md 此刻,我和 Farid Zakaria (https://github.com/fzakaria) 坐在 Tacosprint (https://tacosprint.org/) 这里,一同审视困扰 nixpkgs 长达十年的“stat 风暴”。Tacosprint 的桌子前,devenv 的自动激活功能 (https://devenv.sh/auto-activation/) 会在每次 shell 提示符出现时运行 `devenv hook-should-activate`,以判断你是否已进入项目目录。它的工作几乎微不足道:发现项目、检查信任数据库、输出路径。因此,其运行时间纯粹是启动开销,并且每次重新绘制提示符时都会运行。 ``` $ time devenv hook-should-activate /home/domen/dev/myproject real 0m0.070s ... ``` 每次提示符前要花 70 毫秒,每个提示符都这样。而这并非 devenv 需要付出的代价,而是 nixpkgs 的。每个程序在运行自己代码的第一行之前都要支付这个代价:动态加载器必须找到每个共享库,而 Nix 将包分散到存储中的方式使得这个搜索变得缓慢。这并非新闻。这种开销已被测量、记录,甚至部分修复过不止一次,但十年来它一直悬而未决,没有通用的修复方案被合并到 nixpkgs 中。 大部分问题在于动态加载器在寻找一个共享对象,该对象明明就在存储中,但不在它尝试的第一个目录里。加载器敲了 486 次“错门”才找到正确的门,而且几乎所有工作都在 `main` 开始之前完成。这个数字就是整个问题的关键。高于约 30 毫秒,你就必须在钩子上加一层缓存;低于个位数毫秒,你可以在每次提示符时直接运行它,索性扔掉缓存。并且它与闭包成比例:`imagemagick` 的 `magick --version` 会进行 **1225** 次失败的打开操作: ``` $ strace -f -e openat magick --version 2>&1 >/dev/null | grep '\.so' | grep -c ENOENT 1225 ``` 多年来,社区一直在围绕一个真正的修复方案打转。这篇文章将详细阐述问题所在、人们尝试过的各种方法及其权衡,以及我们为 devenv 尝试的一个更激进的方法,以验证它是否可能:通过将整个程序链接成一个静态二进制文件来彻底删除动态加载器。针对这个普遍问题的跟踪 issue 是 NixOS/nixpkgs#481620 (https://github.com/NixOS/nixpkgs/issues/481620)。 ## 为什么 Nix 让加载器如此辛苦 在传统发行版中,每个共享库都位于少数几个全局目录中,例如 `/usr/lib`。动态加载器的搜索路径很短且大多已被缓存,`ld.so.cache`(由 `ldconfig` 构建)将 soname 查找变成了哈希表命中。Nix 的设计则截然不同。每个包都位于自己独立的 `/nix/store/<哈希>-<名称>/lib` 目录中,并且不存在用于存储库的全局 `ld.so.cache`。为了让二进制文件找到其依赖项,Nix 在 ELF 头中记录了一个 `DT_RUNPATH`,其中 **为每个依赖项列出一个目录**。一个链接了五十个库的程序会获得一个包含数十个条目的 `DT_RUNPATH`。 现在回想一下 glibc 在存在 `DT_RUNPATH` 时如何解析 `DT_NEEDED` soname:它按顺序遍历 `DT_RUNPATH` 中的每个目录,在每个目录中尝试打开 `目录/soname`,直到成功为止。因此,如果要解析 N 个库,而路径包含 M 个目录,则需要大约 N 乘以 M 次 `openat()` 尝试,其中几乎所有的尝试都会失败。这就是 stat 风暴。 情况还会变得更糟。对于它搜索的每个目录,glibc 首先会探查 CPU 对应的 `glibc-hwcaps` 子目录(`x86-64-v3`、`x86-64-v2` 等),这相当于在每台现代机器上为每个目录又增加了大约三次失败的打开操作。在具有热缓存的快速 SSD 上,这一切都不明显。但在慢速磁盘、网络文件系统、冷缓存或低功耗 ARM 板上,这会导致响应速度从敏捷变为迟钝,并且这个开销会随着 shell 脚本生成的每个进程而倍增。 具体来说,我们最密切追踪的两个工作负载: | 工作负载 | 加载的库 | `DT_RUNPATH` 目录数 | 失败的 `.so` 打开次数 | |---|---|---|---| | `devenv version` | 83 | 12 (叶二进制) | ~486 | | `imagemagick magick --version` | 91 | 35 | ~1225 | 二进制文件的 `DT_RUNPATH` 越广,其传递依赖图越深,风暴就越严重。 ## 一个好的修复方案必须保留什么 这个问题长期未解决的原因在于,显而易见的修复方案会破坏人们所依赖的东西。任何认真的解决方案都要接受一系列评判标准的检验: - **`LD_LIBRARY_PATH` 覆盖**。NixOS 通过将 `/run/opengl-driver/lib` 放入 `LD_LIBRARY_PATH` 来注入 GPU 驱动。如果修复方案导致这个机制失效,图形功能就会出问题。 - **`LD_PRELOAD`**。拦截器和垫片仍然必须能够优先加载。 - **libGL / glvnd 运行时切换**。一个针对 Mesa 构建的程序必须在运行时能够使用供应商驱动。 - **两个具有相同 soname 的库**。这是 Nix 模型的核心:一个闭包的不同部分可以合法地依赖于同一个 soname 的不同构建版本,并且解析必须保持每个对象独立。 - **`dlopen`**。运行时加载的插件是一个相关但独立的问题。 - **交叉编译**。必须运行目标加载器的修复方案无法干净地进行交叉编译。 - **磁盘和闭包大小**。你添加的任何元数据都会包含在每个 NAR 中。 - **维护负担**。glibc 或加载器的补丁必须针对每个新的 glibc 版本进行重基,而修补 glibc 会重建整个世界。 到目前为止,没有一种方法能勾选所有框。有趣的部分在于每种方法选择了放弃哪些框。 ## 方法 1:使用绝对路径固化解析 最简单的想法:将每个 `DT_NEEDED` 条目从裸 soname(如 `libfoo.so.1`)重写为它所解析到的库的绝对存储路径。glibc 有一个“斜杠短路”机制:包含 `/` 的 `DT_NEEDED` 会直接打开,跳过所有搜索。没有搜索意味着没有风暴,甚至连 `glibc-hwcaps` 探查也不会发生。 这是一个已经长期研究的领域: - Farid Zakaria (https://github.com/fzakaria) 的 **shrinkwrap** 和 **nix-harden-needed** 工具正是以外部后处理的方式实现这一点。Shrinkwrap 在论文 *Mapping Out the HPC Dependency Chaos* (https://ar5iv.labs.arxiv.org/html/2211.05118) (Zakaria, Scogland, Gamblin, Maltzahn, 2022; arXiv:2211.05118) 中有描述,该论文直接测量了风暴:Emacs 的启动从 1823 次 `stat`/`openat` 系统调用降至 104 次,加速了 36 倍;一个包含 900 个库的 MPI 应用程序在 NFS 上启动 2048 个进程,从 344.6 秒降至 47.8 秒,快了 7.2 倍。这些 NFS 数据最清楚地证明:这种开销在本地热缓存上不可见,但在网络或冷文件系统上会变得极其严重。 - patchelf PR #357 (https://github.com/NixOS/patchelf/pull/357) (`--shrink-wrap`,自 2021 年开放) 将所有传递的 `DT_NEEDED` 拉到顶层二进制文件上,并将它们重写为绝对路径。 - Spack 在 HPC 世界也有一个类似的 `bind` 特性。 - 在 nixpkgs 内部,这种机制已经以临时方式在数十个包中使用。 在评判标准清单上,代价是巨大的。绝对路径 **会丢失 `LD_LIBRARY_PATH` 覆盖**,因此 glvnd 驱动切换会失效,并且你需要为 libc、加载器本身、GL 栈和 initrd 维护一个豁免列表。也没有运行时回退:如果固定的路径不存在,程序就无法启动。 这里还有一个构建时的分岔点。要将 soname 重写为绝对路径,首先必须解析它,有两种方法:**运行二进制文件自身的加载器** 并记录 glibc 实际选择了什么,或者 **静态地遍历 `DT_RUNPATH`** 并自行解析。前者是精确的,但会执行目标代码,因此无法进行交叉编译;后者可以干净地进行交叉编译。绝对路径工具只实现了前者,这就是为什么它始终是手动、每个包的工具,而不是默认设置。静态遍历正是 ELF 注释缓存(方法 3)后来所依赖的技术。 因此,绝对路径是零磁盘、最高速度的选择,对自包含的叶应用程序很有吸引力,但由于覆盖语义而无法作为默认设置。 ## 方法 2:RUNPATH 符号链接农场 如果问题是加载器搜索了太多目录,那么给它一个目录。农场想法最早由 Linus Heckemann (https://github.com/lheckemann) 提出(参见 #24844 (https://github.com/NixOS/nixpkgs/pull/24844)):对于每个 ELF,创建一个单一的目录,其中包含指向该 ELF 所需库的符号链接,并将其 `DT_RUNPATH` 设置为那个单一的目录。 关键细节在于 `DT_NEEDED` 中的 soname 保持短名称。农场只改变了它们被发现的位置,而不是方式。因为农场位于 `DT_RUNPATH` 中,而加载器会在 `LD_LIBRARY_PATH` 之后才查阅它,所以每个覆盖都能继续工作。并且它只使用现成的 `patchelf --set-rpath` 和符号链接来构建,无需 glibc 或 patchelf 的分支,也从不执行目标二进制文件,因此它可以交叉编译。 但保持 soname 短名称也正是它破坏 Nix 模型的地方。农场目录是一个基于 soname 的平面命名空间,因此它只能容纳一个 `libfoo.so.1`。当一个闭包合法地引入了同一个 soname 的两个不同构建版本时(正是 Nix 允许的情况),农场无法同时表示两者,而 glibc 基于 soname 的去重会将它们合并为最早加载的那个。绝对路径(方法 1)绕过了这个问题,因为存储路径成为了键;而故意保留裸 soname 的农场则无法做到。 其余代价是存储污染和 hwcaps 底线。每个 ELF 都会获得自己额外的符号链接目录,因此存储中会充满实际上是对真实库的阴影的农场目录。此外,农场消除了每个目录的乘数,但 **没有** 消除每个 hwcaps 的乘数:加载器仍然会在那一个农场目录内探查 `glibc-hwcaps`。因此,这是一个大的常数因子优化,而不是渐近优化。效果有多大完全取决于你对图表的多少部分进行了农场化: | 农场化范围 | 失败的打开次数 | 减少量 | |---|---|---| | `imagemagick`,仅二进制文件(宽 35 目录 `DT_RUNPATH`) | 1225 → ~213 | 83% | | `devenv`,仅叶二进制文件(窄 12 目录 `DT_RUNPATH`) | 486 → 392 | 19% | | `devenv`,整个图(每个依赖项都使用钩子构建) | 486 → 88 | 82% | 两个 devenv 行就是教训。仅农场化叶文件几乎没什么效果,因为那里的风暴主要由 83 个库之间相互解析造成,而仅叶文件的农场从未涉及这些。只有全图采用才能达到 82%,而剩余的 88 次是不可减少的 hwcaps 探查,而不是真正的库搜索。因此,当包自身的二进制文件具有宽的 `DT_RUNPATH` 时,农场会立即见效,但对于闭包密集的应用程序,需要全图采用。 ## 方法 3:ELF 注释中的每个 DSO 解析缓存 这是最雄心勃勃的方法,并且在评判标准清单上是最佳的。这个想法由 pennae (https://github.com/pennae) 在 #207893 (https://github.com/NixOS/nixpkgs/pull/207893) 中设计:让 `patchelf` 向每个库写入一个小的 `PT_NOTE`,记录每个 `DT_NEEDED` soname 加载器应在何处找到它。一个打过补丁的 glibc 在加载过程中,在 `LD_LIBRARY_PATH` 步骤之后、`DT_RUNPATH` 遍历之前读取该注释,并直接从中解析依赖项。将其放在 `LD_LIBRARY_PATH` 之后读取正是保证安全的原因:覆盖、`LD_PRELOAD` 和 glvnd 切换都能继续工作,并且基于 soname 的去重保持不变,因为 soname 仍然是短名称。 每个缓存条目要么是确切路径,直接打开无需搜索,因此没有 hwcaps 探查;要么是目录提示,用于处理在构建时无法解析的罕见情况(`$ORIGIN` 相对条目,或自身包含 `glibc-hwcaps` 树的目录)。这是唯一一种保留所有语义、不增加任何闭包引用、并且也消除了 hwcaps 底线的方法。 pennae 的原始基准测试显示,一个 armv7 工作负载从 44 秒降至 29 秒(秒,而非毫秒,在 `strace -cf` 下测量),系统调用减少了约 24000 次。在我们对一个经过复兴和清理的版本进行的端到端测试中,一个带有注释的二进制文件以 **零** 次失败的搜索探查解析了其依赖项,而同一个不带注释的二进制文件则经历了完整的风暴,同时 `LD_LIBRARY_PATH` 覆盖仍然优先。 在所有方法中,它的代价是最重的。它需要 **两个** 源码更改:一个 glibc 补丁,使加载器能够理解注释;以及一个 patchelf 更改来写入注释。这是一个阶段的彻底重建,因为修补 glibc 会重建整个世界。pennae 的草案因缺乏“可以”或“不行”的决定而关闭,而非任何技术故障;提出的主要担忧是长期维护一个 glibc 补丁。 ## 方法 4:Guix 风格的每个包 ld.so.cache Guix 在生产中通过为每个包提供一个 `ld.so.cache` 来解决同样的问题,这是 `ldconfig` 生成的相同二进制格式,并使用打过补丁的加载器来查阅它(在其博文 *Taming the 'stat' storm with a loader cache* (https://guix.gnu.org/en/blog/2021/taming-the-stat-storm-with-a-loader-cache/) 中有描述;#207061 (https://github.com/NixOS/nixpkgs/issues/207061) 为此提议用于 nixpkgs)。它保留了 `LD_LIBRARY_PATH`,并且已经在规模上得到验证,但构建缓存需要目标架构的 `ldconfig`/`ldd`,这会破坏交叉编译,并且会遇到 `buildEnv` 冲突和 `dlmopen` 命名空间问题。ELF 注释(方法 3)在一定程度上是对此的回应:它静态地读取 `DT_NEEDED` 和 `DT_RUNPATH`,从不运行外部二进制文件,因此在没有这些代价的情况下保持了相同的 `LD_LIBRARY_PATH` 保证。 ## 方法 5:通过静态链接删除加载器 上述四种方法都让加载器的工作变得更轻松。而静态链接则是直接移除加载器。对于 devenv,一个自包含的 CLI,我们进行了尝试:通过 `pkgsStatic` 构建整个闭包(这意味着使用 musl,因为 glibc 不支持完全的静态链接)将 `devenv version` 和 `hook-should-activate` 从约 70 毫秒降至约 16 毫秒。 | 构建配置 | 加载的库 | 启动时间 | |---|---|---| | 基线(全部动态,glibc) | 83 | ~70ms | | 完全静态(musl) | 0 | ~16ms | 这不是一个 nixpkgs 修复方案,也从未打算成为这样的方案。删除加载器也删除了加载器在运行时所做的所有事情:按需加载插件、遵守驱动和拦截器覆盖、切换到 GPU 供应商的 GL 栈。nixpkgs 的很多东西都依赖于这些,因此静态链接永远不能成为通用的默认方案。它对 devenv 有效,仅仅是因为 devenv 是一个自包含的 CLI,通过自己链接的 C API 与 Nix 通信,不需要这些功能。 有一件事让我们感到惊讶:在 16 毫秒时,虽然加载器已经移除,但 devenv 仍然远高于静态 musl hello world 启动所需的约 2 毫秒,其余部分来自 `execve` 映射镜像以及 devenv 自身的启动工作。即便如此,16 毫秒对于 shell 钩子来说已经足够快,可以放弃每个目录的激活缓存,只需在每个提示符时直接运行检查即可。 ## macOS 呢? macOS 使用不同的加载器 `dyld`,不存在这种风暴。Darwin 上的 Nix 已经实现了方法 1:每个 Mach-O 将其依赖项记录为 `LC_LOAD_DYLIB` 中的绝对存储路径,而不是裸 soname,并且不携带任何 `LC_RPATH`。因此 `dyld` 在第一个尝试的路径上直接打开每个库,而系统框架则直接来自内存中的 dyld 共享缓存,无需访问磁盘。glibc 的 `devenv` 进行了约 486 次失败的打开,而 macOS 上的 devenv 基本上没有。 macOS 上的启动开销是 Nix 特有的。为了决定是否将 `x86_64-darwin` 作为额外平台进行通告,libstore 在启动时 fork 了一个子进程来运行 `arch -arch x86_64 /usr/bin/true`,这在 Apple Silicon 上的每个 Nix 进程中花费了约 13 毫秒。修复方案通过 `stat` Rosetta 2 的固定安装路径(大约 0.01 毫秒)来回答同样的问题 (NixOS/nix#16067 (https://github.com/NixOS/nix/pull/16067))。 ## 并列对比 每一列都框定为你 *想要* 的属性,因此 ✅ 总是好的,❌ 总是代价。 图例: ✅ 是 · ⚠️ 有注意事项 · ❌ 否 · ➖ 不适用。 标记 ⚠️ 或值得额外说明的注意事项在下方脚注中。 | 方法 | 无需 glibc 分支 | 无需 patchelf 变更 | 磁盘开销小 | 保留 `LD_LIBRARY_PATH` / glvnd | 保留重复 soname | 消除 hwcaps 底线 | 交叉编译 | 维护负担轻 | |---|---|---|---|---|---|---|---|---| | 绝对路径 | ✅ | ⚠️¹ | ✅ | ❌ | ✅ | ✅ | ⚠️² | ✅ | | 符号链接农场 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | | ELF 注释 | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | | Guix style cache | ❌ | ❌ | ⚠️³ | ✅ | ✅ | ✅ | ❌ | ❌ | | 静态链接 | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ### 脚注 1. **绝对路径 / patchelf 变更**:编写注释的 patchelf 是一个变更。现有的 shrinkwrap 工具是外部的,但通常使用 `LD_PRELOAD` 或加载器模拟来完成解析,这使其对交叉编译不那么友好。一个静态解析的绝对路径重写(从未实现)不依赖 patchelf,但需要一种不同的元数据机制。 2. **绝对路径 / 交叉编译**:仅当解析是静态完成时才支持交叉编译。 3. **Guix style cache / 磁盘开销**:缓存本身很小,但相关的 `buildEnv` 碰撞机制增加了复杂性,并且缓存文件的生成需要目标架构的 `ldconfig`。 4. **ELF 注释 / 维护负担**:glibc 补丁必须针对每个新的 glibc 版本进行重基,并且 patchelf 变更必须与注释格式保持同步。 5. **静态链接 / 保留重复 soname**:静态链接天生将每个依赖项冻结为一个特定的版本;没有运行时动态性。 6. **静态链接 / 交叉编译**:静态链接很容易交叉编译,但仅限于自包含的程序。 7. **符号链接农场 / 保留重复 soname**:农场是一个基于 soname 的平面命名空间,无法表示相同的 soname 具有不同构建版本的情况。 8. **符号链接农场 / 消除 hwcaps 底线**:加载器仍然会在农场目录内探查 `glibc-hwcaps`,因此每个目录的探查次数减少了,但 hwcaps 探查并未消除。

相似文章

使用Nix的开发环境:四个快速示例

Michael Stapelberg

本教程演示了使用Nix设置开发环境的四种方法,包括交互式一次性使用、配置文件以及密封的Nix Flakes,并以GoCV和OpenCV为例。

不到100行代码实现nix-build

Lobsters Hottest

本文通过用不到100行Go代码重新实现nix-build,揭示了Nix构建过程,表明将派生转换为存储路径本质上就是一次执行。

Nix for Haskell: 静态构建

Lobsters Hottest

本教程介绍如何使用 Nix 为 Haskell 项目创建静态链接的可执行文件,涵盖 GHC 的静态构建配置以及与 Docker 的集成。

我喜欢的 NixOS 声明式安装方式

Michael Stapelberg

一份关于使用 nixos-anywhere 等工具通过网络声明式安装 NixOS 的指南,重点强调在版本控制下管理配置文件。