Железо и архитектуры Суперскалярность и внеочередное исполнение
0%

Суперскалярность и внеочередное исполнение

Суперскалярность и внеочередное исполнение

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

Эта глава — про две идеи, которые сняли потолок. Первая: выдавать в исполнение несколько операций за такт. Вторая: исполнять их не в том порядке, в котором они записаны, а в том, в котором готовы их операнды. Обе появились в промышленных машинах в 1960-х, в 1990-х дошли до массовых микропроцессоров и с тех пор определяют устройство всякого ядра, для которого производительность на поток важнее площади.

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

Две независимые идеи, которые постоянно путают

«Суперскалярный» и «внеочередной» — не синонимы. Это две ортогональные характеристики, и существуют все четыре комбинации.

В порядке программы Вне порядка
Одна операция за такт классический пятиступенчатый конвейер; ядра микроконтроллеров почти не встречается: сложность окна не окупается при ширине 1
Несколько за такт ранние суперскаляры, энергоэффективные ядра мобильных чипов, часть ядер RISC-V все высокопроизводительные ядра общего назначения последних тридцати лет

Различать их полезно потому, что они лечат разные болезни.

  • Ширина (суперскалярность) поднимает потолок: без неё IPC не может превысить единицу, сколько бы независимой работы ни было в коде.
  • Внеочередность приближает к потолку: она позволяет не простаивать, когда очередная по порядку операция ждёт данные, а работы вокруг сколько угодно.

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

Терминология, без которой дальше будет путаница

  • Ширина выдачи (issue width) — сколько микроопераций ядро может отправить в исполнение за такт. Обычно называют ширину самого узкого места фронтенда: декодирования или переименования.
  • Микрооперация (uop) — внутренняя единица работы. Одна инструкция ISA превращается в одну или несколько микроопераций; иногда, наоборот, две инструкции сращиваются в одну микрооперацию. Разбор — в главе про RISC и CISC.
  • Окно инструкций — множество микроопераций, которые ядро одновременно «держит в уме» и среди которых ищет готовые к исполнению.
  • Отставка (retirement, commit) — момент, когда результат микрооперации становится частью архитектурного состояния. Только здесь исполнение снова строго последовательно.
  • IPC — инструкций за такт, величина, обратная CPI из обзорной главы.

Внеочередное ядро: где порядок программы обязателен, а где его нет

Картинка выше — карта всей главы. Обратите внимание на её структуру: порядок обязателен на входе и на выходе, и свободен в середине. Это не декоративная симметрия, а прямое следствие контракта ISA: чтобы восстановить «видимость последовательности», нужно знать, какой она была, — значит, порядок надо запомнить на входе; и нужно применять изменения по одному в этом порядке — значит, на выходе он должен быть восстановлен.

Потолок in-order: почему ширина сама по себе не работает

Возьмём двухканальное in-order ядро: за такт оно может выдать до двух микроопераций, но только соседние по программе и только если обе готовы. Достаточно одной неготовой операции, чтобы встало всё, что за ней.

# Типичный фрагмент: загрузка, зависимая арифметика, независимая работа рядом
    ld    x5, 0(x10)      # промах L1, данные приедут через ~14 тактов
    add   x6, x5, x7      # зависит от x5 — ждёт
    add   x8, x9, x11     # НЕ зависит ни от чего — могла бы выполниться сейчас
    xor   x12, x13, x14   # тоже независима
    sub   x15, x8, x12    # зависит от двух предыдущих

In-order ядро остановится на второй строке и не тронет ни третью, ни четвёртую, хотя обе готовы. Внеочередное ядро пропустит вперёд третью, четвёртую и пятую, а первые две доделает, когда данные приедут. Разница на этом фрагменте — десяток тактов из четырнадцати.

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

ILP: сколько параллелизма реально лежит в вашем коде

Параллелизм на уровне инструкций (ILP) — это свойство не процессора, а программы. Точнее, свойство графа зависимостей: вершины — операции, рёбра — «результат этой нужен той». Минимальное время исполнения на машине с бесконечными ресурсами равно длине критического пути в этом графе, а средний ILP — отношению числа операций к длине критического пути.

Полезно уметь считать это для горячего цикла: цифра сразу говорит, есть ли смысл в широком ядре.

