Бенчмарки


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

Существующие бенчмраки позволят вам быть более уверенным в изменениях и их результаты могут быть голосом в вашу пользу при ревью. Написание бенчмарков дает большие преимущества для программиста и программы и я советую вам писать их побольше. Это весело!

Комментарии (2)


  1. deep_orange
    13.10.2015 01:00

    Не хватает про параллелизм.


  1. voidnugget
    13.10.2015 01:10

    Не хватает про llgo, и сравнение производительности в придачу.
    Сегодня, кстати, в рассылке llvm'a проскочило что в lldb golang'овский runtime добавили 1 и 2