Когерентность кешей

Предпосылки: иерархия памяти и кеш (cache line, L1/L2 per core, L3 shared).

Устройство кеша | Оперативная память

Кеш решил проблему доступа к данным для одного ядра: вместо ~100 нс на каждое обращение к RAM — ~1 нс из L1. Но в многоядерном процессоре у каждого ядра свой L1 и L2. Что происходит, когда два ядра работают с одним и тем же адресом?

Ядро 0 записывает counter = 1 в свой L1. Ядро 1 читает ту же переменную из своего L1, где по-прежнему counter = 0. Два ядра видят разные значения одного адреса — кеши рассогласованы. Без аппаратного механизма согласования многоядерность принципиально сломана: результат программы зависит от того, какое ядро когда прочитало свою копию.

Чтобы многоядерный процессор оставался предсказуемым, в железо встроен аппаратный протокол, который гарантирует: если одно ядро записало значение по адресу X, все остальные ядра при чтении того же адреса увидят это новое значение. Это и есть когерентность кешей (cache coherence, буквально «связность», «согласованность»). Каждое ядро видит актуальные данные, даже если физически они хранятся в L1 другого ядра.

Чтобы обеспечить эту гарантию, каждая кеш-линия должна нести состояние: кто ей владеет, кто может читать, была ли она изменена. Протокол MESI (Modified — изменённая, Exclusive — исключительная, Shared — разделяемая, Invalid — недействительная) кодирует это четырьмя состояниями.

Протокол MESI: четыре состояния кеш-линии

Каждая кеш-линия (64 байта данных) в L1/L2 каждого ядра помечена одним из четырёх состояний:

Modified (M, «изменённая») — линия изменена только в этом кеше. Копия в RAM устарела. Ни у одного другого ядра этой линии нет. Ядро — единоличный владелец и обязано записать данные в RAM (или передать другому ядру) при вытеснении.

Exclusive (E, «исключительная») — линия присутствует только в этом кеше и совпадает с RAM. Ни одно другое ядро её не кеширует. Отличие от Modified: данные чистые, при вытеснении записывать не нужно. Ключевое свойство: переход E M происходит мгновенно — ядро уже единственный владелец, оповещать другие ядра не нужно.

Shared (S, «разделяемая») — линия может присутствовать в кешах нескольких ядер. Все копии совпадают с RAM. Чтение — бесплатно. Запись требует инвалидации всех остальных копий.

Invalid (I, «недействительная») — линия невалидна, данных в этом кеше нет. Любое обращение к этому адресу потребует загрузки из RAM или из кеша другого ядра.

Состояния кеш-линии в протоколе MESI
 
  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
  │ Modified │    │Exclusive │    │  Shared  │    │ Invalid  │
  │          │    │          │    │          │    │          │
  │ только   │    │ только   │    │ несколько│    │ данных   │
  │ здесь,   │    │ здесь,   │    │ ядер,    │    │ нет      │
  │ грязная  │    │ чистая   │    │ чистая   │    │          │
  └──────────┘    └──────────┘    └──────────┘    └──────────┘
  запись: да       запись: да      запись: нет     запись: нет
  чтение: да       чтение: да      чтение: да      чтение: нет
  RAM актуальна:   RAM актуальна:  RAM актуальна:
  нет              да              да

MESI на практике: два ядра и один счётчик

Проследим сценарий с переменной counter шаг за шагом. Переменная находится по адресу 0x7f00, который попадает в кеш-линию, охватывающую адреса 0x7f000x7f3f (64 байта, выровненные по границе 64).

Шаг 1: Core 0 читает counter

Core 0 выполняет load [0x7f00]. Адреса нет ни в одном кеше. Core 0 посылает когерентный запрос на интерконнект (interconnect — общий канал связи между ядрами). В протоколе MESI такой запрос называется bus read (чтение через шину) — название сохранилось с эпохи, когда ядра были связаны общей шиной, хотя в современных процессорах топология другая. Ни одно ядро не кеширует эту линию, поэтому данные приходят из RAM. Кеш-линия загружается в L1 ядра 0 в состоянии Exclusive — только Core 0 имеет копию, и она совпадает с RAM.

Core 0 L1:  [0x7f00-0x7f3f]  состояние = E   counter = 0
Core 1 L1:  ---              состояние = I
RAM:        [0x7f00-0x7f3f]                  counter = 0

Чтение из Exclusive обходится в ~1 нс — обычный L1 hit.

Шаг 2: Core 1 читает counter

