Перейти к содержанию

412 уроков, 25 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 13 из 20 Средний 20 мин 100 XP

Cython: типизация ради скорости

Хирургия вместо компиляции всей программы: cdef-типы превращают горячий модуль в C-расширение. Где типы дают ускорение, а где не дают ничего — с замерами на чистом Python.

Редакция Питоники

Nuitka из прошлого урока компилирует всю программу. Cython — инструмент точнее: это надмножество Python, в которое добавляют типы, и компилируют не программу, а отдельный горячий модуль в C-расширение. Остальной код остаётся обычным Python и просто импортирует ускоренный модуль. Урок показывает механику типизации на живом коде: где типы меняют исполнение, а где остаются украшением.

Чем Cython отличается от Nuitka

Nuitka берёт программу как есть и сохраняет семантику Python автоматически — писать типы не нужно. Cython переворачивает договор: вы сами объявляете типы там, где они важны, и за это получаете настоящий машинный код в этих местах. Масштаб другой: Cython собирает модули, Nuitka — программы. Сборка exe делается поверх — например, PyInstaller подхватывает Cython-расширение как бинарник.

Как выглядит Cython-код

fastmath.pyx — исходник Cython
# fastmath.pyx — исходник Cython
def total(prices):
    cdef double s = 0.0        # C-переменная: без объектов на каждом шаге
    for price in prices:
        s += price
    return s
Файл .pyx — исходник Cython: Python с добавкой объявлений. Ключевое слово cdef объявляет типизированную C-переменную. Компиляция происходит нативным инструментом Cython, поэтому в песочнице блок не запускается.

Строка cdef double s меняет всё: переменная становится машинным числом двойной точности — на каждом шаге цикла нет создания объекта, проверки типа и поиска атрибутов. Именно cdef-объявления дают ускорение. А обычные аннотации — нет: в чистом Python они хранятся как данные и никогда не проверяются:

аннотации без Cython — просто данные
def total(prices: 'list[float]') -> float:
    s = 0.0
    for p in prices:
        s += p
    return s

print(total([100, 250, 80]))
print('Аннотации:', total.__annotations__)
Вывод
430.0
Аннотации: {'prices': 'list[float]', 'return': <class 'float'>}

Функция отработала с целыми числами, хотя аннотация обещала float: интерпретатор хранит аннотации в словаре __annotations__ и не сверяет с ними ни один вызов. Их адресат — инструменты: Cython (в том числе в pure-Python режиме) превращает аннотации в типы при компиляции, а без компиляции они остаются записью.

Типы на границе: что пропускает cdef

Типизированная функция Cython на границе преобразует числа и отбрасывает нечисловые значения с TypeError. Поведение легко воспроизвести проверкой типов:

проверка типов на границе
def strict_double(x):
    if not isinstance(x, float) and not isinstance(x, int):
        raise TypeError('Ожидалось число')
    return x * 2

print(strict_double(2.5))
print(strict_double(4))
try:
    strict_double('много')
except TypeError as e:
    print('Ошибка типа:', e)
Вывод
5.0
8
Ошибка типа: Ожидалось число

Числа прошли — int превращается в double без шума, строка отброшена. Это и есть контракт типизированной функции: принимаю числа, не принимаю остальное, и проверка происходит при входе, а не в глубине цикла.

Сборка расширения

терминал: компиляция .pyx в расширение
pip install cython setuptools
# setup.py:
# from setuptools import setup
# from Cython.Build import cythonize
# setup(ext_modules=cythonize('fastmath.pyx'))
python setup.py build_ext --inplace
Это команды терминала, поэтому блок не запускается в браузерной песочнице: сборка требует настоящего компилятора C. Результат — промежуточный C-файл и скомпилированное расширение, в имени которого живёт версия Python, поэтому имя мы не печатаем.

После сборки import fastmath работает как обычный модуль — только функция внутри нативная. Проверять ускорение принято модулей timeit на реальной машине:

терминал: измерение ускорения
python -m timeit -s "from fastmath import total" "total(list(range(1000)))"
Терминальная команда: расширение существует только на машине, где прошла сборка. В песочнице замер имитируется ниже на чистом Python.

Где типы дают выигрыш

Главный приём — типизированная переменная вместо объекта на каждом шаге цикла. Ближайший родственник в стандартной библиотеке — модуль array: он хранит числа как машинные double без обёрток. Сравним цикл по обычному списку объектов с суммой по типизированному массиву:

объектный цикл против типизированного массива
from array import array
import time

def total_loop(values):
    s = 0.0
    for v in values:
        s += v
    return s

data_obj = [i * 0.5 for i in range(50_000)]
data_arr = array('d', data_obj)

t0 = time.perf_counter()
for _ in range(30):
    total_loop(data_obj)
t_loop = time.perf_counter() - t0

t0 = time.perf_counter()
for _ in range(30):
    sum(data_arr)
t_arr = time.perf_counter() - t0

print('Ответ тот же:', sum(data_arr) == total_loop(data_obj))
print('Массив быстрее объектного цикла:', t_arr < t_loop)
Вывод
Ответ тот же: True
Массив быстрее объектного цикла: True

Разницу мы выразили сравнением больше-меньше, а не абсолютными числами: миллисекунды зависят от машины и версии, а порядок результатов — никогда. Второй приём Cython — вынести поиск атрибута из цикла в локальную переменную: для cdef-объявлений он делает это автоматически:

атрибут в цикле против локальной переменной
import time

class Ledger:
    def __init__(self):
        self.total = 0

def update_dynamic(ledger, steps):
    for i in range(steps):
        ledger.total += i

def update_typed(ledger, steps):
    total = ledger.total
    for i in range(steps):
        total += i
    ledger.total = total

a = Ledger()
b = Ledger()
update_dynamic(a, 1_000)
update_typed(b, 1_000)
print('Результаты совпадают:', a.total == b.total)

t0 = time.perf_counter()
update_dynamic(Ledger(), 50_000)
t_dyn = time.perf_counter() - t0

t0 = time.perf_counter()
update_typed(Ledger(), 50_000)
t_typ = time.perf_counter() - t0

print('Локальная переменная быстрее атрибута:', t_typ < t_dyn)
Вывод
Результаты совпадают: True
Локальная переменная быстрее атрибута: True

Сначала профиль, потом типы

Типизируют не всё подряд, а горячее — то, что съедает время. Найти горячее — работа профилировщика cProfile: соберём профиль двух функций и посмотрим, где программа провела время:

профиль находит горячую функцию
import cProfile, pstats

def loop_total():
    total = 0
    for i in range(300_000):
        total += i
    return total

def quick_sort():
    return sorted([3, 1, 2])

profiler = cProfile.Profile()
profiler.enable()
quick_sort()
loop_total()
profiler.disable()

stats = pstats.Stats(profiler)
times = {}
for key, value in stats.stats.items():
    if key[2] in ('loop_total', 'quick_sort'):
        times[key[2]] = value[3]
hotter = max(times, key=times.get)
print('Обе функции отработали')
print('Компилировать стоит:', hotter)
print('Горячая дольше холодной:', times['loop_total'] > times['quick_sort'])
Вывод
Обе функции отработали
Компилировать стоит: loop_total
Горячая дольше холодной: True

Почти всё время — в цикле: его и типизируют. Сортировку трёх элементов компилировать не стоит. Правило простое: сначала профиль, потом cdef — ускорение функции, на которую ушла доля процента времени, никто не заметит.

Скомпилированное расширение — бинарник с платформенным именем: в exe-сборке PyInstaller подхватывает его как библиотеку, а исходники .pyx пользователю не нужны. Как альтернатива всего-сразу — Nuitka, как другие упаковщики — py2exe и cx_Freeze в следующем уроке.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

def scale(x: 'float') -> float:
    return x * 3

print(scale(5))
print('Аннотации:', scale.__annotations__)
def strict_total(a, b):
    if not isinstance(a, int) or not isinstance(b, int):
        raise TypeError('Ожидались целые')
    return a + b

print(strict_total(2, 3))
try:
    strict_total(2, '3')
except TypeError as e:
    print('Отклонено:', e)
class Box:
    def __init__(self):
        self.total = 0

def hoisted(box, steps):
    t = box.total
    for i in range(steps):
        t += i
    box.total = t

b = Box()
hoisted(b, 100)
print(b.total)
Проверь себя
0 / 7

1. Что такое Cython?

2. Что делает cdef double s = 0.0?

3. Что делают аннотации типов в обычном Python без Cython?

4. Чем Cython отличается от Nuitka по масштабу?

5. Какой код Cython НЕ ускорит?

6. С чего правильно начинать типизацию?

7. Как программа использует скомпилированный Cython-модуль?

Карточки терминов
Запомнено: 0 / 7
Практика

Сделай типизированную имитацию суммы: функция total_typed(values) со локальной переменной s = 0.0 и циклом по values. Посчитай сумму массива array('d') из четырёх чисел и сверь с sum(). Ожидаемый вывод: сумма и True.

practice.py
Вопросы и ответы по уроку

Что такое Cython простыми словами?

Надмножество Python: тот же код плюс объявления типов вроде cdef double. Модуль компилируется в C-расширение, которое импортируется обычным import, а типизированные циклы в нём идут без создания объектов — за счёт этого и ускорение.

Урок 12: Nuitka — компиляция всей программы

Чем cdef отличается от обычных аннотаций?

Аннотации — подписи для инструментов: Python их хранит и не проверяет. cdef — прямое указание компилятору: переменная становится машинным числом, и код вокруг неё собирается без объектов. Ускорение даёт именно cdef и компиляция, а не аннотации сами по себе.

Ускорит ли Cython мою программу?

Сначала профиль. Вычислительные циклы с большим числом итераций — да, иногда в разы. Код, ждущий сеть или диск, — нет: узкое место не интерпретация. Компиляция функции с долей процента времени общую картину не изменит.

Что такое mypyc и как он связан с Cython?

Родственный компилятор: он тоже превращает Python-код в C-расширение, но использует обычные аннотации типов вместо cdef. На mypyc собран сам статический анализатор mypy — известный пример ускорения через типы.

Как Cython-модуль попадает в exe-сборку?

Как бинарник: PyInstaller подхватывает .pyd-расширение как библиотеку и кладёт в бандл. Исходники .pyx и C-файл в сборку не нужны — пользователь получает только скомпилированное расширение.

Урок 10: PyInstaller — сборка exe

Понравился урок? Сошлитесь на него

«Ускорение даёт не наличие типов в коде, а компиляция с ними: до сборки аннотации остаются данными, которые Python не проверяет.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Что дальше

Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.