Недавно я прочел статью про pyrusmorph — python обёртку над 30-летним с++ ядром libmorph/rus. Бенчмарки удивили: в 30 раз быстрее PyMorphy3 при обработке текста. После я задумался. Как это вообще работает? Почему мы пишем import pyrusmorph и получаем доступ к нативному с++ коду и функциям прямо внутри python.
В статье я предлагаю пройти этот путь с нуля. Разобраться, как устроен Python «под капотом», написать своё расширение на чистом C‑API, а потом посмотреть, как эту боль лечит pybind11. В конце — бенчмарки и практические советы, когда вообще стоит лезть в c++ для оптимизации. Бонус — немного о компиляторе python кода (JIT‑компилятор)
Часть 1. Почему Python медленный? Анатомия PyObject
С начало нужно понять, что именно тормозит. Возьмём простую задачу: посчитать сумму квадратов для списка из миллиона чисел.
def sum_of_squares_py(numbers): total = 0.0 for n in numbers: total += n * n return total
Код выглядит производительно. Но что происходит на каждом шаге цикла?
Каждое число в Python — это полноценный объект PyObject в куче. У него есть:
Заголовок — 16 байт
Само значение — 8 байт (
float)Оверхед аллокатора памяти — служебные заголовки (грубо говоря мета‑данные скрипта)
Когда мы пишем n * n, Python должен:
Проверить типы операндов
Вызвать функцию умножения для
floatСоздать новый объект
floatв куче для результатаУвеличить refcount, передать в
total += ...Уменьшить refcount у старого
totalВызвать сборщик мусора(не всегда, но все же)
На один миллион итераций — это миллионы аллокаций памяти и проверок типов. Именно поэтому python медленный. Интерпретатор делает много работы, которая часто может оказаться лишней.
В cи‑подобных языках та же операция — это одна инструкция процессора mulsd над регистрами. Никаких аллокаций, никаких проверок. Проще говоря, так как cи более низкоуровневый язык, не происходят ненужные нам в скриптах (в которых нужна множественная обработка однотипных данных, например) действия со служебными заголовками
Часть 2. Python C‑API: как устроено на практике
Python (его эталонная реализация CPython) написан на с. У него есть C‑API — набор функций и макросов, который позволяет c‑коду работать с python объектами так же, как это делает сам интерпретатор.
Ключевая структура — PyObject. Это базовая структура для всего в Python: чисел, строк, списков, функций. Любой объект в Python — это указатель на PyObject (что доказывает, что все в Python является объектом).
typedef struct _object { Py_ssize_t ob_refcnt; // счётчик ссылок struct _typeobject *ob_type; // указатель на тип } PyObject;
Когда мы пишем расширение, мы:
Получаем
PyObject*из PythonРаботаем с ним через макросы (
PyLong_AsLong,PyFloat_AsDoubleи так далее)Создаём новые
PyObject*для результатаВозвращаем их обратно в Python
Главное правило C‑API: за каждый созданный PyObject ты отвечаешь сам. Забыл увеличить refcount — получишь segfault. Забыл уменьшить — утечка памяти. Это как ручное управление памятью в с, только для python объектов. Кто лезет на сторону c++ должен понимать риски:)
Ну и наконец практика.
Часть 3. Пишем расширение на чистом C‑API
Давайте напишем ту же sum_of_squares, но на C. Создадим файл fastmath.c:
#define PY_SSIZE_T_CLEAN #include <Python.h> // быстрая функция static PyObject* sum_of_squares_c(PyObject* self, PyObject* args) { PyObject* numbers_list; // 1. парсим аргументы: ожидаем один список if (!PyArg_ParseTuple(args, "O", &numbers_list)) { return NULL; // NULL = исключение в Python } // 2. проверяем, что это действительно список if (!PyList_Check(numbers_list)) { PyErr_SetString(PyExc_TypeError, "Expected a list"); return NULL; } // 3. проходим по списку и считаем Py_ssize_t n = PyList_Size(numbers_list); double total = 0.0; for (Py_ssize_t i = 0; i < n; i++) { PyObject* item = PyList_GetItem(numbers_list, i); // borrowed ref double value = PyFloat_AsDouble(item); if (PyErr_Occurred()) { return NULL; // если элемент не float } total += value * value; } // 4. создаём Python-float для результата return PyFloat_FromDouble(total); } // описание методов модуля static PyMethodDef FastMathMethods[] = { {"sum_of_squares", sum_of_squares_c, METH_VARARGS, "Compute sum of squares of a list of floats"}, {NULL, NULL, 0, NULL} // sentinel }; // описание самого модуля static struct PyModuleDef fastmathmodule = { PyModuleDef_HEAD_INIT, "fastmath", // имя модуля NULL, // docstring -1, FastMathMethods }; // точка входа - функция инициализации модуля PyMODINIT_FUNC PyInit_fastmath(void) { return PyModule_Create(&fastmathmodule); }
Блок для людей ни разу не писавших на cи
Код выглядит как набор строгих и подробных инструкций. Тут достаточно удобный api, при этом его нужно полностью покрывать условиями, чтобы узнать, правильные ли параметры передали в функцию на стороне python. Но он, как можно заметить, очень емкий и шаблонный.
Теперь нужно это скомпилировать. Создаём setup.py:
from setuptools import setup, Extension # описываем наш модуль на языке С. # Extension - это инструкция для сборщика. Какой файл скомпилировать и как назвать результат. module = Extension( 'fastmath', # Имя модуля для импорта (import fastmath) sources=['fastmath.c'] # Путь к исходному коду на С ) # Запускаем процесс сборки setup( name='fastmath', # Название пакета version='1.0', # Версия ext_modules=[module] # Передаем наше С-расширение в систему сборки )
Собираем:
python setup.py build_ext --inplace
И получаем файл fastmath.cpython-311-x86_64-linux-gnu.so (или .pyd на Windows). Это и есть наш модуль, который можно импортировать как обычный Python‑пакет (ос сама зарегистрирует его как регистрирует к примеру dll):
import fastmath numbers = [float(i) for i in range(1_000_000)] result = fastmath.sum_of_squares(numbers)
Даже на таком простом примере видно сколько шаблонного кода:
PyArg_ParseTuple— разбор аргументовPyList_Check,PyList_Size,PyList_GetItem— работа со спискомPyFloat_AsDouble,PyFloat_FromDouble— конвертация типовРучное управление
refcount(в нашем примере мы его не трогали, но все же)
Если бы функция принимала 5 аргументов разных типов, возвращала словарь с тремя полями и обрабатывала исключения — код разросся бы до 100+ строк. И это на чистом C.
Часть 4. Pybind11: палочка выручалочка (ну почти)
pybind11 — это header‑only библиотека на c++ v11, которая оборачивает python C‑API в удобные шаблоны. Она делает две вещи:
Автоматически конвертирует типы между c++ и python
Автоматически управляет
refcount
Вот та же функция, но на pybind11 (файл fastmath_pybind.cpp):
#include <pybind11/pybind11.h> #include <pybind11/stl.h> // для автоматической конвертации std::vector #include <vector> double sum_of_squares(const std::vector<double>& numbers) { double total = 0.0; for (double n : numbers) { total += n * n; } return total; } namespace py = pybind11; PYBIND11_MODULE(fastmath_pybind, m) { m.doc() = "Fast math module"; m.def("sum_of_squares", &sum_of_squares, "Compute sum of squares", py::arg("numbers")); }
Все, серьёзно. Это весь код.
Нет
PyObject*Нет
PyArg_ParseTupleНет ручного управления памятью
std::vector<double>автоматически конвертируется из Python‑списка и обратно
Собирается через setuptools с небольшим изменением:
from setuptools import setup from pybind11.setup_helpers import Pybind11Extension ext_modules = [ Pybind11Extension( "fastmath_pybind", ["fastmath_pybind.cpp"], cxx_std=11, ), ] setup( name="fastmath_pybind", version="1.0", ext_modules=ext_modules, )
Часть 5. Бенчмарки
Тестируем на списке из 10 миллионов float. Машина: Ноутбук HONOR MagicBook 14(я бомж лол, так что не судите строго), Python 3.11.
Добавил NumPy так как он тоже написан на c++ с добавлением специализированных библиотек для работы с процессором.
Реализация | Время (сек) |
чистый Python | 0.2521 |
чистый C‑API | 0.0528 |
Pybind11( | 0.1129 |
NumPy ( | 0.0032 |
Что мы видим:
Главный вывод: оверхед pybind11 по сравнению с чистым C‑API должен быть в теории — нулевой. Но на практике взамен на удобство, мы теряем в производительности достаточно много. (Хотя, может, это я просто фигово код написал, ибо повторюсь — в теории должно было отработать практически идентично по времени)
Часть 6. Когда стоит, а когда не стоит лезть в оптимизацию (может всё‑таки костыль?)
Не каждое узкое место нужно исправлять через с++ расширение. Вот мой практический чек‑лист (так же советовался по поводу его с qwen чатом):
Стоит писать на C/C++:
Функция вызывается миллионы раз в цикле
Обработка больших объёмов данных
Вы уперлись в cpu, а не в I/O (если тормозит база данных или сеть — C не поможет)
Есть готовая C/C++ библиотека, которую хочется использовать из Python
Не стоит, если:
Узкое место — это I/O (сетевые запросы, чтение с диска). Используйте
asyncioили многопоточность.Можно ускорить алгоритмически. O(n²) → O(n log n) даст больше, чем переход на C (но если слишком сложно придумать алгоритм, экономия памяти может вам помочь).
Есть готовое решение: NumPy, Cython, Numba. Часто
@njitот Numba даёт тот же прирост без единой строки C‑кода.Вы делаете прототип (MVP приложения). Сначала убедитесь, что задача вообще решается, потом оптимизируйте. (Ну или забейте, скажите что сделаете обнову в четверг, но не говорите в какой 😇)
Альтернативы, о которых стоит знать:
Cython — «Python с типами». Пишете почти обычный Python, добавляете аннотации типов, получаете C‑код. Отлично для математических вычислений. Но костыль:(.
Numba — JIT‑компилятор. Добавляете декоратор
@jit— и функция компилируется в машинный код на лету. Очень развивающаяся тема, о ней будет сказано в бонусе. Возможно, если получит достаточно развития, сможет подвинуть множество языков в их сферах деятельности.Rust + PyO3 — если хочется современный язык с гарантиями безопасности памяти. PyO3 — это «pybind11 для Rust». Ну короче, тоже мозговыносяшка, но только на тебе спавнятся чулки.
Бонус. А может, вообще не писать на C? Магия JIT‑компиляции (Numba)
Мы только что написали код на C, разобрались с PyObject, указателями и сборкой через setup.py, чтобы получить ускорение в 40 раз. Звучит солидно, но давайте честно: поддерживать C‑код в Python‑проекте — это дополнительная мозговыносяшка (кросс‑компиляция под разные ОС, segfault'ы, CI/CD).
А что, если я скажу, что можно получить те же самые 40 x ускорения, добавив в свой Python‑код всего две строчки? Наверное, вы захотите дать мне по голове железной ложкой, но тут тож есть свои подводные камни. Впрочем, сначала о плюсах...
Знакомьтесь, Numba — JIT‑компилятор (Just‑In‑Time) для Python, который транслирует подмножество python и NumPy кода в машинный код на лету с помощью фреймворка LLVM (того же самого, что используется в Clang, Rust и Swift).
Как это выглядит на практике
Возьмём нашу исходную функцию на чистом Python:
from numba import njit import numpy as np # Оригинальная функция на Python def sum_of_squares_py(numbers): total = 0.0 for n in numbers: total += n * n return total # Магическая строка: компилируем функцию в машинный код sum_of_squares_jit = njit(sum_of_squares_py) # Или просто используем декоратор: @njit def sum_of_squares_numba(numbers): total = 0.0 for n in numbers: total += n * n return total
Всё. Никаких .c файлов, никаких PyArg_ParseTuple. Вы вызываете sum_of_squares_numba([1.0, 2.0, 3.0]), и она работает.
Что происходит под капотом?
Когда вы впервые вызываете функцию с декоратором @njit, Numba делает следующее:
Анализ типов
Генерация LLVM IR
Оптимизация и компиляция
Кэширование
Бенчмарк: C‑API vs Numba
Тестируем на массиве из 10 миллионов чисел (для чистоты эксперимента используем numpy.array, так как Numba лучше всего работает именно с ним, хотя списки тоже поддерживает) параметры моей машины вы уже знаете:
Реализация | Время |
C‑API | 0.0528 |
Numba | 0.0093 |
Ps:Numba часто обгоняет ручной C‑код, потому что LLVM‑оптимизатор умеет делать векторизацию (SIMD‑инструкции процессора), которую средний разработчик вручную писать не будет.
Ограничения и минусы JIT
Ограниченный набор Python: Внутри
@njitнельзя использовать произвольные Python‑объекты. Словари, кастомные классы, сложные строковые операции или вызовы сторонних библиотек (кроме NumPy и некоторых других поддерживаемых) приведут к ошибке компиляции или fallback'у в медленный режим (object mode), который ещё медленнее чистого Python.Оверхед первого запуска: Первая компиляция функции занимает время (от миллисекунд до секунд). Если функция вызывается один раз за всё время жизни программы, вы получите замедление. (Лечится флагом
@njit(cache=True)).Динамическая типизация: Если вы передадите в функцию сначала список
int, а потом списокfloat, Numba скомпилирует две разные версии функции под каждый набор типов. Это ест память.
Итог: оверхед pybind11 по сравнению с чистым C‑API — нулевой. Вы получаете удобство C++ без потерь в производительности. А Numba в простых случаях обгоняет даже ручной C‑код за счёт LLVM‑оптимизаций и SIMD‑векторизации.
Нужные ссылки:
Подробнее про устройство python и c‑api — https://docs.python.org/3/c‑api/?spm=a2ty_o01.29997173.0.0.e3bfc9212Su33v
Подробнее про jit — https://numba.readthedocs.io/en/stable/?spm=a2ty_o01.29997173.0.0.e3bfc9212Su33v
Githab статьи все файлы, чтобы вы попробовали запустить их локально (если 404, значит я уже удалил) — https://github.com/stl2802/Different‑types‑of‑compilations

