Процессор

Предпосылки: базовое программирование (переменные, циклы, функции).

Путь данных: Иерархия памяти | Программная модель: CISC и RISC

Когда вы пишете программу, код не “выполняется сам”. Команды нужно по одной прочитать, понять и применить к данным. Пока программа маленькая, об этом почти не думаешь: написал цикл, запустил, получил результат. Но как только возникает вопрос “почему эта программа вообще работает” или “почему простой код может работать медленно”, быстро упираешься в одну и ту же сущность. Нужна часть компьютера, которая и занимается выполнением команд. Эта заметка объясняет, что это за часть, как она работает и почему её внутреннее устройство влияет на скорость программы.

Что делает процессор

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

Возьмём простую задачу: просуммировать массив из миллиона целых чисел.

int sum = 0;
for (int i = 0; i < 1000000; i++)
    sum += arr[i];

Компилятор превращает этот цикл в последовательность машинных инструкций: загрузить элемент массива из памяти в регистр, прибавить к аккумулятору, увеличить индекс, проверить условие цикла, перейти к началу. Каждая итерация — 4–5 инструкций. Миллион итераций — около 5 миллионов инструкций. На процессоре с частотой 3 ГГц это должно занять… сколько? Ответ зависит от того, как процессор устроен внутри.

Такт и регистры

Такт — минимальный временной шаг работы процессора. На частоте 3 ГГц один такт длится 1 / 3 000 000 000 = 0.33 наносекунды. Генератор посылает электрический импульс, и все блоки процессора синхронно делают один шаг своей работы.

Процессор не может складывать «два числа из оперативной памяти» напрямую. У него есть собственное крошечное хранилище — регистры (registers, буквально «ячейки-хранилища»). Доступ к регистру занимает порядка одного такта — доли наносекунды. Доступ к оперативной памяти — порядка 100 наносекунд, в сотни раз медленнее. Если бы процессор каждую операцию делал с RAM напрямую, он простаивал бы 99% времени в ожидании данных. Регистры — решение проблемы «память слишком далеко».

Обратная сторона: регистров мало. В архитектуре x86-64 доступно 16 регистров общего назначения, каждый по 64 бита — всего 128 байт. Эти 16 регистров вместе с несколькими специальными (rip — instruction pointer, указатель на текущую инструкцию; rflags — флаги состояния) образуют регистровый файл (register file) — внутреннюю память ядра, доступную за доли наносекунды. Всё, что не помещается в 128 байт регистров, живёт в оперативной памяти и должно быть явно загружено перед использованием и записано обратно после.

Разрыв между скоростью регистров (~0.33 нс) и скоростью RAM (~100 нс, то есть ~300 тактов простоя) — центральная проблема производительности, и большинство архитектурных решений в процессоре работают вокруг неё.

Тактовая частота

Тактовая частота определяет, сколько тактов процессор выполняет в секунду. Но что именно помещается в один шаг — зависит от того, как устроен процессор внутри.

Цикл «выбрать — декодировать — исполнить»

Процессор работает с инструкциями в цикле из трёх фаз. Fetch (выборка) — загрузить очередную инструкцию по адресу из счётчика инструкций (program counter, rip в x86-64). Decode (декодирование) — разобрать инструкцию на операцию и операнды: в ADD rax, rbx операция — сложение, а операнды — два регистра, чьи значения она складывает. Execute (исполнение) — в зависимости от инструкции выполнить одно действие: операцию в ALU (Arithmetic Logic Unit, арифметико-логическое устройство — блок, выполняющий арифметику и побитовые операции), обращение к памяти или переход.

graph LR
    Инструкция --> Fetch --> Decode --> Execute

Если каждая фаза занимает один такт, одна инструкция обрабатывается за 3 такта. Для нашего цикла суммирования это означает: 5 миллионов инструкций × 3 такта = 15 миллионов тактов. На 3 ГГц — 5 миллисекунд. Но за 9 тактов обрабатываются только 3 инструкции:

  Такт:       1     2     3     4     5     6     7     8     9
  Fetch:     [i1]   .     .    [i2]   .     .    [i3]   .     .
  Decode:     .    [i1]   .     .    [i2]   .     .    [i3]   .
  Execute:    .     .    [i1]   .     .    [i2]   .     .    [i3]

