Производительность систем Бенчмаркинг честно: разогрев, шум, статистика, типичные самообманы
0%

Бенчмаркинг честно: разогрев, шум, статистика, типичные самообманы

Бенчмаркинг честно: разогрев, шум, статистика, типичные самообманы

В предыдущей статье мы разбирали, что измерять: перцентили, latency против throughput, USE и RED. Теперь — как измерять так, чтобы результату можно было верить. Разница между «запустил и посмотрел время» и бенчмарком примерно такая же, как между «глянул в окно» и метеорологическим наблюдением. В первом случае вы получаете число. Во втором — число с известной погрешностью, воспроизводимое, полученное в контролируемых условиях и отвечающее на заранее сформулированный вопрос.

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

Классика жанра — работа Mytkowicz, Diwan, Hauswirth, Sweeney «Producing Wrong Data Without Doing Anything Obviously Wrong!» (ASPLOS 2009). Авторы показали: размер переменных окружения и порядок объектных файлов при линковке меняют время выполнения SPEC-бенчмарков сильнее, чем включение -O3. Измерительная установка вносила больше эффекта, чем изучаемое явление. Ничего «очевидно неправильного» при этом никто не делал.

Бенчмарк — это эксперимент, а не запуск программы

У эксперимента есть обязательные части, и их отсутствие — не «упрощение», а отсутствие эксперимента.

  1. Вопрос и решение. Что вы сделаете, если результат окажется таким? А если противоположным? Если ответ «ничего» — бенчмарк не нужен.
  2. Гипотеза с порогом. Не «стало быстрее», а «медиана упадёт минимум на 15%». Порог задаётся до прогона, иначе вы подгоните его под полученное число.
  3. Управляемые условия. Всё, кроме изучаемого изменения, зафиксировано: железо, данные, версия рантайма, частоты процессора.
  4. Повторность. Нужны и повторы внутри процесса, и повторы процессов — это разные вещи, см. ниже.
  5. Оценка неопределённости. Число без интервала — не результат.
  6. Валидность. Отвечает ли стенд на вопрос про прод. Обычно частично — и надо честно сказать, в какой части.

Три масштаба: что вы меряете и чего это стоит

Бенчмарки образуют спектр от «микро» (одна функция, наносекунды) до «макро» (сервис под нагрузкой). Правило: чем дешевле бенчмарк, тем дальше он от правды о продакшене — не потому, что микробенчмарки плохие, а потому, что они по построению убирают ровно то, что в проде доминирует: холодные кэши, разнородные данные, конкуренцию за ресурсы, сеть.

