open atlas
↑ К треку
Go с нуля до senior GO · 04 · 02

Фаззинг и бенчмарки: входы по покрытию, b.Loop и честность через benchstat

Нативный фаззинг мутирует входы по покрытию от сидов и минимизирует крашеры в testdata/fuzz как вечные регрессии — находя то, что таблицам не под силу. Бенчмарки: b.Loop (1.24) вместо b.N, ns/op и allocs/op через b.ReportAllocs, benchstat по -count=10 прогонам.

GO Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

У самописного парсера длительностей был 31 ряд в таблице и два года продакшена за плечами — все форматы, какие кто-либо придумал: 90s, 1.5h, 2h45m. Когда публичный эндпоинт начал принимать пользовательские интервалы ретраев, кто-то за десять минут собрал FuzzParseInterval с тремя seed-строками и оставил крутиться на обед. Через одиннадцать секунд, на скорости около 94 000 прогонов в секунду, фаззер напечатал панику: slice bounds out of range, вход ”+.” — знак без цифр уводил индекс за конец строки при сканировании суффикса. Ни один ряд таблицы такого не предвидел, потому что каждый ряд писал человек, знающий, как выглядит длительность. Минимизированный крашер лёг файлом в testdata/fuzz — мгновенно став регрессионным тестом, который обычный go test гоняет отныне всегда. Один краш публичного парсера — это 500-ка; тот же краш из горутины без recover — рестарт процесса. Фаззер нашёл за секунды то, чего два года примеров не нашли бы никогда.

Нативный фаззинг: сиды, мутация и корпус

С Go 1.18 фаззинг встроен в go test. Фазз-таргет — функция FuzzXxx(f *testing.F): сид-входы регистрируются через f.Add, затем f.Fuzz получает свойство, обязанное выполняться для любого входа:

func FuzzParseInterval(f *testing.F) {
	f.Add("90s")
	f.Add("2h45m")
	f.Add("")
	f.Fuzz(func(t *testing.T, s string) {
		d, err := ParseInterval(s)
		if err != nil {
			return // невалидный вход можно отклонить — но нельзя паниковать
		}
		// свойство round-trip: форматируем распарсенное и парсим снова
		s2 := FormatInterval(d)
		d2, err := ParseInterval(s2)
		if err != nil || d2 != d {
			t.Fatalf("round-trip сломан: %q -> %v -> %q -> %v (%v)", s, d, s2, d2, err)
		}
	})
}

Без -fuzz go test просто прогоняет сиды плюс закоммиченный корпус — дёшево, детерминированно, дружелюбно к CI. С go test -fuzz=FuzzParseInterval движок мутирует входы по покрытию: вход, достигший новой ветки, сохраняется и мутируется дальше, так что поиск концентрируется на коде, которого движок ещё не видел, — обычно десятки тысяч прогонов в секунду на воркер. Этот механизм и объясняет, почему фаззинг находит то, что таблицам недоступно: таблица кодирует примеры, которые автор вообразил; фаззер исследует пространство входов, на котором код реально ветвится, — знак без цифр, одинокие байты-продолжения UTF-8, 1e309. Когда вход падает, движок его минимизирует (ужимает, пока падение воспроизводится) и пишет в testdata/fuzz/FuzzParseInterval/ — закоммитьте файл, и краш становится постоянным регрессионным тестом, который обычный go test гоняет без флагов. Мастерство — в свойстве: паники ловятся бесплатно, но «не паникует» — слабое свойство; round-trip (parse∘format = id), сверка с эталонной реализацией и инварианты (длина вывода, отсортированность) — вот где фаззинг зарабатывает свою сеньорскую цену.

Викторина

go test -fuzz нашёл падающий вход для вашего парсера и записал файл в testdata/fuzz/FuzzParse/. Что произойдёт на следующем обычном прогоне go test в CI, без флага -fuzz?

Бенчмарки: b.Loop, ns/op, allocs/op

Глядя на результат бенчмарка, как понять: вы измеряете стоимость вашей функции или стоимость пустого цикла, который компилятор давно удалил? У этого вопроса есть точный ответ — и в Go 1.24 он изменился.

Бенчмарк — это BenchmarkXxx(b *testing.B), запускаемый через go test -bench=.. С Go 1.24 цикл пишется как b.Loop():

func BenchmarkParseInterval(b *testing.B) {
	b.ReportAllocs()
	input := strings.Repeat("1h30m", 1) // сетап — вне цикла
	for b.Loop() {
		d, err := ParseInterval(input)
		if err != nil {
			b.Fatal(err)
		}
		_ = d
	}
}

