PunchSpain mourns after latest train tragedy kills 69ESPNEvery Premier League team ranked by bang-for-buck: What if they spent like Man City?Bollywood HungamaBhumi Pednekar reunites with Akshay Kumar for Priyadarshan’s next comedyInquirerMMDA suspends coding scheme on Monday, Nov. 2UN NewsAfghanistan opium production jumps fivefold as farmers find ways round Taliban banDaily MaverickEl Salvador to send over 2,000 soldiers to Haiti for rescue while elections postponedEl ComercioEstados Unidos revela su hoja de ruta para Venezuela: reformas, vacancia de Delcy Rodríguez y eleccionesZDF heuteAktuelle Pressemitteilungen des ZDFFotogramas"Perfecta para ver en familia en Halloween": La película con la que vuelve a los cines el personaje más entrañable de la animación 'stop motion'Business AMDuitse elitesoldaten van KSK bereiden zich voor op mogelijke oorlog met RuslandSoompiSon Suk Ku And Nagayama Eita’s Opposing Detective Styles Complicate Their Partnership In “The Blue Road”Egypt IndependentHeal the victim, heal the world – how Sri Sri Ravi Shankar reclaims lives in war zones, prisons, and rural communities
The Daily Newsstand · Free, Always
Thursday, October 29, 2026

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

Translate

Недавно я прочел статью про 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 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 и некоторых других поддерживаемых) приведут к ошибке компиляции или 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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.