Как процессор выполняет код

Инструкции и регистры, конвейер, предсказание переходов, кэши и кэш-линии. Почему один и тот же цикл бывает быстрее в десять раз — и что это значит для кода на Python, Go, Java и JS.

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

Инструкции и регистры

Процессор не знает ни Python, ни Go. Он умеет простые операции: загрузить число из памяти, сложить, сравнить, перейти по адресу. Каждая — это инструкция длиной от 1 до 15 байт (x86-64) или ровно 4 байта (ARM64).

  • Регистры — ячейки по 64 бита прямо в ядре: у x86-64 их 16 общего назначения (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 × частота). IPC — сколько инструкций в среднем завершается за такт. Современное ядро может до 4–8, а на реальном коде бывает и 0,1.

  • Промах предсказания перехода стоит ~15–20 тактов — он растёт вместе с частотой.
  • Ожидание памяти измеряется в наносекундах: ~80–100 нс до RAM. Чем выше частота, тем больше тактов ядро простаивает.
  • Поэтому код, упирающийся в память, от разгона почти не ускоряется.

Выберите тип нагрузки и двигайте частоту.

Конвейер

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

Конвейер встаёт, если инструкции нужен результат предыдущей (зависимость по данным). Проброс результата (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. Сколько стоит сам переход?

Дорог только промах предсказания. Переход, который почти всегда идёт в одну сторону, угадывается, и ядро выполняет 1–2 таких перехода за такт. Трюки без ветвлений нужны лишь для непредсказуемых условий в горячем цикле, и то после измерения: 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 линий на один столбец.

Массив, список и префетчер

  • Массив лежит сплошным куском. Аппаратный префетчер замечает последовательный обход и загружает линии заранее — ядро почти не ждёт.
  • Связный список: адрес следующего узла известен, только когда загружен текущий. Если узлы разбросаны по памяти, каждый шаг — полный поход в RAM.
  • Это же касается []*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 оптимизирует, а распаковка ничего не создаёт.

Несколько ядер и false sharing

У каждого ядра свои L1 и L2. Чтобы все видели одинаковые данные, ядра следят друг за другом по протоколу когерентности (MESI): писать в линию может только одно ядро, копии в остальных при этом аннулируются.


type counter struct {
    n int64
    _ [56]byte // до 64 байт: своя линия
}

Если два потока пишут в разные переменные одной линии, линия ездит между ядрами по ~50 нс за раз. В Java для этого есть @Contended, в C++ — alignas(64). Атомики и порядок памяти — в презентации про синхронизацию.

SIMD: одна инструкция — много чисел

  • Векторные регистры: SSE и ARM NEON — 128 бит (4 × float32), AVX2 — 256 бит (8), AVX-512 — 512 бит (16). Одна инструкция складывает их все.
  • Компиляторы C, C++, Rust векторизуют простые циклы сами (-O3); компилятор Go — почти никогда, векторные пути в его стандартной библиотеке написаны на ассемблере.
  • Готовые векторизованные библиотеки: numpy, simdjson, 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.

Python и numpy

Одна итерация 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
  • IPC ниже 1 — ядро чаще ждёт память, чем считает. Выше 2 — упираетесь в вычисления.
  • Промахов предсказания больше 1–2% от переходов в горячем коде — ищите непредсказуемые условия.
  • perf record -g + perf report или flame graph — где тратится время; perf annotate — на какой инструкции.
  • perf c2c находит false sharing: линии, которые ядра отнимают друг у друга.
  • Go: 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 в виртуалке.

Сначала измерьте. Микрооптимизации под кэш имеют смысл в горячем цикле, через который идут миллионы элементов, — а не в коде, который ждёт базу.

Главное

  • Процессор исполняет простые инструкции над регистрами. Скорость — это частота × IPC, и IPC важнее.
  • Конвейер и исполнение вне очереди работают, пока есть независимая работа. Длинные цепочки зависимостей и непредсказуемые ветвления их останавливают.
  • Память в сотни раз медленнее ядра. Кэш работает линиями по 64 байта — выигрывает код, который читает данные подряд.
  • Массивы и плотные структуры быстрее указателей и объектов в куче. Раскладка данных важнее мелких оптимизаций кода.
  • Разные ядра не должны писать в одну линию. В Python тяжёлые циклы — внутрь numpy. И всё — после perf stat.

Быстрый код — это код, который не ждёт память.