Инструкции и регистры, конвейер, предсказание переходов, кэши и кэш-линии. Почему один и тот же цикл бывает быстрее в десять раз — и что это значит для кода на Python, Go, Java и JS.
Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки. Предыдущая тема — данные в памяти, следующая — компиляторы и рантаймы.
Процессор не знает ни Python, ни Go. Он умеет простые операции: загрузить число из памяти, сложить, сравнить, перейти по адресу. Каждая — это инструкция длиной от 1 до 15 байт (x86-64) или ровно 4 байта (ARM64).
rax, rcx, rsi…), у ARM64 — 31. Читаются за такт.rip — адрес следующей инструкции, флаги — итог последнего сравнения или вычисления.
// Go
for _, x := range a {
s += x
}
; то же самое после компилятора (x86-64)
loop:
add rax, [rsi] ; s += *p: загрузка + сложение
add rsi, 8 ; p++ (int64 — 8 байт)
cmp rsi, rdx ; дошли до конца?
jne loop ; нет — прыжок назад
Посмотреть у себя: go build -gcflags=-S, objdump -d, godbolt.org. Для Python — python -m dis: там байткод, а не машинный код. Подробнее — в презентации про компиляторы.
Ядро бесконечно повторяет цикл: взять байты по адресу из rip, понять, что это за инструкция, выполнить её и перейти к следующей. Условный переход просто записывает в rip другой адрес.
Программа суммирует массив из пяти чисел. Нажимайте «Фаза», чтобы пройти цикл по шагам, или «Инструкция» — сразу целиком. Следите за регистрами, флагом ZF и ячейками памяти.
Время = инструкции ÷ (IPC × частота). IPC — сколько инструкций в среднем завершается за такт. Современное ядро может до 4–8, а на реальном коде бывает и 0,1.
Выберите тип нагрузки и двигайте частоту.
Пока одна инструкция исполняется, следующая уже декодируется, а третья — выбирается из памяти. Как на сборочной линии: каждая инструкция всё так же проходит пять стадий, но завершается почти каждый такт.
Конвейер встаёт, если инструкции нужен результат предыдущей (зависимость по данным). Проброс результата (forwarding) убирает большинство пауз, но значение из памяти всё равно приходит на такт позже. Компилятор переставляет инструкции, чтобы спрятать и эту паузу. Здесь — классический учебный 5-стадийный конвейер; у настоящих ядер стадий 15–20.
Современное ядро выбирает 4–8 инструкций за такт, держит в окне сотни и запускает любую, чьи операнды готовы, — не по порядку программы. Но истинную цепочку зависимостей не ускорит никто.
// одна цепочка зависимостей
for _, x := range a { s += x }
// четыре независимые цепочки
for i := 0; i+3 < len(a); i += 4 {
s0 += a[i]; s1 += a[i+1]
s2 += a[i+2]; s3 += a[i+3]
}
Сложение float64 — 4 такта задержки, но два блока принимают по сложению каждый такт. Компилятор сам так не переставит: для float это меняет результат (нужен -ffast-math). Выберите число аккумуляторов.
Конвейер не может ждать, пока вычислится условие if: он угадывает направление и выполняет инструкции наперёд. Угадал — переход почти бесплатен. Ошибся — всё начатое выбрасывается: ~15–20 тактов.
for (int i = 0; i < n; i++)
if (data[i] >= 128) // условный переход
sum += data[i];
Знаменитый пример: на отсортированном массиве цикл в ~6 раз быстрее, чем на случайном. Запустите поток значений и сравните. Современный gcc -O3 часто заменяет такой if на инструкцию без перехода (cmov) или SIMD — и разница исчезает.
В горячем цикле миллиард раз выполняется if user.is_admin:. Почти всегда это False. Сколько стоит сам переход?
perf stat -e branch-misses. А в Python проверка стоит десятки наносекунд по другой причине — из-за интерпретатора.Ядро считает быстрее, чем память успевает отвечать. Поэтому между ними — несколько уровней кэша: чем ближе к ядру, тем меньше и быстрее.
Типичные значения для сервера на 3 ГГц. Переключите шкалу на «такт = 1 секунда» — и станет понятно, почему промах кэша важнее лишней инструкции, а поход в сеть — важнее всего. Нажмите на уровень, чтобы узнать подробности.
Кэш хранит не байты, а линии по 64 байта (на Apple M — 128). Прочитали один float64 — в кэш пришли ещё семь соседних. Выгодно трогать соседние адреса (пространственная локальность) и повторно — недавние (временная).
// по строкам: адреса идут подряд
for (i = 0; i < N; i++)
for (j = 0; j < N; j++) sum += m[i][j];
// по столбцам: шаг N × 8 байт
for (j = 0; j < N; j++)
for (i = 0; i < N; i++) sum += m[i][j];
Матрица 8×8 из float64 хранится по строкам: одна строка — одна линия. Выберите порядок обхода и размер кэша. Настоящий L1 — 512–768 линий, но и матрица 4096×4096 — это 4096 линий на один столбец.
[]*T в Go, ArrayList<Integer> в Java, списков объектов в Python и JS: в массиве лежат указатели, а данные — где попало.Модель: RAM — 100 нс, одна линия в 5 нс по шине, 1 нс работы на элемент. Выберите структуру и запустите обход 64 элементов.
В Java сумма 10 млн элементов ArrayList<Integer> считается в несколько раз дольше, чем такого же int[]. Главная причина?
int[] — 40 МБ подряд, префетчер справляется. ArrayList<Integer> — массив ссылок по 4 байта плюс 10 млн объектов по 16 байт, ~200 МБ, и каждое чтение — зависимая загрузка по указателю. Пока объекты лежат в порядке создания, это терпимо, после перемешивания — каждый элемент промах кэша. Проверка границ и get() JIT оптимизирует, а распаковка ничего не создаёт.У каждого ядра свои L1 и L2. Чтобы все видели одинаковые данные, ядра следят друг за другом по протоколу когерентности (MESI): писать в линию может только одно ядро, копии в остальных при этом аннулируются.
type counter struct {
n int64
_ [56]byte // до 64 байт: своя линия
}
Если два потока пишут в разные переменные одной линии, линия ездит между ядрами по ~50 нс за раз. В Java для этого есть @Contended, в C++ — alignas(64). Атомики и порядок памяти — в презентации про синхронизацию.
-O3); компилятор Go — почти никогда, векторные пути в его стандартной библиотеке написаны на ассемблере.memchr, strings.Index, хеш-функции, кодеки.
; c[i] = a[i] + b[i], по 8 float за раз (AVX2)
vmovups ymm0, [rsi + rax]
vaddps ymm0, ymm0, [rdx + rax]
vmovups [rdi + rax], ymm0
add rax, 32
Проверить, векторизовался ли цикл: gcc -O3 -fopt-info-vec, clang -Rpass=loop-vectorize, в дизассемблере — регистры xmm/ymm/zmm.
Одна итерация s += x в CPython — это разбор байткода, проверка типов обоих операндов, поиск метода сложения, создание нового объекта float и счётчики ссылок: сотни машинных инструкций. numpy делает тот же цикл на C над плотным массивом, с SIMD.
Но у вызова numpy есть фиксированная цена — микросекунды на разбор аргументов и подготовку. Двигайте размер массива: на десятке элементов numpy проигрывает обычному циклу. Числа — порядок величин для CPython 3.12 на ядре ~3 ГГц.
Кэш грузит линию целиком, даже если нужна одна переменная из неё. Структура частицы — 64 байта, а цикл обновляет только координату. Массив структур (AoS) тянет из памяти всё; структура массивов (SoA) — только нужные поля.
Так устроены колоночные базы (ClickHouse), pandas и Arrow, ECS в игровых движках. В Java и Python «массив объектов» — вообще массив ссылок на объекты, разбросанные по куче. Выберите раскладку и что делает цикл.
Цикл по numpy-массиву на Python медленный. Его заменили на np.vectorize(f)(a), где f — обычная функция Python. Насколько быстрее станет?
np.vectorize — удобная обёртка над циклом Python, в документации прямо сказано, что ради скорости её не делали. Ускоряет только выражение целиком из операций numpy (a * 2 + 1, np.where) — тогда цикл идёт внутри C-кода. Если так не выразить — Numba, Cython или расширение на C/Rust.
$ perf stat -e cycles,instructions,branch-misses,\
cache-references,cache-misses ./app
3,012,455,120 cycles
9,640,112,004 instructions # 3.20 insn per cycle
2,104,332 branch-misses
41,220,118 cache-references
1,990,214 cache-misses # 4.83% of all cache refs
perf record -g + perf report или flame graph — где тратится время; perf annotate — на какой инструкции.perf c2c находит false sharing: линии, которые ядра отнимают друг у друга.go test -bench и pprof. Java: async-profiler с -e cache-misses, JMH с -prof perfnorm. Python: py-spy, а с 3.12 — python -X perf для perf. Node: --perf-basic-prof.lscpu -C, getconf LEVEL1_DCACHE_LINESIZE; в macOS — sysctl hw.cachelinesize.perf_event_paranoid, нет PMU в виртуалке.Сначала измерьте. Микрооптимизации под кэш имеют смысл в горячем цикле, через который идут миллионы элементов, — а не в коде, который ждёт базу.
perf stat.Быстрый код — это код, который не ждёт память.