От исходника до машинного кода

Что делают компилятор, интерпретатор и JIT с вашим кодом. Почему CPython медленный, как V8 и JVM разгоняют горячие функции и почему иногда всё откатывают назад. Что лежит в исполняемом файле и кто его запускает.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки, вводите выражения и переключайте флажки.

Конвейер компилятора

Любой язык проходит одни и те же этапы: текст → токены → дерево → промежуточное представление → оптимизации → машинный код. Языки отличаются тем, когда это происходит: при сборке, при запуске или никогда.

Выберите язык — увидите, какие этапы делаются заранее, а какие во время работы программы. Нажмите на этап, чтобы узнать подробности.

Лексер, парсер, генератор кода

  • Лексер режет текст на токены: имена, числа, операторы. Пробелы и комментарии исчезают.
  • Парсер строит дерево по грамматике. Приоритет операторов и скобки — это форма дерева: умножение оказывается глубже сложения.
  • Генератор обходит дерево снизу вверх. Для стековой машины — «положи операнды, выполни операцию», для процессора — команды над регистрами.

Введите своё выражение: + - * /, скобки, числа и имена. Переменные считаем аргументами функции — они приходят в регистрах w0, w1…

IR, SSA и что получилось в итоге

  • Оптимизируют не дерево и не машинный код, а промежуточное представление (IR). У Go — своё SSA, у Clang и Rust — LLVM IR, у JVM и V8 — граф Sea of Nodes или его наследники.
  • SSA: каждая переменная присваивается ровно один раз, i = i + 1 превращается в i2 = i1 + 1. Так сразу видно, откуда пришло значение, и оптимизации становятся простыми.
  • Затем выбор инструкций (умножение на 4096 → сдвиг, a + b*2 → одна LEAQ) и распределение регистров.

Справа — настоящий вывод go build -gcflags=-S для amd64. Функция area встроена, проверка границ массива осталась. Посмотреть SSA по каждому проходу: GOSSAFUNC=f go build → ssa.html.


func area(w, h int) int { return w * h }

func f(xs []int, n int) int {
    sum := 0
    for i := 0; i < n; i++ {
        sum += xs[i]*4096 + area(n, 2)
    }
    return sum
}

loop:
  MOVQ  (AX)(CX*8), SI   // xs[i]
  SHLQ  $12, SI          // * 4096 → сдвиг
  LEAQ  (SI)(DI*2), SI   // + n*2: area встроена
  ADDQ  SI, DX           // sum +=
  INCQ  CX               // i++
  CMPQ  DI, CX           // i < n ?
  JLE   done
  CMPQ  BX, CX           // bounds check
  JHI   loop
  JMP   panicBounds

Оптимизации

  • Свёртка констант: 4 * 1024 → 4096, значение подставляется во все места использования.
  • Встраивание (inlining): тело маленькой функции вместо вызова. Главная оптимизация: открывает дорогу всем остальным.
  • Удаление мёртвого кода: ветки, которые никогда не выполнятся, и значения, которые никто не читает.
  • Вынос инвариантов из цикла: то, что не меняется между итерациями, считается один раз.
  • Упрощение операций: умножение на степень двойки → сдвиг.

Включайте проходы по одному. Обратите внимание: многие из них ничего не могут сделать, пока не отработал другой.

Проверьте себя

Бенчмарк на Go считает хеш строки в цикле и показывает 0.3 ns/op — быстрее одного обращения к памяти. Что произошло?


func BenchmarkHash(b *testing.B) {
    for i := 0; i < b.N; i++ {
        hash("hello, world")
    }
}
После встраивания результат никто не читает, а побочных эффектов нет — значит, это мёртвый код. Остался пустой цикл. Результат надо «использовать»: сохранить в глобальную переменную (sink = hash(…)) или в Go 1.24+ писать цикл как for b.Loop() — он не даёт выбросить вызов. Та же ловушка в Java (JMH: Blackhole.consume) и в C++ (benchmark::DoNotOptimize).

Интерпретатор: дерево или байткод

  • Обход дерева: рекурсивно вычисляем узлы AST. Просто, но каждый узел — объект в куче, переход по указателю и косвенный вызов. Так работали ранний Ruby (до 1.9) и многие учебные языки.
  • Байткод: дерево один раз компилируется в плоский массив команд. Цикл «прочитать опкод — перейти к обработчику» компактнее, лучше ложится в кэш и в 2–5 раз быстрее обхода дерева.
  • Так устроены CPython, Ruby (YARV), Lua, PHP, первый уровень V8 (Ignition) и JVM в режиме интерпретации.

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


// Сердце байткодного интерпретатора (упрощённо)
for (;;) {
    uint8_t op = *pc++;          // опкод
    uint8_t arg = *pc++;         // аргумент
    switch (op) {                // косвенный переход
    case LOAD_FAST:
        *sp++ = locals[arg];     // положить на стек
        break;
    case BINARY_OP: {
        PyObject *r = sp[-1], *l = sp[-2];
        PyObject *res = binary_ops[arg](l, r);  // ещё один косвенный вызов
        sp -= 1;
        sp[-1] = res;
        break;
    }
    case RETURN_VALUE:
        return *--sp;
    }
}

