В 20 тысяч раз быстрее: 5 приемов для ускорения кода на Python
Несмотря на то, что Python — один из самых популярных языков программирования в мире, он не лишен недостатков. Самый большой из них, о котором, вероятно, известно всем — это скорость. О пяти способах улучшить Python-код в блоге на Dice рассказал разработчик программного обеспечения Дэвид Болтон.
Курс Англійської.
Онлайн-навчання англійської за методикою Кембриджу — вибір понад мільярда людей.
Примечание: автор протестировал способы на Python 3.7 и 3.9, а чтобы вести отсчет времени в наносекундах, использовал функцию perf_counter_ns из пакета time .
Pythonic
Простыми словами, Pythonic — стиль кода. Поэтому, говоря, что какой-либо код — pythonic — имеется в виду, что он написан в соответствии с идиомами Python. Это использование таких функций как map, sum и range, а также понимание списков и генераторов.
Например, нужно сравнить два способа подсчета всех целых чисел в диапазоне от 1-100, которые кратны 3:
from time import perf_counter_ns def main(): # non pythonic start=perf_counter_ns() total=0 for i in range(1,100): if (i %3)== 0: total += i end=perf_counter_ns() print (f"Non-Pythonic Total of divisible by 3= ") print(f"Time took ") # pythonic start=perf_counter_ns() total =sum(range(1, 100, 3)) end=perf_counter_ns() print (f"Pythonic Total of divisible by 3= ") print(f"Time took ") if __name__ == "__main__": main()
Курс Финансовый директор.
Отримайте комплексне розуміння фінансової діяльності. Час підніматись по карʼєрним сходам.
Non-Pythonic Total of divisible by 3= 1683 Time took 13300 Pythonic Total of divisible by 3= 1683 Time took 2900
Это время второго прогона. Первые запуски были 14,500 и 3,000, то есть от 3,5% до 9% дольше. В данном случае Pythonic-код почти в пять раз быстрее обычного.

Мемоизация
Метод ускорения медленных функций. Это способ оптимизации, при котором сохраняется результат выполнения функции, и этот результат используется при следующем вызове. Если повторные вызовы функций выполняются с одинаковыми параметрами, можно сохранить предыдущие значения вместо повторения ненужных вычислений.
В пакете functool s есть lru_cache , который можно использовать для оформления функции, которую нужно мемоизировать. В приведенном ниже примере:
-
fib — это простая немемоизированная функция Фибоначчи;
from time import perf_counter_ns from functools import lru_cache def main(): def fib(n): return n if n < 2 else fib(n-1) + fib(n-2) @lru_cache(maxsize=None) def mfib(n): return n if n < 2 else mfib(n-1) + mfib(n-2) start=perf_counter_ns() print(f"Non-memoized fib()=") end=perf_counter_ns() print(f"Time took ") start=perf_counter_ns() print(f"Memoized fib()=") end=perf_counter_ns() print(f"Time took ") if __name__ == "__main__": main()
Результат говорит сам за себя:
Non-memoized fib()=9227465 Time took 2905175700 Memoized fib()=9227465 Time took 148700
Код выполнился почти в 20 тысяч раз быстрее.
Кстати, разработчик Орен Тош считает , что код можно еще ускорить, используя подкласс Dictionary с методом __missing__ dunder .
Курс Англійської.
Онлайн-навчання англійської за методикою Кембриджу - вибір понад мільярда людей.
Перевод кода на С
Это не всегда легко, так как для этого нужно знать язык C и то, как он взаимодействует с Python. Кроме того, может быть всего несколько случаев, когда кодинг на C поможет. Помогает то, что CPython написан на C.
В библиотеке ctypes есть библиотека типов C и их отображений в Python. Она также позволяет обращаться к библиотекам операционной системы, но нужно быть готовым работать на достаточно низком уровне и знать язык С, включая массивы, структуры и указатели.
Компиляция Python
Машинный код, который создается при компиляции кода, всегда будет работать быстрее, чем интерпретируемый байт-код. Есть несколько компиляторов Python, включая Numpa , Nuitka , pypi и Cython . Автор советует оптимизировать код Python, прежде чем пытаться компилировать его. Компилятор Numpa — JIT (Just-In-Time), который также обеспечивает ускорение на GPU.
Использование from
Можно постоянно использовать пакет import , но более разумно использовать from , когда можно импортировать только нужную функцию (или функции). Зачем импортировать 20 функций, если нужна только одна? Для таких коротких программ, как ниже, вероятно, разница не будет заметна, но с увеличением размера программы она станет очевидна:
from time import perf_counter_ns def main(): start=perf_counter_ns() n = 10 fact = 1 for i in range(1,n+1): fact = fact * i end=perf_counter_ns() print (f"The factorial of is ") print(f"Time took ") if __name__ == "__main__": main()
При первом запуске было получено 4200 наносекунд, а при последующих — около 3900. Не забывайте, что можно помещать импорты внутрь функций, чтобы они вызывались только тогда, когда это необходимо.
Заключение
Если нужно выделить какой-то один способ, автор склоняется к использованию мемоизации, которая дает максимальные показатели по скорости, но чтобы стать лучшим программистом на Python, желательно освоить Pythonic-подход.
Курс Англійської.
Навчання для різних цілей та рівнів: робоча англійська, початковий рівень, курси для дітей та підлітків.
Как можно ускорить Python сегодня

Python не перестаёт удивлять многих своей гибкостью и эффективностью. Лично я являюсь приверженцем С и Fortran, а также серьёзно увлекаюсь C++, поскольку эти языки позволяют добиться высокого быстродействия. Python тоже предлагает такие возможности, но дополнительно выделяется удобством, за что я его и люблю.
Этот инструмент способен обеспечивать хорошее быстродействие, поскольку имеет в арсенале ключевые оптимизированные библиотеки, а также возможность динамической компиляции основного кода, который предварительно не компилировался. Однако скорость Python значительно падает, когда дело доходит до обработки крупных датасетов или более сложных алгоритмов. В текущей статье мы разберём:
- Почему столь важно думать о «будущем разнородных вычислений».
- Две ключевых сложности, которые необходимо преодолеть в открытом решении.
- Параллельное выполнение задач для более эффективного задействования CPU.
- Использование ускорителя для дополнительного повышения быстродействия.
Размышляя о будущем разнородных вычислений
Несмотря на то, что понимание разнородности не особо важно, если нас интересует лишь ускорение кода Python, стоит отметить значительный сдвиг, который сейчас наблюдается в сфере вычислительных наук. Компьютеры с каждым годом становятся все быстрее. Поначалу этот прирост производительности обеспечивали грамотные и более сложные архитектуры. Затем где-то в период между 1989 и 2006 годами основным ускоряющим фактором стала растущая тактовая частота. Однако в 2006 её увеличение вдруг перестало иметь смысл, и для повышения скорости снова потребовалось изменение архитектур.
В результате многоядерные процессоры обеспечили повышенное быстродействие за счёт увеличения количества (однородных) ядер в процессоре. В отличие от повышения частот получение дополнительного ускорения за счёт многоядерности потребовало перестройки ПО. Классическая статья Херба Саттера «The Free Lunch Is Over» акцентировала потребность в многопоточности. И хотя этот сдвиг был необходим, он существенно усложнил жизнь разработчикам программного обеспечения.
Затем появились ускорители, способные выполнять вычисления в дополнение к CPU. Наиболее успешными из них пока что являются графические процессоры (GPU). Изначально GPU были разработаны для разгрузки основного процессора путём снятия с него задачи по обработке графики, передаваемой на дисплей компьютера. Для задействования этой дополнительной вычислительной мощности появилось несколько программных моделей, но вместо отправки на дисплей их результаты передавались программе, выполняющейся на CPU. Сегодня «разнородные» процессоры в одной системе больше не являются равноценными. Тем не менее все популярные языки программирования обычно предполагают одно вычислительное устройство, поэтому, когда мы выбираем часть кода для выполнения на отдельном таком устройстве, то используем термин «разгрузка».
Несколько лет назад два легендарных представителя индустрии, Джон Хеннеси и Дэвид Паттерсон, заявили о вступлении в «A New Golden Age for Computer Architecture» (новый золотой век компьютерной архитектуры). Разнородные вычисления начинают набирать обороты благодаря появлению множества идей для специализированных под разные области процессоров. Некоторые эти тенденции ждёт успех, другие же обречены на провал, но суть в том, что сфера вычислений изменилась бесповоротно, поскольку больше не ориентирована на выполнение всех вычислений на одном устройстве.
Две ключевых сложности, преодолеваемые одним хорошим решением
И хотя сегодня популярна технология CUDA, она относится лишь к GPU Nvidia. Нам же нужны открытые решения для обработки целой волны новых архитектур ускорителей от различных вендоров. Программам, выполняющимся на разнородных платформах, необходим способ определения доступных в среде выполнения устройств. Им также нужен способ снять с этих устройств нагрузку.
CUDA игнорирует возможность обнаружения устройств, предполагая, что доступны только GPU Nvidia. Python-программистам для использования GPU с CUDA (Nvidia) или ROCm (AMD) доступна библиотека CuPy. Но хоть CuPy и является открытым решением, она не повышает быстродействие CPU и не охватывает других производителей или архитектуры. Нам бы больше подошла программа с возможностью использования на устройствах разных вендоров и поддержкой новых аппаратных решений. Однако, прежде чем радоваться разгрузке с помощью ускорителя, давайте убедимся, что получаем максимум от нашего CPU, так как понимание принципов использования параллелизма и скомпилированного кода также поможет разобраться с использованием многопоточности на ускорителях.
Numba является открытым JIT-компилятором кода Python и NumPy, разработанным Anaconda. Он может компилировать всевозможный численный код Python, включая многие функции NumPy. Numba также поддерживает автоматическое распараллеливание циклов, генерацию ускоряемого GPU кода, создание универсальных функций ( ufuncs ) и обратных вызовов Си.
В рамках проекта Numba компания Intel разработала автоматический параллелизатор, который включается установкой опции parallel=True в @numba.jit . Этот инструмент анализирует распараллеливаемые по данным области кода в скомпилированной функции и планирует их параллельное выполнение. Есть два типа операций, которые Numba может распараллеливать автоматически:
- Неявные распараллеливаемые области, такие как выражения массивов NumPy, NumPy ufunc и функции редукции NumPy.
- Явные циклы параллельных данных, определяемые с помощью выражения numba.prange .
def f1(a,b,c,N): for i in range(N): c[i] = a[i] + b[i]
Его можно сделать явно параллельным, изменив последовательный диапазон ( range ) на параллельный ( prange ) и добавив директиву njit :
@njit(parallel=True) def add(a,b,c,N): for i in prange(N): c[i] = a[i] + b[i]
В итоге время выполнения сократилось с 24.3 до 1.9 секунды, но результаты от системы к системе могут отличаться. Чтобы опробовать этот приём, клонируйте репозиторий с образцами oneAPI (git clone) и откройте блокнот AI-and-Analytics/Jupyter/Numba_DPPY_Essentials_training/Welcome.ipynb. Проще всего сделать это, зарегистрировав бесплатный аккаунт на Intel® DevCloud for oneAPI.
Дополнительное повышение быстродействия с помощью ускорителя
Ускоритель оказывается особенно эффективен, когда приложение загружено достаточным объёмом работы, чтобы её разгрузка оправдала необходимые для этого затраты. Первым шагом мы компилируем выбранные вычисления (фрагмент программы), чтобы их можно было разгрузить на другие устройства. Продолжая предыдущий пример, мы используем расширения Numba (numba-dpex) для обозначения разгружаемого фрагмента программы (подробнее читайте в Jupyter notebook training).
@dppy.kernel def add(a, b, c): i = dppy.get_global_id(0) c[i] = a[i] + b[i]
Фрагмент программы компилируется и распараллеливается так, будто ранее он использовал @njit для подготовки к выполнению на CPU, только на сей раз он подготавливается к переносу на устройство. Код компилируется в промежуточный язык (SPIR-V), который среда выполнения в установленный для его запуска момент сопоставляет с устройством. Это универсальное решение, которое позволяет переносить вычисления на ускоритель любого вендора.
Аргументами для фрагмента программы могут выступать массивы NumPy или массивы унифицированной разделяемой памяти (USM) (тип массивов, явно помещаемых в унифицированную разделяемую память), в зависимости от того, что больше соответствует нашим потребностям. Этот выбор повлияет на настройку данных и активацию фрагментов программы.
Далее мы задействуем решение C++ для открытого мультивендорного и мультиархитектурного программирования под названием SYCL, используя опенсорсную библиотеку dpctl (подробнее читайте в документации GitHub и «Interfacing SYCL and Python for XPU Programming»). Эта библиотека позволяет программам Python обращаться к устройствам SYCL, очередям и источникам памяти, а также выполнять операции с массивами/тензорами Python. Такой подход позволяет избежать изобретения новых решений, уменьшить потребность в освоении новых техник и повысить совместимость.
Подключение к устройству выполняется просто:
device = dpctl.select_default_device() print("Using device . ") device.print_device_info()
Дефолтное устройство можно установить с помощью переменной среды SYCL_DEVICE_FILTER , если мы хотим контролировать выбор устройства без изменения этой простой программы. Библиотека dpctl также поддерживает программное управление для анализа и выбора доступного устройства на основе свойств аппаратного обеспечения.
Фрагмент программы также можно активировать (перенести и выполнить) на устройстве буквально парой строк кода:
with dpctl.device_context(device): dpar_add[global_size,dppy.DEFAULT_LOCAL_SIZE](a,b,c)
Использование device_context приводит к тому, что среда выполнения создаёт все необходимые копии данных (наши данные по-прежнему находились в стандартных массивах NumPy). Кроме этого, dpctl предлагает возможность явного выделения USM-памяти для устройств и управления ею. Это особенно пригождается, когда при углублении в оптимизацию возникают сложности с поручением её обработки для стандартных массивов NumPy среде выполнения.
Асинхронность и синхронность
Написание кода в стиле Python легко поддерживается описанными выше синхронными механизмами. При этом, если мы готовы слегка изменить код, перед нами также открываются асинхронные возможности и сопутствующие им преимущества (сокращение или исключение задержек при перемещении данных и активации фрагментов кода). Подробнее об асинхронном выполнении можете узнать из примера кода в dpctl gemv.
А что насчёт CuPy?
Библиотека CuPy является NumPy/SciPy-совместимой библиотекой, позволяющей выполнять их код на платформах Nvidia CUDA или AMD ROCm. Однако серьёзные усилия, необходимые для повторной реализации CuPy под новые платформы, существенно мешают обеспечить мультивендорную поддержку в одной программе Python, так что две вышеупомянутых сложности она не решает. CuPy может выбирать только среди GPU-устройств с CUDA. При этом она не даёт возможности прямого контроля памяти, хотя и может автоматически выполнять её пулинг с целью сокращения числа вызовов cudaMalloc . При переносе выполнения кода она не позволяет выбирать для этого устройство и в случае отсутствия доступного GPU с CUDA даст сбой. Большую универсальность для приложения можно достичь за счёт использования более удобного инструмента, решающего указанные сложности, связанные с разнородностью устройств.
А что насчёт scikit-learn?
В целом программирование на Python отлично подходит для реализации вычислений, направленных на конкретные данные (методика «compute-follows-data»). Библиотека dpctl поддерживает тензоры, которые мы связываем с заданным устройством. Если мы в своей программе можем привести данные к тензору для устройства (например, dpctl.tensor.asarray(data, device="gpu:0") ), они будут ассоциированы с этим устройством и помещены на него. При использовании пропатченной версии scikit-learn, способной распознавать эти тензоры, задействующие такой тензор методы вычисляются на связанном устройстве автоматически.
Это прекрасный вариант использования динамической типизации в Python, позволяющий понять, где располагаются данные, и направить вычисления именно туда. Единственное, что изменяется в нашем коде Python – это место, где наши тензоры преобразуются в тензоры для устройства. Судя по текущим отзывам, мы ожидаем, что методика «compute-follows-data» станет наиболее популярной среди Python-программистов.
Открытость, мультивендорность и мультиархитектурность
Python может выполнять роль инструмента, объединяющего мощность аппаратного разнообразия и позволяющего задействовать потенциал надвигающегося «Кембрийского взрыва» в сфере ускорителей. Здесь стоит иметь ввиду использование распараллеливания данных через Numba совместно с dpctl и техниками «compute-follows-data» из scikit-learn, поскольку все эти решения не ограничены какими-либо вендорами или архитектурами.
При том, что Numba предоставляет отличную поддержку для NumPy, в перспективе будет нелишним рассмотреть дополнительные возможности для SciPy и прочих направлений программирования на Python. Фрагментация API массивов в Python подтолкнула к стандартизации этих API (подробнее читайте здесь) на фоне возникшего стремления разработчиков разделять рабочие нагрузки с устройствами, помимо CPU. Стандарт API массивов во многом помогает расширить область применения Numba и dpctl, а также их влияние. В NumPy и CuPy поддержка API массивов уже внедрена, и на очереди стоят dpctl с PyTorch. По мере охвата всё большего числа библиотек, реализация поддержки разнородных вычислений (ускорителей всех типов) становится всё доступнее.
Простого использования dpctl.device_context оказывается недостаточно в более сложном коде Python со множеством потоков или асинхронных задач (вот соответствующая тема на GitHub). Здесь будет лучше следовать политике «compute-follows-data», по крайней мере в сложном многопоточном коде Python. Этот подход может стать предпочтительным в сравнении с использованием device_context .
Перед нами есть масса возможностей по оптимизации способов повышения быстродействия Python. Все эти решения являются опенсорсными и на сегодня работают очень хорошо.
Дополнительные материалы
Самое лучшее обучение – это практика. Ну а чтобы было проще начать, ниже я привожу некоторые полезные вспомогательные источники информации.
Для Numba и dpctl есть 90-минутное видео, в котором разбираются описанные выше принципы: «Data Parallel Essentials for Python».
Ещё есть прекрасное видео «Losing your Loops Fast Numerical Computing with NumPy» от Джейка Вандерпласа (автора «Python Data Science Handbook»), где он рассказывает о способах эффективного использования NumPy.
Все описанные в данной статье решения для использования Python в сфере разнородных вычислений являются опенсорсными и по умолчанию включены в наборы инструментов Intel® oneAPI Base и Intel® AI Analytics. NumPy с поддержкой SYCL размещена на GitHub. Расширения компилятора Numba для выделения фрагмента программы и автоматического переноса соответствующей вычислительной нагрузки также находятся на GitHub. Что касается открытых инструментов для управления распараллеливанием данных, то по этой теме доступна документация на GitHub и исследовательская работа «Interfacing SYCL and Python for XPU Programming». Они позволяют программам Python обращаться к устройствам SYCL, очередям, памяти, а также выполнять операции с массивами/тензорами, используя ресурсы SYCL.
Помимо этого, в них реализована обработка исключений, включая асинхронные ошибки в коде устройств. Эти ошибки перехватываются при повторном выбрасывании в виде синхронных исключений соответствующими функциями-обработчиками. Это поведение обеспечивается генераторами расширений Python и хорошо описано в документации сообщества: Cython и Pybind11.
▍ P.S. от переводчика
Уважаемые читатели, поздравляю вас с наступающим Новым годом! По всей видимости, он будет не менее ярким, чем уходящий, и с учётом такого положения дел хочется пожелать всем конструктивной оптимизации жизни и успешной адаптации под быстроменяющиеся реалии. Пусть спутниками в этом процессе для вас станут стойкость, оптимизм и простая взаимная человеческая любовь, которой в нашей чувственной палитре мира стало явно нехватать. Балансируя на правильной смеси этих трёх компонентов, будет куда проще как преодолевать сложности, так и покорять новые вершины.
Отдельно хочу выразить признательность всем постоянным читателям, в особенности тем, кто помогает повышать качество публикуемых мной материалов с помощью объективной критики, рекомендаций и банального указания на ошибки. Я осознаю неполноту своих знаний и стремлюсь при каждой возможности их расширить. Спасибо вам и Всех Благ!