Пока блок Execute работает, блоки Fetch и Decode простаивают — две трети процессора не делают ничего.

Конвейер

Проблема наивной модели в том, что три блока не могут работать одновременно над разными инструкциями. Pipeline (конвейер) решает это: пока Execute обрабатывает инструкцию N, Decode разбирает инструкцию N+1, а Fetch загружает N+2.

  Такт:       1     2     3     4     5     6     7
  Fetch:     [i1]  [i2]  [i3]  [i4]  [i5]   .     .
  Decode:     .    [i1]  [i2]  [i3]  [i4]  [i5]   .
  Execute:    .     .    [i1]  [i2]  [i3]  [i4]  [i5]

Первая инструкция по-прежнему выходит через 3 такта (это задержка — latency). Но начиная с третьего такта каждый следующий такт завершает одну инструкцию. Пропускная способность (throughput) выросла с одной инструкции за 3 такта до одной инструкции за 1 такт — втрое, при той же частоте. Конвейер не ускоряет отдельную инструкцию, но загружает все блоки процессора параллельно. Наш цикл суммирования теперь выполняется за ~5 миллионов тактов вместо 15 — примерно 1.7 миллисекунды вместо 5.

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

Реальные процессоры делят работу не на 3, а на 14–20+ стадий. Больше стадий — более мелкая работа на каждой, а значит можно поднять частоту (короткая стадия завершается быстрее, такт можно укоротить). Но длинный конвейер создаёт новую проблему: чем больше стадий, тем дороже обходится ситуация, когда конвейер не может работать на полную мощность.

Конвейер ломается: зависимость по данным

Конвейер эффективен только пока все стадии заполнены. Когда следующая инструкция нуждается в результате предыдущей, конвейер вынужден ждать.

Data hazard (конфликт данных) — инструкция на стадии Decode нуждается в результате инструкции, которая ещё не дошла до конца Execute. Допустим, i1: r1 = r2 + r3, а i2: r4 = r1 * 5 — второй инструкции нужен r1, который ещё вычисляется. Конвейер вынужден вставить «пузырь» (bubble) — пустой такт, пока данные не будут готовы:

  Такт:       1     2     3     4     5     6
  Fetch:     [i1]  [i2]  [i3]   .    [i4]  [i5]
  Decode:     .    [i1]  [i2]  [i2]  [i3]  [i4]
  Execute:    .     .    [i1]  bub   [i2]  [i3]
                               ^^^
                        пузырь: i2 ждёт результата i1

Частичное решение — forwarding (проброс): результат Execute передаётся напрямую на вход следующей стадии, минуя запись в регистровый файл. Как только i1 завершает сложение, результат тут же подаётся на вход i2 — без ожидания. Это спасает от одного-двух тактов задержки, но не помогает, когда результат приходит из памяти (load-use hazard): данные из RAM идут десятки и сотни тактов, и никакой проброс их не ускорит.

Ветвления и предсказание переходов

Forwarding спасает от задержек внутри арифметики, но есть проблема дороже: условные переходы. В коде постоянно встречаются if, for, while. При команде if (x > 0) процессор на стадии Fetch не знает, какую инструкцию загружать следующей — ту, что в ветке then, или ту, что в ветке else. Результат сравнения станет известен только через несколько тактов, когда инструкция дойдёт до Execute.

Наивное решение — остановить конвейер и ждать результата. При 14 стадиях это означает простой в 14 тактов на каждом ветвлении. В типичном коде условный переход встречается каждые 5–7 инструкций. Если останавливаться каждый раз, конвейер пуст больше половины времени — от его преимуществ не остаётся ничего.

Branch prediction (предсказание переходов) — механизм, который угадывает направление перехода до того, как результат станет известен. Процессор загружает инструкции из предсказанной ветки и продолжает работу, как будто ответ уже получен. Если предсказание верное — никаких потерь. Если неверное — все инструкции, загруженные «по ошибке», сбрасываются, и конвейер заполняется заново. Штраф за промах (misprediction penalty) составляет ~15–20 тактов на современных процессорах — именно столько нужно, чтобы заново заполнить конвейер правильными инструкциями.

