Процессор
Предпосылки: базовое программирование (переменные, циклы, функции).
Путь данных: Иерархия памяти | Программная модель: CISC и RISC | Внеочерёдное исполнение →
Когда вы пишете программу, код не «выполняется сам». Команды нужно по одной прочитать, понять и применить к данным. Пока программа маленькая, об этом почти не думаешь: написал цикл, запустил, получил результат. Но как только возникает вопрос «почему эта программа вообще работает» или «почему простой код может работать медленно», быстро упираешься в одну и ту же сущность — ту часть компьютера, которая берёт инструкции и превращает их в результат. Её роль, внутреннее устройство и то, как это устройство определяет скорость программы — ниже.
Что делает процессор
Процессор — это машина, которая умеет ровно одно: брать инструкцию из памяти, разбираться что она означает, и выполнять её. Потом следующую. Потом следующую. Миллиарды раз в секунду. Любая программа — на Ruby, Rust, C — в конечном счёте превращается в последовательность таких машинных инструкций. Каждая инструкция — элементарная операция: сложить два числа, загрузить значение из памяти, сравнить и перейти по другому адресу. Вся мощь компьютера — это комбинация этих примитивов на огромной скорости.
Возьмём простую задачу: просуммировать массив из миллиона целых чисел.
int sum = 0;
for (int i = 0; i < 1000000; i++)
sum += arr[i];Компилятор превращает этот цикл в последовательность машинных инструкций: загрузить элемент массива из памяти в регистр, прибавить к аккумулятору, увеличить индекс, проверить условие цикла, перейти к началу. Каждая итерация — 4–5 инструкций. Миллион итераций — около 5 миллионов инструкций. На процессоре с частотой 3 ГГц это должно занять… сколько? Ответ зависит от того, как процессор устроен внутри.
Такт и регистры
Такт — минимальный временной шаг работы процессора. На частоте 3 ГГц один такт длится 1 / 3 000 000 000 = 0.33 наносекунды. Генератор посылает электрический импульс, и все блоки процессора синхронно делают один шаг своей работы.
Процессор не может складывать «два числа из оперативной памяти» напрямую. У него есть собственное крошечное хранилище — регистры (registers, буквально «ячейки-хранилища»). Доступ к регистру занимает порядка одного такта — доли наносекунды. Доступ к оперативной памяти — порядка 100 наносекунд, в сотни раз медленнее. Если бы процессор каждую операцию делал с RAM напрямую, он простаивал бы 99% времени в ожидании данных. Регистры — решение проблемы «память слишком далеко».
Обратная сторона: регистров мало. В архитектуре x86-64 доступно 16 регистров общего назначения, каждый по 64 бита — всего 128 байт. Эти 16 регистров вместе с несколькими специальными (rip — instruction pointer, указатель на текущую инструкцию; rflags — флаги состояния) образуют регистровый файл (register file) — внутреннюю память ядра, доступную за доли наносекунды. Всё, что не помещается в 128 байт регистров, живёт в оперативной памяти и должно быть явно загружено перед использованием и записано обратно после.
Разрыв между скоростью регистров (~0.33 нс) и скоростью RAM (~100 нс, то есть ~300 тактов простоя) — центральная проблема производительности, и большинство архитектурных решений в процессоре работают вокруг неё.
Цикл «выбрать — декодировать — исполнить»
Процессор работает с инструкциями в цикле из трёх фаз. Fetch (выборка) — загрузить очередную инструкцию по адресу из счётчика инструкций (program counter, rip в x86-64). Decode (декодирование) — разобрать инструкцию на операцию и операнды: в ADD rax, rbx операция — сложение, а операнды — два регистра, чьи значения она складывает. Execute (исполнение) — в зависимости от инструкции выполнить одно действие: операцию в ALU (Arithmetic Logic Unit, арифметико-логическое устройство — блок, выполняющий арифметику и побитовые операции), обращение к памяти или переход.
graph LR Инструкция --> Fetch --> Decode --> Execute
Если каждая фаза занимает один такт, одна инструкция обрабатывается за 3 такта. Для нашего цикла суммирования это означает: 5 миллионов инструкций × 3 такта = 15 миллионов тактов. На 3 ГГц — 5 миллисекунд. Но за 9 тактов обрабатываются только 3 инструкции:
Такт: 1 2 3 4 5 6 7 8 9
Fetch: [i1] . . [i2] . . [i3] . .
Decode: . [i1] . . [i2] . . [i3] .
Execute: . . [i1] . . [i2] . . [i3]
Пока блок Execute работает, блоки Fetch и Decode простаивают — две трети процессора не делают ничего.
Конвейер
Проблема наивной модели в том, что три блока не могут работать одновременно над разными инструкциями. Pipeline (конвейер) решает это: пока Execute обрабатывает инструкцию N, Decode разбирает инструкцию N+1, а Fetch загружает N+2.
Такт: 1 2 3 4 5 6 7
Fetch: [i1] [i2] [i3] [i4] [i5] . .
Decode: . [i1] [i2] [i3] [i4] [i5] .
Execute: . . [i1] [i2] [i3] [i4] [i5]
Первая инструкция по-прежнему выходит через 3 такта (это задержка — latency). Но начиная с третьего такта каждый следующий такт завершает одну инструкцию. Пропускная способность (throughput) выросла с одной инструкции за 3 такта до одной инструкции за 1 такт — втрое, при той же частоте. Конвейер не ускоряет отдельную инструкцию, но загружает все блоки процессора параллельно. Наш цикл суммирования теперь выполняется за ~5 миллионов тактов вместо 15 — примерно 1.7 миллисекунды вместо 5.
Аналогия: конвейер на заводе. Если один рабочий собирает машину от начала до конца — он тратит 3 часа на штуку. Если три рабочих встают в цепочку и каждый делает свою операцию — первая машина всё ещё выходит через 3 часа, но после этого каждый час готова ещё одна. Производительность выросла в 3 раза, хотя каждый рабочий работает с той же скоростью.
Реальные процессоры делят работу не на 3, а на 14–20+ стадий. Больше стадий — более мелкая работа на каждой, а значит можно поднять частоту (короткая стадия завершается быстрее, такт можно укоротить). Но длинный конвейер создаёт новую проблему: чем больше стадий, тем дороже обходится ситуация, когда конвейер не может работать на полную мощность.
Конвейер ломается: зависимость по данным
Конвейер эффективен только пока все стадии заполнены. Когда следующая инструкция нуждается в результате предыдущей, конвейер вынужден ждать.
Data hazard (конфликт данных) — инструкция на стадии Decode нуждается в результате инструкции, которая ещё не дошла до конца Execute. Допустим, i1: r1 = r2 + r3, а i2: r4 = r1 * 5 — второй инструкции нужен r1, который ещё вычисляется. Конвейер вынужден вставить «пузырь» (bubble) — пустой такт, пока данные не будут готовы:
Такт: 1 2 3 4 5 6
Fetch: [i1] [i2] [i3] . [i4] [i5]
Decode: . [i1] [i2] [i2] [i3] [i4]
Execute: . . [i1] bub [i2] [i3]
^^^
пузырь: i2 ждёт результата i1
Частичное решение — forwarding (проброс): результат Execute передаётся напрямую на вход следующей стадии, минуя запись в регистровый файл. Как только i1 завершает сложение, результат тут же подаётся на вход i2 — без ожидания. Это спасает от одного-двух тактов задержки, но не помогает, когда результат приходит из памяти (load-use hazard): данные из RAM идут десятки и сотни тактов, и никакой проброс их не ускорит. Эту проблему в полной мере решает уже не сам конвейер, а внеочерёдное исполнение — о нём ниже.
Ветвления и предсказание переходов
Forwarding спасает от задержек внутри арифметики, но есть проблема дороже: условные переходы. В коде постоянно встречаются if, for, while. При команде if (x > 0) процессор на стадии Fetch не знает, какую инструкцию загружать следующей — ту, что в ветке then, или ту, что в ветке else. Результат сравнения станет известен только через несколько тактов, когда инструкция дойдёт до Execute.
Наивное решение — остановить конвейер и ждать результата. При 14 стадиях это означает простой в 14 тактов на каждом ветвлении. В типичном коде условный переход встречается каждые 5–7 инструкций. Если останавливаться каждый раз, конвейер пуст больше половины времени — от его преимуществ не остаётся ничего.
Branch prediction (предсказание переходов) — механизм, который угадывает направление перехода до того, как результат станет известен. Процессор загружает инструкции из предсказанной ветки и продолжает работу, как будто ответ уже получен. Если предсказание верное — никаких потерь. Если неверное — все инструкции, загруженные «по ошибке», сбрасываются, и конвейер заполняется заново. Штраф за промах (misprediction penalty) составляет ~15–20 тактов на современных процессорах — именно столько нужно, чтобы заново заполнить конвейер правильными инструкциями.
Ветвление в нашем цикле sum += arr[i] — лёгкий случай: переход на начало берётся 999 999 раз подряд, предсказатель ошибается только на последней итерации. Один промах на миллион — ничтожно, цикл суммирования branch prediction практически не замедляет. Тяжёлый случай — когда направление перехода зависит от данных:
for (int i = 0; i < N; i++) {
if (data[i] >= 128) // условный переход
sum += data[i];
}Если массив отсортирован, переход образует паттерн: сначала все элементы меньше 128 (переход не берётся), потом все больше (переход берётся). Предсказатель выучивает паттерн и почти не ошибается. На неотсортированном массиве со случайными значениями направление перехода хаотично — предсказатель угадывает ~50% случаев. Результат: один и тот же код на отсортированных данных выполняется в 2–5 раз быстрее, чем на неотсортированных, и разница полностью определяется промахами предсказателя. Точность современных предсказателей на реальных программах — 95–99%.
Внутри предсказатели устроены как двухбитные счётчики с четырьмя состояниями («сильно/слабо взят/не взят») на каждый адрес перехода: такая схема устойчива к единичным аномалиям и даёт уже ~90% точности. Продвинутые предсказатели (TAGE — TAgged GEometric history length) дополнительно учитывают глобальную историю других переходов — это позволяет улавливать корреляции вида «если предыдущие два if-а пошли по true, третий тоже».
Задача: оценить потери от промахов предсказания
Процессор с конвейером в 16 стадий выполняет программу из 1000 инструкций, в которой каждая 6-я — условный переход. Предсказатель имеет точность 97%. Сколько тактов потеряно на промахах?
Частая ошибка: считать только число промахов, забывая о длине конвейера.
Переходов: 1000 / 6 ≈ 167. Промахов: 167 × 0.03 ≈ 5. Штраф за промах ≈ длина конвейера = 16 тактов. Потери: 5 × 16 = 80 тактов. При идеальном конвейере 1000 инструкций выполняются за ~1000 тактов (IPC = 1), так что 80 тактов — это ~8% замедления. С точностью 90% промахов было бы 17, потери — 272 такта, ~27% замедления. Отсюда видно, почему разница между 97% и 90% точности предсказания — не «незначительные 7%», а трёхкратное различие в потерях.
Суперскалярное исполнение
Даже при идеальном предсказании переходов пропускная способность конвейера ограничена: одна инструкция за такт. При 3 ГГц — 3 миллиарда инструкций в секунду. Чтобы ускориться дальше, нужно выполнять несколько инструкций за один такт.
Конвейер сделал так, чтобы каждый блок работал на каждом такте. Суперскалярная архитектура (superscalar, буквально «сверхскалярная» — способная обрабатывать больше одного скалярного значения за такт) делает следующий шаг: дублирует исполнительные блоки. Вместо одного ALU процессор содержит несколько: два-три для целочисленных операций, два для операций с плавающей точкой (FPU, Floating Point Unit — блок вещественной арифметики), отдельные блоки для загрузки и записи в память. На каждом такте процессор выбирает, декодирует и отправляет на исполнение не одну, а несколько инструкций параллельно — столько, сколько позволяют независимые ресурсы.
Такт: 1 2 3 4
ALU 0: [i1] [i3] [i5] [i7]
ALU 1: [i2] [i4] [i6] [i8]
FPU: . [f1] [f2] [f3]
Load: [ld1] [ld2] [ld3] [ld4]
Ширина суперскалярного процессора (issue width) — количество инструкций, которые он может запустить за один такт. Современные процессоры имеют ширину 4–6: Intel Golden Cove (Alder Lake, 2021) — до 6 микроопераций (внутренних команд процессора; подробнее ниже) за такт, Apple Firestorm (M1, 2020) — до 8.
Ключевая метрика — IPC (instructions per clock, инструкций за такт). Не суперскалярный конвейер даёт IPC = 1. Суперскалярный процессор с шириной 4 теоретически может дать IPC = 4, но на практике достигает 2–4 в зависимости от кода: не всегда удаётся найти 4 независимые инструкции подряд, не все исполнительные блоки загружены каждый такт, и зависимости по данным создают простои. Производительность процессора определяется формулой:
Производительность ≈ Частота × IPC
Процессор на 3 ГГц с IPC = 1 выполняет 3 миллиарда инструкций в секунду. Процессор на 3 ГГц с IPC = 3 — 9 миллиардов. Вот почему два процессора на одной частоте показывают разный результат: серверный процессор 2010-х с IPC ~1.5 и Apple M1 с IPC ~3–4 оба на 3 ГГц, но M1 выполняет вдвое-втрое больше работы за каждый такт.
Но загрузить все блоки удаётся не всегда. Structural hazard (конфликт ресурсов) возникает, когда две инструкции одновременно нуждаются в одном блоке — например, единственном делителе. Одна из них ждёт. Решается дублированием ресурсов, но каждый дополнительный блок стоит транзисторов и энергии, поэтому редко используемые блоки (делитель, криптографические инструкции) обычно не дублируют.
В нашем цикле суммирования зависимости внутри одной итерации распадаются на две независимые цепочки. Первая: загрузить arr[i] → прибавить к сумме (сложение ждёт загруженного значения). Вторая: увеличить индекс → сравнить с границей → перейти на начало (сравнение ждёт нового индекса, переход — сравнения). Суперскалярный процессор тянет обе цепочки параллельно на разных блоках: в первом такте стартуют загрузка и увеличение индекса, во втором — сложение и сравнение, в третьем — переход. Три такта на пять инструкций вместо пяти последовательных — IPC поднимается с 1 до ~5/3 ≈ 1.7, почти вдвое против скалярного конвейера.
Помимо целочисленных ALU и FPU, суперскалярный процессор может содержать и другие специализированные блоки — например, FMA (Fused Multiply-Add — совмещённое умножение-сложение) для задач линейной алгебры и блоки SIMD (Single Instruction, Multiple Data — одна инструкция, много данных) для обработки нескольких чисел одной инструкцией. Подробно об этом — в заметке о SIMD.
На x86-процессорах сложные инструкции переменной длины на стадии Decode разбиваются на микрооперации (micro-ops, μops) — простые внутренние команды фиксированной длины, с которыми работают конвейер и суперскалярный движок. Подробнее о различиях наборов команд — CISC, RISC и их влиянии на энергоэффективность.
Внеочерёдное исполнение
Суперскалярный процессор может запустить несколько инструкций параллельно, но только если они независимы — не используют результат друг друга. В коде a = b + c; d = a * 2 вторая инструкция зависит от первой: значение a нужно, чтобы вычислить d. Параллельно их запустить нельзя. Чем больше зависимостей в коде, тем ниже реальный IPC. Нужен способ не ждать, а делать что-то полезное.
Out-of-order execution (OoO, внеочерёдное исполнение) разрешает процессору выполнять инструкции не в том порядке, в котором они записаны в программе, а в порядке готовности данных. Если инструкция N ждёт результата из памяти, а инструкции N+1, N+2, N+3 от неё не зависят — процессор находит их и запускает раньше.
Под капотом этот механизм держат три структуры, работающие в паре: reservation station (RS) — очередь ожидания операндов, выдающая инструкцию на исполнение по готовности; reorder buffer (ROB) — журнал исходного порядка, по которому результаты коммитятся обратно «как было в программе»; register renaming — таблица физических регистров, снимающая ложные зависимости по переиспользованным именам. Все три работают параллельно с каждой декодированной инструкцией, и вместе они позволяют суперскаляру найти независимую работу даже в сильно связанном коде.
Цена — сложность и энергия: сотни миллионов транзисторов на отбор готовых инструкций, коммит, физический регистровый файл. В серверных и десктопных процессорах эта цена давно принята за данность, в микроконтроллерах и ультранизкопотребляющих ядрах OoO урезают или выключают полностью. Подробное устройство RS, ROB, register renaming и memory disambiguation — в отдельной заметке внеочерёдное исполнение.
Спекулятивное исполнение
Предсказание переходов говорит процессору, куда пойдёт ветвление. Внеочерёдное исполнение позволяет выполнять инструкции, не дожидаясь подтверждения. Вместе они образуют speculative execution (спекулятивное исполнение): процессор выполняет инструкции из предсказанной ветки до того, как условие вычислено. Если предсказание верное — результаты фиксируются в ROB как обычно. Если нет — ROB отбрасывает всё, что было сделано спекулятивно, и конвейер начинает заново с правильной ветки.
Спекулятивное исполнение критически важно для производительности: без него OoO-процессор останавливался бы на каждом условном переходе, не имея возможности заглянуть дальше. Учитывая, что переходы встречаются каждые 5–7 инструкций, без спекуляции ROB быстро опустеет — нечего будет выполнять параллельно. Помимо ветвлений, процессор так же спекулятивно выполняет и загрузки из памяти, следующие за не зафиксированными записями — там появляется собственная ставка memory disambiguation на непересечение адресов. Побочный эффект спекуляции — основа атак Spectre и Meltdown, о них чуть ниже.
Запись в память — единственная операция, которую спекулятивно сделать нельзя: после записи в RAM откатить её уже невозможно. Поэтому store-инструкции после исполнения уходят в отдельный буфер и лежат там до коммита — как именно, и как этот же буфер помогает скрывать задержку записи, разбирается в store buffer.
Цена спекуляции: Spectre и Meltdown
Сам принцип спекулятивного исполнения — выполнить инструкции до того, как известно, можно ли было, а потом откатить регистры при ошибке — стал причиной двух крупнейших аппаратных уязвимостей, Spectre и Meltdown, обнаруженных в январе 2018 года. Общий принцип обеих атак: при спекулятивном выполнении процессор обращается к данным, к которым у программы нет доступа. Регистры потом откатываются, но побочный эффект — изменение состояния кеша (промежуточной памяти между процессором и RAM — подробно в следующей заметке) — остаётся. Кеш откату не подлежит: это отдельная структура, которую спекулятивный код успевает «нагреть» до того, как ошибка заметится. Атакующий измеряет тайминг доступа к кеш-линиям и по тому, какая из них «нагрета», восстанавливает секретные данные. Meltdown затрагивает процессоры Intel, ARM Cortex-A75 и IBM POWER; AMD к Meltdown не уязвим. Spectre — более фундаментальная проблема предсказания переходов и затрагивает в той или иной форме всю индустрию. Детали вариантов атак и защит — отдельная тема безопасности; здесь важно, что сама спекуляция оказалась наблюдаемой снаружи через кеш.
Стена памяти
Конвейер, суперскалярность, OoO, спекулятивное исполнение — все эти техники увеличивают количество полезной работы, которую процессор выполняет за каждый такт. Но есть ограничение, которое они не могут преодолеть: скорость доступа к данным.
Каждая арифметическая операция требует операндов. Если операнды в регистрах — один такт, ~0.33 нс. Если в оперативной памяти — ~100 нс, что при 3 ГГц составляет ~300 тактов простоя. За эти 300 тактов суперскалярный процессор с шириной 6 мог бы выполнить до 1800 операций — но он ждёт одного числа из RAM.
Проблема глубже, чем кажется. Тактовая частота процессоров росла экспоненциально с 1980-х по середину 2000-х: от 5 МГц (Intel 8086, 1979) до 3.8 ГГц (Pentium 4, 2004) — рост в ~760 раз. Задержка оперативной памяти за то же время улучшилась лишь в ~1.5–2 раза. Разрыв между скоростью процессора и задержкой памяти увеличивается с каждым поколением — это и есть «стена памяти» (memory wall). Почему DRAM не может стать быстрее — вопрос физики хранения данных, разобранный в отдельной заметке.
Для нашего цикла суммирования это означает: даже с OoO-перекрытием загрузок, если массив не помещается в быстрое хранилище рядом с процессором, каждое обращение к arr[i] стоит сотни тактов. Вычисления упираются не в арифметику, а в ожидание данных.
Между регистрами и оперативной памятью необходим промежуточный слой — кеш-память (cache), которая хранит копии часто используемых данных ближе к процессору. Стена памяти ведёт к иерархии кешей. Другое направление от той же развилки — какой именно набор команд процессор предоставляет программе и как это влияет на энергоэффективность: CISC и RISC.
От одного ядра к нескольким
Всё описанное — конвейер, суперскалярность, OoO, спекуляция — это архитектура одного ядра. Современные процессоры помещают таких ядер на кристалл несколько (8–16 в десктопах, 64–128 в серверах), и как только ядра начинают работать с общими данными, встаёт новая задача: согласовать их взгляд на эти данные. Именно поэтому сразу после иерархии памяти идёт когерентность кешей.
Sources
- John L. Hennessy, David A. Patterson, 2017, Computer Architecture: A Quantitative Approach — 6th edition: https://www.elsevier.com/books/computer-architecture/hennessy/978-0-12-811905-1
- David A. Patterson, John L. Hennessy, 2020, Computer Organization and Design RISC-V Edition — 2nd edition
- Fog, Agner, 2024, The microarchitecture of Intel, AMD, and VIA CPUs — optimization reference: https://www.agner.org/optimize/microarchitecture.pdf
- Kocher, Paul et al., 2019, Spectre Attacks: Exploiting Speculative Execution: https://spectreattack.com/spectre.pdf
- Brendan Gregg, 2018, KPTI/KAISER Meltdown Initial Performance Regressions — PostgreSQL 7–17% degradation: https://www.brendangregg.com/blog/2018-02-09/kpti-kaiser-meltdown-performance.html
Путь данных: Иерархия памяти | Программная модель: CISC и RISC | Внеочерёдное исполнение →