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

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

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

Nuitka: компиляция Python-кода в C

От упаковки к компиляции: как Nuitka переводит программу в C, что становится машинным кодом, а что остаётся PyObject — и где ждать ускорения.

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

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

Два режима работы

У Nuitka два режима. accelerated компилирует модуль в C-расширение — файл .pyd на Windows или .so на Unix: такой модуль импортируется обычным Python и ускоряет только себя, но требует установленного интерпретатора. standalone собирает полноценное приложение с библиотеками — как PyInstaller, только код внутри нативный:

РежимНа выходеPython на машине пользователя
acceleratedC-расширение .pyd или .soНужен
standaloneПапка или один exe с библиотекамиНе нужен

Установка и сборка

терминал: установка и компиляция
pip install nuitka
python -m nuitka main.py
Это команды терминала, поэтому блок не запускается в браузерной песочнице: Nuitka — нативный компилятор. При первом запуске он спросит, какой C-компилятор использовать (MSVC или MinGW), и при согласии скачает его.

Для сборки нужен C-компилятор: MSVC или MinGW на Windows, gcc или clang на Unix-системах. Сборка занимает заметно больше времени, чем у PyInstaller, — это цена настоящего компилирования.

Что значит «компиляция в C»

Конвейер такой: исходник разбирается в дерево AST, из дерева Nuitka строит своё представление программы, из него генерирует C-код, и компилятор C доводит до машинного. Первый шаг можно повторить прямо в песочнице — стандартный модуль ast разбирает тот же исходник:

первый шаг компилятора: разбор в дерево
import ast

source = (
    "def total(prices):\n"
    "    s = 0\n"
    "    for p in prices:\n"
    "        s = s + p\n"
    "    return s\n"
)

tree = ast.parse(source)
func = tree.body[0]
print('Функция:', func.name)
print('Аргументы:', [a.arg for a in func.args.args])
kinds = []
for node in ast.walk(func):
    if isinstance(node, (ast.Assign, ast.For, ast.BinOp, ast.Return)):
        kinds.append(type(node).__name__)
print('Конструкции:', ', '.join(kinds))
Вывод
Функция: total
Аргументы: ['prices']
Конструкции: Assign, For, Return, Assign, BinOp

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

свёртка констант на этапе сборки
import ast

class Folder(ast.NodeTransformer):
    def visit_BinOp(self, node):
        self.generic_visit(node)
        ops = {ast.Add: lambda a, b: a + b, ast.Sub: lambda a, b: a - b, ast.Mult: lambda a, b: a * b}
        if (
            isinstance(node.left, ast.Constant)
            and isinstance(node.right, ast.Constant)
            and type(node.op) in ops
        ):
            return ast.Constant(value=ops[type(node.op)](node.left.value, node.right.value))
        return node

print('Было: n = 2 + 3 * 4')
folded = Folder().visit(ast.parse('n = 2 + 3 * 4'))
ast.fix_missing_locations(folded)
ns = {}
exec(compile(folded, '<folded>', 'exec'), ns)
print('Стало: n =', ns['n'])
Вывод
Было: n = 2 + 3 * 4
Стало: n = 14

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

упрощённо: во что превращается цикл
/* упрощённая иллюстрация, а не реальный вывод Nuitka */
static PyObject *impl_total(PyObject *self, PyObject *prices) {
    PyObject *s = PyLong_FromLong(0L);
    /* цикл for p in prices стал машинным циклом */
    /* s = s + p стал вызовом PyNumber_Add(s, p)   */
    return s;
}
Иллюстрация уровня идей: настоящий C-код Nuitka занимает страницы на каждый исходный файл. Главное видно и здесь: числа остаются PyObject — объектами CPython, а поток управления стал статическим.

Что ускоряется, а что нет

Ключ к честным ожиданиям: компиляция не превращает Python в C с машинными числами. Значения остаются теми же объектами — PyObject, и арифметика над ними идёт через те же функции CPython. Проверим это на функции в «компиляторном стиле»:

числа остаются объектами
def compiled_style(a, b):
    s = a + b
    return s * 2

result = compiled_style(21, 21)
print('Результат:', result)
print('Тип результата:', type(result))
Вывод
Результат: 84
Тип результата: <class 'int'>

Nuitka заменяет интерпретацию байткода машинным потоком управления. Числа при этом остаются объектами CPython: ускоряются циклы и вызовы, а не каждая отдельная операция. Отсюда правило: выигрывает код с плотными циклами, ветвлениями и вызовами — а не одинокая арифметика.

Два источника ускорения

Первый источник: код собирается один раз. CPython разбирает и компилирует исходник при каждом запуске программы (пусть и с кэшем pyc), а нативный код собран заранее. Измерим саму идею: разбор строки на каждый вызов против заранее собранного кода:

компиляция один раз против на каждый вызов
import time

source = 'a * a + 2 * b'
ctx = {'a': 7, 'b': 30}

precompiled = compile(source, '<expr>', 'eval')

n = 10_000
t0 = time.perf_counter()
for _ in range(n):
    eval(compile(source, '<expr>', 'eval'), ctx)
t_fresh = time.perf_counter() - t0

t0 = time.perf_counter()
for _ in range(n):
    eval(precompiled, ctx)
t_ready = time.perf_counter() - t0

print('Результат одинаковый:', eval(precompiled, ctx) == eval(compile(source, '<expr>', 'eval'), ctx))
print('Скомпилированный заранее быстрее:', t_ready < t_fresh)
Вывод
Результат одинаковый: True
Скомпилированный заранее быстрее: True

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

