Go 基准测试

用 testing.B 与 go test -bench 建立可重复的性能基线,正确使用 b.N、计时复位、分配统计与子基准对比。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 基准测试

这个概念为什么会出现

优化若没有可重复数字,只剩故事:“感觉快了”“这台机器上好像好点”。Go 把基准测试放进与单元测试同一套 go test 工具链:

  • 同一仓库、同一包、同一 CI 可跑
  • 自动选择迭代次数 b.N 以降低计时噪声
  • 可报告每次操作的分配次数与字节

Benchmark 回答的是“这条路径有多贵”,不是“业务是否正确”。正确性仍归 Test/Fuzz/-race

[!abstract] 一句话理解 Go benchmark 用 BenchmarkXxx(b *testing.B) 在受控循环里重复目标代码,由工具调整 b.N 并报告每操作耗时与可选分配指标,用来建基线与对比实现。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:对比「拼装物流面单备注」的两种字符串实现

仓储系统导出面单时,要把「收件人 + 电话尾号」拼成备注字段,高峰期每秒成千上万次。
产品怀疑 + 拼接分配多,想和 strings.Builder 比一下——这是典型的已知热点路径建基线,不是先上 pprof 全站扫。

BenchmarkXxx + -benchmem 看 ns/op 与 allocs/op,再决定要不要改实现。

// label.go
package shipping

// JoinLabelNote 拼装面单备注:姓名 + 分隔符 + 手机尾号。
// 业务意图:导出面单时的高频纯字符串路径。
func JoinLabelNote(name, mobileTail string) string {
	return name + "/" + mobileTail
}
// label_bench_test.go
package shipping

import (
	"strings"
	"testing"
)

// BenchmarkJoinLabelPlus 测当前线上写法:多次 +。
// 教学点:循环必须跑 b.N 次;用 _ 吃掉结果防被优化掉。
func BenchmarkJoinLabelPlus(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = JoinLabelNote("Ada", "1234")
	}
}

// BenchmarkJoinLabelBuilder 测备选:Builder 写两段再取出。
// 业务意图:同输入下对比分配,而不是「感觉 Builder 更快」。
func BenchmarkJoinLabelBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var sb strings.Builder
		sb.WriteString("Ada")
		sb.WriteString("/")
		sb.WriteString("1234")
		_ = sb.String()
	}
}

建议运行:

go test -bench=. -benchmem .
go test -bench=BenchmarkJoinLabelPlus -count=5 .

期望输出形态(数字因机器而异):

BenchmarkJoinLabelPlus-8       20000000    55.0 ns/op    8 B/op    1 allocs/op
BenchmarkJoinLabelBuilder-8    15000000    80.0 ns/op   24 B/op    2 allocs/op

结合场景再看三个关注点

  1. 循环次数交给 b.N
    面单路径要让工具自动加长迭代,自己写死 1000000 会噪声大、不可比。

  2. -benchmem 解释「贵在分配还是 CPU」
    物流备注这种短串,allocs/op 往往比口头「换 Builder」更有说服力。

  3. Benchmark 不证明正确
    拼装对不对仍归 Test;这里只回答「这条导出路径有多贵」。

核心概念与准确模型

约定

