Есть особый жанр инженерных споров, где обе стороны цитируют одного и того же человека, но никто не дочитал его до конца. Одна сторона уверена, что Дональд Кнут якобы запретил оптимизацию, вторая повторяет мантру "сначала профилируй". На самом деле Кнут в 1974 году написал ровно противоположное крайностям: о мелкой эффективности можно забывать примерно 97 процентов времени, преждевременная оптимизация вредит, но упускать возможности в оставшихся 3 процентах тоже нельзя. Разница между дисциплиной и потерянными неделями проходит именно по этой линии. Ниже разбор, где она проходит, как измерять до того, как переписывать, и какие сценарии оправдывают ускорение с первого дня проекта.
Почему быстрая программа начинается с профилировщика, а не с интуиции
Интуиция разработчика о производительности ошибается чаще, чем кажется. Опытный инженер уверен, что узкое место в цикле умножения, а профилировщик показывает, что 80 процентов времени уходит на чтение конфигурации и на лишние запросы к базе. Причины такой ошибки понятны: мозг хорошо считает количество видимых операций, но не видит стоимость операций невидимых, вызовов системных библиотек, промахов кэша, ожидания сети. Отсюда старая шутка про два правила оптимизации, которую с 1975 года приписывают Майклу Джексону, автору метода структурного проектирования JSP: правило первое, не оптимизировать; правило второе, только для экспертов, не оптимизировать ещё. За шуткой стоит наблюдение, подтверждённое в тысячах команд: почти всё время исполнения почти всегда сосредоточено в крошечной доле кода, и без прибора эту долю не находит даже автор программы.
Приборы для этого сознательно сделаны дешёвыми. Сэмплирующие профилировщики снимают образы стека вызовов десятки раз в секунду и почти не влияют на скорость самой программы, поэтому их можно держать включёнными даже на живом трафике. Огненный график, или flame graph, рисует дерево вызовов с шириной полосы пропорционально времени, и горячий путь на нём виден без чтения чисел: самые широкие полосы и есть виновники. Привычка открывать такой отчёт перед любым ускорением экономит командам недели за год.
Формула Кнута о мелкой эффективности и критических трёх процентах
Точная история знаменитой фразы поучительна. В декабре 1974 года вышла статья Кнута о структурном программировании, и один абзац из неё потом растащили на цитаты. Смысл абзаца трёхчастный: программисты тратят непозволительно много времени на скорость некритичных частей программы; эти попытки ударяют по отладке и сопровождению; о малой эффективности стоит забывать примерно 97 процентов времени, но в критических 3 процентах упускать возможности нельзя. В том же году, в лекции при получении премии Тьюринга, Кнут повторил мысль короче: проблема в том, что усилия тратятся не в тех местах и не в то время. Забавно, что публичная память хранит только вырванную середину и теряет обе рамки. Правило Кнута это не запрет оптимизации, а инструкция о порядке действий: сначала измерение, потом усилие, и только там, где оно окупается.
Историю фразы полезно помнить ещё и потому, что её переврали в обе стороны. Одни прочитали "не оптимизируй" и стали оправдывать ленивые циклы и загрузку всей базы в память, другие поняли слово "преждевременная" как "до релиза" и писали бенчмарки за квартал до продакшена. Кнут же писал о другом: об отсутствии измерения в моменте. Ускорение без замера это ранняя оптимизация; ускорение после замера это нормальная работа, даже если до релиза ещё далеко.
Реальный ущерб от ручной микрооптимизации на раннем этапе
Цена ранней микрооптимизации складывается из трёх счетов. Первый, читаемость: код с развёрнутыми циклами, битовыми трюками и ручным кэшированием теряет структуру, и через полгода автор разбирает его как чужой. Второй, время: часы уходят на ускорение участков, которые профилировщик потом показывает как шум, а рядом горят реальные сроки. Третий, правильность: каждый ручной трюк это лишний вход для ошибки, и битовые манипуляции с округлениями ловятся хуже всего. Отдельный жанр, кэширование всего подряд: без замера оно раздувает память и показывает пользователю вчерашние данные.
Хуже того, ранняя оптимизация почти всегда попадает мимо цели. Требования меняются, горячий путь переезжает в другой модуль, а трюк остаётся и тормозит рефакторинг. Классика жанра: разработчик вручную разворачивает цикл, хотя компилятор делает это сам при включённой оптимизации, и ручная работа уходит в никуда.
Типичный сценарий из практики веб-команд знаком многим. Разработчик два дня вручную ускорял функцию форматирования отчёта с трёх миллисекунд до одной, гордился трёхкратным выигрышем, а первый же профиль страницы показал, что из 900 миллисекунд на эту функцию приходится один процент, а 80 процентов съедают запросы к внешнему API, которые выполнялись последовательно и могли ходить параллельно. Полдня работы над настоящим виновником дали бы стократный эффект. Урок не в том, что функция была плохой, а в том, что без профиля команда платила за скорость там, где её никто не ждал.
Измерение прежде ускорения на конкретных инструментах
Порядок действий прост: замерить, найти горячий путь, ускорить его, замерить снова. Инструменты зрелые и бесплатные. В Python встроен модуль cProfile, отчёт которого сортируется по накопленному времени; для живых процессов без перезапуска есть py-spy; в Linux системный профилировщик perf снимает отчёт с деревом вызовов; в экосистеме Java стандартом стали Java Flight Recorder и async-profiler; глубокий разбор вызовов даёт valgrind с инструментом callgrind. Базовые команды умещаются в три строки:
python3 -m cProfile -s cumtime app.py
py-spy top --pid 4242
perf record -g python3 app.py
Главное правило замера: реальные данные и то железо, на котором код будет жить. Синтетический тест на пустом списке обманет любого. Математика против "оптимизируем всё подряд" тоже проста. По закону Амдала, если доля ускоряемого кода равна p, а ускорение в n раз, то общий выигрыш равен 1 / ((1 - p) + p / n). Если ускорить 20 процентов кода в десять раз, программа получит всего 1 / (0,8 + 0,02), около 1,22, то есть 22 процента. Оптимизировать нужно то, что съедает время, а не то, что неприятно глазу.
Для одиночной функции хватает встроенного микрозамера:
python3 -m timeit -s "xs = list(range(10000))" "sum(xs)"
Он честно повторяет выражение миллионы раз и выдаёт среднее, но у него есть подвох с кэшем: подогретая прогонами функция показывает себя лучше, чем при первом вызове.
Дисциплина замера не менее важна инструмента. Холодный запуск отличается от горячего кэша, частота процессора скачет от нагрева, соседние процессы отбирают ядро, поэтому честный бенчмарк делает прогрев, несколько прогонов и смотрит медиану, а не лучшую попытку. Разницу в считанные проценты нельзя достоверно отличить от шума даже в лаборатории, доверять стоит кратным выигрышам либо серии из десятков прогонов. Отдельная осторожность нужна с синтетикой: функция, ускоренная на массиве из десяти элементов, легко деградирует на реальных данных, где поведение кэша совсем другое.
Оправданная оптимизация горячих путей по измеренным причинам
Есть сценарии, где ускорение с первых дней оправдано и предсказуемо. Игровой движок с бюджетом кадра 16,6 миллисекунды при 60 кадрах в секунду: потолок известен с первого дня, и лишние аллокации в кадре отслеживаются сразу. Прошивка микроконтроллера с 512 килобайтами памяти: лимит задан железом, считать нечего, экономить нужно с первой строчки. Сервис с миллионами запросов в сутки: каждый лишний запрос к базе в цикле оплачивается машинами. Наконец, библиотеки: алгоритмы сортировки в реализациях стандартной библиотеки C++ на отрезках короче 16 элементов переключаются на сортировку вставками, потому что на малых данных она обгоняет быструю за счёт кэша и предсказуемых ветвлений. Это не ранняя оптимизация, а инженерная норма, оплаченная опытом десятков лет.
Сюда же относятся системы реального времени, где ответ позже дедлайна означает брак: там бюджеты раскладывают по компонентам на этапе проектирования, и это часть требований, а не каприз. Мобильное приложение экономит батарею с первой сборки, потому что разряженный аккумулятор это жалоба, а не миллисекунда. Общее у всех честных сценариев одно: измеренный лимит известен до кода и записан числом, спорить с ним бесполезно.
Самый частый реальный виновник в бизнес-коде это даже не процессор. Обращение к кэшу первого уровня занимает порядка одной наносекунды, к оперативной памяти порядка ста, разница в сотню раз. На этом фоне запрос к базе по сети стоит уже миллионы наносекунд. Отсюда классический шаблон ошибки N+1 в ORM: цикл по заказам, в каждой итерации отдельный запрос за клиентом.
# медленно: по одному запросу на каждый заказ
for order in orders:
order.customer = db.get_customer(order.customer_id)
# быстро: один запрос с соединением вместо тысячи
rows = db.query(
"SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id"
)
Такая оптимизация оправдана всегда, потому что у неё измеренный виновник, честный замер до и после и выигрыш в разы, а не в проценты.
Разница между ростом сложности алгоритма и постоянными множителями
Выбор алгоритма по асимптотике и выжимание констант это разные работы, и путать их дорого. На массиве из тысячи элементов разница между O(n log n) и O(n²) незаметна без замера, а на ста тысячах элементов квадрат уже означает миллиарды операций и паузы на секунды. Обратное тоже верно: линейный проход по двадцати элементам часто быстрее бинарного поиска, потому что последовательный доступ ложится в кэш, а ветвления предсказуемы. Асимптотика решает на больших данных, константа решает на малых, и только замер показывает границу. Ещё одна пара из учебника, сборка строки:
# квадратичная: каждый += копирует всю строку заново
text = ""
for line in lines:
text += line
# линейная: одна копия в конце
text = "".join(lines)
Бенчмарк уровня библиотеки стоит закреплять специализированным инструментом: JMH для JVM, Criterion для Rust, Google Benchmark для C++. Самодельный цикл с таймером искажается оптимизатором и сборщиком мусора, и его цифры врут в обе стороны.
Постоянные множители подводят и на структурах данных. Массив из десяти тысяч 32-битных чисел занимает 40 килобайт и целиком ложится в кэш, связный список тех же данных генерирует по промаху на каждый элемент, и теоретически быстрая структура проигрывает "медленному" массиву в разы. Выбор структуры по амортизированной сложности из учебника, без замера на реальных размерах, это та же ранняя оптимизация, только с академической вывеской.
Правила здорового отношения к производительности в команде
Командный кодекс умещается в четыре строки:
- сначала корректный и понятный код, потом профилировщик, потом ускорение;
- каждое изменение производительности подтверждается замером до и после на реальных данных;
- оптимизируется только горячий путь из отчёта, остальной код не трогается;
- выигранное ускорение закрепляется тестом или комментарием с числами, чтобы его не снесли при следующем рефакторинге.
Полезно поддерживать правило и автоматикой: регрессионный тест на производительность в CI гоняет горячий путь на эталонных данных и останавливает сборку, если время выросло выше порога. Тогда выигрыш, добытый честным замером, живёт дольше одной недели, а разговоры об ускорении на ревью сводятся к простому вопросу: где отчёт профилировщика?
За сухими правилами стоит простая экономика. Производительность это требование, а не добродетель, и у требования есть цена: часы команды, читаемость, риск ошибок. Платить стоит там, где замер показал проблему, где цель записана числом, где замедление видит пользователь. Кнута стоит цитировать целиком: в его формуле есть и 97 процентов времени, когда о мелкой эффективности честнее забыть, и 3 процента, где без усилий не обойтись. Проверочный вопрос для любой дискуссии об ускорении звучит скучно: сколько это даст и как мы это узнали. Ответ "примерно процентов двадцать, вот два отчёта" закрывает спор за минуту. Ответ "должно стать быстрее" отправляет задачу в бэклог до появления отчёта. Такая строгость поначалу раздражает, но быстро становится привычкой, и команда перестаёт спорить о вкусах.
Команда, которая измеряет раньше, чем переписывает, тратит дни там, где другие тратят недели, а её быстрые участки остаются быстрыми не потому, что их тайно ускорили, а потому, что их вовремя нашли.