← [ BACK_TO_NOTES ]

Go 基准测试

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

#concept #growing #dev #go

[!info] related notes

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"
)

var labelSink string
var benchmarkName, benchmarkTail = "Ada", "1234"

// BenchmarkJoinLabelPlus 测 + 拼接;包级 sink 让结果逃逸并保留结果。
func BenchmarkJoinLabelPlus(b *testing.B) {
	for i := 0; i < b.N; i++ {
		labelSink = JoinLabelNote(benchmarkName, benchmarkTail)
	}
}

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

分别保存为 label.go、label_bench_test.go;新目录先执行 go mod init example.com/shipping。两种实现都把结果存入包级 sink,模拟结果逃逸;这会影响分配,不能代表结果不逃逸的所有调用场景。

go test -run='^$' -bench=. -benchmem .
go test -run='^$' -bench=BenchmarkJoinLabelPlus -benchmem -count=5 .

输出列如下;这里的占位符不是实测性能结论。名称后的数字是 GOMAXPROCS,不是每次操作创建的 goroutine 数。

BenchmarkJoinLabelPlus-<P>       <N>    <time> ns/op    <bytes> B/op    <allocs> allocs/op
BenchmarkJoinLabelBuilder-<P>    <N>    <time> ns/op    <bytes> B/op    <allocs> 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 语义见文档)

Go 1.24+ 也可用 for b.Loop() { ... }:自动排除循环前 setup 与循环后 cleanup 的计时,并防止循环内被测调用因结果不用而整体消除。一个基准选择 b.Loop 或 b.N 循环,不要混用;本篇保留兼容旧版本的 b.N 写法。

测什么

适合:

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

不适合单独承担:

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

子基准与参数矩阵

以下是结构片段,需提供实际被测函数 sink 与 fmt 导入;这里的 sink 是函数名,不能直接混同于包级结果变量。

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 实现

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

边界情况与反直觉行为

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

只调用无副作用且结果未使用的纯函数,可能被优化。_ = result 不能阻止消除。b.N 写法可将结果存入类型匹配的包级 sink,或用 Go 1.24+ 的 b.Loop;sink 会影响逃逸分析,必须说明测量边界。并行 benchmark 不应无锁写同一 sink。

2. setup 污染计时

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

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

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

4. GC 噪声

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

5. 并行基准误解

RunParallel 将总计 b.N 次操作分给多个 goroutine(不是每个 goroutine 各跑 b.N 次)。ns/op 是整体墙钟耗时除以操作数,不是单请求延迟;可结合 -cpu 比较扩展性。回调内不应调用影响全局计时的 StartTimer/StopTimer/ResetTimer。

常见误区

[!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 -run='^$' -bench=BenchmarkJoinLabel -benchmem -count=5 . > old.txt
# 改代码后
go test -run='^$' -bench=BenchmarkJoinLabel -benchmem -count=5 . > new.txt

可验证实验

实验 1:+ 与 Builder

对长字符串重复拼接对比 + 与 strings.Builder 的 allocs/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. b.N 写法为何使用 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 控制测量时长;也可使用 Go 1.24+ 的 b.Loop,不能与 b.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/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本