Производительность Си — в программах на Питоне.
Питон — простой, но мощный язык, который заслуженно стал одним из самых популярных. Тем не менее, иногда ему не хватает скорости статически типизированных языков с предварительной компиляцией, таких как Cи и Джава.
Почему Питон — медленный?
Как известно, код на Питоне обычно выполняется интерпретатором, а это часто очень медленный процесс — если сравнивать с Джавой и Си, в которых исходный код компилируется в машинный или байт-код (к сожалению, тема компиляции выходит за рамки статьи).
Как ускорить код на Питоне?
Обычно производительности Питона достаточно — если не приходится выполнять «тяжелые» вычисления, в случае которых как раз и могут пригодиться расширения на Cи.
Расширения — это возможность написать функцию (на Cи), скомпилировать ее в модуль Питона и использовать в исходном коде как обычную библиотеку.
Многие популярные модули написаны на Си или Cи++ (например, numpy, pandas, tensorflow и т. д.) — для повышения производительности и (или) расширения низкоуровневой функциональности.
О чем важно знать:
Расширения на Си работают только на реализации Cpython, но поскольку по умолчанию используется именно она, проблемы в этом быть не должно.
Для применения этого подхода рекомендуется иметь базовые знания Си. Но если вы знаете только Питон, статья тоже будет вполне вам понятна.
Как писать расширения на Си
В качестве примера реализуем классическую функцию fib(n)
, которая принимает число n и возвращает соответствующее число в последовательности Фибоначчи, и сравним производительность версий на Питоне и Си.
Прежде всего нам понадобится API для Питона, Python.h
— заголовочный файл Си, который содержит всё необходимое для взаимодействия с Питоном.
Установка API:
На Линуксе обычно нужно установить пакет
python-dev
илиpython3-dev
(если он еще не установлен). (В некоторых дистрибутивах название пакета может отличаться.)В стандартной установке Windows Питон по умолчанию уже есть.
В macOS Питон тоже должен быть установлен — если это не так, запустите
brew reinstall python
.
Теперь откройте любой редактор кода и создайте файл модуля Си. Рекомендуется следовать соглашению об именовании (получится что-то вроде module_name.c
), но в целом можете называть как хотите. В статье наш модуль будет называться c_module.c
.
Прежде чем писать код расширения, необходимо включить пару основных определений и объявлений:
// This definition is needed for future-proofing your code
// see https://docs.python.org/3/c-api/arg.html#:~:text=Note%20For%20all,always%20define%20PY_SSIZE_T_CLEAN.
#define PY_SSIZE_T_CLEAN
// The actual Python API
#include <Python.h>
Эти строки рекомендуется поместить в начало файла — для обеспечения совместимости.
В Питоне всё является объектом, поэтому наша функция c_fib(n)
тоже должна возвращать объект, а именно указатель PyObject
(определенный в Python.h
).
// pure C function that will be called recursively
int fib(int n)
{
if (n <= 1)
return n;
return fib(n-1) + fib(n-2);
}
// function that will be called from Python code
// wraps around the pure C fib function
PyObject* c_fib(PyObject* self, PyObject* args)
{
int n;
PyArg_ParseTuple(args, "i", &n);
n = fib(n);
return PyLong_FromLong(n);
}
После этого необходимо объявить, какие функции экспортировать из модуля, чтобы они были доступны из Питона.
// array containing the module's methods' definitions
// put here the methods to export
// the array must end with a {NULL} struct
PyMethodDef module_methods[] =
{
{"c_fib", c_fib, METH_VARARGS, "Method description"},
{NULL} // this struct signals the end of the array
};
// struct representing the module
struct PyModuleDef c_module =
{
PyModuleDef_HEAD_INIT, // Always initialize this member to PyModuleDef_HEAD_INIT
"c_module", // module name
"Module description", // module description
-1, // module size (more on this later)
module_methods // methods associated with the module
};
// function that initializes the module
PyMODINIT_FUNC PyInit_c_module()
{
return PyModule_Create(&c_module);
}
Определение методов модуля
Каждый экспортированный метод представляет собой структуру, содержащую:
Имя экспортируемого метода (у нас это «
c_fib
»).Фактически экспортируемый метод (
c_fib
).Тип принимаемых методом аргументов (у нас —
METH_VARARGS
). Из документации поMETH_VARARGS
: «Это типичное соглашение о вызовах с методами типаPyCFunction
. Функция ожидает два значенияPyObject*
. Первый — это объект self для методов; для функций модуля это объект модуля. Второй параметр (часто его называютargs
) — объект кортежа, представляющий все аргументы».И
const char*
с описанием метода.
Определение модуля
Модуль представлен в виде структуры (см. код выше). Код должен быть вполне понятен — вопросы может вызвать разве что аргумент m_size, для которого мы задали значение -1
. Выдержка из документации:
Значение
-1
для аргументаm_size
означает, что модуль не поддерживает субинтерпретаторы, потому что имеет глобальное состояние.
Функция инициализации модуля
При импорте модуля вызывается PyMODINIT_FUNC
и инициализирует его. Обратите внимание, что имя функции должно начинаться с PyInit_
и заканчиваться именем модуля — то есть, в нашем примере это будет PyInit_c_module()
.
Здесь описаны лишь несколько возможностей API для Питона — подробнее можно почитать на странице документации.
Компиляция расширения в модуль
После написания кода на Си нужно скомпилировать его в модуль для Питона — к счастью, для этого есть множество встроенных инструментов.
Создайте скрипт на Питоне (по традиции — setup.py
) со следующим кодом:
# import tools to create the C extension
from distutils.core import setup, Extension
module_name = 'c_module'
# the files your extension is comprised of
c_files = ['c_module.c']
extension = Extension(
module_name,
c_files
)
setup(
name=module_name,
version='1.0',
description='The package description',
author='Nicholas Obert',
author_email='nchlsuba@gmail.com',
url='https://my.web.site/some_page',
ext_modules=[extension]
)
Скрипт обладает множеством возможностей, но мы будем использовать только команды build
и install
. Подробнее смотрите в документации или в выводе с флагом «help»:
python3 setup.py --help
В командной строке выполните следующее:
python3 setup.py build
В результате появится каталог с именем build
, внутри которого будут скомпилированные библиотеки. После завершения работы команды выполните:
python3 setup.py install
В систему будут установлены только что собранные библиотеки, и ими можно будет пользоваться откуда угодно.
Для этой команды могут понадобиться права администратора или суперпользователя (root). Можно не выполнять установку для всей системы, но в этом случае для использования расширения придется задействовать относительный импорт.
Использование расширения в программе на Питоне
В файле Питона импортируйте только что созданный модуль, используя выбранное имя — в нашем случае это c_module
:
import c_module
print(c_module.c_fib(5))
# output: 5
Как видите, расширение используется так же, как и любой другой модуль.
Сравнение с версией на «чистом» Питоне
Теперь сравним функцию c_fib
с ее аналогом на Питоне. Воспользуемся встроенным модулем time
:
import c_module
from time import time
# Python fib version using recursion
def py_fib(n):
if (n <= 1):
return n
return py_fib(n-1) + py_fib(n-2)
n = 5
# C test
t = time()
c_res = c_module.c_fib(n)
c_time = time() - t
# Python test
t = time()
py_res = py_fib(n)
py_time = time() - t
print(f'Input: {n}\n{py_res=}, {py_time=}\n{c_res=}, {c_time=}')
Вывод:
Input: 5
py_res=5, py_time=5.245208740234375e-06
c_res=5, c_time=1.6689300537109375e-06
Как и ожидалось, функция на Си работает быстрее.
На различных компьютерах время исполнения будет различаться, но версия на Си всегда будет быстрее.
А теперь попробуем на больших числах:
Input: 10
py_res=55, py_time=5.245208740234375e-05
c_res=55, c_time=2.6226043701171875e-06
Input: 30
py_res=832040, py_time=0.40490126609802246
c_res=832040, c_time=0.004115581512451172
Input: 40
py_res=102334155, py_time=50.17047834396362
c_res=102334155, c_time=0.4414968490600586
Версия Си явно превосходит версию на Питоне в случае больших чисел. Если нужно выполнить несколько простых вычислений, то использование Си вряд ли будет оправданно, поскольку разница в производительности будет минимальной. Но если у вас трудоемкая операция или функция, которую необходимо выполнять много раз, скорости Питона может быть недостаточно.
И здесь расширения на Си могут здорово выручить: так вы поручите всю тяжелую работу производительному языку, а в качестве основного продолжите использовать Питон.
Примеры использования
Допустим, есть задача выполнить трудоемкие вычисления — например, для криптографического алгоритма, глубокого машинного обучения или обработки больших объемов данных. В этом случае расширения на Си могут снять нагрузку с интерпретатора Питона и ускорить работу приложения.
Что, если вам нужно создать низкоуровневый интерфейс или работать с памятью непосредственно из Питона? Здесь тоже стоит использовать расширения на Си — если вы знаете, как работать с «чистыми» указателями.
Еще один практический пример — оптимизация уже существующего «подтормаживающего» приложения на Питоне без переписывания его на другом языке.
А возможно, вы просто обожаете оптимизацию и хотите, чтобы код работал как можно быстрее, но не спешите расставаться с высокоуровневыми абстракциями для работы с сетью, графическим интерфейсом и т. д. — тогда вы определенно полюбите расширения на Си.
Время — ресурс, которого всегда не хватает. Используйте его с умом.
Заключение
Расширения на Си — отличное дополнение в арсенале разработчика, будь вы фанат производительности и эффективности или любитель смешивать различные технологии и экспериментировать с чем-то новым: вы не только получаете почти «бесплатный» скачок производительности, но и расширяете функциональные возможности Питона, не прибегая к устаревшему стеку технологий.
Благодарю за внимание.
О переводчике
Перевод статьи выполнен в Alconost.
Alconost занимается локализацией игр, приложений и сайтов на 70 языков. Переводчики-носители языка, лингвистическое тестирование, облачная платформа с API, непрерывная локализация, менеджеры проектов 24/7, любые форматы строковых ресурсов.
Мы также делаем рекламные и обучающие видеоролики — для сайтов, продающие, имиджевые, рекламные, обучающие, тизеры, эксплейнеры, трейлеры для Google Play и App Store.
Комментарии (24)
Sleuthhound
05.08.2021 14:39+3Си нынче не в моде, нужно писать на Rust.
Кстате, а можно модули для питона писать на Rust?
StarCatSTT
05.08.2021 16:58+1
Stranger6667
06.08.2021 15:44+2Могу посоветовать серию статей (за моим авторством) на эту тему - https://dygalo.dev/blog/rust-for-a-pythonista-3/
В последней части там довольно много деталей о том как такие модули собирать, тестировать и дебажить.
GaryKomarov
05.08.2021 16:11-3Может просто с Python на Golang перейти и все?
Начать можно с написания C-Shared DLL на Go и вызова их из Python...
DrrRos
05.08.2021 16:13+2Конкретно на числах Фибоначчи
@lru_cache
должно дать похожие результатыBarabashkad
11.08.2021 11:22тогда уж похожую технику можно использовать и для С версии ;-)
конечно придеться по потеть .....
но можно написать на плюсах и прокинуть в С ;-), конечно будет чуть больше кода чем готовый декоратор в пайтоне , но эфект не меньше и
думаю в конечном итоге разрыв даже увеличиться ;-)
N-Cube
05.08.2021 19:51+1Можно написать стандартную C библиотеку и подключить с помощью ctypes, который прекрасно поддерживается numpy. В реальности еще OpenMP нужен для распараллеливания по ядрам или OpenMPI по хостам. Вот пример такой библиотеки на гитхабе: https://github.com/mobigroup/gis-snippets/blob/master/geomed3d/geomed3dv3.py А еще можно заменить бинарные расширения на numba плюс dask или joblib, получив практически такую же скорость вычислений и распараллеливание. Пример на гитхабе: https://github.com/mobigroup/gis-snippets/blob/master/Synthetic%20Model%20Inversion/basic.ipynb
N-Cube
05.08.2021 19:58Сорри за первую ссылку, которая не кликабельна - чертов новый редактор комментариев то кусок текста убивает, то вот ссылки калечит, что на планшете, что на ноутбуке. Ссылка: https://github.com/mobigroup/gis-snippets/tree/master/geomed3d Тут лежит набор файлов .c и .h с библиотекой на C, питоновская «обертка» к модулю geomed3dv4.py и Jupyter notebook с примером geomed3dv4.ipynb
santjagocorkez
06.08.2021 00:48+3int для Фибоначчи - это несколько плохой пример. Он переполняется очень быстро, эти чуть больше 40 значений проще вообще тогда предвычислить и запихнуть в константы. Прямо в Питон.
Более того, даже unsigned long long рано или поздно может переполниться. И что тогда будете делать? А в Python собственная реализация int практически "резиновая". Да, это несёт накладные расходы. Тем не менее, я бы демонстрировал мощь Python C-API не на таком примере, а на примере POSIX threads. Это, пожалуй, самый большой камень в огороде Питона из-за GIL.
То есть, с помощью C-extension можно приделать модуль, который полностью отвечает требованиям внутреннего API Python (то есть, принимает и отдаёт в функциях Python-объекты), а внутри делает, например, так:
{ PyThreadState *_save; Py_UNBLOCK_THREADS do_some_jobs_in_threads(); Py_BLOCK_THREADS }
Конечно, это не единственное, чего можно достичь. Среди дополнительных плюсов использования C-extension:
Более точное и аккуратное управление памятью, потому что int в интерпретаторе - это всегда громоздкая структура, а нам, например, требуется несколько 4-битных полей, которые можно плотно упаковать в структуру без выравнивания
Операции над примитивами в C всегда будут быстрее, чем операции над "примитивами" в интерпретаторе. Желающие могут убедиться, как всё сложно в интерпретаторе, попробовав реализовать 1-в-1 небольшую портянку на 50 строк Python-функции, но в виде C-extension и без использования сишных примитивов. Все эти PyCFunction, PyObject_GetAttribute, возня с INCREF/DECREF. В общем, всё то, что делал бы интерпретатор, только ручками. А затем скомпилировать и сравнить производительность. Выигрыш, если и будет, придётся в микроскоп разглядывать.
Некоторую часть вышеперечисленного (включая, кстати, работу с потоками исполнения) можно покрыть с помощью Cython. Но он далёк от совершенства и при этом генерирует очень грязный и избыточный код. Как прототип для MVP подойдёт, но потом всё равно оптимизировать и доводить до ума.
А вот чего действительно очень не хватает: это внятной документации по awaitable в C-API.
N-Cube
06.08.2021 16:29int в интерпретаторе - это всегда громоздкая структура, а нам, например, требуется несколько 4-битных полей, которые можно плотно упаковать в структуру без выравнивания
А какой практический смысл оптимизировать хранение одного int? Если у вас больше одного int (или не int) - есть массивы numpy, к этому блоку памяти можно получить прямой доступ из внешней библиотеки, с помощью cython и так далее. Выделяем память с помощью инициализации массива numpy, работаем с областью памяти из библиотеки и из питона читаем результат. Что вы тут оптимизировать собрались? Более того, с помощью numba можно практически такую же скорость получить для python кода.
santjagocorkez
07.08.2021 00:44Ну, во-первых, обычно, конечно же, надо не один int оптимизировать, а десятки или сотни всяких структур, указателей и указателей на указатели. Во-вторых, обработка из Питона результатов работы библиотеки превращается в боль, если мы говорим о хоть какой-то кросс-платформенности. На одной платформе без выравнивания не бывает, на другой указатели 32-битные, на третьей 64-битные, на четвертой еще какие-нибудь приколы. Всё это превращается в тонны if-else и подобных частоколов стреляющих в ногу ружей, и всё равно получается масса работы с int интерпретатора, а это постоянные boxing/unboxing.
N-Cube
07.08.2021 07:50Все намного проще, на самом деле. Вот как с помощью ctypes передать указатель на int массив numpy во внешнюю библиотеку: ndpointer(ctypes.c_int32, flags="C_CONTIGUOUS") и сами данные x.astype(ctypes.c_int32). В библиотеке на C объявляем этот аргумент функции как int32_t *x и работаем с этим массивом. Можно переданные массивы и читать и модифицировать, а потом в питоне прочитать измененную версию. Никаких проблем с кроссплатформенностью нет - нужно правильно указать типы данных, к примеру, int32. Если же вы привыкли везде писать int вместо int32, то вы сами стреляете себе в ногу. Аналогично, можно и с библиотекой на фортране легко работать. Ну, на фортране я сам давно уж не пишу, а вот читать код и подключать библиотеки порой приходится (особенно в языке R любят фортран библиотеки без единого комментария и с кучей «магии», для переноса научных вычислений на питон приходится разбираться).
q1erty
06.08.2021 10:53-5Добрый вечер, сорри за оффтоп небольшой, подскажите, пожалуйста, как формулу в питоне представить в задаче 5? Очень степени смущают. Спасибо.
SunRiseX64
06.08.2021 17:46+1A = p * (1+r/n) ** nt
Но вы правы, это оффтоп. Для вопросов больше подходит https://qna.habr.com
q1erty
06.08.2021 18:15-4Раз уж вы ответили, а разве nt не нужно представить как произведение n * t ? И в скобки поставить, так как у степени приоритет?
seroj_ko
10.08.2021 15:33сразу вспомнился альтернативный вариант ускорения Python попроще - через Nim, nimpy и nimporter:
https://habr.com/ru/company/otus/blog/543332/
nirom
Если ли какие-либо библиотеки, фреймворки для разработки модулей Python на C/C++?
RekGRpth
пару раз использовал SWIG
Chaos_Optima
Boost.Python, PySide(Qt идёт в комплекте если нужен), PyBind вообще их довольно много, но это всё для плюсов.
N-Cube
ctypes посмотрите - прекрасно и просто работает, совместим с numpy, несколькими комментариями ниже я ссылочки на гитхаб привел.
masai
ctypes — это слишком низкоуровневая штука. Подходит, если уже есть сильная библиотека. Если же нужно написать сколько-нибудь сложный модуль и можно писать на C++, то лучше взять pybind11. Из того, чем пользовался, это самый удобный вариант.
С помощью этой библиотеки можно легко писать функции и классы, которые импортируются сразу как родные без какой-либо питоновской обвязки. Поддерживается очень много всего, есть удобная трансляция типов между языками.