优化CPU密集型Go热路径的笔记
摘要
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
暂无内容
查看缓存全文
缓存时间: 2026/05/14 09:22
# 优化 CPU 密集型 Go 热路径的笔记
来源:https://blog.andr2i.com/posts/2026-05-03-notes-from-optimizing-cpu-bound-go-hot-paths
## Andrii 的博客
2026年5月3日
Go 做了很多正确的事,我也因此喜爱 Go。但在将 Brotli 移植到纯 Go(go-brrr)的过程中,我反复遇到同一个模式:符合习惯的抽象会让热路径变慢,而最快的版本往往是手工复制并特化的。
## 缺乏零成本抽象
在我优化的热循环中,泛型、多态分发(通过接口)以及闭包常常阻止编译器生成与具体版本相同的代码。原因在于,对于我所使用的形式,Go 编译器并不会内联这些调用(在本文中我们会频繁遇到内联的问题,因为内联相当重要)。是的,编译器有时可以内联一个直接的闭包调用,或者将接口调用去虚拟化,但在实际遇到的模式中它并没有这样做,于是我承担了调用的额外开销。
接口调用不被内联的原因很明显——它们允许在运行时而非编译时交换实现。而泛型允许在编译时交换实现。如果你来自 C++ 或 Rust 这类语言,会期望泛型函数被单态化(所有变体在编译时预先生成为具体函数),但在 Go 中不会发生,至少不是那种形式。Go 采用的是他们称之为 GC Shape Stenciling 的方法,其中部分内容在编译时预生成,但类型参数上的方法调用最终会通过接口风格的分发(技术上,itab 是通过泛型字典访问的,而不是通过普通的接口参数,但在热路径上的效果相同)。内联的不可能性在提案中有说明:
> 唯一的例外是方法调用在编译时无法完全解析……在完全模板化的实现本可以内联的情况下,内联不会发生。
那么该怎么办呢?实际上,没问题。我们只需要不使用像泛型这样的抽象,而是复制具体的函数。我们拿一个具体的函数,完全复制它,并将我们原本想要参数化的部分替换掉。不用说,这会导致大量的代码重复。在 Brotli 移植中,有 16 个几乎相同的函数,它们唯一的区别是调用了不同版本的 `hash` 函数。这 16 个变体无法通过使用抽象合并为一个,因为该函数用在热路径上。因此性能问题通过代码复制得以解决,但这引入了潜在的巨大维护问题。当然,这可以通过代码生成在一定程度上缓解,但很可能你只会遇到 2-3 个重复变体,不足以引入代码生成。
下一节深入探讨具体 vs 泛型 vs 接口方法的基准测试,并附带一些底层汇编的分析,你可以愉快地跳过。
### 深入探讨
让我们通过一个例子来说明上述所有情况。这是我在真实代码库中使用的函数,去掉了不重要的部分。
```go
func StoreConcrete(t *Table, data []byte) {
end := uint32(len(data))
for i := uint32(0); i+4 <= end; i++ {
v := binary.LittleEndian.Uint32(data[i:])
key := (v * HashMul32) >> (32 - BucketBits)
minor := uint32(t.Num[key]) & (BlockSize - 1)
t.Buckets[minor+key*BlockSize] = v
}
}
```
另一种选择是使用泛型:
```go
type Hasher interface {
Hash(uint32) uint32
}
type H5Hasher struct{}
func (h H5Hasher) Hash(v uint32) uint32 {
return (v * HashMul32) >> (32 - BucketBits)
}
func StoreGeneric[H Hasher](t *Table, data []byte) {
// ...
key := h.Hash(v)
// ...
}
```
另一种选择是使用多态分发:
```go
func StoreInterface(t *Table, data []byte, h Hasher) {
// ...
key := h.Hash(v)
// ...
}
```
另一种选择是将闭包传递给函数:
```go
func StoreClosure(t *Table, data []byte, hash func(uint32) uint32) {
// ...
key := hash(v)
// ...
}
```
展开查看完整代码
```go
import "encoding/binary"
const HashMul32 = 0x1E35A7BD
const (
BucketBits = 14
BlockBits = 4
BucketSize = 1 << BucketBits
BlockSize = 1 << BlockBits
)
type Table struct {
Num [BucketSize]uint16
Buckets [BucketSize * BlockSize]uint32
}
func StoreConcrete(t *Table, data []byte) {
end := uint32(len(data))
for i := uint32(0); i+4 <= end; i++ {
v := binary.LittleEndian.Uint32(data[i:])
key := (v * HashMul32) >> (32 - BucketBits)
minor := uint32(t.Num[key]) & (BlockSize - 1)
t.Buckets[minor+key*BlockSize] = v
}
}
func StoreGeneric[H Hasher](t *Table, data []byte) {
var h H
end := uint32(len(data))
for i := uint32(0); i+4 <= end; i++ {
v := binary.LittleEndian.Uint32(data[i:])
key := h.Hash(v)
minor := uint32(t.Num[key]) & (BlockSize - 1)
t.Buckets[minor+key*BlockSize] = v
}
}
func StoreInterface(t *Table, data []byte, h Hasher) {
end := uint32(len(data))
for i := uint32(0); i+4 <= end; i++ {
v := binary.LittleEndian.Uint32(data[i:])
key := h.Hash(v)
minor := uint32(t.Num[key]) & (BlockSize - 1)
t.Buckets[minor+key*BlockSize] = v
}
}
func StoreClosure(t *Table, data []byte, hash func(uint32) uint32) {
end := uint32(len(data))
for i := uint32(0); i+4 <= end; i++ {
v := binary.LittleEndian.Uint32(data[i:])
key := hash(v)
minor := uint32(t.Num[key]) & (BlockSize - 1)
t.Buckets[minor+key*BlockSize] = v
}
}
```
基准测试设置:
```go
func BenchmarkStore(b *testing.B) {
t := &Table{}
data := makeData(1 << 14)
hash := func(v uint32) uint32 {
return (v * HashMul32) >> (32 - BucketBits)
}
b.Run("Concrete", func(b *testing.B) {
b.SetBytes(int64(len(data)))
for b.Loop() {
StoreConcrete(t, data)
}
})
b.Run("Generic", func(b *testing.B) {
b.SetBytes(int64(len(data)))
for b.Loop() {
StoreGeneric[H5Hasher](t, data)
}
})
b.Run("Interface", func(b *testing.B) {
b.SetBytes(int64(len(data)))
for b.Loop() {
StoreInterface(t, data, H5Hasher{})
}
})
b.Run("Closure", func(b *testing.B) {
b.SetBytes(int64(len(data)))
for b.Loop() {
StoreClosure(t, data, hash)
}
})
}
func makeData(n int) []byte {
b := make([]byte, n)
x := uint32(0xDEADBEEF)
for i := range b {
x = x*1664525 + 1013904223
b[i] = byte(x >> 24)
}
return b
}
```
变体 | 吞吐量 | Δ vs Concrete
--- | --- | ---
Concrete | 378.0 MiB/s | —
Generic | 320.6 MiB/s | −15.18%
Closure | 322.0 MiB/s | −14.82%
Interface | 274.3 MiB/s | −27.44%
哇!差异相当显著。
与 Concrete 函数相关的汇编
```asm
// func StoreConcrete(t *Table, data []byte) {
PUSHQ BP // 0x52e4a0
MOVQ SP, BP
MOVQ BX, 0x18(SP)
// for i := uint32(0); i+4 <= end; i++ {
XORL DX, DX
JMP 0x52e4ed
// minor := uint32(t.Num[key]) & (BlockSize - 1)
TESTB AL, 0(AX) // 0x52e4ad
// return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
MOVL 0(BX)(DX*1), R9
// key := (v * HashMul32) >> (32 - BucketBits)
IMULL $0x1e35a7bd, R9, R9
SHRL $0x12, R9
// minor := uint32(t.Num[key]) & (BlockSize - 1)
MOVZX 0(AX)(R9*2), R10
ANDL $0xf, R10
// t.Buckets[minor+key*BlockSize] = v
LEAQ (R10)(R9*8), R10
MOVL R9, 0(AX)(R10*4)
INCL DX
// }
LEAL 4(DX), R9 // i+4
LEAL -8(BP), R10 // `end`
CMPL R9, R10
// if i+4 <= end
JBE 0x52e4ad
POPQ BP
RET
```
与 Generic 函数相关的汇编
```asm
//TEXT hashdemo.StoreGeneric[go.shape.struct {}](SB)
//func StoreGeneric[H Hasher](t *Table, data []byte) {
CMPQ SP, 0x10(R14)
JBE 0x52f0f0
PUSHQ BP
MOVQ SP, BP
SUBQ $0x10, SP
// for i := uint32(0); i+4 <= end; i++ {
MOVQ AX, 0x20(SP)
MOVQ BX, 0x28(SP)
MOVQ CX, 0x30(SP)
MOVQ DI, 0x38(SP)
MOVQ SI, 0x40(SP)
XORL DX, DX
JMP 0x52f072
// minor := uint32(t.Num[key]) & (BlockSize - 1)
MOVZX 0(CX)(BX*2), R8 // 0x52f02f
ANDL $0xf, R8
// t.Buckets[minor+key*BlockSize] = v
LEAQ (R8)(BX*8), R8
MOVL AX, 0(CX)(R8*4)
INCL DX
// }
LEAL 4(DX), AX
LEAL -8(BP), CX
CMPL AX, CX
JBE 0x52f041
MOVQ 0x40(SP), BP
ADDQ $0x10, SP
POPQ BP
RET
// inside the loop after reading v
MOVL 0(SI)(DX*1), AX // 0x52f0b3
// CALL generic dictionary lookup and indirect call
MOVQ 0(DI)(AX*8), AX
CALL AX
// ...
```
与 Generic Hasher.Hash 相关的汇编
```asm
//TEXT hashdemo.H5Hasher.Hash(SB)
//func (h H5Hasher) Hash(v uint32) uint32 {
CMPQ SP, 0x10(R14)
JBE 0x52f150
XORL AX, AX
CMPQ BX, $0x4
// return (v * HashMul32) >> (32 - BucketBits)
IMULL $0x1e35a7bd, BX, AX
SHRL $0x12, AX
RET
// 以下为额外的边界检查代码(在函数被作为闭包调用时某些情况下会触发)
CALL runtime.panicwrap(SB)
```
与 Closure 函数相关的汇编
```asm
//TEXT hashdemo.StoreClosure(SB)
//func StoreClosure(t *Table, data []byte, hash func(uint32) uint32) {
CMPQ SP, 0x10(R14)
JBE 0x52e779
PUSHQ BP
MOVQ SP, BP
SUBQ $0x10, SP
// for i := uint32(0); i+4 <= end; i++ {
MOVQ AX, 0x20(SP)
MOVQ SI, 0x40(SP)
MOVQ CX, 0x30(SP)
MOVQ BX, 0x28(SP)
MOVQ DI, 0x38(SP)
XORL DX, DX
JMP 0x52e710
// minor := uint32(t.Num[key]) & (BlockSize - 1)
MOVZX 0(CX)(BX*2), R8 // 0x52e6cf
ANDL $0xf, R8
// t.Buckets[minor+key*BlockSize] = v
LEAQ (R8)(BX*8), R8
MOVL AX, 0(CX)(R8*4)
INCL DX
// }
LEAL 4(DX), AX
LEAL -8(BP), CX
CMPL AX, CX
JBE 0x52e6de
MOVQ 0x40(SP), BP
ADDQ $0x10, SP
POPQ BP
RET
// 闭包调用处
MOVQ 0x48(SP), R8 // 闭包上下文
MOVQ 0x50(SP), R9 // 函数指针
MOVL 0(SI)(DX*1), AX
CALL R9
```
与 Interface 函数相关的汇编
```asm
//TEXT hashdemo.H5Hasher.Hash(SB)
// return (v * HashMul32) >> (32 - BucketBits)
IMULL $0x1e35a7bd, AX, AX
SHRL $0x12, AX
RET
//TEXT hashdemo.StoreInterface(SB)
//func StoreInterface(t *Table, data []byte, h Hasher) {
CMPQ SP, 0x10(R14)
JBE 0x52e650
PUSHQ BP
MOVQ SP, BP
SUBQ $0x18, SP
// for i := uint32(0); i+4 <= end; i++ {
MOVQ AX, 0x28(SP)
MOVQ CX, 0x38(SP)
MOVQ BX, 0x30(SP)
MOVQ DI, 0x40(SP)
MOVQ R8, 0x50(SP)
MOVQ SI, 0x48(SP)
XORL DX, DX
JMP 0x52e5dd
// minor := uint32(t.Num[key]) & (BlockSize - 1)
MOVZX 0(CX)(DX*2), R9 // 0x52e594
ANDL $0xf, R9
// t.Buckets[minor+key*BlockSize] = v
LEAQ (R9)(DX*8), R9
MOVL AX, 0(CX)(R9*4)
INCL DX
// }
LEAL 4(DX), AX
LEAL -8(BP), CX
CMPL AX, CX
JBE 0x52e59e
MOVQ 0x50(SP), BP
ADDQ $0x18, SP
POPQ BP
RET
// 接口调用(通过 itab)
MOVQ 0x38(SP), R8 // itab
MOVQ 0x40(SP), R9 // 数据指针
MOVL 0(R9)(DX*1), AX
MOVQ 0x20(R8), R10 // Hash 方法偏移
CALL R10
```
泛型/接口惩罚背后的机制并非编译器懒惰,而是与 Go 的运行时模型相关。GC Shape Stenciling 之所以存在,是因为 GC 需要遍历栈,并且每个值必须有已知的大小和指针位图;完全的单态化会导致二进制文件严重膨胀,或者将某些形状仍推至接口风格的分发。27% 的接口差距是为了保持统一栈遍历而付出的代价,并非可修复的 bug。
PGO 是这里正确的逃生出口:80 单元的保守预算是因为编译器不知道哪些点是热的,而 PGO 根据性能分析证据提高了每条边的阈值。没有 PGO,编译器被迫在所有地方都保持保守。
我对 PGO 的看法:当你发布最终二进制文件时它更有帮助。作为库作者,你无法保证最终用户会运行 PGO。
关于重复问题:
> “16 个几乎相同的函数”模式是手工实现的单态化。你重建了 Rust 和 C++ 自动完成的工作,代价是维护成本(16 个副本会随时间漂移)。标准的缓解方法是使用 `go generate` 从单个模板生成,这既保持了维护故事,又不损失内联预算的优势。如果这些特化长期存在,值得这样做。
这很公平。我曾考虑过模板化实现,但这些副本在调优过程中分歧很大,我担心模板最终会比重复本身更难维护。
关于抽象开销何时真正重要:
> 27% 的接口数字是针对热循环的。接口分发每次调用大约需要 3-4 ns 的 itab 查找;在一个每字节哈希内核中,这占据了很大比例,但在一个每缓存行(每个分发 64 字节工作)的内核中,它降到了很低的个位数。因此抽象税与每次调用的工作量成反比,这为判断何时应该特化提供了正确的框架。
我认为这是正确的框架。在微小的面向字节的内核中,即使很小的分发开销也会很快变得显而易见。
关于实际上有效的 asm 模式:
> PREFETCHT0 问题也解释了为什么 Go 的 asm 逃生口感觉假。Plan 9 asm 函数永远不会内联,因此任何 asm 辅助函数都带有完整的 call 和 ret。实际有效的模式是使用构建标签的 \_amd64.s 文件来实现整个热循环,并带有可移植的 Go 回退;你承诺维护两者,但不再与内联器作斗争。
这也符合我的经验。小的 asm 逃生口往往最终会与内联器对抗,而不是帮助它。
关于布局敏感性:
> 最后一点:3 到 4% 的布局敏感性是每个非托管编译器都会遇到的对齐前端问题。C++ 有 BOLT,Rust 通过 LLVM 获得一些。Go 的内部链接器不暴露这些钩子。务实的修复不是工具,而是基准测试协议:在 N 个不相关的小提交中取平均,并将单个提交的变动低于 5% 视为噪声。
同意,尽管在实践中这会在迭代时显著拉长优化反馈循环。
相似文章
当编译器让你惊喜
Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。
就用Go
一篇带有强烈观点的开发者文章倡导使用Go编程语言,强调其简洁的语法、强大的标准库、高效的并发模型以及单二进制部署,作为对过于复杂的现代技术栈的实用替代方案。
为变更优化,而非应用性能
本文指出,软件团队常常过度优化微性能基准测试,却牺牲了开发者体验和工程吞吐量,而这两者才是长期交付速度与可维护性的真正瓶颈。
本地LLM实战测试:代码生成、质量与速度权衡
作者构建了一个基准测试框架,用于评估本地LLM在自动生成Go代码方面的能力,重点聚焦SIEM流水线的日志解析器生成,并发布了对比质量与速度的测试结果。
让 Julia 达到 C++ 的速度(2019)
这是 BYU FLOW Lab 于 2019 年发布的一篇博客文章,以真实的空气动力学应用(涡粒子法)作为基准测试,探讨如何优化 Julia 代码以匹配 C++ 的性能。作者分享了在 Julia 中实现高性能计算的经验,涵盖类型声明、JIT 编译以及代码优化技巧。