8.2 手写热点函数实测
「把热点函数改成汇编会更快」——这句话对一半,错一半。对的部分是:汇编确实能突破编译器的限制(比如让 gc 不做的自动向量化成为可能)。错的部分是:如果你不懂现代 CPU 的执行模型,手写的汇编很可能比编译器生成的还慢。
这一节就用一个最简单的求和函数,把这句话用数字证明出来。
本节要回答的问题是:同一个热点函数,纯 Go、朴素汇编、展开汇编、NEON 汇编各是多少纳秒,加速比从哪来。结论先行:对 4096 个
int64求和,纯 Go 约 1377 ns;朴素汇编(1 个累加器、每次 1 个元素)约 1407 ns,反而更慢;4 路展开 + 4 个独立累加器约 513 ns,2.68×;NEON 双累加器约 678 ns,2.03×——输给了标量展开。加速比的来源不是「汇编」二字,而是打破累加器的依赖链。
8.2.1 选一个真实的热点
求和是刻意挑的「最小热点」:它简单到能一眼看懂,又足够真实——向量内积、直方图、统计聚合的底层都是它。我们的目标函数:
// func SumGo(x []int64) int64
func SumGo(x []int64) int64 {
var s int64
for _, v := range x {
s += v
}
return s
}
输入是 4096 个 int64(32 KB,能放进 M1 的一级缓存)。所有版本都用同一份数据、同一个基准框架,保证可比。
8.2.2 基线:纯 Go 版本
先量出 Go 的基线。这里有个细节:SumGo 是普通 Go 函数,编译器可能内联它,也可能做边界检查消除——这正是 gc 的长处。
$ GOTOOLCHAIN=go1.27.0 go test -bench=. -benchtime=300000x -count=3
BenchmarkSumGo-10 300000 1386 ns/op
BenchmarkSumGo-10 300000 1374 ns/op
BenchmarkSumGo-10 300000 1372 ns/op
纯 Go 约 1377 ns。换算一下:4096 个元素 / 1377 ns ≈ 3 个元素每纳秒?不对——是 4096 / 1377 ≈ 2.97 元素每纳秒?也不是,是 1377 ns 处理 4096 个元素,即约 0.336 ns/元素。在约 3 GHz 的 M1 上,这大约是每个周期处理一个元素。这个速度已经不慢,说明 gc 生成的代码质量并不差。
8.2.3 第一版:朴素汇编(实测:反而更慢)
按最直白的思路写:一个累加器,循环每次加一个元素。
// func SumScalar(x []int64) int64
TEXT ·SumScalar(SB), NOSPLIT, $0-32
MOVD x_base+0(FP), R0 // R0 = 切片数据指针
MOVD x_len+8(FP), R1 // R1 = 长度
MOVD $0, R2 // R2 = 累加器
CBZ R1, sdone
sloop:
MOVD (R0), R3 // R3 = *R0
ADD R3, R2, R2 // R2 += R3
ADD $8, R0, R0 // R0 += 8(下一个元素)
SUB $1, R1, R1 // R1--
CBNZ R1, sloop
sdone:
MOVD R2, ret+24(FP)
RET
实测:
$ GOTOOLCHAIN=go1.27.0 go test -bench=. -benchtime=300000x -count=3
BenchmarkSumScalar-10 300000 1387 ns/op
BenchmarkSumScalar-10 300000 1404 ns/op
BenchmarkSumScalar-10 300000 1429 ns/op
约 1407 ns,比纯 Go 的 1377 ns 还慢。 这是本节最重要的一个数字:手写汇编不等于更快。
8.2.4 诊断:为什么朴素汇编输给 Go
朴素汇编和 Go 版本做的事看起来一模一样,为什么更慢?两个原因:
- 依赖链。循环里
ADD R3, R2, R2每一步都依赖上一步的结果。arm64 的整数加法延迟约 1 个周期,但每个元素都串在一条依赖链上,处理器无法并行——只能做到每周期 1 个元素。 - 缺少边界检查消除。gc 能把
range的边界检查全部消掉,生成的循环体比「读指针 + 加法 + 指针自增 + 计数器自减」更紧凑。
所以朴素汇编输的不是「汇编」,而是没有利用现代 CPU 的并行度。要让汇编赢,必须打破那条依赖链。
8.2.5 第二版:4 路展开 + 4 个独立累加器(2.68×)
思路:用 4 个独立的累加器,每次处理 4 个元素。4 条 ADD 之间互不依赖,处理器可以把它们并行发射。
// func SumUnroll(x []int64) int64
TEXT ·SumUnroll(SB), NOSPLIT, $0-32
MOVD x_base+0(FP), R0
MOVD x_len+8(FP), R10
MOVD $0, R2
MOVD $0, R7
MOVD $0, R8
MOVD $0, R9
AND $3, R10, R11 // R11 = len & 3(尾部元素数)
LSR $2, R10, R4 // R4 = len / 4
CBZ R4, utail
uloop:
MOVD 0(R0), R3
MOVD 8(R0), R5
MOVD 16(R0), R6
MOVD 24(R0), R12
ADD R3, R2, R2 // 四个累加器,互不依赖
ADD R5, R7, R7
ADD R6, R8, R8
ADD R12, R9, R9
ADD $32, R0, R0
SUB $1, R4, R4
CBNZ R4, uloop
utail:
CBZ R11, ucombine
utloop:
MOVD 0(R0), R3
ADD R3, R2, R2
ADD $8, R0, R0
SUB $1, R11, R11
CBNZ R11, utloop
ucombine:
ADD R7, R2, R2 // 合并四个累加器
ADD R8, R2, R2
ADD R9, R2, R2
MOVD R2, ret+24(FP)
RET
实测:
$ GOTOOLCHAIN=go1.27.0 go test -bench=. -benchtime=300000x -count=3
BenchmarkSumUnroll-10 300000 502.0 ns/op
BenchmarkSumUnroll-10 300000 500.1 ns/op
BenchmarkSumUnroll-10 300000 536.9 ns/op
约 513 ns,相对纯 Go 的 1377 ns 是 2.68× 加速。同样的指令集、同样的内存访问,仅仅因为打破了依赖链,性能翻了一倍多。
8.2.6 第三版:NEON(实测 2.03×,输给标量)
既然 M1 有 NEON,用 SIMD 是不是更快?用 2 个独立的 NEON 累加器、每次处理 4 个元素(每个 VADD 处理 2 个):
// func SumNeon(x []int64) int64
TEXT ·SumNeon(SB), NOSPLIT, $0-32
MOVD x_base+0(FP), R0
MOVD x_len+8(FP), R10
VEOR V4.B16, V4.B16, V4.B16 // 累加器 V4 = 0
VEOR V5.B16, V5.B16, V5.B16 // 累加器 V5 = 0
AND $3, R10, R11
LSR $2, R10, R4
CBZ R4, ntail
ADD $16, R0, R1
nloop:
VLD1 (R0), [V0.D2] // 载入 2 个 int64
VLD1 (R1), [V1.D2]
VADD V0.D2, V4.D2, V4.D2
VADD V1.D2, V5.D2, V5.D2
ADD $32, R0, R0
ADD $32, R1, R1
SUB $1, R4, R4
CBNZ R4, nloop
ntail:
VADD V5.D2, V4.D2, V4.D2 // 合并
VMOV V4.D[0], R2
VMOV V4.D[1], R3
ADD R3, R2, R2
CBZ R11, ndone
ntloop:
MOVD 0(R0), R5
ADD R5, R2, R2
ADD $8, R0, R0
SUB $1, R11, R11
CBNZ R11, ntloop
ndone:
MOVD R2, ret+24(FP)
RET
实测:
$ GOTOOLCHAIN=go1.27.0 go test -bench=. -benchtime=300000x -count=3
BenchmarkSumNeon-10 300000 675.3 ns/op
BenchmarkSumNeon-10 300000 676.5 ns/op
BenchmarkSumNeon-10 300000 681.4 ns/op
约 678 ns,2.03×——输给了标量展开版。这是个值得琢磨的结果:SIMD 明明是「更宽」的指令,为什么没赢?
8.2.7 汇总对比表
| 版本 | 实测耗时 | 相对纯 Go | 说明 |
|---|---|---|---|
| 纯 Go | ≈ 1377 ns | 1.00× | gc 已做边界检查消除 |
| 朴素汇编(1 累加器) | ≈ 1407 ns | 0.98× | 依赖链未打破,反而略慢 |
| 4 路展开(4 累加器) | ≈ 513 ns | 2.68× | 打破依赖链,最优 |
| NEON(2 累加器) | ≈ 678 ns | 2.03× | 宽度更大,但累加器偏少 |
| NEON(1 累加器) | ≈ 1517 ns | 0.91× | 依赖链 + SIMD 延迟,最差 |
为什么会这样?核心在于吞吐量 vs 延迟:
- 标量展开用 4 条独立的整数加法流水线,M1 有多个整数 ALU,可以并行执行,吞吐量高。
- NEON 的
VADD虽然一次处理 2 个元素,但只有 2 个累加器、且 SIMD 加法本身延迟更高(数周期),如果累加器不够多,就还是被延迟卡住。SIMD 要赢,必须同时具备「够宽的向量」和「够多的独立累加器」——本节的 2 个累加器不够,所以输给了 4 路标量。
这个结论推翻了一个常见的直觉:「用了 SIMD 就一定比标量快」。真相是——并行度的瓶颈往往不在向量宽度,而在累加器数量。
8.2.8 正确性验证
性能之前先正确。所有汇编版本都必须和纯 Go 版本给出相同结果。用一段测试验证:
func TestCorrect(t *testing.T) {
want := SumGo(data)
for _, f := range []func([]int64) int64{SumScalar, SumUnroll, SumNeon} {
if got := f(data); got != want {
t.Fatalf("got %d want %d", got, want)
}
}
}
实测:
$ GOTOOLCHAIN=go1.27.0 go test -run TestCorrect -v
=== RUN TestCorrect
--- PASS: TestCorrect (0.00s)
PASS
ok asmall 0.530s
特别注意尾部处理:len 不一定被 4 整除,展开循环处理不了的那几个元素必须由标量尾循环兜底(R11 = len & 3)。手写展开汇编最常出的 bug 就是漏掉尾部——它在 4096 这种整齐的长度上跑得好好的,一遇到 4097 就静默少算一个元素。
8.2.9 教训与决策
这一节用一组数字回答了「手写汇编到底值不值」:
- 朴素汇编不如 Go(0.98×)。不要以为「换成汇编就快」。
- 加速比来自打破依赖链,而不是「汇编」二字(2.68×)。
- SIMD 不是万能的,累加器不够多时,标量展开反而更快(2.03× vs 2.68×)。
- 正确性优先:尾部处理、整数溢出(本节求和用
int64,溢出是 UB 但求和语义上不溢出)都必须覆盖测试。
决策建议:
| 情况 | 建议 |
|---|---|
| 逻辑简单、Go 版本已接近 1 元素/周期 | 不值得手写汇编 |
| 有明确的依赖链瓶颈、且可展开 | 手写展开汇编,收益可达 2× 以上 |
| 需要真正的 SIMD | 手写 NEON/AVX,但要用足够多的累加器 |
| 只是「觉得汇编快」 | 先测基线,再决定 |
下一节我们离开汇编,回到更常见的场景:unsafe 与 reflect——它们的边界在哪,代价有多大,以及 unsafe.Pointer 那四条必须背下来的规则。
阅读导航:上一节:8.1 plan9 汇编读写与寄存器 ABI · 下一节:8.3 unsafe/reflect 边界与 Pointer 规则 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。