"""Оценка ILP: критический путь по графу зависимостей и достижимый IPC.

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

Сложность: O(V + E) по времени, O(V) по памяти, где V — число операций,
E — число зависимостей. Проход один, потому что операции уже в порядке программы.
"""
from dataclasses import dataclass, field


@dataclass
class Op:
    name: str
    dst: str | None
    srcs: tuple[str, ...] = ()
    lat: int = 1                 # латентность результата в тактах


def critical_path(ops: list[Op]) -> dict:
    ready: dict[str, int] = {}   # регистр -> такт, когда значение готово
    finish: dict[str, int] = {}  # имя операции -> такт завершения
    span = 0

    for op in ops:
        start = max((ready.get(s, 0) for s in op.srcs), default=0)
        end = start + op.lat
        finish[op.name] = end
        if op.dst is not None:
            # при переименовании регистров запись НЕ создаёт зависимости:
            # каждая запись получает своё физическое имя
            ready[op.dst] = end
        span = max(span, end)

    n = len(ops)
    return {
        "операций": n,
        "критический путь, тактов": span,
        "предельный ILP": round(n / span, 2),
    }


# Латентности — порядки величины для высокопроизводительного ядра 2020-х:
# целочисленное сложение 1 такт, умножение 3, попадание в L1 около 4-5,
# промах до DRAM — сотни тактов.
цепочка = [Op(f"add{i}", "acc", ("acc", f"x{i}"), 1) for i in range(16)]
четыре = [Op(f"add{i}", f"acc{i % 4}", (f"acc{i % 4}", f"x{i}"), 1) for i in range(16)]
с_умножением = [Op(f"mul{i}", "acc", ("acc", f"x{i}"), 3) for i in range(16)]

for имя, прог in [("одна цепочка", цепочка), ("4 цепочки", четыре), ("цепочка умножений", с_умножением)]:
    print(имя, critical_path(прог))

Что показывает такой расчёт на реальном коде:

  • Скалярный код с одним аккумулятором имеет ILP около единицы независимо от ширины ядра. Восьмиканальное ядро на нём работает ровно как одноканальное.
  • Развёрнутый цикл с несколькими аккумуляторами имеет ILP, равный числу аккумуляторов, — пока их не станет больше, чем портов исполнения.
  • Обход связного списка имеет ILP, близкий к нулю: длина критического пути равна числу узлов, умноженному на латентность памяти. Это тот случай, когда ни ширина, ни окно не помогают вообще.

Отсюда практический вывод, который стоит запомнить раньше всей механики: широкое внеочередное ядро не создаёт параллелизм, оно его находит. Если параллелизма в коде нет, ядро простаивает при полном комплекте исполнительных блоков. Именно поэтому оптимизация «на архитектуру» почти всегда сводится к тому, чтобы дать процессору больше независимых цепочек.

Ложные зависимости и переименование регистров

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

Тип Пример Природа
RAW (истинная) add x1, x2, x3 затем sub x4, x1, x5 реальный поток данных, устранить нельзя
WAR (антизависимость) sub x4, x1, x5 затем add x1, x6, x7 конфликт имени: x1 переиспользован
WAW (выходная) add x1, ... затем mul x1, ... конфликт имени: обе пишут в x1

WAR и WAW возникают только потому, что архитектурных регистров конечное число и компилятор вынужден переиспользовать имена. В in-order конвейере с одной точкой записи они безвредны. При переупорядочивании — смертельны: если вторая запись в x1 произойдёт раньше первой, кто-то прочитает не то значение.

Решение известно с проекта IBM System/360 Model 91 (1967, алгоритм Роберта Томасуло): дать каждой записи собственное физическое имя. Архитектурный регистр перестаёт быть ячейкой и становится ярлыком, который переклеивается с одного физического регистра на другой.

