Бенчмаркинг честно: разогрев, шум, статистика, типичные самообманы
В предыдущей статье мы разбирали, что измерять: перцентили, latency против throughput, USE и RED. Теперь — как измерять так, чтобы результату можно было верить. Разница между «запустил и посмотрел время» и бенчмарком примерно такая же, как между «глянул в окно» и метеорологическим наблюдением. В первом случае вы получаете число. Во втором — число с известной погрешностью, воспроизводимое, полученное в контролируемых условиях и отвечающее на заранее сформулированный вопрос.
Плохая новость: некорректный бенчмарк хуже отсутствия бенчмарка. Отсутствие данных заставляет сомневаться. Красивое неправильное число даёт ложную уверенность — и вы полгода оптимизируете то, что не влияет на пользователя, а иногда делаете систему медленнее, потому что бенчмарк показал улучшение там, где в проде наступило ухудшение.
Классика жанра — работа Mytkowicz, Diwan, Hauswirth, Sweeney «Producing Wrong Data Without Doing Anything Obviously Wrong!» (ASPLOS 2009). Авторы показали: размер переменных окружения и порядок объектных файлов при линковке меняют время выполнения SPEC-бенчмарков сильнее, чем включение -O3. Измерительная установка вносила больше эффекта, чем изучаемое явление. Ничего «очевидно неправильного» при этом никто не делал.
Бенчмарк — это эксперимент, а не запуск программы
У эксперимента есть обязательные части, и их отсутствие — не «упрощение», а отсутствие эксперимента.
- Вопрос и решение. Что вы сделаете, если результат окажется таким? А если противоположным? Если ответ «ничего» — бенчмарк не нужен.
- Гипотеза с порогом. Не «стало быстрее», а «медиана упадёт минимум на 15%». Порог задаётся до прогона, иначе вы подгоните его под полученное число.
- Управляемые условия. Всё, кроме изучаемого изменения, зафиксировано: железо, данные, версия рантайма, частоты процессора.
- Повторность. Нужны и повторы внутри процесса, и повторы процессов — это разные вещи, см. ниже.
- Оценка неопределённости. Число без интервала — не результат.
- Валидность. Отвечает ли стенд на вопрос про прод. Обычно частично — и надо честно сказать, в какой части.
Три масштаба: что вы меряете и чего это стоит
Бенчмарки образуют спектр от «микро» (одна функция, наносекунды) до «макро» (сервис под нагрузкой). Правило: чем дешевле бенчмарк, тем дальше он от правды о продакшене — не потому, что микробенчмарки плохие, а потому, что они по построению убирают ровно то, что в проде доминирует: холодные кэши, разнородные данные, конкуренцию за ресурсы, сеть.
Микробенчмарк отвечает на вопрос «какая из двух реализаций хеш-функции быстрее в идеальных условиях». Он не отвечает на вопрос «станет ли сервис быстрее». Между этими вопросами лежат доля функции в общем времени (закон Амдала — 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/).
Анатомия честного бенчмарка
по результату?"] --> H["Гипотеза и порог значимого
эффекта, заданные заранее"] H --> W["Нагрузка: данные и профиль
максимально как в проде"] W --> E["Стенд: фиксация частот,
пиннинг ядер, изоляция"] E --> WU["Разогрев до стационарного режима
по статистическому критерию"] WU --> M["Серия: k независимых процессов
по n итераций, с чередованием A и B"] M --> S["Устойчивая статистика: медиана
и доверительный интервал"] S --> D{"Эффект больше
порога и шума?"} D -- "нет" --> N["Увеличить k и n или честно
сказать: разницы не видно"] N --> M D -- "да" --> V["Проверка на проде: совпали
знак и порядок величины?"] V -- "нет" --> W V -- "да" --> R["Зафиксировать базовую линию
и сторожевой тест в CI"]
Обратите внимание на нижнюю петлю: если ускорение из бенчмарка не подтвердилось на проде, виноват бенчмарк, а не прод. Значит, модель нагрузки неверна, и чинить надо её.
Разогрев: почему первые прогоны врут
Первое измерение почти всегда самое медленное, часто на порядок. Причины складываются:
- Страничные ошибки. Первое касание каждой страницы — 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/.
Чеклист перед тем, как поверить числу
Краткая версия чеклиста Брендана Грегга, адаптированная:
- Зачем я это меряю и что сделаю с результатом?
- Похожа ли нагрузка на продовую по объёму данных, распределению ключей и доле промахов?
- Прогрелось ли до стационарного режима — и проверял ли я это графиком, а не на веру?
- Не выкинул ли компилятор мою работу? (
perf stat, изменение размера входа) - Зафиксированы ли частоты, пиннинг, изоляция? Не CI-раннер, не батарея, не burstable?
- Сколько независимых процессов, сколько итераций, чередовались ли версии?
- Есть ли доверительный интервал и пересекаются ли интервалы A и B?
- Больше ли эффект заранее заданного порога, а не только «статистически значим»?
- Что с ошибками, таймаутами и отброшенными прогонами — они в отчёте?
- Совпадает ли направление эффекта с тем, что видно в проде?
Шаблон строки отчёта, которую не стыдно показать:
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 эндпоинта/orders41 → 36 мс.
Мини-итог
- Бенчмарк — эксперимент. Без вопроса, гипотезы, порога, повторности и оценки неопределённости это просто запуск программы.
- Разогрев обязателен, а его конец определяется статистическим критерием, не таймером. Если стационарный режим не наступает — это результат, а не помеха.
- Компилятор и JIT удалят работу, результат которой не нужен. Sink-переменные,
Blackhole,black_box,b.Loop()— не суеверия. - Шум приходит с шести слоёв: верхние чинятся кодом, средние — стендом, нижние — только повторами и выбором железа.
- Время выполнения распределено ненормально и мультимодально: медиана с доверительным интервалом вместо «среднее ± сигма», k процессов вместо одного, чередование A и B.
- Coordinated omission делает плохой сервис красивым; лечится открытой моделью нагрузки и отсчётом задержки от запланированного времени.
- Ошибка выжившего живёт в каждом отчёте, где нет строк «ошибки» и «таймауты».
- Чужие числа, включая табличку Дина, — оценка порядка величины, а не истина о вашей системе.
- Улучшение, не подтвердившееся на проде, — не улучшение, и виноват в этом бенчмарк.
Источники
- Mytkowicz, Diwan, Hauswirth, Sweeney. Producing Wrong Data Without Doing Anything Obviously Wrong! ASPLOS 2009. PDF
- Georges, Buytaert, Eeckhout. Statistically Rigorous Java Performance Evaluation. OOPSLA 2007. PDF
- Kalibera, Jones. Rigorous Benchmarking in Reasonable Time. ISMM 2013. Страница работы
- Aleksey Shipilëv. Nanotrusting the Nanotime. shipilev.net
- Gil Tene. How NOT to Measure Latency — видео, HdrHistogram
- Brendan Gregg. Systems Performance, 2-е изд.; Benchmarking Checklist
- Raj Jain. The Art of Computer Systems Performance Analysis — классика по планированию экспериментов и статистике измерений.
- Инструменты: JMH и jmh-samples, benchstat, hyperfine, Criterion.rs, исполнители k6
- Интерактивные latency numbers — с поправкой на год
Что дальше
Мы научились получать числа, которым можно верить, и отличать реальный эффект от шума. Но бенчмарк говорит только «стало быстрее» или «стало медленнее» — он не говорит, где уходит время. Следующий шаг: вскрыть программу и посмотреть, на каких строках она проводит такты.
Профилирование CPU: сэмплирование, flame graphs, горячие пути