Core 1 выполняет load [0x7f00]. Адреса в его кеше нет. Core 1 посылает bus read. Core 0 видит этот запрос на интерконнекте (механизм snooping, буквально «подслушивание»: контроллер кеша каждого ядра отслеживает адреса всех транзакций на интерконнекте и сверяет их со своими кеш-линиями — если адрес совпадает, ядро реагирует по правилам MESI). Core 0 обнаруживает, что у него есть эта линия в состоянии E. Оба ядра переводят свои копии в Shared.

Core 0 L1:  [0x7f00-0x7f3f]  состояние = S   counter = 0
Core 1 L1:  [0x7f00-0x7f3f]  состояние = S   counter = 0
RAM:        [0x7f00-0x7f3f]                  counter = 0

Теперь оба ядра могут читать counter за 1 нс. Данные идентичны. Пока линия остаётся в Shared, повторные чтения с обеих сторон не генерируют трафик на интерконнекте — каждое ядро работает с собственной копией в L1, и протокол не вмешивается.

Шаг 3: Core 0 записывает counter = 1 (invalidate и подтверждение инвалидации)

Core 0 выполняет store [0x7f00], 1. Линия в состоянии Shared — значит, у другого ядра есть копия. Прежде чем записать, Core 0 посылает на интерконнект invalidate — сообщение «я буду писать по этому адресу, все остальные — сбросьте свои копии».

Core 1 видит invalidate, переводит свою копию в Invalid. Core 0 получает подтверждение инвалидации (invalidate acknowledgement), переводит свою линию в Modified и выполняет запись.

Core 0 L1:  [0x7f00-0x7f3f]  состояние = M   counter = 1
Core 1 L1:  [0x7f00-0x7f3f]  состояние = I   (данные невалидны)
RAM:        [0x7f00-0x7f3f]                  counter = 0  (устарела!)

Обратите внимание: RAM не обновлена. В состоянии Modified свежие данные существуют только в L1 ядра 0 — RAM содержит устаревшую копию, пока ядро не отдаст линию (write-back).

Шаг 4: Core 1 читает counter

Cache-to-cache transfer

Cache-to-cache transfer — это передача актуальной кеш-линии напрямую из кеша одного ядра в кеш другого, без чтения из RAM. Она нужна, когда другое ядро уже держит более свежую копию линии, чем память. В нашем сценарии это происходит именно здесь.

Core 1 выполняет load [0x7f00]. Его копия — Invalid. Core 1 посылает bus read. Core 0 видит запрос через snooping и обнаруживает, что линия в состоянии Modified — значит, в RAM устаревшие данные. Core 0 отдаёт актуальную копию Core 1 напрямую (cache-to-cache transfer), минуя RAM. В чистом MESI при переходе M S линия одновременно записывается в RAM — иначе нарушился бы инвариант состояния Shared «все копии совпадают с RAM». Обе копии переходят в Shared.

Core 0 L1:  [0x7f00-0x7f3f]  состояние = S   counter = 1
Core 1 L1:  [0x7f00-0x7f3f]  состояние = S   counter = 1
RAM:        [0x7f00-0x7f3f]                  counter = 1  (обновлена при M->S)

Core 1 получил актуальное значение. Когерентность сработала: запись ядра 0 стала видна ядру 1.

sequenceDiagram
    participant C0 as Core 0 / L1
    participant BUS as Интерконнект
    participant C1 as Core 1 / L1
    participant RAM as RAM

    C0->>BUS: bus read counter
    BUS->>RAM: запрос линии
    RAM-->>C0: данные
    Note over C0: состояние: Exclusive

    C1->>BUS: bus read counter
    BUS-->>C0: snoop: bus read
    C0-->>C1: копия линии
    Note over C0,C1: обе копии: Shared

    C0->>BUS: invalidate counter
    BUS-->>C1: invalidate
    C1-->>C0: подтверждение инвалидации
    Note over C0: Modified, counter = 1

    C1->>BUS: bus read counter
    BUS-->>C0: snoop: bus read
    C0-->>C1: cache-to-cache transfer
    C0->>RAM: write-back counter = 1
    Note over C0,C1: обе копии: Shared, counter = 1

Важный момент в этой последовательности: интерконнект нужен не для каждого чтения, а только в точках смены владения или когда другой кеш уже держит более свежую копию, чем RAM. Пока линия локальна и не оспаривается, чтения и записи обходятся в ~1 нс — обычный L1 hit без дополнительной задержки на когерентный протокол.

Обязательный write-back при каждом M S — цена чистого MESI. Реальные процессоры отходят от него в обе стороны. AMD и ARM часто используют MOESI: добавляется состояние Owned, позволяющее держать «грязную» линию разделяемой между ядрами без немедленного обновления RAM. Intel с Nehalem — MESIF (F — Forward: из нескольких Shared-копий одна помечена как «ответственная за передачу» запросам от других кешей). Суть когерентности от этих расширений не меняется: все те же инварианты «одна Modified или несколько Shared», тот же набор переходов. Owned и Forward оптимизируют, кто именно обслуживает запросы и когда можно отложить запись в RAM, — это важно для производительности cache-to-cache transfer, не для ментальной модели протокола.