func BenchmarkName(b *testing.B) {
	// 可选:昂贵 setup
	b.ResetTimer() // setup 不计入
	for i := 0; i < b.N; i++ {
		// 被测代码
	}
}
API作用
b.N框架选定的迭代次数
b.ResetTimer丢弃之前计时
b.StopTimer / StartTimer暂停/恢复计时
b.ReportAllocs强制报告分配(或用 -benchmem
b.SetBytes(n)报告 MB/s 吞吐
b.Run(name, ...)子基准
b.RunParallel并行基准(多 goroutine 分摊 b.N 语义见文档)

测什么

适合:

  • 热点纯函数、编解码、序列化、正则、模板
  • 两种算法/数据结构对比
  • 分配是否回退(regression)

不适合单独承担:

  • 跨机器绝对 SLA 承诺(噪声大)
  • 未固定输入分布的“全系统性能”
  • 正确性(应用 Test)

子基准与参数矩阵

func BenchmarkMarshal(b *testing.B) {
	for _, n := range []int{64, 4096, 1 << 20} {
		b.Run(fmt.Sprintf("size=%d", n), func(b *testing.B) {
			data := make([]byte, n)
			b.ResetTimer()
			for i := 0; i < b.N; i++ {
				_ = sink(data)
			}
		})
	}
}

与 pprof 的分工

手段问题
benchmark已知路径的成本与对比
pprof CPU谁在吃 CPU
pprof heap谁在分配/持有
trace延迟与调度时间线

常见流程:benchmark 发现回归 → pprof 定位热点 → 改代码 → benchmark 确认。

规范 vs 实现

  • 约定与 APItesting 包与 go test 文档定义如何写、如何跑。
  • 计时分辨率、调度、GC 介入、编译器优化:属实现与运行环境,同一基准在不同 GOMAXPROCS/机器上数字不同。
  • 不要把某次 ns/op 写进语言规范式的“真理”。

边界情况与反直觉行为

1. 编译器把工作优化没了

只调用无副作用且结果未使用的纯函数,可能被优化。使用包级 var sink int 或返回值赋给 sink

2. setup 污染计时

在循环外构造巨大输入却忘了 ResetTimer,数字虚高。

3. 第一次迭代的惰性初始化

缓存、sync.Once、正则 MustCompile 放错位置会扭曲结果。明确“是否包含初始化成本”。

4. GC 噪声

短基准易受 GC 影响。-count=5 看分布;必要时 b.ReportMetric 或拉长运行 -benchtime.

5. 并行基准误解

RunParallel 测的是并行扩展性,不是简单“更快”。共享资源锁竞争可能更慢。

常见误区

[!warning] 常见误区:用 time.Now 自己循环当基准 错误:手写计时、固定 1000 次。
正确:用 testing.B 与足够 benchtime

[!warning] 常见误区:一次数字定胜负 错误:跑一次 A 比 B 快 2% 就合并。
正确:-count 多次,看噪声;大改进才有决策意义。

[!warning] 常见误区:优化未证明的热点 错误:先微优化再测。
正确:先 profile/bench 证明路径昂贵。

[!warning] 常见误区:benchmark 替代正确性测试 错误:只 bench 不 test。
正确:先对后快。

工程实践

  1. 固定输入:同一数据集;记录 Go 版本与关键 flag。
  2. 对比同机前后:PR 上贴 benchstat 风格对比(工具可外置)。
  3. 防回归:对核心热点保留 benchmark,CI 可选跑(耗时权衡)。
  4. 报告分配:内存优化优先看 allocs/op
  5. 真实一点的负载:仅微基准不够时,补集成压测(另一层)。
  6. 文档:注释写明测的是哪条路径、是否含分配。
go test -bench=BenchmarkConcat -benchmem -count=5 | tee old.txt
# 改代码后
go test -bench=BenchmarkConcat -benchmem -count=5 | tee new.txt

可验证实验

实验 1:+ 与 Builder

对长字符串重复拼接对比 +strings.Builderallocs/op

实验 2:ResetTimer

在 reset 前后插入 time.Sleep,观察 ns/op 变化。

实验 3:SetBytes

对拷贝 n 字节的函数 b.SetBytes(n),观察 MB/s。

实验 4:子基准尺寸矩阵

不同 n 下算法交叉点(小 n 与大 n 胜者可能不同)。

本节总结

  • 本质:受控重复测量 + 工具选 b.N
  • 关键指标:ns/op、B/op、allocs/op、可选吞吐。
  • 最易错:测错东西、优化被优化掉、单次噪声决策。
  • 下一步pprof 解释“为什么慢”;GC 理解分配压力。

自测题

概念题

  1. 为什么循环条件必须是 i < b.N
  2. b.ResetTimer 解决什么问题?
  3. -benchmem 多了哪些列?

代码推理题

func BenchmarkSum(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sum(1000)
	}
}

sum 结果未使用,可能发生什么?如何避免?

工程思考题

CI 每次 PR 全量 -bench=. -count=10 是否合适?如何折中?

参考答案

展开
  1. 框架通过调整 N 达到稳定计时;手写次数失去该机制。
  2. 排除 setup 耗时。
  3. 每操作字节与分配次数。
    代码题:可能被优化掉;用 sink 保存结果。
    工程题:通常只对热点包/变更相关 bench 或 nightly 跑;PR 用短 benchtime 冒烟。

延伸阅读与资料来源

资料类型支撑
Package testing — B标准库Benchmark API
go test — bench flags命令-bench/-benchmem/-benchtime
Using Subtests and Sub-benchmarks博客子基准
Profiling Go Programs博客与 pprof 衔接

笔记元信息

  • 建议文件名:go-benchmarking.md
  • 所属阶段:阶段五 / 性能反馈
  • 学习顺序:会写 Test 之后
  • 建议下一篇:pprof竞态检测
  • 本篇状态:已深化(书章结构;测量陷阱与指标)
创建于 2026/6/20 更新于 2026/7/15