Бенчмарки
Бенчмарки это тесты для производительности. Довольно полезно иметь их в проекте и сравнивать их результаты от коммита к коммиту. В Go есть очень хороший инструментарий для написания и запуска бенчмарков. В этой статье я покажу, как использовать пакет
testing
для написания бенчмарков.Как написать бенчмарк
Это просто в Go. Вот пример простейшего бенчмарка:
func BenchmarkSample(b *testing.B) {
for i := 0; i < b.N; i++ {
if x := fmt.Sprintf("%d", 42); x != "42" {
b.Fatalf("Unexpected string: %s", x)
}
}
}
Сохраните этот код в файл bench_test.go и запустите команду
go test -bench=. bench_test.go
.Вы увидите что-то вроде:
testing: warning: no tests to run
PASS
BenchmarkSample 10000000 206 ns/op
ok command-line-arguments 2.274s
Мы видим здесь, что одна итерация бенчмарка заняла 206 наносекунд. Это было действительно просто. Но есть еще пара интересных вещей о бенчмарках в Go.
Что вы можете тестировать бенчмарками?
По умолчанию
go test -bench=.
тестирует только скорость вашего кода, однако вы можете добавить флаг -benchmem
, который позволит тестировать потребление памяти и количество аллокаций памяти. Это будет выглядеть так:PASS
BenchmarkSample 10000000 208 ns/op 32 B/op 2 allocs/op
Здесь мы видим количество байт и аллокаций памяти за итерацию. Полезная информация как по мне. Вы также можете включить эти результаты для каждого бенчмарка в отдельности вызвав метод
b.ReportAllocs()
.Но это еще не все, вы можете также задать пропускную способность за одну итерацию в байтах при помощи метода
b.SetBytes(n int64)
. Например:func BenchmarkSample(b *testing.B) {
b.SetBytes(2)
for i := 0; i < b.N; i++ {
if x := fmt.Sprintf("%d", 42); x != "42" {
b.Fatalf("Unexpected string: %s", x)
}
}
}
Теперь вывод будет:
PASS
BenchmarkSample 5000000 324 ns/op 6.17 MB/s 32 B/op 2 allocs/op
ok command-line-arguments 1.999s
Вы можете видеть колонку с пропускной способности, которая равна
6.17 MB/s
в моем случае.Начальные условия для бенчмарков
Что если вам нужно сделать что-нибудь перед каждой итерацией бенчмарка? Вы конечно же не захотите включать время этой операции в результаты бенчмарка. Я написал очень простую структуру данных
Set
для тестирования:type Set struct {
set map[interface{}]struct{}
mu sync.Mutex
}
func (s *Set) Add(x interface{}) {
s.mu.Lock()
s.set[x] = struct{}{}
s.mu.Unlock()
}
func (s *Set) Delete(x interface{}) {
s.mu.Lock()
delete(s.set, x)
s.mu.Unlock()
}
и бенчмарк для метода
Delete
:func BenchmarkSetDelete(b *testing.B) {
var testSet []string
for i := 0; i < 1024; i++ {
testSet = append(testSet, strconv.Itoa(i))
}
for i := 0; i < b.N; i++ {
b.StopTimer()
set := Set{set: make(map[interface{}]struct{})}
for _, elem := range testSet {
set.Add(elem)
}
for _, elem := range testSet {
set.Delete(elem)
}
}
}
В этом коде имеется две проблемы:
- Время и память создания слайса
testSet
включаются в первую итерация (и это не очень большая проблема, потому что итераций будет много) - Время и память на вызов метода
Add
включается в каждую итерацию
Для таких случаев у нас имеются методы
b.ResetTimer()
, b.StopTimer()
и b.StartTimer()
. Здесь показано их использование в предыдущем бенчмарке:func BenchmarkSetDelete(b *testing.B) {
var testSet []string
for i := 0; i < 1024; i++ {
testSet = append(testSet, strconv.Itoa(i))
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
set := Set{set: make(map[interface{}]struct{})}
for _, elem := range testSet {
set.Add(elem)
}
b.StartTimer()
for _, elem := range testSet {
set.Delete(elem)
}
}
}
Теперь начальная настройка не будет учтена в результатах и мы увидим только результаты вызова метода
Delete
.Сравнение бенчмарков
Конечно, в бенчмарках мало толку, если вы не можете их сравнить после изменения кода. Вот пример кода, который сериализует структуру в
json
и бенчмарк для него:type testStruct struct {
X int
Y string
}
func (t *testStruct) ToJSON() ([]byte, error) {
return json.Marshal(t)
}
func BenchmarkToJSON(b *testing.B) {
tmp := &testStruct{X: 1, Y: "string"}
js, err := tmp.ToJSON()
if err != nil {
b.Fatal(err)
}
b.SetBytes(int64(len(js)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := tmp.ToJSON(); err != nil {
b.Fatal(err)
}
}
}
Допустим, этот код уже добавлен в git, теперь я хочу попробовать клевый трюк и измерить прирост (или падение) производительности. Я слегка меняю метод
ToJSON
:func (t *testStruct) ToJSON() ([]byte, error) {
return []byte(`{"X": ` + strconv.Itoa(t.X) + `, "Y": "` + t.Y + `"}`), nil
}
Самое время запустить бенчмарки, в этот раз сохраним их вывод в файлы:
go test -bench=. -benchmem bench_test.go > new.txt
git stash
go test -bench=. -benchmem bench_test.go > old.txt
Мы можем сравнить эти результаты с помощью утилиты benchcmp. Вы можете установить ее, выполнив команду
go get golang.org/x/tools/cmd/benchcmp
. Вот результаты сравнения:# benchcmp old.txt new.txt
benchmark old ns/op new ns/op delta
BenchmarkToJSON 1579 495 -68.65%
benchmark old MB/s new MB/s speedup
BenchmarkToJSON 12.66 46.41 3.67x
benchmark old allocs new allocs delta
BenchmarkToJSON 2 2 +0.00%
benchmark old bytes new bytes delta
BenchmarkToJSON 184 48 -73.91%
Это очень полезно иметь такие таблицы при изменениях, к тому же они могут добавить солидности к вашим пулл реквестам в opensource проекты.
Запись профилей
Также вы можете записать
cpu
и memory
профили во время выполнения бенчмарков:go test -bench=. -benchmem -cpuprofile=cpu.out -memprofile=mem.out bench_test.go
Про анализ профилей вы можете прочитать отличный пост в официальном блоге Go.
Заключение
Бенчмарки это прекрасный инструмент для программиста. И Go позволяет вам очень легко писать и анализировать результаты бенчмарков. Новые бенчмарки позволяют вам найти узкие места в производительности, подозрительный код (эффективный код обычно проще и легче читается) или использование неправильных инструментов для задач.
Существующие бенчмраки позволят вам быть более уверенным в изменениях и их результаты могут быть голосом в вашу пользу при ревью. Написание бенчмарков дает большие преимущества для программиста и программы и я советую вам писать их побольше. Это весело!
deep_orange
Не хватает про параллелизм.