Skip to main content
  1. 专题系列/
  2. Go高性能编程Workshop/

第2章:基准测试(Benchmarking)

·3956 words·8 mins

测量两次,切割一次。 — 古老的谚语

在我们尝试提高一段代码的性能之前,首先我们必须知道它当前的性能。

本节重点介绍如何使用Go testing框架构建有用的基准测试,并提供避免陷阱的实用技巧。

2.1 基准测试的基本规则

在开始编写基准测试之前,你需要有一个安静稳定的环境来运行测试。

不要在共享硬件上运行基准测试

不要在共享硬件上运行基准测试,不要在运行基准测试时浏览网页或播放音乐。

注意功耗管理和热伸缩(thermal scaling)

确保机器处于空闲状态——不要在运行基准测试时运行系统更新,不要在笔记本电脑上使用电池运行基准测试,确保笔记本电脑已完全充电并插上电源。

如果你在运行基准测试时遇到不一致的结果,可能是由于热伸缩(thermal scaling)导致机器变热并降频。有一些工具可以让你降低机器的最大时钟速度以获得一致的结果(https://github.com/aclements/perflock)。

注意虚拟化和云基础设施

如果使用虚拟机或云基础设施,请注意虚拟化管理程序(hypervisor)的影响。

运行足够长的时间以获得准确的结果

基准测试必须运行足够长的时间才能准确。微基准测试(micro-benchmarks)<1µs尤其难以进行准确测试。提高基准测试运行时间以产生更一致的结果:

go test -bench=. -benchtime=10s

多次运行以消除噪音

基准测试受到系统上其他进程的调度、CPU热伸缩以及现代CPU上的各种超线程效果的影响。

多次运行基准测试并取平均值可以帮助消除这些因素的影响:

go test -bench=. -count=10

2.2 使用testing包进行基准测试

testing包内置了对编写基准测试的支持。如果我们有一个简单的函数:

func Fib(n int) int {
    if n < 2 {
        return n
    }
    return Fib(n-1) + Fib(n-2)
}

则该函数的基准测试如下:

func BenchmarkFib20(b *testing.B) {
    for n := 0; n < b.N; n++ {
        Fib(20)  // 运行Fib(20) b.N次
    }
}

基准测试的规则:

  • 基准测试函数以Benchmark开头
  • 基准测试函数接受一个*testing.B参数
  • 基准测试循环必须运行b.N
    • b.N的值由测试框架自动调整
    • 框架会调整b.N直到基准测试运行足够长时间(默认1秒)

运行基准测试

使用go test-bench标志运行基准测试:

# 运行当前包中的所有基准测试
go test -bench=.

# 运行匹配特定模式的基准测试
go test -bench=Fib

# 运行包中的所有基准测试,匹配Fib
go test -bench=Fib .

注意:

  • 默认情况下,go test会运行单元测试然后运行基准测试
  • 要只运行基准测试而跳过单元测试,使用-run标志并提供一个不匹配任何测试的模式:
go test -run=^$ -bench=.

基准测试输出解读

运行基准测试会产生类似这样的输出:

BenchmarkFib20-8         500      2837849 ns/op

输出解释:

  • BenchmarkFib20-8:基准测试名称
    • 8GOMAXPROCS的值(可用的CPU核心数)
  • 500:循环运行了500次(b.N的值)
  • 2837849 ns/op:每次操作平均耗时2,837,849纳秒(约2.8毫秒)

提高基准测试准确性

使用-benchtime标志:

# 运行更长时间以提高准确性
go test -bench=. -benchtime=10s

# 或指定固定的迭代次数
go test -bench=. -benchtime=20x

比较不同输入的基准测试

让我们比较Fib(1)Fib(10)Fib(20)的性能:

func BenchmarkFib1(b *testing.B) {
    for n := 0; n < b.N; n++ {
        Fib(1)
    }
}

func BenchmarkFib10(b *testing.B) {
    for n := 0; n < b.N; n++ {
        Fib(10)
    }
}

func BenchmarkFib20(b *testing.B) {
    for n := 0; n < b.N; n++ {
        Fib(20)
    }
}

运行结果:

$ go test -bench=.
BenchmarkFib1-8     1000000000      2.06 ns/op
BenchmarkFib10-8      5000000       338 ns/op
BenchmarkFib20-8         500      2837849 ns/op

观察:

  • Fib(1)非常快,只需2纳秒
  • Fib(10)需要338纳秒
  • Fib(20)需要约2.8毫秒
  • 递归算法的性能随输入呈指数增长

2.3 使用benchstat比较基准测试

在上一节中,我们看到单次基准测试运行受到功率管理、后台进程和热管理的影响。

对于一组基准测试,问题更加严重,当值变化时,很难判断基准测试的结果是真实的还是误差范围内的变化。

安装benchstat

go install golang.org/x/perf/cmd/benchstat@latest

使用benchstat

Benchstat 可以获取一组基准测试运行并告诉你它们的稳定性。这是一个示例:

# 运行基准测试10次并保存结果
go test -bench=Fib20 -count=10 > old.txt

假设我们对代码进行了优化,现在想看看改进了多少:

# 再次运行基准测试10次
go test -bench=Fib20 -count=10 > new.txt

# 使用benchstat比较
benchstat old.txt new.txt

benchstat输出:

name     old time/op  new time/op  delta
Fib20-8  2.83ms ± 1%  1.71ms ± 3%  -39.43%  (p=0.000 n=10+10)

输出解释:

  • old time/op:优化前的平均时间
  • new time/op:优化后的平均时间
  • ±1%:标准差(结果的变化范围)
  • -39.43%:性能提升了39.43%
  • (p=0.000 n=10+10)
    • p=0.000:p值,非常接近0表示差异显著
    • n=10+10:每组运行了10次

p值的含义:

  • p < 0.05:差异统计上显著(95%置信度)
  • p < 0.01:差异非常显著(99%置信度)
  • p > 0.05:差异可能只是噪音

Benchstat还会报告在样本之间未检测到性能变化:

name     old time/op  new time/op  delta
Fib20-8  2.83ms ± 1%  2.82ms ± 2%   ~     (p=0.190 n=10+10)

~表示benchstat无法确定新旧样本之间是否存在性能变化。

建议:

  • 在进行任何优化之前,先运行基准测试并保存结果
  • 进行优化后,再次运行并使用benchstat比较
  • 只有当benchstat显示显著差异时,才认为优化有效

2.4 避免基准测试的启动成本

有时你的基准测试有一次性设置逻辑,你不想包含在基准测试的时间中:

func BenchmarkExpensive(b *testing.B) {
    // 昂贵的设置
    boringAndExpensiveSetup()
    
    b.ResetTimer()  // 重置计时器
    
    for n := 0; n < b.N; n++ {
        // 要测试的函数
        thing()
    }
}

b.ResetTimer() 会重置基准测试时钟,排除设置时间。

如果设置逻辑在循环内部

如果你的基准测试的设置逻辑在for循环内部,使用b.StopTimer()b.StartTimer()来暂停基准测试计时器:

func BenchmarkComplicated(b *testing.B) {
    for n := 0; n < b.N; n++ {
        b.StopTimer()  // 暂停计时
        complicatedSetup()
        b.StartTimer()  // 继续计时
        
        // 要测试的函数
        thing()
    }
}

注意: 过度使用StopTimerStartTimer会影响基准测试的准确性。尽量在循环外部进行设置。

2.5 基准测试内存分配

分配(allocations)计数和大小与基准测试时间密切相关。

你可以告诉testing框架记录被测试代码的分配统计信息:

go test -bench=. -benchmem

示例:

func BenchmarkRead(b *testing.B) {
    b.ReportAllocs()  // 或者在命令行使用-benchmem
    for n := 0; n < b.N; n++ {
        // 要测试的代码
    }
}

输出:

BenchmarkRead-8   500000   3557 ns/op   800 B/op   6 allocs/op

输出解释:

  • 3557 ns/op:每次操作耗时
  • 800 B/op:每次操作分配的字节数
  • 6 allocs/op:每次操作的分配次数

为什么关注分配?

  1. 分配本身需要时间
  2. 增加GC压力
  3. 影响缓存性能

减少分配通常是提高性能的有效方法。

示例:预分配slice

func WithoutPrealloc(size int) []int {
    var s []int
    for i := 0; i < size; i++ {
        s = append(s, i)
    }
    return s
}

func WithPrealloc(size int) []int {
    s := make([]int, 0, size)  // 预分配容量
    for i := 0; i < size; i++ {
        s = append(s, i)
    }
    return s
}

func BenchmarkWithoutPrealloc(b *testing.B) {
    for n := 0; n < b.N; n++ {
        WithoutPrealloc(1000)
    }
}

func BenchmarkWithPrealloc(b *testing.B) {
    for n := 0; n < b.N; n++ {
        WithPrealloc(1000)
    }
}

运行:

$ go test -bench=. -benchmem
BenchmarkWithoutPrealloc-8   50000   32420 ns/op   57344 B/op  11 allocs/op
BenchmarkWithPrealloc-8     100000   16580 ns/op    8192 B/op   1 allocs/op

结果分析:

  • 预分配快了约2倍
  • 预分配使用的内存少了86%
  • 预分配的分配次数从11次减少到1次

2.6 注意编译器优化

这个例子来自issue #14813

const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101

func popcnt(x uint64) uint64 {
    x -= (x >> 1) & m1
    x = (x & m2) + ((x >> 2) & m2)
    x = (x + (x >> 4)) & m4
    return (x * h01) >> 56
}

func BenchmarkPopcnt(b *testing.B) {
    for i := 0; i < b.N; i++ {
        popcnt(uint64(i))
    }
}

问题: 运行这个基准测试会显示它非常快,但可能是因为编译器优化掉了循环体。

解决方案:将结果赋值给包级变量

var result uint64

func BenchmarkPopcnt(b *testing.B) {
    var r uint64
    for i := 0; i < b.N; i++ {
        r = popcnt(uint64(i))
    }
    result = r  // 确保编译器不会优化掉循环
}

为什么这样有效:

  • 编译器必须假设result会被其他地方读取
  • 因此不能优化掉popcnt的调用

更好的方式:使用testing.B的API

从Go 1.4开始,testing包提供了b.SetBytes(n int64)来记录每次操作处理的字节数。

虽然这主要用于IO基准测试,但也可以防止编译器优化:

func BenchmarkPopcnt(b *testing.B) {
    for i := 0; i < b.N; i++ {
        x := popcnt(uint64(i))
        _ = x  // 明确表示我们使用了结果
    }
}

2.7 基准测试常见错误

错误1:在循环内做设置工作

// ❌ 错误:每次迭代都重新创建数据
func BenchmarkBad(b *testing.B) {
    for n := 0; n < b.N; n++ {
        data := make([]int, 10000)  // 设置工作
        for i := range data {
            data[i] = i
        }
        processData(data)  // 实际要测试的代码
    }
}

// ✅ 正确:在循环外准备数据
func BenchmarkGood(b *testing.B) {
    data := make([]int, 10000)
    for i := range data {
        data[i] = i
    }
    b.ResetTimer()
    
    for n := 0; n < b.N; n++ {
        processData(data)
    }
}

错误2:测试环境不稳定

// ❌ 问题:
// - 后台程序运行
// - CPU频率动态变化
// - 只运行一次

// ✅ 解决:
// - 关闭不必要的程序
// - 固定CPU频率
// - 多次运行
go test -bench=. -count=10 -benchmem

错误3:忽略内存分配

// ❌ 只看时间,忽略分配
go test -bench=.

// ✅ 同时看时间和分配
go test -bench=. -benchmem

错误4:只运行一次基准测试

// ❌ 单次运行结果不可靠
go test -bench=.

// ✅ 多次运行,使用benchstat
go test -bench=. -count=10 > old.txt
# 修改代码...
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt

2.8 从基准测试进行性能剖析

testing包内置了对生成CPU、内存和block profiling的支持。

生成CPU profile

go test -bench=. -cpuprofile=cpu.out

生成内存profile

go test -bench=. -memprofile=mem.out

生成block profile

go test -bench=. -blockprofile=block.out

分析profile

# 交互式分析CPU profile
go tool pprof cpu.out

# 在浏览器中查看
go tool pprof -http=:8080 cpu.out

注意: 一次只使用一个profile标志。

我们将在下一章详细讨论pprof。

2.9 讨论

问题:

  1. 问: 基准测试的b.N是如何确定的?

    答: testing包会自动调整b.N,使基准测试运行至少1秒(或-benchtime指定的时间)。它从b.N=1开始,如果运行得太快,就增加b.N并重新运行。

  2. 问: 为什么我的基准测试结果每次都不一样?

    答: 基准测试受到很多因素影响:系统负载、CPU频率缩放、缓存状态等。使用-count多次运行并用benchstat分析。

  3. 问: 我应该在CI中运行基准测试吗?

    答: 可以,但要小心。CI环境通常是虚拟化的,结果可能不稳定。考虑:

  4. 问: 基准测试够准确吗?需要用专业工具吗?

    答: 对于大多数情况,Go的基准测试工具已经足够。如果需要更深入的分析,可以使用:

    • perf(Linux)
    • Instruments(macOS)
    • Intel VTune

系列导航:

参考资料