《Go 语言高级编程》8.2 手写热点函数实测

上一节学会了读汇编,这一节用它做实事:把一个求和热点函数从 Go 改写成 arm64 汇编,实测朴素版、4 路展开版、NEON 版的性能。结论出人意料——朴素汇编比纯 Go 还慢,只有打破依赖链的展开版才拿到 2.68 倍加速,而 NEON 反而输给标量展开。

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 版本做的事看起来一模一样,为什么更慢?两个原因:

  1. 依赖链。循环里 ADD R3, R2, R2 每一步都依赖上一步的结果。arm64 的整数加法延迟约 1 个周期,但每个元素都串在一条依赖链上,处理器无法并行——只能做到每周期 1 个元素。
  2. 缺少边界检查消除。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 ns1.00×gc 已做边界检查消除
朴素汇编(1 累加器)≈ 1407 ns0.98×依赖链未打破,反而略慢
4 路展开(4 累加器)≈ 513 ns2.68×打破依赖链,最优
NEON(2 累加器)≈ 678 ns2.03×宽度更大,但累加器偏少
NEON(1 累加器)≈ 1517 ns0.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 教训与决策

这一节用一组数字回答了「手写汇编到底值不值」:

  1. 朴素汇编不如 Go(0.98×)。不要以为「换成汇编就快」。
  2. 加速比来自打破依赖链,而不是「汇编」二字(2.68×)。
  3. SIMD 不是万能的,累加器不够多时,标量展开反而更快(2.03× vs 2.68×)。
  4. 正确性优先:尾部处理、整数溢出(本节求和用 int64,溢出是 UB 但求和语义上不溢出)都必须覆盖测试。

决策建议:

情况建议
逻辑简单、Go 版本已接近 1 元素/周期不值得手写汇编
有明确的依赖链瓶颈、且可展开手写展开汇编,收益可达 2× 以上
需要真正的 SIMD手写 NEON/AVX,但要用足够多的累加器
只是「觉得汇编快」先测基线,再决定

下一节我们离开汇编,回到更常见的场景:unsafe 与 reflect——它们的边界在哪,代价有多大,以及 unsafe.Pointer 那四条必须背下来的规则。

阅读导航:上一节:8.1 plan9 汇编读写与寄存器 ABI · 下一节:8.3 unsafe/reflect 边界与 Pointer 规则 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练