这是 yaklang 的 AOT 编译器 ssa2llvm 的一次真实二进制剪裁实践。

1. 问题:hello world 凭什么 110MB?#

1.1 一个"什么都不依赖"的编译器#

ssa2llvm 干的事情很简单:把 yak 脚本编译成原生可执行文件。

但它有一个硬性目标:**用户机器上什么都不用装**。不需要 Go 工具链,不需要 clang,不需要链接器。代码生成、编译、链接全部在编译器内部完成,运行时也提前打包在编译器里。对用户来说就是拿一个文件,跑一下,脚本就变成可执行程序了。

听起来很美好。代价是什么?

1.2 一个绕不开的矛盾#

yak 脚本什么都有可能写:可能只是 print,可能要用 http 发请求,可能要用 ssa 做代码分析。

编译器没法预知用户会写什么,所以内置的运行时必须****什么都包含****。但任何一个具体脚本只会用到其中很小一部分——hello world 甚至一个模块都不用。

一开始没有任何处理,结果就是:一个只打印 "hello" 的程序,把整套运行时原封不动地带了进去,编译出来 **109.77 MB**。

1.3 大头不是代码,是"函数档案"#

更反直觉的是:这 110MB 里,真正的机器码只占一小部分,大头是****元数据****——Go 语言为了保证运行时功能(GC、panic 堆栈、反射),会为每一个函数保留一份"档案"。

整套运行时里大约有 **17 万个函数**,其中 **60.7% 属于 TypeScript、PHP、Java 等语言前端**。也就是说,一个什么都不做的 hello world,却要带着 17 万份函数档案出门,六成还是他根本用不到的语言解析器。

这就是原始问题:**不是"代码太多",而是"为了能编译任意脚本,必须把整个yaklang标准库都背着"**。

2. 四条候选优化路线#

从原始问题出发,我们认真想过四条路。

路线 A:让 Go 自己删——最理想,但时机不对#

Go 链接器有非常完整的死代码消除:从程序入口出发,沿引用关系把所有"活着"的函数标记出来,其余整体丢掉,而且删函数的时候会****连元数据一起删****。

但它只在****编译运行时库**的时候生效。而 ssa2llvm 不能依赖编译环境,运行时标准库必须在发版时预构建好;用户编译脚本时,我们手里只有一个全量的 archive。**决定"哪些模块进 archive"必须在发版时做完,编译脚本时只能在既成的 archive 上做减法。****

路线 B:拆成小包,用哪个链哪个——被 Go 的运行时硬阻断#

这是 C 世界的常规操作:一个大库拆成很多小库,链接时只放需要的那些。

但 Go 的 c-archive 不行:**每一份都自带一整套 Go 运行时**——各自的 `runtime.main`、各自的 pclntab、各自的模块数据。两份放进同一个链接命令,重复符号直接失败,两份运行时,GC 和堆栈全部错乱。

路线 C:预构建几个大档位——可行,但只能分大块#

既然不能拼小包,那就只能"一个组合一份完整 archive"。可模块的任意组合是巨大的,这个表根本列不出来。

所以****可行的只有少数几个固定档位****,比如纯计算、网络、代码分析各一档。当然档位拆分成大块,必然出现"某个脚本只多用了一个模块,却要跳到大一档"的浪费。

路线 D:链接期删除——可行,但 Go 不给下刀的地方#

既然 archive 是既成的,那就在链接时把用不到的模块代码删掉。链接器确实有"垃圾回收"(`--gc-sections`),但它有个前提:代码必须被切成足够小的段。而 Go 的 c-archive 把所有代码放在****一个巨大的段****里,要么全留、要么全丢。

所以我们得先自己改造 archive,让链接器有地方下刀(这就是后面的 elfsplit)。

我们的选择:C + D,两层正交#

- ****档位(C)****决定"这份 archive 里总共有多少元数据"——让 Go 链接器在发版时就把不要的模块连元数据一起删掉;

- ****链接期删除(D)****决定"档内剩下的代码有多少能进产物"——回收档位粒度不足造成的浪费。

两者互不重叠,可以叠加。

3. 分块 yaklib:core / net / staticanalyze#

3.1 先量化:哪些模块真的"大"#

我们先把完整 yaklib 的代码按模块拆开,量了一下每个模块节的真实大小:

模块节 大小
.modtext.ssafront 12,454,500 B(11.88 MiB)
.modtext.sharednet 9,018,202 B(8.60 MiB)
.modtext.ssa 2,951,716 B(2.82 MiB)
.modtext.shared 1,031,361 B(0.98 MiB)
.modtext.cli 30,330 B
.modtext.http 25,118 B
.modtext.yakit 2,075 B
.modtext.os 52 B
.modtext.poc 52 B
.modtext.codec 52 B

结论很直接:**真正的大块只有四个**——语言前端(ssafront,11.88 MiB)、网络/数据库公共闭包(sharednet,8.60 MiB)、SSA 静态分析(ssa,2.82 MiB)和公共核心(shared,0.98 MiB)。`os`/`poc`/`codec` 各只有 52 字节,那只是注册函数,真正实现都在公共闭包里。

3.2 用依赖数据分档,不拍脑袋#

