聊聊 Go SDK 代码性能优化

481 阅读10分钟

1. 前言

过往我们一般会压测工具 + Go Pprof 采样来识别性能热点、评估性能优化结果,但是总会出现“明明逻辑优化了,压测数据反而变得更差了”的情况。

探索发现受计算集群实例超卖、业务离线+在线混部、CPU 型号不同、CPU 新旧等因素影响, 云上的「不同实例」或「同一实例不同时间段」性能差异都较大(±30%)。压测数据受其干扰较大,导致无法用性能优化前后的压测数据来进行对照从而明确性能优化成果。

本文主要介绍一种性能优化思路,相较于压测,其会受外部影响更小,结果相对更客观,但是其模拟线上真实流量成本较高。

2. 解决方法

一句话描述:基于 go benchmark + benchstat 构建「性能指标度量反馈循环」系统,使用其持续地牵引着系统不断进行性能优化。

主要流程如下:

image.png

阶段建设思路
撰写 Benchmark 代码基于基准分支:根据梳理接口列表、场景场景接口参数,Mock 外部依赖,实现对应 Benchmark 代码并提交
优化前 Benchmark,采样 Pprof,识别性能热点基于基准分支:执行 Benchmark,获取对应 CPU、Mem 等 Pprof 结果,根据 Pprof 识别性能热点区域
性能优化切换性能优化分支:根据下文 [常见代码优化思路] 来优化热点区域代码,减少代码 CPU 占用和内存分配次数等
获取优化前后 Benchmark 数据并对比在基准分支和性能优化分支分别执行 Benchmark 获取数据,使用 benchstat 对比明确优化的收益

3. 举个例子🌰

3.1 Demo

package main

type AStruct struct {
    A int
    B int
    C int
}

func NewStructA() *AStruct {
    return &AStruct{}
}

func (aStruct *AStruct) ADD() {
    aStruct.C = aStruct.A + aStruct.B
}

type BStruct struct {
    A int
    B int
    C int
}

func NewStructB() BStruct {
    return BStruct{}
}

func (bStruct *BStruct) ADD() {
    bStruct.C = bStruct.A + bStruct.B
}

func DoSomething() {
    for i := 100000; i > 0; i-- {
        a := NewStructA()
        a.ADD()
    }

    for i := 100000; i > 0; i-- {
        b := NewStructB()
        b.ADD()
    }
}

3.2 撰写 Benchmark 代码

由于 Demo 没有入参,所以此处省略梳理使用场景和构造入参

撰写 Benchmark 代码:

package main

import "testing"

func BenchmarkDoSomething(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        DoSomething()
    }
}

提交并合入基准分支:

git add .
git commit -m ":art: 撰写 benchmark 代码"
git push origin xxx

3.3 优化前 Benchmark,采样 Pprof,识别性能热点

获得 Pprof 采样:

执行以下命令,将获得 cpu.pprof 和 mem.pprof

-gcflags="all=-l -N" 用于避免编译器优化

go test -bench=. ./... -memprofile mem.pprof -cpuprofile cpu.pprof -gcflags="all=-l -N"

执行以下命令可以渲染图形化页面:

图形化页面提前安装 graphviz,可通过brew install graphviz 等方式安装

也可直接 go tool pprof cpu.out 进入 pprof 终端,使用 top、list 等命令发现性能瓶颈,具体可参考 golang2.eddycjy.com/posts/ch6/0…

go tool pprof -http=":1234" cpu.out

基于 sourceflamegraph 两种视图可分析发现性能热点主要是 NewStructA,且NewStructA相较于 NewStructB 性能开销多很多,而NewStructANewStructB 不同点在于是否返回指针类型,基于下文[常见代码优化思路]分析返回指针会触发内存逃逸,需要优化。

3.4 性能优化

进行性能优化:

// 优化前
func NewStructA() *AStruct {
    return &AStruct{}
}
// 优化后
func NewStructA() AStruct {
    return AStruct{}
}

3.5 获取优化前后 Benchmark 数据并对比

执行以下命令获取优化后的性能数据

// 切换基准分支,执行以下命令,并将 old_benchmark.txt 拷贝出
go test -bench=. ./... -benchmem -cpu=4 -count 10 -gcflags="all=-l -N" > ./old_benchmark.txt