Ветвление в нашем цикле sum += arr[i] — лёгкий случай: переход на начало берётся 999 999 раз подряд, предсказатель ошибается только на последней итерации. Один промах на миллион — ничтожно, цикл суммирования branch prediction практически не замедляет. Тяжёлый случай — когда направление перехода зависит от данных:

for (int i = 0; i < N; i++) {
    if (data[i] >= 128)   // условный переход
        sum += data[i];
}

Если массив отсортирован, переход образует паттерн: сначала все элементы меньше 128 (переход не берётся), потом все больше (переход берётся). Предсказатель выучивает паттерн и почти не ошибается. На неотсортированном массиве со случайными значениями направление перехода хаотично — предсказатель угадывает ~50% случаев. Результат: один и тот же код на отсортированных данных выполняется в 2–5 раз быстрее, чем на неотсортированных, и разница полностью определяется промахами предсказателя. Точность современных предсказателей на реальных программах — 95–99%.

Суперскалярное исполнение

Даже при идеальном предсказании переходов пропускная способность конвейера ограничена: одна инструкция за такт. При 3 ГГц — 3 миллиарда инструкций в секунду. Чтобы ускориться дальше, нужно выполнять несколько инструкций за один такт.

Конвейер сделал так, чтобы каждый блок работал на каждом такте. Суперскалярная архитектура (superscalar, буквально «сверхскалярная» — способная обрабатывать больше одного скалярного значения за такт) делает следующий шаг: дублирует исполнительные блоки. Вместо одного ALU процессор содержит несколько: два-три для целочисленных операций, два для операций с плавающей точкой (FPU, Floating Point Unit — блок вещественной арифметики), отдельные блоки для загрузки и записи в память. На каждом такте процессор выбирает, декодирует и отправляет на исполнение не одну, а несколько инструкций параллельно — столько, сколько позволяют независимые ресурсы.

  Такт:       1      2      3      4
  ALU 0:     [i1]   [i3]   [i5]   [i7]
  ALU 1:     [i2]   [i4]   [i6]   [i8]
  FPU:        .     [f1]   [f2]   [f3]
  Load:      [ld1]  [ld2]  [ld3]  [ld4]

Ширина суперскалярного процессора (issue width) — количество инструкций, которые он может запустить за один такт. Современные процессоры имеют ширину 4–6: Intel Golden Cove (Alder Lake, 2021) — до 6 микроопераций (внутренних команд процессора; подробнее ниже) за такт, Apple Firestorm (M1, 2020) — до 8.

Ключевая метрика — IPC (instructions per clock, инструкций за такт). Не суперскалярный конвейер даёт IPC = 1. Суперскалярный процессор с шириной 4 теоретически может дать IPC = 4, но на практике достигает 2–4 в зависимости от кода: не всегда удаётся найти 4 независимые инструкции подряд, не все исполнительные блоки загружены каждый такт, и зависимости по данным создают простои. Производительность процессора определяется формулой:

Производительность ≈ Частота × IPC

Процессор на 3 ГГц с IPC = 1 выполняет 3 миллиарда инструкций в секунду. Процессор на 3 ГГц с IPC = 3 — 9 миллиардов. Вот почему два процессора на одной частоте показывают разный результат: серверный процессор 2010-х с IPC ~1.5 и Apple M1 с IPC ~3–4 оба на 3 ГГц, но M1 выполняет вдвое-втрое больше работы за каждый такт.

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

В нашем цикле суммирования зависимости внутри одной итерации распадаются на две независимые цепочки. Первая: загрузить arr[i] → прибавить к сумме (сложение ждёт загруженного значения). Вторая: увеличить индекс → сравнить с границей → перейти на начало (сравнение ждёт нового индекса, переход — сравнения). Суперскалярный процессор тянет обе цепочки параллельно на разных блоках: в первом такте стартуют загрузка и увеличение индекса, во втором — сложение и сравнение, в третьем — переход. Три такта на пять инструкций вместо пяти последовательных — IPC поднимается с 1 до ~5/3 ≈ 1.7, почти вдвое против скалярного конвейера.

Помимо целочисленных ALU и FPU, суперскалярный процессор может содержать и другие специализированные блоки — например, FMA (Fused Multiply-Add — совмещённое умножение-сложение) для задач линейной алгебры и блоки SIMD (Single Instruction, Multiple Data — одна инструкция, много данных) для обработки нескольких чисел одной инструкцией. Подробно об этом — в заметке о SIMD.

На x86-процессорах сложные инструкции переменной длины на стадии Decode разбиваются на микрооперации (micro-ops, μops) — простые внутренние команды фиксированной длины, с которыми работают конвейер и суперскалярный движок. Подробнее о различиях наборов команд — CISC, RISC и их влиянии на энергоэффективность.

Внеочерёдное исполнение

Суперскалярный процессор может запустить несколько инструкций параллельно, но только если они независимы — не используют результат друг друга. В коде a = b + c; d = a * 2 вторая инструкция зависит от первой: значение a нужно, чтобы вычислить d. Параллельно их запустить нельзя. Чем больше зависимостей в коде, тем ниже реальный IPC. Нужен способ не ждать, а делать что-то полезное.

Out-of-order execution (OoO, внеочерёдное исполнение) разрешает процессору выполнять инструкции не в том порядке, в котором они записаны в программе, а в порядке готовности данных. Если инструкция N ждёт результата из памяти, а инструкции N+1, N+2, N+3 от неё не зависят — процессор находит их и запускает раньше.

Допустим, программа содержит такую последовательность:

1: load  r1, [addr_A]     # загрузить из памяти в r1 (~300 тактов)
2: add   r2, r1, 10       # r2 = r1 + 10  (зависит от r1)
3: load  r3, [addr_B]     # загрузить другой адрес (~300 тактов)
4: mul   r4, r3, 5        # r4 = r3 * 5   (зависит от r3)
5: add   r5, r6, r7       # r5 = r6 + r7  (не зависит ни от r1, ни от r3)

Процессор с выполнением по порядку (in-order) начинает load r1 и ждёт ~300 тактов. Потом выполняет add r2, начинает load r3, снова ждёт ~300 тактов. Общее время — около 600 тактов, большая часть которых — ожидание:

In-order:
  такт:    0         300       301       600       601  602
  i1:      |--load r1--|
  i2:                   |add r2|
  i3:                           |---load r3---|
  i4:                                          |mul r4|
  i5:                                                  |add r5|
  итого: ~602 тактов

Процессор с OoO видит всю картину сразу. Он запускает load r1 и load r3 параллельно — оба обращения к памяти идут одновременно. Пока данные в пути, он исполняет add r5 = r6 + r7, потому что эта инструкция не зависит ни от одного загружаемого значения. Когда r1 приходит — выполняется add r2. Когда r3mul r4:

Out-of-order:
  такт:    0         1       300       301  302
  i1:      |---load r1------------|
  i3:      |---load r3------------|
  i5:      |add r5|                               (готова сразу)
  i2:                              |add r2|       (ждала r1)
  i4:                              |mul r4|       (ждала r3)
  итого: ~302 тактов

Вместо 600 тактов — ~300, потому что оба обращения к памяти перекрылись, а независимая арифметика выполнилась в «дырке» ожидания.

Внутри OoO-процессора инструкции проходят через несколько структур. После декодирования инструкция попадает в reservation station (RS, станция резервирования) — буфер ожидания, где она ждёт готовности своих операндов. Как только оба операнда доступны (вычислены предыдущими инструкциями или уже лежат в регистрах), RS отправляет инструкцию на свободный исполнительный блок. Это и есть внеочерёдная диспетчеризация: инструкции запускаются не в порядке программы, а в порядке готовности.

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

Ещё одно препятствие для OoO — ложные зависимости. В коде r1 = r2 + r3; r1 = r4 + r5 вторая инструкция не использует результат первой, данные независимы, но обе пишут в один и тот же регистр r1. Если OoO выполнит вторую раньше первой, а потом первую — первая затрёт результат второй, и в r1 окажется r2 + r3 вместо ожидаемого r4 + r5. Чтобы такого не произошло, наивный OoO вынужден сериализовать такую пару — хотя по данным они не зависят друг от друга.