- Блог компании RUVDS.com
- Python
- GPGPU
Как ускорить приложения на Python
Рассказываем, как проанализировать и ускорить имеющийся код на Python, чтобы приложение работало так же быстро, как на C++.
На Python пишут как десктопные программы, так и высокопрофессиональные web-приложения. Он является интерпретируемым языком и благодаря этому можно использовать продвинутые инструменты. Например, интроспекцию и метапрограммирование.
Но Python накладывает и некоторые ограничения, одно из них — снижение скорости работы по сравнению с программами, написанными на компилируемых языках программирования (C++ и др).
Андрей Смирнов
Python-разработчик, преподаватель по финансовой грамотности в Московской школе программистов (МШП)
В статье я разберу интересный кейс, чтобы проанализировать и ускорить имеющийся код на Python.
Исходные данные (демо-приложение)
Сразу же скажу, что мы не будем погружаться в пучину хардкорной отладки и продираться сквозь десяток уровней вызовов функций в стеке и сложные алгоритмические конструкции. Причина проста: все методы, которые я покажу сегодня, прекрасно воспроизводятся на простом коде и после этого тиражируются на любые масштабные проекты.
А в качестве стартового кода мы возьмём задачу: имеется магазин, продающий определенные товары. Товар характеризуется тремя величинами: название, цена, валюта. Необходимо реализовать хранилище товаров, заполнить его некими товарами.
На языке Python такая задача решается быстро:
Сразу отмечу, что я взял достаточно большой размер списка с данными для того, чтобы программа выполнялась такое количество времени, которое позволит не искать дельту в тысячных долях секунды.
Профилирование
Казалось бы, необходимо оптимизировать код, но как понять, что именно необходимо менять? Для этого нужно собрать с приложения определённые метрики, показывающие, насколько хорошо оно работает.
Процесс сбора этих метрик называется профилированием приложения. Проводить процесс профилирования можно как по времени работы, так и по памяти.
Профилирование по времени
Сначала добавим в нашу программу измерение скорости её работы. Для этого в Python есть специальная функция time, находящаяся в одноименном модуле. Идея использования этой функции очень проста: мы изменяем текущее время в начале работы программы и в конце. Далее считаем дельту, которая будет являться длительностью работы программы.
И ещё несколько пунктов, которые обязательно нужно сказать про этот код:
- В ходе профилирования нет смысла измерять время работы кода, ответственного за ввод данных с клавиатуры, чтение из файла, получение данных из сетевого хранилища и т.д. Эти операции априори будут медленными из-за низкой скорости передачи данных по сравнению с аналогичной скоростью в передачи данных в ОЗУ компьютера. Если вы понимаете, что проблема низкой скорости кроется в коде ввода данных, тогда его нужно профилировать отдельно от основной программы.
- Одна и та же программа, запущенная два раза, практически никогда не выдаст идентичное время выполнения. Это происходит из-за того, что программа выполняется в операционной системе, в которой постоянно работают фоновые процессы. И чаще всего отключить все лишние процессы невозможно. В таком случае, чтобы минимизировать их влияние, достаточно всего лишь запустить программу многократно и посчитать среднее время выполнения (что и сделано в коде).
Этот код при запуске показал следующие тайминги:
Сразу можно заметить, что отклонение по времени доходит до половины секунды. Запускал код я на системе со следующей конфигурацией:
- Intel Core i7-7700HQ
- 16Gb RAM
- KUbuntu 22.04
Ещё немного про профилирование по времени и сразу же первая оптимизация
Если у вас “тормозит” программа, в которой сотни и тысячи строк кода и сама архитектура этого кода состоит их множества функций и классов, тогда использовать замер таймингов в том виде, в котором я написал выше, будет крайне неудобно.
Но эта проблема решаема с помощью встроенного в Python средства профилирования, идеально подходящего для такой ситуации — утилиты cProfile. Она способна не просто запустить код и рассчитать время его работы, но и рассчитать время работы каждого отдельного метода (включая даже низкоуровневые методы создания списков, выделения памяти, добавления объектов и т.д.).
Для того, чтобы запустить cProfile, не требуется менять код. Достаточно просто запустить программу на исполнение с подключением дополнительного модуля:
python3 -m cProfile main.py
В таком случае вся программа выполнится и после неё будет выведена детальная информация о времени выполнения каждой функции:
Сразу же есть две мысли:
- Наличие любого дополнительного профилировщика замедляет программу. Это происходит, потому что любой профилировщик добавляет свой исполняемый код, благодаря которому и собирается статистика выполнения. В результате этого среднее время выполнения нашей программы увеличилось с 2.46 до 3.26 секунд.
- Сразу же можно заметить, что больше всего раз вызывается метод list.append, который добавляет новый объект в список. И именно на этом месте появляется идея для оптимизации: если мы заранее знаем, что объектов будет добавляться именно три миллиона, что мешает нам создать заранее список такого размера?
Попробуем изменить код так, чтобы список создавался сразу:
Запустим его также с использованием cProfile. И что же мы видим?
Среднее время уменьшилось до 2.06 секунд, и это со включённым профилировщиком. А без него будет так вообще 1.66! И всё путём простейшей оптимизации.
Профилирование по памяти
Также сразу же добавим в наш код профилирование по памяти, так как очень интересно узнать “сколько же занимает в памяти три миллиона товаров”. Для подсчёта памяти будем использовать библиотеку pympler.
И такой код при размере хранилища в три миллиона товаров показал следующие результаты:
Размер хранилища: 176000864 байт или 167.85 мегабайт
Вы можете заметить, что я убрал из кода подсчёт по времени. Причина проста: pympler для подсчёта количества занимаемой памяти проходит по всем имеющимся структурам данных, и во время подсчёта скорость выполнения увеличивается раз в пять, делая профилирование по времени неоправданным.
А теперь, когда мы достаточно знаем о поведении нашего приложения (и во времени, и в памяти) – приступим к его последовательной оптимизации.
Способы оптимизации
Оптимизация структур данных
Начнём мы с несколько нестандартной оптимизации, а именно — залезем внутрь нашего объекта товара и основательно там покопаемся.
Сейчас объект представлен в виде обыкновенного класса. Давайте подумаем, а возможно ли здесь использовать какую-нибудь иную структуру данных, которая построена на основе класса, но имеет дополнительный функционал? И такая структура есть, она называется датакласс. Правда, сразу стоит оговориться, что обычный датакласс является небольшой надстройкой над обычным классом, в которой разработчики языка чётко указали, какие будут поля и какие они будут иметь типы данных. А нам будет интересен датакласс с фиксированными полями, в который невозможно добавить новые поля.
Почему это важно? Для того, чтобы иметь возможность добавлять и удалять поля в рантайме, в классах питона реализована структура словаря __dict__. А это, в свою очередь, далеко не всегда является необходимым функционалом.
Поэтому, если сформировать чёткую структуру данных (а чаще всего для хранения больших объёмов данных используются как раз жёстко определённые структуры), то после этого можно убрать функционал динамического добавления полей, и в таком случае объекты будут работать быстрее.
Реализуем эту идею (для этого определим кортеж __slots__).
Если этот код запустить и проверить время выполнения, то мы получим ускорение в среднем на 25 процентов
А если директиву __slots__ указать в коде, который мы профилировали по памяти, то результаты получатся ещё более сногсшибательными:
Размер хранилища: 80000504 байт или 76.29 мегабайт
То есть, путём отказа от динамического добавления элементов мы сразу уменьшили расходы памяти нашего приложения вдвое!
И на этом мы не остановимся.
Оптимизация интерпретатора
Следующая оптимизация, которая может помочь нам в достижении нашей цели — замена интерпретатора Python на интерпретатор PyPy.
Согласно определению из Википедии, PyPy – это интерпретатор языка Python, написанный на языке Python. Однако в него встроен трассирующий JIT-компилятор, способный преобразовывать код на Python в машинный код прямо во время выполнения программы. Эта особенность позволяет ему существенно ускорить процесс исполнения программы без каких либо изменений кода.
Установим pypy следующей командой:
sudo apt install pypy3
А после этого запустим код с его помощью:
pypy3 main.py
Результаты говорят сами за себя: скомпилированный код априори выполняется намного быстрее, нежели интерпретируемый код. Время исполнения уменьшилось ещё на 68%. И для такого запуска абсолютно не потребовалось менять исходный код.
Справедливости ради нужно заметить, что за счёт глубинной оптимизации некоторые сторонние библиотеки, которыми вы можете пользоваться, не смогут запуститься в pypy. И для них придётся искать аналоги. Но самые популярные библиотеки (такие как twisted, django, numpy, scikit-learn и другие) им полностью поддерживаются и работоспособны.
А как ещё можно оптимизировать?
В мире существуют и другие способы оптимизации, но они уже относятся к категории радикальных, подразумевающих кардинальное изменение структуры кода и (или) даже языка программирования. Среди них:
- изменение структуры хранимых данных со списка объектов на pandas.DataFrame.
- добавление строгой типизации и адаптация кода под компилятор cython
- распараллеливание программы на потоки при помощи Nvidia CUDA.
- И, наконец, если затраты от потерь производительности существенно превышают затраты от кардинальной переработки кода, можно попробовать переписать критичные части кода на языке C++ и оформить их в виде библиотеки, функции из которой можно запустить из Python-кода.
Итоги
Итак, в ходе нашего увлекательного путешествия мы
- написали код
- измерили его производительность (по памяти и по времени)
- оптимизировали его несколько раз
- результаты в виде графиков приведены ниже.
В каждом из случаев получилось улучшить измеряемый показатель производительности более чем в два раза, так что считаю, что цель достигнута.
Как ускорить python

