Рабочий процесс оптимизации: бюджеты, регрессии в CI, когда остановиться
Одиннадцать предыдущих статей учили находить и устранять узкие места: мерить перцентили, читать flame graph, ловить N+1, считать промахи кэша, гонять нагрузку. Всё это — навыки одного захода. Эта статья про то, что происходит между заходами, и она отвечает на три вопроса, которые обычно остаются без ответа. Куда именно мы идём? «Сделать быстрее» — не цель, а настроение; цель — число, привязанное к участку системы и к способу проверки. Как удержать достигнутое? Ускорение на 30%, полученное за неделю, съедается за квартал десятком безобидных правок по 2–3% каждая: никто не виноват, все коммиты хорошие, система медленнее исходной. Когда остановиться? Оптимизация без критерия остановки — это либо бесконечный проект, либо код, который через год никто не решается тронуть.
Главный принцип трека — сначала измерь, потом чини — на уровне процесса выглядит так: процесс сам должен быть измеримым. Не «мы следим за производительностью», а «вот файл с бюджетами, вот джоб, который его проверяет, вот график, вот три случая за квартал, когда джоб остановил мердж». Всё остальное — благие намерения.
Три способа проиграть, уже умея оптимизировать
Бесконечная оптимизация. Инженер профилирует, находит 12% в сериализации, чинит, находит 7% в аллокациях, чинит, находит 4%… Через месяц p99 лучше на 20%, фичи не выходили, и никто не может сказать, надо было останавливаться на второй неделе или на четвёртой: заранее не договорились, что считается «достаточно быстро».
Эрозия. Победа не закреплена измерением в конвейере. Через два месяца добавили middleware с логированием тела запроса, потом валидацию через рефлексию, потом «временный» синхронный вызов сервиса профилей. Каждая правка стоит 3–8 мс и проходит ревью, потому что 5 мс никто не видит глазами. Итог — исходные цифры плюс 15%, и виновника уже не найти: коммитов четыре тысячи.
Локальный успех. Функция ускорена в 40 раз, о чём написан отличный отчёт. На запрос она влияет на 0,3%, потому что занимала 0,4% времени. Об этом честно предупреждает закон Амдала, который мы считали в статье о конкурентности, но считать его надо до работы, а не в ретроспективе. Все три лечатся одним и тем же: заранее зафиксированным числом (бюджет), автоматической проверкой этого числа (регрессии в CI) и явным правилом остановки.
Контур: жизненный цикл одной гипотезы
Оптимизация — цикл проверки гипотез, в котором большая часть гипотез отбрасывается. Полезно сделать цикл явным: у каждой гипотезы есть состояние, и переход разрешён только при выполнении условия.
Три перехода несут почти всю ценность. Подозрение → Гипотеза. Пропуск этого перехода — самая частая ошибка. «Кажется, тут медленный цикл» — подозрение. Гипотеза выглядит иначе: «спан serialize занимает 24 мс при бюджете 20; профиль показывает 71% времени в json.Marshal через рефлексию; кодогенерация должна убрать 12–15 мс». Есть участок, есть измерение, есть предсказанная величина.
Эксперимент → Правка. Здесь работает вся статистика из статьи про честный бенчмаркинг: разогрев, независимые повторы, чередование версий, доверительный интервал. Порог задаётся до эксперимента, иначе задним числом любой результат объявляется успехом. Верификация → Закрепление. Улучшение, которого не видно в проде, — не улучшение, и виноват в этом не прод, а бенчмарк: он мерил не ту нагрузку. Правило одной правки — не бюрократия, а требование измеримости: если в коммите три оптимизации, при откате вы не знаете, какая дала эффект, а какая регрессию.
Бюджет: число, которое превращает «быстрее» в «готово»
Бюджет производительности — заранее согласованное число, нарушение которого считается дефектом. Не «желательно быстро», а «p99 GET /catalog ≤ 400 мс; если больше — это баг, и он блокирует релиз так же, как упавший тест». Бюджет даёт приоритет (чинить то, что вышло за рамки), критерий остановки (вернулись — прекратили) и переводит спор из области вкуса в область арифметики.
Откуда берётся верхнее число
- Продуктовое требование. Влияние задержки на конверсию измеряли многие: эксперименты Google и Bing на 100–500 мс, сводка WPO Stats. Полезны они не абсолютом (у вас другая аудитория), а порядком величины и тем, что оправдывают саму постановку цели.
- Восприятие человеком. Классические пороги Nielsen Norman Group: 0,1 с — «мгновенно», 1 с — «поток мысли не прерван», 10 с — «внимание потеряно».
- Физический потолок. Прежде чем обещать 50 мс, посчитайте, из чего они складываются: RTT до региона, обязательные обращения к диску и сети. Здесь пригождается табличка «latency numbers every programmer should know» — но с прямой оговоркой: её числа относятся к 2012 году, интерактивная версия Colin Scott их экстраполирует, и часть строк устарела на порядок (NVMe против «диска», задержки внутри ЦОД). Это проверка на здравый смысл, а не справочник: свои числа меряют сами, как в «Измерении».
- Текущее состояние. Если сейчас p99 = 900 мс, то бюджет 400 мс — это проект, а не бюджет. Ставят промежуточную цель и двигают её. Формально бюджет оформляется как SLO — цель с окном и долей: «99% запросов быстрее 400 мс за скользящие 30 дней». Механику разбирает SRE-книга Google, связь с метриками команды — «DORA и инженерные метрики».
Разложение бюджета по участкам
Верхнее число бесполезно, пока не разложено. Разложение превращает «сайт тормозит» в «база съедает 210 мс при бюджете 120».
Из семи строк три вышли за бюджет, но перерасход распределён крайне неравномерно: 90 мс из 110 дал один участок. Значит, чинят его, а не «всё понемногу» — это приоритизация по закону Амдала, применённая к бюджету. Резерв (60 мс) — не запас на развитие, а признание фактов: хвост шумит, GC случается, соседи по хосту существуют. Резерв, распределённый «чтобы не пропадал», приводит к нарушению SLO при первом же всплеске. Делят бюджет не поровну, а по стоимости изменения: участок, где вы физически ничего не сделаете (RTT до пользователя, обязательный внешний вызов), получает свою честную величину, остальное делится между управляемым. Каждое розданное число затем проверяют измерением — обнаруживается, что «сериализация, ну пара миллисекунд» на самом деле 24. У каждой строки должен быть владелец: бюджет без владельца нарушается первым. Тонкость: перцентили не складываются. Сумма p99 участков — не p99 целого, обычно она пессимистичнее, потому что хвосты редко совпадают по времени (разбор — в «Измерении»). Поэтому разложение считают как бюджет на типичный вклад плюс запас, а проверяют всегда сквозной метрикой, а не суммой строк.
Бюджеты не только во времени
Время — самая шумная величина в CI. Рядом с временным бюджетом заводят бюджеты на детерминированные счётчики: они не зависят от соседей по раннеру и частоты процессора, поэтому порог по ним ставится жёстко, а не «плюс-минус 20%».
| Величина | Типичный бюджет | Чем меряется | Что ловит |
|---|---|---|---|
| SQL-запросов на эндпоинт | ≤ 4 | перехват драйвера в тесте | N+1 в момент появления (https://courses.digitable.life/post/performance/08-database-performance/) |
| Аллокаций на операцию | ≤ 12 | b.ReportAllocs(), AllocsPerRun |
давление на GC (https://courses.digitable.life/post/performance/04-memory/) |
| Инструкций на операцию | ±3% | perf stat -e instructions |
самая стабильная замена времени |
| Промахов L3 на операцию | ±10% | perf stat -e cache-misses |
регрессии локальности (https://courses.digitable.life/post/performance/05-cache-and-locality/) |
| Сетевых вызовов на запрос | ≤ 2 | трассировка в интеграционном тесте | «ещё один синхронный вызов» |
| Размер JS-бандла | ≤ 180 КиБ gzip | size-limit, bundlesize |
главный бюджет фронтенда (https://courses.digitable.life/post/frontend/14-web-performance/) |
| Syscalls на запрос | ±5% | strace -c, perf trace |
потерю буферизации (https://courses.digitable.life/post/performance/06-io-and-syscalls/) |
Правило: если величину можно сделать детерминированной — сделайте и поставьте жёсткий порог. Время оставьте для эшелонов, где есть нормальный стенд.
Бюджет как файл в репозитории
Бюджет в вики не проверяется. Бюджет в репозитории проверяется джобом и меняется через pull request с обсуждением — то есть работает как код.
# perf-budget.yaml — источник истины для CI, дашборда и дежурного
version: 1
defaults:
regression_threshold_pct: 20 # шумные величины: падаем только при уверенном ухудшении
confirm_runs: 2 # повторный прогон перед тем, как красить сборку
endpoints:
- id: catalog_list
route: "GET /catalog"
owner: team-catalog
slo: { p99_ms: 400, window: 30d } # сквозная цель: стенд и прод
breakdown: # то самое разложение с картинки выше
{ network_tls_ms: 60, edge_ms: 15, app_cpu_ms: 80, db_ms: 120,
cache_and_rpc_ms: 45, serialize_ms: 20, reserve_ms: 60 }
hard_limits: # детерминированные величины, порог жёсткий
{ sql_queries: 4, rpc_calls: 2, allocs_per_request: 4200 }
frontend:
{ bundle_gzip_kib: 180, lcp_p75_ms: 2500 } # пороги Core Web Vitals
Файл читают три потребителя: джоб в CI (падает при нарушении hard_limits), генератор дашборда (рисует факт против бюджета) и дежурный (видит, чей это участок). Готовый аналог во фронтенде — budgets в Lighthouse CI.
Что делать, когда бюджет нарушен
Законных реакций три: оптимизировать участок; перераспределить бюджет, если у соседа есть неиспользуемый запас (через PR, с обоснованием, а не молча); изменить бюджет, признав, что число было взято неверно (тоже через PR и с подписью владельца SLO). Чего делать нельзя — молча игнорировать: один проигнорированный бюджет обесценивает все остальные за месяц. Как бюджеты портятся: бюджет на среднее (хвост невидим), бюджет без нагрузки и объёма данных («p99 = 80 мс» на пустой базе — шутка), бюджет без окна агрегации, бюджет без резерва и бюджет, который ни разу не краснел — последний просто выставлен выше факта и ничего не проверяет.
Эшелоны защиты от регрессий
Ни один механизм не ловит все регрессии. Быстрые проверки видят только то, что явно измеряют; правдоподобные — медленны и шумны. Поэтому строят несколько эшелонов с разной скоростью обратной связи и разной чувствительностью.
Главный вывод из картинки: чем правее, тем правдивее и дороже. Регрессия, пойманная юнит-тестом, стоит десять минут разработчика; та же регрессия на канарейке — откат релиза; замеченная в тренде через месяц — неделю расследования. Поэтому дешёвые эшелоны нагружают максимально, а дорогие оставляют для того, что дешёвые физически не видят.
Эшелон 1: детерминированные счётчики
Самый недооценённый инструмент. Он вообще не измеряет время — и именно поэтому работает в шумном CI.
// orders_alloc_test.go — бюджет аллокаций как обычный тест.
func TestRenderOrderAllocBudget(t *testing.T) {
o := fixtureOrder() // одинаковые входные данные каждый прогон
buf := make([]byte, 0, 4096)
// AllocsPerRun сам делает разогрев; результат целочисленный
// и не зависит от нагрузки на раннер.
got := int(testing.AllocsPerRun(100, func() { buf = renderOrder(buf[:0], o) }))
const budget = 12 // зафиксировано в perf-budget.yaml, менять — через PR
if got > budget {
t.Fatalf("аллокаций на операцию: %d при бюджете %d; если рост "+
"осознанный — обновите бюджет и объясните почему", got, budget)
}
}
Тот же приём для базы — прямой убийца N+1, который иначе всплывает только под нагрузкой:
@pytest.fixture
def query_log(db_engine):
"""Считает SQL-запросы через события SQLAlchemy: величина детерминированная."""
stmts = []
listener = lambda conn, cur, statement, *a: stmts.append(statement)
event.listen(db_engine, "before_cursor_execute", listener)
yield stmts
event.remove(db_engine, "before_cursor_execute", listener)
def test_catalog_query_budget(client, query_log):
"""Бюджет 4 запроса: N+1 добавит по запросу на позицию — тест покраснеет сразу."""
assert client.get("/catalog?page=1&size=50").status_code == 200
assert len(query_log) <= 4, (
f"{len(query_log)} запросов при бюджете 4:\n"
+ "\n".join(f" {i + 1}. {s[:120]}" for i, s in enumerate(query_log))
)
В Django это встроено (assertNumQueries), в Rails есть n_plus_one_control, в Go считают обёрткой над database/sql. Механика одна: перехватить драйвер, посчитать вызовы, сравнить с числом из бюджета. Сообщение об ошибке обязано печатать сами запросы, иначе разработчик увидит «было 4, стало 27» и не поймёт, что произошло. Стоимость такой проверки — $O(1)$ на запрос по времени и $O(k)$ памяти на $k$ операторов, то есть её можно повесить на десятки эндпоинтов сразу.
Эшелон 2: сравнительный бенчмарк в одном джобе
Абсолютные числа в CI бессмысленны: раннеры разных поколений, шумные соседи, троттлинг. Работает только сравнение base и head на одном раннере с чередованием.
# .github/workflows/perf.yml
name: perf-compare
on: pull_request
jobs:
bench:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # нужны обе ветки в одном рабочем каталоге
- uses: actions/setup-go@v5
with: { go-version: '1.24' }
- run: go install golang.org/x/perf/cmd/benchstat@latest
# Чередуем base и head: 6 циклов по 5 итераций вместо 30 подряд, чтобы
# дрейф частоты и шум соседа размазались по обеим версиям одинаково.
- run: |
set -euo pipefail
BASE=origin/${{ github.base_ref }}
for i in 1 2 3 4 5 6; do
git checkout --quiet "$BASE" -- .
go test ./internal/render -run='^$' -bench=. -benchmem -count=5 >> base.txt
git checkout --quiet HEAD -- .
go test ./internal/render -run='^$' -bench=. -benchmem -count=5 >> head.txt
done
- run: benchstat -row /name -col .file base.txt head.txt | tee result.txt
# Порог по величине эффекта, а не только по p-value: 20% — не жадность,
# а признание того, что шум ubuntu-latest доходит до 15%.
- run: python3 ci/check_bench.py result.txt --max-regression-pct 20
Вывод benchstat в современном формате:
│ base.txt │ head.txt │
│ sec/op │ sec/op vs base │
Render/small 8.214µ ± 2% 6.031µ ± 3% -26.58% (p=0.000 n=30)
Render/large 41.02µ ± 1% 30.44µ ± 2% -25.79% (p=0.000 n=30)
Encode 1.204µ ± 1% 1.211µ ± 2% ~ (p=0.481 n=30)
Кроме процентов здесь важны три вещи. Символ ~ у Encode означает не «стало так же», а «данных не хватает, чтобы утверждать». Величина ± 2% — разброс стенда: если у вас там 25%, обсуждать проценты изменения бессмысленно, сначала чините стенд. n=30 — число независимых измерений; при n=5 любой вывод хрупок. Устройство самих пайплайнов — в «Основах CI» и «Тестах в CI». Аналоги в других экосистемах: Criterion.rs с сохранением базовой линии, JMH, pytest-benchmark с --benchmark-compare-fail, hyperfine для CLI.
Эшелон 3: ночной прогон на выделенном стенде
Микробенчмарк не видит регрессий, живущих во взаимодействии: рост числа запросов к базе, потеря keep-alive (https://courses.digitable.life/post/performance/10-network-performance/), падение hit rate (https://courses.digitable.life/post/performance/09-caching/), новый контеншн на пуле. Это ловит нагрузочный прогон — по открытой модели, на прод-подобных данных, ночью, на выделенном железе (https://courses.digitable.life/post/performance/11-load-testing/).
// k6/checkout.js — пороги здесь и есть бюджет, вынесенный в исполняемый вид
export const options = {
// Открытая модель: 400 запросов в секунду независимо от того, успевает ли
// система. Закрытая модель прячет деградацию (coordinated omission).
scenarios: {
steady: { executor: 'constant-arrival-rate', rate: 400, timeUnit: '1s',
duration: '15m', preAllocatedVUs: 200, maxVUs: 2000 },
},
thresholds: {
'http_req_duration{endpoint:checkout}': ['p(99)<900', 'p(95)<420'],
'http_req_duration{endpoint:catalog}': ['p(99)<400'],
// abortOnFail: нет смысла греть стенд ещё 14 минут, если сервис уже сыпется
'http_req_failed': [{ threshold: 'rate<0.001', abortOnFail: true }],
'dropped_iterations': ['count<10'], // генератор не успел — прогон недостоверен
},
discardResponseBodies: true,
};
Отчёт в конце прогона (числа иллюстративные, с конкретного стенда — на ваш они не переносятся):
✓ status 200
dropped_iterations.............: 0
http_req_failed................: 0.02% ✓ 62 ✗ 359 281
✗ http_req_duration{endpoint:checkout} p(95)=388ms p(99)=1.02s
ERRO[0904] thresholds on metrics 'http_req_duration{endpoint:checkout}' have been crossed
p95 в бюджете, p99 пробит в полтора раза. Такое расхождение почти всегда означает не «стало медленнее вообще», а появление отдельного медленного режима — блокировки, промаха кэша, ретрая: искать надо второй горб распределения, а не среднее замедление. Строка dropped_iterations обязательна к чтению: если генератор не успел отправить запланированные запросы, весь прогон недостоверен, и заниматься надо генератором.
Эшелон 4: канареечный релиз
Единственный эшелон, где нагрузка настоящая по определению: новая версия получает 1–5% трафика, метрики RED сравниваются с контрольной группой, при превышении порога — автооткат («CD и стратегии релизов»). Три правила, без которых канарейка врёт: сравнивать с одновременной контрольной группой, а не со вчерашним днём (иначе вы измеряете суточный профиль трафика); учитывать состав трафика (если балансировщик шлёт на канарейку новые соединения, она чаще платит за TLS-рукопожатия и выглядит медленнее без всякой регрессии); давать время на разогрев — JIT, прогрев кэшей, заполнение пулов.
Эшелон 5: непрерывное профилирование
Медленный дрейф по 1–2% на релиз не поймает ни один порог: каждое изменение тонет в шуме, а за год набегает двукратное замедление. Видно его только на длинном тренде и в разностном профиле.
go tool pprof -diff_base=profiles/v1.41-cpu.pb.gz \
-top -nodecount=12 profiles/v1.42-cpu.pb.gz
flat flat% sum% cum cum%
0.68s 8.91% 8.91% 0.71s 9.31% reflect.Value.Field
0.31s 4.06% 12.97% 0.94s 12.32% encoding/json.(*encodeState).reflectValue
-0.22s 2.88% 10.09% -0.22s 2.88% internal/render.appendFast
Положительные числа — время, добавленное новой версией; отрицательные — убранное. Строка reflect.Value.Field с плюсом почти наверняка означает, что кто-то заменил кодогенерацию на рефлексию «для читаемости». Тот же -diff_base работает для профилей аллокаций и лежит в основе differential flame graph (https://courses.digitable.life/post/performance/03-cpu-profiling/). Идея непрерывного профилирования прода описана в Google-Wide Profiling (Ren et al., 2010); открытые реализации — Parca и Pyroscope. Накладные расходы при 100 Гц — доли процента, так что «это дорого» обычно означает «мы не мерили». Сводка по эшелонам — что каждый ловит и какой должна быть реакция:
| Эшелон | Обратная связь | Ловит | Не ловит | Реакция |
|---|---|---|---|---|
| Детерминированные счётчики | секунды | N+1, лишние аллокации, рост бандла | всё, что не сосчитано явно | блокировать мердж |
| Сравнительный бенчмарк A/B | минуты | замедление горячей функции | эффекты уровня системы | блокировать при превышении порога |
| Ночной прогон на стенде | часы | контеншн, пулы, кэши, деградацию под нагрузкой | редкие прод-паттерны | заявка утром, не блокировать |
| Канареечный релиз | часы | всё, что зависит от реальных данных | проявляющееся только на 100% | автооткат |
| Непрерывное профилирование | дни–недели | медленный дрейф, регрессии зависимостей | ничего не блокирует | квартальный разбор |
Статистика порогов: почему «падать на любой регрессии» ломает CI
Здесь ломается большинство самодельных систем. Соблазн понятен: стало на 4% хуже — красим сборку. Через две недели команда добавляет [skip perf] в каждый коммит.
Множественные сравнения. Пусть у вас $m$ бенчмарков и вы красите сборку при $p < \alpha$. Вероятность хотя бы одной ложной тревоги:
$$P_{\text{ложн}} = 1 - (1 - \alpha)^m$$
При $\alpha = 0{,}05$ и $m = 60$ это $1 - 0{,}95^{60} \approx 0{,}95$: почти каждый прогон красный без единой настоящей регрессии. Лечится тремя способами вместе — поправка на множественность (Холм или Бенджамини–Хохберга по FDR), порог по величине эффекта, а не только по значимости, и подтверждающий повторный прогон.
Чувствительность против шума. Минимальный различимый эффект при $n$ измерениях в каждой группе, мощности около 80% и $\alpha = 5%$:
$$\mathrm{MDE} \approx 2{,}8 \cdot \sigma \cdot \sqrt{\frac{2}{n}}$$
где $\sigma$ — относительное стандартное отклонение стенда. На типичном облачном раннере $\sigma \approx 8%$, и при $n = 10$ получается MDE около 10%. Ставить порог 3% на таком стенде — значит генерировать ложные тревоги, а не ловить регрессии: для порога 3% нужно либо $n \approx 110$, либо стенд с $\sigma$ порядка 1–2% (выделенное железо, фиксированные частоты, изолированные ядра). Порог — не решение о строгости, а следствие качества стенда; его вычисляют, а не выбирают. Практический рецепт: измерьте $\sigma$ своего стенда, прогнав одну и ту же версию против самой себя двадцать раз. Если такой A/A-прогон краснеет, ваш порог заведомо неверен. Это самая полезная диагностика перф-CI, и её почти никто не делает. Статистическая база — в «Вероятности и статистике».
Регрессия не обязана быть в одном коммите. Дрейф по 1,5% за релиз не пробьёт порог 20% никогда. Против него работает не порог, а обнаружение точек разладки (change point detection) на временном ряде метрики: алгоритм ищет момент, когда изменился уровень ряда, и уже потом привязывает его к диапазону коммитов. Так устроена система MongoDB, описанная в работе Change Point Detection in Software Performance Testing (Daly et al., ICPE 2020): к рядам результатов применяется E-Divisive means, найденные точки превращаются в заявки. Алгоритм стоит $O(n^2)$ по времени на сегмент и $O(n)$ по памяти, что при тысячах точек считается за секунды офлайн — то есть его место в ночной аналитике, а не внутри PR-джоба. Мораль: порог в PR-джобе ловит грубые регрессии, тренд с разладками ловит дрейф. Один механизм не заменяет другой.
по всему ряду и автозаявки
Триаж: это регрессия или шум?
Красный перф-джоб — не приговор, а начало расследования: без явного алгоритма триажа команда либо чинит шум, либо привыкает игнорировать сигнал.
больше порога?"} B -- нет --> Z["Шум: записать точку в ряд,
не блокировать"] B -- да --> C{"Повторный прогон
подтвердил?"} C -- нет --> Y["Флейки: карантин бенчмарка
и заявка на стенд"] C -- да --> D{"Детерминированный счётчик
тоже вырос?"} D -- да --> E["Регрессия почти наверняка настоящая:
смотреть дельту счётчика"] D -- нет --> F{"Разностный профиль
показывает новый узел?"} F -- да --> G["Локализовано: узел из pprof diff_base
ведёт к правке"] F -- нет --> H{"Менялись зависимости
или тулчейн?"} H -- да --> I["Проверить обновление библиотеки
отдельным прогоном"] H -- нет --> J["git bisect run
по бенчмарку с порогом"] E & G & I & J --> K{"Замедление осознанное
и оправданное?"} K -- да --> L["Обновить бюджет через PR
и записать в журнал"] K -- нет --> M["Чинить или откатывать"]
Когда виновник не очевиден, работает автоматическая бисекция — тот же git bisect, но с бенчмарком вместо теста:
# bisect-perf.sh — «тест» для git bisect run: код 0 = быстро, 1 = медленно
set -euo pipefail
go test ./internal/render -run='^$' -bench=BenchmarkRenderLarge -count=8 > /tmp/cur.txt
NS=$(python3 ci/median_ns.py /tmp/cur.txt) # медиана ns/op
# Порог грубее искомой регрессии: бисекция ищет ступеньку, а не измеряет её.
(( NS > 36000 )) && exit 1 || exit 0 # база 30 мкс + 20%
# git bisect start HEAD v1.41
# git bisect run ./bisect-perf.sh → ~9 шагов на 400 коммитах: log2(400) ≈ 8,6
Бисекция требует $O(\log n)$ прогонов на $n$ коммитах, но каждый прогон должен быть достаточно длинным, чтобы решение «быстро или медленно» не определялось шумом. Практическое правило: порог берут вдвое грубее искомой регрессии, а прогон — вдвое длиннее обычного.
Когда останавливаться
Самый недооценённый навык: продолжать оптимизировать всегда приятнее, чем признать, что дальше невыгодно. Критерий 1: бюджет закрыт. Все строки разложения в рамках, сквозной p99 подтверждён на стенде и в проде — работа закончена. Не «пока есть ещё идеи», а закончена; освободившееся время идёт туда, где отдача выше.
Критерий 2: потолок Амдала достигнут. Если доля времени, доступная вашему воздействию, равна $p$, предельное ускорение операции равно
$$S_{\max} = \frac{1}{1 - p}$$
При $p = 0{,}3$ потолок — 1,43×, и никакая гениальность этого не изменит. Считать надо до начала: если участок даёт 8% времени, максимум, что вы купите, — 8,7%, и стоит поискать участок пожирнее. Смежный потолок — физический: сравните текущее время с суммой неустранимых затрат (RTT, обязательные обращения к диску, минимальный объём вычислений). В 1,3 раза от физического пола дальше идёт борьба за проценты ценой архитектурной сложности.
Критерий 3: экономика. Оптимизация — инвестиция, и её оценивают в деньгах:
$$EV = p_{\text{усп}} \cdot \Delta \cdot V - C$$
где $p_{\text{усп}}$ — вероятность, что задумка сработает (по журналу решений вашей команды обычно 0,4–0,6), $\Delta$ — величина улучшения, $V$ — ценность её единицы, $C$ — стоимость работы вместе с поддержкой усложнённого кода. Пример: сервис занимает 40 инстансов по 90 USD в месяц (3600 USD). Оптимизация обещает −25% CPU, то есть 900 USD в месяц или 10800 USD в год. Работа — три инженеро-недели, около 6000 USD, плюс усложнение кода. При $p_{\text{усп}} = 0{,}6$ ожидаемая ценность за год: $0{,}6 \cdot 10800 - 6000 \approx 480$ USD — почти ноль, решение принимается по другим соображениям (например, квота региона). А при 400 инстансах ответ очевиден. Тот же счёт с обратной стороны: если 200 мс на странице исторически давали +0,4% конверсии, а конверсия стоит 2 млн USD в год, эти 200 мс стоят 8000 USD в год, и три недели окупаются. Пока не назван $V$, спор «стоит ли это делать» неразрешим («Приоритизация»).
Правый нижний квадрант самый опасный: дорогие работы с малой отдачей, зато технически интересные. Именно туда уходят месяцы, если нет бюджета и нет счёта в деньгах.
Критерий 4: цена сложности превысила выигрыш. Признаки перехода черты: правку нельзя объяснить новому человеку за пять минут; появился комментарий «не трогать, здесь важен порядок полей»; тесты проверяют не поведение, а представление данных в памяти; оптимизация опирается на неспецифицированное поведение компилятора или конкретную версию рантайма; выигрыш есть только на текущем размере данных, а он удвоится за год. Быстрая, но нечитаемая система — долг, который отдают инцидентами («Запахи кода и рефакторинг», «Поддержка и техдолг»).
Критерий 5: узкое место переехало — доминирует другой участок, и он вне вашего контроля. Это нормальный финал, но его обязательно фиксируют в журнале, иначе через квартал кто-нибудь начнёт заново оптимизировать уже оптимизированное. Признаки, что останавливаться надо было раньше: последние три правки дали суммарно меньше, чем разброс измерений; вы оптимизируете код, которого нет в профиле прода; вы спорите о 2% на стенде с $\sigma = 8%$; ускорение подтверждается только на синтетических данных с равномерным распределением ключей.
Журнал решений: perf ADR
Знание о производительности вашей системы живёт в головах и испаряется при смене команды. Дешёвый способ его сохранить — короткая запись на каждую значимую перф-работу, включая неудачные: они важнее, потому что экономят недели тем, кто придёт после. Формат тот же, что у архитектурных решений (ADR), только с обязательными числами.
# PERF-2026-014: Кодогенерация сериализации ответа каталога
Дата 2026-05-18 · Статус: принято · Владелец: team-catalog
Бюджетная строка: catalog_list / serialize_ms (бюджет 20 мс)
## Контекст и измерение
p99 `GET /catalog` = 468 мс при бюджете 400. Трассировка: спан `serialize` — 24 мс,
бюджет 20. Профиль CPU (прод, 60 с, 100 Гц): 71% времени спана — `reflect.Value.Field`.
## Гипотеза и предсказание (записаны до эксперимента)
Кодогенерация маршалинга уберёт рефлексию. Ожидаем спан 8–11 мс, аллокации
на ответ 4344 → менее 1200 Б. Порог приёмки: спан ≤ 12 мс.
## Результат и чего не ожидали
Бенчмарк (30 прогонов, чередование, выделенный хост, σ = 1,8%):
8,214 → 6,031 мкс/оп (−26,6%, ДИ −28,1…−25,0%); 4344 → 1136 Б/оп.
Прод после канарейки 5%: спан 24 → 9,4 мс, p99 эндпоинта 468 → 441 мс —
упал на 27 мс, а не на 15 мс сериализации: пропала часть давления на GC.
## Цена и закрепление
+1 шаг кодогенерации в сборке (+9 с); теги структуры больше не читаются в рантайме.
`hard_limits.allocs_per_request: 4200` в perf-budget.yaml, бенчмарк в наборе перф-джоба.
Запись об отрицательном результате не менее ценна: «PERF-2026-011: пул буферов для тела ответа — эффект −1,2% при доверительном интервале, накрывающем ноль; отклонено. Причина: аллокации уже обслуживались size class рантайма, реального давления не было». Такая строка экономит будущей команде три дня.
Организация: кто отвечает за производительность
- Перф-критерий в definition of done. Для эндпоинтов из бюджетного файла PR не мержится без зелёного перф-джоба — дальше это просто работает.
- Дежурство по перф-заявкам. Заявки от ночного прогона и детектора разладок идут в очередь дежурного, а не в общий бэклог, где умирают («Наблюдаемость и дежурства»).
- Квартальный ритм. Обзор трендов: p99 по эндпоинтам за 90 дней, дельта профиля от релиза к релизу, сколько бюджетов краснело и пересматривалось; полдня работы отменяют большую часть внезапных пожаров. Бюджет ошибок как рычаг: Если SLO нарушен и бюджет ошибок исчерпан, приоритет автоматически смещается с фич на производительность; правило записывается заранее, иначе каждый раз превращается в переговоры.
Чего делать не стоит — заводить отдельную «команду производительности», которой сдают чужой медленный код: она быстро становится узким местом и снимает ответственность с авторов. Работает обратное: инструменты и бюджеты общие, ответственность у владельцев сервисов, эксперты помогают и учат.
Сквозной пример: неделя одной регрессии
Полезно заметить, чего в этой хронике нет: фразы «попробуем убрать вот это, вдруг поможет». Каждый шаг — либо измерение, либо проверка одной гипотезы. Общее время — четыре дня, из них собственно правка — час.
Типичные ошибки процесса
- Оптимизировать без бюджета. Нет критерия остановки — работа не заканчивается, а прерывается по усталости.
- Строить перф-CI на абсолютных числах. Порог «ns/op < 5000» на облачном раннере краснеет от соседа, а не от кода.
- Ставить порог, не измерив σ стенда. Без A/A-прогона порог — суеверие.
- Красить сборку на каждом шуме. Через две недели проверку начинают обходить, и защиты не остаётся.
- Один эшелон вместо нескольких. Микробенчмарки не видят системных эффектов, нагрузочные тесты — одной лишней аллокации в цикле.
- Не хранить историю измерений и версии окружения. Без ряда точек не поймать дрейф; без версий раннера и рантайма ряд несравним сам с собой.
- Игнорировать нарушенный бюджет. Один игнор обесценивает систему целиком.
- Не записывать отрицательные результаты и считать успехом бенчмарк, а не прод. Через год кто-то потратит те же три недели на ту же идею; а пока канарейка или трассировка не подтвердили эффект, эффекта нет.
Мини-итог
- Разовые ускорения не складываются в быструю систему — складывается процесс: бюджет → эшелоны защиты → триаж → журнал.
- Бюджет производительности есть число с окном, участком, владельцем и способом проверки; он даёт и приоритет, и критерий остановки.
- Разложение бюджета важнее верхнего числа: чинят участок с наибольшим перерасходом, а не самый медленный.
- Детерминированные счётчики (SQL-запросы, аллокации, размер бандла, инструкции) — единственные величины, на которые в CI можно ставить жёсткий порог; абсолютные времена недостоверны, работает только сравнение base и head в одном джобе.
- Порог вычисляется из σ вашего стенда, а не выбирается по вкусу; A/A-прогон — обязательная калибровка. При 60 бенчмарках и α = 5% почти каждая сборка красная случайно.
- Медленный дрейф ловится не порогом, а поиском точек разладки на длинном ряде; останавливаются по одному из пяти критериев: бюджет закрыт, потолок Амдала, экономика, цена сложности, узкое место переехало.
- Журнал решений с числами, включая отрицательные результаты, — самый дешёвый способ не проходить один путь дважды.
Источники
- Google SRE Book. Service Level Objectives — https://sre.google/sre-book/service-level-objectives/
- D. Daly et al. Change Point Detection in Software Performance Testing. ICPE 2020 — https://arxiv.org/abs/2003.00584
- G. Ren et al. Google-Wide Profiling. IEEE Micro, 2010 — https://research.google/pubs/pub36575/; T. Mytkowicz et al. Producing Wrong Data Without Doing Anything Obviously Wrong! ASPLOS 2009 — https://dl.acm.org/doi/10.1145/1508284.1508275; A. Georges et al. Statistically Rigorous Java Performance Evaluation. OOPSLA 2007 — https://dri.es/files/oopsla07-georges.pdf
- Brendan Gregg. Systems Performance, 2-е изд. — https://www.brendangregg.com/systems-performance-2nd-edition-book.html; Benchmarking Checklist
- Nielsen Norman Group. Response Times: The 3 Important Limits — https://www.nngroup.com/articles/response-times-3-important-limits/; Interactive Latency Numbers — с поправкой на год публикации исходных цифр
- Инструменты: benchstat, pprof, k6 thresholds, Lighthouse CI, Criterion.rs, pytest-benchmark, Parca, Pyroscope
Что дальше
Трек «Производительность систем» закончен: от карты и методики через измерение, бенчмаркинг, профилирование, память, кэши процессора, ввод-вывод и конкурентность к базам данных и процессу, который удерживает результат.
Куда идти дальше, в зависимости от того, где болит:
- Качество как дисциплина — Тестирование и «Нагрузочное и перф-тестирование»: те же прогоны со стороны QA.
- Инфраструктура и конвейеры — DevOps: CI/CD, выкатки и наблюдаемость, в которые встраиваются перф-джобы из этой статьи.
- Данные и хранилища — Базы данных и особенно «Индексы и планы запросов»: перерасход бюджета чаще всего живёт именно там.
- Масштаб за пределами узла — Распределённые системы и «Наблюдаемость»: хвосты, ретраи и координация, необъяснимые локальным профилем.
- Клиентская сторона — Фронтенд и «Веб-производительность»: свои бюджеты и свои метрики восприятия.
- Алгоритмическая основа — Алгоритмы и «Практическая оптимизация»: когда упёрлись в потолок и надо менять подход, а не константу.
Общая карта портала и порядок изучения треков — в «Дорожной карте».