Программирование GoТестированиеGo-разработчик, отвечающий за производительность и качество тестов

Вам дали benchmark, в котором результат вычисления нигде не используется. Какой риск это создаёт для достов...

Вам дали benchmark, в котором результат вычисления нигде не используется. Какой риск это создаёт для достоверности измерения?

package checksum

import "testing"

func sum(data []byte) uint64 {
    var result uint64
    for _, value := range data {
        result += uint64(value)
    }
    return result
}

func BenchmarkSum(b *testing.B) {
    data := make([]byte, 1024)
    for i := 0; i < b.N; i++ {
        sum(data)
    }
}
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Результат sum(data) не используется, поэтому компилятор может встроить функцию и удалить вычисление как не имеющее наблюдаемого эффекта. В таком случае benchmark измерит не стоимость суммирования, а в основном накладные расходы цикла или даст искусственно заниженное время.

Чтобы сохранить вычисление, результат обычно записывают в пакетную переменную-sink, например var benchmarkResult uint64. Это делает результат наблюдаемым для компилятора, хотя сама запись в переменную добавляет небольшую измеряемую операцию.

Исторический контекст

Механизм benchmark в пакете testing предназначен для многократного выполнения операции и сравнения её времени и аллокаций. Количество итераций задаётся фреймворком через b.N, чтобы получить достаточно стабильное измерение.

Такая схема предполагает, что тело цикла действительно выполняет проверяемую работу. Оптимизирующий компилятор не обязан сохранять вычисления, результат которых никак не влияет на наблюдаемое поведение программы.

Постановка проблемы

В примере функция вычисляет сумму, но возвращаемое значение сразу отбрасывается. Если функция будет встроена, компилятор может доказать отсутствие побочных эффектов и убрать весь расчёт.

Это опасно тем, что benchmark может выглядеть очень быстрым и стабильным, хотя измеряемая операция фактически не выполняется. Сравнение двух таких тестов также может привести к неправильному выводу о производительности.

Подробное решение

Используют глобальную переменную, в которую записывают результат:

package checksum import "testing" var benchmarkResult uint64 func BenchmarkSum(b *testing.B) { data := make([]byte, 1024) for i := 0; i < b.N; i++ { benchmarkResult = sum(data) } }

Запись в переменную вне локального контекста создаёт наблюдаемый эффект, поэтому компилятор не может просто удалить вызов sum. Sink не должен использоваться в рабочем коде: он нужен только для корректности benchmark.

Важно, что sink не делает измерение абсолютно чистым. Время присваивания и возможные особенности конкретной версии компилятора остаются частью измерения, но обычно их влияние мало по сравнению с полностью удалённым вычислением. Для коротких операций этот overhead может быть заметным, поэтому полезно сравнивать результаты с осторожностью и увеличивать объём работы на одну итерацию.

Наличие вызова функции само по себе не гарантирует, что работа будет измерена: функцию могут встроить, а мёртвый результат — устранить. При этом оптимизация зависит от кода и версии компилятора, поэтому нельзя утверждать, что удаление произойдёт в каждом конкретном случае; проблема заключается в отсутствии гарантии.

Ситуация из практики

Команда сравнивала две реализации хеширования. В первой benchmark вызывал функцию и игнорировал результат, во второй результат сохранялся в sink. Первая реализация показывала почти нулевое время, но профилирование рабочего приложения подтверждало, что хеширование не является бесплатным.

Рассматривались два варианта. Полностью отключить оптимизации компилятора можно для диагностики, но такие результаты плохо отражают рабочую сборку. Добавить sink проще и сохраняет обычные оптимизации; недостаток — небольшая стоимость записи.

Выбрали sink и проверили benchmark на нескольких размерах входа. После этого результаты стали согласовываться с профилем приложения, а сравнение реализаций стало содержательным.

Что кандидаты часто упускают

  1. Достаточно ли объявить локальную переменную для сохранения результата?

Нет, если значение остаётся неиспользованным внутри функции. Например, result := sum(data) без дальнейшего чтения всё ещё может быть удалено вместе с вычислением. Sink обычно делают пакетной переменной и присваивают ему результат каждой итерации.

  1. Можно ли вместо sink вызвать fmt.Println для предотвращения оптимизации?

Технически вывод создаёт наблюдаемый эффект, но он несопоставимо дороже измеряемой операции и разрушает смысл benchmark. Вывод также создаёт лишний шум и искажает результаты. Sink решает задачу с гораздо меньшими накладными расходами.

  1. Почему результат benchmark всё равно может быть недостоверным после добавления sink?

Sink предотвращает одну конкретную ошибку — устранение вычисления компилятором. Он не устраняет влияние аллокаций, сборки мусора, размера входа, прогрева кэшей, ветвления и внешней нагрузки. Поэтому нужно выбирать реалистичные данные, запускать сравнения в одинаковых условиях и интерпретировать результаты как статистическое измерение, а не как точную константу стоимости операции.