Объедините простоту Python со скоростью C

Основное преимущество Python заключается в том, что он очень удобен для разработчиков и прост в освоении. Однако у этих вариантов дизайна есть серьезный недостаток; они заставляют Python выполняться значительно медленнее, чем некоторые другие языки. Эта статья покажет вам, как съесть свой торт и получить его тоже. Мы собираемся устранить узкое место в нашем коде Python и использовать Cython, чтобы чрезвычайно ускорить его. Я настоятельно рекомендую прочитать эту статью, прежде чем продолжать получать четкое представление о проблеме, которую мы пытаемся решить. Cython поможет нам:

  • используйте синтаксис, подобный Python, для написания кода, который Cython затем сгенерирует C-код. Нам не придется писать на C самим
  • скомпилировать C-код и упаковать его в модуль Python, который мы можем импортировать (так же, как import time)
  • повысить скорость выполнения нашей программы ›70x
  • похвастаться перед коллегами нашим сверхбыстрым кодом

Я разделил эту статью на 4 части: во-первых, мы устанавливаем зависимости и настраиваем в части A. Затем, в части B, мы просто сосредоточимся на том, чтобы получить код Cython для запуска Python. Как только это будет сделано, мы оптимизируем наш код Cython в части C, используя удобный встроенный инструмент, который точно скажет вам, где находятся узкие места в вашем коде. Затем, в части D, мы выжмем последний бит скорости за счет многопроцессорности нашего модуля, что приведет к более чем 1,7 миллиардам вычислений в секунду!
Давайте программировать!

Используя Cython для создания модуля Python и многопроцессорной обработки полученной функции, мы увеличили скорость выполнения с 25 тысяч э/мс до 1,75 млн э/мс. Это увеличение скорости в 70 раз!

Прежде чем мы начнем

Создание пакета Cython имеет ряд огромных преимуществ, но требует немного больше усилий, чем обычное программирование на Python. Подумайте о следующем, прежде чем Cythonize каждую последнюю строку вашего кода.

  • Убедитесь, что ваш код работает медленно по правильной причине.
    Мы не можем написать код, который ждет быстрее. Я рекомендую сначала просмотреть эту статью, потому что она объясняет, почемуPython медленный, как он работает внутри и как вы можете обойти определенные узкие места. Таким образом, вы лучше поймете, как Cython является очень хорошим решением для нашей медлительности.
  • Является ли параллелизм проблемой?
    Можно ли решить вашу проблему с помощью использования потоков (например, ожидания API)? Может быть, параллельное выполнение кода на нескольких процессорах поможет вам ускорить работу за счет многопроцессорности?
  • Вы хороший программист на C или просто интересуетесь, как Python и C работают вместе? Прочтите эту статью о том, как модули Python пишутся на C (как Python использует C-код).
  • Обязательно используйте виртуальную среду. Cython не требует этого, но это лучшая практика.

Часть A — Установка и настройка

Установка очень проста.

pip istall Cython

Чтобы продемонстрировать, как Cython может ускорить выполнение ресурсоемких задач, воспользуемся простым примером: мы собираемся Cythonize функцию, которая подсчитывает количество простых чисел в заданном диапазоне. Код Python для этого выглядит так:

Обратите внимание, что это вряд ли самый эффективный способ вычисления простых чисел, но сейчас это не важно. Нам просто нужна функция, которая много вычисляет.

Часть B. Создание, упаковка и импорт

Во-первых, мы собираемся создать очень простую функцию Cython, которая очень похожа на ту, которую мы написали на Python. Цель этой части:

  1. создать функцию
  2. скомпилировать и упаковать C-код в модуль Python
  3. импортировать и использовать нашу функцию.

Далее мы оптимизируем функцию для достижения умопомрачительной скорости.

1. Создание функции Cython

Давайте создадим новый файл с именем primecounter.pyx и:

  • скопируйте функцию prime_count_vanilla_range из предыдущей части в файл
  • Переименуйте функцию, которую мы только что вставили, в prime_counter_cy.

Сейчас мы просто запустим код Python в Cython. Это возможно, потому что Cython является расширенным набором Python; все, что вы можете сделать на Python, вы можете сделать и на Cython.

Простое копирование функции уже должно дать хорошее ускорение, потому что код уже скомпилирован. Однако, прежде чем мы сможем это проверить, мы должны получить код в Python с помощью модуля.

2. Компиляция и упаковка в пакет Python

Следующим шагом будет указание Cython взять pyx-файл, скомпилировать его в C и поместить этот код в модуль Python, который мы можем импортировать и использовать в нашем коде Python. Для этого нам понадобится простой setup.py скрипт, определяющий, что и как мы хотим упаковать. Вот как это выглядит:

