- BrainTools - https://www.braintools.ru -

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

Недавно я прочел статью про pyrusmorph — python обёртку над 30-летним с++ ядром libmorph/rus. Бенчмарки удивили: в 30 раз быстрее PyMorphy3 при обработке текста. После я задумался. Как это вообще работает? Почему мы пишем import pyrusmorph и получаем доступ к нативному с++ коду и функциям прямо внутри python.

В статье я предлагаю пройти этот путь с нуля. Разобраться, как устроен Python «под капотом», написать своё расширение на чистом C‑API, а потом посмотреть, как эту боль [1] лечит 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)

  • Оверхед аллокатора памяти [2] — служебные заголовки (грубо говоря мета‑данные скрипта)

Когда мы пишем 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 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(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 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 и некоторых других поддерживаемых) приведут к ошибке [3] компиляции или 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 [4]

Подробнее про jit — https://numba.readthedocs.io/en/stable/?spm=a2ty_o01.29997173.0.0.e3bfc9212Su33v [5]

Githab статьи все файлы, чтобы вы попробовали запустить их локально (если 404, значит я уже удалил) — https://github.com/stl2802/Different‑types‑of‑compilations [6]

Автор: Stl2802

Источник [7]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/37710

URLs in this post:

[1] боль: http://www.braintools.ru/article/9901

[2] памяти: http://www.braintools.ru/article/4140

[3] ошибке: http://www.braintools.ru/article/4192

[4] https://docs.python.org/3/c‑api/?spm=a2ty_o01.29997173.0.0.e3bfc9212Su33v: https://docs.python.org/3/c-api/?spm=a2ty_o01.29997173.0.0.e3bfc9212Su33v

[5] https://numba.readthedocs.io/en/stable/?spm=a2ty_o01.29997173.0.0.e3bfc9212Su33v: https://numba.readthedocs.io/en/stable/?spm=a2ty_o01.29997173.0.0.e3bfc9212Su33v

[6] https://github.com/stl2802/Different‑types‑of‑compilations: https://github.com/stl2802/Different-types-of-compilations

[7] Источник: https://habr.com/ru/articles/1101090/?utm_campaign=1101090&utm_source=habrahabr&utm_medium=rss

www.BrainTools.ru

Rambler's Top100