Что переименование даёт и чего стоит:

  • Даёт. WAR и WAW исчезают полностью. Два подряд идущих add x1, ... — это две независимые операции, которые могут исполниться в любом порядке и одновременно.
  • Даёт бесплатно. Обнуление регистра идиомой xor eax, eax или mv a0, x0 часто выполняется прямо на этапе переименования: ядро просто указывает ярлык на «регистр, содержащий ноль», не занимая ни такта исполнения. То же относится к пересылкам регистр-регистр — их иногда устраняют переклеиванием ярлыка (move elimination).
  • Стоит площади. Физических регистров нужно намного больше архитектурных: по одному на каждую запись, находящуюся в полёте. Порядок для высокопроизводительных ядер 2020-х — сотни физических регистров на каждый файл (целочисленный, векторный) при 16–32 архитектурных.
  • Стоит энергии и задержки. Таблица псевдонимов читается и обновляется для каждой микрооперации каждый такт, причём с учётом зависимостей внутри самой группы: если в одной группе из шести микроопераций третья пишет x1, а пятая его читает, переименование обязано это заметить в том же такте. Логика получается квадратичной по ширине — одна из причин, по которым ширина не растёт бесконечно.

Отдельная важная деталь: регистр флагов у x86 и ARM переименовывается наравне с обычными. Более того, его приходится дробить на части, потому что разные инструкции пишут разные подмножества флагов, и без дробления возникли бы ложные зависимости на пустом месте. Отсутствие регистра флагов в RISC-V — это в том числе экономия именно здесь (Система команд).

Буфер переупорядочивания: единственная точка, где меняется мир

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

Ответ — буфер переупорядочивания (ROB, reorder buffer): кольцевая очередь, куда микрооперации попадают в порядке программы при диспетчеризации и откуда уходят тоже в порядке программы при отставке.

Два состояния тут нужно различать особенно чётко, потому что их путают чаще всего.

Завершена (executed) — результат посчитан и лежит в физическом регистре. Зависимые операции уже его получили и работают дальше. Но архитектурно ничего не произошло: если старшая операция вызовет исключение, этот результат исчезнет без следа.

Отставлена (retired) — операция дошла до головы буфера, все предшествующие ушли, исключений нет. Только теперь изменение становится видимым: ярлык на физический регистр закрепляется в архитектурной таблице, запись в память выпускается из буфера записей в кэш, счётчик команд официально продвигается.

Из этого следуют три вещи.

  1. Точные исключения получаются автоматически. Обработчику передают управление в момент отставки виновника; всё, что моложе, к этому времени просто выбрасывается. Контракт из главы 03 выполнен, хотя исполнение шло как попало.
  2. Сброс дёшев по конструкции. Он не откатывает вычисления, а помечает часть буфера недействительной и восстанавливает таблицу псевдонимов. Порядок стоимости — единицы тактов на сам сброс плюс полное перезаполнение конвейера, что и даёт штраф ошибки предсказания порядка 15–20 тактов у высокопроизводительных ядер конца 2010-х (Предсказание переходов).
  3. Размер ROB — это и есть «окно». Он задаёт, насколько далеко вперёд ядро может убежать от застрявшей операции. Порядок величины для настольных и серверных ядер 2020-х — сотни записей; для энергоэффективных мобильных ядер — десятки-сотня; для ядер микроконтроллеров ROB нет вовсе.

Тонкость, которая мешает многим: ROB не «содержит результаты». В современных реализациях результаты живут в физическом регистровом файле, а в ROB лежат только учётные данные — тип операции, старое отображение приёмника, флаги исключений, признак завершения. Это экономит громадное количество проводов: иначе каждый результат пришлось бы копировать дважды.

Планировщик: цикл «пробуждение — выбор»

Сердце внеочередного ядра — планировщик (он же станции резервирования, issue queue). Каждый такт он делает две вещи:

  1. Пробуждение. По шинам с результатами приходят имена физических регистров, которые только что стали готовы. Каждая ожидающая запись сравнивает их со своими источниками и, если все источники получены, помечает себя готовой.
  2. Выбор. Из готовых записей выбирается столько, сколько есть свободных портов, обычно по эвристике «сначала самые старые».

Этот цикл обязан укладываться в один такт для операций с латентностью один такт. Иначе цепочка зависимых сложений выполнялась бы со скоростью не такт на операцию, а два, и весь смысл терялся бы. Отсюда фундаментальная проблема: логика сравнения устроена как ассоциативная память, и её сложность растёт примерно квадратично с произведением «число записей × число шин результата». Это, а не площадь, — главный ограничитель размера планировщика.

