Когда 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;

Когда мы пишем расширение, мы:

  1. Получаем PyObject* из Python

  2. Работаем с ним через макросы (PyLong_AsLong, PyFloat_AsDouble и так далее)

  3. Создаём новые PyObject* для результата

  4. Возвращаем их обратно в 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 fastmathnumbers = [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 setupfrom pybind11.setup_helpers import Pybind11Extensionext_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(fastmath_pybind)

0.1129

NumPy (np.sum(arr**2))

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 njitimport numpy as np# Оригинальная функция на Pythondef 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)# Или просто используем декоратор:@njitdef 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

ссылка на оригинал статьи https://habr.com/ru/articles/1101090/