Микробенчмарк отвечает на вопрос «какая из двух реализаций хеш-функции быстрее в идеальных условиях». Он не отвечает на вопрос «станет ли сервис быстрее». Между этими вопросами лежат доля функции в общем времени (закон Амдала — https://courses.digitable.life/post/performance/07-concurrency-performance/), поведение кэшей на реальных данных (https://courses.digitable.life/post/performance/05-cache-and-locality/) и вероятность того, что узкое место вообще в базе (https://courses.digitable.life/post/performance/08-database-performance/).

Анатомия честного бенчмарка

Обратите внимание на нижнюю петлю: если ускорение из бенчмарка не подтвердилось на проде, виноват бенчмарк, а не прод. Значит, модель нагрузки неверна, и чинить надо её.

Разогрев: почему первые прогоны врут

Первое измерение почти всегда самое медленное, часто на порядок. Причины складываются:

  • Страничные ошибки. Первое касание каждой страницы — minor page fault порядка микросекунды. Рабочий набор в 1 ГБ при страницах 4 КБ — это около 262 тысяч фолтов. Лечится -XX:+AlwaysPreTouch в JVM, MAP_POPULATE в mmap.
  • Кэши CPU и TLB, предсказатель переходов. Ему нужны сотни-тысячи проходов, чтобы выучить шаблон ветвлений.
  • JIT-компиляция. HotSpot: интерпретатор → C1 (быстрый код плюс сбор профиля) → C2 (агрессивные оптимизации по профилю), пороги — тысячи вызовов. V8: Ignition → Sparkplug → Maglev → TurboFan. .NET: многоуровневый JIT с OSR и динамическим PGO. PyPy — трассирующий JIT.
  • Сборщик мусора и аллокатор. Куча не выросла до рабочего размера, полных сборок ещё не было, первые malloc расширяют арену через mmap.
  • Внешние подсистемы. Пул соединений пуст, кэш планов холодный, page cache базы не прогрет, окно перегрузки TCP маленькое.
  • Турбо и температура — разогрев наоборот. Первые секунды процессор работает на буст-частоте, потом упирается в теплопакет. Измерение может ухудшаться со временем.

Переход Steady → Deopt — источник тонкого самообмана. Бенчмарк гоняет один тип объекта, JIT делает call-site мономорфным и инлайнит вызов. В проде через тот же код проходят пять реализаций интерфейса, инлайн-кэш становится мегаморфным, вызов дорожает в разы. Бенчмарк показал 3 нс, прод живёт с 20 нс — и оба числа «правильные».

Разогрев по критерию, а не «секунд пять»

Фиксированная длительность разогрева — угадывание. Правильный критерий статистический: греем, пока коэффициент вариации в скользящем окне не перестанет падать.

import statistics, time

def timed(fn) -> float:
    """Одно измерение в секундах. perf_counter_ns — монотонный таймер
    с наносекундным разрешением; time.time() для этого не годится."""
    t0 = time.perf_counter_ns()
    fn()
    return (time.perf_counter_ns() - t0) / 1e9

def run_until_stable(fn, window=30, cv_target=0.02, max_warmup=5000, samples=200):
    """Разогрев до стационарного режима вместо «подождём пять секунд».
    Критерий остановки: коэффициент вариации (сигма / среднее) в скользящем
    окне последних `window` итераций опустился ниже cv_target.
    Время: O((warmup + samples) * стоимость fn), память: O(window + samples)."""
    recent: list[float] = []
    for _ in range(max_warmup):
        recent.append(timed(fn))
        if len(recent) > window:
            recent.pop(0)
        if len(recent) == window:
            mean = statistics.fmean(recent)
            if mean and statistics.pstdev(recent) / mean < cv_target:
                break
    else:
        # Не стабилизировался — это результат, а не помеха: либо стенд шумный,
        # либо нагрузка нестационарна (утечка, рост индекса, копится состояние).
        raise RuntimeError("стационарный режим не достигнут")
    return [timed(fn) for _ in range(samples)]

Если режим не наступает, увеличивать лимит разогрева бессмысленно. Такую систему надо измерять иначе: длинным прогоном с графиком «время против номера итерации».

Компилятор умнее вашего бенчмарка

Оптимизатор не обязан выполнять код, результат которого никому не нужен, — и он этим пользуется.

package bench

import (
	"strconv"
	"testing"
)

// sink — пакетная переменная: компилятор не может доказать, что запись
// в неё бесполезна, поэтому вычисление не будет выброшено.
var sink int

// ПЛОХО: результат никому не нужен, вход — константа.
// Тело цикла может быть удалено целиком, а вызов свёрнут на этапе компиляции.
func BenchmarkAtoiBroken(b *testing.B) {
	for i := 0; i < b.N; i++ {
		strconv.Atoi("2147483647")
	}
}

// ХОРОШО: результат утекает наружу, вход меняется — нет свёртки констант.
// b.Loop() (Go 1.24+) сам удерживает аргументы и результаты от устранения
// и исключает подготовку данных из измеряемого времени.
func BenchmarkAtoi(b *testing.B) {
	inputs := []string{"0", "42", "-17", "2147483647", "не число"}
	b.ReportAllocs()
	for b.Loop() {
		for _, s := range inputs {
			n, _ := strconv.Atoi(s)
			sink += n
		}
	}
}

Аналоги в других экосистемах: Blackhole.consume() в JMH, std::hint::black_box в Rust, benchmark::DoNotOptimize в Google Benchmark. Рядом живёт вынос инварианта: если вычисление не зависит от счётчика цикла, его поднимут за цикл — вы намерили одно выполнение вместо миллиона, поделили на b.N и получили фантастические пикосекунды.

Сигналы, что код выкинули: время на операцию меньше 1 нс (это 3–4 такта, реальная работа с памятью столько не стоит); время не зависит от размера входа; удалили половину тела функции — цифра не изменилась; perf stat показывает на два порядка меньше инструкций, чем вы ожидали. Лучший разбор темы — «Nanotrusting the Nanotime» Алексея Шипилёва, полезно даже если вы не пишете на Java.

Шум: откуда он и как его прижать

Слои источников шума в бенчмарке

Верхние слои чинятся кодом бенчмарка, средние — настройкой стенда, нижние — только выбором железа и увеличением числа повторов.

# 1. Фиксируем частоту: убираем DVFS и turbo как источник дрейфа.
sudo cpupower frequency-set -g performance
echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo   # Intel
echo 0 | sudo tee /sys/devices/system/cpu/cpufreq/boost           # AMD

# 2. Выключаем SMT: сосед по физическому ядру — крупнейший источник шума.
echo off | sudo tee /sys/devices/system/cpu/smt/control

# 3. Фиксируем раскладку адресного пространства: ASLR меняет выравнивание
#    и попадания в кэш (тот самый эффект из работы Mytkowicz et al.).
setarch "$(uname -m)" -R ./bench

# 4. Прибиваем процесс к ядру, а память — к его NUMA-узлу.
taskset -c 6 numactl --membind=0 ./bench

# 5. Изолируем ядра от планировщика: isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7
#    в параметрах загрузки ядра, либо динамически:
sudo cset shield --cpu 4-7 --kthread on && sudo cset shield --exec -- ./bench

# 6. Проверяем троттлинг ДО и ПОСЛЕ прогона.
grep MHz /proc/cpuinfo | head -4 && sudo dmesg | grep -iE 'thermal|throttl'

Правила стенда, которые важнее любой из этих команд: не бенчмаркайте на ноутбуке от батареи (частота зависит от температуры стола); не бенчмаркайте в общем CI-раннере (разброс до десятков процентов); не бенчмаркайте в burstable-инстансе вроде AWS T-семейства (кончились кредиты CPU — производительность падает в разы); прогоняйте A и B на одной машине, потому что разброс между экземплярами одной модели процессора достигает 5–10%.

Менее шумная метрика: считайте инструкции, а не секунды

Время стены зависит от частоты, соседей и планировщика. Число выполненных инструкций — почти нет. Для сравнения двух версий кода это часто лучший прокси:

perf stat -r 10 -e task-clock,cycles,instructions,branch-misses,cache-misses ./sort_v1 data.txt
 Performance counter stats for './sort_v1 data.txt' (10 runs):

          812.44 msec task-clock       #  0.998 CPUs utilized     ( +-  0.51% )
   3,241,772,918      cycles           #  3.990 GHz               ( +-  0.48% )
   5,918,203,447      instructions     #  1.83  insn per cycle    ( +-  0.02% )
      31,204,882      branch-misses    #  2.11% of all branches   ( +-  0.61% )
      24,880,113      cache-misses     #  30.6  M/sec             ( +-  1.44% )

         0.81402 +- 0.00417 seconds time elapsed  ( +-  0.51% )

Время стены гуляет на 0,51%, а число инструкций — на 0,02%. Если оптимизация снизила инструкции на 20% — это надёжный сигнал даже на шумной машине. Если инструкций столько же, а время «улучшилось», вы поймали шум. Ключ -r 10 заставляет perf прогнать команду десять раз и посчитать разброс сам — самый дешёвый честный бенчмарк для CLI-программ. Оговорка: IPC — не метрика «хорошести», код может иметь высокий IPC и быть медленным просто потому, что делает лишнюю работу. Инструкции хороши как сравнительный показатель для двух версий одной задачи.

Статистика: почему «среднее ± сигма» здесь не работает

Мультимодальное распределение времени операции

Время выполнения не распределено нормально. Оно ограничено снизу (физика не даёт быстрее), не ограничено сверху, скошено вправо и часто мультимодально: одна мода — обычный путь, вторая — путь с паузой GC, третья — с промахом в кэш или ретраем. Среднее и сигма описывают такое распределение примерно никак.

Оценка Что показывает Когда полезна Чем врёт
min нижняя граница возможного сравнение реализаций на шумном стенде прод не работает в лучшем случае
медиана типичный прогон основная оценка при сравнении версий молчит про хвост
среднее суммарная стоимость планирование мощностей и денег уезжает от одного выброса
p99, p99.9 опыт худших пользователей SLO и пользовательская задержка требует много данных, сам шумный
max худший случай системы реального времени почти всегда это шум стенда

Практическое правило: сравнивая версии кода, смотрите на медиану и на p99 одновременно. Оптимизация, улучшившая медиану на 20% и удвоившая p99, обычно вредна — пользователь чувствует хвост.

Итерации против процессов

Самая недооценённая деталь. Kalibera и Jones в «Rigorous Benchmarking in Reasonable Time» (ISMM 2013) показали: разброс между процессами обычно больше разброса между итерациями внутри процесса. Причина — раскладка памяти, ASLR, состояние аллокатора, конкретные решения JIT в этом запуске. Отсюда вывод: тысяча итераций в одном процессе даёт узкий и неправильный доверительный интервал. Нужно k независимых процессов (обычно 5–20) по n итераций. В JMH это @Fork(5), в Go — отдельные запуски go test, склеенные в один файл, в Criterion.rs — отдельные вызовы cargo bench.

Доверительный интервал бутстрапом

Формула стандартной ошибки предполагает нормальность. Бутстрап не предполагает ничего — он просто много раз пересобирает выборку с возвращением.

функция bootstrap_ci(выборка, статистика, B, уровень):
    n ← длина(выборка)
    оценки ← пустой список
    повторить B раз:
        ресэмпл ← n элементов из выборки, выбранных С ВОЗВРАЩЕНИЕМ
        добавить статистика(ресэмпл) в оценки
    отсортировать оценки
    α ← (1 − уровень) / 2
    вернуть (квантиль(оценки, α), квантиль(оценки, 1 − α))
import random, statistics

def bootstrap_ci(sample, stat=statistics.median, b=10_000, level=0.95, seed=0):
    """Перцентильный бутстрап-интервал для любой статистики.
    Время: O(b * n) плюс сортировка O(b log b). Память: O(b + n).
    Для n = 200 и b = 10000 — десятки миллисекунд, дешевле любого прогона."""
    rnd = random.Random(seed)
    n = len(sample)
    ests = sorted(stat([sample[rnd.randrange(n)] for _ in range(n)]) for _ in range(b))
    return ests[int((1 - level) / 2 * b)], ests[int((1 + level) / 2 * b) - 1]

def permutation_test(a, c, stat=statistics.median, iters=20_000, seed=0):
    """Двусторонний перестановочный тест: какова вероятность увидеть
    наблюдаемую разницу, если распределения на самом деле одинаковы.
    В отличие от t-теста не требует нормальности и равных дисперсий.
    Время: O(iters * (n + m)). Память: O(n + m)."""
    rnd = random.Random(seed)
    observed = abs(stat(a) - stat(c))
    pool, n, extreme = list(a) + list(c), len(a), 0
    for _ in range(iters):
        rnd.shuffle(pool)
        if abs(stat(pool[:n]) - stat(pool[n:])) >= observed:
            extreme += 1
    return (extreme + 1) / (iters + 1)  # +1: p-значение не бывает ровно нулём

Если доверительные интервалы двух версий пересекаются, вы не доказали разницу. Это не значит, что её нет, — это значит, что данных не хватает. Честных выходов два: набрать больше измерений или написать «разницу обнаружить не удалось».

И про множественные сравнения: гоняете сорок бенчмарков и радуетесь двум «статистически значимым» улучшениям при уровне 0,05? Ровно два ложных срабатывания и ожидаются. Лечится поправкой Холма или Бенджамини — Хохберга, а на практике проще требовать, чтобы улучшение было не только значимым, но и больше заранее заданного порога (скажем, 5% по медиане): порог отсекает достоверный, но бесполезный выигрыш в 0,4%. Про природу p-значения — https://courses.digitable.life/post/mathematics/14-probability-and-statistics/.

Инструменты и их вывод

Go: go test -bench и benchstat

# -run='^$' отключает обычные тесты. Десять ОТДЕЛЬНЫХ запусков дают разброс
# между процессами, а не только внутри одного (что дал бы -count=10).
for i in $(seq 10); do go test -run='^$' -bench=Parse -benchmem >> old.txt; done
git switch optimization
for i in $(seq 10); do go test -run='^$' -bench=Parse -benchmem >> new.txt; done
go run golang.org/x/perf/cmd/benchstat@latest old.txt new.txt
goos: linux
goarch: amd64
pkg: example.com/parser
cpu: AMD Ryzen 9 5900X 12-Core Processor
                │   old.txt   │              new.txt               │
                │   sec/op    │   sec/op     vs base               │
Parse/json-24     8.214µ ± 2%   6.031µ ± 3%  -26.58% (p=0.000 n=10)
Parse/xml-24      31.02µ ± 1%   30.88µ ± 2%        ~ (p=0.481 n=10)
geomean           15.96µ        13.65µ       -14.47%

Читать так: ± 2% — это не сигма, а половина ширины доверительного интервала по медиане. Значок ~ означает «разницы не обнаружено», и это полноценный результат, а не его отсутствие. benchstat использует непараметрический критерий Манна — Уитни, то есть не предполагает нормальности (документация). Подробнее про бенчмарки в Go — https://courses.digitable.life/post/golang/05-testing/.

JVM: JMH и никак иначе

Мерить JVM руками через System.nanoTime() практически невозможно — слишком много уровней компиляции. JMH существует ровно для этого.

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 20, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 5, jvmArgsAppend = {"-XX:+AlwaysPreTouch"}) // пять отдельных JVM!
@State(Scope.Benchmark)
public class ParseBenchmark {
    // НЕ final: иначе C2 свернёт значение в константу ещё при компиляции.
    private String input = "2147483647";