Возможно, вы знакомы со сценарием setup.py: он используется при создании собственного пакета Python. Подробнее о создании собственного пакета здесь (общий пакет) и здесь (частный пакет)».

Мы просто определяем список расширений и передаем его функции setup. В Extension мы даем нашему модулю имя. Таким образом, мы можем import primes, а затем primes.prime_counter_cy(0, 1000) позже. Сначала мы создадим и установим модуль. Код ниже действует как pip install primes:

python setup.py build_ext --inplace

Вы также можете использовать CythonBuilder для компиляции, сборки и упаковки кода Cython; посмотрите здесь.

Устранение неполадок
Cython скомпилирует pyx в C-файл, который мы включим в модуль. Для этого процесса компиляции ему нужен компилятор. Если вы получаете сообщение типа Microsoft Visual C++ 14.0 or greater is required, это означает, что у вас нет компилятора. Вы можете решить эту проблему, установив инструменты сборки C++, которые можно скачать здесь.

3. Импорт и использование нашей функции

Теперь, когда наш модуль скомпилирован, упакован и установлен, мы можем легко импортировать и использовать его:

import primes
print(primes.prime_counter_cy(0, 1000))   
# >> will correctly print out 168

Когда мы измеряем функцию Python и функцию Cython, мы уже видим хорошее ускорение:

Finding primes between 0 and 1000
Total number of evaluations required = 78 thousand
[py]     2.92ms (25k per ms)
[cy]     1.58ms (42k per ms)

Несмотря на то, что мы просто скопировали функцию и не потратили ни секунды на ее оптимизацию, она все равно работает быстрее именно потому, что код скомпилирован.

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

Теперь начинается самое интересное: давайте начнем оптимизацию и посмотрим, сколько скорости мы сможем выжать из нашей машины!

Часть C — Оптимизация нашей функции Cython

Мы собираемся настроить функцию в файле pyx, чтобы ускорить ее. Давайте сначала посмотрим на результат, чтобы мы могли его пройти.

Мы добавили типы для переменных и для самой функции. Многие из этих дополнений позволяют C скомпилировать нашу программу, чтобы мы не увязали в интерпретаторе Python. Ознакомьтесь с этой статьей для получения дополнительной информации о том, почему интерпретатор так сильно замедляет выполнение, как его ускорить (спойлер: написание модуля Cython — одна из них!)

Когда переменная в Cython не типизирована, мы возвращаемся к тому, как Python обрабатывает переменные; проверка каждого из них с помощью интерпретатора и сохранение их в PyObject (опять же, ознакомьтесь с статьей). Это очень медленно, поэтому, вводя наши переменные, мы позволяем C обрабатывать их, что невероятно быстро.

Добавление типов

В строке 1 мы определяем prime_counter_cy как функцию типа cpdef. Это означает, что и Python, и C могут получить доступ к функции. В строке 1 пишем int range_from. Таким образом, компилятор знает, что тип данных range_from является целым числом. Поскольку мы знаем, каких данных ожидать, мы избегаем множества проверок. То же самое происходит в строке 3, где мы задаем целое число с именем prime_count. В двух строках ниже мы определяем num и divnum. Особенность этих двух целых чисел заключается в том, что они еще не имеют значения, их значение устанавливается только в строках 7 и 8.

Простое добавление типов значительно увеличило производительность. Проверьте это:

Finding primes between 0 and 50k
Total number of evaluations required = 121 million
[py]        4539ms ( 27k /ms)
[cy]        2965ms ( 41k /ms)
[cy+types]   265ms (458k /ms)

Мы переходим от чуть более 4,5 секунд к четверти секунды. Это увеличение скорости в 17 раз, просто добавление некоторых типов.

Дальнейшая оптимизация с использованием аннотаций

Все наши переменные определены; как мы можем оптимизировать дальше? Помните setup.py? В строке 8 (см. выше) мы вызвали функцию cythonize с помощью annotate=True. Это создает файл HTML в том же каталоге, что и наш файл pyx.

Когда мы открываем этот файл в браузере, мы видим наш код, отмеченный желтыми линиями, которые показывают, насколько близка строка кода к Python. Ярко-желтый означает, что он очень похож на Python (читай: медленный), а белый означает, что он ближе к C (быстрый). Вот как это выглядит, когда мы открываем наш primecounter.html в браузере:

На изображении выше вы можете видеть, что добавление типов делает для кода. Вы также можете нажать на каждую строку, чтобы увидеть полученный C-код. Щелкнем по строке 28, чтобы увидеть, почему она не полностью белая.

Как вы можете видеть на изображении выше, Python проверяет ZeroDivisionError. Я не думаю, что это необходимо, потому что диапазон, который вызывает divnum, начинается с 2.

Избегайте ненужных проверок

Так что давайте оптимизировать дальше! Мы добавляем в нашу функцию декоратор, который говорит компилятору избегать проверки ZeroDivisionError. Делайте это только тогда, когда вы очень уверены в своем коде, потому что избегание проверок означает дополнительные риски сбоя вашей программы:

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

Ознакомьтесь с нашими аннотациями:

Finding primes between 0 and 50k
Total number of evaluations required = 121 million
[py]        4539ms ( 27k /ms)
[cy]        2965ms ( 41k /ms)
[cy+types]   265ms (458k /ms)
[cy-check]   235ms (517k /ms)

Эта небольшая директива еще больше улучшила время выполнения!

Часть D — Еще больше скорости

Так что теперь наша функция довольно оптимизирована, она почти полностью работает в машинном коде. Как мы можем выжать еще больше скорости? Если вы читали эту статью, у вас может возникнуть идея. Наш код по-прежнему работает на одном ЦП, в то время как у моего ноутбука их 12, так почему бы не использовать больше?

Приведенный выше код создает ProcessPool (опять же, прочитайте эту статью), который разделит все задания на все доступные ЦП. В строке 3 мы используем функцию для деления начального и конечного числа на количество рабочих. Если мы хотим от 0 до 100 с 10 рабочими, это генерирует от 0 до 9, от 10 до 19, от 20 до 29 и т. д.

Затем мы создаем задание, отправляя его исполнителю пула потоков. В последней записи мы извлекаем результат каждого задания и суммируем результаты, чтобы получить количество простых чисел.

Стоит ли вкладываться в настройку?

Как объясняется в этой статье, применение нескольких процессов требует небольших вложений; требуется некоторое время, прежде чем процессы будут созданы. Если сравнить нашу новую многопроцессорную функцию с обычной, она будет еще медленнее, когда мы проверим первые 10 тысяч чисел:

Finding primes between 0 and 10k
Total number of evaluations required = 5.7 million
[py]       246ms ( 23k /ms)
[cy]       155ms ( 37k /ms)
[cy+types]  14ms (423k /ms)
[cy-check]  12ms (466k /ms)
[cy-mp]    201ms ( 29k /ms)

Да, это совсем не быстро. Давайте посмотрим, что произойдет, когда мы проверим первые 50 000 номеров:

finding primes between 0 and 50k
Total number of evaluations required = 121 million
[py]       4761ms ( 25k /ms)
[cy]       3068ms ( 40k /ms)
[cy+types]  304ms (399k /ms)
[cy-check]  239ms (508k /ms)
[cy-mp]     249ms (487k /ms)

Обратите внимание, что мы не компенсируем затраты на настройку процессов за счет увеличения скорости вычислений.

Финальный тест

Для финального теста мы собираемся найти все простые числа от 0 до 200k. Обратите внимание, что мы уже ждем несколько секунд для первых двух методов. Также обратите внимание, что увеличение нашего диапазона экспоненциально увеличивает общее количество необходимых вычислений. По этой причине мы сравним только методы Cython:

finding primes between 0 and 200k
Total number of evaluations required = 1.7 billion
[cy+types]  3949ms ( 433k /ms)
[cy-check]  3412ms ( 502k /ms)
[cy-mp]      978ms (1750k /ms)

И вот мы видим наш результат; мы выполняем 1,75 миллиона вычислений в миллисекунду. Обратите внимание, что количество реальных операций еще больше!

Используя Cython для создания модуля Python и многопроцессорной обработки полученной функции, мы увеличили скорость выполнения с 25 тыс. э/мс до 1,75 млн э/мс. Это увеличение скорости в 70 раз!

Заключение

В этой статье я надеялся показать, что вы можете расширить свой код Python с помощью небольшого количества Cython, чтобы добиться невероятного увеличения скорости, сочетая простоту кодирования на Python с невероятной скоростью скомпилированного C. Хотите

Я надеюсь, что все было так ясно, как я надеюсь, но если это не так, пожалуйста, дайте мне знать, что я могу сделать, чтобы уточнить дальнейшие вопросы. А пока ознакомьтесь с моими другими статьями на всевозможные темы, связанные с программированием, например:

Удачного кодирования!

— Майк

P.S. Нравится, что я делаю? Следуй за мной!