Затем вы можете просто добавить префикс python -m cProfile и запустить его с профилировщиком:
$ python -m cProfile benchmark.py 1444992 function calls (1443814 primitive calls) in 1.208 seconds Ordered by: cumulative time ncalls tottime percall cumtime percall filename:lineno(function) 1 0.000 0.000 1.208 1.208 benchmark.py:7() 1 0.048 0.048 1.151 1.151 benchmark.py:21(run) 50000 0.031 0.000 0.657 0.000 _output.py:182(write) 50000 0.045 0.000 0.618 0.000 _output.py:73(send) 50000 0.064 0.000 0.566 0.000 _output.py:452(__call__) . a million more lines of output .
Существует также профилирование для Python API, поэтому вы можете профилировать определенные функции в запросе интерпретатора Python или в Jupyter notebook.
Выходной формат представляет собой таблицу, что не совсем удобно: каждая строка является результатом вызова функции, которая выполнялась в течение профилируемого промежутка времени, однако вы не знаете, как этот вызов связан с вызовами других функций. Поэтому, если у вас есть функция, которую можно вызвать с помощью нескольких путей выполнения кода, могут возникнуть сложности с определением, какой именно блок кода отвечает за вызов медленной функции.
Что cProfile может вам сказать
Если вы посмотрите на таблицу выше, то увидите, что:
- _output.py(__call__) вызвали 50 000 раз. У нас получилось четное число, потому что это тестовый скрипт, который запускает один и тот же код в цикле 10 000 раз. Если вы не вызывали функцию намеренно множество раз, то это может быть полезно для определения большого количества вызовов для выявления занятых внутренних циклов.
- _output.py(send) работал в течение 0.618 секунд (включая время, затраченное на вызываемые им функции) и 0.045 секунд (не считая вызываемых им функций).
Используя эту информацию, вы можете определить медленные функции, которые можно оптимизировать.
Как это работает
cProfile проводит измерение для каждого вызова функции . В частности, вызов функции во время ее выполнения выглядит следующим образом:
start = time() try: f() finally: elapsed = time() - start
Профилировщик записывает время в начале и в конце. Полученная разница является временем работы функции.
Проблемы с cProfile
Хотя cProfile всегда доступен при любой установке Python, он также имеет ряд существенных проблем.
Проблема №1: Высокие затраты вычислительных ресурсов и искаженные результаты
Как вы понимаете, выполнение дополнительной работы для каждого вызова функции требует определенных затрат:
$ python benchmark.py 7855 messages/sec $ python -m cProfile benchmark.py 5264 messages/sec . cProfile output .
Обратите внимание, насколько медленно работает cProfile. И что еще хуже, замедление происходит неравномерно для всей программы: поскольку оно связано с количеством вызовов функций, блоки вашего кода с большим количеством таких вызовов будут замедляться еще больше. Таким образом, эти накладные расходы могут исказить результат.
Проблема №2: Слишком много информации
Если вы помните, выходные данные cProfile, которые мы видели выше, содержат строку для каждой отдельной функции, которая была вызвана во время выполнения программы. Большинство данных вызовов функций не имеют отношения к нашей проблеме с производительностью: они выполняются быстро и только один или два раза.
Поэтому, когда вы читаете выходные данные cProfile, вы имеете дело с большим количеством шума, который мешает распознать сигнал.
Проблема № 3: Измерение результатов работы на локальном компьютере
Довольно часто ваша программа будет работать медленно только в реальных условиях с реальными входными данными. Возможно, только определенные запросы пользователей замедляют работу вашего веб-приложения, и вы не знаете какие именно. Возможно, ваш командный файл работает медленно только с реальными данными.
Но cProfile, как мы видели, немного замедляет вашу программу, и поэтому вы, вероятно, не захотите запускать его в своем рабочем проекте. Таким образом, в то время как низкая скорость работы появляется только при работе с реальными проектами, cProfile может быть полезен только на стадии разработки.
Проблема № 4: Производительность измеряется только для функций
cProfile может сообщить вам о том, что « slowfunc() работает медленно», когда он усредняет все входные данные для этой функции. И это нормально, если функция сама по себе всегда медленная.
Но иногда у вас есть алгоритмический код, который работает медленно только при определенных входных данных. Вполне возможно, что:
- slowfunc(100) выполняется быстро.
- slowfunc(0) выполняется медленно.
cProfile не сможет сказать вам, какие входные данные вызвали замедление работы, что может затруднить диагностику проблемы.
cProfile: Его, как правило, недостаточно
Обобщая все недостатки, cProfile не должен быть вашим предпочтительным инструментом для повышения производительности. Поэтому далее мы поговорим о его двух альтернативах:
- Pyinstrument решает проблемы №1 и №2.
- Eliot решает проблемы №3 и №4.
2. Pyinstrument: Профилировщик выборки
Pyinstrument решает две проблемы, которые мы рассмотрели выше, а именно:
- Затраты вычислительных ресурсов меньше, чем у cProfile, при этом результаты не искажаются.
- Он отфильтровывает ненужные вызовы функций, поэтому шума становится меньше.
Pyinstrument измеряет системное время, а не процессорное, поэтому он также может фиксировать замедление, вызванное сетевыми запросами, записью на диск, конфликтом блокировок и т. д.
Как вы можете его использовать
Использование Pyinstrument аналогично cProfile — просто добавьте префикс к вашему скрипту:
$ python benchmark.py 7561 messages/sec $ python -m pyinstrument benchmark.py 6760 messages/sec . pyinstrument output .
Обратите внимание, что у него есть некоторые накладные расходы, но не такие большие, как у cProfile, при этом затраты вычислительных ресурсов распределены равномерно.
Pyinstrument также поддерживает API Python, так что вы можете использовать его для профилирования определенных фрагментов кода в интерактивном интерпретаторе Python или блокноте Jupyter.
Выходные данные
На выходе Pyinstrument представляет собой дерево вызовов, измеряющее системное время:
1.506 benchmark.py:7 ├─ 1.477 run benchmark.py:21 │ ├─ 0.604 attach myapp/_code.py:52 │ │ └─ 0.598 write myapp/_code.py:130 │ │ ├─ 0.557 final_write myapp/_foo.py:181 .
В отличие от построчной функции cProfile, Pyinstrument дает вам дерево вызовов функций, поэтому вы можете получить информацию о скорости работы. Функция может появляться несколько раз, если низкая скорость работы вызвана несколькими путями выполнения кода.
В результате вывод Pyinstrument намного проще интерпретировать, ведь он предоставляет более подробное понимание структуры производительности вашей программы, чем вывод cProfile по умолчанию.
Как это работает (кошачья версия)
Представьте, что у вас есть кошка. Вы хотите знать, как эта кошка проводит свое время.
Вы могли бы непрерывно шпионить за ней, но это был бы весьма трудоемкий процесс. Поэтому вместо этого вы решаете периодически делать «замеры»: каждые 5 минут вы заглядываете в комнату, где находится кошка, и записываете, что она делает.
- 12:00: Спит
- 12:05: Спит
- 12:10: Ест
- 12:15: Использует лоток
- 12:20: Спит
- 12:25: Спит
- 12:30: Спит
Через несколько дней можно подвести итоги своих наблюдений:
- 80%: Спит
- 10%: Ест
- 9%: Использует лоток
- 1%: С тоской смотрит в окно на птиц
Итак, насколько точно это наблюдение? Поскольку ваша цель — изучить, где кошка проводит большую часть своего времени, информационная сводка, вероятно, будет точной. И чем чаще проводятся наблюдения (==измерения) и чем больше наблюдений вы сделаете, тем точнее будет сводка.
Если ваша кошка проводит большую часть своего времени во сне, вы ожидаете, что большинство выборочных наблюдений покажут, что она спит. И да, вы пропустите некоторые быстрые и редкие действия, но для целей «на что кошка тратила большую часть своего времени» эти быстрые и редкие действия не имеют значения.
Как это работает (программная версия)
Как и наш кот, Pyinstrument периодически наблюдает за поведением Python-программы: каждую 1 мс он проверяет, какая функция запущена в данный момент. Это означает:
- Если функция кумулятивно медленная, она будет появляться часто.
- Если функция кумулятивно быстрая, мы, как правило, ее вообще не увидим.
Это означает, что в нашей сводке производительности меньше шума: функции, которые почти не используются, в основном будут отсутствовать. Но в целом сводка довольно точна с точки зрения того, как программа потратила свое время, если мы произвели достаточное количество записей наблюдений.
3. Eliot: Библиотека логирования
Последним инструментом, который мы подробно рассмотрим, является Eliot , написанная мной библиотека логирования. Она решает оставшиеся две проблемы, которые мы видели в cProfile:
- Логирование можно использовать в продакшене.
- Логирование может записывать аргументы для функций.
Как вы видите, Eliot предоставляет некоторые уникальные возможности, которые делают лучше его запись производительности, чем у обычных библиотек логирования. При этом, проделав некоторую дополнительную работу, вы можете получить те же преимущества и от других библиотек ведения логов.
Добавление логирования в существующий код
Рассмотрим следующий набросок программы:
def add(a, b): # . implementation . def multiply(a, b): # . implementation . def multiplysum(a, b, c): return multiply(add(a, b), c) print(multiplysum(1, 2, 4))
Мы можем взять этот код и добавить к нему логирование:
from eliot import log_call, to_file to_file(open("out.log", "w")) @log_call def add(a, b): # . implementation . @log_call def multiply(a, b): # . implementation . # etc. Specifically, we do two
В частности, мы делаем две вещи:
- Сообщаем Eliot куда выводить сообщения журнала (в данном случае это файл с именем out.log ).
- Добавляем декоратор функции @log_call . Это позволяет зарегистрировать факт вызова функции, ее аргументы и возвращаемое значение (или возникшее исключение).
У Eliot есть другие, более детализированные API, но @log_call достаточно для демонстрации преимуществ ведения журнала логов.
Выходные данные Eliot
Как только мы запустим программу, мы сможем просмотреть журналы с помощью инструмента под названием eliot-tree :
$ python calc.py 12 $ eliot-tree out.log ─── multiplysum (inputs a=1 b=2 c=4) ⧖ 10.0s ├── add (inputs a=1 b=2) ⧖ 1.0s │ └── result: 3 ├── multiply (inputs a=3 b=4) ⧖ 9.0s │ └── result: 12 └── result: 12
Обратите внимание на сходство с Pyinstrument – дерево действий . Я немного упростил вывод — изначально так, чтобы он поместился на слайде, который я использовал в статье для доклада, — но даже в этой версии этого достаточно, чтобы оценить производительность.
У Eliot каждое действие имеет начало и конец и может запускать другие действия, в результате чего получается дерево. Поскольку мы знаем, когда начинается и заканчивается каждое зарегистрированное действие, мы также знаем, сколько времени оно заняло.
В этом случае каждое действие сопоставляется с вызовом функции в отношении один к одному. И есть некоторые отличия от вывода Pyinstrument:
- Вместо объединения нескольких вызовов функций вы видите каждый вызов отдельно от других.
- Вы можете видеть аргументы и возвращаемый результат каждого вызова.
- Вы можете увидеть прошедшее системное время для каждого действия.
Например, вы можете видеть, что multiplysum() заняло 10 секунд, но больше всего времени было потрачено на multiply() , с входными данными 3 и 4. Таким образом, вы сразу знаете, что для оптимизации производительности нужно сосредоточиться на multiply() , и у вас есть некоторые начальные входные данные (3 и 4), чтобы поиграть ими и посмотреть на изменения.
Ограничения ведения журнала
Логирование само по себе не является достаточным источником информации о производительности.
Во-первых, вы получаете информацию только из кода, в который вы явно добавили вызовы ведения журнала. Профилировщик может работать с любым произвольным фрагментом кода без предварительной подготовки, но с логированием вам придется проделать некоторую работу заранее.
Если вы не добавили код ведения журнала, вы не получите никакой информации. Eliot делает это немного лучше, поскольку структура дерева действий дает вам некоторое представление о том, где было потрачено время, но этого все же недостаточно, если логирование слишком редкое.
Во-вторых, вы не можете добавлять логирование везде, потому что это замедлит вашу программу. Ведение журнала влечет за собой накладные расходы — это требует больше затрат, чем у cProfile. Поэтому вам нужно добавлять его разумно, в ключевые моменты, где он будет максимизировать информацию, которую он дает, не влияя на производительность.
4. Правильный выбор инструментов
Итак, когда вы должны использовать каждый инструмент?
Всегда добавляйте ведение журнала
Любая нетривиальная программа, вероятно, нуждается в некотором логировании, хотя бы для того, чтобы отлавливать баги и ошибки. И если вы уже добавляете ведение журнала, вы также можете позаботиться о том, чтобы регистрировать информацию, необходимую для отладки производительности.
Eliot делает это проще, поскольку регистрация действий по своей сути предоставляет вам информацию о затраченном время. Однако, приложив усилия, вы можете сделать это для любой библиотеки логирования.
После этого используйте профилировщик выборки, такой как Pyinstrument.
Ведение журнала может помочь вам определить конкретное место, где ваша программа работает медленно, и, по крайней мере, некоторые входные данные, вызывающие замедления, но этого часто недостаточно. Итак, следующим шагом будет использование профилировщика, в частности, профилировщика выборки, такого как Pyinstrument:
- Имеет низкие накладные расходы и, что более важно, не искажает результаты.
- Измеряет системное время, поэтому можете не беспокоиться, что CPU является «узким местом».
- Выводит только медленные функции, опуская нерелевантные быстрые функции.
Используйте cProfile, если вам нужна индивидуальная метрика затрат
Если вам когда-нибудь понадобится написать собственный профилировщик, cProfile позволит вам подключить различные функции затрат , что делает его простым инструментом для измерения более необычных показателей.
Вы можете измерить:
- Только CPU.
- Не только CPU, но и все время, потраченное на ожидание событий, не связанных с CPU.
- Количество добровольных переключений контекста, т.е. количество системных вызовов, которые занимают много времени.
- Распределение памяти.
- В более широком смысле, также можно произвести замеры любого увеличивающегося счетчика.
5. TL;DR
В качестве хорошей отправной точки для работы с инструментами оптимизации производительности я предлагаю вам следующий алгоритм:
- Зарегистрируйте ключевые входы и выходы, а также прошедшее время ключевых действий, используя Eliot или какую-либо другую библиотеку регистрации.
- Используйте Pyinstrument или другой профилировщик выборки в качестве профилировщика по умолчанию.
- Используйте cProfile, если вам нужен собственный профайлер.
Из этой статьи мы узнали:
- Что собой представляют cProfile, Pyinstrument и Eliot.
- Обсудили их плюсы и минусы.
- Узнали как лучше комбинировать работу профилировщиков.
Материалы по теме
- Медлительность Python: причины проблем со скоростью
- Сравниваем скорость С++ и Python на примере стереозрения в OpenCV на Raspberry Pi