Правая нижняя ветвь заслуживает отдельного разговора. Латентность попадания в L1 известна заранее — порядок 4–5 тактов у ядер общего назначения. Чтобы зависимая операция стартовала ровно в такт прихода данных, планировщик будит её заранее, ещё не зная, будет ли попадание. Если случился промах, пробуждение оказывается ложным, и все операции, выданные под это предположение, приходится вернуть в очередь. Механизм называется replay, и он объясняет одно наблюдаемое явление: код с высокой долей промахов L1 теряет больше тактов, чем следует из простого умножения «промахи × штраф», потому что часть работы делается дважды.

Ёмкости структур: порядки величины

Точные числа — свойство конкретной микроархитектуры, они меняются каждое поколение и по-разному считаются вендорами. Полезны именно порядки и пропорции между структурами.

Структура Что ограничивает Порядок для мобильного энергоэффективного ядра Порядок для настольного или серверного ядра 2020-х
Ширина переименования максимальный IPC 2–4 4–8
Буфер переупорядочивания как далеко можно убежать от простоя десятки–сотня записей сотни записей
Планировщик сколько операций одновременно рассматривается десятки около сотни и более
Физические регистры (целочисленные) сколько результатов в полёте сотня и меньше сотни
Очередь загрузок / записей сколько обращений к памяти в полёте десятки около сотни
Промахов L1 одновременно (MSHR) параллелизм по памяти единицы–десяток десяток-другой

Из пропорций видно проектное правило: все структуры должны расти согласованно. Огромный ROB при маленьком планировщике бесполезен — окно физически большое, но искать готовые операции ядро может только в узкой его части. Ровно так же бесполезен широкий бэкенд при узком фронтенде.

Память: самая трудная часть внеочередного ядра

С регистрами всё просто: зависимости видны в момент декодирования, потому что номера регистров закодированы в инструкции. С памятью — нет. Зависит ли ld x5, 0(x10) от предшествующей sd x6, 0(x11), выясняется только после вычисления обоих адресов, а это происходит уже глубоко в исполнении.

Ядро обязано соблюдать три правила:

  1. Загрузка должна увидеть значение последней предшествующей ей записи по тому же адресу.
  2. Записи выпускаются в кэш только после отставки — иначе спекулятивную запись было бы нечем отменить.
  3. Для многоядерной системы должна соблюдаться модель памяти данной ISA (Многоядерность).

Механика такая. Все обращения к памяти регистрируются в очередях загрузок и записей (LSQ) в порядке программы. При исполнении загрузки её адрес сравнивается с адресами всех более старых записей в очереди:

  • Совпадение, данные записи известныпересылка «запись → загрузка» (store-to-load forwarding): значение отдаётся прямо из очереди, минуя кэш. Быстро, но не мгновенно: порядок задержки — несколько тактов сверх обычного попадания в L1.
  • Совпадение, данные записи ещё не посчитаны → загрузка обязана ждать.
  • Совпадения нет → загрузка идёт в кэш.
  • Адрес старой записи ещё не вычислен → главная развилка, см. ниже.

Спекуляция по зависимостям в памяти

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

Поэтому ядро спекулирует: предполагает, что конфликта нет, и выпускает загрузку вперёд. Если позже выяснится, что старшая запись легла по тому же адресу, — нарушение обнаруживается, и загрузка вместе со всем, что от неё зависело, аннулируется. Штраф сопоставим со сбросом при ошибке предсказания перехода.

Чтобы не платить его постоянно, ядра держат предсказатель зависимостей по памяти: он запоминает пары «эта загрузка когда-то конфликтовала с той записью» и в следующий раз заставляет её подождать. Идея восходит к работам про Store Sets (Chrysos, Emer, ISCA 1998).

Практических следствий несколько, и они прямо влияют на код:

  • Запись и последующее чтение одного объекта — узкое место. Классический пример: сериализация через маленький буфер, где каждая запись немедленно читается. Пересылка «запись → загрузка» спасает, но только при удачном совпадении: если загрузка шире записи или пересекает её частично (записали два байта, читаем четыре), пересылка не срабатывает и ядро вынуждено ждать, пока запись доедет до кэша. Порядок потери — десяток и более тактов на каждое такое обращение.
  • Совпадение младших битов адреса при разных страницах может вызывать ложные срабатывания сравнения: сравнивать полный физический адрес дорого, поэтому часть проверок делается по младшим битам. Эффект известен как ложное наложение по 4 КиБ и проявляется как загадочное замедление при определённом взаимном смещении буферов — лечится сдвигом раскладки данных.
  • Указатели, про которые компилятор не знает, что они не пересекаются, заставляют его сохранять порядок обращений в коде — и тогда даже идеальное железо не поможет. Отсюда restrict в C и вся тема алиасинга (Оптимизации компилятора).

