Конвейер: как процессор делает несколько дел одновременно
Есть простое наблюдение, из которого выросла вся современная микроархитектура. Когда процессор выполняет одну команду, большая часть его схем простаивает: пока АЛУ считает сумму, кэш команд не занят ничем, регистровый файл уже отдал операнды и ждёт, блок записи результата бездельничает. Оборудование оплачено целиком, а работает по очереди — примерно как если бы на заводе стоял один рабочий, который последовательно обходит пять станков.
Конвейер — это ответ: пусть все пять станков работают одновременно, каждый над своим изделием. Идея настолько универсальна, что встречается везде — от сборочной линии Форда до обработки запросов в веб-сервере, — но именно в процессоре она наталкивается на неприятную особенность: изделия на ленте зависят друг от друга. Следующая команда может нуждаться в результате предыдущей. Может оказаться условным переходом, из-за которого вообще неизвестно, какие команды должны идти следом. Может вызвать исключение и потребовать, чтобы всё, что уже частично выполнено дальше по ленте, было аккуратно отменено — да так, чтобы программа этого не заметила.
Эта глава — про механику перекрытия и про цену этих трёх неприятностей.
Где мы находимся: что уже разобрано и что начинается здесь
Мы предполагаем, что базовая механика уже понятна. Цикл «выборка — декодирование — исполнение», регистры и АЛУ разобраны во вводном треке (Как работает процессор), путь от исходника к машинному коду — там же (От кода до исполнения), а регистры, стек и соглашения о вызовах с точки зрения программиста — в главе про ассемблер (Основы ассемблера).
Из этого трека нам понадобятся три вещи. Первая — понятие такта и критического пути: такт определяется самой длинной комбинационной цепочкой, а не средней (Транзистор и вентиль). Вторая — система команд как контракт: программа имеет право видеть только последовательное исполнение, а как оно достигается — дело реализации (Система команд). Третья — почему форма кодирования команд влияет на устройство фронтенда (RISC и CISC и RISC-V).
Чего здесь не будет: выдачи нескольких команд за такт и переупорядочивания — это следующая глава; серьёзного предсказания переходов — глава 08; поведения кэшей при промахах — глава 09. Здесь мы разбираем скелет, на который всё это навешивается: простой конвейер, исполняющий команды строго в порядке программы. Такой конвейер — не музейный экспонат: именно так устроены ядра микроконтроллеров, энергоэффективные ядра в мобильных чипах и большинство свободных реализаций RISC-V (Микроконтроллеры и периферия).
Идея: разделить работу, а не ускорить её
Ключевая мысль, которую нужно усвоить сразу: конвейер не делает ни одну отдельную команду быстрее. Он делает так, что за единицу времени их завершается больше.
Возьмём условную схему без конвейера. Пусть полная обработка команды — выборка, декодирование, арифметика, обращение к памяти, запись результата — занимает 800 пикосекунд комбинационной логики. Значит, такт не может быть короче примерно 800 пс плюс накладные расходы на защёлкивание, и машина выдаёт одну команду за такт длиной 800+ пс.
Теперь разрежем логику на пять примерно равных кусков и поставим между ними регистры. Каждый кусок — около 160 пс. Такт становится короче в пять раз… почти. К каждой ступени добавляются накладные расходы синхронной дисциплины: задержка выхода триггера, окно setup, перекос тактовой сети. Порядок этих накладных расходов для высокопроизводительных КМОП-ядер — единицы десятков пикосекунд, и это фиксированная добавка, которая не уменьшается вместе со ступенью.
Без конвейера: T_такта = T_логики + O
С k ступенями: T_такта = T_логики / k + O (при идеально равных ступенях)
Ускорение = (T_логики + O) / (T_логики / k + O)
При T_логики = 800 пс, O = 30 пс:
k = 1 → такт 830 пс, ускорение 1,00
k = 5 → такт 190 пс, ускорение 4,37
k = 10 → такт 110 пс, ускорение 7,55
k = 20 → такт 70 пс, ускорение 11,9
k = 40 → такт 50 пс, ускорение 16,6
k → ∞ → такт O пс, ускорение (T_логики + O) / O = 27,7 — недостижимый потолок
Числа условные, но пропорции реалистичны, и из них видно главное: отдача от углубления конвейера убывает. Каждая новая ступень добавляет полную порцию накладных расходов и отрезает всё меньший кусок полезной логики. И это ещё до того, как мы учли конфликты, из-за которых лента то и дело останавливается.
Латентность и пропускная способность — это разные вещи
Пятиступенчатый конвейер в установившемся режиме завершает одну команду за такт. Но каждая конкретная команда по-прежнему проводит в нём пять тактов. Первое — пропускная способность, второе — латентность.
Различие латентности и пропускной способности — сквозная тема всего трека. Оно вернётся в кэшах (промах имеет большую латентность, но контроллер может обслуживать несколько промахов параллельно), в памяти, в межъядерных шинах и в ускорителях, где пропускная способность покупается ценой чудовищной латентности отдельной операции (Ускорители).
Практическое следствие: на коротких участках кода конвейер не помогает. Если программа всё время прыгает по не предсказуемым адресам, лента постоянно опустошается и наполняется заново, и машина работает со скоростью, близкой к латентности, а не к пропускной способности.
Классические пять ступеней
Каноническое разбиение, которое живёт в учебниках со времён MIPS и на котором построены почти все учебные реализации RISC-V:
| Ступень | Что делает | Что может пойти не так |
|---|---|---|
| IF (fetch) | читает слово команды по адресу из PC, увеличивает PC | промах кэша команд, отказ страницы, неверный адрес |
| ID (decode) | разбирает поля команды, читает регистры, формирует immediate | нераспознанная команда, нарушение привилегий |
| EX (execute) | АЛУ: арифметика, вычисление адреса, сравнение для перехода | переполнение, деление на ноль |
| MEM (memory) | обращение к кэшу данных для load и store | промах, отказ страницы, невыровненный доступ |
| WB (write back) | запись результата в регистровый файл | ничего: это точка фиксации |
Обратите внимание на асимметрию: почти все неприятности случаются в начале и середине, а единственное необратимое действие — запись результата — происходит в самом конце. Это не случайность, а сознательный принцип проектирования, к которому мы вернёмся в разделе про исключения.
Что физически хранится между ступенями
Межступенчатый регистр (pipeline register, «латч») — это не одна защёлка, а широкая шина триггеров. В ней лежит всё, что понадобится последующим ступеням, потому что переспросить уже нельзя: предыдущая ступень занята следующей командой.
IF/ID : слово команды, PC этой команды, PC+4, признак «выборка дала отказ»
ID/EX : значения двух прочитанных регистров, immediate, номер регистра-приёмника,
управляющие сигналы для EX / MEM / WB, PC, признаки исключений
EX/MEM : результат АЛУ (или адрес), данные для записи в память, номер приёмника,
управляющие сигналы для MEM / WB, накопленные признаки исключений
MEM/WB : значение для записи в регистр (из АЛУ или из памяти), номер приёмника,
сигнал «писать или нет», накопленные признаки исключений
Три вывода отсюда — практические, а не декоративные.
- Управляющие сигналы едут вместе с данными. Декодер работает один раз, в ID, а его решения путешествуют по конвейеру как обычные биты. Именно поэтому «сложная команда» дорога не столько исполнением, сколько шириной этих регистров.
- Конвейер стоит площади и энергии. Каждый разрез добавляет сотни триггеров, которые переключаются каждый такт. Это одна из причин, по которым глубокий конвейер плохо сочетается с жёстким бюджетом мощности (Мощность и пределы).
- Состояние конвейера — это состояние машины. При переключении контекста операционная система не сохраняет содержимое межступенчатых регистров: конвейер просто доводит до конца или отменяет то, что в нём есть. Видимое состояние — только архитектурные регистры и память (Процессы и планирование).
Почему форма системы команд определяет форму конвейера
Пять ступеней укладываются так красиво не сами по себе. Это следствие трёх решений системы команд, которые мы разбирали в главе про RISC и CISC:
- Фиксированная длина команды. Ступень IF знает, где кончается команда, ещё до декодирования. Если длина переменная (x86 — от 1 до 15 байт), то перед декодированием нужен отдельный этап определения границ, и он плохо параллелится: чтобы узнать, где начинается вторая команда, надо разобрать первую.
- Операнды в фиксированных полях. Номера регистров лежат на одних и тех же битовых позициях почти во всех форматах RISC-V, поэтому чтение регистрового файла можно начать параллельно с декодированием, не дожидаясь, пока станет ясен тип команды.
- Модель load/store. Обращение к памяти отделено от арифметики, поэтому ступень MEM ровно одна и стоит в одном и том же месте. В архитектуре «регистр-память» одна команда может потребовать: посчитать адрес, сходить в память, выполнить арифметику, сходить в память снова. Уложить это в фиксированную последовательность ступеней невозможно.
Отсюда — практическое решение, которое приняли реализации x86: разбить сложные команды на внутренние микрооперации фиксированного вида и конвейеризовать уже их. Фронтенд платит за совместимость, бэкенд работает с чем-то очень похожим на RISC. Эту конструкцию мы подробно разбирали в главе 04.
Конфликты: три вида и три разных лечения
Конфликт (hazard) — ситуация, в которой следующая команда не может войти в свою ступень в запланированный такт. Их ровно три класса, и путать их не стоит: лечатся они по-разному.
нужный аппаратный блок?} B -- нет --> S1[Структурный конфликт
лечится дублированием блока
или его конвейеризацией] B -- да --> C{Готовы ли операнды?} C -- нет --> D{Есть ли обходной путь
от более поздней ступени?} D -- да --> F[Форвардинг: значение берётся
не из регистрового файла] D -- нет --> S2[Останов конвейера
вставляется пузырь] C -- да --> E{Известен ли адрес
следующей команды?} E -- нет --> S3[Конфликт по управлению
предположение и возможный сброс] E -- да --> G[Команда идёт дальше]
Структурные конфликты: не хватает железа
Классический пример из учебников: если кэш команд и кэш данных — это одна память с одним
портом, то ступень MEM команды load и ступень IF следующей команды дерутся за один и тот же
ресурс. Решение — разделить кэши первого уровня на L1i и L1d. Это, кстати, и есть исторический
ответ на вопрос, почему у процессора гарвардская организация на уровне кэшей при фон-неймановской
модели памяти в контракте (Иерархия памяти).
Структурные конфликты никуда не делись, просто переехали в более узкие места:
- Порты регистрового файла. Регистровый файл с восемью портами чтения и четырьмя записи — крупный и энергоёмкий блок; число портов всегда компромисс.
- Неконвейеризованные блоки. Делитель, как правило, не конвейеризован: он занят целиком на всё время операции. Порядок латентности целочисленного деления на высокопроизводительных ядрах конца 2010-х — единицы-десятки тактов в зависимости от разрядности и данных; это сильно больше, чем у сложения, и это одна из причин, по которым компиляторы так старательно заменяют деление на константу умножением со сдвигом (Оптимизации компилятора).
- Порт записи. Если результаты приходят от блоков с разной латентностью, они могут попытаться записаться в один и тот же такт.
Конфликты по данным: команда ждёт результат
Три вида зависимостей — их принято называть по тому, в каком порядке идут чтение и запись:
| Обозначение | Что происходит | Опасно ли в простом конвейере |
|---|---|---|
| RAW (read after write) | вторая команда читает то, что пишет первая | да — настоящая зависимость по данным |
| WAR (write after read) | вторая пишет в регистр, который читает первая | нет: чтение в ID происходит раньше записи в WB |
| WAW (write after write) | обе пишут в один регистр | нет, пока запись одна и в порядке программы |
WAR и WAW называют ложными зависимостями: они возникают не из-за потока данных, а из-за того, что регистров конечное число и имена переиспользуются. В строгом in-order конвейере с единственной точкой записи они безвредны. Но стоит появиться блокам с разной латентностью или внеочередному исполнению — и они превращаются в реальную проблему, ради которой изобрели переименование регистров (глава 07).
Форвардинг: не ждать записи, а взять по дороге
Наивная реализация требует, чтобы значение сначала доехало до WB и попало в регистровый файл, и только потом было прочитано в ID. Для соседних команд это три такта простоя. Но значение-то уже посчитано — оно лежит в межступенчатом регистре EX/MEM в конце того же такта, когда сработало АЛУ.
Форвардинг (он же bypass) — это набор мультиплексоров на входах АЛУ, которые умеют брать операнд не из регистрового файла, а прямо с выхода более поздней ступени. Основные пути:
EX/MEM → вход EX результат АЛУ предыдущей команды
MEM/WB → вход EX результат команды, отстоящей на две позиции
MEM/WB → вход MEM данные для store, только что прочитанные из памяти
Плюс маленький, но важный трюк: регистровый файл пишется в первой половине такта и читается во второй. Тогда команда в WB и команда в ID «расходятся» внутри одного такта без всякого обходного пути. Приём старый, зато экономит целый набор мультиплексоров.
Сеть обходных путей — не бесплатное решение. Каждый путь удлиняет критический путь перед АЛУ (появляется лишний мультиплексор), а сложность растёт квадратично с числом ступеней и исполнительных блоков. В широких внеочередных ядрах сеть обходов — один из главных ограничителей тактовой частоты.
Load-use: конфликт, который форвардинг не лечит
А вот случай, где даже полная сеть обходов бессильна. Результат загрузки становится известен только в конце ступени MEM. Если следующая команда нуждается в нём в своей ступени EX, а эта ступень идёт раньше, то данные физически ещё не существуют.
Обратите внимание: простаивает не только зависимая команда, но и все, кто идёт за ней. Пузырь (bubble) вставляется в ID/EX как «пустая команда», а ступени IF и ID замораживаются — им запрещают обновлять PC и IF/ID. Пузырь дальше едет по конвейеру и выходит через WB, ничего не записав.
Именно поэтому load-use — самая частая причина простоев в простом конвейере. И именно поэтому у большинства RISC-архитектур минимальная задержка загрузки составляет как минимум один такт даже при попадании в L1.
Что с этим делает компилятор
Планировщик команд (instruction scheduler) переставляет независимые команды так, чтобы заполнить слот между загрузкой и её потребителем. Разберём на RISC-V.
# Было: сумма двух полей структуры. Между lw и add — прямая зависимость
lw t0, 0(a0) # загрузка
lw t1, 4(a0) # загрузка
add t2, t0, t1 # t0 готов, но t1 — нет: пузырь
sw t2, 8(a0)
# Стало: планировщик развёл загрузку и её потребителя,
# вставив между ними независимую работу
lw t0, 0(a0)
lw t1, 4(a0)
addi a1, a1, 1 # независимая команда занимает слот
add t2, t0, t1 # к этому такту t1 уже приехал
sw t2, 8(a0)
Это одна из самых старых причин, по которым машинный код не совпадает с порядком строк в
исходнике; подробнее — в главе про
генерацию кода. Важный нюанс: планировщик
должен знать конкретную микроархитектуру. Расписание, оптимальное для in-order ядра, для
внеочередного не даёт почти ничего, а иногда вредит — потому и существуют флаги вроде
-mtune, отдельные от -march.
Конфликты по управлению: неизвестно, что выбирать дальше
Ступень IF должна знать адрес следующей команды в следующем такте. Для условного перехода этот адрес зависит от сравнения, которое выполняется в EX. Разрыв — несколько тактов, и всё это время конвейер обязан что-то выбирать.
простейшая стратегия — «переход не будет взят» F->>D: следующая команда по PC+4 (предположение) F->>D: ещё одна по PC+8 (предположение) D->>E: beq доходит до EX, сравнение выполнено alt Предположение верно E-->>F: продолжай, ничего не меняем Note over F,W: штрафа нет, конвейер полон else Предположение неверно E-->>F: сброс — выбранные команды аннулируются Note over D,E: их управляющие сигналы обнуляются,
в WB они не пишут ничего E-->>F: новый PC — целевой адрес перехода Note over F,W: конвейер наполняется заново,
потерянные такты равны глубине до EX end
Стратегии, которые применяются в простых конвейерах, — по возрастанию сложности:
- Останов до разрешения. Честно, просто и дорого: штраф платится на каждом переходе.
- Статическое предположение «не будет взят». Продолжаем выбирать по PC+4; если ошиблись — сбрасываем. Дёшево, потому что сброс — это обнуление управляющих сигналов, а не откат регистров: до WB никто ничего не записал.
- Раннее разрешение. Перенести сравнение и вычисление целевого адреса из EX в ID. Штраф падает до одного такта, но критический путь ступени ID удлиняется — классический размен «меньше тактов простоя против более длинного такта».
- Слот задержки перехода. Архитектурное решение MIPS и SPARC: команда сразу после перехода выполняется всегда, независимо от исхода. Компилятор обязан её туда положить. Это работало, пока конвейер был мелким, и стало чистым вредом, когда конвейеры углубились: один слот перестал закрывать штраф, а из контракта его уже не выкинуть. Хрестоматийный пример того, как деталь реализации, просочившаяся в систему команд, переживает свою полезность (Система команд).
RISC-V слот задержки не имеет сознательно: разработчики учли этот опыт (RISC-V). Вместо этого — расчёт на предсказатель, которому посвящена глава 08.
Ещё одна деталь: штраф платится только при неверном предположении, и его величина равна числу ступеней от выборки до места разрешения. В пятиступенчатом конвейере это 1–3 такта. В глубоком высокопроизводительном ядре конца 2010-х — порядка 15–20 тактов. Отсюда вся арифметика следующих глав: чем глубже конвейер, тем дороже ошибка и тем важнее предсказатель.
Точные исключения: самое дорогое обещание конвейера
Вот тонкое место, которое обычно недооценивают. Контракт системы команд обещает: если команда вызвала исключение, то все предшествующие ей команды выполнены полностью, а сама она и все последующие не оставили следов. Это называется точным исключением, и без него невозможны ни отладчики, ни виртуальная память, ни корректная обработка сигналов (Системные вызовы).
Конвейер это обещание нарушает по построению: в момент, когда команда №5 в ступени MEM ловит отказ страницы, команды №6 и №7 уже находятся в EX и ID, а команда №4 ещё не записала результат. Хуже того, исключения обнаруживаются в разных ступенях и, значит, в неправильном порядке: отказ страницы при выборке команды №7 будет обнаружен раньше, чем отказ при обращении к памяти команды №5, хотя №5 идёт в программе раньше.
Решение простое и элегантное: исключение не обрабатывается сразу. Оно записывается как признак в межступенчатый регистр и едет вместе с командой до точки завершения. Обрабатывается самая старшая из тех, что дошли.
Из этой схемы следует принцип, который стоит запомнить как отдельную мысль: у конвейера есть единственная точка, в которой изменение состояния становится необратимым. Всё, что до неё, — черновик, который можно молча выбросить. В простом конвейере эта точка — ступень WB. Во внеочередном ядре она называется retirement, и её обслуживает буфер переупорядочивания. Механизм разный, принцип один.
Здесь же корень целого класса проблем безопасности: аннулированная команда не оставляет следов в архитектурном состоянии, но вполне может оставить их в микроархитектурном — например, подтянуть строку в кэш. На этом построены Spectre и Meltdown, разбор — в главе 08.
Неравные ступени: умножители, FP и делители
Пять равных ступеней — учебная идеализация. В реальности разные операции требуют существенно разного времени, и есть три способа с этим жить:
- Растянуть такт под самую медленную операцию. Тогда сложение будет ждать делитель — все команды платят за худшую. Так не делают.
- Дать медленным операциям несколько тактов. Умножитель конвейеризуют: он состоит из нескольких ступеней и принимает новую операцию каждый такт, хотя результат отдаёт через несколько. Латентность порядка 3–5 тактов у целочисленного умножения на высокопроизводительных ядрах последнего десятилетия — при пропускной способности одна операция за такт.
- Оставить блок неконвейеризованным. Так поступают с делением и извлечением корня: логика слишком велика, а операции слишком редки, чтобы платить за конвейеризацию. Блок занят целиком, и следующее деление ждёт.
Как только латентности разошлись, немедленно возвращаются две проблемы, которых не было в ровном конвейере:
- Завершение не в порядке программы. Деление, начатое раньше, закончится позже сложения. Значит, WAW-зависимость снова опасна, а порт записи становится дефицитным ресурсом.
- Исключения становятся неточными. Если сложение уже записало результат, а начатое до него деление только сейчас сообщило о переполнении, восстановить состояние «как будто выполнено ровно до делителя» невозможно. Ряд машин 1980-х честно объявляли исключения с плавающей точкой неточными и предлагали программисту с этим жить. Современный ответ другой: буфер переупорядочивания, который откладывает все записи до момента фиксации в порядке программы — и это ровно тема следующей главы.
Насколько глубоким должен быть конвейер
Соблазн понятен: короче ступень — выше частота — больше операций в секунду. Именно этой логикой руководствовались проекты рубежа 1990–2000-х, и именно она привела в стену.
Против глубины работают четыре силы одновременно:
- Накладные расходы на ступень. Триггеры не масштабируются вместе с логикой; их доля в такте растёт (см. раскладку такта в главе 02).
- Штраф ошибки предсказания. Он линейно растёт с глубиной. При частоте ошибок в несколько процентов и штрафе в 20 тактов на переходы уходят заметные проценты всего времени исполнения.
- Длина обходных путей. В глубоком конвейере результат «отстаёт» на больше тактов, и зависимая команда чаще вынуждена ждать, даже когда обход существует.
- Энергия. Больше триггеров — больше переключений тактовой сети, а поднятая ради глубины частота требует более высокого напряжения. Динамическая мощность растёт примерно как квадрат напряжения, умноженный на частоту (Мощность и пределы).
Порядки величины по классам устройств (именно порядки — точная глубина зависит от конкретного ядра, а вендоры считают ступени по-разному, поэтому сравнивать числа между семействами некорректно):
| Класс ядра | Порядок числа ступеней | Почему так |
|---|---|---|
| Микроконтроллерное ядро (Cortex-M нижних серий, малые RISC-V) | 2–3 | важнее площадь, энергия и предсказуемость задержки прерывания, а не частота |
| Учебный и встраиваемый in-order RISC | 5–8 | классический баланс, минимум логики конфликтов |
| Энергоэффективные ядра мобильных SoC (in-order или слабо внеочередные) | около 8–10 | компромисс между частотой и площадью на ядро |
| Высокопроизводительные ядра, серверные и настольные, конец 2010-х — 2020-е | порядка 14–20 от выборки до фиксации | частота 3–5 ГГц, при этом штраф ошибки предсказания порядка 15–20 тактов |
| Экстремально глубокие конвейеры начала 2000-х | 20–30 и более | ставка на частоту, которая не оправдалась: тепло и штрафы съели выигрыш |
Академический ответ на вопрос «сколько ступеней оптимально» дали примерно тогда же: A. Hartstein и T. Puzak, «The Optimum Pipeline Depth for a Microprocessor» (ISCA 2002) показали, что оптимум по производительности лежит существенно глубже, чем оптимум по производительности на ватт, — а промышленность к тому моменту уже упёрлась именно в ватты.
Отдельно стоит сказать про микроконтроллеры. Там глубокий конвейер прямо вреден: чем он глубже, тем дольше и тем менее предсказуемо время реакции на прерывание, а для управления двигателем или радиотрактом важна именно детерминированность (Прерывания и тайминги). Трёх ступеней достаточно, а сэкономленная площадь идёт на периферию.
Считаем CPI: маленькая модель
Полезно уметь оценивать эффект простоев в уме. Базовая формула:
Время = Команды × CPI × Длительность такта
CPI = CPI_идеальный + простои_на_команду
Для простого конвейера CPI_идеальный = 1, а простои складываются из:
доля_load × вероятность_load_use × 1 такт
+ доля_переходов × доля_ошибок × штраф
+ доля_промахов × штраф_промаха
Простая модель, которая позволяет прикинуть, куда уходят такты. Ниже — псевдокод, а затем реализация: покомандный проход с учётом времени готовности каждого регистра.
для каждой команды i по порядку:
такт_начала_EX = максимум(текущий_такт, время_готовности операндов)
если между текущим тактом и такт_начала_EX есть разрыв — это простой
время_готовности[приёмник] = такт_начала_EX + латентность(тип команды)
если команда — переход и предсказание неверно:
добавить штраф и опустошить конвейер
"""Оценка CPI простого in-order конвейера с форвардингом.
Модель намеренно грубая: она не учитывает кэш-промахи и суперскалярность,
но показывает главное — вклад зависимостей и переходов в число тактов.
"""
from dataclasses import dataclass
@dataclass
class Instr:
kind: str # "alu" | "load" | "store" | "branch" | "mul" | "div"
dst: str | None # регистр-приёмник
srcs: tuple # регистры-источники
mispredict: bool = False
# Латентность результата в тактах: через сколько тактов после начала EX
# значение может быть подхвачено обходным путём. Порядки величины для
# высокопроизводительного in-order ядра последнего десятилетия.
LATENCY = {"alu": 1, "load": 3, "store": 1, "branch": 1, "mul": 3, "div": 20}
MISPREDICT_PENALTY = 8 # порядок для конвейера средней глубины
def simulate(program: list[Instr]) -> dict:
ready_at: dict[str, int] = {} # регистр -> такт, с которого значение доступно
cycle = 0 # такт, в котором очередная команда войдёт в EX
stalls = 0
branch_cost = 0
for ins in program:
# когда операнды станут доступны
operands_ready = max((ready_at.get(r, 0) for r in ins.srcs), default=0)
start = max(cycle, operands_ready)
stalls += start - cycle # разрыв — это пузыри
if ins.dst is not None:
ready_at[ins.dst] = start + LATENCY[ins.kind]
cycle = start + 1 # конвейер принимает по команде за такт
if ins.kind == "branch" and ins.mispredict:
branch_cost += MISPREDICT_PENALTY
cycle += MISPREDICT_PENALTY # опустошение и повторное наполнение
n = len(program)
return {
"инструкций": n,
"тактов": cycle,
"CPI": round(cycle / n, 2),
"простои по данным": stalls,
"штрафы переходов": branch_cost,
}
# Цепочка зависимостей: каждая команда ждёт предыдущую
chain = [Instr("alu", "x1", ("x1", "x2")) for _ in range(8)]
# Та же работа, но по четырём независимым цепочкам
parallel = [
Instr("alu", f"x{1 + i % 4}", (f"x{1 + i % 4}", "x9")) for i in range(8)
]
# Цикл с загрузкой и немедленным использованием
load_use = []
for _ in range(4):
load_use += [
Instr("load", "x5", ("x10",)),
Instr("alu", "x6", ("x5", "x6")), # потребитель сразу за загрузкой
Instr("branch", None, ("x6",), mispredict=False),
]
for name, prog in [("цепочка", chain), ("4 цепочки", parallel), ("load-use", load_use)]:
print(name, simulate(prog))
Сложность модели: O(n) по времени, где n — число команд, и O(r) по памяти, где r — число различных регистров. То есть её можно натравить на дизассемблированный горячий цикл целиком.
Что показывает запуск (числа зависят от заданных латентностей, важны пропорции): цепочка зависимых сложений идёт заметно медленнее четырёх независимых цепочек той же длины, хотя команд ровно столько же. Это и есть параллелизм на уровне команд (ILP) — ресурс, который конвейер умеет использовать, а последовательный код ему не даёт.
Настоящие инструменты статического моделирования устроены сложнее и знают реальные таблицы
портов и латентностей: llvm-mca для LLVM,
uops.info как справочник измеренных латентностей x86,
таблицы Агнера Фога для x86 и оптимизационные руководства ARM.
Как посмотреть на конвейер на своей машине
Конвейер не абстракция: его простои считают аппаратные счётчики. Названия событий отличаются между вендорами, поколениями и версиями ядра, поэтому сначала смотрим, что доступно.
# Какие события знает ваша система
perf list | grep -Ei 'stall|cycles|branch'
# Базовая картина: сколько тактов на команду и как часто мы ошибаемся в переходах
perf stat -e cycles,instructions,branches,branch-misses ./program
# Где именно теряются такты: фронтенд (нечего исполнять)
# или бэкенд (есть что, но ресурсы заняты или данные не приехали)
perf stat -e cycles,stalled-cycles-frontend,stalled-cycles-backend ./program
Как читать результат:
- IPC = instructions / cycles. Для простого in-order конвейера потолок 1. Значение около 0,3–0,5 на нагрузке с обходом указателей — типичная картина «ждём память».
- branch-misses / branches — доля ошибок предсказания. Единицы процентов на обычном коде; десятки процентов означают, что где-то есть плохо предсказуемое ветвление.
- stalled-cycles-frontend высок — проблема с подачей команд: промахи кэша команд, плохая раскладка кода, слишком часто взятые переходы.
- stalled-cycles-backend высок — исполнение упирается в данные или ресурсы: промахи кэша данных, длинные цепочки зависимостей, занятые исполнительные блоки.
Оговорка, о которой все забывают: на внеочередных ядрах эти счётчики приблизительны. Спекуляция и переупорядочивание размывают понятие «такт простоя из-за X». Точнее работает методика Top-Down, где такты раскладываются на четыре категории по месту потерь; она подробнее разбирается в профилировании CPU и в главе про измерения.
Статический анализ горячего цикла без запуска:
# Модель конвейера для конкретной микроархитектуры: пропускная способность,
# давление на порты, критический путь по зависимостям
llvm-mca -mcpu=native -timeline hot_loop.s
Что это значит для кода
Пять практических следствий — то, ради чего программисту вообще нужна эта глава.
1. Считать команды недостаточно. Две функции с одинаковым числом операций могут отличаться по времени в разы, если у одной длинная цепочка зависимостей, а у другой — нет.
2. Длинные цепочки зависимостей — главный тормоз счётного кода. Классический пример:
// Латентностно-ограниченный вариант: каждое сложение ждёт предыдущее.
// Время ≈ n × латентность сложения.
double sum(const double *a, size_t n) {
double s = 0.0;
for (size_t i = 0; i < n; i++) s += a[i]; // одна цепочка
return s;
}
// Ограниченный пропускной способностью: четыре независимые цепочки.
// Время ≈ n × (1 / пропускная способность), что заметно меньше.
double sum4(const double *a, size_t n) {
double s0 = 0, s1 = 0, s2 = 0, s3 = 0;
size_t i = 0;
for (; i + 4 <= n; i += 4) {
s0 += a[i]; s1 += a[i+1]; s2 += a[i+2]; s3 += a[i+3];
}
double s = (s0 + s1) + (s2 + s3);
for (; i < n; i++) s += a[i];
return s;
}
Разница здесь не в числе операций — оно одинаково, — а в том, что второй вариант даёт конвейеру
четыре независимые цепочки вместо одной. Важная оговорка: компилятор не имеет права сделать
такое преобразование сам для чисел с плавающей точкой, потому что сложение не ассоциативно и
результат изменится в последних битах. Именно поэтому существуют флаги вроде -ffast-math, и
именно поэтому включать их бездумно не стоит.
3. Непредсказуемые ветвления дороже, чем кажется. Ветвление, которое предсказывается верно,
почти бесплатно. Ветвление на случайных данных стоит полный штраф. Отсюда известный приём —
заменять его арифметикой без переходов (cmov, csel, маски). Но приём не универсален:
безусловное вычисление обеих ветвей тоже стоит тактов, а зависимость по данным никуда не
девается. Подробный разбор — в главе 08.
4. Обход указателей — худший случай для конвейера. При переходе по связному списку адрес следующего узла известен только после прихода данных предыдущего. Ни конвейер, ни предвыборка не могут работать вперёд: это последовательность зависимых load-use с латентностью памяти вместо латентности кэша. Ровно поэтому массив структур почти всегда быстрее списка на тех же данных (Кэш и локальность).
5. Пишите так, чтобы у процессора была работа. Это общий принцип, который дальше в треке только усиливается: и SIMD, и внеочередное исполнение, и многоядерность — все они извлекают выгоду из независимой работы и бессильны против последовательной цепочки.
Типичные заблуждения
«Конвейер ускоряет выполнение команды». Нет. Отдельная команда проходит через конвейер столько же или даже дольше — из-за накладных расходов на ступенях. Растёт только пропускная способность.
«Больше ступеней — быстрее процессор». Только до определённого предела и только при хорошем предсказателе. Начиная с некоторой глубины прирост частоты полностью съедается штрафами и энергией.
«NOP ничего не стоит». Стоит: занимает слот выборки, место в конвейере и энергию. NOP-ы, которые компилятор вставляет для выравнивания, — плата за раскладку кода, а не бесплатная пустота.
«У ядра N ступеней, значит штраф ошибки предсказания N тактов». Не совсем: штраф — это путь от точки, где команда выбирается, до точки, где выясняется ошибка, плюс время повторного наполнения. Он может быть и меньше полной глубины, и больше, если ошибка обнаруживается поздно.
«Условное исполнение всегда лучше перехода». Ровно наоборот при хорошо предсказуемом ветвлении: команда с условием всё равно занимает ресурсы и держит зависимость по регистру, а верно предсказанный переход почти бесплатен. Именно поэтому в 64-битном ARM от повсеместных условных суффиксов отказались (глава 03).
«Конвейер — это про x86 и большие процессоры». Конвейеризованы даже трёхступенчатые ядра микроконтроллеров, и там его свойства (в первую очередь предсказуемость) важнее, чем в сервере.
Мини-итог
- Конвейер повышает пропускную способность, не трогая латентность отдельной команды. Это различие — ключ ко всему дальнейшему.
- Выигрыш от глубины ограничен накладными расходами на ступень и растёт всё медленнее; при этом штраф ошибки предсказания растёт линейно, а энергия — быстрее.
- Три класса конфликтов лечатся по-разному: структурные — дублированием и конвейеризацией блоков, по данным — форвардингом и, если не помогает, простоем, по управлению — предположением и сбросом.
- Load-use — единственный конфликт по данным, который форвардинг принципиально не устраняет; отсюда работа планировщика команд в компиляторе.
- Форма системы команд определяет форму конвейера: фиксированная длина, фиксированные поля операндов и модель load/store — не догма RISC, а прямые требования конвейеризуемости.
- Точные исключения обеспечиваются единственной точкой фиксации: до неё всё, что делает конвейер, — обратимый черновик. Из этого принципа вырастает буфер переупорядочивания.
- Как только исполнительные блоки получают разную латентность, возвращаются WAW-конфликты, борьба за порт записи и проблема неточных исключений — и требуется следующий уровень механизмов.
- Программист влияет на конвейер не «оптимизациями», а структурой кода: длиной цепочек зависимостей, предсказуемостью ветвлений и характером доступа к памяти.
Источники
- Patterson D., Hennessy J. Computer Organization and Design, RISC-V Edition — глава 4 целиком посвящена построению пятиступенчатого конвейера с конфликтами, форвардингом и исключениями. Лучшая отправная точка, если хочется собрать конвейер самому.
- Hennessy J., Patterson D. Computer Architecture: A Quantitative Approach — приложение C («Pipelining: Basic and Intermediate Concepts») даёт количественную сторону: формулы CPI, разбор конфликтов, влияние глубины.
- Hartstein A., Puzak T. «The Optimum Pipeline Depth for a Microprocessor», ISCA 2002 — https://dl.acm.org/doi/10.1145/545214.545220: оптимум глубины по производительности и по производительности на ватт.
- Sprangle E., Carmean D. «Increasing Processor Performance by Implementing Deeper Pipelines», ISCA 2002 — https://dl.acm.org/doi/10.1145/545214.545219: аргументы противоположной стороны того же спора, полезно читать вместе с предыдущей.
- Berkeley CS152/CS252, материалы курса по архитектуре — https://inst.eecs.berkeley.edu/~cs152/: слайды и лабораторные с реализациями конвейера на RISC-V.
- Fog A. The Microarchitecture of Intel, AMD and VIA CPUs — https://www.agner.org/optimize/: измеренные латентности и пропускные способности, разбор конкретных микроархитектур.
- Документация
llvm-mca— https://llvm.org/docs/CommandGuide/llvm-mca.html: как читать временную диаграмму конвейера для своего кода. - Спецификации RISC-V — https://riscv.org/technical/specifications/: базовый набор, на котором удобно разбирать конвейеризацию без исторических исключений.
Что дальше
Мы разобрали конвейер, который исполняет команды строго по одной за такт и строго в порядке программы. Его потолок очевиден: CPI не может быть меньше единицы, а любая зависимость или ошибка предсказания отбрасывает выше этого потолка. Следующий шаг — научиться выдавать несколько команд за такт и исполнять их в том порядке, в каком готовы данные, сохраняя при этом видимость последовательного исполнения. Как раз об этом — Суперскалярность и внеочередное исполнение.