История
Современные ЦП используют протоколы согласованности кэша, такие как MESI, для синхронизации данных между приватными кэшами L1 разных ядер. Когда независимые потоки записывают данные в разные области памяти, которые случайно находятся на одной кэш-линии (обычно 64 или 128 байт), аппаратное обеспечение сериализует эти операции, постоянно аннулируя и передавая право собственности на эту линию, что называется ложным совместным доступом. C++17 представил std::hardware_destructive_interference_size, чтобы показать ширину кэш-линии архитектуры, позволяя разработчикам разделять изменяемые данные, так что «горячие» переменные каждого потока занимают отдельные линии и избегают этих накладных расходов на синхронизацию.
Проблема
Применение alignas(std::hardware_destructive_interference_size) к переменной с автоматическим временем хранения гарантирует, что начальный адрес объекта является кратным размеру кэш-линии внутри конкретной стековой рамки своего потока. Однако это выравнивание локально для представления потока в памяти и не гарантирует исключительное владение физической кэш-линией. Если объект меньше кэш-линии, смежные переменные на одном стеке — или переменные на стеках различных потоков, которые оказываются выделенными по физическим адресам, отличающимся на кратные размеры линии — могут соответствовать одной физической кэш-линии. Следовательно, аппаратное обеспечение все равно будет испытывать трафик согласованности, когда другой поток записывает в другую переменную на той же линии, что делает спецификацию alignas недостаточной для изоляции.
Решение
Чтобы гарантировать избегание ложного совместного доступа, данные должны быть дополнены так, чтобы занимать всю кэш-линию, обеспечивая, что никакие другие данные не разделяют физическое хранилище, независимо от компоновки адресов в момент выполнения. Это достигается путем определения структуры, которая как выровнена, так и по размеру соответствует std::hardware_destructive_interference_size.
#include <new> #include <cstddef> #include <atomic> struct alignas(std::hardware_destructive_interference_size) PaddedCounter { std::atomic<int> value; // Дополнительные байты заполняют остаток кэш-линии для предотвращения совместного доступа char padding[std::hardware_destructive_interference_size - sizeof(std::atomic<int>)]; }; // Массив гарантирует, что каждый элемент находится на отдельной кэш-линии PaddedCounter thread_counters[8];
Описание проблемы
Процессор рыночных данных с низкой задержкой использовал восемь рабочих потоков, каждый из которых вел свой собственный счетчик тиков в глобальном массиве std::atomic<int> stats[8]. Каждый поток эксклюзивно увеличивал свой собственный индекс без блокировок, но профилирование показало, что пропускная способность стабилизировалась на доле теоретического максимума, при этом счетчики ЦП показывали чрезмерные циклы согласованности кэша, а не вычисления в пользовательском режиме. Расследование подтвердило, что атомарные целые числа, несмотря на свою логическую независимость, были упакованы непрерывно в одну 64-байтную кэш-линию, что вызвало разрушительное вмешательство между ядрами.
Решение 1: Локальные выровненные переменные
Команда изначально попыталась объявить alignas(64) std::atomic<int> local_stat внутри функции исполнения каждого потока, передавая указатели в поток мониторинга. Этот подход требовал минимальной переработки и избегал глобального состояния. Однако он оказался ненадежным, потому что компилятор мог разместить другие автоматические переменные рядом с local_stat в одной и той же кэш-линии, а выделения стеков разных потоков могли быть разделены точными кратными 64 байтам, что привело к тому, что выровненные переменные стали аналогами одной и той же физической линии и тем самым продолжили ложный совместный доступ.
Решение 2: Выделение памяти с использованием необработанных указателей
Еще один рассматриваемый подход выделял каждый счетчик через new std::atomic<int> в надежде, что выделитель кучи разнесет выделения по удаленным адресам памяти. Хотя это иногда уменьшало напряженность, это вводило недетерминированную производительность, потому что небольшие выделения зачастую обслуживались из непрерывных слоев, и метаданные выделителя могли разместить разные объекты в одной и той же кэш-линии. Более того, это требовало ручного управления памятью и не обеспечивало гарантий выравнивания или дополнения на этапе компиляции.
Выбранное решение и результат
Финальная реализация приняла структуру PaddedCounter, определенную выше, храня экземпляры в статическом массиве. Это решение было выбрано, поскольку оно детерминированно обеспечивало разделение по кэш-линиям через дополнение и выравнивание на этапе компиляции, устраняя аппаратное столкновение независимо от компоновки памяти в момент выполнения. Потребление памяти увеличилось с 32 байт до 512 байт, что было приемлемо для прироста производительности. Результатом стала двенадцатикратная увеличения пропускной способности и снижение изменчивости латентности, что соответствовало требованиям обработки менее чем за микросекунду.
Почему применение alignas(std::hardware_destructive_interference_size) к маленькому объекту не предотвращает ложный совместный доступ с другими данными в одном потоке?
alignas контролирует только выравнивание начального адреса объекта, но не его размер. Если объект меньше кэш-линии (например, 4-байтное целое на 64-байтной линии), оставшиеся байты этой кэш-линии могут содержать другие переменные. Когда компилятор размещает другую переменную на этой же линии или когда переменная другого потока соответствует той же физической линии, возникает ложный совместный доступ. Истинная изоляция требует, чтобы объект занимал всю линию с помощью дополнения, а не просто чтобы он был выровнен по старту.
В чем различие между std::hardware_destructive_interference_size и std::hardware_constructive_interference_size, и когда группировка данных в пределах последней улучшает производительность?
std::hardware_destructive_interference_size — это минимальное разделение, необходимое для предотвращения ложного совместного доступа, в то время как std::hardware_constructive_interference_size — это максимальный размер данных, который выигрывает от пространственной локальности на одной и той же кэш-линии. Группировка связанных полей с частым доступом (например, координаты x, y, z точки) в структурах, которые помещаются в конструктивный размер, гарантирует, что они находятся на одной линии, максимизируя коэффициенты попаданий в кэш и эффективность предвыборки, в то время как разрушительный размер используется для разделения не связанных изменяемых данных.
Как ложный совместный доступ влияет на операции std::atomic с использованием memory_order_relaxed, и почему расслабленный порядок памяти не решает снижение производительности?
Даже с memory_order_relaxed, который не накладывает никаких ограничений на окружающие операции с памятью, атомарная запись все равно требует от ядра ЦП получения исключительного владения кэш-линией (цикл Чтение-Для-Владения). Если другой поток недавно изменил другую переменную на той же линии, протокол согласованности кэша заставляет линию «прыгать» между ядрами. Эта синхронизация на аппаратном уровне происходит независимо от логических гарантий модели памяти C++, что означает, что ложный совместный доступ несет полную латентность промаха кэша, независимо от указанного порядка памяти.