Сколько окна нужно, чтобы спрятать память

Есть простой количественный способ понять, зачем ROB на сотни записей. Это закон Литтла, применённый к ядру: чтобы поддерживать поток IPC инструкций в такт при среднем времени пребывания операции в ядре L тактов, в ядре одновременно должно находиться IPC × L операций.

"""Сколько записей окна нужно, чтобы промах в DRAM не остановил ядро.

Модель грубая, но она объясняет пропорции реальных структур.
Сложность: O(1) по времени и памяти.
"""

def нужное_окно(ipc: float, доля_промахов: float, штраф_тактов: int,
                базовая_латентность: float = 1.5) -> dict:
    # среднее время жизни микрооперации в ядре
    L = базовая_латентность + доля_промахов * штраф_тактов
    return {
        "среднее время в ядре, тактов": round(L, 1),
        "нужный размер окна": round(ipc * L),
    }


# Порядки величины для настольного ядра 2020-х при частоте около 3 ГГц:
# промах до DRAM — 200–350 тактов.
print("плотные данные ", нужное_окно(ipc=3.0, доля_промахов=0.002, штраф_тактов=280))
print("обход указателей", нужное_окно(ipc=3.0, доля_промахов=0.05,  штраф_тактов=280))

Первый сценарий даёт окно в единицы-десятки записей: при редких промахах хватает небольшого буфера. Второй требует окна в тысячи — то есть полностью спрятать промах в DRAM не может ни одно существующее ядро. Отсюда честный вывод: внеочередное исполнение прячет попадания в L2 и L3 и частично промахи в память, но перед потоком зависимых промахов оно бессильно.

Второй важный параметр — параллелизм по памяти (MLP): сколько промахов ядро держит в полёте одновременно. Его ограничивает число регистров обработки промахов (MSHR) в L1, порядок — десяток-другой у ядер общего назначения. Отсюда практический ориентир, который часто оказывается решающим:

  • десять независимых промахов стоят примерно как один плюс небольшая надбавка;
  • десять зависимых промахов стоят ровно в десять раз дороже одного.

Разница между массивом индексов и связным списком — ровно эта. Подробнее механика — в главе про подсистему памяти, прикладная сторона — в кэше и локальности.

Почему ширина не растёт бесконечно

С начала 1990-х ширина выдачи выросла примерно с двух до полудюжины и с тех пор растёт крайне медленно, хотя транзисторов стало на порядки больше. Причин несколько, и все они инженерные.

  • Квадратичные структуры. Логика переименования, сравнения в планировщике и сеть обходных путей растут примерно как квадрат ширины. Удвоение ширины требует учетверения этих блоков и удлиняет их критический путь, то есть режет частоту.
  • Порты регистрового файла. Ширина W требует порядка 2W портов чтения и W записи. Площадь многопортовой памяти растёт быстрее числа портов, а задержка доступа — вместе с ней. Отсюда приёмы вроде разбиения файла на кластеры с ограниченной связью между ними.
  • Кончается ILP. Классические измерения предельного ILP на реальных программах (работы Уолла и Лэма конца 1980-х — начала 1990-х) показали, что при реалистичных предположениях о предсказании и памяти достижимый параллелизм редко превышает единицы операций за такт. Восьмиканальное ядро на коде с ILP=2 просто не имеет что делать.
  • Точность предсказания становится потолком. Чем шире ядро, тем больше работы выбрасывается при каждой ошибке. Тут прямая связь с главой 08: широкое ядро окупается только вместе с хорошим предсказателем.
  • Энергия. Все спекулятивно выполненные и потом отменённые операции потрачены впустую. Доля отменённой работы на некоторых нагрузках достигает десятков процентов, и она вся оплачена ваттами (Мощность и пределы).

Именно из этих ограничений выросли остальные направления: раз в одном потоке параллелизма больше не найти — берём его из нескольких потоков (Многоядерность), из векторов (SIMD) или из специализированных блоков (Ускорители).

SMT: чужой работой заполнить своё окно

Одновременная многопоточность (SMT, у одного из вендоров известная под названием Hyper-Threading) — прямое следствие наблюдения выше. Если окно наполовину пустое, потому что в одном потоке кончился параллелизм, пусть вторую половину займёт другой поток.

