Два независимых атомарных счётчика обновляются разными потоками. Почему их размещение в одной кэш-линии может резко снизить производительность?
Это проявление ложного совместного использования кэш-линии (false sharing). Потоки не конфликтуют с точки зрения модели памяти C++, но запись одного атомика инвалидирует копию всей кэш-линии у другого ядра, из-за чего второй поток вынужден повторно получать её.
Атомарность сохраняется, однако резко возрастает стоимость когерентности кэшей. Обычно проблему уменьшают разнесением часто изменяемых данных по разным кэш-линиям.
Многопроцессорные системы используют кэширование, чтобы каждое ядро могло быстро работать с данными. Когерентность поддерживается не для отдельных байтов, а для блоков фиксированного размера — кэш-линий, обычно нескольких десятков байт.
Поэтому независимые поля, случайно расположенные рядом, могут конкурировать за один блок кэша. Выравнивание и заполнение структуры применяют как оптимизационный подход для снижения такого противодействия между потоками.
Предположим, один поток часто увеличивает свой счётчик, а другой — другой счётчик. Если оба объекта находятся в одной кэш-линии, ядра постоянно передают эту линию друг другу в состоянии, допускающем запись.
Это особенно заметно для коротких циклов с атомарными операциями, где стоимость синхронизации кэшей становится сопоставимой или выше стоимости самой арифметики. Программа при этом может быть полностью корректной: отсутствуют конфликтующие обращения к одной переменной и data race не возникает.
Неверный вывод о том, что атомики «медленные сами по себе», приводит к неправильной оптимизации — например, к замене корректного атомика на небезопасную обычную переменную.
При записи ядро должно получить право изменять кэш-линию. Остальные ядра, хранящие копии этой линии, получают уведомление об инвалидировании. Если другое ядро почти одновременно пишет в соседний объект из той же линии, линия снова передаётся между ядрами.
Для устранения эффекта данные раздвигают минимум на размер аппаратной кэш-линии или используют подходящее аппаратное destructive-interference alignment, если оно доступно в конкретной реализации C++. Пример ниже использует 64 байта как распространённое, но не универсальное значение:
Такое выравнивание — не универсальная гарантия: размер кэш-линии зависит от платформы, а размещение объектов внутри более крупных структур и особенности аллокатора также имеют значение. В C++17 можно учитывать std::hardware_destructive_interference_size, если реализация предоставляет корректное значение.
Изменение порядка памяти с seq_cst на relaxed может убрать часть ограничений на переупорядочивание, но не устраняет необходимость поддерживать когерентность при атомарной записи. Для часто изменяемых счётчиков обычно выбирают подходящий слабый порядок памяти, а проблему ложного совместного использования решают именно размещением данных.
Разнесение полей увеличивает расход памяти и иногда ухудшает локальность доступа. Поэтому его следует применять к горячим данным после измерений, а не ко всем атомарным полям подряд.
В системе сбора метрик каждый рабочий поток обновлял собственный атомарный счётчик. Логически счётчики не пересекались, но производительность почти не росла при добавлении ядер. Профилирование показало интенсивный обмен кэш-линиями между ядрами.
Рассматривались три варианта:
Выбрали третий вариант, поскольку счётчики обновлялись часто, а их количество было ограниченным. После изменения размещения масштабирование улучшилось; итоговый эффект проверили отдельным бенчмарком на целевой архитектуре.
memory_order_relaxed ложное совместное использование?Нет. relaxed ослабляет требования к порядку видимости других операций, но атомарная запись всё равно должна быть согласована между ядрами. Изменение значения атомика приводит к обычной для записи необходимости обеспечить когерентность соответствующей кэш-линии.
Поэтому relaxed может уменьшить накладные расходы на барьеры и упорядочивание, но не решает проблему расположения независимых объектов в одной линии.
Нет. Оно возможно у любых данных, которые разные ядра часто изменяют, включая обычные поля, если доступ к ним сам по себе корректно синхронизирован или данные разделены по потокам.
Для атомиков проблема заметнее, потому что их часто используют именно в горячих счётчиках, флагах и очередях. Термин описывает не нарушение модели памяти, а неэффективный обмен кэш-линиями.
Не всегда. Нужно гарантировать, что часто изменяемые объекты не попадают в одну кэш-линию, включая их фактическое размещение внутри контейнеров, массивов и выделенной памяти.
Кроме того, значение размера линии должно соответствовать целевой платформе. Фиксированное alignas(64) часто практично, но является платформенным допущением; переносимое решение должно учитывать доступные константы реализации и подтверждаться измерениями.