a * (b + 2)Что делают компилятор, интерпретатор и JIT с вашим кодом. Почему CPython медленный, как V8 и JVM разгоняют горячие функции и почему иногда всё откатывают назад. Что лежит в исполняемом файле и кто его запускает.
Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки, вводите выражения и переключайте флажки.
Любой язык проходит одни и те же этапы: текст → токены → дерево → промежуточное представление → оптимизации → машинный код. Языки отличаются тем, когда это происходит: при сборке, при запуске или никогда.
Выберите язык — увидите, какие этапы делаются заранее, а какие во время работы программы. Нажмите на этап, чтобы узнать подробности.
Введите своё выражение: + - * /, скобки, числа и имена. Переменные считаем аргументами функции — они приходят в регистрах w0, w1…
i = i + 1 превращается в i2 = i1 + 1. Так сразу видно, откуда пришло значение, и оптимизации становятся простыми.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, значение подставляется во все места использования.Включайте проходы по одному. Обратите внимание: многие из них ничего не могут сделать, пока не отработал другой.
Бенчмарк на 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).Главная цена интерпретатора — диспетчеризация: на каждую простую операцию — загрузка опкода и косвенный переход, который предсказатель переходов часто угадывает плохо. Об этом — в презентации про процессор.
// Сердце байткодного интерпретатора (упрощённо)
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;
}
}
Команды берут операнды с вершины стека и кладут результат обратно. Локальные переменные — в массиве «быстрых» слотов, к ним обращаются по номеру, а не по имени.
Это настоящий вывод python3.12 -m dis. Нажимайте «Шаг» и следите за стеком. В 3.13–3.14 часть пар команд склеена в одну (LOAD_FAST_LOAD_FAST), но принцип тот же.
int вместо 8, а в списке ещё и указатель на него.a + b каждый раз заново выясняет, что такое a и b.Выберите среду и посмотрите, во что обходится одно сложение. Числа — порядок величины для x86-64.
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
FOR_ITER, STORE_FAST, два LOAD_FAST, BINARY_OP, STORE_FAST, JUMP_BACKWARD, и каждый шаг создаёт новый объект int. sum() пробегает массив указателей в C и складывает в обычную long-переменную. Отсюда правило: чем больше работы внутри встроенных функций, NumPy и C-расширений, тем меньше платите за интерпретатор.Нажмите «Пуск» и смотрите, как падает время вызова. Пороги JVM — настоящие значения по умолчанию. V8 считает не вызовы, а «бюджет» выполненного байткода — пороги условные.
Оптимизирующий JIT ставит на то, что типы останутся такими, как в профиле. Код получается как у C, но с проверками-охранниками. Охранник не сработал — деоптимизация: состояние переносится обратно в интерпретатор, а оптимизированный код выбрасывается.
Вызывайте add с разными аргументами. В V8 целые до 2³⁰ хранятся без упаковки (Smi), остальные числа — в объектах HeapNumber. Посмотреть у себя: node --trace-opt --trace-deopt app.js.
У объекта JS нет класса, но V8 его заводит: скрытый класс (Map) описывает, какие поля и по каким смещениям лежат. Добавили поле — переход к новому классу. Одинаковый порядок полей — общий класс.
Место доступа o.x запоминает пары «класс → смещение». Один класс — мономорфный доступ (сравнение и загрузка), 2–4 — полиморфный, больше 4 — мегаморфный, поиск по общей хеш-таблице. Создавайте объекты и вызывайте getX.
Двигайте ползунок объёма работы: от CLI-утилиты до долгоживущего сервиса. Модель с типичными цифрами для счётного кода; на коде, который ждёт сеть и диск, разница меньше.
.o): машинный код с дырками на месте адресов и таблица символов — что определено здесь, а что нужно найти (U в выводе nm)..so и заглушку в PLT — адрес подставит загрузчик.ELF — формат исполняемых файлов Linux. Секции нужны линковщику и отладчику, сегменты — загрузчику: он отображает их в память с правами r-x, r--, rw-. Часть секций в память не попадает вовсе.
Нажмите на секцию. Go — реальные размеры hello world для linux/amd64, C — типичные для gcc -O2 с динамической линковкой.
execve читает заголовок ELF и отображает сегменты PT_LOAD в память. Страницы читаются с диска лениво — при первом обращении.PT_INTERP, управление получает динамический загрузчик ld-linux-x86-64.so.2. Он отображает все .so из NEEDED, применяет перемещения и заполняет GOT..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 -m dis, dis.dis(f, adaptive=True) после прогрева — какие команды специализировались.--print-bytecode, решения JIT — --trace-opt --trace-deopt. Горячие места — --cpu-prof.-XX:+PrintCompilation — что и на каком уровне скомпилировано, -XX:+PrintInlining (с UnlockDiagnosticVMOptions) — что встроено.-gcflags=-m — встраивание и убегание в кучу, -gcflags=-S — ассемблер, GOSSAFUNC — все проходы SSA.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
.so. Статический бинарник переносим, динамический зависит от загрузчика и libc в системе.Быстрый код — это код, про который компилятор может доказать или угадать, что он простой.