прямой код против стековой машины
import time

def direct(a, b, c):
    return (a + b) * c

def vm(program, args):
    stack = []
    for op, arg in program:
        if op == 'push':
            stack.append(args[arg])
        elif op == 'add':
            y = stack.pop()
            x = stack.pop()
            stack.append(x + y)
        elif op == 'mul':
            y = stack.pop()
            x = stack.pop()
            stack.append(x * y)
        elif op == 'ret':
            return stack[0]

prog = [('push', 0), ('push', 1), ('add', None), ('push', 2), ('mul', None), ('ret', None)]
args = (7, 5, 3)
print('Результат совпадает:', vm(prog, args) == direct(7, 5, 3))

n = 30_000
t0 = time.perf_counter()
for _ in range(n):
    direct(7, 5, 3)
t_direct = time.perf_counter() - t0

t0 = time.perf_counter()
for _ in range(n):
    vm(prog, args)
t_vm = time.perf_counter() - t0

print('Прямой код быстрее стековой машины:', t_direct < t_vm)
Вывод
Результат совпадает: True
Прямой код быстрее стековой машины: True

Обе разницы мы выразили сравнением больше-меньше, а не абсолютными миллисекундами: время зависит от машины, а порядок результатов — никогда. Нативный код получает оба выигрыша разом: собран заранее и исполняется без диспетчера.

Nuitka против PyInstaller

КритерийPyInstallerNuitka
Что с кодомБайткод на виртуальной машинеМашинный код
Скорость исполненияКак у обычного PythonВыше на плотном коде
СборкаБыстро, компилятор не нуженНужен C-компилятор, дольше
СовместимостьПочти всёРедкие несовместимости с динамикой
СутьУпаковкаКомпиляция
терминал: standalone-сборка
python -m nuitka --standalone --onefile main.py
Команда терминала, в песочнице не выполняется: standalone-сборка требует установленного C-компилятора и занимает заметно дольше упаковки PyInstaller, зато код внутри получается нативным.

Для точечного ускорения вместо компиляции всей программы существует хирургический инструмент: Cython добавляет к Python типы и компилирует только горячий модуль — об этом следующий урок.

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

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

import ast

class Folder(ast.NodeTransformer):
    def visit_BinOp(self, node):
        self.generic_visit(node)
        ops = {ast.Add: lambda a, b: a + b, ast.Mult: lambda a, b: a * b}
        if (
            isinstance(node.left, ast.Constant)
            and isinstance(node.right, ast.Constant)
            and type(node.op) in ops
        ):
            return ast.Constant(value=ops[type(node.op)](node.left.value, node.right.value))
        return node

ns = {}
folded = Folder().visit(ast.parse('n = 5 + 2 * 10'))
ast.fix_missing_locations(folded)
exec(compile(folded, '<folded>', 'exec'), ns)
print('n =', ns['n'])
import ast

tree = ast.parse('def f(x):\n    return x + 1\n')
func = tree.body[0]
print('Функция:', func.name)
kinds = [type(n).__name__ for n in ast.walk(func) if isinstance(n, (ast.BinOp, ast.Return))]
print('Конструкции:', ', '.join(kinds))
def direct(a, b):
    return (a + b) * 2

def vm(program, args):
    stack = []
    for op, arg in program:
        if op == 'push':
            stack.append(args[arg])
        elif op == 'add':
            y = stack.pop()
            stack.append(stack.pop() + y)
        elif op == 'mul':
            y = stack.pop()
            stack.append(stack.pop() * y)
        elif op == 'ret':
            return stack[0]

prog = [('push', 0), ('push', 1), ('add', None), ('push', 2), ('mul', None), ('ret', None)]
print(vm(prog, (5, 4, 2)) == direct(5, 4))
Проверь себя
0 / 7

1. Что делает Nuitka?

2. Чем режим accelerated отличается от standalone?

3. Что нужно для сборки Nuitka?

4. Почему компиляция не даёт «машинных чисел» как в настоящем C?

5. Что такое свёртка констант?

6. Какой код может вести себя иначе после нативной компиляции?

7. Главное различие PyInstaller и Nuitka?

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

Сравни «компиляцию на каждый вызов» и «заранее собранный код»: два цикла по 1000 вызовов eval — первый каждый раз компилирует строку, второй использует готовый code-объект. Напечатай два булевых: одинаковый результат и заранее собранный быстрее.

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

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

Компилятор, который переводит вашу Python-программу в C и собирает из неё настоящий исполняемый файл. Код перестаёт исполняться по инструкциям виртуальной машины — машинный код идёт напрямую, поэтому плотные циклы и вызовы ускоряются.

Урок 10: PyInstaller — упаковка в exe

Nuitka или PyInstaller — что выбрать?

PyInstaller — когда нужна простая и быстрая упаковка: байткод и интерпретатор в exe без компилятора. Nuitka — когда важна скорость исполнения или сокрытие кода: сборка дольше, нужен C-компилятор, но код становится нативным.

Нужно ли переписывать код под Nuitka?

Обычно нет: Nuitka компилирует обычный Python. Проверять стоит глубоко рефлексивный код — инспекцию фреймов, подмену байткода, трюки с globals. Правило: сначала тесты на собранной сборке, потом релиз.

Зачем Cython, если есть Nuitka?

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

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

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

«Числа при этом остаются объектами CPython: ускоряются циклы и вызовы, а не каждая отдельная операция.»

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

TelegramVK

Что дальше

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

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

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