b.Loop закрывает две хронические лжи старого стиля for i := 0; i < b.N; i++. Первая — устранение мёртвого кода: компилятор может заметить, что результат не используется, и удалить вызов — старым бенчмаркам требовались пакетные sink-переменные; b.Loop автоматически держит аргументы и результаты тела цикла живыми. Бенчмарк, рапортующий 0.25 ns/op, измеряет стоимость пустого цикла, а не вашей функции. Вторая — амортизация сетапа: стиль b.N перезапускал всю функцию с растущим N (1, 100, 10000…), пока прогон не превышал -benchtime (по умолчанию 1 с), повторяя сетап каждый раунд, если вы не вспомнили про b.ResetTimer; b.Loop выполняет сетап ровно один раз и замеряет только цикл. Числа: ns/op — стенное время на итерацию; -benchmem или b.ReportAllocs() добавляют B/op и allocs/op — счётчики аллокаций точны (берутся из счётчиков аллокатора рантайма) и обычно являются самым стабильным и самым действенным числом бенчмарка: срезать allocs/op с 7 до 1 — реальная победа независимо от шума таймера.

benchstat: статистическая честность

Одиночный прогон «до/после», доказывающий, что ваша оптимизация дала «на 4% быстрее», не доказывает ничего. Бенчмарки стенного времени на реальных машинах гуляют — тепловой троттлинг, миграция между ядрами, раскладка кэшей и ASLR, вкладка браузера — легко ±2–5% на ноутбуке и хуже на разделяемых CI-раннерах. Честный протокол: прогнать каждую сторону ~10 раз и отдать статистику benchstat:

// go test -bench=ParseInterval -count=10 > old.txt
// ... применяем изменение ...
// go test -bench=ParseInterval -count=10 > new.txt
// benchstat old.txt new.txt
//
//             │   old.txt   │             new.txt              │
// ParseInterval  412.5n ± 2%   369.0n ± 1%   -10.55% (p=0.000 n=10)
// (незначимое изменение печатается как: ~ , p=0.483)

benchstat сообщает медиану, разброс и p-value по критерию Манна — Уитни; если распределения статистически неразличимы, вместо дельты печатается ~. Эта тильда и есть фича: она не даёт выдать шум за победу. Дисциплина, удерживающая числа честными: тихая машина или выделенный раннер, -count=10, сначала сравнивать allocs/op (точные) и бенчмаркать тот уровень, который чувствуют пользователи — выигрыш 30% в функции, занимающей 0.1% профиля, — погрешность округления; потому бенчмарки идут в паре с pprof, а не вместо него.

Викторина

Бенчмарк в старом стиле выполняет sum := compute(x), но нигде не использует sum, и рапортует 0.3 ns/op. Что измеряется на самом деле?

Вспомните перед уходом
  1. 01
    Проследи путь от go test -fuzz=FuzzParse до закоммиченного регрессионного теста и объясни, почему фаззинг находит баги, пропущенные таблицей в 31 ряд.
  2. 02
    Коллега постит одиночный прогон бенчмарка до/после с -4% ns/op. Что не так с заявлением и каков честный протокол?
Итог

Нативный фаззинг Go превращает функцию-свойство в поиск: f.Add сеет корпус, движок мутирует входы с обратной связью по покрытию, концентрируя усилия на неисследованных ветках, а любой падающий вход минимизируется и записывается в testdata/fuzz, где после коммита обычный go test реплеит его вечно — крашер становится регрессионным тестом. Ценность относительно таблиц структурная, а не инкрементальная: таблицы держат входы, которые автор вообразил; фаззер находит ”+.”, уронивший двухлетний парсер за одиннадцать секунд, потому что такой ряд не напишет ни один человек. Сильные таргеты утверждают больше, чем выживание: round-trip, паритет с эталоном, инварианты. Бенчмарки заслуживают того же скепсиса. b.Loop в Go 1.24 закрывает две классические дыры стиля b.N: устранение мёртвого кода, превращавшее неиспользуемые результаты в фантазии о 0.3 ns/op, и сетап, повторявшийся в авторазмерных раундах, если не вспомнить b.ResetTimer. Читайте allocs/op (точный, из счётчиков аллокатора) раньше ns/op (шумного, из стенных часов) и никогда не сравнивайте одиночные прогоны: -count=10 на сторону и benchstat, чей p-value Манна — Уитни печатает тильду для статистически неразличимых результатов — ту самую тильду, что не пускает историю про «на 4% быстрее» в чейнджлог, когда это тепловой шум. Фаззинг охраняет корректность на входах, которых вы не писали; benchstat охраняет честность на числах, в которые хочется верить. Теперь, когда вы увидите бенчмарк с результатом 0.3 ns/op или коллегу, постящего одиночный прогон «до/после», вы будете точно знать, какие вопросы задать, прежде чем доверять цифре.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.