Каждое ядро процессора живёт со своей копией правды: собственные кэши первого, второго и третьего уровней держат копии одних и тех же строк памяти, и без специального протокола система развалилась бы на несогласованные взгляды. Протокол когерентности держит иллюзию единой памяти, но поддержание иллюзии стоит тактов и полосы межпроцессорной шины, и стоит двум горячим потокам подраться за одну строку кэша, как производительность проваливается в разы без единого видимого признака в обычных графиках загрузки. Диагностикой таких проблем занимается отдельный класс инструментов, и главный из них в Linux, perf c2c, был придуман ровно для охоты на строки, которые ядро гоняет между сокетами. Хорошая новость в том, что лечение обычно стоит минуту правки выравнивания, а плохая в том, что без специальных инструментов проблему не видно вовсе: обычные профилировщики покажут всплеск в кэшных событиях, но не назовут виновника.

Разберём протоколы когерентности, как их правила видны программисту, ложное разделение строк, работу perf c2c и обслуживающие инструкции кэша.

Протокол MESI и его родственники

Базовая схема называется MESI по четырём состояниям строки кэша. Modified означает, что строка изменена и расходится с памятью,Exclusive что она есть только у этого ядра и совпадает с памятью, Shared что копий много и все совпадают с памятью, Invalid что строка недействительна. Чтение чужой изменённой строки заставляет владельца выгрузить её, и на этой рутине строится вся когерентность: любой запрос на запись инвалидирует чужие копии, любой запрос на чтение изменённой строки переносит свежие данные. Классическая драка за строку выглядит как пинг-понг: ядро А пишет строку и держит её изменённой, ядро Б просит чтение, А выгружает и переходит в общее, Б инвалидирует копию А своей записью, и цикл повторяется на каждом шаге обоих потоков, сжигая шину на обмене, который с точки зрения логики программы вовсе не нужен. Вендоры расширили схему в двух направлениях: Intel добавила состояние Forward в MESIF, выделяя одного держателя копии, который и отвечает на запросы, а AMD добавила состояние Owned в MOESI, позволяя нескольким ядрам делить изменённую строку без немедленной выгрузки в память. Разница не академическая: она определяет, где физически происходит обмен между ядрами, в кэше или через контроллер памяти, и как выглядит трафик под нагрузкой.

Базовый размер строки 64 байта на большинстве платформ, и это значение читается стандартными средствами:

lscpu -C
getconf LEVEL1_DCACHE_LINESIZE

Раскладка кэшей по ядрам и уровням в выводе lscpu показывает и общий кэш третьего уровня, который важен для понимания, где кончается дешёвый обмен и начинается дорогой межсокетный.

Как правила когерентности видны программисту

Модель памяти x86 сильная: записи не переупорядочиваются между собой, чтения тоже, и программист чувствует слабость только в паре чтение после записи, где работает буфер записи. Отсюда классика: поток пишет флаг, сосед его читает, но без барьера читатель видит новое значение флага со старыми данными. В ядре такие места закрыты парами smp_mb и примитивами READ_ONCE и WRITE_ONCE, в приложениях стандартными барьерами C11 с memory_order_acquire и memory_order_release, а полная последовательность требует mfence на x86. На ARM модель памяти слабая по умолчанию, и там барьеры dmb и dsb стоят за каждым smp_mb, что делает перенесённый с x86 код без явных барьеров источником трудноуловимых гонок. Пара передач флага с гарантиями выглядит стандартно:

atomic_store_explicit(&ready, 1, memory_order_release);
int v = atomic_load_explicit(&ready, memory_order_acquire);

Отдельно от порядка у процессора стоит порядок компилятора: переупорядочить доступы может и оптимизатор, и примитивы READ_ONCE и WRITE_ONCE в ядре существуют именно для того, чтобы запретить компилятору трогать горячие чтения и записи, которые программист посчитал по одному разу.

Атомарные операции через префикс LOCK или встроенные функции __atomic_cmpxchg несут в себе неявный барьер и цену когерентного трафика: атомик на строке, которую редактируют десять ядер, превращается в серию инвалидаций, и дешёвый на бумаге счётчик становится узким местом. Барьер полной последовательности на x86 стоит дороже остальных, потому что mfence обязан дождаться опустошения буфера записи, и на горячих путях его ставят только там, где без него не обойтись. Классическое решение, либо развести данные по разным строкам, либо свести атомики к минимуму через агрегацию в потоках, и выбор между ними опять же делает замер, а не интуиция.

Ложное разделение строки кэша

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