    @Benchmark
    public int returnResult() {
        return Integer.parseInt(input); // возвращённое значение JMH «потребит» сам
    }

    @Benchmark
    public void manyResults(Blackhole bh) {
        for (int i = 0; i < 8; i++) bh.consume(Integer.parseInt(input));
    }
}
Benchmark                       Mode  Cnt   Score   Error  Units
ParseBenchmark.returnResult     avgt  100  11.482 ± 0.137  ns/op
ParseBenchmark.manyResults      avgt  100  91.204 ± 1.918  ns/op

@Fork(5) здесь важнее остальных аннотаций: без него вы измеряете одно конкретное решение JIT, а не поведение программы. Обязательно просмотрите jmh-samples — это три десятка разобранных способов обмануть себя.

CLI, Rust, Python

Для программ, которые запускаются и завершаются, hyperfine делает всё правильно из коробки — разогрев, повторы, статистику, обнаружение выбросов:

Benchmark 1: ./sort_v1 data.txt
  Time (mean ± σ):     842.3 ms ±   9.1 ms    [User: 803.2 ms, System: 36.4 ms]
  Range (min … max):   831.7 ms … 861.0 ms    20 runs

Summary
  './sort_v2 data.txt' ran  1.36 ± 0.02 times faster than './sort_v1 data.txt'

