第 3 章:体积实测——goc、LLVM 后端、gcc 三方对照
前面两章反复出现"小很多"“小 83%“这类说法。这一章把数字摊开——全部是实测,不是引用。
测量环境:Windows 11 + MSYS2 UCRT64,gcc 16.2.0(/d/msys64/ucrt64/bin/gcc.exe),goc 提交 f200cd1。gcc 加不加 -static 结果一样(因为默认就链到静态的 msys-2.0 dll)。
Hello World 三方对照
先看最简单的情况。三种写法,同一份"输出 Hello, world!“的意图:
实测结果:
| 写法 | 编译器 | 产物体积 |
|---|---|---|
Aprint("Hello, world!") | goc 自研后端 | 2048 字节 |
| A 同上 | gocl LLVM 后端 | 1536 字节 |
B printf | goc 自研后端 | 6656 字节 |
| B 同上 | gocl LLVM 后端 | 4096 字节 |
| C 手写 print 等价 | gcc -O2 | 38989 字节 |
B printf | gcc -O2 | 38989 字节 |
几个观察:
1. print 内建确实把体积砍到了1/19。 goc 的 2048 字节 vs gcc 的 38989 字节,差 94.7%。而且 -O0 就是这个体积——不是优化出来的,是架构上就没那么多东西。
2. 但 print 不是免费的。 换成 printf,goc 从 2048涨到 6656(+4608)。这个增量就是 printf 家族的代码——格式化、%d 的整数转文本、512 字节输出缓冲。gcc 那边 printf 和手写 print 体积完全一样(38989),因为 CRT 反正已经在那了,多一个薄包装不占地方。
3. puts 是"看起来轻,实际不轻"的那个。 实测 goc 的 puts("Hello, world!") 是 7680 字节,比 printf 还大。原因是 puts 依赖 goclib 里完整的字符串库和 stdout 缓冲初始化,而 print 是专门写的最薄路径。在 goc 上,print 比 puts 更省——这跟直觉相反,但符合"内建按需发射"的设计。
稍复杂的程序:stress.c
src/examples/stress.c 是个综合用例——函数调用、多返回值、算术、嵌套循环、比较、位运算都有。
| 优化档 | goc(自研) | gocl(LLVM) | gcc -O2 |
|---|---|---|---|
-O0 | 9728 | 5632 | 40784 |
-O1 | 7680 | 5632 | — |
-Os | 7680 | 5632 | — |
-O2 | 7680 | 5632 | 40784 |
这里有两个值得说的点。
gocl(LLVM 后端)比自研后端小 27%~42%
这个结果出乎我的意料,但完全合理。
LLVM 的强项是全局值编号(GVN)和 SSA 形式的死代码消除——它是先构建完整的控制流图再优化,而不是逐条指令流式改写。goc 的 -O1 是在指令流上跑五个 pass(内联、常量传播、窥孔、死存储消除、跨块死存储消除),局部性很强但缺少全局视角。
更明显的是:gocl 在 -O0 档就已经是 5632 字节,等于 goc 在 -O1/-Os 档的水平。也就是说 LLVM 那套优化几乎是"免费"的——因为 SSA 形式天然就是优化友好的表示。
goc 的 -O1 能把 9728 压到 7680(-21%),说明自研优化器确实在干活,但和 LLVM 还有一个量级上的差距。
gcc 在这条用例上赢了数字,但赢得没意义
gcc -O2 是 40784 字节,goc 是 7680——小 81%。但这个数字说明不了 goc 更优,因为它同时拖了 msvcrt.dll,而 goc 一个字节都没拖。
要公平比较,得看体积 + 依赖两个维度。这也是 goc README 里那张表的口径。
怎么验证 gocl 的输出是对的
gocl 是第二后端,产物更小,但要用它之前得先确认它算得对。有个现成的办法:拿它和自研后端逐字节对比。
为什么这样比对有价值?因为两个后端的实现路径完全不同——自研后端是 Go 直接发 x86-64 指令,gocl 是把整个程序降成 LLVM IR 再交给 LLVM 优化。路径不同意味着没有共享的错误,两份输出不一致时差异点就是问题所在。
gocl 还支持把 IR dump 出来:
这个开关在排查问题时非常有用——它能立刻回答"是 IR 层就错了,还是后面的 codegen 错了”。比如你IR 里写的是 sub i32 0, 8(正确),但反汇编出来只有一条写低 32 位的 mov,那就说明问题出在调用约定的 8 字节参数槽上,跟 IR 无关。
想更进一步,可以用 clang 编译同一份 IR 做参照——MSYS2 的 UCRT64 里就有 clang:
LLVM 的行为是可预测的,所以"clang 也这么生成"就说明问题不在 IR 内容上,而在 goc 自己的某个假设里。
gocl 目前是实验性后端(roadmap 里 P0 还有缺口),默认后端完全不受影响。是否切换,建议先用上面的方式自己验一遍。
复现方法
想自己验这些数字:
读数时的一个坑
我一开始测出”print 是 6656 字节”,以为自己抓到了 README 的错(README 说 2048)。结果README 没错——2048 对应的确实是 print 内建,是我把 printf 版的产物当成了 print 版。
这类实测最容易出错的地方不是数据本身,而是数据对应的是哪份源码、哪个版本。所以这篇里每个数字都注明了来源。同样的道理也适用于结论:光看一个数字没法判断对错,得知道它是在什么条件下测的。
小结
| 结论 | 依据 |
|---|---|
| goc vs gcc 差一个量级 | 依赖表 + 体积双重优势 |
print 内建比 puts 更省 | 2048 vs 7680 |
print 内建在 -O0 就是 2048 | 不是优化出来的 |
| gocl(LLVM)比自研后端小 27%~42% | stress.c 三档实测 |
gocl 的 -O0 等于自研后端的 -O1 | 5632 vs 7680 |
最后一条最值得琢磨:LLVM 的 SSA + GVN 几乎是免费的优化,而自研后端要跑五个 pass 才达到接近的效果。差的不只是优化能力,更是表示形式——LLVM 从一开始就是优化友好的 SSA,goc 是在指令流上做局部改写。
这背后有个通用道理:中间表示的设计决定了优化的天花板。想在现有表示上继续榨性能,不如回头改表示。
下一章项目状态与roadmap会把这几天实测到的能/不能全部列出来,包括回归数字和一个"失败其实源自环境缺失"的例子。
本章所有数字均为实测,核对于 2026-10-06,goc 提交
f200cd1。goc 迭代很快,读到时若已更新,以仓库 README 为准。