сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Рантайм Python

Профилирование 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, остальные разбираются в тренажёре.

  1. #profiling_perf1 / 5
    Нужно обработать файл на 50 млн строк, не съев всю память. Приём в Python?
    A)Читать и обрабатывать лениво — генератором, по строке
    B)Загрузить файл целиком в список и обработать его одним проходом
    C)Поднять несколько процессов, чтобы у каждого была своя копия файла
    D)Увеличить лимит памяти интерпретатора специальным флагом запуска
    показать ответ и разбор
    +A)Читать и обрабатывать лениво — генератором, по строке

    // разбор: Ленивая обработка: итерировать файл по строкам (сам объект файла — генератор строк) или отдавать данные генератором, держа в памяти лишь текущий кусок, а не весь набор. Так постоянное потребление памяти не зависит от размера входа. Это общий приём стриминга больших данных в Python.

  2. #profiling_perf2 / 5
    С чего правильно начинать оптимизацию медленного кода?
    A)С переписывания самых сложных на вид участков — они наверняка и есть самые медленные
    B)С профилирования: измерить, где реально узкое место, а не гадать
    C)С замены всех циклов на генераторы по всему проекту ради общей экономии времени
    D)С перехода на другой язык программирования, раз текущий код работает медленно
    показать ответ и разбор
    +B)С профилирования: измерить, где реально узкое место, а не гадать

    // разбор: Правило оптимизации: сперва измерить. Интуиция о том, что тормозит, часто ошибается — реальное узкое место вскрывает профайлер. Оптимизировать наугад — тратить силы на участки, не влияющие на общее время, и рисковать усложнить код без выгоды. Порядок: профилировать → найти горячую точку → оптимизировать её → снова измерить, что стало лучше.

  3. #profiling_perf3 / 5
    Что показывает cProfile?
    A)Список строк кода, которые ни разу не исполнились во время прогона программы
    B)Потребление оперативной памяти каждым объектом программы в каждый момент времени
    C)Построчный расход времени внутри одной конкретной функции с точностью до строки
    D)Сколько времени и вызовов пришлось на каждую функцию за прогон
    показать ответ и разбор
    +D)Сколько времени и вызовов пришлось на каждую функцию за прогон

    // разбор: cProfile — встроенный детерминированный профайлер: он собирает, сколько раз вызвана каждая функция и сколько суммарного/собственного времени в ней проведено. Это первый инструмент, чтобы найти функции-пожиратели времени. Для детализации внутри функции берут line_profiler (построчно), для сэмплирующего профиля живого процесса — py-spy, а для памяти — отдельные профилировщики.

  4. #profiling_perf4 / 5
    Функцию оптимизировали, ускорив её в 3 раза, а общее время почти не изменилось. Почему?
    A)Она давала малую долю общего времени — закон Амдала ограничивает выигрыш
    B)Ускорение съел сборщик мусора, который стал запускаться ровно втрое чаще прежнего
    C)Python кеширует результаты, поэтому повторные замеры показывают одно и то же время
    D)Оптимизация не сработала: раз общее время не упало, функция не стала быстрее на деле
    показать ответ и разбор
    +A)Она давала малую долю общего времени — закон Амдала ограничивает выигрыш

    // разбор: Общий выигрыш ограничен долей, которую занимала оптимизируемая часть (закон Амдала): ускорить втрое участок, дающий 5% времени, — почти незаметно для целого. Поэтому и начинают с профилирования: силы вкладывают в настоящие горячие точки, где сокращение времени даёт ощутимый общий эффект, а не в то, что на глаз кажется сложным, но исполняется редко.

  5. #profiling_perf5 / 5
    Что обычно даёт больший выигрыш: смена алгоритма (O(n²)→O(n log n)) или микрооптимизации внутри цикла?
    A)Они дают одинаковый эффект, ведь обе в итоге сокращают число операций программы
    B)Микрооптимизации: подкрутка операций в цикле обычно важнее асимптотики алгоритма
    C)Смена алгоритма — на больших n она бьёт любые константные микроправки
    D)Всё решает только язык: на Python ни то, ни другое ощутимого выигрыша не приносит
    показать ответ и разбор
    +C)Смена алгоритма — на больших n она бьёт любые константные микроправки

    // разбор: Микрооптимизации улучшают константу — выигрыш фиксированный. Смена алгоритмической сложности меняет то, как время растёт с размером данных: на больших n переход O(n²)→O(n log n) экономит порядки, чего никакая подкрутка операций в квадратичном цикле не догонит. Поэтому сначала смотрят на алгоритмы и структуры данных, и лишь потом — на микрооптимизации горячего участка.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.