C-расширения честно: лестница вариантов, граница GIL и налог на маршалинг
Время интерпретатора уходит на диспетчеризацию, боксинг, refcount; C обходит все три. Лестница: векторизация numpy, типы Cython, cffi/ctypes, C-API в конце. Расширения могут отпускать GIL; поэлементное пересечение границы съедает выигрыш. Wheels и manylinux — цена дистрибуции.
Команде рисков понадобились попарные расстояния по 10 000 векторов — сто миллионов операций с float. Чистый Python: 90 секунд, далеко за окном батча. Инженер обернул C-ядро расстояния через ctypes и вызвал его из того же вложенного Python-цикла, уверенный, что C всё исправит. Новое время: 110 секунд — медленнее. Каждый из ста миллионов вызовов платил примерно микросекунду пограничной стоимости — боксинг аргументов, сборка вызова, анбоксинг результата, — и это съело весь выигрыш C с добавкой. Лекарством оказался не дополнительный C, а перенос самого цикла через границу: один вызов scipy.spatial.distance.cdist, одно пересечение на всю матрицу, 0.9 секунды. Шестинедельный Cython-порт, который вот-вот должны были одобрить, превратился в однострочный дифф, а на вики команды появился урок: исход решает граница, а не язык.
Куда на самом деле уходит время интерпретатора
Когда горячий Python-цикл медленный, время редко сидит в вашей арифметике — оно в механике вокруг неё. Каждый байткод проходит через диспетчерский цикл ceval. Каждое a + b на int проверяет типы обоих операндов, разбоксивает их из объектов кучи, считает, выделяет объект результата и подкручивает счётчики ссылок на всём, чего коснулось. Нативное сложение — ~1 нс; интерпретируемая версия стоит десятки наносекунд даже после того, как специализирующий интерпретатор 3.11 сузил разрыв. Медленность структурна — фиксированный оверхед на операцию, — поэтому всякое настоящее лекарство имеет одну форму: либо заставить каждый шаг интерпретатора делать больше работы (векторизовать), либо скомпилировать шаги прочь (расширить).
Реалистичная лестница вариантов
Беритесь за них по порядку и останавливайтесь на первой ступени, которая закрывает SLO:
- Векторизация numpy / pandas — кто-то уже написал, отладил и дистрибутирует этот C. Если данные ложатся в массивы, это дни работы за выигрыш 10-100x на числовых циклах, без новой сборочной инфраструктуры.
- Cython — постепенно: ваш существующий Python компилируется как есть на скромные ~1.2-1.5x, а
cdef-типы и типизированные memoryview на горячем цикле открывают 10-100x. Кодовая база остаётся, появляется шаг компиляции — и всё из раздела о дистрибуции ниже. - cffi / ctypes — когда быстрый код уже существует как C-библиотека. ctypes не требует компилятора, но берёт порядка микросекунды за вызов; cffi быстрее и с более здравым API. Оба — для обёртывания, а не для ускорения цикла повызовно.
- Голый C-API — последним. Вы вручную управляете
Py_INCREF/Py_DECREF: один пропущенный decref течёт вечно, один лишний портит кучу и взрывается позже в невиновном коде. Пересборка на каждую минорную версию Python, если не целитесь в стабильный ABI.
Вместе четыре ступени образуют лестницу совокупной стоимости владения: каждый шаг вверх покупает контроль, но тратит бюджет сопровождения. Перепрыгните ступень 1 и сразу возьмитесь за ступень 4 — и однодневный numpy-рефактор превратится в шестимесячный C-порт, которого будущий коллега будет бояться.
Профиль показывает 80% CPU сервиса в одном чисто-питоновском цикле с арифметикой над массивами float. Первый ход по лестнице вариантов?
Граница GIL
GIL сериализует исполнение байткода, а не всё подряд. Расширение может отпустить его вокруг чисто-C секций — Py_BEGIN_ALLOW_THREADS — и крупные так и делают: numpy на заметных операциях, hashlib, zlib, компрессия, большинство I/O. Практическое следствие: ThreadPoolExecutor, гоняющий крупные numpy-матмулы, насыщает все ядра, а те же потоки на чисто-питоновской арифметике сериализуются на GIL и используют одно. Вопрос GIL никогда не звучит как «потоки — да или нет», а как «держит ли его горячая секция». Это же мост к уроку 4: расширения поменяли математику потоков задолго до free-threaded сборок.
Налог на маршалинг
Пересечение границы Python/C конвертирует PyObject в C-значения и обратно, собирает вызов и трогает счётчики ссылок — от 100 нс до 1 мкс за пересечение в зависимости от механизма. Десять миллионов поэлементных вызовов — это 1-10 секунд чистой пограничной стоимости до всякой полезной работы. Правило: векторизуйте весь цикл, а не внутренний вызов — передайте массив один раз, получите один результат. По той же причине pandas .apply(python_fn) — не векторизация: датафрейм уходит в C, но ваша функция вытаскивает каждую строку обратно.
Сборка и дистрибуция, честно
Скомпилированный код распространяется wheel-ами: по одному на ОС x архитектуру x минорную версию Python — если не целиться в стабильный ABI (abi3), схлопывающий ось Python. На Linux ответ на вопрос «какой дистрибутив» — manylinux: сборка внутри контейнеров со старым glibc, чтобы один wheel работал на всех дистрибутивах. Пропустите эту работу — и пользователи будут компилировать из исходников, а ваш трекер задач станет хелпдеском по C-тулчейнам на машинах, которых вы никогда не видели.
Сопровождение: сегфолты вместо трейсбеков
Ошибка refcount не поднимает исключение; она портит кучу и роняет процесс минуты спустя в коде, который ничего плохого не делал. Отладка переезжает из pdb в gdb и корки. Каждое обновление Python — пересборка и перетест матрицы. Расширение быстро в день 1 и дорого в день 400 — закладывайте в бюджет второе число.
Правило, которое запирает весь урок
Прежде чем браться за любую ступень, докажите, что горячий цикл упирается в интерпретатор: py-spy --native или cProfile из урока 1. Если время уже внутри numpy, json или драйвера базы, вы будете переписывать клей вокруг и так сишного ядра — вся цена, никакого выигрыша.
Коллега заменил чисто-питоновскую внутреннюю функцию C-версией через ctypes внутри того же цикла на 10 млн итераций. Задача стала медленнее. Почему?
- 01Перескажите лестницу вариантов с честной ценой каждой ступени — и ворота, которые надо пройти до подъёма вообще.
- 02Объясните налог на маршалинг и границу GIL — и как каждый меняет проектное решение.
Python-циклы медленны по структурным причинам — диспетчеризация ceval на байткод, боксинг каждого значения в объекты кучи, трафик refcount на каждом связывании, — поэтому C выигрывает не волшебной арифметикой, а пропуском этой механики, и каждое лекарство — либо «больше работы на шаг интерпретатора», либо «меньше шагов интерпретатора». Лестница упорядочивает лекарства по совокупной стоимости владения: numpy и pandas вручают сопровождаемый C за изменение масштаба дней; Cython позволяет типизировать только горячий цикл и сохранить кодовую базу; cffi и ctypes обёртывают уже существующий C; голый C-API стоит последним, потому что ручной refcount превращает баги в отложенные сегфолты, а каждый релиз Python — в пересборку. Архитектурой правят два пограничных факта: маршалинг стоит 0.1-1 мкс за пересечение, так что цикл обязан пересекать границу раз на массив, иначе налог пожирает выигрыш — 110-секундный ctypes-урок из хука; и расширения могут отпускать GIL (глобальную блокировку интерпретатора) внутри чисто-C секций — единственное место, где Python-потоки честно параллелятся по ядрам. Дистрибуция — часть цены: wheels на платформы, manylinux для Linux, abi3 для сжатия матрицы; сопровождение тоже — трейсбеки уступают коркам. Теперь, когда видишь предложение «добавить C» к медленному циклу, — первый вопрос: где именно в цикле пересекается граница: раз на весь массив или раз на каждый элемент?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.