Register renaming (переименование регистров) снимает это ограничение. Внутри процессор держит значительно больше физических регистров, чем видит программа (на x86-64 программе доступно 16 регистров общего назначения, физических внутри — 200+), и таблицу соответствия «архитектурный регистр, который видит код → текущий физический, в котором реально лежат данные». Инструкция, пишущая в r1, получает свежий физический слот (скажем, P42): результат пойдёт туда, а таблица сразу обновляется на r1 → P42. Любое последующее чтение r1 сверяется с таблицей и забирает актуальный физический регистр. Когда появляется вторая запись в r1, ей выдаётся уже другой слот — скажем, P57, таблица обновляется на r1 → P57, но P42 остаётся жить, пока на него есть читатели. Две записи в r1 оказываются в разных физических местах, ложной зависимости между ними больше нет — они могут выполняться параллельно.

flowchart LR
    D["Decode"] --> R["Register renaming"]
    R --> RS["Reservation station<br>ждёт готовности операндов"]
    R --> ROB["Reorder buffer (ROB)<br>помнит порядок программы"]
    RS --> EX1["ALU"]
    RS --> EX2["Load/store"]
    RS --> EX3["FPU"]
    EX1 --> ROB
    EX2 --> ROB
    EX3 --> ROB
    ROB --> C["Commit<br>строго по порядку программы"]

RS отвечает на вопрос «что уже готово к запуску?», ROB — на «в каком порядке результаты делать видимыми программе?», register renaming — на «как не дать одинаковым именам регистров создать ложную зависимость». Все три вместе позволяют выполнять независимые инструкции раньше, не ломая модель последовательного исполнения.

Путь инструкции через OoO-процессор: (1) decode разбирает инструкцию; (2) register renaming назначает физический регистр; (3) инструкция занимает запись в ROB и отправляется в reservation station; (4) RS ждёт готовности операндов; (5) как только операнды готовы, инструкция уходит на исполнительный блок; (6) результат записывается в физический регистр, инструкция помечается как завершённая в ROB; (7) когда инструкция оказывается на голове ROB (все более старые уже зафиксированы), происходит commit — результат становится архитектурно видимым, запись ROB освобождается.

Запись в ROB выделяется при декодировании и освобождается только при коммите. Если инструкция загрузки ждёт данные из памяти — как те 200 тактов в примере выше — её запись занята, и все более молодые инструкции за ней не могут зафиксироваться, даже если давно завершились. Старые записи висят, а процессор параллельно продолжает декодировать новые и выделять им следующие слоты — буфер постепенно заполняется. Когда свободных записей не остаётся, процессор останавливает декодирование — поток новых инструкций иссякает. Поэтому современные процессоры наращивают ROB: Intel Golden Cove — 512 записей, Apple M1 Firestorm — ~630. Чем больше буфер, тем дальше процессор заглядывает за застрявшую инструкцию и тем больше независимой работы находит.

В нашем цикле суммирования sum += arr[i] OoO не устраняет главную зависимость: каждое прибавление к sum ждёт результата предыдущего — это loop-carried dependency (зависимость между итерациями), которую перестановкой инструкций не обойти. Но OoO всё равно помогает: он перекрывает загрузку arr[i+1] с адресной арифметикой следующей итерации, пока текущее сложение вычисляется. Полезная работа заполняет «дырки» ожидания, даже если сама цепочка sum остаётся последовательной.

Спекулятивное исполнение

Предсказание переходов говорит процессору, куда пойдёт ветвление. Внеочерёдное исполнение позволяет выполнять инструкции, не дожидаясь подтверждения. Вместе они образуют speculative execution (спекулятивное исполнение): процессор выполняет инструкции из предсказанной ветки до того, как условие вычислено. Если предсказание верное — результаты фиксируются в ROB как обычно. Если нет — ROB отбрасывает всё, что было сделано спекулятивно, и конвейер начинает заново с правильной ветки.

Спекулятивное исполнение критически важно для производительности: без него OoO-процессор останавливался бы на каждом условном переходе, не имея возможности заглянуть дальше. Учитывая, что переходы встречаются каждые 5–7 инструкций, без спекуляции ROB быстро опустеет — нечего будет выполнять параллельно.