Переход M -> S в диаграмме выше включает cache-to-cache transfer и write-back.

Когерентность — не атомарность

Когерентность гарантирует видимость каждой отдельной записи. Но операция counter++ — это не одна запись, а три шага: load (прочитать текущее значение), add (прибавить 1), store (записать результат). Атомарная операция (atomic operation) — операция, которую процессор выполняет целиком, не позволяя другому ядру вмешаться между её шагами. Когерентность не даёт атомарности: она гарантирует, что каждый отдельный store станет виден, но не запрещает другому ядру вклиниться между load и store.

Core 0                 Core 1
──────                 ──────
load counter   (= 5)
                       load counter   (= 5)
add 1          (= 6)
                       add 1          (= 6)
store counter  (= 6)
                       store counter  (= 6)
 
Ожидалось: 7. Получилось: 6. Потерян один инкремент.

Когерентность здесь работает корректно: каждый store виден другим ядрам. Проблема в том, что последовательность load-add-store не атомарна — другое ядро может вклиниться между load и store. Эта ситуация называется гонкой (race condition) — результат зависит от того, в каком порядке ядра чередуют свои шаги.

Процессор предоставляет для этого атомарные инструкции: они выполняют read-modify-write как единое целое, не позволяя другим ядрам вмешаться. Поверх аппаратных атомарных инструкций строятся программные примитивы синхронизации, обеспечивающие неделимость произвольных блоков кода.

Цена когерентности в наносекундах

Порядок величин задержек на типичном серверном процессоре (~2020):

Операция                                    Задержка
──────────────────────────────────────────────────────
L1 hit (локальная линия: E, M или S)        ~1 нс
L2 hit                                      ~4 нс
L3 hit (локально)                           ~12 нс
Чтение линии Modified у другого ядра         ~20-70 нс
  (cache-to-cache transfer)
Запись в линию Shared                        ~20-50 нс
  (invalidate + подтверждение инвалидации)
Промах по всем кешам (из RAM)                ~80-100 нс

Чтение линии Modified у другого ядра в таблице выше — это cache-to-cache transfer.

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

Эти десятки наносекунд — цена передачи владения кеш-линией между ядрами на одном сокете. На процессорах с чиплетной компоновкой цена растёт: у AMD Ryzen и EPYC ядра сгруппированы в CCX (Core Complex) — кластеры по 4–8 ядер со своим L3. Когерентная транзакция между CCX идёт через межчиплетную шину (Infinity Fabric) и стоит 80–200 нс — эффект, похожий на NUMA, но для кешей: локально на CCX — десятки наносекунд, между CCX — сотни. Подробный пример с атомарным инкрементом общего счётчика — в атомарных инструкциях; здесь важно общее правило: как только операция требует invalidate и подтверждения инвалидации, локальный доступ уровня L1 превращается в когерентную транзакцию на десятки (а на cross-чиплетном обращении — сотни) наносекунд.

Store buffer: когерентность не платится синхронно

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

Для атомарных инструкций эта уловка не работает: read-modify-write должен дождаться захвата линии в Modified, иначе неделимость была бы фикцией. Поэтому атомарные операции над оспариваемой линией платят полную цену когерентности, а обычные записи — амортизированную.

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

False sharing: ловушка скрытого разделения

Пинг-понг легко заметить на общем счётчике: оба ядра действительно записывают в один адрес. Но существует ситуация, когда два потока работают с разными переменными, и производительность всё равно деградирует в десятки раз. Это false sharing (ложное разделение) — одна из самых коварных проблем многоядерного программирования.

Два потока, каждый инкрементирует свой собственный счётчик. Логически — никакого разделения данных:

struct counters {
    int64_t thread0_counter;  // байты 0-7
    int64_t thread1_counter;  // байты 8-15
} c;
 
Core 0:                          Core 1:
  повторить 100M раз:              повторить 100M раз:
    c.thread0_counter++              c.thread1_counter++

Каждый поток пишет в свою переменную. Никакой гонки данных. Можно ожидать, что два потока отработают за то же время, что один — каждое ядро инкрементирует свою переменную в своём L1.

На практике два потока с этой структурой работают в десятки раз медленнее одного потока. При полном отсутствии логического разделения данных.

Причина: кеш работает линиями, а не байтами

thread0_counter занимает байты 0-7 структуры, thread1_counter — байты 8-15. Размер структуры — 16 байт, а кеш-линия — 64. Допустим, c начинается по адресу 0x7f80 — началу кеш-линии. Тогда оба поля попадают в одну линию:

Кеш-линия 64 байта
┌────────────────────────────────────────────────────────────────┐
│ thread0_counter │ thread1_counter │        (пустые байты)      │
│   байты 0-7     │   байты 8-15    │        байты 16-63         │
└────────────────────────────────────────────────────────────────┘

Протокол MESI оперирует целыми кеш-линиями. Когда Core 0 записывает в thread0_counter, он инвалидирует всю линию в кеше Core 1. Core 1 при следующей записи в thread1_counter обнаруживает, что линия в состоянии Invalid, и вынужден запрашивать актуальную копию у Core 0. Получает, переводит в Modified, записывает — и инвалидирует линию у Core 0. Пинг-понг, идентичный тому, что происходит с настоящим общим счётчиком.

flowchart LR
    A["Одна кеш-линия:<br>thread0_counter + thread1_counter"] --> B["Core 0 пишет thread0_counter"]
    B --> C["Линия -> Modified в Core 0<br>копия Core 1 -> Invalid"]
    C --> D["Core 1 пишет thread1_counter"]
    D --> E["Линия -> Modified в Core 1<br>копия Core 0 -> Invalid"]
    E --> F["Следующая запись Core 0<br>снова требует передачу владения"]
    F --> B

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

Масштаб проблемы

С ростом числа ядер ситуация ухудшается: каждый invalidate требует подтверждения инвалидации от ядер, кеширующих эту линию, и когерентный трафик на интерконнекте растёт.

Устранение false sharing: выравнивание по кеш-линии

Решение — разнести переменные в разные кеш-линии. Если каждая переменная начинается с границы 64 байт, она гарантированно не делит линию ни с чем. Выравнивание (alignment) — размещение данных по адресам, кратным их размеру: четырёхбайтовый int по адресу, кратному 4. Подробнее — в ABI и размещении данных. Здесь тот же принцип, но цель не корректность доступа, а производительность.

В C с помощью _Alignas (C11):

struct counters {
    _Alignas(64) int64_t thread0_counter;  // линия 0: байты 0-63
    _Alignas(64) int64_t thread1_counter;  // линия 1: байты 64-127
};

Размер структуры вырос с 16 байт до 128 байт (две кеш-линии). Зато каждый поток работает со своей линией, пинг-понг прекращается, и время работы возвращается к ожидаемому — столько же, сколько один поток. Платёж — память: восьмикратный рост размера структуры ради двух 8-байтовых счётчиков. Поэтому выравнивание по кеш-линии оправдано для полей, в которые часто и независимо пишут разные ядра (горячие счётчики, очереди, per-CPU данные), а не для всех подряд. Аналогичные механизмы есть в Rust (#[repr(align(64))]), C++ (alignas(64)), Java (@Contended), Go (padding).

Когда кеш становится накладным расходом

Все примеры выше — два ядра. На реальных процессорах ядер десятки, и иерархия — L1 → L2 → L3 (общий для всех ядер сокета) → RAM. Когерентный протокол живёт в этой иерархии: L3 берёт на себя роль координатора — встроенный в него snoop filter (фильтр слежения) знает, какие ядра кешируют какую линию, и направляет invalidate/bus read адресно, а не рассылает всем. Это снижает когерентный трафик, но не меняет картины для программиста: запись одного ядра становится видна другим через тот же протокол.

Любая операция, требующая когерентной транзакции с другим ядром, стоит 20–70 нс (а между CCX/сокетами — до сотен) — сопоставимо с промахом в RAM. Кеш помогает только когда данные локальны для ядра. Как только два ядра начинают писать в одну линию — настоящее разделение или false sharing — кеш превращается из ускорителя в накладной расход на передачу владения. Разница в два порядка между L1-hit и cross-socket обращением — самый дорогой компонент атомарных инструкций.

Цена, которую здесь зафиксировала таблица, — это цена передачи владения. Вторая часть задержки памяти — физика самого DRAM: строки, столбцы, банки, тайминги. Без неё число «~100 нс из RAM» повисает как данность; разберём его устройство в оперативной памяти.

См. также

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

Sources

  • John L. Hennessy, David A. Patterson, 2017, Computer Architecture: A Quantitative Approach — Chapter 5: Memory Hierarchy Design, Section 5.2: Cache Coherence
  • Intel, 2024, Intel 64 and IA-32 Architectures Software Developer’s Manual — Volume 3, Chapter 11 (Memory Cache Control)
  • Ulrich Drepper, 2007, What Every Programmer Should Know About Memory — Section 3.3.4: Cache Coherency

Устройство кеша | Оперативная память