Бенчмарки
Бенчмарки это тесты для производительности. Довольно полезно иметь их в проекте и сравнивать их результаты от коммита к коммиту. В 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 позволяет вам очень легко писать и анализировать результаты бенчмарков. Новые бенчмарки позволяют вам найти узкие места в производительности, подозрительный код (эффективный код обычно проще и легче читается) или использование неправильных инструментов для задач.
Существующие бенчмраки позволят вам быть более уверенным в изменениях и их результаты могут быть голосом в вашу пользу при ревью. Написание бенчмарков дает большие преимущества для программиста и программы и я советую вам писать их побольше. Это весело!