怎么决定哪些模块放一起?我们跑了 `go list -deps`,看每个包被哪些模块闭包引用——**同生共死的包天然应该待在一起**。按依赖自然聚合成三档:

档位 包含模块 archive 大小
core os / codec / yakit(纯计算) 13 MB
net core + cli / poc / http 72 MB
staticanalyze syntaxflow + ssa(含语言前端) 220 MB

3.3 编译时自动选最小可用档#

编译器根据脚本实际用到的模块,**自动选最小可用档**:纯计算脚本落在 core,用到网络落在 net,需要代码分析才上 staticanalyze。构建每一档 archive 时,不在模块列表里的代码和元数据,会被链接器一起删干净。

4. 链接期删除:--gc-sections 和 elfsplit#

4.1 --gc-sections 是什么#

链接器的"垃圾回收":从程序入口出发,沿着引用关系把所有"活着"的代码和数据标记出来,**没被标记的整段丢弃**。前提是代码被切成足够小的段,段越细,回收越精细。

4.2 Go c-archive 的尴尬#

Go 编译出来的 c-archive,把所有代码放在****一个巨大的 `.text` 段****里。对 `--gc-sections` 来说,这个段要么全留、要么全丢——不是链接器不努力,是代码根本没有给它下刀的地方。

4.3 elfsplit:把一整块板子切成拼图#

我们自己写了一个工具(elfsplit),在构建 archive 时把里面的 ELF 重新切一遍:

  1. 遍历符号表,把每个函数按它归属的模块分类;

  2. 把每个模块的代码****物理搬进独立的段****(`.modtext.<模块>`);

  3. 把对应的重定位信息也一起搬过去。

切完之后,--gc-sections 就能按模块粒度整段删除没用的代码——脚本没用 ssa,整个 .modtext.ssa 连带语言前端一起扔掉。

4.4 删完之后,要修 Go 的数据#

二进制不能"删了就跑",Go 运行时对自己内部结构有严格要求:

- **初始化任务**:被删模块的 init 任务要标记为"已完成",否则运行时启动时会去找一个不存在的初始化函数;

- **哑函数**:从保留代码指向被删代码的调用,要重定向到"一调用就报错"的占位函数——宁可带着路径报错,也不能静默返回错误结果;

- **代码段映射表**:Go 运行时内部有一张"代码段在哪"的映射表,代码搬过家之后必须同步更新,否则程序一跑就崩;

- **可选的符号折叠**:函数名是元数据里的一大块,折叠掉还能再省约 15MB,代价是 panic 堆栈里看不到函数名(行号还在),默认关闭。

4.5 代码在哪里#

想看的同学可以直接去 yaklang 仓库翻:

  • common/yak/ssa2llvm/cmd/elfsplit/——ELF 拆节工具(切段、搬重定位、pclntab 折叠)
  • common/yak/ssa2llvm/runtime/patch/——链接期运行时修补(重定位中和、init 标记、textsectmap 修复)
  • common/yak/ssa2llvm/compiler/——自包含编译/链接入口(linker_selfcontained.go、compile_dispatch_selfcontained.go)
  • common/yak/ssa2llvm/runtime/tiers/——档位定义与自动选择
  • common/yak/ssa2llvm/scripts/build_yaklib.sh、build_tiers.sh——运行时与档位构建
  • common/yak/ssa2llvm/tests/artifact_pruning_test.go——裁剪回归测试(行为 + 体积 + ELF 节三重证据)
  • common/yak/ssa2llvm/docs/article-link-time-pruning.md——完整技术版文章

5. 效果#

5.1 同一个 hello world 的对比#

同样一份 print("hello") 代码,我们量了两种状态:只分块(不做链接删除)和分块 + 链接删除:

档位 只分块、不删减 分块 + 链接删除
全量(原始运行时) 109.77 MiB 84.62 MiB
staticanalyze(=全量档) 109.77 MiB 84.62 MiB
net(网络档) 37.28 MiB 28.73 MiB
core(纯计算档) 8.10 MiB 7.87 MiB

第一行是"什么都没做"的原始基线;staticanalyze 档就是全量档,分块本身对它没有收益,收益在更小的两档。net 档单靠分块就从 109.77 MiB 降到 37.28 MiB,core 档再降到 8.10 MiB;叠加链接删除后分别是 28.73 MiB 和 7.87 MiB。

5.2 不同脚本的实际效果#

脚本 选中档 原(单一全量档) 现在
println("hello") core 84.62 MiB 7.87 MiB
codec.EncodeBase64 core 85.62 MiB 8.10 MiB
http.Get net 94.50 MiB 37.28 MiB

同一档内链接删除依然在干活:net 档上 hello world 是 28.73 MiB,`http.Get` 是 37.28 MiB——差的 **8.55 MiB** 正是链接期删除在档内又删掉的模块代码。

两层叠加:hello world 从原始 109.77 MiB 到 **7.87 MiB(−92.8%)**,其中分块贡献了大头,链接删除在档内继续抠。

6. 总结#

- **档位负责元数据,链接期删除负责代码,两者正交、叠加使用**;

  • 档位是量级上的改变——它让"删代码的那个工具"在发版时就顺手把元数据也删了;
  • 链接期删除负责回收档位粒度不足造成的浪费;
  • 下一步:给更多模块补轻量导出表,让更多脚本能落进 core 档