Железо и архитектуры Конвейер: как процессор делает несколько дел одновременно
0%

Конвейер: как процессор делает несколько дел одновременно

Конвейер: как процессор делает несколько дел одновременно

Есть простое наблюдение, из которого выросла вся современная микроархитектура. Когда процессор выполняет одну команду, большая часть его схем простаивает: пока АЛУ считает сумму, кэш команд не занят ничем, регистровый файл уже отдал операнды и ждёт, блок записи результата бездельничает. Оборудование оплачено целиком, а работает по очереди — примерно как если бы на заводе стоял один рабочий, который последовательно обходит пять станков.

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

Эта глава — про механику перекрытия и про цену этих трёх неприятностей.

Где мы находимся: что уже разобрано и что начинается здесь

Мы предполагаем, что базовая механика уже понятна. Цикл «выборка — декодирование — исполнение», регистры и АЛУ разобраны во вводном треке (Как работает процессор), путь от исходника к машинному коду — там же (От кода до исполнения), а регистры, стек и соглашения о вызовах с точки зрения программиста — в главе про ассемблер (Основы ассемблера).

Из этого трека нам понадобятся три вещи. Первая — понятие такта и критического пути: такт определяется самой длинной комбинационной цепочкой, а не средней (Транзистор и вентиль). Вторая — система команд как контракт: программа имеет право видеть только последовательное исполнение, а как оно достигается — дело реализации (Система команд). Третья — почему форма кодирования команд влияет на устройство фронтенда (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  : значение для записи в регистр (из АЛУ или из памяти), номер приёмника,
          сигнал «писать или нет», накопленные признаки исключений

Три вывода отсюда — практические, а не декоративные.

  1. Управляющие сигналы едут вместе с данными. Декодер работает один раз, в ID, а его решения путешествуют по конвейеру как обычные биты. Именно поэтому «сложная команда» дорога не столько исполнением, сколько шириной этих регистров.
  2. Конвейер стоит площади и энергии. Каждый разрез добавляет сотни триггеров, которые переключаются каждый такт. Это одна из причин, по которым глубокий конвейер плохо сочетается с жёстким бюджетом мощности (Мощность и пределы).
  3. Состояние конвейера — это состояние машины. При переключении контекста операционная система не сохраняет содержимое межступенчатых регистров: конвейер просто доводит до конца или отменяет то, что в нём есть. Видимое состояние — только архитектурные регистры и память (Процессы и планирование).

Почему форма системы команд определяет форму конвейера

Пять ступеней укладываются так красиво не сами по себе. Это следствие трёх решений системы команд, которые мы разбирали в главе про RISC и CISC:

  • Фиксированная длина команды. Ступень IF знает, где кончается команда, ещё до декодирования. Если длина переменная (x86 — от 1 до 15 байт), то перед декодированием нужен отдельный этап определения границ, и он плохо параллелится: чтобы узнать, где начинается вторая команда, надо разобрать первую.
  • Операнды в фиксированных полях. Номера регистров лежат на одних и тех же битовых позициях почти во всех форматах RISC-V, поэтому чтение регистрового файла можно начать параллельно с декодированием, не дожидаясь, пока станет ясен тип команды.
  • Модель load/store. Обращение к памяти отделено от арифметики, поэтому ступень MEM ровно одна и стоит в одном и том же месте. В архитектуре «регистр-память» одна команда может потребовать: посчитать адрес, сходить в память, выполнить арифметику, сходить в память снова. Уложить это в фиксированную последовательность ступеней невозможно.

Отсюда — практическое решение, которое приняли реализации x86: разбить сложные команды на внутренние микрооперации фиксированного вида и конвейеризовать уже их. Фронтенд платит за совместимость, бэкенд работает с чем-то очень похожим на RISC. Эту конструкцию мы подробно разбирали в главе 04.

Конфликты: три вида и три разных лечения

Конфликт (hazard) — ситуация, в которой следующая команда не может войти в свою ступень в запланированный такт. Их ровно три класса, и путать их не стоит: лечатся они по-разному.

Структурные конфликты: не хватает железа

Классический пример из учебников: если кэш команд и кэш данных — это одна память с одним портом, то ступень 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. Разрыв — несколько тактов, и всё это время конвейер обязан что-то выбирать.

Стратегии, которые применяются в простых конвейерах, — по возрастанию сложности:

  1. Останов до разрешения. Честно, просто и дорого: штраф платится на каждом переходе.
  2. Статическое предположение «не будет взят». Продолжаем выбирать по PC+4; если ошиблись — сбрасываем. Дёшево, потому что сброс — это обнуление управляющих сигналов, а не откат регистров: до WB никто ничего не записал.
  3. Раннее разрешение. Перенести сравнение и вычисление целевого адреса из EX в ID. Штраф падает до одного такта, но критический путь ступени ID удлиняется — классический размен «меньше тактов простоя против более длинного такта».
  4. Слот задержки перехода. Архитектурное решение 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 и делители

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

  1. Растянуть такт под самую медленную операцию. Тогда сложение будет ждать делитель — все команды платят за худшую. Так не делают.
  2. Дать медленным операциям несколько тактов. Умножитель конвейеризуют: он состоит из нескольких ступеней и принимает новую операцию каждый такт, хотя результат отдаёт через несколько. Латентность порядка 3–5 тактов у целочисленного умножения на высокопроизводительных ядрах последнего десятилетия — при пропускной способности одна операция за такт.
  3. Оставить блок неконвейеризованным. Так поступают с делением и извлечением корня: логика слишком велика, а операции слишком редки, чтобы платить за конвейеризацию. Блок занят целиком, и следующее деление ждёт.

Как только латентности разошлись, немедленно возвращаются две проблемы, которых не было в ровном конвейере:

  • Завершение не в порядке программы. Деление, начатое раньше, закончится позже сложения. Значит, 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 CPUshttps://www.agner.org/optimize/: измеренные латентности и пропускные способности, разбор конкретных микроархитектур.
  • Документация llvm-mcahttps://llvm.org/docs/CommandGuide/llvm-mca.html: как читать временную диаграмму конвейера для своего кода.
  • Спецификации RISC-V — https://riscv.org/technical/specifications/: базовый набор, на котором удобно разбирать конвейеризацию без исторических исключений.

Что дальше

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

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов