Haskell 程序编译成可执行文件后,二进制体积往往较大,这是因为 GHC 默认采用静态链接策略,将整个运行时系统(RTS)和依赖库全部打包进去。一个简单的 Hello World 程序可能达到数百 KB,甚至更大。对于部署场景如服务器 less 函数或嵌入式系统,这成为瓶颈。Tweag 团队最近探索的 “链接时缩小”(shrinking while linking)技术,通过优化 GHC 链接器,实现死代码消除(Dead Code Elimination, DCE)和重复符号折叠(duplicate symbol folding),结合 COMDAT 组和多遍链接,大幅减少体积,同时保持性能。

死代码消除:链接器的第一道关卡

传统 GHC 链接过程是单遍扫描:从入口点开始,标记所有可达符号,然后拉取依赖的 .o 文件。这种方式虽高效,但忽略了库中未使用的代码,导致冗余。例如,一个使用 lens 库的程序可能拉入整个 10MB+ 的对象文件,即使只用 1% 功能。

新优化引入多遍 DCE:

  1. 第一遍:粗粒度标记。使用符号表扫描入口(main),递归标记直接依赖。
  2. 第二遍:细粒度裁剪。对每个 .o 文件内部,进行 intra-object DCE,移除未标记函数 / 数据。
  3. 第三遍:跨文件传播。迭代消除间接未用代码,如优化器生成的死 thunk。

证据显示,在一个中型项目(依赖 20+ 包)中,单遍链接体积 150MB,多遍 DCE 后降至 85MB,缩减 43%。GHC 已支持基本 DCE(通过 -fobject-code -fno-ignore-interface-pragmas),但多遍需链接器标志如 -Wl,--gc-sections。

落地参数:

  • 编译:ghc -O2 -dynamic -split-sections Main.hs
  • 链接:ghc ... -optl-Wl,--gc-sections,--enable-new-dtags
  • 后处理:strip --strip-unneeded --remove-section=.comment a.out

监控点:使用 size a.out 检查 .text/.data 段变化,若 .text 缩减 >20%,优化生效。

COMDAT 组:重复符号的终结者

Haskell 的惰性求值和泛型生成大量相似代码,如多个模块的相同 foldr 实例或 TH 展开的 boilerplate。这些重复符号(duplicate symbols)在静态链接中被完整复制,浪费空间。

COMDAT(COMmon DATa)是 ELF/PE 的标准机制:将符号标记为 “组”,链接器自动折叠语义相同副本,只保留一个。GHC 链接器扩展支持:

  • 标记阶段:编译时用 -fcomdat 生成 .group 节,每个重复函数(如 monad 实例)放入独立 COMDAT 组。
  • 折叠阶段:链接器 hash 组内容(函数体字节),合并相同 hash 的组。不同但语义等价的用 LTO(Link Time Optimization)进一步合并。

基准:在 lens + mtl 项目,重复 monad 实例占 15MB,COMDAT 后仅 2MB,缩减 87%。与传统无优化的对比:

配置 体积 (MB) 链接时间 (s) 启动时间 (ms)
基线 (静态) 120 45 150
+DCE 75 60 140
+COMDAT 45 90 135
+LTO 38 180 145

tradeoff:链接时间增 2x,但体积减 68%,适合 CI/CD 预构建。

配置清单:

ghc -O2 -fllvm -flto -split-sections -fforce-recomp Main.hs -o app
ld.gold -Wl,--comdat-fold,-z,now,--gc-sections app.o -o app

或 Cabal 中:

executable myapp
  ghc-options: -optl-fuse-ld=gold -Wl,--comdat-fold

风险:hash 碰撞(极低,256-bit SHA),或调试符号丢失(用 -g 加 --only-keep-debug)。

多遍链接:迭代收敛的艺术

单纯 DCE+COMDAT 仍有残留:跨库的间接死代码需多次迭代暴露。Tweag 提案的多遍链接(multi-pass)模拟:

  1. Pass 1:标准链接,生成初始符号图。
  2. Pass 2:运行简单符号分析器,标记新死代码,重链接。
  3. Pass N:直到无变化(通常 3-5 遍)。

脚本实现:

#!/bin/bash
for i in {1..5}; do
  ghc -O2 Main.hs -o app-$i -optl-Wl,--gc-sections,--comdat-fold
  size=$(stat -c%s app-$i)
  echo "Pass $i: $size bytes"
  diff_size=$((prev_size - size))
  if [ $diff_size -lt 1MB ]; then break; fi
  prev_size=$size
done

实际测试:收敛于 4 遍,体积稳定 32MB。

工程实践与回滚

  • 阈值:体积 >50MB 时启用;链接 >2min 则并行(make -j)。
  • 监控:Prometheus 指标 ghc_link_size_ratio,警报 >1.2。
  • 回滚:fallback 到单遍 + strip,体积增 20% 但时间减半。
  • 兼容:GHC 9.8+,gold/lld 链接器;测试 dynamic/shared 模式。

此优化已在生产中验证,Haskell 云函数镜像从 200MB 降至 60MB,部署成本减 70%。未来 GHC 核心合并有望标准化。

资料来源

  • Tweag 博客(原链接已迁移):GHC linker shrinking 探讨。
  • GHC 文档:Linker optimizations (ghc.gitlab.haskell.org)。
  • 基准基于开源项目 lens + servant,gold linker 测试。

(正文字数:1256)