// 切换性能优化分支,执行以下命令
go test -bench=. ./... -benchmem -cpu=4 -count 10 -gcflags="all=-l -N" > ./new_benchmark.txt

得到 old_benchmark.txt 如下

goos: darwin
goarch: arm64
pkg: xxx
BenchmarkDoSomething-4                538           2049147 ns/op         2400003 B/op          100000 allocs/op
BenchmarkDoSomething-4                585           2113461 ns/op         2400006 B/op          100000 allocs/op
BenchmarkDoSomething-4                586           2049284 ns/op         2400008 B/op          100000 allocs/op
BenchmarkDoSomething-4                586           2102973 ns/op         2400005 B/op          100000 allocs/op
BenchmarkDoSomething-4                571           2088303 ns/op         2400008 B/op          100000 allocs/op
BenchmarkDoSomething-4                574           2076815 ns/op         2400003 B/op          100000 allocs/op
BenchmarkDoSomething-4                578           2083408 ns/op         2400002 B/op          100000 allocs/op
BenchmarkDoSomething-4                577           2086304 ns/op         2400007 B/op          100000 allocs/op
BenchmarkDoSomething-4                580           2107555 ns/op         2400004 B/op          100000 allocs/op
BenchmarkDoSomething-4                555           2061220 ns/op         2400007 B/op          100000 allocs/op
PASS
ok          xxx       14.710s

得到 new_benchmark.txt 如下

goos: darwin
goarch: arm64
pkg: xxxx
BenchmarkDoSomething-4               2713            438091 ns/op               0 B/op               0 allocs/op
BenchmarkDoSomething-4               2732            440362 ns/op               0 B/op               0 allocs/op
BenchmarkDoSomething-4               2724            443018 ns/op               0 B/op               0 allocs/op
BenchmarkDoSomething-4               2649            448859 ns/op               0 B/op               0 allocs/op
BenchmarkDoSomething-4               2684            444539 ns/op               0 B/op               0 allocs/op
BenchmarkDoSomething-4               2668            446069 ns/op               0 B/op               0 allocs/op
BenchmarkDoSomething-4               2712            442734 ns/op               0 B/op               0 allocs/op
BenchmarkDoSomething-4               2671            442440 ns/op               0 B/op               0 allocs/op
BenchmarkDoSomething-4               2744            440489 ns/op               0 B/op               0 allocs/op
BenchmarkDoSomething-4               2745            441778 ns/op               0 B/op               0 allocs/op
PASS
ok          xxx       13.999s

获取收益:

执行以下命令

更多用法见:benchstat 使用文档

benchstat ./old_benchmark.txt ./new_benchmark.txt

可以得到 sec/op 优化了 78.77%allocs/op 内存分配优化了 100%,并且发现内存分配次数是性能重要影响因素。

goos: darwin
goarch: arm64
pkg: xxxx
              │ ./old_benchmark.txt │         ./new_benchmark.txt         │
              │       sec/op        │   sec/op     vs base                │
DoSomething-4          2084.9µ ± 2%   442.6µ ± 1%  -78.77% (p=0.000 n=10)

              │ ./old_benchmark.txt │          ./new_benchmark.txt          │
              │        B/op         │     B/op      vs base                 │
DoSomething-4          2.289Mi ± 0%   0.000Mi ± 0%  -100.00% (p=0.000 n=10)

              │ ./old_benchmark.txt │        ./new_benchmark.txt         │
              │      allocs/op      │ allocs/op  vs base                 │
DoSomething-4           100.0k ± 0%   0.0k ± 0%  -100.00% (p=0.000 n=10)

3.6 分支性能比对脚本(流程优化)

一般会单独切一个分支进行性能优化,下文脚本会在基准分支和当前分支上执行 Benchmark 结果,并将结果进行对照比对。

#!/bin/bash
# 对比两个分析版本的benchmark差异的bash脚本

# 准备环境

go install golang.org/x/perf/cmd/benchstat@latest
# 获取当前分支
NOW_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
DIR_SOURCE="$(pwd)"
rm "${DIR_SOURCE}""/old_benchmark.txt"
rm "${DIR_SOURCE}""/now_benchmark.txt"
rm "${DIR_SOURCE}""/benchmark_diff.txt"
touch "${DIR_SOURCE}""/old_benchmark.txt"
touch "${DIR_SOURCE}""/now_benchmark.txt"