Стековая машина CPython

Команды берут операнды с вершины стека и кладут результат обратно. Локальные переменные — в массиве «быстрых» слотов, к ним обращаются по номеру, а не по имени.

Это настоящий вывод python3.12 -m dis. Нажимайте «Шаг» и следите за стеком. В 3.13–3.14 часть пар команд склеена в одну (LOAD_FAST_LOAD_FAST), но принцип тот же.

Почему CPython медленный

  • Диспетчеризация: на каждую команду байткода — косвенный переход и десяток служебных инструкций.
  • Упаковка: каждое число — объект в куче: 28 байт на int вместо 8, а в списке ещё и указатель на него.
  • Динамические типы: a + b каждый раз заново выясняет, что такое a и b.
  • Счётчики ссылок меняются на каждом шаге — это записи в память.

Выберите среду и посмотрите, во что обходится одно сложение. Числа — порядок величины для x86-64.

Специализирующий интерпретатор (3.11+)

CPython 3.11 научился подглядывать за типами. Каждая команда с кэшем после пары выполнений заменяет себя специализированной версией: BINARY_OP → BINARY_OP_ADD_INT. Та проверяет типы одним сравнением и сразу складывает.

Если типы поменялись — промах: команда выполняется медленным путём, а после 52 промахов специализируется заново. Нужной специализации нет — откладывает попытку с экспоненциальной паузой. Модель по исходникам CPython 3.12; увидеть у себя: dis.dis(f, adaptive=True).

Проверьте себя

Список из миллиона целых чисел. Почему sum(xs) работает в 5–10 раз быстрее, чем цикл?


s = 0
for x in xs:
    s += x
Цикл на Python — это 6–7 команд байткода на элемент: FOR_ITER, STORE_FAST, два LOAD_FAST, BINARY_OP, STORE_FAST, JUMP_BACKWARD, и каждый шаг создаёт новый объект int. sum() пробегает массив указателей в C и складывает в обычную long-переменную. Отсюда правило: чем больше работы внутри встроенных функций, NumPy и C-расширений, тем меньше платите за интерпретатор.

JIT: горячий код и уровни

  • Всё начинается с интерпретатора: он сразу готов к работе и собирает профиль — какие типы приходят, какие ветки выполняются.
  • Счётчики вызовов и итераций циклов находят горячий код. Его компилируют в фоновом потоке: сначала быстро и просто, потом медленно и тщательно.
  • Долгие циклы переключаются на скомпилированный код прямо посреди итераций (OSR, on-stack replacement).

Нажмите «Пуск» и смотрите, как падает время вызова. Пороги JVM — настоящие значения по умолчанию. V8 считает не вызовы, а «бюджет» выполненного байткода — пороги условные.

Спекуляция и деоптимизация

Оптимизирующий JIT ставит на то, что типы останутся такими, как в профиле. Код получается как у C, но с проверками-охранниками. Охранник не сработал — деоптимизация: состояние переносится обратно в интерпретатор, а оптимизированный код выбрасывается.

Вызывайте add с разными аргументами. В V8 целые до 2³⁰ хранятся без упаковки (Smi), остальные числа — в объектах HeapNumber. Посмотреть у себя: node --trace-opt --trace-deopt app.js.

Скрытые классы и inline cache

У объекта JS нет класса, но V8 его заводит: скрытый класс (Map) описывает, какие поля и по каким смещениям лежат. Добавили поле — переход к новому классу. Одинаковый порядок полей — общий класс.

Место доступа o.x запоминает пары «класс → смещение». Один класс — мономорфный доступ (сравнение и загрузка), 2–4 — полиморфный, больше 4 — мегаморфный, поиск по общей хеш-таблице. Создавайте объекты и вызывайте getX.

AOT, JIT или интерпретатор

  • AOT (Go, Rust, C, GraalVM native-image): быстрый старт, мало памяти, предсказуемо. Но оптимизировать можно только по статике.
  • JIT (JVM, V8, .NET, PyPy): медленный старт и прогрев, больше памяти, зато пиковая скорость на уровне AOT, а иногда выше — компилятор видит реальный профиль.
  • Интерпретатор (CPython, Ruby): старт быстрый, но в 30–100 раз медленнее на счётных циклах.

Двигайте ползунок объёма работы: от CLI-утилиты до долгоживущего сервиса. Модель с типичными цифрами для счётного кода; на коде, который ждёт сеть и диск, разница меньше.

Объектные файлы и линковка

  • Компилятор превращает каждый файл в объектный (.o): машинный код с дырками на месте адресов и таблица символов — что определено здесь, а что нужно найти (U в выводе nm).
  • Линковщик собирает символы, раскладывает секции по адресам и заполняет дырки по записям о перемещениях (relocations).
  • Статическая линковка копирует код библиотеки в бинарник. Динамическая оставляет ссылку на .so и заглушку в PLT — адрес подставит загрузчик.