Если hyperfine пишет Warning: Statistical outliers were detected — не игнорируйте: обычно это фоновый процесс, троттлинг или сборка мусора, и оно же живёт в вашем p99. Criterion.rs сам хранит базовую линию между запусками и печатает time: [6.0125 µs 6.0311 µs 6.0524 µs] — нижнюю границу интервала, оценку и верхнюю границу; к этой форме записи стоит приучать себя и в других инструментах. В Python timeit берёт минимум из повторов (защита от шума, но систематически оптимистично), pytest-benchmark честнее — даёт медиану, IQR и выбросы; помните, что накладные расходы интерпретатора могут полностью утопить разницу между вашими алгоритмами.

Coordinated omission: самый дорогой самообман нагрузочных тестов

Термин ввёл Гил Тене. Суть: генератор нагрузки с закрытой моделью перестаёт отправлять запросы, пока ждёт ответа, — и потому не измеряет самые тяжёлые последствия зависания.

Реальные пользователи не координируются с вашим сервером: они шлют запросы по своему расписанию, и во время зависания очередь растёт. Правильная модель — открытая: запросы генерируются с заданной частотой независимо от того, ответил сервер или нет, а задержка считается от запланированного времени старта. Инструменты: исполнители constant-arrival-rate и ramping-arrival-rate в k6, wrk2 вместо wrk (он и написан Тене из-за этой проблемы), метод recordValueWithExpectedInterval() в HdrHistogram, а в самописном генераторе — хранение intended_start и подсчёт задержки как finish - intended_start.

