Кеші лінії: справжній елемент обміну
Програмісти мислять у термінах змінних – окремих, іменованих частин даних, таких як лічильник цілого числа чи поле структури. Але рівень пам’яті не має жодного поняття про змінну; він працює з фіксованими блоками розміром 64 байти, що називаються кеш-лініями, на практично всіх сучасних процесорах x86 та ARM. Якщо програміст оголошує два лічильники цілих чисел, кожен розміром 4 байти, поруч у структурі або як сусідні глобальні змінні, компілятор і менеджер пам’яті, швидше за все, розмістить їх в одній кеш-лінії, оскільки ніщо про оголошення змінних не гарантує вирівнювання кеш-ліній. Це означає, що з точки зору протоколу узгодженості апаратного забезпечення, обидва незалежні лічильники насправді є одним блоком: запис до будь-якого з них вимагає того ж самого механізму інвалідності та передачі всього 64-байтного блоку, як і запис до обох одночасно. Невідповідність між логічним представленням програмістом, незалежними змінними та фактичною гранулярністю відстеження апаратним забезпечення, цілі кеш-лінії, є коренем хибного обміну, і це не видно з читання коду, оскільки ніщо в оголошеннях змінних не виглядає неправильно; проблема існує лише тим, як вони випадково розташовані в пам’яті на рівні адреси байта.
Як MESI стає проблемою продуктивності
Уявіть собі систему, де багато ядер працюють паралельно. Щоб забезпечити, що всі ядра бачать однакову версію даних, використовуються протоколи узгодження кешу, такі як MESI. Коли одне ядро записує дані в кеш-лінію, інші ядра повинні перевірити та, можливо, видалити ці дані з власного кешу. Це гарантує, що всі ядра мають доступ до актуальної інформації. Проблема виникає, коли цей механізм працює неправильно. Якщо ядра випадково обмінюються даними, які не пов'язані між собою (тобто так зване «хибне обміну»), кожен запис і оновлення в одній змінній змушує всі інші ядра перевіряти та видаляти дані з кешу. Це призводить до безліч зайвих операцій, що значно сповільнюють роботу системи. Уявіть, що одне ядро постійно записує одну і ту ж змінну, а інше ядро – іншу. Хоча між цими змінними немає жодного зв’язку, протокол узгодження кешу все одно змусить обидва ядра постійно перевіряти та видаляти дані з кешу. Це призводить до безлічі зайвих операцій, що значно сповільнюють роботу системи. Це особливо проблематично в інтенсивних паралельних програмах, де багато ядер працюють одночасно, оскільки навіть незначні помилки можуть мати великий вплив на продуктивність.
Виправлення: Заповнення та вирівнювання
Стандартне рішення для запобігання неточному обміну полями є простим, коли діагноз поставлений: переконайтеся, що змінні, написані різними потоками, розташовані на окремих лініях кешу, щоб кожне письмо від ядра не впливало на жодну іншу. У практичному сенсі це означає додавання явних байтів заповнення між полями, які різні потоки будуть писати незалежно, часто використовуючи специфічні для компілятора директиви вирівнювання, щоб змусити структуру або поле починатися з чистого 64-байтового кордону, або використовувати мовні функції, такі як std::hardware_destructive_interference_size C++17, яка явно розкриває розмір кешу платформи, щоб код міг заповнювати його порівняно. Це рішення коштує реальної пам’яті, заповнення 4-байтового лічильника до повної лінії 64 байт, що призводить до втрати 60 байтів на лічильник, тому заповнення застосовується вибірково, зазвичай лише до гарячих, часто суперечливих полів, таких як лічильники для кожного потоку, стан блокування обертання або покажчики голови та хвоста кільцевого буфера, а не узагальнено до кожної структури в коді. Деякі дизайни йдуть далі і свідомо групують дані, які завжди доступні разом, на одній лінії, техніку, яку часто підсумовують як структурування даних за шаблоном доступу замість логічного згрупування, оскільки дві поля, які завжди читаються та записуються одним потоком, отримують користь від спільного рядка, отримання обох в одному промазі кешу, тоді як поля, торкаються різних потоків, повинні бути розділені. Ця напруга — щільно упаковуйте пов’язані дані для одного потоку, але широко розсіюйте перехресні потоки, що суперечать, є постійною темою системної програмування з орієнтацією на продуктивність і по суті все, чого вчить неточний обмін.
NUMA: Когда даже кэш имеет местоположение
В одноядерных системах с унифицированной памятью все ядра могут получить доступ к любому адресу в оперативной памяти с примерно одинатной задержкой. Таким образом, после устранения ложных совпадений (false sharing) дальнейшая настройка в основном касается поведения кэша. Однако многоядерные серверы используют архитектуру Non-Uniform Memory Access (NUMA), где общий объем оперативной памяти физически разделен на банки, каждый из которых электрически соединен и принадлежит одному конкретному сокету процессора, называемому узлом NUMA. Ядро может быстро получать доступ к памяти своего локального узла, но для доступа к памяти другого узла необходимо пересечь межсоточную связь, такую как Intel UPI или AMD Infinity Fabric, что добавляет значительную задержку, иногда на 30-60% и более, а также потребляет пропускную способность общей межсоединительной сети, которой нуждается и другой трафик. Это означает, что стоимость любого доступа к памяти, а критически – стоимость трафика согласованности при соревновании ядер на разных сокетах за одну и ту же строку кэша, зависит от того, где в физической памяти фактически находится данный объект относительно ядра, которое его обращается. Операционные системы предоставляют политики распределения ресурсов, ориентированные на NUMA, такие как политика «первый-прикосновение», при которой страница физически размещается на узле, принадлежащем ядру, которое первым обращается к ней, а также явные API для фиксации памяти или потоков к определенным узлам, чтобы обеспечить оптимальную производительность критически важных приложений. Ложные совпадения согласованности между узлами NUMA являются особенно серьезным случаем: не только каждое письмо вызывает инвалидацию и передачу данных, но и инвалидация и передача данных должны пересечь более медленную межсоточную связь, усугубляя два отдельных источника нагрузки в одну значительно большую штрафную плату.
Діагностика цих проблем у реальному програмному забезпеченні
Через те, що як хибне обміну, так і погана локальність NUMA призводять до повністю правильних результатів і не залишають жодних очевидних слідів у коді джерела, їх зазвичай виявляють шляхом профілювання, а не перевірки коду. Контролери апаратного забезпечення продуктивності на сучасних процесорах можуть повідомляти про події конфліктів кешу-рядків та підрахунки доступу до пам’яті віддаленого місця безпосередньо, і інструменти, такі як Intel VTune, Linux perf c2c, які спеціально візуалізують конфлікти кешу-рядків між ядрами, та numastat для статистики локальності NUMA, будуються спеціально для виявлення цих невидимих витрат. Типовий симптом хибного обміну — це багатопотокова програма, яка погано масштабується або навіть негативно змінюється з додаванням більшої кількості потоків, незважаючи на те, що робоче навантаження здається бездоганним паралельним і без будь-якої реальної логічної залежності між даними потоків. Типовий симптом неправильного розміщення NUMA — це невідповідна продуктивність залежно від того, які саме ядра призначено потоку, оскільки потік випадково призначений на сокет далеко від своїх даних буде працювати повільніше, ніж той, що призначено поруч, навіть якщо код і дані однакові. Обидва ці проблеми мають спільний корінь — урок полягає в тому, що фізична структура та розміщення даних у пам’яті не є косметичною реалізацією, а є першим рівнем продуктивним змінною, яка знаходиться на одному рівні з алгоритмічною складністю, і досвідчені системні програмісти активно проектують навколо цього, а не виявляють випадково.
Часті запитання
Що таке хибне спільництво в одній реченні?
Хибне спільництво – це штраф продуктивності, який виникає, коли два потоки записують у логічно непов’язані змінні, які випадково ділять одну кеш-лінію, спричиняючи непотрібні операції узгодження кешу між ядрами. Це не призводить до неправильних результатів, лише уповільнює виконання.
Який розмір типової кеш-лінії та чому цей розмір має значення?
Більшість сучасних процесорів x86 і ARM використовують 64-байтні кеш-лінії, тобто будь-яка дія узгодження впливає на весь цей блок, вирівняний на 64 байти, незалежно від того, яку частину з нього фактично торкається даний доступ. Дві 4-баイトві змінні, розміщені в одній 64-байтній блоці, з точки зору апаратного забезпечення, є одним об’єктом спільного використання.
Як виправити хибне спільництво?
Стандартним рішенням є додавання заповнювачів або вирівнювання конфліктуючих змінних так, щоб кожна з них потрапляла на окрему кеш-лінію, часто використовуючи атрибути вирівнювання компілятора або портабльову константу, як std::hardware_destructive_interference_size в C++17. Це обмінює трохи додаткового використання пам’яті на усунення непотрібного трафіку узгодження між ядрами.
Що таке NUMA та чому це має значення тут?
NUMA означає Неоднорідний доступ до пам'яті, що описує багатопроцесорні системи з різним часом доступу до локальних пам’ятних банків кожного процесора порівняно з пам’яттю, приєднаною до іншого сокета. Коли хибне спільництво відбувається між ядрами на різних вузлах NUMA, трафік узгодження також повинен проходити через повільний міжсокетовий інтерконнект, що посилює штраф.
Як можна виявити хибне спільництво в реальній програмі?
Оскільки воно не залишає слідів у коді джерела, хибне спільництво зазвичай виявляється шляхом профілювання, використовуючи інструменти, такі як Linux's perf c2c, які візуалізують конфлікти на рівні кеш-ліній між ядрами, або Intel VTune's аналіз доступу до пам’яті. Типовий симптом – паралельна програма, яка погано масштабується або навіть сповільнюється при додаванні більшої кількості потоків, незважаючи на відсутність будь-яких реальних логічних залежностей даних.
Спробуйте наживо
Усе, що вище, працює прямо у вашому браузері — відкрийте NUMA Memory Access & False Sharing і змінюйте параметри під час роботи. Нічого не встановлюється, нічого не завантажується на сервер, уся модель живе в одній вкладці.
▶ Відкрити симуляцію NUMA Memory Access & False Sharing