Что внутри исполняемого файла

ELF — формат исполняемых файлов Linux. Секции нужны линковщику и отладчику, сегменты — загрузчику: он отображает их в память с правами r-x, r--, rw-. Часть секций в память не попадает вовсе.

Нажмите на секцию. Go — реальные размеры hello world для linux/amd64, C — типичные для gcc -O2 с динамической линковкой.

Что происходит при запуске

  1. Ядро в execve читает заголовок ELF и отображает сегменты PT_LOAD в память. Страницы читаются с диска лениво — при первом обращении.
  2. Если есть PT_INTERP, управление получает динамический загрузчик ld-linux-x86-64.so.2. Он отображает все .so из NEEDED, применяет перемещения и заполняет GOT.
  3. Вызываются конструкторы (.init_array), затем _start → main. У Go сначала стартует рантайм: планировщик, сборщик мусора, потом init() всех пакетов.

Код .so отображается из одного файла во все процессы — в памяти он один. Подробнее об отображении файлов — в презентации про виртуальную память.


$ ldd ./app
  linux-vdso.so.1 (0x00007ffd8a3f2000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
  /lib64/ld-linux-x86-64.so.2

$ cat /proc/$(pidof app)/maps
55d0c0a00000-55d0c0a01000 r--p  app        # заголовки
55d0c0a01000-55d0c0a02000 r-xp  app        # .text
55d0c0a02000-55d0c0a03000 r--p  app        # .rodata
55d0c0a03000-55d0c0a05000 rw-p  app        # .data .got
55d0c1f6b000-55d0c1f8c000 rw-p  [heap]
7f1c2e400000-7f1c2e428000 r--p  libc.so.6
7f1c2e428000-7f1c2e5bd000 r-xp  libc.so.6
7f1c2e7c6000-7f1c2e7f0000 r-xp  ld-linux-x86-64.so.2
7ffd8a3a1000-7ffd8a3c2000 rw-p  [stack]

$ LD_DEBUG=bindings ./app    # кто чей символ разрешил

Проверьте себя

Go-сервис собрали на Ubuntu, положили в образ alpine. Запуск: exec ./app: no such file or directory. Файл на месте и исполняемый. В чём дело?

Пакеты net и os/user по умолчанию используют cgo, и бинарник линкуется динамически с glibc. Ядро не находит указанный в заголовке загрузчик и возвращает ENOENT — про сам загрузчик, а не про ваш файл. В Alpine вместо glibc — musl. Решение: CGO_ENABLED=0 go build (полностью статический бинарник) или сборка в том же образе. Проверить: file ./app и ldd ./app. Чужая архитектура дала бы exec format error.

Как это увидеть у себя

  • Python: python -m dis, dis.dis(f, adaptive=True) после прогрева — какие команды специализировались.
  • Node: байткод — --print-bytecode, решения JIT — --trace-opt --trace-deopt. Горячие места — --cpu-prof.
  • Java: -XX:+PrintCompilation — что и на каком уровне скомпилировано, -XX:+PrintInlining (с UnlockDiagnosticVMOptions) — что встроено.
  • Go: -gcflags=-m — встраивание и убегание в кучу, -gcflags=-S — ассемблер, GOSSAFUNC — все проходы SSA.
  • Любой язык: godbolt.org — код и ассемблер рядом, perf annotate — где горячо.

$ python3 -m dis script.py
$ node --print-bytecode app.js | less
$ node --trace-opt --trace-deopt app.js | grep add
  [marking … add for optimization to TURBOFAN]
  [bailout (kind: deopt-eager, reason: not a Smi)]
$ java -XX:+PrintCompilation Main | head
$ go build -gcflags='-m' ./...
  ./main.go:7:6: can inline area
  ./main.go:12:20: inlining call to area
  ./main.go:20:9: &User{...} escapes to heap
$ go build -gcflags='-S' 2>&1 | less

$ nm app | grep ' U '   # внешние символы
$ readelf -lS app        # сегменты, секции
$ objdump -d --no-show-raw-insn app | less
$ file app; ldd app      # статический?
$ go build -ldflags='-s -w'  # без DWARF

Главное

  • Компилятор — конвейер: токены → дерево → IR → оптимизации → машинный код. Языки различаются тем, какая часть делается до запуска.
  • Главная оптимизация — встраивание: после неё работают свёртка констант, удаление мёртвого кода и вынос из циклов. Поэтому бенчмарки нужно защищать от «слишком умного» компилятора.
  • Интерпретатор платит за диспетчеризацию, упаковку и динамические типы. Переносите горячие циклы во встроенные функции и C-библиотеки.
  • JIT ставит на типы из профиля и откатывается, если ставка не сыграла. Стабильные типы и одинаковый порядок полей — быстрый код.
  • AOT — быстрый старт и мало памяти, JIT — пиковая скорость после прогрева.
  • Бинарник — это сегменты для загрузчика плюс ссылки на .so. Статический бинарник переносим, динамический зависит от загрузчика и libc в системе.

Быстрый код — это код, про который компилятор может доказать или угадать, что он простой.