12. Суперскалярность, Out Of Order Execution и Register Renaming
В предыдущей главе 11. Конвейеризация. Почему CPU исполняет несколько инструкций одновременно мы совершили качественный рывок: превратили исполнение машинных инструкций в непрерывный промышленный конвейер. Это позволило ядру завершать по одной инструкции за каждый такт генератора, доведя показатель IPC (Instructions Per Cycle) до заветной единицы: $IPC = 1.0$.
Но если вы откроете спецификацию современных серверных и десктопных процессоров (Apple Silicon M-серии, Intel Core Ultra или AMD Zen 5), вы обнаружите невероятный факт: их ширина (сколько инструкций ядро способно выдать и завершить за один такт) достигает 6, 8 или даже больше. Реальный же IPC на типичном коде обычно составляет 1–4: пиковая ширина — это потолок, а не то, что достигается постоянно.
Как процессорное ядро умудряется завершать шесть операций за те крошечные 250 пикосекунд, пока длится один такт на частоте 4 ГГц? Неужели законы физики перестали действовать?
Ответ кроется в трех сложнейших и самых красивых микроархитектурных концепциях в истории кремния:
- Суперскалярность (Superscalar Architecture): превращение однополосной дороги в многополосный автобан;
- Внеочередное исполнение (Out-of-Order Execution, OoOE): восстание против строгого порядка кода;
- Переименование регистров (Register Renaming): разрушение ложных зависимостей с помощью иллюзии сотен невидимых регистров.
Благодаря этой тройке ваш код на Go работает в 3–5 раз быстрее, чем если бы процессор исполнял его строго по инструкциям из ассемблерного листинга. Давайте заглянем в эту кремниевую симфонию.
1. Суперскалярность: Расширение шоссе
Классический конвейер из предыдущей статьи можно сравнить с идеальной однополосной трассой. Даже если автомобили летят бампер к бамперу с нулевой дистанцией, через пункт оплаты физически не может проехать больше одной машины в секунду.
Суперскалярный процессор (Superscalar CPU) строит рядом 4, 6 или 8 параллельных полос и открывает 8 терминалов оплаты:
flowchart TD
subgraph SingleCore ["Суперскалярное ядро CPU (Вычислительный кластер)"]
direction TB
Fetch["Блок выборки (Fetch): 6–8 инструкций за такт"] --> Decode["Блок декодирования (Decode): 6 параллельных декодеров"]
Decode --> Dispatch["Диспетчер портов (Issue / Dispatch)"]
Dispatch --> Port0["Порт 0: ALU 0 + Branch"]
Dispatch --> Port1["Порт 1: ALU 1 + Сдвиг"]
Dispatch --> Port2["Порт 2: ALU 2 (Арифметика)"]
Dispatch --> Port3["Порт 3: Векторный блок SIMD / FPU 0"]
Dispatch --> Port4["Порт 4: Векторный блок SIMD / FPU 1"]
Dispatch --> Port5["Порт 5: AGU (Load 0, Чтение памяти)"]
Dispatch --> Port6["Порт 6: AGU (Load 1, Чтение памяти)"]
Dispatch --> Port7["Порт 7: AGU (Store, Запись памяти)"]
end
classDef core fill:#131d33,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
classDef ports fill:#1e293b,stroke:#6366f1,stroke-width:1px,color:#f8fafc;
class SingleCore,Fetch,Decode,Dispatch core;
class Port0,Port1,Port2,Port3,Port4,Port5,Port6,Port7 ports;
(Схема портов — обобщённая иллюстрация идеи. Реальная раскладка портов у каждого ядра своя, у Intel, AMD и Apple она отличается.)
Инженеры многократно продублировали исполнительные блоки внутри каждого ядра:
- 3–5 целочисленных ALU: для параллельного сложения, вычитания и битовых операций;
- 2–4 блока FPU / SIMD: для параллельных векторных вычислений и операций с плавающей точкой;
- 2–3 блока генерации адресов (AGU — Address Generation Unit): для одновременного чтения нескольких адресов памяти;
- Выделенные порты записи (Store Data): для выгрузки результатов в кэш.
На фазе выборки широкий декодер забирает из кэша инструкций L1i целую пачку команд (например, до 6 инструкций в x86-64 или до 8 в Apple M3). Если инструкции не зависят друг от друга, диспетчер отправляет их на разные порты: пока одно ALU складывает операнды A и B, второе ALU параллельно вычитает C и D, а блок AGU загружает данные из оперативной памяти.
Все эти операции выдаются на исполнение одновременно в одном такте (а вот завершаются они с разной латентностью: сложение — за 1 такт, загрузка из кэша — за несколько).
2. Out of Order Execution: Бунт против порядка
Суперскалярность работает великолепно, пока инструкции в коде независимы. Но в реальных программах на Go данные жестко переплетены:
a := readFromMemory(ptr) // Инструкция 1: долгое чтение из памяти RAM (~60 нс / 200 тактов)
b := a + 10 // Инструкция 2: жестко ждет 'a'
c := 100 * 5 // Инструкция 3: операнды константны, полностью независима!
d := c - 2 // Инструкция 4: ждет только 'c'
Что произошло бы в строгом конвейере (In-Order), где инструкции обязаны выполняться в порядке их следования в бинарнике?
- Инструкция 1 промахивается мимо кэша и зависает на 200 тактов в ожидании ответа от планки DDR5.
- Инструкция 2 заблокирована, так как ждет
a. - Все ядро процессора с его четырьмя мощными ALU замирает в ступоре на 200 тактов!
- Инструкции 3 и 4, которым до переменной
aнет никакого дела и которые можно посчитать за полтакта, безропотно стоят в очереди и ждут!
Чтобы победить это безумие, архитекторы внедрили Внеочередное исполнение (Out-of-Order Execution, OoOE).
Метафора кухни ресторана
Представьте шеф-повара на кухне:
- Заказ №1: Сварить сложный французский луковый суп (требует 40 минут тушения).
- Заказ №2: Нарезать салат (требует 2 минуты).
- Заказ №3: Разлить по бокалам сок (требует 10 секунд).
Шеф-повар не стоит 40 минут над кастрюлей супа, сложа руки! Он ставит бульон на медленный огонь (отправляет асинхронный запрос в контроллер памяти) и немедленно берется за нарезку салата и розлив напитков.
Как работает OoOE в кремнии
Современный процессор ведет себя как распределенная вычислительная сеть:
flowchart TD
FetchOrder["1. Выборка и Декодирование<br>(Строго In-Order)"] --> RS["2. Станции резервирования (Reservation Stations / Scheduler)<br>Пул ожидания готовности операндов"]
RS -->|"Операнды a не готовы"| Wait["Инструкция 2: Ждет данные из RAM"]
RS -->|"Операнды c готовы!"| Exec["<b>Вне очереди!</b><br>Инструкция 3 и 4 немедленно в ALU"]
Wait -.->|"Через 200 тактов данные пришли"| ExecLater["Инструкция 2 исполняется"]
Exec --> ROB["3. Reorder Buffer (ROB)<br>Переупорядочивание и коммит (Строго In-Order)"]
ExecLater --> ROB
classDef stage fill:#131d33,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
classDef ooo fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc;
class FetchOrder,ROB stage;
class RS,Wait,Exec,ExecLater ooo;
- In-Order Front-End: Команды загружаются из памяти и декодируются строго по порядку.
- Reservation Stations (Станции резервирования): Декодированные команды попадают в единый пул ожидания. Здесь они сидят до тех пор, пока на их входах не появятся физические данные.
- Out-of-Order Dispatch: Как только операнды команды готовы (например, для
c := 100 * 5числа100и5уже на месте), планировщик мгновенно выстреливает ее на свободный порт ALU! - Пока медленная Инструкция 1 ждет RAM, ядро успевает вычислить десятки независимых последующих команд. Процессор превращается в машину потока данных (Dataflow Engine).
3. Register Renaming: Иллюзия аппаратных регистров
Когда процессор начинает выполнять команды вне очереди, он сталкивается с фундаментальным тупиком — Ложными зависимостями по именам (Name Dependencies).
Вспомним 8. ISA. Интерфейс между железом и софтом: в архитектуре x86-64 программисту и компилятору доступно всего 16 архитектурных регистров. Компилятор Go вынужден непрерывно переиспользовать одни и те же регистры (AX, BX) для совершенно не связанных между собой вычислений.
Взглянем на этот ассемблерный фрагмент:
MOVQ $10, AX // (1) AX = 10
ADDQ AX, BX // (2) BX = BX + AX (Ждет инструкцию 1: истинная зависимость RAW)
MOVQ $20, AX // (3) AX = 20 (Перезапись AX новым значением!)
SUBQ AX, CX // (4) CX = CX - AX (Ждет инструкцию 3)
(Синтаксис Go-ассемблера Plan 9: источник слева, приёмник справа, как мы разбирали в 9. Базовый Ассемблер. Как CPU видит код.)
Логически блок (1–2) и блок (3–4) абсолютно независимы! Блок (3–4) использует имя AX лишь потому, что у компилятора кончились свободные регистры.
Но процессор видит конфликт:
- WAW (Write-After-Write): Инструкция 3 не может записать
20вAXраньше, чем Инструкция 1 запишет10. - WAR (Write-After-Read): Инструкция 3 не может перезаписать
AX, пока Инструкция 2 не прочитает старое значение10!
Внеочередное выполнение должно было бы остановиться. Но инженеры совершили фокус: Переименование регистров (Register Renaming).
Физический регистровый файл (PRF)
16 регистров (AX, BX...), которые мы видим в ассемблере — это архитектурная иллюзия.
Физически на кремниевом кристалле распаян гигантский массив из сотен реальных регистров — Physical Register File (PRF) (например, 224 регистра в AMD Zen 4 или более 300 в Apple M3).
Специальная аппаратная таблица маппинга (RAT — Register Alias Table) на этапе декодирования на лету подменяет имена регистров:
flowchart LR
subgraph ArchCode ["Архитектурный код Go (Иллюзия)"]
I1["(1) MOVQ $10, AX"]
I2["(2) ADDQ AX, BX"]
I3["(3) MOVQ $20, AX"]
I4["(4) SUBQ AX, CX"]
end
subgraph PhysCode ["Физическое исполнение в PRF (Реальность)"]
P1["(1) MOVQ $10, P1"]
P2["(2) ADDQ P1, BX"]
P3["(3) MOVQ $20, P2 <-- Подмена AX на P2!"]
P4["(4) SUBQ P2, CX"]
end
I1 --> P1
I2 --> P2
I3 --> P3
I4 --> P4
classDef arch fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#f8fafc;
classDef phys fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc;
class ArchCode,I1,I2,I3,I4 arch;
class PhysCode,P1,P2,P3,P4 phys;
Для первой команды архитектурный AX связывается с физической ячейкой P1.
Для третьей команды тот же архитектурный AX связывается с совершенно другой физической ячейкой P2!
Ложные конфликты WAW и WAR мгновенно испаряются:
- Цепочка (1)
MOVQ $10, P1$\to$ (2)ADDQ P1, BXвыполняется на ALU 0; - Цепочка (3)
MOVQ $20, P2$\to$ (4)SUBQ P2, CXодновременно и параллельно выполняется на ALU 1!
4. Reorder Buffer (ROB): Восстановление порядка
Если ядро процессора перемалывает команды в хаотичном внеочередном порядке, возникает тревожный инженерный вопрос:
Что произойдет, если Инструкция 1 вызовет ошибку сегментации (аппаратное исключение)? А если прилетит аппаратное прерывание от сетевой карты? (Заметим: panic() в Go — это программный механизм рантайма, к ROB он отношения не имеет; ROB сбрасывают аппаратные исключения, прерывания и ошибки предсказания ветвлений.)
Если бы процессор сохранял результаты в память хаотично, операционная система обнаружила бы кошмар: Инструкции 3 и 4 уже сделали бы свои записи видимыми для других ядер, а упавшая Инструкция 1 всё еще висит! (На самом деле запись в память не покидает буфер записей store buffer до момента завершения инструкции — retirement — именно поэтому такой кошмар и не случается.) Программа оказалась бы в разорванном, неконсистентном состоянии.
Поэтому процессор обязан соблюдать железное правило:
Исполняться команды могут вне очереди (Out-of-Order Execution), но фиксироваться результаты ОБЯЗАНЫ строго по порядку (In-Order Retirement / Commit)!
Для этого служит Буфер переупорядочивания (Reorder Buffer — ROB):
- Каждая декодированная команда встает в кольцевой буфер ROB в строгом исходном порядке программы.
- Команды вычисляются на свободных ALU вне очереди; их временные результаты хранятся либо в слотах ROB (классическая схема P6), либо — в современных ядрах — в физическом регистровом файле (PRF), а ROB хранит лишь порядок и статус.
- Инструкция покидает голову буфера ROB (Retirement) и переносит свои данные в видимые архитектурные регистры только тогда, когда все предыдущие команды успешно завершились без ошибок!
- Если при вычислении произошло аппаратное исключение, прерывание или была неверно предсказана ветвь, процессор просто мгновенно сбрасывает хвост буфера ROB: все результаты внеочередных вычислений бесследно испаряются, состояние машины остается безупречно чистым.
Mechanical Sympathy: Data-Oriented Design в Go
Какое практическое значение эти сложнейшие кремниевые механизмы имеют для нас — инженеров, пишущих на Go?
Они дают железобетонный ответ на извечный холивар: почему связные списки и разветвленные деревья в реальных продакшн-системах почти всегда драматически проигрывают плоским слайсам ([]T), даже если алгоритмическая оценка по $O(N)$ у них одинакова?
Связный список: Убийца внеочередного окна
Посмотрим на классический обход связного списка:
type Node struct {
Val int
Next *Node
}
func sumList(head *Node) int {
sum := 0
for curr := head; curr != nil; curr = curr.Next {
sum += curr.Val
}
return sum
}
Связный список создает Pointer Chasing (погоню за указателями) — идеальную, неразрывную цепь зависимостей:
- Процессор не может узнать адрес узла $N+1$, пока не прочитает поле
Nextиз памяти узла $N$. - Окно внеочередного выполнения (ROB на 512 инструкций) оказывается абсолютно бесполезным: в него нельзя загрузить будущие итерации цикла!
- Мощнейший суперскалярный процессор с 6 портами ALU деградирует до уровня простейшего калькулятора 1970-х годов: он ждет ответа от оперативной памяти на каждом шаге, а его реальный $IPC$ падает с потенциальных 4.0 до позорных $0.05\dots 0.1$ — если узлы списка разбросаны по памяти и каждый переход — промах кэша. (Если же узлы лежат рядом — а аллокатор Go нередко размещает последовательно созданные узлы подряд, — результат будет заметно лучше; всё определяется раскладкой в памяти.)
Непрерывный слайс: Рай для суперскалярности
А теперь взглянем на суммирование плоского слайса:
func sumSlice(nums []int) int {
sum := 0
for i := 0; i < len(nums); i++ {
sum += nums[i]
}
return sum
}
В слайсе адрес следующего элемента вычисляется по элементарной арифметической формуле ptr + i*8 ($\text{Address}(i) = \text{ptr} + i \times 8$):
Процессор видит эту регулярность:
- Он моментально разворачивает цикл на 100 итераций вперед в буфере ROB;
- Блоки AGU параллельно посылают десятки запросов в память — это называется Memory-Level Parallelism (MLP);
- Контроллер памяти загружает целые строки кэша (Cache Lines по 64 байта) за один присест;
- Все параллельные порты ALU загружены работой на 100%, и процессор выдает $IPC > 3.0$.
Слайс работает быстрее связного списка не просто потому, что он «компактнее лежит в памяти». Он быстрее потому, что позволяет суперскалярному Out-of-Order ядру раскрыть свою истинную природу параллельных вычислений.
Итог
- Суперскалярность (Superscalar): наличие нескольких независимых вычислительных портов (ALU, FPU, AGU) внутри одного ядра, позволяющее декодировать и завершать несколько инструкций за такт ($IPC > 1.0$).
- Out-of-Order Execution (OoOE): разрыв строгого порядка программы. Инструкции ждут готовности операндов в Станциях резервирования и исполняются сразу, как только освободился подходящий вычислительный блок.
- Register Renaming: аппаратная подмена 16 архитектурных регистров ассемблера на сотни скрытых физических регистров (PRF), полностью устраняющая ложные зависимости WAW и WAR.
- Reorder Buffer (ROB): гарантирует консистентность состояния системы. Команды вычисляются вне очереди, но фиксируют результаты в памяти строго последовательно (In-Order Retirement).
- Mechanical Sympathy: структуры данных на базе указателей (списки, деревья) парализуют Out-of-Order окно процессора из-за цепочек зависимостей. Плоские структуры на базе слайсов активируют Memory-Level Parallelism (MLP) и нагружают все аппаратные порты ядра на максимум.
Мы научили процессор заглядывать вперед на сотни инструкций в буфере ROB. Но что происходит, если на этом пути ядро натыкается на условный оператор if? Как процессор может заглядывать вперед, если он еще не знает, выполнится ли условие?
В следующей лекции мы познакомимся с самым дерзким и опасным механизмом современного процессора — спекулятивным исполнением и предсказанием будущего: 13. Предсказание ветвлений и Спекулятивное исполнение.