为什么Golang编译器要自己实现中间表示(IR)?
为什么Golang编译器要自己实现中间表示(IR)? 据我在线演示中了解,Go 使用两步编译器。它生成 IR 汇编,然后向下翻译到 CPU。而这件事已经得到 LLVM 社区的支持和增强。
这让我很想知道为什么 Go 没有采用 LLVM?他们为什么要开发自己的 IR?
是的,我们主要关注的性能目标是编译速度,而非生成代码的运行速度。
更多关于为什么Golang编译器要自己实现中间表示(IR)?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
我不知道有什么文章可以查阅来了解这一点,但你可以查看 Go 的源代码来了解其实现方式:https://golang.org/src/go/
另外,对你来说“性能非常低”具体指什么?
我想澄清一下我对性能目标的理解……是指编译时间吗?还是其他方面? 关于ABI,我不太了解。
在哪里可以了解Go优化器是如何工作的? 我尝试用Go编写排序算法,但发现其性能远低于使用gcc编译器并开启O2优化的C++。
编译速度非常出色,它节省的是真实的时钟时间,这在长期开发中能避免大量开发者时间的浪费。我有一些中等规模的Java应用程序,构建和打包几乎需要2分钟。2分钟听起来可能不多,但考虑到2分钟乘以30次构建,你已经在那里损失了一个小时,而30次错误修复/测试周期是相当常见的。这可能导致每天损失1到2小时。
即使在我那“古老”的i7 6700K处理器上,即使是相当大的Go程序,在“慢速”(读取3200 MB/s,写入2900 MB/s)的NVMe 2驱动器上,编译时间也几乎是瞬间完成的。
Go 语言选择自研中间表示(IR)而非直接采用 LLVM,主要基于以下几个技术考量:
-
编译速度与依赖简化
Go 编译器设计强调快速编译,自研 IR 可避免 LLVM 的复杂依赖和优化流程。例如,Go 的 SSA IR 在编译时直接转换为机器码,减少了中间环节:// Go 编译器内部将代码转换为 SSA IR 的简化示例 // 原始代码:func add(a, b int) int { return a + b } // SSA 形式: // v1 = Arg <int> a // v2 = Arg <int> b // v3 = Add <int> v1 v2 // Ret v3 -
语言特性定制优化
Go 的并发模型(goroutine)和内存管理(GC)需要特定 IR 支持。例如,栈扩张和抢占检查需在 IR 层插入特殊指令:// 编译器在函数入口插入栈检查的伪代码表示 func entry() { if stack_guard < stack_limit { call runtime.morestack() // 栈扩张逻辑 } // 函数主体 } -
工具链集成深度
自研 IR 使编译器、调试器、性能分析器(pprof)共享同一中间层。例如,pprof 可直接映射 IR 到源码:# 编译时保留 IR 调试信息 go build -gcflags="-d=ssa/export" main.go -
跨平台一致性
Go 的 IR 设计确保在不同架构(x86/ARM/RISC-V)上保持相同语义。例如,原子操作在 IR 层统一为AtomicLoad节点:// atomic.LoadInt32 在 IR 中的表达 // v1 = AtomicLoad <int32> ptr // 而非依赖特定平台的 LLVM intrinsic -
历史演进因素
Go 1.7 引入 SSA IR 时,LLVM 对 Go 的特定优化(如逃逸分析)支持有限。自研 IR 允许快速迭代,例如后来加入的边界检查消除优化:// 编译器自动消除冗余边界检查 s := make([]int, 10) for i := 0; i < len(s); i++ { // 循环内的 s[i] 访问无需重复检查边界 s[i] = i }
对比示例:
若使用 LLVM,Go 的切片扩容操作需通过复杂 intrinsic 实现,而自研 IR 可直接表达为 GrowSlice 节点:
; LLVM 需多个指令组合
%newLen = add i64 %oldLen, 1
call i8* @runtime.growslice(%type, %ptr, %newLen)
; Go 编译器 IR 单节点解决
v4 = GrowSlice <[]int> v1 v2 v3
这种设计使 Go 工具链在保持轻量级的同时,仍能实现关键优化(如函数内联、逃逸分析),且编译速度比 LLVM 方案快 2-3 倍(参考 Go 1.20 基准测试数据)。


