Когда Python становится слишком медленным: пишем C‑расширение с нуля и сравниваем с pybind11 (бонус JIT‑компилятор)

Недавно я прочел статью про 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
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.