export const options = {
  scenarios: {
    steady: {
      // Открытая модель: k6 держит ЗАДАННУЮ частоту запросов независимо от
      // того, отвечает сервис или встал. Не хватило VU — увидим это
      // в dropped_iterations, а не спрячем в красивом p99.
      executor: 'constant-arrival-rate',
      rate: 1000, timeUnit: '1s', duration: '5m',
      preAllocatedVUs: 200, maxVUs: 2000,
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(99)<250'],
    // Ненулевой dropped_iterations означает: генератор сам стал узким
    // местом. Результат такого прогона недействителен.
    dropped_iterations: ['count<1'],
  },
};

Проверка, которую делают редко: убедитесь, что генератор нагрузки не является узким местом. Запустите его против заглушки, отвечающей мгновенно, и посмотрите, какую частоту он реально выдаёт. Профили нагрузки подробно — в https://courses.digitable.life/post/performance/11-load-testing/, а доклад Тене «How NOT to Measure Latency» стоит посмотреть целиком.

Ошибка выжившего: считаем только тех, кто дошёл

История про самолёты Абрахама Вальда известна всем, а в бенчмарках эту ошибку повторяют ежедневно:

  • Таймауты не попадают в статистику задержек. Клиент отвалился по таймауту в 5 с — записи нет. Чем хуже сервис, тем красивее его p99: самые медленные запросы просто выпадают из выборки.
  • Ошибки исключены из расчёта. 20% ответов 503 отдались за 2 мс и «улучшили» среднее.
  • Ушедшие пользователи не логируются. Кто закрыл вкладку на пятой секунде, в метриках отсутствует. Смотрите на bounce rate рядом с задержкой.
  • «Грязные» прогоны отброшены. Правило отбрасывания должно быть задано до прогона и применяться к обеим версиям одинаково.
  • Публикуется только выигравшая конфигурация. Перебрали двенадцать наборов флагов, показали лучший — это то же самое, что p-hacking. Сюда же: библиотека попадает в сравнение с профилем нагрузки, на котором она хороша.
  • Перцентили усредняются по узлам. Среднее из p99 десяти подов — величина без смысла. Перцентили считаются по объединённому распределению; для этого существуют HDR-гистограммы и t-digest.

Практический приём: публикуйте рядом с задержкой число завершённых, число ошибок и число таймаутов. Если этих трёх чисел нет, отчёту о задержках верить нельзя.

Табличка Дина: интуиция, а не справочник

Список «latency numbers every programmer should know» (Джефф Дин, на основе более ранней версии Питера Норвига) — прекрасный инструмент для оценки порядка величины и очень плохой справочник абсолютных значений. Ходящие по интернету варианты датируются примерно 2009–2012 годами. С тех пор: обращение в DRAM (около 100 нс) почти не изменилось, потому что латентность памяти растёт куда медленнее пропускной способности; скорость света тоже не изменилась, поэтому межконтинентальный RTT в сотню-другую миллисекунд остался прежним; позиционирование HDD в 10 мс для большинства систем стало неактуальным — случайное чтение с NVMe идёт за десятки микросекунд, на два порядка быстрее; оценка сжатия 1 КБ считалась по алгоритмам и ядрам той эпохи, современные LZ4 и zstd дают другие числа.

Пользуйтесь табличкой, чтобы за пять секунд понять: «раз в запросе 30 обращений по сети внутри ДЦ, это уже десятки миллисекунд, и никакая оптимизация парсинга не спасёт». Не пользуйтесь ею, чтобы обосновать конкретное число в дизайн-документе. Интерактивная версия Колина Скотта показывает, как эти оценки экстраполируются по годам, — уже это полезно как напоминание об их возрасте. Общий принцип трека: любое чужое число — гипотеза о вашей системе, а не факт о ней, и проверяется она одним прогоном на вашем железе.

Каталог самообманов

Самообман Как выглядит Как поймать
Устранение мёртвого кода 0,3 нс/оп, время не зависит от входа сверить с perf stat, добавить sink
Свёртка констант один и тот же вход, литерал или final вход из массива, не final
Нет разогрева первый прогон в 20 раз медленнее график «время против номера итерации»
Слишком долгий прогон время растёт к концу тренд, проверка троттлинга и утечки
Один процесс узкий интервал, невоспроизводимо k независимых процессов
Мономорфный call-site бенчмарк 3 нс, прод 20 нс подмешать несколько реализаций интерфейса
Идеальные данные всё в L1, нулевая доля промахов реальный срез данных и их объём
Coordinated omission p99 подозрительно близок к медиане открытая модель, dropped_iterations
Ошибка выжившего ошибок и таймаутов нет в отчёте публиковать completed / errors / timeouts
Разное железо для A и B «на моей машине быстрее» одна машина, чередование прогонов
Тюнинг под бенчмарк улучшение только на синтетике обязательная проверка на проде
Метрика не та улучшили throughput, ухудшили p99 всегда смотреть на пару метрик
Множественные сравнения «два из сорока значимы» порог эффекта плюс поправка

Отдельно про чередование: если прогнать сначала десять запусков версии A, а потом десять версии B, любой медленный дрейф стенда (нагрев, фоновое обновление, заполнение диска) будет целиком приписан версии B. Чередуйте A, B, A, B — это бесплатно и убирает целый класс систематических ошибок.

Бенчмарки в CI: другая задача, другие правила

Абсолютные числа в CI недостоверны: раннеры разные, соседи разные, разброс до десятков процентов. Что работает: метрики без времени (число аллокаций, количество запросов к БД, размер бандла, число инструкций) — они детерминированы, и порог на них ставится жёсткий; сравнение в одном джобе — собрать базовую ветку и текущую, прогнать чередованием на одном раннере и сравнить между собой, а не с историческим числом; широкие пороги плюс отдельный тренд — падать на 3% регрессии значит получить недоверие к системе за неделю, поэтому порог ставят на 20–30%, а точные измерения ведут ночью на выделенном физическом хосте. Подробнее про бюджеты, ловлю регрессий и вопрос «когда остановиться» — https://courses.digitable.life/post/performance/12-optimization-workflow/; про устройство пайплайнов — https://courses.digitable.life/post/devops/01-ci-fundamentals/.

Чеклист перед тем, как поверить числу

Краткая версия чеклиста Брендана Грегга, адаптированная:

  1. Зачем я это меряю и что сделаю с результатом?
  2. Похожа ли нагрузка на продовую по объёму данных, распределению ключей и доле промахов?
  3. Прогрелось ли до стационарного режима — и проверял ли я это графиком, а не на веру?
  4. Не выкинул ли компилятор мою работу? (perf stat, изменение размера входа)
  5. Зафиксированы ли частоты, пиннинг, изоляция? Не CI-раннер, не батарея, не burstable?
  6. Сколько независимых процессов, сколько итераций, чередовались ли версии?
  7. Есть ли доверительный интервал и пересекаются ли интервалы A и B?
  8. Больше ли эффект заранее заданного порога, а не только «статистически значим»?
  9. Что с ошибками, таймаутами и отброшенными прогонами — они в отчёте?
  10. Совпадает ли направление эффекта с тем, что видно в проде?

Шаблон строки отчёта, которую не стыдно показать:

Parse/json: медиана 8,21 → 6,03 мкс (−26,6%, 95% ДИ −28,1…−25,0%), p99 14,8 → 12,1 мкс, аллокации 4344 → 1136 Б/оп. 10 процессов × 200 итераций, чередование, Ryzen 9 5900X, turbo off, SMT off, ядро 6 изолировано, Go 1.24.2. Ошибок 0. Canary: p99 эндпоинта /orders 41 → 36 мс.

Мини-итог

  • Бенчмарк — эксперимент. Без вопроса, гипотезы, порога, повторности и оценки неопределённости это просто запуск программы.
  • Разогрев обязателен, а его конец определяется статистическим критерием, не таймером. Если стационарный режим не наступает — это результат, а не помеха.
  • Компилятор и JIT удалят работу, результат которой не нужен. Sink-переменные, Blackhole, black_box, b.Loop() — не суеверия.
  • Шум приходит с шести слоёв: верхние чинятся кодом, средние — стендом, нижние — только повторами и выбором железа.
  • Время выполнения распределено ненормально и мультимодально: медиана с доверительным интервалом вместо «среднее ± сигма», k процессов вместо одного, чередование A и B.
  • Coordinated omission делает плохой сервис красивым; лечится открытой моделью нагрузки и отсчётом задержки от запланированного времени.
  • Ошибка выжившего живёт в каждом отчёте, где нет строк «ошибки» и «таймауты».
  • Чужие числа, включая табличку Дина, — оценка порядка величины, а не истина о вашей системе.
  • Улучшение, не подтвердившееся на проде, — не улучшение, и виноват в этом бенчмарк.

Источники

Что дальше

Мы научились получать числа, которым можно верить, и отличать реальный эффект от шума. Но бенчмарк говорит только «стало быстрее» или «стало медленнее» — он не говорит, где уходит время. Следующий шаг: вскрыть программу и посмотреть, на каких строках она проводит такты.

Профилирование CPU: сэмплирование, flame graphs, горячие пути

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов