Загрузка всей строки кэша сразу, чтобы избежать конкуренции за несколько ее элементов

Предполагая, что есть три части данных, которые мне нужны из строки кэша с высокой конкуренцией, есть ли способ загрузить все три вещи «атомарно», чтобы избежать более одного обращения к любому другому ядру?

На самом деле я не использую нужно как гарантию правильности атомарности для моментального снимка всех трех элементов, просто в обычном случае, когда все три элемента считываются за один и тот же тактовый цикл. Я хочу избежать случая, когда строка кэша поступает, но затем приходит запрос на недействительность до того, как все 3 объекта будут прочитаны. Это приведет к тому, что третьему доступу потребуется отправить еще один запрос на совместное использование линии, что еще больше усугубит конкуренцию.

Например,

class alignas(std::hardware_destructive_interference_size) Something {
    std::atomic<uint64_t> one;
    std::uint64_t two;
    std::uint64_t three;
};

void bar(std::uint64_t, std::uint64_t, std::uint64_t);

void f1(Something& something) {
    auto one = something.one.load(std::memory_order_relaxed);
    auto two = something.two;
    if (one == 0) {
        bar(one, two, something.three);
    } else {
        bar(one, two, 0);
    }

}

void f2(Something& something) {
    while (true) {
        baz(something.a.exchange(...));
    }
}

Могу ли я каким-то образом гарантировать, что one, two и three загружаются вместе без нескольких RFO в условиях жесткой конкуренции (предположим, что f1 и f2 работают одновременно)?

Целевой архитектурой/платформой для целей этого вопроса является Intel x86 Broadwell, но если есть метод или встроенный компилятор, который позволяет делать что-то вроде этого с максимальной переносимостью, это также было бы здорово.

Простите, если я неправильно понимаю, но разве одна строка кэша не всегда загружается атомарно? Вы хотите загружать строки кэша множественный, смежный для монопольного доступа атомарно?

alter_igel 30.05.2019 23:30

@alterigel Извините. В этом контексте под атомарным я подразумевал что-то, что обслуживается за одну поездку туда и обратно/RFO. Я обновлю вопрос, чтобы уточнить

Curious 30.05.2019 23:33

@alterigel обновлен, спасибо

Curious 31.05.2019 00:20

@ Любопытно, вы проверили спецификации x86? насколько я знаю, есть некоторые инструкции процессора для предварительной выборки памяти в кеш, прежде чем вы захотите ее использовать.

Raxvan 31.05.2019 00:36

@Raxvan, я с ними не знаком. Ты случайно не про __builtin_prefetch?

Curious 31.05.2019 00:37

@Raxvan: Предварительная выборка программного обеспечения, вероятно, здесь не полезна. Строка является высококонкурентной, поэтому она не будет просто сидеть в нашем кеше L1d, пока ее не прочитают фактические нагрузки. Если есть какое-то время после поступления предварительной выборки до загрузки, RFO от другого ядра, вероятно, сделает ее недействительной. Если вы выполняете предварительную выборку пары инструкций перед обычной загрузкой, это также бесполезно (если только это не предварительная выборка записи с prefetchw); загрузка по требованию также запрашивает всю строку кэша.

Peter Cordes 31.05.2019 02:56

SW PF может помочь, если вы можете выполнить предварительную выборку маленький перед чтением, чтобы загрузка (и) по запросу была load_hit_pre.sw_pf (событие производительности) и скрывала немного межъядерной задержки. Но настроить это сложно и зависит от того, какие другие остановки обычно происходят между PF и фактической нагрузкой; чем раньше вы это сделаете, тем больше вероятность того, что это «слишком рано»; намного хуже, чем обычная проблема с настройкой SW PF, где есть окно приличного размера между «достаточно рано» (чтобы получить попадания L1d) и «слишком рано» (выселено перед использованием из-за конфликтов кеша, а не инвалидации). Вот это склон с обрывом.

Peter Cordes 31.05.2019 03:08

Но в любом случае, SW PF выполняет ничего, чтобы помочь выполнить все 3 загрузки одновременно; это одинаково полезно независимо от того, используете ли вы идею векторной нагрузки Хади или нет.

Peter Cordes 31.05.2019 03:10
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
5
8
623
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Пока размер std::atomic<uint64_t> не превышает 16 байт (что имеет место во всех основных компиляторах), общий размер one, two и three не превышает 32 байта. Следовательно, вы можете определить объединение __m256i и Something, где поле Something выровнено по 32 байтам, чтобы гарантировать, что оно полностью содержится в одной 64-байтовой строке кэша. Чтобы загрузить все три значения одновременно, вы можете использовать одну 32-байтовую операцию загрузки AVX. Соответствующая встроенная функция компилятора — _mm256_load_si256, которая заставляет компилятор выдавать инструкцию VMOVDQA ymm1, m256. Эта инструкция поддерживается декодированием с одной загрузкой uop на Intel Haswell и более поздних версиях.

Выравнивание по 32 байта на самом деле необходимо только для того, чтобы гарантировать, что все поля содержатся в 64-байтовой строке кэша. Однако _mm256_load_si256 требует, чтобы указанный адрес памяти был выровнен по 32 байтам. В качестве альтернативы можно использовать _mm256_loadu_si256, если адрес не выровнен по 32 байтам.

Ах интересно. Здесь важно отметить, что первая переменная должна быть атомарной. Является ли встроенный компилятор и соответствующая инструкция атомарной и налагает ли он хотя бы порядок получения памяти?

Curious 31.05.2019 01:36

@Curious x86 ISA гарантирует атомарность только для 8-байтовых обращений, которые не пересекают строку кэша (см.: Атомарность на x86). Хотя в руководстве не говорится, что выровненный и кэшируемый VMOVDQA ymm1, m256 является атомарным, он является атомарным в Haswell и более поздних версиях на практике. Однако сложность здесь в том, что семантика std::atomic<uint64_t> не сохраняется при доступе к нему через поле __m256i.

Hadi Brais 31.05.2019 02:06

@Curious и Hadi: см. также Поэлементная атомарность векторной загрузки/хранения и сбора/разброса? - на практике мы знаем, что это нормально, но в руководствах по x86 нет письменного гарантия, что более широкие векторные нагрузки никогда не будут разрываться внутри фрагмента с выравниванием по 8 байтам.

Peter Cordes 31.05.2019 02:36

@Hadi: вы определенно хотите выравнивания; Процессоры AMD могут вводить разрывы через границы, меньшие, чем полная строка кэша. (По крайней мере, K8/K10 может, вероятно, все еще семейство Bulldozer) Конечно, Bulldozer/Ryzen в любом случае будут декодировать 32-байтную загрузку как две 16-битные половины, но, надеюсь, они обе могут выполняться в одном и том же тактовом цикле, когда поступает строка кэша. , и избегайте повторного запроса, если в следующем цикле будет получено недействительное значение.

Peter Cordes 31.05.2019 02:38

Не могли бы вы объяснить сложность немного больше? Я не думаю, что понимаю.. Также спасибо @PeterCordes

Curious 31.05.2019 03:38

@Curious: std::atomic имеет специальные правила, которые четко определяют чтение, пока другой поток пишет его. например на практике нельзя предположить, что загрузка одного и того же указателя дважды даст одно и то же значение. _mm_load_si256 похож на обычное разыменование, и делает ли это нет. На практике разыменование указателя volatile __m256i* должен быть эквивалентен memory_order_relaxed нагрузке на x86. Для более сильного заказа вам также понадобится atomic_thread_fence. (Это может быть деталь реализации, как заборы влияют на изменчивые, а не только std::atomic, объекты.)

Peter Cordes 31.05.2019 03:43

Спасибо за ответ! Если бы я мог принять два ответа, я бы это сделал.

Curious 31.05.2019 06:45
Ответ принят как подходящий

терминология: нагрузка не будет генерировать RFO, для нее не требуется владение. Он только отправляет запрос на доля данные. Несколько ядер могут считывать данные с одного и того же физического адреса параллельно, при этом у каждого из них есть его горячая копия в кэше L1d.

Однако другие ядра, записывающие строку, будут отправлять RFO, которые делают недействительной общую копию в нашем кеше, и да, они могут прийти после чтения одного или двух элементов строки кеша до того, как все будут прочитаны. (Я обновил ваш вопрос описанием проблемы в этих терминах.)


SIMD-загрузка Hadi — хорошая идея для захвата всех данных с помощью одной инструкции.

Насколько нам известно, _mm_load_si128() на практике является атомарным для своих 8-байтовых фрагментов, поэтому он может безопасно заменить .load(mo_relaxed) атомарного. Но см. Поэлементная атомарность векторной загрузки/хранения и сбора/разброса? - на это нет четкой письменной гарантии.

Если вы использовали _mm256_loadu_si256(), остерегайтесь настройки GCC по умолчанию -mavx256-split-unaligned-load: Почему gcc не разрешает _mm256_loadu_pd как одиночный vmovupd? Так что это еще одна веская причина использовать выровненную загрузку, помимо необходимости избегать разделения строк кэша.

Но мы пишем на C, а не на ассемблерном языке, поэтому нам нужно позаботиться о некоторых других вещах, которые делает std::atomic с mo_relaxed: в частности, повторные загрузки с одного и того же адреса могут не дать одинакового значения. Вероятно, вам нужно разыменовать volatile __m256i* для имитации того, что load(mo_relaxed).

Вы можете использовать atomic_thread_fence(), если хотите более строгого порядка; Я думаю, что на практике компиляторы С++ 11, поддерживающие встроенные функции Intel, будут заказывать изменчивые разыменования относительно. заборы так же, как std::atomic загружает / хранит. В ISO C++ объекты volatile по-прежнему подвержены гонке данных UB, но в реальных реализациях, которые могут, например, компилировать ядро ​​​​Linux, volatile можно использовать для многопоточности. (Linux использует собственные атомарные вычисления с помощью volatile и встроенного ассемблера, и я думаю, что это считается поддерживаемым поведением gcc/clang.) Учитывая, что на самом деле делает volatile (объект в памяти соответствует абстрактной машине C++), он просто автоматически работает, несмотря ни на что. rules-lawyer опасается, что технически это UB. Это UB, о котором компиляторы не могут знать или заботиться, потому что в этом весь смысл volatile.

На практике есть все основания полагать, что все выровненные 32-байтовые загрузки/сохранения в Haswell и более поздних версиях являются атомарными. Разумеется, для чтения из L1d в неупорядоченный бэкэнд, но и даже для передачи строк кэша между ядрами. (например, многосокетный K10 может разрываться на 8-байтовых границах с помощью HyperTransport, так что это действительно отдельная проблема). Единственная проблема, связанная с ее использованием, - это отсутствие какой-либо письменной гарантии или одобренного поставщиком ЦП способа использования обнаружить этой «функции».


Кроме этого, для переносимого кода это может помочь поднять auto three = something.three; из ветки; неправильное предсказание ветвления дает ядру гораздо больше времени, чтобы аннулировать строку перед третьей загрузкой.

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

    bar(one, two, one == 0 ? something.three : 0);

Broadwell может выполнять 2 загрузки за такт (как и все основные x86, начиная с Sandybridge и K8); моп обычно выполняются в порядке старейших-готовых-первым, поэтому вполне вероятно (если эта загрузка должна была ждать данных от другого ядра), что наш 2 загрузочные моп будут выполняться в первом возможном цикле после прибытия данных.

Мы надеемся, что 3-я загрузочная операция будет запущена в цикле после этого, оставляя очень маленькое окно для недействительности, которая вызовет проблему.

Или на процессорах с загрузкой только 1 за такт, при этом все 3 загрузки соседствуют в сборке, что уменьшает окно для аннулирования.

Но если one == 0 встречается редко, то three часто вообще не нужен, поэтому безусловная загрузка несет в себе риск ненужных запросов на него. Таким образом, вы должны учитывать этот компромисс при настройке, если вы не можете охватить все данные за одну SIMD-загрузку.


Как обсуждалось в комментариях, программная предварительная выборка потенциально может помочь скрыть некоторую задержку между ядрами.

Но вы должны выполнять предварительную выборку намного позже, чем для обычного массива, поэтому поиск мест в вашем коде, которые часто выполняются от ~ 50 до ~ 100 циклов до вызова f1(), является сложной проблемой и может «заразить» много другого кода с помощью детали, не связанные с его нормальной работой. И вам нужен указатель на правильную строку кэша.

Вам нужно, чтобы PF опаздывал достаточно, чтобы загрузка по требованию происходила через несколько (десятков) циклов до, когда предварительно выбранные данные действительно поступают. Это противоположно обычному варианту использования, где L1d является буфером для предварительной выборки и хранения данных из завершенных предварительных выборок до того, как к ним попадет нагрузка по требованию. Но вы хочуload_hit_pre.sw_pf perf-события (предварительная выборка загрузки), потому что это означает, что загрузка по запросу произошла, когда данные все еще находились в полете, до того, как есть шанс, что они будут признаны недействительными.

Это означает, что настройка еще более ломка и сложна, чем обычно, потому что вместо почти плоской точки наибольшего удовольствия для расстояния предварительной выборки, где раньше или позже не повредит, раньше скрывает большую задержку вплоть до того момента, когда она допускает недействительность, так что это спускаться до самого обрыва. (И любые слишком ранние предварительные выборки только усугубляют общую конкуренцию.)

Спасибо за подробный ответ! Всегда интересно читать ваши ответы и комментарии :) У меня есть несколько дополнений. Первый касается этой части — Конечно для чтения из L1d в неупорядоченный бэкенд Что такое неисправный бэкенд?

Curious 31.05.2019 06:08
Broadwell может запускать 2 нагрузки за такт. Почему только 2? Не могли бы вы указать, где это указано? Там может быть более интересная информация... Я предполагаю, что нет никакого способа загрузить все содержимое интенсивно оспариваемой кэш-линии за один цикл?
Curious 31.05.2019 06:08
Кроме этого, для переносимого кода может помочь поднять auto three = something.three; вне отделения; Компилятор обычно сам не реализует такие оптимизации?
Curious 31.05.2019 06:09

@ Любопытно: компиляторы буду выполняют if-преобразование в безветвящиеся, если решат, что оно того стоит. Здесь это допустимо, потому что указатель на class не может указывать на частичный объект в конце страницы (за которым следует несопоставленная страница). И потому, что x86 asm не заботится об одновременном чтении, если другой поток пишет. Но обратите внимание, что абстрактная машина C++ не вообще считывает three, если one не равно нулю. Это преобразование на уровне источник может вызвать гонку данных UB, если !one означает, что его может писать другой поток.

Peter Cordes 31.05.2019 06:24

В любом случае, обычно компиляторы помещают условную работу внутри условных ветвей. Если code-gen является разветвленным, мы можем захотеть обратного из-за этого особого обстоятельства, так что нет, это не то, что вы когда-либо ожидали от компилятора. Ложное совместное использование и высокая конкуренция — это очень и очень плохо, поэтому компиляторы оптимизируют для нормального случая, когда нет необходимости очень плотно группировать нагрузки (и в этом есть кое-что, что можно получить: вообще не выполнять нагрузку в другой ветке).

Peter Cordes 31.05.2019 06:26
Но обратите внимание, что абстрактная машина C++ вообще не читает три, если один не равен нулю. Это преобразование на исходном уровне может вызвать гонку данных UB, если !one означает, что его может писать другой поток. Ах, в этом есть смысл, неразумно ли ожидать, что компиляторы проверят другой код и увидят, что этого на самом деле не происходит?
Curious 31.05.2019 06:28

@ Любопытно: неупорядоченный бэкэнд = механизм, который выполняет неупорядоченное выполнение в ядре ЦП. Интерфейсная часть извлекает/декодирует инструкции для передачи на серверную часть. Я говорил там о получении данных загрузки в физические регистры (и в сеть обхода пересылки для инструкций, использующих результат).

Peter Cordes 31.05.2019 06:28

Бэкэнд не заботится о ветках? Я не думаю, что я полностью глуп :(

Curious 31.05.2019 06:29

@ Любопытно: какой «другой код»? Вы пишете функцию, которая принимает указатель. Он может указывать на что угодно, и любое количество других неизвестных потоков также может иметь указатель на него и выполнять неизвестный код. В любом случае, как я уже сказал, правило «как если» по-прежнему допускает преобразование (поскольку x86 asm не имеет UB гонки данных), но компиляторы могут быть устойчивы к нему. И нет, он не будет анализировать всю программу, чтобы узнать больше об указателях, переданных вашим функциям потока, и о том, что еще может иметь ссылки на них.

Peter Cordes 31.05.2019 06:32
Бэкэнд не заботится о ветках? Я думаю, вы совершенно не понимаете, о чем я говорил. Упрощу: "Для чтения из L1d в регистры". Но в любом случае, внешний интерфейс должен использовать предсказание ветвлений, чтобы следовать за ветвями и передавать (вероятно) правильную последовательность инструкций (декодированных в uops) на серверную часть. Когда серверная часть выполняет условную или непрямую ветвь, это просто означает проверку прогноза и, возможно, необходимость отката и указание интерфейсной части передать ему фактический правильный путь. Это то, что может заставить three загрузиться на много циклов позже.
Peter Cordes 31.05.2019 06:36

Исходя из этого описания, я предположил, что вы объясняете, как загрузка до трех может происходить одновременно с двумя другими. Не могли бы вы привести пример того, как это приведет к тому, что загрузка для трех произойдет намного позже? Если интерфейс предсказывает, что ветвь, ведущая к загрузке, фактически не выполняется?

Curious 31.05.2019 06:38

@Curious: Broadwell могу загружает всю строку кэша за 1 цикл, используя две загрузки по 32 байта. У вас нет гарантии, что обе загрузки действительно выполняются в одном и том же цикле, но это вероятно, если они обе застряли в ожидании одной и той же строки кэша. Вы можете прочесть что угодно о семействе микроархитектур Sandybridge, чтобы узнать, что оно имеет загрузочные исполнительные блоки на 2 порта, по сравнению с 1 в Nehalem. например realworldtech.com/haswell-процессор — это глубокое погружение в Haswell (в основном то же самое, что и Broadwell), а на последней странице есть полная блок-схема.

Peter Cordes 31.05.2019 06:39

Но также таблицы инструкций Агнера Фога, uops.info, instlatx64.atw.hu и собственные документы Intel (руководство по оптимизации и даже полуполезные таблицы задержки/пропускной способности в их встроенных средствах поиска) говорят вам, что нагрузки имеют пропускную способность 2 в такт на семействе Sandybridge. . Таким образом, в основном любая информация о производительности будет включать это. Также en.wikichip.org/wiki/intel/microarchitectures/….

Peter Cordes 31.05.2019 06:42

@ Любопытно: в своем ответе я уже приводил пример того, как загрузка three может быть отложена: если это происходит после условного перехода, который неверно прогнозирует, он выполняется позже, по крайней мере, на дюжину циклов, ветвь неправильно прогнозирует штраф. Неверный прогноз не может быть обнаружен до тех пор, пока не завершится загрузка one.

Peter Cordes 31.05.2019 06:44

Спасибо за обсуждение! Кажется, у меня есть ответ :)

Curious 31.05.2019 06:44

Другие вопросы по теме