struct counters {
  alignas(64) unsigned long producer;
  alignas(64) unsigned long consumer;
};

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

perf c2c и охота на строки

Инструмент perf c2c собирает выборку обращений к памяти с данными о том, где строка была в момент обращения, и сводит статистику по физическим строкам:

perf c2c record -a -- sleep 10
perf c2c report --stdio

Отчёт возглавляет список строк с наибольшим числом HITM, обращений к изменённой строке в чужом кэше, то есть ровно тех обменов, которые мы ищем, и по верхним записям сразу видно масштаб: сотни HITM на строку в секунду между узлами означают работающий пинг-понг, десятки внутри одного сокета терпимый фон. Инструмент собирает данные по-разному на разных платформах: на Intel точная выборка PEBS приносит адреса и состояния строк, на AMD ту же работу делает IBS, на ARM статистический профилировщик SPE, и вывод отчёта при этом одинаковый, что и делает c2c универсальным рецептом. По каждой строке видно смещения полей с попаданиями, участвующие процессы и потоки и разрез по NUMA-узлам, и локальный конфликт внутри сокета отличим от дальнего между сокетами сразу. Дальше маршрут стандартный: взять смещение, найти поле в структуре источника, разнести по строкам и пересчитать. На Intel инструмент опирается на точную выборку PEBS, на AMD работает через IBS с тем же отчётом, на ARM через SPE, то есть рецепт переносится между платформами практически без изменений, и это редкий случай единого инструмента на весь зоопарк.

Дополняют картину обычные счётчики кэша:

perf stat -e LLC-load-misses,LLC-store-misses -a -- sleep 10

Они говорят, что проблема есть, а c2c говорит, где именно, и в этом разделении труда весь метод.

Собственные инструкции обслуживания кэша

Помимо пассивного наблюдения у программы есть активные рычаги на состояние кэша. Инструкция CLFLUSH выбрасывает строку, её свежее продолжение CLFLUSHOPT делает это эффективнее, а CLWB, выставленная в новых процессорах, выгружает строку в память, оставляя её валидной копией в кэше, что идеально для журналирования на энергонезависимых носителях, где данные должны дойти до памяти, но продолжат читаться из кэша. Нетемпоральные записи пропускают кэш целиком и годятся для больших потоковых данных, которые всё равно не вернутся к чтению, а префикс занятости строки prefetchw готовит строку к будущей записи, экономя лишний ход когерентного протокола.

Практические применения все с прицелом на обмен: кольцевые буферы, где производитель и потребитель обязаны не драться за строки заголовков, очереди событий с отдельными строками под хвост и голову, данные для устройств DMA, где явная выгрузка через CLWB надёжнее надежды на своевременный сброс. Интринсики короткие:

_mm_stream_si64((long long *)dst, val);
_mm_clflushopt(line);
_mm_clwb(line);
_mm_sfence();

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

Режимы слежки на уровне платформы

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

На ARM ту же работу делает фабрика CMN с собственным фильтром слежки, а протокол на линиях описан стандартом CHI, и события фильтра доступны в PMU меша, что позволяет считать когерентный трафик тем же perf. Для CXL-устройств с собственной когерентностью архитектура предусматривает обратную инвалидацию, когда устройство держит копии строк и должно принимать от хоста уведомления об изменениях, и это отдельный пласт настроек, где ошибки прошивки выливаются в тихое расхождение данных.

Практический порядок анализа когерентных проблем

Анализ когерентных проблем сводится к последовательности:

  1. снять раскладку кэшей и строк через lscpu и getconf, зафиксировав размер строки платформы;
  2. прогнать эталонную нагрузку под perf stat с событиями промахов последнего уровня и убедиться, что аномалия воспроизводится;
  3. собрать perf c2c на том же сценарии и выписать верхние строки по HITM с их смещениями;
  4. найти владельцев строк в исходниках и разнести делящиеся поля по строкам выравниванием;
  5. пересчитать и убедиться, что HITM ушли, а не переехали на соседние структуры;
  6. для межсокетных случаев проверить режим слежки в BIOS и топологию привязок потоков через numactl.

Шестой пункт про режим слежки не академический: на многопроцессорных узлах смена режима в прошивке нередко убирает остаточный HITM-фон, который кодом не лечится, и это дешевле любого переписывания. Финальная проверка после правок повторяет вторую и третью команды на том же сценарии, и исчезновение верхних строк из отчёта c2c служит подтверждением, что драка за строки закончилась, а не перекочевала в соседние поля.

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