优化CPU密集型Go热路径的笔记

Hacker News Top 新闻

摘要

本文讨论了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% 视为噪声。 同意,尽管在实践中这会在迭代时显著拉长优化反馈循环。

相似文章

当编译器让你惊喜

Lobsters Hottest

Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。

就用Go

Lobsters Hottest

一篇带有强烈观点的开发者文章倡导使用Go编程语言,强调其简洁的语法、强大的标准库、高效的并发模型以及单二进制部署,作为对过于复杂的现代技术栈的实用替代方案。

为变更优化,而非应用性能

Hacker News Top

本文指出,软件团队常常过度优化微性能基准测试,却牺牲了开发者体验和工程吞吐量,而这两者才是长期交付速度与可维护性的真正瓶颈。

让 Julia 达到 C++ 的速度(2019)

Hacker News Top

这是 BYU FLOW Lab 于 2019 年发布的一篇博客文章,以真实的空气动力学应用(涡粒子法)作为基准测试,探讨如何优化 Julia 代码以匹配 C++ 的性能。作者分享了在 Julia 中实现高性能计算的经验,涵盖类型声明、JIT 编译以及代码优化技巧。