Производительность систем Рабочий процесс оптимизации: бюджеты, регрессии в CI, когда остановиться
0%

Рабочий процесс оптимизации: бюджеты, регрессии в CI, когда остановиться

Рабочий процесс оптимизации: бюджеты, регрессии в 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 мс; если больше — это баг, и он блокирует релиз так же, как упавший тест». Бюджет даёт приоритет (чинить то, что вышло за рамки), критерий остановки (вернулись — прекратили) и переводит спор из области вкуса в область арифметики.

Откуда берётся верхнее число

  1. Продуктовое требование. Влияние задержки на конверсию измеряли многие: эксперименты Google и Bing на 100–500 мс, сводка WPO Stats. Полезны они не абсолютом (у вас другая аудитория), а порядком величины и тем, что оправдывают саму постановку цели.
  2. Восприятие человеком. Классические пороги Nielsen Norman Group: 0,1 с — «мгновенно», 1 с — «поток мысли не прерван», 10 с — «внимание потеряно».
  3. Физический потолок. Прежде чем обещать 50 мс, посчитайте, из чего они складываются: RTT до региона, обязательные обращения к диску и сети. Здесь пригождается табличка «latency numbers every programmer should know» — но с прямой оговоркой: её числа относятся к 2012 году, интерактивная версия Colin Scott их экстраполирует, и часть строк устарела на порядок (NVMe против «диска», задержки внутри ЦОД). Это проверка на здравый смысл, а не справочник: свои числа меряют сами, как в «Измерении».
  4. Текущее состояние. Если сейчас p99 = 900 мс, то бюджет 400 мс — это проект, а не бюджет. Ставят промежуточную цель и двигают её. Формально бюджет оформляется как SLO — цель с окном и долей: «99% запросов быстрее 400 мс за скользящие 30 дней». Механику разбирает SRE-книга Google, связь с метриками команды — «DORA и инженерные метрики».

Разложение бюджета по участкам

Верхнее число бесполезно, пока не разложено. Разложение превращает «сайт тормозит» в «база съедает 210 мс при бюджете 120».

Бюджет p99 по участкам: план против факта

Из семи строк три вышли за бюджет, но перерасход распределён крайне неравномерно: 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-джобе ловит грубые регрессии, тренд с разладками ловит дрейф. Один механизм не заменяет другой.

Триаж: это регрессия или шум?

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

Когда виновник не очевиден, работает автоматическая бисекция — тот же 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 нарушен и бюджет ошибок исчерпан, приоритет автоматически смещается с фич на производительность; правило записывается заранее, иначе каждый раз превращается в переговоры.

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

Сквозной пример: неделя одной регрессии

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

Типичные ошибки процесса

  1. Оптимизировать без бюджета. Нет критерия остановки — работа не заканчивается, а прерывается по усталости.
  2. Строить перф-CI на абсолютных числах. Порог «ns/op < 5000» на облачном раннере краснеет от соседа, а не от кода.
  3. Ставить порог, не измерив σ стенда. Без A/A-прогона порог — суеверие.
  4. Красить сборку на каждом шуме. Через две недели проверку начинают обходить, и защиты не остаётся.
  5. Один эшелон вместо нескольких. Микробенчмарки не видят системных эффектов, нагрузочные тесты — одной лишней аллокации в цикле.
  6. Не хранить историю измерений и версии окружения. Без ряда точек не поймать дрейф; без версий раннера и рантайма ряд несравним сам с собой.
  7. Игнорировать нарушенный бюджет. Один игнор обесценивает систему целиком.
  8. Не записывать отрицательные результаты и считать успехом бенчмарк, а не прод. Через год кто-то потратит те же три недели на ту же идею; а пока канарейка или трассировка не подтвердили эффект, эффекта нет.

Мини-итог

  • Разовые ускорения не складываются в быструю систему — складывается процесс: бюджет → эшелоны защиты → триаж → журнал.
  • Бюджет производительности есть число с окном, участком, владельцем и способом проверки; он даёт и приоритет, и критерий остановки.
  • Разложение бюджета важнее верхнего числа: чинят участок с наибольшим перерасходом, а не самый медленный.
  • Детерминированные счётчики (SQL-запросы, аллокации, размер бандла, инструкции) — единственные величины, на которые в CI можно ставить жёсткий порог; абсолютные времена недостоверны, работает только сравнение base и head в одном джобе.
  • Порог вычисляется из σ вашего стенда, а не выбирается по вкусу; A/A-прогон — обязательная калибровка. При 60 бенчмарках и α = 5% почти каждая сборка красная случайно.
  • Медленный дрейф ловится не порогом, а поиском точек разладки на длинном ряде; останавливаются по одному из пяти критериев: бюджет закрыт, потолок Амдала, экономика, цена сложности, узкое место переехало.
  • Журнал решений с числами, включая отрицательные результаты, — самый дешёвый способ не проходить один путь дважды.

Источники

Что дальше

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

Куда идти дальше, в зависимости от того, где болит:

Общая карта портала и порядок изучения треков — в «Дорожной карте».

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

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

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

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