Помимо ветвлений, процессор спекулирует и на загрузках из памяти. Store-to-load forwarding (буквально «пересылка от записи к загрузке») — если предыдущая инструкция записала значение по адресу, а следующая читает тот же адрес, процессор не ждёт, пока запись зафиксируется в памяти: он берёт значение напрямую из store buffer (буфер ещё не зафиксированных записей, где запись живёт от исполнения до коммита) и отдаёт его загрузке. Memory disambiguation (буквально «снятие неоднозначности по адресам памяти») — ситуация сложнее: загрузка готова исполняться, а адрес предшествующей записи ещё не вычислен (сам store ждёт свои операнды). Процессор встаёт перед выбором: ждать, пока адрес записи посчитается, или угадать, что адреса не совпадут, и выполнить загрузку сейчас. Он выбирает второе — оптимистично предполагает, что конфликта нет, и запускает загрузку спекулятивно. Когда адрес записи наконец вычисляется, процессор сверяет его с адресом загрузки; если совпали — результат загрузки и всё, что от неё зависело, откатывается, как при промахе предсказания переходов.

Но именно этот механизм стал причиной двух крупнейших аппаратных уязвимостей — Spectre и Meltdown, обнаруженных в январе 2018 года. Общий принцип обеих атак: при спекулятивном выполнении процессор обращается к данным, к которым у программы нет доступа. Результат откатывается, но побочный эффект — изменение состояния кеша (промежуточной памяти между процессором и RAM — подробно в следующей заметке) — остаётся. Атакующий измеряет тайминг доступа к кешу и восстанавливает секретные данные.

Исправления потребовали изменений в микрокоде, операционных системах и компиляторах. Совокупная цена — снижение производительности на 2–30% в зависимости от рабочей нагрузки. Вычислительные задачи почти не пострадали: они редко обращаются к ядру. Задачи с частыми системными вызовами (обращениями к операционной системе — базы данных, сетевые серверы) пострадали сильнее: PostgreSQL потерял 7–17% производительности на операциях SELECT.

Спекуляция превратила кеш из прозрачной оптимизации в поверхность атаки — и этот факт резко сделал его видимым для программиста. Но атаки Spectre/Meltdown показали более глубокую проблему: даже если спекулятивное исполнение безопасно, доступ к памяти остаётся узким местом. Каждый раз, когда процессор ждёт данные, конвейер останавливается. Несколько техник (OoO, спекулятивная загрузка) помогают скрывать ожидание, но полностью избежать его невозможно.

Стена памяти

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

Каждая арифметическая операция требует операндов. Если операнды в регистрах — один такт, ~0.33 нс. Если в оперативной памяти — ~100 нс, что при 3 ГГц составляет ~300 тактов простоя. За эти 300 тактов суперскалярный процессор с шириной 6 мог бы выполнить до 1800 операций — но он ждёт одного числа из RAM.

Проблема глубже, чем кажется. Тактовая частота процессоров росла экспоненциально с 1980-х по середину 2000-х: от 5 МГц (Intel 8086, 1979) до 3.8 ГГц (Pentium 4, 2004) — рост в ~760 раз. Задержка оперативной памяти за то же время улучшилась лишь в ~1.5–2 раза. Разрыв между скоростью процессора и задержкой памяти увеличивается с каждым поколением — это и есть «стена памяти» (memory wall). Почему DRAM не может стать быстрее — вопрос физики хранения данных, разобранный в отдельной заметке.

Для нашего цикла суммирования это означает: даже с OoO-перекрытием загрузок, если массив не помещается в быстрое хранилище рядом с процессором, каждое обращение к arr[i] стоит сотни тактов. Вычисления упираются не в арифметику, а в ожидание данных.

Между регистрами и оперативной памятью необходим промежуточный слой — кеш-память (cache), которая хранит копии часто используемых данных ближе к процессору. Стена памяти ведёт к иерархии кешей. Но есть и другой вопрос: какой набор команд процессор предоставляет программе и как это влияет на энергоэффективность — CISC и RISC.

От одного ядра к нескольким

Всё вышеописанное — конвейер, суперскалярность, OoO, спекулятивное исполнение — это архитектура одного ядра (core). Ядро — независимая копия всего вычислительного конвейера внутри процессора: собственные регистры, собственные стадии fetch/decode/execute, собственные кеши L1 и L2. Каждое ядро исполняет свой поток инструкций полностью автономно, как если бы это был отдельный простой процессор. Современные процессоры помещают на один кристалл несколько таких ядер: 8–16 в десктопных, 64–128 в серверных.

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

Sources


Путь данных: Иерархия памяти | Программная модель: CISC и RISC