Перейти до змісту

Бенчмарки, фазинг і детектор гонитв

Три інструменти, що постачаються з go test і знаходять те, чого звичайний тест не знайде: наскільки швидкий код, вхідні дані, про які ви й не думали, і баги конкурентності, що трапляються лише інколи.

func BenchmarkConcatBuilder(b *testing.B) {
    for b.Loop() {
        ConcatBuilder(input)
    }
}

Бенчмарки

Бенчмарк — це func BenchmarkXxx(b *testing.B) у файлі _test.go. b.Loop() виконує тіло достатню кількість разів, щоб отримати стабільне вимірювання — скільки саме, вирішує фреймворк.

go test -run XXX -bench . -benchmem ./...

-run XXX не збігається з жодним тестом, тож запускаються лише бенчмарки. -bench приймає regex; . означає "усі".

BenchmarkConcatPlus-12       37771     31376 ns/op   265138 B/op   499 allocs/op
BenchmarkConcatBuilder-12   506193      2366 ns/op     3320 B/op     9 allocs/op

Читання рядка: -12 — це GOMAXPROCS, далі кількість ітерацій, далі наносекунди на операцію, далі — з -benchmem — байти й кількість виділень пам'яті (allocations) на операцію.

Це те саме застереження про += у циклі зі статті про оператори, тільки виміряне: версія зі strings.Builder приблизно в тринадцять разів швидша й робить дев'ять виділень пам'яті замість 499. allocs/op часто є найбільш дієвим числом, бо саме кількість виділень визначає навантаження на збирач сміття.

Тримайте налаштування поза вимірюванням

func BenchmarkWithSetup(b *testing.B) {
    data := strings.Repeat("x", 1000)
    b.ResetTimer()
    for b.Loop() {
        _ = strings.ToUpper(data)
    }
}

b.ResetTimer() відкидає все виміряне досі. Також є b.StopTimer()/b.StartTimer() для налаштування на кожній ітерації, хоча це достатньо повільно, тож зазвичай краще перебудувати код.

Не давайте компілятору видалити вашу роботу

Якщо результат не використовується, оптимізатор може повністю прибрати виклик, і ви виміряєте порожній цикл. Присвоюйте пакетній змінній:

var sink string

func BenchmarkSink(b *testing.B) {
    for b.Loop() {
        sink = ConcatBuilder(input)
    }
}

b.Loop() стійкіший до цього за старішу форму for i := 0; i < b.N; i++, але sink лишається надійною звичкою. Бенчмарк, що показує неправдоподібно мало наносекунд на операцію, зазвичай оптимізовано геть.

Порівнюйте чесно

Один прогін — це шум. Числа бенчмарків рухаються разом із троттлінгом процесора, іншими процесами й станом кешу. Робіть кілька прогонів (-count=10) і порівнюйте через benchstat, який повідомляє, чи є різниця статистично значущою. Вимірюйте зміну на тій самій машині в тій самій сесії; ніколи не порівнюйте числа з двох різних машин.

Фазинг

Ціль для фазингу — це func FuzzXxx(f *testing.F). Ви надаєте насіннєві вхідні дані (seed inputs) і властивість; час виконання генерує мутації, шукаючи ту, що її ламає:

func FuzzParseKV(f *testing.F) {
    f.Add("a=b")
    f.Add("noequals")
    f.Add("=")

    f.Fuzz(func(t *testing.T, s string) {
        k, v, ok := ParseKV(s)
        if ok && k+"="+v != s {
            t.Fatalf("round trip failed for %q: %q %q", s, k, v)
        }
    })
}
go test -run XXX -fuzz FuzzParseKV -fuzztime 4s ./...

Без -fuzz ціль все одно виконується як звичайний тест над насіннєвим корпусом, тож цілі для фазингу корисні в CI, навіть коли ви не фазите. Фазити можна лише одну ціль за раз.

Що він знаходить і що записує

Наведіть його на функцію з неперевіреним припущенням — і він знайде вхідні дані за мілісекунди:

fuzz: minimizing 27-byte failing input file
--- FAIL: FuzzFirstByte (0.03s)
    testing.go:2076: panic: runtime error: index out of range [0] with length 0
    Failing input written to testdata/fuzz/FuzzFirstByte/5838cdfae7b16cde

Варто помітити дві речі. Він спершу мінімізував вхідні дані — 27 байтів, на яких він спіткнувся, звелися до найменшого, що досі провалюється. І він записав випадок у testdata/:

go test fuzz v1
string("")

Закомітьте цей файл. Він стає частиною насіннєвого корпусу, тож баг перетворюється на постійний регресійний тест, який виконується при кожному go test, без потреби в жодному фазингу.

Що робить властивість гарною

Твердження не може звучати як "вивід дорівнює X", бо ви не знаєте вхідних даних. Корисні форми:

  • Не панікує — часто цього достатньо самого по собі для парсера.
  • Проходить туди й назад — decode(encode(x)) == x.
  • Збігається з повільнішою, очевидно правильною реалізацією.
  • Дотримуються інваріанти — вивід відсортований, довжина збережена.

Фазинг підходить для будь-чого, що приймає ненадійні байти: парсерів, декодерів, валідаторів, усього, що обробляє тіло запиту.

Детектор гонитв

-race інструментує доступ до пам'яті й повідомляє про несинхронізований конкурентний доступ:

go test -race ./...
WARNING: DATA RACE
Read at 0x00c0000122c8 by goroutine 19:
      r_test.go:20 +0x68
Previous write at 0x00c0000122c8 by goroutine 8:
      r_test.go:20 +0x78

Показуються обидва стеки викликів, що зазвичай робить баг очевидним. Тут це counter++ зі ста горутин — інкремент є читанням і записом, а не атомарною операцією, тож губить оновлення.

Три речі, які варто зрозуміти про нього:

Він повідомляє лише про гонитви, які дійсно сталися. Це не статичний аналіз. Гонитва на шляху, яким ваш тест ніколи не проходить, невидима, тому запуск усього набору тестів під -race важливіший за запуск одного тесту.

Він коштує. Приблизно в п'ять-десять разів повільніше і значно більше пам'яті. Запускайте його в CI на кожній збірці; локально — коли торкаєтесь конкурентності.

У нього немає хибних спрацювань. Якщо -race про щось повідомляє — це справжній баг. Не додавайте м'ютекс лише щоб його заглушити — спочатку зрозумійте, що саме є спільним.

-race також ловить несинхронізований доступ до мапи, який інакше проявляється як убивство процесу середовищем виконання з повідомленням "concurrent map writes" у непередбачуваний момент.

З досвіду Python: бенчмарки — це timeit, вбудований у раннер тестів, із доданими підрахунками виділень пам'яті. Фазинг — це Hypothesis, але заснований на мутаціях, а не на стратегіях, і він автоматично зберігає провали на диск як регресійні тести. У детектора гонитв немає аналога в Python — через GIL більшість цих багів там статися не можуть, а в Go можуть, ще й як.

Швидка довідка

Задача Форма
бенчмарк func BenchmarkXxx(b *testing.B), for b.Loop()
запустити їх go test -run XXX -bench . -benchmem ./...
виключити налаштування b.ResetTimer()
уникнути видалення оптимізатором присвоєння пакетному sink
порівняти прогони -count=10 плюс benchstat
ціль для фазингу func FuzzXxx(f *testing.F), f.Add, f.Fuzz
фазити її go test -run XXX -fuzz FuzzXxx -fuzztime 30s
знайдений провал записаний у testdata/fuzz/... — закомітьте його
гарні властивості без паніки, туди й назад, інваріанти
знайти гонитви даних go test -race ./... у CI

Джерела