Профилирование Python-кода
Написал функцию, которая ищет 20 тысяч значений в списке из 20 тысяч элементов. Профилировщик показал: 3,281 секунды всего, из них 3,279 внутри одной строки поиска. Заменил список на множество - та же работа заняла 1,1 миллисекунды. В три тысячи раз быстрее, и изменение состоит из одного слова.
Стержень: ускоряют не то, что кажется медленным, а то, что показал замер; и чаще всего дело оказывается в неверной структуре данных, а не в языке.
// Формулировки: «как найти узкое место?», «чем профилировщик отличается от таймера?», «Python медленный, что делать?»
Сначала измерить
Профилировщик показывает, сколько времени провели внутри каждой функции и сколько раз её вызвали. В моём замере это сразу дало ответ: из 3,281 секунды 3,279 ушло в одну функцию поиска, а на подготовку данных - доли миллисекунды. Гадать было не о чем.
Простой таймер вокруг куска кода отвечает на другой вопрос - сколько заняла вот эта операция целиком. Он дешевле и годится для сравнения двух вариантов, но не покажет, ГДЕ внутри время. Оба инструмента нужны: профилировщик - чтобы найти место, таймер - чтобы сравнить до и после.
// И третий инструмент для прода: профилировщик, работающий выборкой. Он не замедляет процесс заметно, потому что не считает каждый вызов, а периодически смотрит, где программа находится сейчас. Обычный профилировщик на проде включать не стоит - он сам по себе замедляет работу в разы.
- профилировщик
- показывает, где именно проведено время и сколько было вызовов
- профилировщик выборкой
- периодически смотрит, где программа сейчас; годится для прода
Структура данных решает больше, чем язык
Мой главный замер именно об этом. Проверка вхождения по списку - 3173,9 миллисекунды, по множеству - 1,1. Разница в три тысячи раз, потому что список проверяет элементы по очереди, а множество считает хэш и смотрит в одно место. Ускорять код тут не нужно вовсе, нужна правильная структура.
Второй классический случай - склейка строк. Накопление через сложение в цикле на 50 тысячах шагов заняло 40,2 миллисекунды, сборка тем же содержимым через объединение - 3,9. Причина та же по духу: строки неизменяемы, и каждое сложение создаёт новую строку целиком.
// Третий - память. Список из миллиона чисел занял у меня 38,6 мегабайта, а та же сумма, посчитанная через генератор, - 0,4 килобайта пиковой памяти. Если данные нужны один раз и по порядку, материализовать их в список незачем.
- поиск по списку
- проверяет элементы по очереди; время растёт с размером
- поиск по множеству
- считает хэш и смотрит в одно место; время почти не зависит от размера
- генератор
- выдаёт значения по одному, не держа их все в памяти
Когда язык действительно мешает
Бывает и так, что структура данных верная, а всё равно медленно. Тогда работают три приёма по возрастанию сложности. Первый: перенести горячий цикл в библиотеку, написанную на C, - численные вычисления, разбор данных, работа с массивами обычно уже имеют такие библиотеки, и они на порядок быстрее ручного цикла.
Второй: убрать работу вообще. Кэшировать результат, считать один раз вместо каждого вызова, брать из базы сразу нужное вместо выборки всего и фильтрации в коде. Это почти всегда даёт больше, чем ускорение самого кода.
// Третий: вынести тяжёлое из запроса в фон и отвечать пользователю сразу. И только после всего этого имеет смысл думать про другой интерпретатор или переписывание части на компилируемом языке. Порядок именно такой, потому что каждый следующий шаг дороже предыдущего в разы, а выигрыш обычно меньше.
- горячий цикл
- место, где программа проводит большую часть времени
- убрать работу
- не ускорять вычисление, а не делать его вовсе
Как отвечать: «Сервис тормозит. Как искать узкое место?»
Сначала мерить, потом чинить. Профилировщик показывает, где именно проведено время и сколько было вызовов; в моём учебном примере он сразу показал, что из трёх секунд две девятьсот ушли в одну строку поиска, и гадать было не о чем. На проде беру профилировщик выборкой - он не замедляет процесс, потому что периодически смотрит, где программа сейчас, а не считает каждый вызов. Дальше в девяти случаях из десяти дело оказывается не в языке. Самое частое - неверная структура данных: я мерил, проверка вхождения по списку заняла три секунды, а по множеству ту же работу - миллисекунду, разница в три тысячи раз от одного слова. Второе по частоте - лишняя работа: выбрали из базы всё и отфильтровали в коде, посчитали одно и то же несколько раз. И только когда структура верна и лишнего нет, имеет смысл думать про библиотеки на C и про вынос тяжёлого в фон.
Ответ ставит замер первым, отделяет прод от разработки и называет две самые частые причины с числом. Порядок действий тут и есть содержание ответа.
На чём валятся
- −Оптимизируют то, что кажется медленным, без замера.
- −Проверяют вхождение по списку там, где нужно множество.
- −Склеивают строки сложением в цикле.
- −Материализуют в список данные, которые нужны один раз по порядку.
- −Включают обычный профилировщик на проде и замедляют сервис в разы.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.
- Нужно обработать файл на 50 млн строк, не съев всю память. Приём в Python?A)Читать и обрабатывать лениво — генератором, по строкеB)Загрузить файл целиком в список и обработать его одним проходомC)Поднять несколько процессов, чтобы у каждого была своя копия файлаD)Увеличить лимит памяти интерпретатора специальным флагом запуска
показать ответ и разбор
+A)Читать и обрабатывать лениво — генератором, по строке// разбор: Ленивая обработка: итерировать файл по строкам (сам объект файла — генератор строк) или отдавать данные генератором, держа в памяти лишь текущий кусок, а не весь набор. Так постоянное потребление памяти не зависит от размера входа. Это общий приём стриминга больших данных в Python.
- С чего правильно начинать оптимизацию медленного кода?A)С переписывания самых сложных на вид участков — они наверняка и есть самые медленныеB)С профилирования: измерить, где реально узкое место, а не гадатьC)С замены всех циклов на генераторы по всему проекту ради общей экономии времениD)С перехода на другой язык программирования, раз текущий код работает медленно
показать ответ и разбор
+B)С профилирования: измерить, где реально узкое место, а не гадать// разбор: Правило оптимизации: сперва измерить. Интуиция о том, что тормозит, часто ошибается — реальное узкое место вскрывает профайлер. Оптимизировать наугад — тратить силы на участки, не влияющие на общее время, и рисковать усложнить код без выгоды. Порядок: профилировать → найти горячую точку → оптимизировать её → снова измерить, что стало лучше.
- Что показывает cProfile?A)Список строк кода, которые ни разу не исполнились во время прогона программыB)Потребление оперативной памяти каждым объектом программы в каждый момент времениC)Построчный расход времени внутри одной конкретной функции с точностью до строкиD)Сколько времени и вызовов пришлось на каждую функцию за прогон
показать ответ и разбор
+D)Сколько времени и вызовов пришлось на каждую функцию за прогон// разбор: cProfile — встроенный детерминированный профайлер: он собирает, сколько раз вызвана каждая функция и сколько суммарного/собственного времени в ней проведено. Это первый инструмент, чтобы найти функции-пожиратели времени. Для детализации внутри функции берут line_profiler (построчно), для сэмплирующего профиля живого процесса — py-spy, а для памяти — отдельные профилировщики.
- Функцию оптимизировали, ускорив её в 3 раза, а общее время почти не изменилось. Почему?A)Она давала малую долю общего времени — закон Амдала ограничивает выигрышB)Ускорение съел сборщик мусора, который стал запускаться ровно втрое чаще прежнегоC)Python кеширует результаты, поэтому повторные замеры показывают одно и то же времяD)Оптимизация не сработала: раз общее время не упало, функция не стала быстрее на деле
показать ответ и разбор
+A)Она давала малую долю общего времени — закон Амдала ограничивает выигрыш// разбор: Общий выигрыш ограничен долей, которую занимала оптимизируемая часть (закон Амдала): ускорить втрое участок, дающий 5% времени, — почти незаметно для целого. Поэтому и начинают с профилирования: силы вкладывают в настоящие горячие точки, где сокращение времени даёт ощутимый общий эффект, а не в то, что на глаз кажется сложным, но исполняется редко.
- Что обычно даёт больший выигрыш: смена алгоритма (O(n²)→O(n log n)) или микрооптимизации внутри цикла?A)Они дают одинаковый эффект, ведь обе в итоге сокращают число операций программыB)Микрооптимизации: подкрутка операций в цикле обычно важнее асимптотики алгоритмаC)Смена алгоритма — на больших n она бьёт любые константные микроправкиD)Всё решает только язык: на Python ни то, ни другое ощутимого выигрыша не приносит
показать ответ и разбор
+C)Смена алгоритма — на больших n она бьёт любые константные микроправки// разбор: Микрооптимизации улучшают константу — выигрыш фиксированный. Смена алгоритмической сложности меняет то, как время растёт с размером данных: на больших n переход O(n²)→O(n log n) экономит порядки, чего никакая подкрутка операций в квадратичном цикле не догонит. Поэтому сначала смотрят на алгоритмы и структуры данных, и лишь потом — на микрооптимизации горячего участка.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.