Инженерно это означает: продублировать всё, что относится к архитектурному состоянию потока (счётчик команд, таблица псевдонимов, ROB или его разделы), и разделить всё, что относится к исполнению (планировщик, исполнительные блоки, кэши, предсказатель).

Практические следствия, которые видны из прикладного кода:

  • Выигрыш непостоянен. На нагрузке с низким ILP и частыми промахами SMT даёт заметный прирост общей пропускной способности. На нагрузке, которая уже насыщает исполнительные блоки (плотный векторный счёт), он не даёт почти ничего и может даже вредить: два потока делят один кэш и вытесняют данные друг друга.
  • Латентность отдельного потока обычно ухудшается. SMT покупает пропускную способность, а не отзывчивость. Для сервисов с жёстким требованием по хвостовым задержкам его иногда выключают осознанно.
  • Разделение микроархитектурных ресурсов — это канал утечки. Два потока на одном ядре наблюдают друг друга по времени доступа к общим структурам. Поэтому в средах с недоверенными соседями SMT нередко отключают (Безопасность и изоляция).
  • Логическое ядро — не физическое. Планировщик ОС обязан это учитывать при раскладке потоков, иначе два счётных потока окажутся на одном физическом ядре, пока соседнее простаивает (Процессы и планирование).

Как увидеть всё это на своей машине

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

# Базовая картина: сколько инструкций за такт и сколько работы выброшено
perf stat -e cycles,instructions,branch-misses ./program

# Методика Top-Down: раскладка тактов по четырём категориям —
# фронтенд не подаёт, плохая спекуляция, бэкенд занят, полезная работа
perf stat -M TopdownL1 ./program
perf stat -M TopdownL2 ./program     # уточнение: память против исполнительных блоков

# Сколько работы отменено: разница между выданным и отставленным
perf stat -e uops_issued.any,uops_retired.retire_slots ./program   # имена событий зависят от платформы

# Статическая модель конкретного горячего цикла без запуска:
# давление на порты, критический путь по зависимостям, временная диаграмма
llvm-mca -mcpu=native -timeline -iterations=100 hot_loop.s

Как читать результат:

  • Retiring высок — ядро занято полезной работой; дальнейшее ускорение возможно только уменьшением объёма работы или векторизацией.
  • Bad Speculation высок — проблема в предсказании; смотрите главу 08.
  • Backend Bound → Memory Bound — окно заполнено ожидающими памяти операциями; лечится раскладкой данных, а не переписыванием арифметики.
  • Backend Bound → Core Bound — упёрлись в исполнительные порты или в длину цепочки зависимостей; лечится развёрткой, несколькими аккумуляторами, заменой дорогих операций.
  • Frontend Bound — ядру нечего подавать: промахи кэша команд, плохая раскладка кода, слишком много переходов.

Методика Top-Down описана в работе Ahmad Yasin (ISPASS 2014) и подробно разбирается в профилировании CPU.

Что это значит для кода

1. Давайте ядру независимые цепочки. Это главное. Несколько аккумуляторов вместо одного, развёртка цикла, разбиение длинной редукции на части — всё это работает не «потому что меньше проверок условия», а потому что увеличивает ILP. Оговорка та же, что и в главе 06: для плавающей точки компилятор не имеет права сделать это сам, так как сложение не ассоциативно.

2. Считайте латентности, а не количество операций. Замена деления на умножение с реципроком выигрывает не потому, что «умножение проще», а потому что латентность деления на порядок больше и блок не конвейеризован.

3. Ложные зависимости через память — реальны. Частичные перекрытия записи и чтения, запись в один и тот же адрес из разных функций, «горячая» переменная-счётчик рядом с данными. Всё это создаёт цепочки, которых нет в вашей модели программы, но есть в очереди памяти.

4. Непредсказуемое ветвление стоит дороже, чем в узком ядре. При ширине шесть каждый такт сброса — это шесть потерянных слотов, а не один.

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

6. Проверяйте, а не угадывайте. Между «я думаю, здесь узкое место» и perf stat -M TopdownL1 дистанция обычно в один порядок величины (Измерения).

Типичные заблуждения