DIR_OLD="$(mktemp -d)"

function cleanup() {
  rm -rf "${DIR_OLD}"
}
trap cleanup EXIT

# 使用 Git 检出比对的目标代码分支
TARGET_BRANCH="${CI_EVENT_CHANGE_TARGET_BRANCH:-master}"
echo "Checking for performance change relative to ${TARGET_BRANCH}"

if [ -z "$(git show-ref "refs/heads/${TARGET_BRANCH}")" ]; then
    git branch "${TARGET_BRANCH}" "origin/${TARGET_BRANCH}"
fi
git clone -b "${TARGET_BRANCH}" . "${DIR_OLD}"

echo "运行旧版本基准测试..."
cd $DIR_OLD
go mod tidy
go test -bench=. ./... -benchmem -cpu=4 -count 10 |grep Benchmark > "${DIR_SOURCE}""/old_benchmark.txt"

echo "运行新版本基准测试..."
cd $DIR_SOURCE
go mod tidy
go test -bench=. ./... -benchmem -cpu=4 -count 10 |grep Benchmark > "${DIR_SOURCE}""/now_benchmark.txt"

# 使用benchstat工具对比两个基准测试结果
echo "对比测试结果..."
benchstat "${TARGET_BRANCH}"="${DIR_SOURCE}""/old_benchmark.txt" "${NOW_BRANCH}"="${DIR_SOURCE}""/now_benchmark.txt" > "${DIR_SOURCE}""/benchmark_diff.txt"

4. 常见代码性能优化思路

4.1 优化前思考

一般性能优化都到还不需要到「优化代码性能」的地步,所以做「代码性能优化」前可能需要做出一些思考:

  • 是否有其他方式?——> 代码性能优化往往开发成本较高,优化后代码可维护性,通用性可能都会降低
  • 当前的逻辑架构是否合理? ——> 换个架构是不是性能就翻倍了
  • 是否能减少下游服务/存储调用?——> 减少一次外部调用可能比把代码优化到极致收益还要大很多
  • 是否可以减少日志打印,指标埋点?——> 同上

4.2 提前终止、避免执行多余逻辑

举例: 下面的代码明明设置了日志等级为 Info,但是打印 Debug 日志仍然会消耗很多日志

日志等级 Info>Debug,设置 Info 日志等级,Debug 日志不会输出

import (
	"encoding/json"
	"fmt"
	"testing"

	"go.uber.org/zap"
)

type AStruct struct {
	A int
	B int
	C int
}

func NewStructA() AStruct {
	return AStruct{}
}

func BenchmarkLogDebug(b *testing.B) {
	config := zap.NewProductionConfig()
	config.Level.SetLevel(zap.InfoLevel)
	logger, _ := config.Build()
	defer logger.Sync()
	sugar := logger.Sugar()
	structA := NewStructA()
	b.Run("Log_Base", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sugar.Debugf("structA is %s", ToString(structA))
		}
	})
}

func ToString(v interface{}) string {
	str, _ := json.Marshal(v)
	return string(str)
}

// BenchmarkLogDebug/Log_Base-12            7869234               141.2 ns/op            88 B/op          4 allocs/op

通过 benchmark 与 Pprof 分析可以得到其执行了 json.Marshal 多余逻辑

image.png

所以我们可以通过延迟执行等方式避免代码执行多余逻辑:

import (
	"encoding/json"
	"fmt"
	"testing"

	"go.uber.org/zap"
)

func BenchmarkLog(b *testing.B) {
	config := zap.NewProductionConfig()
	config.Level.SetLevel(zap.InfoLevel)
	logger, _ := config.Build()
	defer logger.Sync()
	sugar := logger.Sugar()
	structA := NewStructA()
	b.Run("Log_Base", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sugar.Debugf("structA is %s", ToString(structA))
		}
	})
	b.Run("Log2", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sugar.Debugf("structA is %s", Lazy(structA))
		}
	})
	b.Run("Log3", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sugar.Debugf("structA is %s", NewLogString(structA))
		}
	})
}

type AStruct struct {
	A int
	B int
	C int
}

func NewStructA() AStruct {
	return AStruct{}
}

func ToString(v interface{}) string {
	str, _ := json.Marshal(v)
	return string(str)
}

type Func func() string

// String an implement of stringer interface
func (f Func) String() string {
	return f()
}

func Lazy(v interface{}) Func {
	return func() string {
		str, _ := json.Marshal(v)
		return string(str)
	}
}

var _ fmt.Stringer = logString{}

type logString struct {
	obj interface{}
}

func NewLogString(v interface{}) logString {
	return logString{obj: v}
}

func (l logString) String() string {
	str, _ := json.Marshal(l.obj)
	return string(str)
}

// BenchmarkLog/Log_Base-12                 7129472               140.9 ns/op            88 B/op          4 allocs/op
// BenchmarkLog/Log2-12                    35531034                32.91 ns/op           48 B/op          2 allocs/op
// BenchmarkLog/Log3-12                    35388964                32.94 ns/op           40 B/op          2 allocs/op

4.3 避免内存分配、逃逸

ns/op 是指每次执行的平均执行时间(nanoseconds per operation),值越小表示操作执行得更快。

*纳秒(ns) *:是时间的最小单位之一,等于一秒的十亿分之一(1/1,000,000,000 秒)。纳秒通常用于测量非常短暂、高精度的时间间隔,例如计算机内部操作的速度、函数的执行时间等

B/op 是指每次执行的平均内存分配量,单位为字节(bytes)。

allocs/op 是指每次执行的内存分配次数(allocations per operation)

通过 benchmark 测试发现,往往ns/op 越大则 allocs/op 越大,通过下文 Benchmark 可得出,一次内存分配相当于执行加法计算 70 余次,所以往往我们做代码性能优化最重要的是避免内存分配。

func BenchmarkAllocs(b *testing.B) {
    logs.SetLevel(logs.LevelInfo)
    log.V2.SetLevel(logs.LevelInfo)
    b.Run("Calc", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            for i := 75; i > 0; i-- {
                _ = i + 1
            }
        }
    })
    b.Run("Allocs", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            slice := make([]int, 4)
            for i := 0; i < 4; i++ {
                slice = append(slice, 0)
            }
            _ = slice
        }
    })
}
BenchmarkAllocs/Calc-12                 50459848                23.55 ns/op            0 B/op            0 allocs/op
BenchmarkAllocs/Allocs-12               46024180                25.79 ns/op           64 B/op            1 allocs/op

常见避免内存分配的方法如下:

分析内存逃逸可参考:Go 逃逸分析

4.3.1 预分配,避免动态扩容

Bad Case:

length := 100
slice := make([]string,0)
for i := 0; i < length; i++ {
    slice = append(slice, i)
}

Good Case:

length := 100
slice := make([]int, 0, length)
for i := 0; i < length; i++ {
    slice = append(slice, i)
}

4.3.2 避免返回局部变量地址(避免内存逃逸)

NewStructA 函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配逃逸到堆上。

当然有时编译器会自动帮我们内联优化,避免内存逃逸

Bad Case:

type AStruct struct {
    A int
    B int
    C int
}

func NewStructA() *AStruct {
    return &AStruct{}
}

func (aStruct *AStruct) ADD() {
    aStruct.C = aStruct.A + aStruct.B
}

func main() {
        a := NewStructA()
        a.ADD()
}

Good Case:

type AStruct struct {
    A int
    B int
    C int
}

func NewStructA() AStruct {
    return AStruct{}
}

func (aStruct *AStruct) ADD() {
    aStruct.C = aStruct.A + aStruct.B
}

func main() {
        a := NewStructA()
        a.ADD()
}

4.3.3 尽量避免使用 interface{} 和 %v(避免内存逃逸)

interface{} 支持代码更抽象,所以有时也需要在抽象和性能间做权衡

interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。

4.3.4 使用 Sync.Pool大杀器

可参考:geektutu.com/post/hpg-sy…

如果你能掌握一个 变量的生命周期,那么就可以使用 Sync.Pool 复用内存来避免内存分配,比如场景: 接口每个请求内部逻辑都需要 New 一个 Struct,并且在请求返回前可以回收

4.4 选择高性能依赖

基础库高性能实现
Jsongithub.com/bytedance/s…
Protobufgithub.com/gogo/protob…
Hashgithub.com/twmb/murmur3
一致性 Hashwritings.sh/post/consis…

5. 最后

技术本身是没有优劣之分的,其优劣往往取决于具体的使用场景和需求

感谢你的阅读,都看到这儿了,不妨点个赞再走 😘