«Внеочередное исполнение переставляет мои инструкции, значит порядок в исходнике не важен». Важен: он задаёт граф зависимостей и раскладку данных, а ядро переставляет только внутри окна и только то, что независимо.

«Процессор сам распараллелит мой цикл». Нет. Он найдёт независимые операции в окне на несколько сотен микроопераций. Настоящую параллельность по данным даёт векторизация, по потокам — многоядерность.

«Больше ядер в ROB — больше производительность». Только до момента, пока хватает ILP и пока планировщик успевает просматривать окно. Дальше растёт площадь и энергия, а IPC — нет.

«Спекулятивно выполненная и отменённая операция ничего не стоила». Стоила энергии, слота в окне, порта исполнения и, что важнее всего, оставила следы в кэше и предсказателях. На этом стоят Spectre и родственные атаки.

«SMT удваивает производительность ядра». SMT заполняет пустые слоты. На нагрузке, которая уже насыщает бэкенд, прирост близок к нулю.

«У этого ядра ширина 8, значит IPC до 8». Ширина 8 — это обычно ширина одной ступени фронтенда. Реальный потолок задаётся самым узким местом всей цепочки, а практический IPC на обычном коде — единицы, часто меньше двух.

«Внеочередное исполнение спасает от промахов памяти». Оно прячет промахи в ближние уровни и параллелит независимые промахи. Цепочку зависимых обращений в DRAM оно не прячет никак.

Мини-итог

  • Суперскалярность (ширина) и внеочередность (свобода порядка) — независимые свойства; по-настоящему работают только вместе.
  • Ядро не создаёт параллелизм, а находит его в окне. Предел задаётся критическим путём графа зависимостей вашей программы.
  • Переименование регистров устраняет ложные зависимости WAR и WAW, превращая архитектурные имена в ярлыки на физические регистры; цена — сотни физических регистров и квадратичная по ширине логика.
  • Буфер переупорядочивания возвращает порядок на выходе и тем самым обеспечивает точные исключения и дешёвый сброс. Отставка — единственная точка, где меняется видимое состояние.
  • Планировщик с циклом «пробуждение — выбор» — главный ограничитель частоты и размера окна; спекулятивное пробуждение под ожидаемую латентность L1 порождает механизм повтора при промахе.
  • Память — самая сложная часть: зависимости неизвестны до вычисления адресов, поэтому ядро спекулирует и держит отдельный предсказатель зависимостей; частичные перекрытия записи и чтения дороги.
  • Размер окна, нужный чтобы спрятать промах в DRAM, на порядок больше реализуемого; отсюда важность параллелизма по памяти и раскладки данных.
  • Ширина упирается в квадратичные структуры, порты регистрового файла, точность предсказания, энергию и, главное, в конечность ILP реального кода. Дальнейший рост производительности пришлось искать в векторах, потоках и специализации.

Источники

  • R. M. Tomasulo. An Efficient Algorithm for Exploiting Multiple Arithmetic Units. IBM Journal, 1967 — doi.org/10.1147/rd.111.0025. Первоисточник переименования и динамического планирования.
  • J. E. Smith, A. R. Pleszkun. Implementing Precise Interrupts in Pipelined Processors. IEEE ToC, 1988 — работа, из которой вырос буфер переупорядочивания.
  • J. Hennessy, D. Patterson. Computer Architecture: A Quantitative Approach — глава 3 полностью посвящена ILP, переименованию, окну и их пределам.
  • D. Wall. Limits of Instruction-Level Parallelism. ASPLOS, 1991 — doi.org/10.1145/106972.106991: измерение того, сколько ILP вообще есть в реальных программах.
  • G. Chrysos, J. Emer. Memory Dependence Prediction using Store Sets. ISCA, 1998 — doi.org/10.1145/279358.279378.
  • A. Yasin. A Top-Down Method for Performance Analysis and Counters Architecture. ISPASS, 2014 — методика, лежащая в основе perf stat -M Topdown.
  • A. Fog. The Microarchitecture of Intel, AMD and VIA CPUs — agner.org/optimize: измеренные ёмкости структур и латентности по поколениям.
  • uops.info — автоматически измеренные латентности, пропускные способности и раскладка по портам для инструкций x86.
  • Документация llvm-mcallvm.org: как читать временную диаграмму внеочередного исполнения для своего кода.

Что дальше

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

Предсказание переходов и спекуляция

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

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

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

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